Skip to content

Commit d023e4c

Browse files
m4tthewericclaude
andauthored
board: approved means GitLab says approved (#160)
* board: a fully approved roster short of a rule reads partial, not approved Every assigned reviewer approving no longer buckets an MR as approved while GitLab's isApproved is false. An unassigned codeowner section can still be owed, so the pill shows the n/m partial count instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * board: approval counts read rule slots filled, not approvers GitLab's required counts rule slots while given counts approvers, so one approver on a many-section MR read 1/83. approvalSlots derives filled from required minus remaining, and the pill, tooltip, standing line, respond sheet and progress sort all read it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * board: an approval filling no rule slot keeps the conversation state A roster approval that counts toward no rule no longer drops the row to an amber needs review; it falls through to commented or comments resolved. Base test fixtures carry remaining so no count renders NaN, and the progress sort gets a case where approvers and slots disagree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0807c67 commit d023e4c

10 files changed

Lines changed: 157 additions & 40 deletions

‎apps/board/src/__tests__/view.test.ts‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function mr(overrides: Partial<BoardMR>): BoardMR {
3939
pipelineState: 'none',
4040
unresolvedThreads: 0,
4141
reviewerComments: 0,
42-
reviews: { required: 2, given: 0, isApproved: false },
42+
reviews: { required: 2, given: 0, remaining: 2, isApproved: false },
4343
blockers: {},
4444
autoMergeButton: { visible: false, isActive: false },
4545
...overrides,
@@ -629,20 +629,59 @@ describe('sortMRs', () => {
629629
const list = [
630630
mr({
631631
iid: 1,
632-
reviews: { required: 2, given: 0, isApproved: false } as any,
632+
reviews: {
633+
required: 2,
634+
given: 0,
635+
remaining: 2,
636+
isApproved: false,
637+
} as any,
633638
}),
634639
mr({
635640
iid: 2,
636-
reviews: { required: 2, given: 2, isApproved: true } as any,
641+
reviews: {
642+
required: 2,
643+
given: 2,
644+
remaining: 0,
645+
isApproved: true,
646+
} as any,
637647
}),
638648
mr({
639649
iid: 3,
640-
reviews: { required: 2, given: 1, isApproved: false } as any,
650+
reviews: {
651+
required: 2,
652+
given: 1,
653+
remaining: 1,
654+
isApproved: false,
655+
} as any,
641656
}),
642657
];
643658
expect(sortMRs(list, 'progress').map(m => m.iid)).toEqual([2, 3, 1]);
644659
});
645660

661+
test('progress counts rule slots filled, not approvers', () => {
662+
const list = [
663+
mr({
664+
iid: 1,
665+
reviews: {
666+
required: 4,
667+
given: 2,
668+
remaining: 2,
669+
isApproved: false,
670+
} as any,
671+
}),
672+
mr({
673+
iid: 2,
674+
reviews: {
675+
required: 4,
676+
given: 1,
677+
remaining: 1,
678+
isApproved: false,
679+
} as any,
680+
}),
681+
];
682+
expect(sortMRs(list, 'progress').map(m => m.iid)).toEqual([2, 1]);
683+
});
684+
646685
test('does not mutate input', () => {
647686
const list = [
648687
mr({ iid: 1, createdAt: '2026-07-05T00:00:00Z' }),
@@ -893,22 +932,41 @@ describe('groupMRs status', () => {
893932
expect(groups.map(g => g.label)).toEqual(['approved']);
894933
});
895934

896-
test('every assigned reviewer approved buckets approved even short of the rule count', () => {
935+
test('every assigned reviewer approved stays out of approved while a rule is short', () => {
897936
const list = [
898937
mr({
899938
iid: 1,
900-
reviewerComments: 0,
939+
reviewerComments: 2,
901940
threadSummary: { awaiting: 0, replied: 0, resolved: 4 },
902941
reviews: {
903942
required: 2,
904943
given: 1,
944+
remaining: 1,
905945
isApproved: false,
906946
reviewers: [{ reviewState: 'APPROVED' }],
907947
} as any,
908948
}),
909949
];
910950
const groups = groupMRs(list, 'status', [], NOW);
911-
expect(groups.map(g => g.label)).toEqual(['approved']);
951+
expect(groups.map(g => g.label)).toEqual(['needs review']);
952+
});
953+
954+
test('an approval that fills no rule slot keeps the conversation state', () => {
955+
const list = [
956+
mr({
957+
iid: 1,
958+
reviewerComments: 2,
959+
reviews: {
960+
required: 2,
961+
given: 1,
962+
remaining: 2,
963+
isApproved: false,
964+
reviewers: [{ reviewState: 'APPROVED' }],
965+
} as any,
966+
}),
967+
];
968+
const groups = groupMRs(list, 'status', [], NOW);
969+
expect(groups.map(g => g.label)).toEqual(['commented']);
912970
});
913971

914972
test('a reviewer still pending keeps a part-approved MR out of the approved bucket', () => {

‎apps/board/src/client/board/RespondSheet.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import type { GateItemDisplay } from '@mattstack/gate-kit/react';
2020
import { Button, Chip } from '@mattstack/tui-kit';
2121
import type { GateRow } from '../../gates/store.ts';
22+
import { approvalSlots } from '../../view.ts';
2223
import type { BoardMRWithReview } from '../types.ts';
2324
import { parseGateCtx, type PlanCtx, type PostCtx } from './gate-ctx.ts';
2425
import type { GateFormState } from './GateForm.tsx';
@@ -444,6 +445,7 @@ function ResponseRows({
444445
row's status pill never disagree. */
445446
function MrStatusCard({ mr }: { mr: BoardMRWithReview }) {
446447
const b = mr.blockers;
448+
const { filled, required } = approvalSlots(mr);
447449
const ci =
448450
mr.pipelineState === 'passed'
449451
? { chip: 'pass', intent: 'ok' as const, text: 'pipeline passing' }
@@ -458,7 +460,7 @@ function MrStatusCard({ mr }: { mr: BoardMRWithReview }) {
458460
key: 'approvals',
459461
chip: mr.reviews.isApproved ? 'ok' : 'wait',
460462
intent: mr.reviews.isApproved ? ('ok' as const) : ('warn' as const),
461-
text: `${mr.reviews.given} of ${mr.reviews.required} approvals`,
463+
text: `${filled} of ${required} approvals`,
462464
},
463465
b.hasConflicts
464466
? {

‎apps/board/src/client/board/__tests__/answered-sheet-dom.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const MR = {
4040
author: { username: 'jvasquez', name: 'Joel Vasquez' },
4141
pipelineState: 'passed',
4242
blockers: { any: false, hasConflicts: false },
43-
reviews: { isApproved: false, given: 0, required: 1 },
43+
reviews: { isApproved: false, given: 0, required: 1, remaining: 1 },
4444
} as unknown as BoardMRWithReview;
4545

4646
function shipped(overrides: Partial<GateRow> = {}): GateRow {

‎apps/board/src/client/board/__tests__/respond-header-dom.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const MR = {
4242
pipelineState: 'passed',
4343
behindTarget: null,
4444
blockers: { any: false, hasConflicts: false },
45-
reviews: { isApproved: false, given: 0, required: 1 },
45+
reviews: { isApproved: false, given: 0, required: 1, remaining: 1 },
4646
} as unknown as BoardMRWithReview;
4747

4848
const PLAN = JSON.stringify({

‎apps/board/src/client/board/__tests__/respond-sheet-dom.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const MR = {
3838
pipelineState: 'passed',
3939
behindTarget: null,
4040
blockers: { any: false, hasConflicts: false },
41-
reviews: { isApproved: false, given: 0, required: 1 },
41+
reviews: { isApproved: false, given: 0, required: 1, remaining: 1 },
4242
} as unknown as BoardMRWithReview;
4343

4444
const thread = (summary: string) =>

‎apps/board/src/client/board/__tests__/row-status.test.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
EXECUTION_UNASSIGNED_MESSAGE,
88
rowStatus,
99
statusPhrase,
10+
statusReasons,
1011
} from '../row-status.ts';
1112

1213
const NOW = Date.parse('2026-09-12T12:00:00Z');
@@ -21,7 +22,13 @@ function mr(over: Partial<BoardMRWithReview> = {}): BoardMRWithReview {
2122
sourceBranch: 'feature/acme-2214',
2223
targetBranch: 'main',
2324
author: { username: 'pat', name: 'Pat' },
24-
reviews: { isApproved: false, required: 1, given: 0, reviewers: [] },
25+
reviews: {
26+
isApproved: false,
27+
required: 1,
28+
given: 0,
29+
remaining: 1,
30+
reviewers: [],
31+
},
2532
blockers: { any: false },
2633
mergeButton: { visible: false, disabled: false, loading: false },
2734
gates: [],
@@ -58,7 +65,13 @@ const settled = (over: Over = {}) =>
5865
...over,
5966
} as never);
6067
const unapproved = (given: number, required: number) => ({
61-
reviews: { isApproved: false, required, given, reviewers: [] },
68+
reviews: {
69+
isApproved: false,
70+
required,
71+
given,
72+
remaining: required - given,
73+
reviewers: [],
74+
},
6275
});
6376
const blockedBy = (flags: Over) => ({ blockers: { any: true, ...flags } });
6477
const MERGEABLE = {
@@ -1592,6 +1605,40 @@ describe('statusPhrase: the pill says what the status group says', () => {
15921605
});
15931606
});
15941607

1608+
test('every assigned reviewer approved but a codeowner rule still open reads partial, not approved', () => {
1609+
const rosterApproved = {
1610+
reviews: {
1611+
isApproved: false,
1612+
required: 2,
1613+
given: 1,
1614+
remaining: 1,
1615+
reviewers: [{ username: 'pat', reviewState: 'APPROVED' }],
1616+
},
1617+
};
1618+
expect(statusPhrase(settled(rosterApproved))).toEqual({
1619+
text: '1/2 approved',
1620+
hue: 'cyan',
1621+
});
1622+
expect(
1623+
statusPhrase(settled({ ...rosterApproved, reviewerComments: 2 }))
1624+
).toEqual({ text: '1/2 approved', hue: 'cyan' });
1625+
});
1626+
1627+
test('the count is rule slots filled, not approvers: one approver can fill many rules', () => {
1628+
const wide = settled({
1629+
reviews: {
1630+
isApproved: false,
1631+
required: 83,
1632+
given: 1,
1633+
remaining: 64,
1634+
reviewers: [{ username: 'pat', reviewState: 'APPROVED' }],
1635+
},
1636+
blockers: { any: true, awaitingApprovals: true },
1637+
});
1638+
expect(statusPhrase(wide)).toEqual({ text: '19/83 approved', hue: 'cyan' });
1639+
expect(statusReasons(wide)).toBe('blocked:\n· awaiting approvals (19/83)');
1640+
});
1641+
15951642
test('an untouched MR is amber', () => {
15961643
expect(statusPhrase(settled(unapproved(0, 2)))).toEqual({
15971644
text: 'needs review',

‎apps/board/src/client/board/__tests__/row-view-dom.test.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ function mr(over: Partial<BoardMRWithReview> = {}): BoardMRWithReview {
3939
sourceBranch: 'feature/acme-2214',
4040
targetBranch: 'main',
4141
author: { username: 'pat', name: 'Pat' },
42-
reviews: { isApproved: false, required: 1, given: 0, reviewers: [] },
42+
reviews: {
43+
isApproved: false,
44+
required: 1,
45+
given: 0,
46+
remaining: 1,
47+
reviewers: [],
48+
},
4349
reviewerComments: 0,
4450
diff: { additions: 1455, deletions: 13, filesChanged: 4 },
4551
updatedAt: new Date(NOW - 32 * 3600_000).toISOString(),

‎apps/board/src/client/board/__tests__/stage-sheet-dom.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const MR = {
4141
author: { username: 'jvasquez', name: 'Joel Vasquez' },
4242
pipelineState: 'passed',
4343
blockers: { any: false, hasConflicts: false },
44-
reviews: { isApproved: false, given: 0, required: 1 },
44+
reviews: { isApproved: false, given: 0, required: 1, remaining: 1 },
4545
} as unknown as BoardMRWithReview;
4646

4747
function ship(overrides: Partial<GateRow> = {}): GateRow {

‎apps/board/src/client/board/row-status.ts‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { unwrapGateAnswer } from '@mattstack/gate-kit';
1010
import type { BoardMR } from '../../data.ts';
1111
import { hasChangesRequested } from '../../data.ts';
1212
import { respondOutcome } from '../../respond-outcome.ts';
13-
import { statusBucket } from '../../view.ts';
13+
import { approvalSlots, statusBucket } from '../../view.ts';
1414
import type {
1515
BoardMRWithReview,
1616
DoctorStatus,
@@ -89,10 +89,10 @@ export function statusReasons(mr: BoardMR): string {
8989
if (b.needsRebase) reasons.push('source branch needs a rebase');
9090
if (b.pipelineFailing) reasons.push('pipeline is failing');
9191
if (b.pipelineRunning) reasons.push('pipeline still running');
92-
if (b.awaitingApprovals)
93-
reasons.push(
94-
`awaiting approvals (${mr.reviews.given}/${mr.reviews.required})`
95-
);
92+
if (b.awaitingApprovals) {
93+
const { filled, required } = approvalSlots(mr);
94+
reasons.push(`awaiting approvals (${filled}/${required})`);
95+
}
9696
if (b.hasUnresolvedDiscussions)
9797
reasons.push(`unresolved discussions (${mr.unresolvedThreads})`);
9898
if (b.hasMergeError)
@@ -118,15 +118,9 @@ const PILL_HUE: Record<string, PillHue> = {
118118
approvals have got refines the untouched state rather than replacing it. */
119119
export function statusPhrase(mr: BoardMR): { text: string; hue: PillHue } {
120120
const { label } = statusBucket(mr);
121-
if (
122-
label === 'needs review' &&
123-
mr.reviews.required > 0 &&
124-
mr.reviews.given > 0
125-
)
126-
return {
127-
text: `${mr.reviews.given}/${mr.reviews.required} approved`,
128-
hue: 'cyan',
129-
};
121+
const { filled, required } = approvalSlots(mr);
122+
if (label === 'needs review' && required > 0 && filled > 0)
123+
return { text: `${filled}/${required} approved`, hue: 'cyan' };
130124
return { text: label, hue: PILL_HUE[label] ?? 'amber' };
131125
}
132126

@@ -663,9 +657,9 @@ const CI_RUNNING: Candidate = {
663657
};
664658

665659
function approvalsDetail(mr: BoardMRWithReview): string | undefined {
666-
const { given, required } = mr.reviews;
667-
return given > 0 && required > 0
668-
? `${given} of ${required} approvals`
660+
const { filled, required } = approvalSlots(mr);
661+
return filled > 0 && required > 0
662+
? `${filled} of ${required} approvals`
669663
: undefined;
670664
}
671665

‎apps/board/src/view.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,21 @@ export function flattenStack<M extends BoardMR>(
368368
];
369369
}
370370

371+
/** GitLab counts approvals per rule, and one approver can fill several
372+
rules, so `given` (approvers) and `required` (rule slots) are different
373+
units. Every "n/m approved" reads this pair so the two never mix. */
374+
export function approvalSlots(mr: BoardMR): {
375+
filled: number;
376+
required: number;
377+
} {
378+
const { required, remaining } = mr.reviews;
379+
return { filled: Math.max(required - remaining, 0), required };
380+
}
381+
371382
/** Approval ratio in [0,1]; used by the "progress" sort. */
372383
function progress(mr: BoardMR): number {
373-
const req = mr.reviews.required;
374-
if (req > 0) return mr.reviews.given / req;
384+
const { filled, required } = approvalSlots(mr);
385+
if (required > 0) return filled / required;
375386
return mr.reviews.given > 0 ? 1 : 0;
376387
}
377388

@@ -493,10 +504,7 @@ function ageBucket(
493504
return { label: 'Older', order: 9 };
494505
}
495506

496-
/** The roster's verdict, not the approval rule's arithmetic: every assigned
497-
reviewer has approved. An MR in this state reads approved even while a
498-
project rule still wants more approvals -- the shortfall stays visible as
499-
the awaiting-approvals blocker, not as the review state. */
507+
/** Every assigned reviewer has approved. */
500508
function allReviewersApproved(mr: BoardMR): boolean {
501509
const reviewers = mr.reviews.reviewers ?? [];
502510
return (
@@ -516,8 +524,10 @@ export function statusBucket(mr: BoardMR): { label: string; order: number } {
516524
// not their own groups, so an MR with conflicts still shows under its review
517525
// state instead of being hidden in a "conflicts" bucket.
518526
if (hasChangesRequested(mr)) return { label: 'changes requested', order: 0 };
519-
if (mr.reviews.isApproved || allReviewersApproved(mr))
520-
return { label: 'approved', order: 4 };
527+
if (mr.reviews.isApproved) return { label: 'approved', order: 4 };
528+
// A rule no assigned reviewer covers (a codeowner section) can still be owed.
529+
if (allReviewersApproved(mr) && approvalSlots(mr).filled > 0)
530+
return { label: 'needs review', order: 2 };
521531
if (mr.reviewerComments > 0) return { label: 'commented', order: 1 };
522532
// Reviewed and all threads resolved, just not formally approved — further along
523533
// than an untouched MR, so it sits between "needs review" and "approved".

0 commit comments

Comments
 (0)