Skip to content

Commit 227ac8d

Browse files
committed
Require attribution resolution before item approval
1 parent 19aabad commit 227ac8d

5 files changed

Lines changed: 18 additions & 7 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,7 @@ A large-change regression reproduced HTTP 413 when the browser sent the full con
3434
## Review round 1
3535

3636
Reproduced and fixed no-change approval with item-owned ambiguous segments; the runner refuses it and the UI directs the user to resolve attribution first. Previously approved no-change items become stale if ambiguous work appears. Reproduced malformed non-ASCII credentials returning a generic conflict instead of unauthorized; credentials now require the expected ASCII hex shape before constant-time comparison. Reproduced stale item controls surviving a failed refresh; errors now discard the loaded view and require refresh. Added acceptance persistence and whole-plan empty-state browser coverage. No findings declined.
37+
38+
## Review round 2
39+
40+
Extended the attribution guard to mixed items with both owned and ambiguous segments. The regression reproduced ordinary approval succeeding with unresolved attribution. Approval now rejects any item-specific ambiguity, previously approved affected items become stale, and the UI routes every such item to attribution resolution. Added browser coverage for this mixed case. No findings declined.

‎runner/review.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class ReviewService {
4949
});
5050
const states = approvalStates(plan, segments, saved.approvals, identity);
5151
for (const item of plan.items) {
52-
if (states[item.id] === 'approved' && ((!segments.some(segment => segment.row === item.id) && segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id))) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale';
52+
if (states[item.id] === 'approved' && (segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id)) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale';
5353
}
5454
const expected: ReviewState = { revision: plan.revision, snapshotId: snapshot.id, reviewVersion };
5555
const notes = this.store.getReviewNotes(identity);
@@ -82,7 +82,7 @@ export class ReviewService {
8282
const { identity } = this.config;
8383
if (command.action === 'approve' && typeof command.item === 'string') {
8484
const item = view.items.find(item => item.id === command.item);
85-
if (item && item.count === 0 && item.ambiguousCount > 0) throw new Error('Resolve this item’s ambiguous changes before confirming no change is needed.');
85+
if (item && item.ambiguousCount > 0) throw new Error('Resolve this item’s ambiguous changes before approval.');
8686
const approval = approveItem(view.plan, view.segments, command.item, identity, command.confirmNoChange === true);
8787
this.store.saveReview(identity, view.expected, [approval], []);
8888
} else if ((command.action === 'assign' || command.action === 'accept') && typeof command.key === 'string') {

‎test/browser/review.spec.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,8 @@ test('shows the whole-plan empty state without claiming checks passed',async({pa
6969
const {repository,identity}=app.service.config;const base=app.service.store.getSnapshot(identity).base;execFileSync('git',['reset','--hard',base],{cwd:repository,stdio:'pipe'});
7070
await page.goto(app.url);await expect(page.getByRole('heading',{name:'No code changes yet'})).toBeVisible();await expect(page.getByRole('button',{name:'Confirm no change needed',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'AI review: – Not run',exact:true})).toBeVisible();
7171
});
72+
test('routes mixed owned and ambiguous items to attribution resolution',async({page})=>{
73+
const {repository,identity}=app.service.config;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return Math.min(10000, 200 * 2 ** attempt);\n}\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','P2 changes retry'],{cwd:repository,stdio:'pipe'});
74+
const head=execFileSync('git',['rev-parse','HEAD'],{cwd:repository,encoding:'utf8'}).trim(),snapshot=app.service.store.getSnapshot(identity);app.service.store.recordHistory(identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]);
75+
await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByRole('button',{name:'Resolve ambiguous changes',exact:true}).click();await expect(page.getByRole('heading',{name:'Ambiguous',exact:true})).toBeVisible();
76+
});

‎test/review.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ it('refuses no-change confirmation while the item still owns ambiguous changes',
3939
service.store.recordHistory(config.identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]);
4040
const view=service.load();expect(view.items[0]!.count).toBe(0);expect(view.segments.some(s=>s.row==='Ambiguous'&&s.owners.includes('P1'))).toBe(true);
4141
expect(()=>service.act({action:'approve',item:'P1',confirmNoChange:true,token:view.token})).toThrow(/ambiguous/i);
42+
expect(view.items[1]!.count).toBeGreaterThan(0);
43+
expect(()=>service.act({action:'approve',item:'P2',token:view.token})).toThrow(/ambiguous/i);
4244
});

‎web/public/app.js‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,10 @@ function render() {
159159
? `<p>${esc(item.intent)}</p>${item.reasons.map((reason) => `<p class="warn">! Stale: ${esc(reason)}</p>`).join("")}`
160160
: "";
161161
$("approve").hidden = !item;
162-
$("approve").textContent = item?.count
163-
? `Approve ${item.id}`
164-
: item?.ambiguousCount
165-
? "Resolve ambiguous changes"
162+
$("approve").textContent = item?.ambiguousCount
163+
? "Resolve ambiguous changes"
164+
: item?.count
165+
? `Approve ${item.id}`
166166
: "Confirm no change needed";
167167
$("approve").disabled = item?.state === "approved";
168168
$("view-toggle").innerHTML =
@@ -324,7 +324,7 @@ function showDialog(html) {
324324
}
325325
$("approve").onclick = () => {
326326
const item = data?.items.find((item) => item.id === selected);
327-
if (item?.count === 0 && item.ambiguousCount > 0) {
327+
if (item?.ambiguousCount > 0) {
328328
select("Ambiguous");
329329
return;
330330
}

0 commit comments

Comments
 (0)