@theagilemonkeys/facility 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -47
- package/package.json +3 -4
- package/src/cli.mjs +27 -176
- package/src/detect.mjs +24 -94
- package/src/doctor.mjs +54 -559
- package/src/init.mjs +92 -535
- package/templates/agents/address-review.md +53 -0
- package/templates/agents/architect.md +50 -0
- package/templates/agents/builder.md +58 -0
- package/templates/agents/ci-doctor.md +55 -0
- package/templates/agents/pr-reviewer.md +52 -0
- package/templates/agents/security-audit.md +54 -0
- package/modules/README.md +0 -35
- package/modules/ai-queryability/agents/queryability-reviewer.md +0 -35
- package/modules/ai-queryability/module.json +0 -9
- package/modules/ai-queryability/standard-section.md +0 -22
- package/modules/analytics/agents/analytics-reviewer.md +0 -32
- package/modules/analytics/commands/add-telemetry.md +0 -23
- package/modules/analytics/module.json +0 -10
- package/modules/analytics/standard-section.md +0 -23
- package/modules/database/agents/data-security-reviewer.md +0 -38
- package/modules/database/commands/new-migration.md +0 -24
- package/modules/database/guards/migration-versions.mjs +0 -41
- package/modules/database/guards/migrations-immutable.mjs +0 -57
- package/modules/database/hooks/protect-migrations.fragment.mjs +0 -10
- package/modules/database/module.json +0 -25
- package/modules/database/standard-section.md +0 -20
- package/modules/design-system/agents/design-reviewer.md +0 -37
- package/modules/design-system/module.json +0 -9
- package/modules/design-system/standard-section.md +0 -15
- package/src/add.mjs +0 -77
- package/src/platform-admin.mjs +0 -1552
- package/src/platform-config.mjs +0 -39
- package/src/platform.mjs +0 -1759
- package/src/render.mjs +0 -66
- package/templates/claude/settings.json +0 -71
- package/templates/delivery/verify.mjs +0 -157
- package/templates/doctor/resolve.mjs +0 -572
- package/templates/guards/README.md +0 -30
- package/templates/guards/_kit.mjs +0 -81
- package/templates/guards/actions-pinned.mjs +0 -38
- package/templates/guards/run.mjs +0 -111
- package/templates/guards/watchtower-locked.mjs +0 -66
- package/templates/prompts/address-review.md +0 -14
- package/templates/prompts/architect.md +0 -63
- package/templates/prompts/builder.md +0 -79
- package/templates/prompts/doctor.md +0 -69
- package/templates/prompts/review.md +0 -14
- package/templates/prompts/sweep.md +0 -75
- package/templates/receipts/collect.mjs +0 -297
- package/templates/review/finalize.mjs +0 -38
- package/templates/scripts/move-board-status.sh +0 -155
- package/templates/security/sync-findings.mjs +0 -226
- package/templates/standard/STANDARD.md +0 -141
- package/templates/standard/agents-block.md +0 -25
- package/templates/watchtower/budgets.json +0 -12
- package/templates/watchtower/canary.mjs +0 -216
- package/templates/watchtower/health.mjs +0 -148
- package/templates/watchtower/outcomes.mjs +0 -188
- package/templates/workflows/facility-address-review.yml +0 -154
- package/templates/workflows/facility-canary.yml +0 -61
- package/templates/workflows/facility-codex.yml +0 -327
- package/templates/workflows/facility-crew.yml +0 -351
- package/templates/workflows/facility-doctor.yml +0 -174
- package/templates/workflows/facility-review.yml +0 -135
- package/templates/workflows/facility-security-sweep.yml +0 -204
- package/templates/workflows/facility-watchtower.yml +0 -87
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import { pathToFileURL } from "node:url";
|
|
7
|
-
|
|
8
|
-
const MODES = new Set([
|
|
9
|
-
"architect",
|
|
10
|
-
"builder",
|
|
11
|
-
"review",
|
|
12
|
-
"address_review",
|
|
13
|
-
"ci_doctor",
|
|
14
|
-
"security_sweep",
|
|
15
|
-
"po",
|
|
16
|
-
"learning",
|
|
17
|
-
"canary",
|
|
18
|
-
"custom",
|
|
19
|
-
]);
|
|
20
|
-
const PROVIDERS = new Set(["claude_code", "codex_cli", "byo"]);
|
|
21
|
-
const MAX_RECEIPT_CHECKS = 200;
|
|
22
|
-
|
|
23
|
-
export function collectReceipt(env = process.env, now = new Date()) {
|
|
24
|
-
const provider = requiredChoice(env.FACILITY_RECEIPT_PROVIDER, PROVIDERS, "provider");
|
|
25
|
-
const mode = requiredChoice(env.FACILITY_RECEIPT_MODE, MODES, "mode");
|
|
26
|
-
const result = normalizeResult(env.FACILITY_RECEIPT_RESULT);
|
|
27
|
-
const startedAt = validDate(env.FACILITY_RECEIPT_STARTED_AT) ?? now;
|
|
28
|
-
const engine = parseEngineEvidence(env.FACILITY_RECEIPT_ENGINE_JSONL);
|
|
29
|
-
const checkEvidence = parseChecks(env.FACILITY_RECEIPT_CHECKS_FILE);
|
|
30
|
-
const target = githubTarget(env.GITHUB_EVENT_PATH);
|
|
31
|
-
const baseSha = gitCommitSha(env.FACILITY_RECEIPT_BASE_SHA);
|
|
32
|
-
const git = gitActivity(baseSha, env.GITHUB_WORKSPACE);
|
|
33
|
-
const actor = env.GITHUB_ACTOR;
|
|
34
|
-
const receipt = {
|
|
35
|
-
schema: "facility.run.v1",
|
|
36
|
-
run_id: [env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB].filter(Boolean).join(":"),
|
|
37
|
-
provider,
|
|
38
|
-
...(env.FACILITY_RECEIPT_MODEL ? { model: env.FACILITY_RECEIPT_MODEL } : {}),
|
|
39
|
-
mode,
|
|
40
|
-
result,
|
|
41
|
-
usage: {
|
|
42
|
-
input_tokens: engine.usage.input_tokens,
|
|
43
|
-
output_tokens: engine.usage.output_tokens,
|
|
44
|
-
cache_read: engine.usage.cache_read,
|
|
45
|
-
cache_write: engine.usage.cache_write,
|
|
46
|
-
cost_cents: engine.usage.cost_cents,
|
|
47
|
-
cost_source: engine.usage.cost_source,
|
|
48
|
-
},
|
|
49
|
-
activity: {
|
|
50
|
-
turns: engine.activity.turns,
|
|
51
|
-
shell_commands: engine.activity.shell_commands,
|
|
52
|
-
file_changes: Math.max(engine.activity.file_changes, git.filesChanged),
|
|
53
|
-
mcp_tool_calls: engine.activity.mcp_tool_calls,
|
|
54
|
-
web_searches: engine.activity.web_searches,
|
|
55
|
-
tool_calls: engine.activity.tool_calls,
|
|
56
|
-
errors: engine.activity.errors + (result === "failed" ? 1 : 0),
|
|
57
|
-
},
|
|
58
|
-
github: {
|
|
59
|
-
owner: env.GITHUB_REPOSITORY?.split("/")[0],
|
|
60
|
-
repo: env.GITHUB_REPOSITORY?.split("/")[1],
|
|
61
|
-
issue: target.issue,
|
|
62
|
-
pr: target.pr,
|
|
63
|
-
...(baseSha ? { base_sha: baseSha } : {}),
|
|
64
|
-
...(actor ? { actor_sha256: sha256(actor) } : {}),
|
|
65
|
-
},
|
|
66
|
-
timing: {
|
|
67
|
-
started_at: startedAt.toISOString(),
|
|
68
|
-
ended_at: now.toISOString(),
|
|
69
|
-
duration_ms: Math.max(0, now.getTime() - startedAt.getTime()),
|
|
70
|
-
},
|
|
71
|
-
events: { count: engine.eventCount, checks: checkEvidence.total },
|
|
72
|
-
checks: checkEvidence.items,
|
|
73
|
-
checks_truncated: checkEvidence.total > checkEvidence.items.length,
|
|
74
|
-
};
|
|
75
|
-
const integrity = {
|
|
76
|
-
algorithm: "sha256",
|
|
77
|
-
previous_sha256: null,
|
|
78
|
-
};
|
|
79
|
-
return {
|
|
80
|
-
...receipt,
|
|
81
|
-
integrity: {
|
|
82
|
-
...integrity,
|
|
83
|
-
payload_sha256: sha256(stableStringify({ ...receipt, integrity })),
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function writeReceipt(receipt, env = process.env) {
|
|
89
|
-
if (!verifyReceipt(receipt)) throw new Error("refusing to publish an invalid Facility receipt");
|
|
90
|
-
const output = resolve(
|
|
91
|
-
env.FACILITY_RECEIPT_OUTPUT ??
|
|
92
|
-
join(env.RUNNER_TEMP ?? ".facility-receipts", "facility-run.json"),
|
|
93
|
-
);
|
|
94
|
-
mkdirSync(dirname(output), { recursive: true });
|
|
95
|
-
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
|
|
96
|
-
if (env.GITHUB_OUTPUT) {
|
|
97
|
-
writeFileSync(
|
|
98
|
-
env.GITHUB_OUTPUT,
|
|
99
|
-
`path=${output}\nsha256=${receipt.integrity.payload_sha256}\n`,
|
|
100
|
-
{ flag: "a" },
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
if (env.GITHUB_STEP_SUMMARY) {
|
|
104
|
-
writeFileSync(
|
|
105
|
-
env.GITHUB_STEP_SUMMARY,
|
|
106
|
-
[
|
|
107
|
-
"### Facility agent receipt",
|
|
108
|
-
"",
|
|
109
|
-
`- Mode: \`${receipt.mode}\``,
|
|
110
|
-
`- Result: \`${receipt.result}\``,
|
|
111
|
-
`- Receipt SHA-256: \`${receipt.integrity.payload_sha256}\``,
|
|
112
|
-
"- Integrity: SHA-256 (verify the separate GitHub Actions attestation when enabled)",
|
|
113
|
-
"",
|
|
114
|
-
].join("\n"),
|
|
115
|
-
{ flag: "a" },
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
return output;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function verifyReceipt(receipt) {
|
|
122
|
-
if (!receipt || typeof receipt !== "object" || receipt.schema !== "facility.run.v1") return false;
|
|
123
|
-
const { integrity, ...content } = receipt;
|
|
124
|
-
const digestable = {
|
|
125
|
-
...content,
|
|
126
|
-
integrity: {
|
|
127
|
-
algorithm: integrity?.algorithm,
|
|
128
|
-
previous_sha256: integrity?.previous_sha256 ?? null,
|
|
129
|
-
...(integrity?.attestation ? { attestation: integrity.attestation } : {}),
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
return (
|
|
133
|
-
integrity?.algorithm === "sha256" &&
|
|
134
|
-
typeof integrity.payload_sha256 === "string" &&
|
|
135
|
-
integrity.payload_sha256 === sha256(stableStringify(digestable))
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function parseEngineEvidence(path) {
|
|
140
|
-
const evidence = {
|
|
141
|
-
usage: {
|
|
142
|
-
input_tokens: 0,
|
|
143
|
-
output_tokens: 0,
|
|
144
|
-
cache_read: 0,
|
|
145
|
-
cache_write: 0,
|
|
146
|
-
cost_cents: null,
|
|
147
|
-
cost_source: "unavailable",
|
|
148
|
-
},
|
|
149
|
-
activity: {
|
|
150
|
-
turns: 0,
|
|
151
|
-
shell_commands: 0,
|
|
152
|
-
file_changes: 0,
|
|
153
|
-
mcp_tool_calls: 0,
|
|
154
|
-
web_searches: 0,
|
|
155
|
-
tool_calls: 0,
|
|
156
|
-
errors: 0,
|
|
157
|
-
},
|
|
158
|
-
eventCount: 0,
|
|
159
|
-
};
|
|
160
|
-
if (!path || !existsSync(path)) return evidence;
|
|
161
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean)) {
|
|
162
|
-
let event;
|
|
163
|
-
try {
|
|
164
|
-
event = JSON.parse(line);
|
|
165
|
-
} catch {
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
evidence.eventCount += 1;
|
|
169
|
-
const type = String(event.type ?? event.event ?? "");
|
|
170
|
-
const itemType = String(event.item?.type ?? event.name ?? "");
|
|
171
|
-
if (type === "turn.completed" || type === "assistant" || type === "assistant_message") {
|
|
172
|
-
evidence.activity.turns += 1;
|
|
173
|
-
}
|
|
174
|
-
if (type === "error" || type === "turn.failed" || itemType === "error") {
|
|
175
|
-
evidence.activity.errors += 1;
|
|
176
|
-
}
|
|
177
|
-
if (itemType === "command_execution") evidence.activity.shell_commands += 1;
|
|
178
|
-
if (itemType === "file_change") evidence.activity.file_changes += 1;
|
|
179
|
-
if (itemType === "mcp_tool_call") evidence.activity.mcp_tool_calls += 1;
|
|
180
|
-
if (itemType === "web_search") evidence.activity.web_searches += 1;
|
|
181
|
-
if (itemType) evidence.activity.tool_calls += 1;
|
|
182
|
-
mergeUsage(evidence.usage, event.usage ?? event.response?.usage ?? event.item?.usage);
|
|
183
|
-
}
|
|
184
|
-
return evidence;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function mergeUsage(usage, value) {
|
|
188
|
-
if (!value || typeof value !== "object") return;
|
|
189
|
-
usage.input_tokens = integer(value.input_tokens ?? value.inputTokens, usage.input_tokens);
|
|
190
|
-
usage.output_tokens = integer(value.output_tokens ?? value.outputTokens, usage.output_tokens);
|
|
191
|
-
usage.cache_read = integer(value.cache_read ?? value.cached_input_tokens, usage.cache_read);
|
|
192
|
-
usage.cache_write = integer(value.cache_write, usage.cache_write);
|
|
193
|
-
const cents =
|
|
194
|
-
value.cost_cents ??
|
|
195
|
-
(typeof value.cost_usd === "number" ? Math.round(value.cost_usd * 100) : undefined);
|
|
196
|
-
if (typeof cents === "number" && Number.isFinite(cents) && cents >= 0) {
|
|
197
|
-
usage.cost_cents = Math.round(cents);
|
|
198
|
-
usage.cost_source = "engine";
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function parseChecks(path) {
|
|
203
|
-
if (!path || !existsSync(path)) return { items: [], total: 0 };
|
|
204
|
-
const checks = [];
|
|
205
|
-
for (const line of readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean)) {
|
|
206
|
-
try {
|
|
207
|
-
const value = JSON.parse(line);
|
|
208
|
-
const status = ["passed", "failed", "skipped", "unknown"].includes(value.status)
|
|
209
|
-
? value.status
|
|
210
|
-
: "unknown";
|
|
211
|
-
checks.push({
|
|
212
|
-
name: String(value.name ?? value.command ?? "unnamed check"),
|
|
213
|
-
status,
|
|
214
|
-
source: value.self_reported === false ? "platform" : "agent",
|
|
215
|
-
...(Number.isInteger(value.exit_code) ? { exit_code: value.exit_code } : {}),
|
|
216
|
-
});
|
|
217
|
-
} catch {}
|
|
218
|
-
}
|
|
219
|
-
return { items: checks.slice(0, MAX_RECEIPT_CHECKS), total: checks.length };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function gitActivity(baseSha, worktree) {
|
|
223
|
-
if (!baseSha || !worktree) return { filesChanged: 0 };
|
|
224
|
-
try {
|
|
225
|
-
const files = execFileSync(
|
|
226
|
-
"git",
|
|
227
|
-
["-C", worktree, "diff", "--name-only", `${baseSha}...HEAD`],
|
|
228
|
-
{ encoding: "utf8" },
|
|
229
|
-
);
|
|
230
|
-
return { filesChanged: files.split(/\r?\n/).filter(Boolean).length };
|
|
231
|
-
} catch {
|
|
232
|
-
return { filesChanged: 0 };
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function githubTarget(path) {
|
|
237
|
-
if (!path || !existsSync(path)) return {};
|
|
238
|
-
try {
|
|
239
|
-
const event = JSON.parse(readFileSync(path, "utf8"));
|
|
240
|
-
if (Number.isInteger(event.pull_request?.number)) return { pr: event.pull_request.number };
|
|
241
|
-
if (Number.isInteger(event.issue?.number)) {
|
|
242
|
-
return event.issue.pull_request ? { pr: event.issue.number } : { issue: event.issue.number };
|
|
243
|
-
}
|
|
244
|
-
} catch {}
|
|
245
|
-
return {};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function normalizeResult(value) {
|
|
249
|
-
if (value === "success" || value === "succeeded") return "succeeded";
|
|
250
|
-
if (value === "cancelled" || value === "canceled") return "canceled";
|
|
251
|
-
if (value === "skipped") return "skipped";
|
|
252
|
-
return "failed";
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function gitCommitSha(value) {
|
|
256
|
-
return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value)
|
|
257
|
-
? value.toLowerCase()
|
|
258
|
-
: undefined;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function requiredChoice(value, choices, name) {
|
|
262
|
-
if (!value || !choices.has(value))
|
|
263
|
-
throw new Error(`FACILITY_RECEIPT_${name.toUpperCase()} is invalid`);
|
|
264
|
-
return value;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function validDate(value) {
|
|
268
|
-
if (!value) return null;
|
|
269
|
-
const date = new Date(value);
|
|
270
|
-
return Number.isNaN(date.getTime()) ? null : date;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function integer(value, fallback) {
|
|
274
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function sha256(value) {
|
|
278
|
-
return createHash("sha256").update(value).digest("hex");
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function stableStringify(value) {
|
|
282
|
-
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
283
|
-
if (value && typeof value === "object") {
|
|
284
|
-
return `{${Object.entries(value)
|
|
285
|
-
.filter(([, inner]) => inner !== undefined)
|
|
286
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
287
|
-
.map(([key, inner]) => `${JSON.stringify(key)}:${stableStringify(inner)}`)
|
|
288
|
-
.join(",")}}`;
|
|
289
|
-
}
|
|
290
|
-
return JSON.stringify(value);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
294
|
-
const receipt = collectReceipt();
|
|
295
|
-
const output = writeReceipt(receipt);
|
|
296
|
-
console.log(`Facility agent receipt written to ${output}`);
|
|
297
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Generated by facility — make every successful automated review observable.
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
|
|
5
|
-
const repo = required("GITHUB_REPOSITORY");
|
|
6
|
-
const prNumber = required("FACILITY_PR_NUMBER");
|
|
7
|
-
const startedAt = required("FACILITY_REVIEW_STARTED_AT");
|
|
8
|
-
const inlineComments = ghJson([
|
|
9
|
-
"api",
|
|
10
|
-
`repos/${repo}/pulls/${prNumber}/comments?per_page=100`,
|
|
11
|
-
]).filter((comment) => comment.created_at >= startedAt && comment.user?.type === "Bot");
|
|
12
|
-
|
|
13
|
-
if (inlineComments.length > 0) {
|
|
14
|
-
console.log(`Facility review produced ${inlineComments.length} inline finding(s).`);
|
|
15
|
-
process.exit(0);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const marker = `<!-- facility-review:${process.env.GITHUB_RUN_ID ?? "unknown"} -->`;
|
|
19
|
-
const body = [
|
|
20
|
-
"Facility automated review completed with no actionable findings.",
|
|
21
|
-
"",
|
|
22
|
-
"This is advisory evidence, not approval; the required human review and merge gate remain in force.",
|
|
23
|
-
"",
|
|
24
|
-
marker,
|
|
25
|
-
].join("\n");
|
|
26
|
-
execFileSync("gh", ["api", `repos/${repo}/issues/${prNumber}/comments`, "-f", `body=${body}`], {
|
|
27
|
-
stdio: "inherit",
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
function ghJson(args) {
|
|
31
|
-
return JSON.parse(execFileSync("gh", args, { encoding: "utf8" }));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function required(name) {
|
|
35
|
-
const value = process.env[name];
|
|
36
|
-
if (!value) throw new Error(`${name} is required`);
|
|
37
|
-
return value;
|
|
38
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# Generated by facility — https://github.com/theam/facility
|
|
4
|
-
#
|
|
5
|
-
# Reflect the invoked agent on the org GitHub Project board by setting the
|
|
6
|
-
# triggering issue's "Status" field:
|
|
7
|
-
#
|
|
8
|
-
# /architect -> Planning (only advances from empty/Backlog; never backward)
|
|
9
|
-
# /builder -> In Progress (invoking /builder is acceptance of the plan)
|
|
10
|
-
# verified delivery -> In Review
|
|
11
|
-
#
|
|
12
|
-
# Forward-only: the board is never moved backward (an issue already in
|
|
13
|
-
# "In Review"/"Done" is left alone). The script no-ops cleanly when the
|
|
14
|
-
# PROJECTS_PAT secret is missing, so the crew keeps working before the token
|
|
15
|
-
# is configured.
|
|
16
|
-
#
|
|
17
|
-
# Requires: gh, jq. Auth via GH_TOKEN (a PAT/App token with org Projects
|
|
18
|
-
# read+write; the default GITHUB_TOKEN cannot write org-level Projects v2).
|
|
19
|
-
#
|
|
20
|
-
# Env: MODE (builder|architect|review), ISSUE_NODE_ID, ORG, PROJECT_NUMBER.
|
|
21
|
-
# Optional env to adapt to your board's column names:
|
|
22
|
-
# STATUS_COLUMNS ordered, comma-separated (default below)
|
|
23
|
-
# ARCHITECT_STATUS target column for /architect (default "Planning")
|
|
24
|
-
# BUILDER_STATUS target column for /builder (default "In Progress")
|
|
25
|
-
# REVIEW_STATUS target column after verified delivery (default "In Review")
|
|
26
|
-
set -euo pipefail
|
|
27
|
-
|
|
28
|
-
: "${MODE:?MODE required}"
|
|
29
|
-
: "${ISSUE_NODE_ID:?ISSUE_NODE_ID required}"
|
|
30
|
-
: "${ORG:?ORG required}"
|
|
31
|
-
: "${PROJECT_NUMBER:?PROJECT_NUMBER required}"
|
|
32
|
-
STATUS_COLUMNS="${STATUS_COLUMNS:-Backlog,Planning,Ready,In Progress,In Review,Done}"
|
|
33
|
-
ARCHITECT_STATUS="${ARCHITECT_STATUS:-Planning}"
|
|
34
|
-
BUILDER_STATUS="${BUILDER_STATUS:-In Progress}"
|
|
35
|
-
REVIEW_STATUS="${REVIEW_STATUS:-In Review}"
|
|
36
|
-
|
|
37
|
-
if [ -z "${GH_TOKEN:-}" ]; then
|
|
38
|
-
echo "::notice::PROJECTS_PAT not configured — skipping Project #${PROJECT_NUMBER} status move."
|
|
39
|
-
exit 0
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
|
-
case "$MODE" in
|
|
43
|
-
architect) TARGET="$ARCHITECT_STATUS" ;;
|
|
44
|
-
builder) TARGET="$BUILDER_STATUS" ;;
|
|
45
|
-
review) TARGET="$REVIEW_STATUS" ;;
|
|
46
|
-
*) echo "::notice::mode '$MODE' has no status mapping — skipping."; exit 0 ;;
|
|
47
|
-
esac
|
|
48
|
-
|
|
49
|
-
# gh api graphql with a small retry — the GitHub API occasionally returns a
|
|
50
|
-
# transient 401/5xx that succeeds on a second attempt.
|
|
51
|
-
gql() {
|
|
52
|
-
local attempt out err
|
|
53
|
-
err=$(mktemp)
|
|
54
|
-
for attempt in 1 2 3; do
|
|
55
|
-
if out=$(gh api graphql "$@" 2>"$err"); then printf '%s' "$out"; rm -f "$err"; return 0; fi
|
|
56
|
-
sleep $((attempt * 2))
|
|
57
|
-
done
|
|
58
|
-
echo "::warning::GraphQL call failed after 3 attempts: $(tail -n1 "$err")" >&2
|
|
59
|
-
rm -f "$err"
|
|
60
|
-
return 1
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
# --- Project + Status single-select field metadata ---
|
|
64
|
-
proj_json=$(gql -F number="$PROJECT_NUMBER" -f org="$ORG" -f query='
|
|
65
|
-
query($org:String!, $number:Int!) {
|
|
66
|
-
organization(login:$org) {
|
|
67
|
-
projectV2(number:$number) {
|
|
68
|
-
id
|
|
69
|
-
field(name:"Status") {
|
|
70
|
-
... on ProjectV2SingleSelectField { id options { id name } }
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}')
|
|
75
|
-
|
|
76
|
-
PROJECT_ID=$(jq -r '.data.organization.projectV2.id // ""' <<<"$proj_json")
|
|
77
|
-
STATUS_FIELD_ID=$(jq -r '.data.organization.projectV2.field.id // ""' <<<"$proj_json")
|
|
78
|
-
TARGET_OPTION_ID=$(jq -r --arg n "$TARGET" \
|
|
79
|
-
'.data.organization.projectV2.field.options[]? | select(.name==$n) | .id' <<<"$proj_json")
|
|
80
|
-
|
|
81
|
-
if [ -z "$PROJECT_ID" ] || [ -z "$STATUS_FIELD_ID" ] || [ -z "$TARGET_OPTION_ID" ]; then
|
|
82
|
-
echo "::warning::Could not resolve Project #${PROJECT_NUMBER} / Status option '$TARGET' — skipping."
|
|
83
|
-
exit 0
|
|
84
|
-
fi
|
|
85
|
-
|
|
86
|
-
# --- Find the item for this issue on the project (add it if missing) ---
|
|
87
|
-
item_json=$(gql -f issue="$ISSUE_NODE_ID" -f query='
|
|
88
|
-
query($issue:ID!) {
|
|
89
|
-
node(id:$issue) {
|
|
90
|
-
... on Issue {
|
|
91
|
-
projectItems(first:50) {
|
|
92
|
-
nodes {
|
|
93
|
-
id
|
|
94
|
-
project { id }
|
|
95
|
-
fieldValueByName(name:"Status") {
|
|
96
|
-
... on ProjectV2ItemFieldSingleSelectValue { name }
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}')
|
|
103
|
-
|
|
104
|
-
ITEM_ID=$(jq -r --arg p "$PROJECT_ID" \
|
|
105
|
-
'.data.node.projectItems.nodes[]? | select(.project.id==$p) | .id' <<<"$item_json")
|
|
106
|
-
CURRENT=$(jq -r --arg p "$PROJECT_ID" \
|
|
107
|
-
'.data.node.projectItems.nodes[]? | select(.project.id==$p) | .fieldValueByName.name // ""' <<<"$item_json")
|
|
108
|
-
|
|
109
|
-
if [ -z "$ITEM_ID" ]; then
|
|
110
|
-
ITEM_ID=$(gql -f project="$PROJECT_ID" -f content="$ISSUE_NODE_ID" -f query='
|
|
111
|
-
mutation($project:ID!, $content:ID!) {
|
|
112
|
-
addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } }
|
|
113
|
-
}' --jq '.data.addProjectV2ItemById.item.id')
|
|
114
|
-
CURRENT=""
|
|
115
|
-
echo "Added issue to Project #${PROJECT_NUMBER} (item ${ITEM_ID})."
|
|
116
|
-
fi
|
|
117
|
-
|
|
118
|
-
# --- Forward-only guard (board column order from STATUS_COLUMNS) ---
|
|
119
|
-
rank() {
|
|
120
|
-
local name="$1" i=0 col
|
|
121
|
-
if [ -z "$name" ]; then echo 0; return; fi
|
|
122
|
-
IFS=',' read -ra cols <<< "$STATUS_COLUMNS"
|
|
123
|
-
for col in "${cols[@]}"; do
|
|
124
|
-
if [ "$col" = "$name" ]; then echo "$i"; return; fi
|
|
125
|
-
i=$((i + 1))
|
|
126
|
-
done
|
|
127
|
-
echo 0
|
|
128
|
-
}
|
|
129
|
-
cur_rank=$(rank "$CURRENT")
|
|
130
|
-
tgt_rank=$(rank "$TARGET")
|
|
131
|
-
|
|
132
|
-
# /architect only kicks off planning; if work has already moved past the first
|
|
133
|
-
# column, leave the board as-is (a follow-up question shouldn't drag it back).
|
|
134
|
-
if [ "$MODE" = "architect" ] && [ "$cur_rank" -gt 0 ]; then
|
|
135
|
-
echo "::notice::Issue already at '${CURRENT}' — /architect leaves the board unchanged."
|
|
136
|
-
exit 0
|
|
137
|
-
fi
|
|
138
|
-
# Never move backward or sideways.
|
|
139
|
-
if [ -n "$CURRENT" ] && [ "$tgt_rank" -le "$cur_rank" ]; then
|
|
140
|
-
echo "::notice::Issue at '${CURRENT}'; not moving to '${TARGET}' (forward-only)."
|
|
141
|
-
exit 0
|
|
142
|
-
fi
|
|
143
|
-
|
|
144
|
-
# --- Apply ---
|
|
145
|
-
gql \
|
|
146
|
-
-f project="$PROJECT_ID" -f item="$ITEM_ID" -f field="$STATUS_FIELD_ID" -f option="$TARGET_OPTION_ID" \
|
|
147
|
-
-f query='
|
|
148
|
-
mutation($project:ID!, $item:ID!, $field:ID!, $option:String!) {
|
|
149
|
-
updateProjectV2ItemFieldValue(input:{
|
|
150
|
-
projectId:$project, itemId:$item, fieldId:$field,
|
|
151
|
-
value:{ singleSelectOptionId:$option }
|
|
152
|
-
}) { projectV2Item { id } }
|
|
153
|
-
}' >/dev/null
|
|
154
|
-
|
|
155
|
-
echo "Moved issue to '${TARGET}' on Project #${PROJECT_NUMBER} (was '${CURRENT:-unset}')."
|