Skip to content

Commit 5ed71cf

Browse files
committed
feat: stage issue triage and defer setup approval
1 parent fa0d5cd commit 5ed71cf

22 files changed

Lines changed: 2341 additions & 471 deletions

.github/scripts/patch-gh-aw-lock.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { readFile, writeFile } from "node:fs/promises";
22

33
const workflows = new URL("../workflows/", import.meta.url);
44

5-
async function patchCopilotByokOutput() {
6-
const path = new URL("pi-upstream-lockstep.lock.yml", workflows);
5+
async function patchCopilotByokOutput(name) {
6+
const path = new URL(name, workflows);
77
const source = await readFile(path, "utf8");
88
const activation = source.match(/jobs:\n activation:[\s\S]*?\n agent:/)?.[0];
99
if (!activation) throw new Error("gh-aw activation job not found");
@@ -37,6 +37,18 @@ async function patchOpenCodeProvider() {
3737
await writeFile(path, patched);
3838
}
3939

40+
async function patchDisabledDetectionOutput() {
41+
const path = new URL("issue-triage.lock.yml", workflows);
42+
const source = await readFile(path, "utf8");
43+
const oldValue =
44+
"DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }}";
45+
const newValue = "DETECTION_AGENTIC_EXECUTION_OUTCOME: skipped";
46+
if (!source.includes(oldValue) && !source.includes(newValue)) {
47+
throw new Error("gh-aw disabled detection marker not found");
48+
}
49+
await writeFile(path, source.replace(oldValue, newValue));
50+
}
51+
4052
async function patchMaintenanceChoice() {
4153
const path = new URL("agentics-maintenance.yml", workflows);
4254
const source = await readFile(path, "utf8");
@@ -46,6 +58,12 @@ async function patchMaintenanceChoice() {
4658
await writeFile(path, patched);
4759
}
4860

49-
await patchCopilotByokOutput();
61+
for (const name of [
62+
"issue-triage.lock.yml",
63+
"pi-upstream-lockstep.lock.yml",
64+
]) {
65+
await patchCopilotByokOutput(name);
66+
}
5067
await patchOpenCodeProvider();
68+
await patchDisabledDetectionOutput();
5169
await patchMaintenanceChoice();
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
3+
const LABELS = new Set([
4+
"bug",
5+
"documentation",
6+
"duplicate",
7+
"enhancement",
8+
"invalid",
9+
"needs-human",
10+
"question",
11+
]);
12+
const DISPOSITIONS = new Set([
13+
"confirmed",
14+
"feature_request",
15+
"fixed_on_main",
16+
"manual_review",
17+
"needs_information",
18+
"not_reproduced",
19+
"question",
20+
"released",
21+
"security_review",
22+
]);
23+
const NEXT_STEPS = new Set([
24+
"answer",
25+
"design_review",
26+
"fix",
27+
"maintainer_review",
28+
"monitor_release",
29+
"private_security_review",
30+
"request_confirmation",
31+
"request_details",
32+
]);
33+
const ITEM_KEYS = new Set([
34+
"assessment",
35+
"disposition",
36+
"evidence",
37+
"item_number",
38+
"label",
39+
"next_step",
40+
"type",
41+
"version",
42+
]);
43+
44+
const fail = (message) => {
45+
throw new Error(`issue triage validation failed: ${message}`);
46+
};
47+
48+
const configuredTargets = () => {
49+
const values = (process.env.TRIAGE_ALLOWED_TARGETS ?? "")
50+
.split(",")
51+
.map((value) => value.trim())
52+
.filter(Boolean);
53+
if (values.some((value) => !/^\d+$/.test(value))) fail("invalid target set");
54+
return new Set(values.map(Number));
55+
};
56+
57+
const configuredLimit = () => {
58+
const value = Number(process.env.TRIAGE_MAX_ITEMS);
59+
if (!Number.isInteger(value) || value < 1 || value > 25) {
60+
fail("invalid item limit");
61+
}
62+
return value;
63+
};
64+
65+
const checkPrivateOutput = (source) => {
66+
const terms = (process.env.PRIVATE_COPY_DENYLIST ?? "")
67+
.split(/\r?\n/)
68+
.map((term) => term.trim().toLowerCase())
69+
.filter(Boolean);
70+
if (terms.length === 0) fail("private-output protection unavailable");
71+
const normalized = source.toLowerCase();
72+
if (terms.some((term) => normalized.includes(term))) {
73+
fail("protected content detected");
74+
}
75+
};
76+
77+
const issueNumber = (value) => {
78+
const normalized = String(value ?? "");
79+
if (!/^\d+$/.test(normalized)) fail("item number must be numeric");
80+
const number = Number(normalized);
81+
if (!Number.isSafeInteger(number) || number < 1) fail("invalid item number");
82+
return number;
83+
};
84+
85+
const assessment = (value) => {
86+
if (typeof value !== "string") fail("assessment must be text");
87+
const normalized = value.trim();
88+
if (!normalized || normalized.length > 1200) fail("assessment length");
89+
return normalized;
90+
};
91+
92+
const version = (value) => {
93+
if (value === undefined || value === "") return undefined;
94+
if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value)) {
95+
fail("invalid version");
96+
}
97+
return value;
98+
};
99+
100+
const evidence = (value, repository) => {
101+
if (value === undefined || value === "") return [];
102+
if (typeof value !== "string") fail("evidence must be text");
103+
const links = value.split(/\r?\n/).map((link) => link.trim()).filter(Boolean);
104+
if (links.length > 3) fail("too many evidence links");
105+
for (const link of links) {
106+
const url = URL.parse(link);
107+
const prefix = `/${repository}/`;
108+
if (!url || url.protocol !== "https:" || url.hostname !== "github.com") {
109+
fail("external evidence is not allowed");
110+
}
111+
if (!url.pathname.startsWith(prefix)) fail("wrong evidence repository");
112+
}
113+
return links;
114+
};
115+
116+
const normalizeItem = (item, repository) => {
117+
if (!item || typeof item !== "object" || Array.isArray(item)) fail("invalid item");
118+
if (Object.keys(item).some((key) => !ITEM_KEYS.has(key))) fail("unexpected field");
119+
if (!LABELS.has(item.label)) fail("unsupported label");
120+
if (!DISPOSITIONS.has(item.disposition)) fail("unsupported disposition");
121+
if (!NEXT_STEPS.has(item.next_step)) fail("unsupported next step");
122+
const normalized = {
123+
issueNumber: issueNumber(item.item_number),
124+
label: item.label,
125+
disposition: item.disposition,
126+
nextStep: item.next_step,
127+
assessment: assessment(item.assessment),
128+
evidence: evidence(item.evidence, repository),
129+
};
130+
const release = version(item.version);
131+
if (item.disposition === "released" && !release) fail("released version required");
132+
if (release) normalized.version = release;
133+
return normalized;
134+
};
135+
136+
const normalizeOutput = (source) => {
137+
checkPrivateOutput(source);
138+
const parsed = JSON.parse(source);
139+
if (!parsed || !Array.isArray(parsed.items)) fail("items array missing");
140+
const allowed = configuredTargets();
141+
const limit = configuredLimit();
142+
if (allowed.size > limit) fail("target set exceeds item limit");
143+
const unknown = parsed.items.filter(
144+
(item) => item?.type !== "stage_triage" && item?.type !== "noop",
145+
);
146+
if (unknown.length > 0) fail("unsupported output type");
147+
const staged = parsed.items.filter((item) => item?.type === "stage_triage");
148+
const repository = process.env.TRIAGE_REPOSITORY ?? "";
149+
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) fail("invalid repository");
150+
const items = staged.map((item) => normalizeItem(item, repository));
151+
const actual = new Set(items.map((item) => item.issueNumber));
152+
const exact = actual.size === items.length && actual.size === allowed.size;
153+
if (!exact || [...allowed].some((target) => !actual.has(target))) {
154+
fail("output does not match target set");
155+
}
156+
return { schema: 1, items: items.sort((a, b) => a.issueNumber - b.issueNumber) };
157+
};
158+
159+
const main = async () => {
160+
const [input, output] = process.argv.slice(2);
161+
if (!input) fail("input path required");
162+
const source = await readFile(input, "utf8");
163+
if (source.length > 1_000_000) fail("agent output is too large");
164+
const artifact = normalizeOutput(source);
165+
if (output) await writeFile(output, `${JSON.stringify(artifact, null, 2)}\n`);
166+
};
167+
168+
try {
169+
await main();
170+
} catch (error) {
171+
console.error(error instanceof Error ? error.message : "issue triage validation failed");
172+
process.exitCode = 1;
173+
}

.github/scripts/verify-workflows.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
44

55
const root = resolve(import.meta.dirname, "../..");
66
const generated = [
7+
".github/workflows/issue-triage.lock.yml",
78
".github/workflows/pi-runtime-review.lock.yml",
89
".github/workflows/pi-upstream-lockstep.lock.yml",
910
".github/workflows/agentics-maintenance.yml",

0 commit comments

Comments
 (0)