Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/extract-release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ extract_release_notes() {
{ print }
')"

# Replace standalone occurrences of "PR" with "Release" (word boundaries only,
# so words like "PROXY" or "PRs" inside other tokens are untouched)
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]])PR([^[:alnum:]]|$)/\1Release\2/g')"
Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '1,90p' scripts/extract-release-notes.sh | cat -n

echo
echo "== sed boundary behavior probe =="
python3 - <<'PY'
import subprocess

pattern = r'<(^|[^\[:alnum:]])PR([^\[:alnum:]]|$)>\1Release\2/g'
# Try sed with equivalent raw POSIX pattern that matches alphanumeric boundary.
inputs = ["PR", "PR_STATUS", "xPRy", "PRx", "xPR", "PROXY", "PRs", "Release PR STATUS", "PR x"]
for p in [
    "s/(^|[^[:alnum:]])PR([^[:alnum:]]|$)/\\1Release\\2/g",
    "s/(^|[^[:alnum:]_])PR([^[:alnum:]_]|$)/\\1Release\\2/g",
]:
    print(f"-- pattern: {p}")
    for s in inputs:
        out = subprocess.check_output(["sed", "-E", p], text=True, input=f"{s}\n").rstrip()
        print(f"{s!r} -> {out!r}")
PY

Repository: Resgrid/IC

Length of output: 4668


Treat underscores as part of the token boundary.

[:alnum:] does not include _, so inputs like PR_STATUS or Release PR STATUS are incorrectly rewritten to Release_STATUS / Release Release STATUS. Include the underscore in both surrounding boundary classes.

Suggested fix
-  cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]])PR([^[:alnum:]]|$)/\1Release\2/g')"
+  cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]_])PR([^[:alnum:]_]|$)/\1Release\2/g')"

Add regression coverage for PR, PRs, PROXY, and PR_STATUS.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Replace standalone occurrences of "PR" with "Release" (word boundaries only,
# so words like "PROXY" or "PRs" inside other tokens are untouched)
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]])PR([^[:alnum:]]|$)/\1Release\2/g')"
# Replace standalone occurrences of "PR" with "Release" (word boundaries only,
# so words like "PROXY" or "PRs" inside other tokens are untouched)
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed -E 's/(^|[^[:alnum:]_])PR([^[:alnum:]_]|$)/\1Release\2/g')"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/extract-release-notes.sh` around lines 38 - 40, Update the sed
expression in the cleaned_body transformation to include underscore as a
non-boundary character alongside alphanumerics, preventing replacements within
tokens such as PR_STATUS while preserving standalone PR replacement. Add
regression coverage for PR, PRs, PROXY, and PR_STATUS.


# Fourth pass: Remove any remaining HTML comment lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^<!--.*-->$/d' | sed '/^<!--/d' | sed '/^-->$/d')"

Expand Down
8 changes: 8 additions & 0 deletions src/api/incidentCommand/incidentCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import type {
ReopenCommandInput,
ResourceAssignment,
ResourceAssignmentResult,
SendMessageToCommandInput,
SendMessageToCommandResult,
SetNeedStatusInput,
TacticalObjective,
TacticalObjectiveResult,
Expand Down Expand Up @@ -61,6 +63,12 @@ export const transferCommand = async (input: TransferCommandInput) => {
return response.data;
};

/** Sends a free-form message directly to the incident's commander (and optionally deputies) via their notification channels. */
export const sendMessageToCommand = async (input: SendMessageToCommandInput) => {
Comment on lines +66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing return type documentation violates Rule [23] for async functions. Update the JSDoc to include @returns {Promise<SendMessageToCommandResult>} and document possible rejection scenarios.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/incidentCommand/incidentCommand.ts:

Line 66 to 67:

Missing return type documentation violates Rule [23] for async functions. Update the JSDoc to include `@returns {Promise<SendMessageToCommandResult>}` and document possible rejection scenarios.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const response = await createApiEndpoint('/IncidentCommand/SendMessageToCommand').post<SendMessageToCommandResult>({ ...input });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Missing error handling violates Rule [28] by exposing the network POST call without contextual wrapping. Enclose the external request in a try/catch block, log contextual data such as endpoint names and input identifiers, and map the result to an application-level domain error.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/incidentCommand/incidentCommand.ts:

Line 68:

Missing error handling violates Rule [28] by exposing the network POST call without contextual wrapping. Enclose the external request in a try/catch block, log contextual data such as endpoint names and input identifiers, and map the result to an application-level domain error.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return response.data;
};

export const closeCommand = async (incidentCommandId: string) => {
const response = await createApiEndpoint(`/IncidentCommand/CloseCommand/${encodeURIComponent(incidentCommandId)}`).put<IncidentCommandResult>({});
return response.data;
Expand Down
117 changes: 113 additions & 4 deletions src/app/(app)/__tests__/command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jest.mock('lucide-react-native', () => {
MapPin: icon('map-pin'),
Mic: icon('mic'),
MicOff: icon('mic-off'),
Eye: icon('eye'),
Pencil: icon('pencil'),
Phone: icon('phone'),
PhoneOff: icon('phone-off'),
RadioTower: icon('radio-tower'),
Expand Down Expand Up @@ -87,6 +89,45 @@ jest.mock('@/components/command/incident-map-card', () => ({
IncidentMapCard: () => null,
}));

// The resource details sheet pulls in the Mapping API — stub it with a plain action surface.
jest.mock('@/components/command/resource-details-sheet', () => {
const React = require('react');
return {
ResourceDetailsSheet: (props: any) =>
props.isOpen
? React.createElement('View', { testID: 'resource-details-sheet' }, [
React.createElement('Text', { key: 'label', testID: 'resource-details-action-label' }, props.actionLabel),
React.createElement(
'Text',
{
key: 'action',
testID: props.actionTestID,
onPress: () => {
props.onAction();
props.onClose();
},
},
props.actionLabel
),
props.secondaryActionLabel
? React.createElement(
'Text',
{
key: 'secondary',
testID: props.secondaryActionTestID,
onPress: () => {
props.onSecondaryAction();
props.onClose();
},
},
props.secondaryActionLabel
)
: null,
])
: null,
};
});

// Call resource viewers pull in webview / keyboard-controller natives — stub them out here.
jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({
VideoFeedTabContent: () => null,
Expand Down Expand Up @@ -282,7 +323,7 @@ describe('CommandBoard', () => {
unmount();
});

it('lists tracked department units and personnel as resources with their lane and releases on remove', () => {
it('lists tracked department units and personnel as resources with their lane and releases via the details sheet', () => {
const board = serverBoard('101', {
Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }],
Assignments: [
Expand All @@ -300,16 +341,84 @@ describe('CommandBoard', () => {
users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }],
});

const { getByTestId, getByText, unmount } = render(<CommandBoard />);
const { getByTestId, getAllByText, getByText, unmount } = render(<CommandBoard />);

expect(getByTestId('resource-dept-as-1')).toBeTruthy();
expect(getByTestId('resource-dept-as-2')).toBeTruthy();
expect(getByText('Sam Jones')).toBeTruthy();
expect(getByText('Station 1')).toBeTruthy();
expect(getByTestId('resource-dept-as-2-status')).toBeTruthy();
expect(getByText('command.unassigned')).toBeTruthy();
// Lane badge text ("Unassigned") also appears on the filter chip — assert at least the badge exists
expect(getAllByText('command.unassigned').length).toBeGreaterThan(0);

fireEvent.press(getByTestId('resource-dept-view-as-2'));
expect(getByTestId('resource-details-sheet')).toBeTruthy();
fireEvent.press(getByTestId('resource-details-action'));
expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-2');

unmount();
});

it('filters the resources list by unassigned / assigned / all', () => {
const board = serverBoard('101', {
Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }],
Assignments: [
{ ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false },
{ ResourceAssignmentId: 'as-2', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: '', ResourceKind: 1, ResourceId: 'u-9', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false },
],
}) as any;

setupStores({
boards: { '101': board },
activeCallId: '101',
calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
users: [{ UserId: 'u-9', FirstName: 'Sam', LastName: 'Jones', GroupName: 'Station 1', Status: 'Responding' }],
});

const { getByTestId, queryByTestId, unmount } = render(<CommandBoard />);

// All: both visible
expect(getByTestId('resource-dept-as-1')).toBeTruthy();
expect(getByTestId('resource-dept-as-2')).toBeTruthy();

fireEvent.press(getByTestId('resource-filter-unassigned'));
expect(queryByTestId('resource-dept-as-1')).toBeNull();
expect(getByTestId('resource-dept-as-2')).toBeTruthy();

fireEvent.press(getByTestId('resource-filter-assigned'));
expect(getByTestId('resource-dept-as-1')).toBeTruthy();
expect(queryByTestId('resource-dept-as-2')).toBeNull();

fireEvent.press(getByTestId('resource-filter-all'));
expect(getByTestId('resource-dept-as-1')).toBeTruthy();
expect(getByTestId('resource-dept-as-2')).toBeTruthy();

unmount();
});

it('lane items offer both remove-from-lane and release in the details sheet', () => {
const board = serverBoard('101', {
Nodes: [{ CommandStructureNodeId: 'lane-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, NodeType: 0, Name: 'Division A', SortOrder: 0 }],
Assignments: [{ ResourceAssignmentId: 'as-1', IncidentCommandId: 'cmd-101', DepartmentId: 1, CallId: 101, CommandStructureNodeId: 'lane-1', ResourceKind: 0, ResourceId: 'unit-5', AssignedByUserId: 'u1', AssignedOn: '2026-07-19T10:00:00Z', RequirementsWarning: false }],
}) as any;

setupStores({
boards: { '101': board },
activeCallId: '101',
calls: [{ CallId: '101', Number: '26-14', Name: 'Fire', Address: '', Nature: '', LoggedOnUtc: '2026-07-19T10:00:00Z' }],
});

const { getByTestId, unmount } = render(<CommandBoard />);

fireEvent.press(getByTestId('lane-resource-view-as-1'));
expect(getByTestId('resource-details-sheet')).toBeTruthy();

fireEvent.press(getByTestId('resource-details-action'));
expect(mockMoveResourceAssignment).toHaveBeenCalledWith('101', 'as-1', '');

fireEvent.press(getByTestId('resource-dept-remove-as-1'));
// Reopen — release is the secondary action for lane items
fireEvent.press(getByTestId('lane-resource-view-as-1'));
fireEvent.press(getByTestId('resource-details-secondary-action'));
expect(mockReleaseResourceAssignment).toHaveBeenCalledWith('101', 'as-1');

unmount();
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export default function TabLayout() {
} | null>(null);
if (novuReady) {
lastNovuConfig.current = {
subscriberId: `${rights?.DepartmentCode}_User_${userId}`,
subscriberId: `${rights?.DepartmentCode}_IC_User_${userId}`,
applicationIdentifier: config!.NovuApplicationId,
backendUrl: config!.NovuBackendApiUrl,
socketUrl: config!.NovuSocketUrl,
Expand Down
Loading
Loading