Skip to content

Commit 7fe00b1

Browse files
authored
Merge pull request #42 from DGouron/feat/followup-importants
feat(followup): re-verify Important issues on pending-approval MRs
2 parents 7aa2f7d + cb17aff commit 7fe00b1

File tree

10 files changed

+395
-7
lines changed

10 files changed

+395
-7
lines changed

src/cli/parseCliArgs.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,19 @@ interface VersionArgs {
3939
command: 'version';
4040
}
4141

42+
interface FollowupImportantsArgs {
43+
command: 'followup-importants';
44+
project: string | undefined;
45+
yes: boolean;
46+
}
47+
4248
interface HelpArgs {
4349
command: 'help';
4450
}
4551

46-
export type CliArgs = StartArgs | StopArgs | StatusArgs | LogsArgs | InitArgs | ValidateArgs | VersionArgs | HelpArgs;
52+
export type CliArgs = StartArgs | StopArgs | StatusArgs | LogsArgs | InitArgs | ValidateArgs | FollowupImportantsArgs | VersionArgs | HelpArgs;
4753

48-
const KNOWN_COMMANDS = ['start', 'stop', 'status', 'logs', 'init', 'validate'] as const;
54+
const KNOWN_COMMANDS = ['start', 'stop', 'status', 'logs', 'init', 'validate', 'followup-importants'] as const;
4955
type KnownCommand = (typeof KNOWN_COMMANDS)[number];
5056

5157
function hasFlag(args: string[], long: string, short?: string): boolean {
@@ -94,6 +100,14 @@ function parseStatusArgs(args: string[]): StatusArgs {
94100
};
95101
}
96102

103+
function parseFollowupImportantsArgs(args: string[]): FollowupImportantsArgs {
104+
return {
105+
command: 'followup-importants',
106+
project: getFlagValue(args, '--project', '-p'),
107+
yes: hasFlag(args, '--yes', '-y'),
108+
};
109+
}
110+
97111
function parseLogsArgs(args: string[]): LogsArgs {
98112
const linesValue = getFlagValue(args, '--lines', '-n');
99113
return {
@@ -154,5 +168,7 @@ export function parseCliArgs(args: string[]): CliArgs {
154168
return parseInitArgs(args);
155169
case 'validate':
156170
return parseValidateArgs(args);
171+
case 'followup-importants':
172+
return parseFollowupImportantsArgs(args);
157173
}
158174
}

src/interface-adapters/controllers/http/mrTrackingAdvanced.routes.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,71 @@ export const mrTrackingAdvancedRoutes: FastifyPluginAsync<MrTrackingAdvancedRout
285285
return { success: true, mr: result };
286286
});
287287

288+
fastify.post('/api/mr-tracking/followup-importants', async (request, reply) => {
289+
const body = request.body as { projectPath?: string };
290+
291+
let repos: RepositoryConfig[];
292+
if (body.projectPath) {
293+
const validation = validateProjectPath(body.projectPath);
294+
if (!validation.valid) {
295+
reply.code(400);
296+
return { success: false, error: validation.error };
297+
}
298+
const repo = getRepositories().find(r => r.localPath === validation.path && r.enabled);
299+
if (!repo) {
300+
reply.code(404);
301+
return { success: false, error: 'Repository not configured' };
302+
}
303+
repos = [repo];
304+
} else {
305+
repos = getRepositories().filter(r => r.enabled);
306+
}
307+
308+
const candidates: Array<{ mr: import('../../../entities/tracking/trackedMr.js').TrackedMr; projectPath: string }> = [];
309+
for (const repo of repos) {
310+
const mrs = reviewRequestTrackingGateway.getByState(repo.localPath, 'pending-approval');
311+
for (const mr of mrs) {
312+
if (mr.totalWarnings > 0) {
313+
candidates.push({ mr, projectPath: repo.localPath });
314+
}
315+
}
316+
}
317+
318+
if (candidates.length === 0) {
319+
return { success: true, triggered: 0, candidates: [], failed: [] };
320+
}
321+
322+
const failed: Array<{ mrId: string; error: string }> = [];
323+
let triggered = 0;
324+
325+
for (const { mr, projectPath } of candidates) {
326+
try {
327+
const internalResponse = await fastify.inject({
328+
method: 'POST',
329+
url: '/api/mr-tracking/followup',
330+
payload: { mrId: mr.id, projectPath },
331+
});
332+
const data = JSON.parse(internalResponse.body);
333+
if (data.success) {
334+
triggered++;
335+
} else {
336+
failed.push({ mrId: mr.id, error: data.error });
337+
}
338+
} catch (error) {
339+
failed.push({ mrId: mr.id, error: error instanceof Error ? error.message : String(error) });
340+
}
341+
}
342+
343+
logInfo('Followup importants batch triggered', { triggered, failed: failed.length, total: candidates.length });
344+
345+
return {
346+
success: true,
347+
triggered,
348+
candidates: candidates.map(c => ({ mrId: c.mr.id, mrNumber: c.mr.mrNumber, title: c.mr.title })),
349+
failed,
350+
};
351+
});
352+
288353
fastify.post('/api/mr-tracking/sync', async (request, reply) => {
289354
const body = request.body as { projectPath?: string; mrId?: string };
290355
const { projectPath, mrId } = body;

src/interface-adapters/views/dashboard/index.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -822,11 +822,16 @@ <h1>Reviewflow</h1>
822822
const mrPrefix = mr.platform === 'github' ? '#' : '!';
823823
const threadInfo = type === 'pending-fix'
824824
? `<span class="mr-threads has-open"><i data-lucide="message-circle"></i> ${mr.openThreads} ouvert${mr.openThreads > 1 ? 's' : ''}</span>`
825-
: `<span class="mr-threads all-resolved"><i data-lucide="check-circle"></i> Résolus</span>`;
825+
: (type === 'pending-approval' && mr.totalWarnings > 0)
826+
? `<span class="mr-threads has-warnings"><i data-lucide="alert-triangle"></i> ${mr.totalWarnings} important${mr.totalWarnings > 1 ? 's' : ''}</span>`
827+
: `<span class="mr-threads all-resolved"><i data-lucide="check-circle"></i> Résolus</span>`;
826828

827829
const openBtn = `<a href="${mr.url}" target="_blank" class="btn-action open"><i data-lucide="external-link"></i> Ouvrir</a>`;
828830
const autoFollowupChecked = mr.autoFollowup !== false ? 'checked' : '';
829-
const actions = type === 'pending-fix'
831+
const showFollowupActions = type === 'pending-fix' ||
832+
(type === 'pending-approval' && mr.totalWarnings > 0);
833+
834+
const actions = showFollowupActions
830835
? `<label class="auto-followup-toggle" onclick="event.stopPropagation()" title="Active/désactive le followup automatique après un push">
831836
<input type="checkbox" ${autoFollowupChecked} onchange="toggleAutoFollowup('${mr.id}', this.checked)">
832837
Auto follow-up
@@ -855,7 +860,7 @@ <h1>Reviewflow</h1>
855860
return `
856861
<div class="mr-item-accordion" data-mr-id="${mr.id}">
857862
<div class="mr-item-header" onclick="toggleMrAccordion('${accordionId}')">
858-
<div class="review-status ${type === 'pending-fix' ? 'running' : 'completed'}"></div>
863+
<div class="review-status ${type === 'pending-fix' ? 'running' : (type === 'pending-approval' && mr.totalWarnings > 0) ? 'warnings' : 'completed'}"></div>
859864
<div class="mr-info">
860865
<div class="mr-title">
861866
<a href="${mr.url}" target="_blank" onclick="event.stopPropagation()">${mrPrefix}${mr.mrNumber}</a> - ${mr.title}

src/interface-adapters/views/dashboard/styles.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ h1 { font-size: 1.75rem; font-weight: 600; }
276276
.review-status.queued { background: #3b82f6; }
277277
.review-status.completed { background: #22c55e; }
278278
.review-status.failed { background: #ef4444; }
279+
.review-status.warnings { background: #f59e0b; }
279280

280281
.review-info { flex: 1; min-width: 0; }
281282
.review-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -762,6 +763,7 @@ h1 { font-size: 1.75rem; font-weight: 600; }
762763
}
763764
.mr-threads.has-open { color: #ef4444; }
764765
.mr-threads.all-resolved { color: #22c55e; }
766+
.mr-threads.has-warnings { color: #f59e0b; }
765767
.mr-actions {
766768
display: flex;
767769
gap: 0.5rem;

src/main/cli.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { StartDaemonUseCase, type StartDaemonDependencies } from '../usecases/cl
1212
import { StopDaemonUseCase, type StopDaemonDependencies } from '../usecases/cli/stopDaemon.usecase.js';
1313
import { QueryStatusUseCase, type QueryStatusDependencies } from '../usecases/cli/queryStatus.usecase.js';
1414
import { ReadLogsUseCase, type ReadLogsDependencies } from '../usecases/cli/readLogs.usecase.js';
15+
import { FollowupImportantsUseCase } from '../usecases/cli/followupImportants.usecase.js';
1516
import { readPidFile, writePidFile, removePidFile } from '../shared/services/pidFileManager.js';
1617
import { isProcessRunning } from '../shared/services/processChecker.js';
1718
import { PID_FILE_PATH, LOG_FILE_PATH } from '../shared/services/daemonPaths.js';
@@ -51,6 +52,7 @@ Commands:
5152
status Show server status
5253
logs Show daemon logs
5354
validate Validate configuration
55+
followup-importants Trigger followups for pending-approval MRs with Important issues
5456
5557
Init options:
5658
-y, --yes Accept all defaults (non-interactive)
@@ -77,6 +79,10 @@ Logs options:
7779
Validate options:
7880
--fix Auto-fix correctable issues
7981
82+
Followup-importants options:
83+
-p, --project <path> Scan specific project only
84+
-y, --yes Skip confirmation prompt
85+
8086
General options:
8187
-v, --version Show version
8288
-h, --help Show this help
@@ -246,6 +252,23 @@ export function executeLogs(follow: boolean, lines: number, deps: LogsDeps): voi
246252
}
247253
}
248254

255+
async function executeFollowupImportants(project: string | undefined): Promise<void> {
256+
const pidData = readPidFile(PID_FILE_PATH);
257+
if (!pidData || !isProcessRunning(pidData.pid)) {
258+
console.error(red('Server is not running. Start with: reviewflow start'));
259+
process.exit(1);
260+
}
261+
262+
const usecase = new FollowupImportantsUseCase({
263+
serverPort: pidData.port,
264+
log: console.log,
265+
error: console.error,
266+
fetch: globalThis.fetch,
267+
});
268+
269+
await usecase.execute({ project });
270+
}
271+
249272
const DEFAULT_SCAN_PATHS = [
250273
join(homedir(), 'Documents'),
251274
join(homedir(), 'Projects'),
@@ -571,5 +594,9 @@ if (isDirectlyExecuted) {
571594
case 'validate':
572595
executeValidate(args.fix);
573596
break;
597+
598+
case 'followup-importants':
599+
executeFollowupImportants(args.project);
600+
break;
574601
}
575602
}

src/tests/units/cli/parseCliArgs.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,60 @@ describe('parseCliArgs', () => {
247247
});
248248
});
249249

250+
describe('followup-importants command', () => {
251+
it('should detect followup-importants command', () => {
252+
const result = parseCliArgs(['followup-importants']);
253+
254+
expect(result.command).toBe('followup-importants');
255+
});
256+
257+
it('should detect --project flag with value', () => {
258+
const result = parseCliArgs(['followup-importants', '--project', '/path/to/repo']);
259+
260+
expect(result).toEqual(expect.objectContaining({
261+
command: 'followup-importants',
262+
project: '/path/to/repo',
263+
}));
264+
});
265+
266+
it('should detect -p short flag for project', () => {
267+
const result = parseCliArgs(['followup-importants', '-p', '/path/to/repo']);
268+
269+
expect(result).toEqual(expect.objectContaining({
270+
command: 'followup-importants',
271+
project: '/path/to/repo',
272+
}));
273+
});
274+
275+
it('should detect --yes flag', () => {
276+
const result = parseCliArgs(['followup-importants', '--yes']);
277+
278+
expect(result).toEqual(expect.objectContaining({
279+
command: 'followup-importants',
280+
yes: true,
281+
}));
282+
});
283+
284+
it('should detect -y short flag for yes', () => {
285+
const result = parseCliArgs(['followup-importants', '-y']);
286+
287+
expect(result).toEqual(expect.objectContaining({
288+
command: 'followup-importants',
289+
yes: true,
290+
}));
291+
});
292+
293+
it('should default project to undefined and yes to false', () => {
294+
const result = parseCliArgs(['followup-importants']);
295+
296+
expect(result).toEqual(expect.objectContaining({
297+
command: 'followup-importants',
298+
project: undefined,
299+
yes: false,
300+
}));
301+
});
302+
});
303+
250304
describe('version and help flags', () => {
251305
it('should detect --version flag', () => {
252306
const result = parseCliArgs(['--version']);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { FollowupImportantsUseCase } from '../../../../usecases/cli/followupImportants.usecase.js';
3+
4+
function createMockFetch(response: object) {
5+
return vi.fn().mockResolvedValue({
6+
ok: true,
7+
json: () => Promise.resolve(response),
8+
});
9+
}
10+
11+
function createDeps(overrides: Partial<Parameters<typeof FollowupImportantsUseCase.prototype.execute>[1]> = {}) {
12+
return {
13+
serverPort: 3000,
14+
log: vi.fn(),
15+
error: vi.fn(),
16+
fetch: createMockFetch({ success: true, triggered: 0, candidates: [], failed: [] }),
17+
...overrides,
18+
};
19+
}
20+
21+
describe('FollowupImportantsUseCase', () => {
22+
it('should call the batch API endpoint', async () => {
23+
const fetch = createMockFetch({ success: true, triggered: 0, candidates: [], failed: [] });
24+
const deps = createDeps({ fetch });
25+
const usecase = new FollowupImportantsUseCase(deps);
26+
27+
await usecase.execute({});
28+
29+
expect(fetch).toHaveBeenCalledWith(
30+
'http://localhost:3000/api/mr-tracking/followup-importants',
31+
expect.objectContaining({ method: 'POST' }),
32+
);
33+
});
34+
35+
it('should pass projectPath when provided', async () => {
36+
const fetch = createMockFetch({ success: true, triggered: 0, candidates: [], failed: [] });
37+
const deps = createDeps({ fetch });
38+
const usecase = new FollowupImportantsUseCase(deps);
39+
40+
await usecase.execute({ project: '/path/to/repo' });
41+
42+
expect(fetch).toHaveBeenCalledWith(
43+
expect.any(String),
44+
expect.objectContaining({
45+
body: JSON.stringify({ projectPath: '/path/to/repo' }),
46+
}),
47+
);
48+
});
49+
50+
it('should log message when no candidates found', async () => {
51+
const deps = createDeps();
52+
const usecase = new FollowupImportantsUseCase(deps);
53+
54+
await usecase.execute({});
55+
56+
expect(deps.log).toHaveBeenCalledWith(expect.stringContaining('No pending-approval'));
57+
});
58+
59+
it('should log triggered count and candidate details', async () => {
60+
const fetch = createMockFetch({
61+
success: true,
62+
triggered: 2,
63+
candidates: [
64+
{ mrId: 'gitlab-proj-10', mrNumber: 10, title: 'Fix auth' },
65+
{ mrId: 'gitlab-proj-11', mrNumber: 11, title: 'Add tests' },
66+
],
67+
failed: [],
68+
});
69+
const deps = createDeps({ fetch });
70+
const usecase = new FollowupImportantsUseCase(deps);
71+
72+
await usecase.execute({});
73+
74+
expect(deps.log).toHaveBeenCalledWith(expect.stringContaining('2'));
75+
expect(deps.log).toHaveBeenCalledWith(expect.stringContaining('!10'));
76+
expect(deps.log).toHaveBeenCalledWith(expect.stringContaining('!11'));
77+
});
78+
79+
it('should log failures when some followups fail', async () => {
80+
const fetch = createMockFetch({
81+
success: true,
82+
triggered: 1,
83+
candidates: [
84+
{ mrId: 'gitlab-proj-10', mrNumber: 10, title: 'Fix auth' },
85+
],
86+
failed: [{ mrId: 'gitlab-proj-11', error: 'timeout' }],
87+
});
88+
const deps = createDeps({ fetch });
89+
const usecase = new FollowupImportantsUseCase(deps);
90+
91+
await usecase.execute({});
92+
93+
expect(deps.error).toHaveBeenCalledWith(expect.stringContaining('1 failed'));
94+
});
95+
});

0 commit comments

Comments
 (0)