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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- Intake refinement now files the split admission recommends. When admission holds an issue at the lanes App's `status: needs-decision` only because some, not all, of its done-when items ask for what an unattended lane run cannot perform, and the triage raised no flag, the `refine-issue` run judges the split. When the model returns exactly the held item numbers, `apply-refinement` files one child issue as the lanes App, labeled `needs-human` and born triaged, that holds those items as written, with their indented sub-lines, and links back. It then replaces them in the issue body with one line linking the child, lifts `status: needs-decision`, and triages the issue once more so admission reads the rest. The refinement marker keeps it to once per issue, and a retry reuses the child it filed. A proposal that names any other items, drafts criteria or asks questions alongside a split is refused before anything is written.
5 changes: 5 additions & 0 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ test("intake-triage.yml: a thin issue's refinement runs its model with no write
assert.equal(token.with.activity, "apply-refinement");
assert.equal(token.with["issue-number"], "${{ format('{0}', github.event.issue.number || inputs.issue-number) }}");
assert.equal(step(apply, "apply").run, 'node ".base/$SCRIPT" --apply');
// Select and apply read the admission rules and web hosts the split uses
// from the default branch, and the model must return the split field.
assert.equal(step(select, "select").env.BASE_PATH, ".base");
assert.equal(step(apply, "apply").env.BASE_PATH, ".base");
assert.match(prompt, /"required":\["exact","outcome","criteria","dependencies","related","questions","sources","split"\]/);
});

test("intake-triage.yml: the model may not apply good first issue or help wanted, and is told the verdict lines", () => {
Expand Down
43 changes: 30 additions & 13 deletions .github/scripts/intake-triage/admit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
// lane run cannot do (unrunnableCriteria below): a run on a named host or
// OS, a paid eval, a run repeated N times, a measurement over time, or a
// page on a host outside resolve-issue's web-access domains. The refusal
// recommends splitting those items into a needs-human child issue;
// recommends splitting those items into a needs-human child issue, which
// refine.mjs files when some items are left for the lane;
// - 10: check-wip-cap's status: over the queued cap, or with the lane paused
// over the WIP or held cap (from wip.pause-from), the issue gets
// `admission-deferred`, which work-unblock's hourly sweep admits once there
Expand Down Expand Up @@ -140,17 +141,39 @@ const excerpt = (text) => {
return plain.length > 80 ? `${plain.slice(0, 77)}...` : plain;
};

// [{line, text, more}] for each task-list item, in order: its index among
// the body's `\n`-split lines, its text after the task-list prefix, and the
// indexes of the lines after it indented deeper than its marker (a sub-list
// or wrapped text, part of the item). Item n (1-based) is entry n - 1. Pure.
export const taskItems = (body, taskList) => {
const lines = String(body ?? "").split("\n");
const indent = (line) => line.length - line.trimStart().length;
const items = [];
lines.forEach((line, index) => {
const trimmed = line.trimStart();
if (!trimmed.startsWith(taskList)) {
return;
}
const more = [];
for (let next = index + 1; next < lines.length && lines[next].trim() !== "" && indent(lines[next]) > indent(line); next += 1) {
more.push(next);
}
items.push({ line: index, text: trimmed.slice(taskList.length), more });
});
return items;
};

// The admission comment's text for a refusal, after ADMIT_MARKER.
export const refusalText = (reasons) =>
`Not admitted to the issue lane: ${reasons}. A recorded decision does not change these rules (\`.github/actions/resolve-config/admission-rules.json\`); a person who wants it worked adds \`agent-ready\`.`;

// [{n, kind, text}] for each task-list item (1-based n) the lane cannot
// perform. `webHosts` is resolve-issue's web-access domains: "any" skips the
// URL check, an absent list allows only GitHub and local hosts, and a `*.`
// entry allows that domain's subdomains. Pure.
export function unrunnableCriteria(body, taskList, webHosts) {
const allowed = webHosts === "any" ? null : allowedBy([...GITHUB_HOSTS, ...(Array.isArray(webHosts) ? webHosts : [])].map((h) => h.toLowerCase()));
const items = String(body ?? "")
.split("\n")
.map((line) => line.trimStart())
.filter((line) => line.startsWith(taskList))
.map((line) => line.slice(taskList.length));
const items = taskItems(body, taskList).map(({ text }) => text);
const found = [];
items.forEach((text, index) => {
let kind = UNRUNNABLE.find(([, pattern]) => pattern.test(text))?.[0];
Expand Down Expand Up @@ -394,13 +417,7 @@ export async function admit({ github, repository, number, verdict, rules, humans
await github("DELETE", `${base}/labels/${encodeURIComponent(READY)}`);
await github("POST", `${base}/labels`, { labels: [NEEDS_DECISION] });
await undefer();
await sayWhy(
github,
repository,
base,
comments,
`Not admitted to the issue lane: ${reasons}. A recorded decision does not change these rules (\`.github/actions/resolve-config/admission-rules.json\`); a person who wants it worked adds \`agent-ready\`.`,
);
await sayWhy(github, repository, base, comments, refusalText(reasons));
log(`admit: #${number} refused (${reasons})`);
return { outcome: "refused", reasons: decision.reasons };
}
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/intake-triage/admit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ describe("decide", () => {
});

test("a rendered refinement criterion that reads a page outside the lane's web hosts is still refused", () => {
const output = { exact: true, outcome: "Match the spec.", criteria: ["The page at https://example.org/spec says nineteen."], dependencies: [], related: [], questions: [], sources: [] };
const output = { exact: true, outcome: "Match the spec.", criteria: ["The page at https://example.org/spec says nineteen."], dependencies: [], related: [], questions: [], sources: [], split: [] };
const body = render(validateOutput(output, new Set()));
assert.ok(!body.includes("https://"), "the rendered comment breaks the URL");
const result = decide(facts({ issue: thin, comments: [refinement(app, body)] }));
Expand Down
Loading
Loading