mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Move the PR body's machine-read lines into a closing Agent notes section The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Teach /ship to write plain titles, PR openings, commit messages and changelog entries The README now lists what gstack is for, so a PR opening can name the goal it serves. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep tests, size budgets and generated files in step with plain titles and Agent notes Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the test-profile list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep text people add below the PR notes, and fix three smaller review findings The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes. Review-Finding: p1/specialist-batched#1 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Mask credentials in refused-title lines and keep older PRs' plan lines readable A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Print a fixed placeholder when a credential in a refused title cannot be masked Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep a plain-title policy edit from scheduling every paid eval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Record the paid-eval list change as non-behavioral for the shared test harness Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Recheck the PR title on every edit so a base change cannot turn a refused title green Review-Finding: s11/targeted#1 reader=targeted Review-Finding: s11/adversarial-native#1 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
148 lines
9.9 KiB
TypeScript
148 lines
9.9 KiB
TypeScript
/**
|
|
* The Agent notes section (lib/agent-notes.ts, GST-1825): the collapsed block at the end of a PR body that holds the
|
|
* lines gstack's tools read. Covers where the receipt goes, which receipt binds (legacy top, inside the notes, a quoted
|
|
* example in the readable part, none), and that the Plan line and issue lines are found inside the notes.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { AGENT_NOTES_MARKER, agentNotesOrBody, renderAgentNotes, splitAgentNotes, withAgentNotes } from '../lib/agent-notes';
|
|
import { parsePlanLine } from '../lib/plan-record';
|
|
import { bindReceipt, computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceipt, receiptRuns, renderReceiptBlock } from '../lib/ship-receipt';
|
|
import type { ShipReceipt } from '../lib/headless-artifacts';
|
|
|
|
const HEAD = 'a'.repeat(40);
|
|
const OTHER = 'b'.repeat(40);
|
|
const receipt = (head: string, pr = 7): ShipReceipt => orderReceipt({ schema_version: 1, pr, head, base: 'main' });
|
|
const notes = (content: string) => renderAgentNotes(content);
|
|
|
|
describe('splitAgentNotes and withAgentNotes', () => {
|
|
test('a body with a section splits into the readable part and the content; one without has no notes', () => {
|
|
const body = 'Opening sentence.\n\n## Summary\n\nx\n\n' + notes('Plan: exempt: docs-only\nFixes #14');
|
|
expect(splitAgentNotes(body)).toEqual({ readable: 'Opening sentence.\n\n## Summary\n\nx\n\n', notes: 'Plan: exempt: docs-only\nFixes #14' });
|
|
expect(splitAgentNotes('Opening.\n')).toEqual({ readable: 'Opening.\n', notes: null });
|
|
expect(agentNotesOrBody('Opening.\n')).toBe('Opening.\n');
|
|
});
|
|
|
|
test('the marker inside a code fence opens no section, and </details> inside a fence in the notes does not close it', () => {
|
|
const quoted = 'Example:\n\n```md\n' + AGENT_NOTES_MARKER + '\n```\n';
|
|
expect(splitAgentNotes(quoted).notes).toBeNull();
|
|
const inner = 'Plan: exempt: x\n\n```text\n</details>\n```';
|
|
expect(splitAgentNotes('Body.\n\n' + notes(inner)).notes).toBe(inner);
|
|
});
|
|
|
|
test('withAgentNotes appends a section, replaces an existing one in place, keeps a trailing badge after it, and is stable', () => {
|
|
const once = withAgentNotes('Opening.\n\n## Summary\n\nx\n', 'Fixes #1');
|
|
expect(once).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #1'));
|
|
const badge = '<!-- capy-badge:start -->\nB\n<!-- capy-badge:end -->\n';
|
|
const twice = withAgentNotes(once + '\n' + badge, 'Fixes #2');
|
|
expect(twice).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #2') + '\n' + badge);
|
|
expect(withAgentNotes(twice, 'Fixes #2')).toBe(twice);
|
|
});
|
|
|
|
test('a collapsible a person adds after the section stays after it, and one nested inside the notes stays inside', () => {
|
|
const shots = '<details><summary>Screenshots</summary>\n\n\n\n</details>\n';
|
|
const body = 'Opening.\n\n' + notes('Fixes #1') + '\n' + shots;
|
|
expect(splitAgentNotes(body).notes).toBe('Fixes #1');
|
|
expect(withAgentNotes(body, 'Fixes #2')).toBe('Opening.\n\n' + notes('Fixes #2') + '\n' + shots);
|
|
expect(placeReceipt(body, receipt(HEAD)).endsWith('</details>\n\n' + shots)).toBe(true);
|
|
const nested = 'Fixes #1\n\n<details><summary>Runs</summary>\n\nr\n\n</details>';
|
|
expect(splitAgentNotes('Opening.\n\n' + notes(nested) + '\n' + shots).notes).toBe(nested);
|
|
});
|
|
|
|
test('an open or two-line opener and an unclosed collapsible inside the notes keep text added after the section outside it', () => {
|
|
const after = '\nA note a person added.\n';
|
|
for (const opener of ['<details open><summary>Agent notes</summary>', '<details>\n<summary>Agent notes</summary>']) {
|
|
const body = `Opening.\n\n${AGENT_NOTES_MARKER}\n${opener}\n\nFixes #1\n\n</details>\n${after}`;
|
|
expect(splitAgentNotes(body).notes).toBe('Fixes #1');
|
|
expect(withAgentNotes(body, 'Fixes #2').endsWith('</details>\n' + after)).toBe(true);
|
|
}
|
|
const unclosed = 'Opening.\n\n' + notes('Fixes #1\n\n<details><summary>Runs</summary>\n\nr') + '\n' + after;
|
|
expect(splitAgentNotes(unclosed).notes).toBe('Fixes #1\n\n<details><summary>Runs</summary>\n\nr');
|
|
expect(withAgentNotes(unclosed, 'Fixes #2').endsWith('</details>\n' + after)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('placeReceipt: the receipt lives in the Agent notes section', () => {
|
|
test('a body with no receipt gets a section holding the receipt; the other notes content stays above it', () => {
|
|
const fresh = placeReceipt('Opening.\n\n## Summary\n\nx\n', receipt(HEAD));
|
|
expect(fresh).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes(renderReceiptBlock(receipt(HEAD))));
|
|
const withLines = placeReceipt('Opening.\n\n' + notes('Plan: exempt: docs-only\nFixes #14'), receipt(HEAD));
|
|
expect(splitAgentNotes(withLines).notes).toBe('Plan: exempt: docs-only\nFixes #14\n\n' + renderReceiptBlock(receipt(HEAD)).trim());
|
|
expect(placeReceipt(withLines, receipt(HEAD))).toBe(withLines);
|
|
});
|
|
|
|
test('a new receipt replaces the one in the notes, the old one joins the runs block there, and a quoted example fence in the readable part is removed', () => {
|
|
const example = 'Here is what the record looks like:\n\n' + renderReceiptBlock(receipt(OTHER, 1)) + '\nMore prose.\n';
|
|
const first = placeReceipt(example + '\n' + notes('Fixes #14'), receipt(HEAD));
|
|
const second = placeReceipt(first, receipt(OTHER));
|
|
expect(second.match(/```gstack-ship-receipt/g)).toHaveLength(1);
|
|
expect(splitAgentNotes(second).readable).toBe('Here is what the record looks like:\n\nMore prose.\n\n');
|
|
expect(extractReceipt(second).receipt!.head).toBe(OTHER);
|
|
expect(receiptRuns(second).map(r => r.head)).toEqual([HEAD]);
|
|
expect(splitAgentNotes(second).notes).toStartWith('Fixes #14\n\n```gstack-ship-receipt');
|
|
});
|
|
|
|
test('a legacy body (receipt at the top, no section) keeps its layout, so its Plan and issue lines stay readable', () => {
|
|
const legacy = renderReceiptBlock(receipt(HEAD)) + '\nOpening.\n\n## Approvals\n\nPlan: exempt: docs-only\n';
|
|
const placed = placeReceipt(legacy, receipt(OTHER));
|
|
expect(placed).toStartWith('```gstack-ship-receipt\n');
|
|
expect(placed).not.toContain(AGENT_NOTES_MARKER);
|
|
expect(extractReceipt(placed).receipt!.head).toBe(OTHER);
|
|
expect(receiptRuns(placed).map(r => r.head)).toEqual([HEAD]);
|
|
expect(parsePlanLine(placed)?.line).toBe('Plan: exempt: docs-only');
|
|
});
|
|
});
|
|
|
|
describe('which receipt binds', () => {
|
|
test('(a) a legacy receipt at the top of a body with no section binds', () => {
|
|
const body = renderReceiptBlock(receipt(HEAD)) + '\nOpening.\n\n## Summary\n\nx\n';
|
|
expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD });
|
|
});
|
|
|
|
test('(b) a receipt inside the Agent notes section binds', () => {
|
|
const body = 'Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #14\n\n' + renderReceiptBlock(receipt(HEAD)));
|
|
expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD });
|
|
});
|
|
|
|
test('(c) an example fence in the readable part never binds when the body has a section; the real one in the notes does', () => {
|
|
const body = 'Opening.\n\n' + renderReceiptBlock(receipt(OTHER)) + '\n## Summary\n\nx\n\n' + notes(renderReceiptBlock(receipt(HEAD)));
|
|
expect(extractReceipt(body).receipt!.head).toBe(HEAD);
|
|
expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD });
|
|
expect(bindReceipt(body, '7', [OTHER])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_STALE' });
|
|
const quotedOnly = 'Opening.\n\n' + renderReceiptBlock(receipt(OTHER)) + '\n' + notes('Fixes #14');
|
|
expect(bindReceipt(quotedOnly, '7', [OTHER])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' });
|
|
});
|
|
|
|
test('(d) a body with no receipt binds nothing, with or without a section', () => {
|
|
expect(bindReceipt('Opening.\n', '7', [HEAD])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' });
|
|
expect(bindReceipt('Opening.\n\n' + notes('Fixes #14'), '7', [HEAD])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' });
|
|
});
|
|
|
|
test('the census counts the receipt in the notes and ignores a quoted one in the readable part', () => {
|
|
const body = 'Opening.\n\n' + renderReceiptBlock(orderReceipt({ schema_version: 1, head: OTHER, base: 'main', run: 'quoted' })) + '\n'
|
|
+ notes(renderReceiptBlock(orderReceipt({ schema_version: 1, head: HEAD, base: 'main', run: 'real', session_kind: 'unattended' })));
|
|
const census = computeCensus('acme/widget', '2026-11-01', [{ number: 1, mergedAt: '2026-11-02T00:00:00Z', body }], null);
|
|
expect(census.runs).toEqual(['real']);
|
|
expect(census.unattended_prs).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('readers find their lines inside the Agent notes section', () => {
|
|
test('the Plan line is read from the notes first; with none there, the readable part still counts', () => {
|
|
const body = 'Opening.\n\n## Summary\n\nx\n\n' + notes('Plan: exempt: docs-only\n\n' + renderReceiptBlock(receipt(HEAD)));
|
|
expect(parsePlanLine(body)).toEqual({ kind: 'exempt', line: 'Plan: exempt: docs-only', reason: 'docs-only' });
|
|
expect(parsePlanLine('Plan: approved: r-1\n\n' + notes('Plan: exempt: docs-only'))?.kind).toBe('exempt');
|
|
const readableOnly = 'Plan: approved: r-1\n\n' + notes('Fixes #14');
|
|
expect(parsePlanLine(readableOnly)).toEqual({ kind: 'approved', line: 'Plan: approved: r-1', token: 'r-1' });
|
|
const older = 'Opening.\n\n## Approvals\n\nPlan: exempt: docs-only\n';
|
|
expect(parsePlanLine(placeReceipt(older, receipt(HEAD)))).toEqual(parsePlanLine(older));
|
|
expect(parsePlanLine('## Approvals\n\nPlan: exempt: docs-only\n')?.kind).toBe('exempt');
|
|
});
|
|
|
|
test('issue lines count inside the notes, in the readable part, and in a legacy body', () => {
|
|
const issues = { fixes: [14], refs: [30], flakes: [] };
|
|
expect(missingIssueLines('Opening.\n\n' + notes('Fixes #14\nRefs #30'), issues)).toEqual([]);
|
|
expect(missingIssueLines('## Issues\nFixes #14\n\n' + notes('Refs #30'), issues)).toEqual([]);
|
|
expect(missingIssueLines('Opening.\n\n' + notes('Fixes #14'), issues)).toEqual(['Refs #30']);
|
|
});
|
|
});
|