|
| 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 | +} |
0 commit comments