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
32 changes: 32 additions & 0 deletions scripts/__tests__/extract-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';

const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh');

const extractReleaseNotes = (body: string): string => {
const result = spawnSync('bash', [scriptPath, '--extract-only'], {
encoding: 'utf8',
input: body,
});

if (result.status !== 0) {
throw new Error(result.stderr);
}

return result.stdout.trim();
};

describe('extract-release-notes', () => {
it.each(['##', '###'])('normalizes a %s PR Description heading', (heading) => {
const notes = extractReleaseNotes(`${heading} PR Description\n\nAdds the release change.`);

expect(notes).toBe(`${heading} Description\nAdds the release change.`);
expect(notes).not.toContain('PR Description');
});

it('continues to prefer the dedicated Release Notes section', () => {
const notes = extractReleaseNotes('## PR Description\nInternal PR context\n\n## Release Notes\nUser-facing change\n\n## Testing\nPassed');

expect(notes).toBe('User-facing change');
});
});
44 changes: 30 additions & 14 deletions scripts/extract-release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ export GH_TOKEN=$5
# Function to extract release notes from PR body
extract_release_notes() {
local body="$1"

# First pass: Remove everything between CodeRabbit comment markers using sed
local cleaned_body="$(printf '%s\n' "$body" \
local cleaned_body
cleaned_body="$(printf '%s\n' "$body" \
| sed '/<!-- This is an auto-generated comment: release notes by coderabbit.ai -->/,/<!-- end of auto-generated comment: release notes by coderabbit.ai -->/d')"

# Second pass: Remove the "Summary by CodeRabbit" section
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| awk '
Expand All @@ -24,34 +25,49 @@ extract_release_notes() {
/^## / && skip==1 { skip=0 }
skip==0 { print }
')"

# Third pass: Remove any remaining HTML comment lines

# Third pass: Replace PR-only description headings with release-friendly headings
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| awk '
/^#+[[:space:]]+PR Description[[:space:]]*$/ {
sub(/PR Description[[:space:]]*$/, "Description")
}
{ print }
')"

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

# Fifth pass: Remove specific CodeRabbit lines
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| (grep -v '✏️ Tip: You can customize this high-level summary in your review settings\.' || true) \
| (grep -v '<!-- This is an auto-generated comment: release notes by coderabbit.ai -->' || true) \
| (grep -v '<!-- end of auto-generated comment: release notes by coderabbit.ai -->' || true))"
# Fifth pass: Trim leading and trailing whitespace/empty lines

# Sixth pass: Trim leading and trailing whitespace/empty lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | awk 'NF {p=1} p')"

# Try to extract content under "## Release Notes" heading if it exists
local notes="$(printf '%s\n' "$cleaned_body" \
local notes
notes="$(printf '%s\n' "$cleaned_body" \
| awk 'f && /^## /{exit} /^## Release Notes/{f=1; next} f')"

# If no specific "Release Notes" section found, use the entire cleaned body
if [ -z "$notes" ]; then
notes="$cleaned_body"
fi

# Final trim
notes="$(printf '%s\n' "$notes" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"

printf '%s\n' "$notes"
}

if [ "${1:-}" = "--extract-only" ]; then
extract_release_notes "$(cat)"
exit 0
fi

# Determine source of release notes
NOTES=""

Expand Down
5 changes: 3 additions & 2 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios';

import { refreshTokenRequest } from '@/lib/auth/api';
import { refreshTokenSingleFlight } from '@/lib/auth/api';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';

// Create axios instance with default config
const axiosInstance: AxiosInstance = axios.create({
baseURL: getBaseApiUrl(),
timeout: 15000,

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

The magic number 15000 used for the axios timeout obscures intent and complicates future tuning. Extract a shared constant such as DEFAULT_REQUEST_TIMEOUT_MS = 15000 to allow consistent reuse across the codebase.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File src/api/common/client.tsx:

Line 11:

The magic number `15000` used for the axios timeout obscures intent and complicates future tuning. Extract a shared constant such as `DEFAULT_REQUEST_TIMEOUT_MS = 15000` to allow consistent reuse across the codebase.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with πŸ‘ or πŸ‘Ž to help Kody learn from this interaction.

​

​

headers: {
'Content-Type': 'application/json',
},
Expand Down Expand Up @@ -83,7 +84,7 @@ axiosInstance.interceptors.response.use(
throw new Error('No refresh token available');
}

const response = await refreshTokenRequest(refreshToken);
const response = await refreshTokenSingleFlight(refreshToken);
const { access_token, refresh_token: newRefreshToken } = response;

// Update tokens in store
Expand Down
151 changes: 151 additions & 0 deletions src/app/(app)/__tests__/incidents.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';

import { type IncidentCommandSummary } from '@/models/v4/incidentCommand/incidentCommandModels';
import { useIncidentsStore } from '@/stores/command/incidents-store';

jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn(),
}));

jest.mock('expo-router', () => ({
router: { push: jest.fn() },
}));

jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock('@/components/ui/focus-aware-status-bar', () => ({
FocusAwareStatusBar: () => null,
}));

jest.mock('@/components/command/incident-card', () => {
const React = require('react');
const { Text } = require('react-native');

return {
IncidentCard: ({ summary }: { summary: { CallName?: string | null; IncidentCommandId: string; Name?: string | null } }) =>
React.createElement(Text, { testID: `incident-summary-${summary.IncidentCommandId}` }, summary.Name ?? summary.CallName),
};
});

jest.mock('@/components/common/loading', () => {
const React = require('react');
const { Text } = require('react-native');

return {
Loading: ({ text }: { text: string }) => React.createElement(Text, { testID: 'loading' }, text),
};
});

jest.mock('@/components/common/zero-state', () => {
const React = require('react');
const { Text, View } = require('react-native');

return {
__esModule: true,
default: ({ description, heading }: { description: string; heading: string }) =>
React.createElement(View, { testID: 'zero-state' }, React.createElement(Text, null, heading), React.createElement(Text, null, description)),
};
});

jest.mock('@/components/ui/flat-list', () => {
const React = require('react');
const { View } = require('react-native');

return {
FlatList: ({
data,
keyExtractor,
ListEmptyComponent,
renderItem,
testID,
}: {
data: { IncidentCommandId: string }[];
keyExtractor: (item: { IncidentCommandId: string }) => string;
ListEmptyComponent?: unknown;
renderItem: (info: { item: { IncidentCommandId: string } }) => unknown;
testID?: string;
}) => React.createElement(View, { testID }, data.length > 0 ? data.map((item) => React.createElement(View, { key: keyExtractor(item) }, renderItem({ item }) as never)) : (ListEmptyComponent as never)),
};
});

import IncidentsScreen from '../incidents';

const summaries: IncidentCommandSummary[] = [
{
IncidentCommandId: 'ic-1',
DepartmentId: 1,
CallId: 101,
Name: 'Warehouse Fire',
CallName: 'Commercial Structure Fire',
CallNumber: 'C-1001',
CallAddress: '100 Industrial Way',
Status: 0,
EstablishedOn: '2026-07-25T10:00:00Z',
CommanderName: 'Alex Rivera',
CommandPostLocationText: 'North entrance',
AssignedPersonnelCount: 8,
AssignedUnitCount: 3,
},
{
IncidentCommandId: 'ic-2',
DepartmentId: 1,
CallId: 202,
Name: 'Flood Response',
CallName: 'Flooding',
CallNumber: 'C-2002',
CallAddress: '200 River Road',
Status: 0,
EstablishedOn: '2026-07-25T11:00:00Z',
CommanderName: 'Jordan Lee',
CommandPostLocationText: 'Community center',
AssignedPersonnelCount: 5,
AssignedUnitCount: 2,
},
];

describe('IncidentsScreen search', () => {
beforeEach(() => {
useIncidentsStore.setState({
summaries,
includeClosed: false,
isLoading: false,
error: null,
});
});

it('filters incidents by searchable summary fields', () => {
const { unmount } = render(<IncidentsScreen />);

fireEvent.changeText(screen.getByTestId('incidents-search'), 'C-2002');

expect(screen.queryByTestId('incident-summary-ic-1')).toBeNull();
expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy();
unmount();
});

it('clears the search and restores the incident list', () => {
const { unmount } = render(<IncidentsScreen />);

fireEvent.changeText(screen.getByTestId('incidents-search'), 'warehouse');
expect(screen.queryByTestId('incident-summary-ic-2')).toBeNull();

fireEvent.press(screen.getByTestId('incidents-search-clear'));

expect(screen.getByTestId('incident-summary-ic-1')).toBeTruthy();
expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy();
unmount();
});

it('shows a search-specific empty state when no incidents match', () => {
const { unmount } = render(<IncidentsScreen />);

fireEvent.changeText(screen.getByTestId('incidents-search'), 'not-a-real-incident');

expect(screen.getByText('common.no_results_found')).toBeTruthy();
expect(screen.getByText('incidents.no_search_results_description')).toBeTruthy();
unmount();
});
});
21 changes: 20 additions & 1 deletion src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { NovuProvider } from '@novu/react-native';
import Countly from 'countly-sdk-react-native-bridge';
import * as NavigationBar from 'expo-navigation-bar';
import { Redirect, router, SplashScreen, Tabs } from 'expo-router';
import { Redirect, router, SplashScreen, Tabs, usePathname } from 'expo-router';
import { ArrowLeft, ClipboardList, CloudAlert, LayoutDashboard, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -26,6 +26,7 @@ import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
import { getAppHeaderHeight } from '@/lib/app-shell-layout';
import { useAuthStore } from '@/lib/auth';
import { logger } from '@/lib/logging';
import { getMapsHeaderState } from '@/lib/maps-route';
import { useIsFirstTime } from '@/lib/storage';
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
import { audioService } from '@/services/audio.service';
Expand All @@ -51,6 +52,8 @@ export default function TabLayout() {
const { width, height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const isLandscape = width > height;
const pathname = usePathname();
const mapsHeaderState = useMemo(() => getMapsHeaderState(pathname), [pathname]);
const { isActive, appState } = useAppLifecycle();
const { trackEvent } = useAnalytics();

Expand Down Expand Up @@ -429,6 +432,20 @@ export default function TabLayout() {
[t, weatherAlertsIcon, headerLeftMap, headerRightNotification]
);

// The Maps browser is a hidden tab route so its entire nested stack retains the
// authenticated app shell. The landing page gets the drawer menu; child routes
// get a back button while the tab bar remains available.
const mapsOptions = useMemo(
() => ({
href: null,
title: t(mapsHeaderState.titleKey),
headerShown: true as const,
headerLeft: mapsHeaderState.showMenu ? headerLeftMap : headerLeftBack,
headerRight: headerRightNotification,
}),
[t, mapsHeaderState, headerLeftMap, headerLeftBack, headerRightNotification]
);

// POI detail renders inside the tab shell (app header + tab bar stay visible) but has no tab
// bar entry of its own; the header shows a back button instead of the drawer menu.
const poiDetailOptions = useMemo(
Expand Down Expand Up @@ -504,6 +521,8 @@ export default function TabLayout() {
tab bar (href: null). IC shell shows: Map, Calls, Settings. */}
<Tabs.Screen name="weather-alerts" options={weatherAlertsOptions} />

<Tabs.Screen name="maps" options={mapsOptions} />

<Tabs.Screen name="settings" options={settingsOptions} />

<Tabs.Screen name="poi/[id]" options={poiDetailOptions} />
Expand Down
Loading
Loading