@uchibeke/gallivant 0.0.0-stage → 0.2.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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/gh.mjs
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// The only module that talks to GitHub, and it does so through the `gh` CLI so there is no HTTP client, no token
|
|
2
|
+
// parsing and no pagination code to own. `exec` is injectable so the tick can be tested against a fake.
|
|
3
|
+
//
|
|
4
|
+
// Logins: GitHub renders bots as `name[bot]` in REST and as `login` + `is_bot` in gh's GraphQL output. Everything
|
|
5
|
+
// is normalised to the bare login plus a `bot` flag before any comparison.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
import { ROUND_MARKER, NUDGE_MARKER, RESET_MARKER, READY_LABEL, NEEDS_LABEL, labelAliases } from "./public-format.mjs";
|
|
9
|
+
import { assertPublicText, assertNoNul } from "./public_output.mjs";
|
|
10
|
+
import { normalizePublicBody, parseBlock, renderBlock } from "./block.mjs";
|
|
11
|
+
import { repositoryName, parseJson } from "./learning_data.mjs";
|
|
12
|
+
import { redact } from "./execution_io.mjs";
|
|
13
|
+
export { ROUND_MARKER, NUDGE_MARKER, RESET_MARKER, READY_LABEL, NEEDS_LABEL };
|
|
14
|
+
|
|
15
|
+
const GH_TIMEOUT_MS = 60_000;
|
|
16
|
+
// A hundred long, JSON-escaped bodies plus metadata can exceed one MiB.
|
|
17
|
+
const LEARNING_PAGE_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
/** The scan cap for a PR diff. A diff this size or larger cannot be shown to the output scan whole. */
|
|
19
|
+
export const MAX_DIFF_BYTES = 16 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export const normalizeLogin = login => String(login || "").replace(/^app\//, "").replace(/\[bot\]$/, "").toLowerCase();
|
|
22
|
+
|
|
23
|
+
/** One shape for authors from either API: { login, bot }. */
|
|
24
|
+
export const actor = a => {
|
|
25
|
+
if (!a) return { login: "", bot: false };
|
|
26
|
+
const raw = a.login ?? a.user?.login ?? "";
|
|
27
|
+
const bot = a.is_bot === true || a.type === "Bot" || a.user?.type === "Bot" || /\[bot\]$/.test(raw) || /^app\//.test(raw);
|
|
28
|
+
return { login: normalizeLogin(raw), bot };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** owner/name from any GitHub PR URL, as GitHub spells it (lowercased, since GitHub treats names case-insensitively). */
|
|
32
|
+
export function repoFromUrl(url) {
|
|
33
|
+
const m = String(url || "").match(/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i);
|
|
34
|
+
return m ? { repo: `${m[1]}/${m[2]}`.toLowerCase(), number: Number(m[3]) } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* owner/repo from a git remote URL, lowercased, or null when the URL is not a GitHub remote we understand.
|
|
39
|
+
* The supported forms are the ones `gh repo clone` and `git clone` produce: https, scp-style ssh, and ssh://.
|
|
40
|
+
* Callers compare the result for equality, never by substring, so `acme/widget-fork` never matches `acme/widget`.
|
|
41
|
+
*/
|
|
42
|
+
export function repoFromRemote(url) {
|
|
43
|
+
const s = String(url || "").trim().replace(/\/+$/, "");
|
|
44
|
+
const m = s.match(/^(?:https?:\/\/(?:[^@/]*@)?github\.com\/|(?:ssh:\/\/)?(?:[^@/]+@)?github\.com[:/])([\w-][\w.-]*)\/([\w-][\w.-]*?)(?:\.git)?$/i);
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
return `${m[1]}/${m[2]}`.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** GitHub owner/repo identity ignores letter case without rewriting either spelling. */
|
|
50
|
+
export const repositoryMatches = (left, right) => typeof left === "string" && left.length > 0 && typeof right === "string" && left.toLowerCase() === right.toLowerCase();
|
|
51
|
+
|
|
52
|
+
/** Whether a remote URL points at exactly this `owner/repo`. */
|
|
53
|
+
export const remoteMatches = (url, nameWithOwner) => {
|
|
54
|
+
const parsed = repoFromRemote(url);
|
|
55
|
+
return repositoryMatches(parsed, nameWithOwner);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Reviews and comments come from REST (numeric ids, `user.type`, `since` filters); the rest from `gh pr view`.
|
|
59
|
+
const PR_FIELDS = "number,url,title,state,isDraft,mergeable,headRefOid,headRefName,isCrossRepository,author,createdAt,updatedAt,statusCheckRollup,labels,body,baseRefName,mergeCommit,mergedAt";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `gh` the daemon itself runs.
|
|
63
|
+
*
|
|
64
|
+
* `credential` is a function, resolved per call rather than captured once, for the reason `makeGit` gives: a
|
|
65
|
+
* long-lived daemon outlives a token, and an operator who re-authenticates under it should not have to restart it.
|
|
66
|
+
* It defaults to resolving nothing, in which case `gh` falls back to its own stores exactly as before, which is
|
|
67
|
+
* right for the daemon (not sandboxed) and is what keeps every existing test working unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function makeGh({ credential = async () => null, exec = execFileAsync, log = null } = {}) {
|
|
70
|
+
const failureDetails = new WeakMap();
|
|
71
|
+
const env = async () => {
|
|
72
|
+
const token = (await credential())?.token;
|
|
73
|
+
return token ? { ...process.env, GH_TOKEN: token } : process.env;
|
|
74
|
+
};
|
|
75
|
+
const LABELS = [[READY_LABEL, "5319e7", "Checks green, reviewer clear, completion receipt accepted"], [NEEDS_LABEL, "b60205", "Work paused; operator attention required"]];
|
|
76
|
+
const run = async (args, { cwd, timeout = GH_TIMEOUT_MS, signal } = {}) => {
|
|
77
|
+
signal?.throwIfAborted();
|
|
78
|
+
const resolvedEnv = await env();
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
try {
|
|
81
|
+
const { stdout } = await exec("gh", args, { cwd, env: resolvedEnv, timeout, maxBuffer: 32 * 1024 * 1024, ...(signal ? { signal } : {}) });
|
|
82
|
+
return stdout;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error && typeof error === 'object') {
|
|
85
|
+
const secrets=Object.entries(resolvedEnv).filter(([key,value])=>/(?:TOKEN|SECRET|KEY|PASSWORD)/i.test(key) && typeof value==='string' && value.length).map(([,value])=>value);
|
|
86
|
+
failureDetails.set(error,redact(String(error.stderr || error.message || 'GitHub CLI failed'),secrets).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,' ').slice(0,1200));
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const json = async (args, opts) => { const out = await run(args, opts); return out.trim() ? JSON.parse(out) : null; };
|
|
92
|
+
// `--paginate` alone concatenates one JSON array per page; `--slurp` wraps them so the output parses as one array.
|
|
93
|
+
const pages = path => json(["api", path, "--paginate", "--slurp"]).then(r => (r || []).flat());
|
|
94
|
+
|
|
95
|
+
// Remove every historical spelling separately: one absent alias must not hide a forbidden/network failure on
|
|
96
|
+
// another. Existing retry counters therefore cover the whole migration and never dispatch behind a stale label.
|
|
97
|
+
const removeOneLabel = (repo, n, label, { lenient = false } = {}) => run(["pr", "edit", String(n), "--repo", repo, "--remove-label", label])
|
|
98
|
+
.catch(e => {
|
|
99
|
+
const message = String(e.message || "");
|
|
100
|
+
const missingLabel = /\blabel\b.*\bnot found\b|\bdoes not have\b.*\blabel\b|\bno label\b/i.test(message)
|
|
101
|
+
|| (message.toLowerCase().includes(label.toLowerCase()) && /\bnot found\b/i.test(message));
|
|
102
|
+
if (!lenient && !missingLabel) throw e;
|
|
103
|
+
});
|
|
104
|
+
const removeLabel = async (repo, n, label, opts) => {
|
|
105
|
+
let result;
|
|
106
|
+
for (const alias of labelAliases(label)) result = await removeOneLabel(repo, n, alias, opts);
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
const labelsEnsured = new Set();
|
|
110
|
+
const ensureLabels = async repo => {
|
|
111
|
+
if (labelsEnsured.has(repo)) return;
|
|
112
|
+
for (const [name, color, desc] of LABELS) await run(["label", "create", name, "--repo", repo, "--color", color, "--description", desc, "--force"]);
|
|
113
|
+
labelsEnsured.add(repo);
|
|
114
|
+
};
|
|
115
|
+
const publicWrite = (args, value, field) => { assertPublicText(value, field); return run(args); };
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
run,
|
|
119
|
+
json,
|
|
120
|
+
// Learning uses explicit bounded pages rather than the live review cursor or
|
|
121
|
+
// --paginate. Never follow an evidence URL or a server-supplied next link.
|
|
122
|
+
learningRepository: (repo, { signal } = {}) => { repositoryName(repo); return json(["api", "--hostname", "github.com", `repos/${repo}`], { signal }).then(r => { if (!Number.isSafeInteger(r.id) || r.id < 1) throw new Error("invalid immutable repository ID"); return { host: "github.com", id: String(r.id), visibility: r.visibility ?? (r.private ? "private" : "public") }; }); },
|
|
123
|
+
async learningPage(repo, kind, { pr = null, page = 1, signal } = {}) {
|
|
124
|
+
repositoryName(repo);
|
|
125
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(repo) || !["pulls", "reviews", "inline", "comments"].includes(kind) || !Number.isSafeInteger(page) || page < 1 || page > 10_000 || (kind !== "pulls" && (!Number.isSafeInteger(pr) || pr < 1))) throw new Error("invalid bounded learning page");
|
|
126
|
+
const resource = kind === "pulls" ? `pulls?state=all&sort=updated&direction=desc` : kind === "reviews" ? `pulls/${pr}/reviews?` : kind === "inline" ? `pulls/${pr}/comments?` : `issues/${pr}/comments?`;
|
|
127
|
+
const path = `repos/${repo}/${resource}${kind === "pulls" ? "&" : ""}per_page=100&page=${page}`;
|
|
128
|
+
let response;
|
|
129
|
+
try {
|
|
130
|
+
response = (await exec("gh", ["api", "--hostname", "github.com", path, "--include"], { env: await env(), timeout: GH_TIMEOUT_MS, maxBuffer: LEARNING_PAGE_BYTES, ...(signal ? { signal } : {}) })).stdout;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const headers = String(e.stdout || "");
|
|
133
|
+
if (/HTTP[^\n]*\b(?:429|403)\b/.test(headers) && (/retry-after:/i.test(headers) || /x-ratelimit-remaining:\s*0/i.test(headers))) {
|
|
134
|
+
const seconds = Number(headers.match(/retry-after:\s*(\d+)/i)?.[1]);
|
|
135
|
+
const reset = Number(headers.match(/x-ratelimit-reset:\s*(\d+)/i)?.[1]);
|
|
136
|
+
return { items: [], complete: false, rateLimited: true, retryAt: Number.isFinite(seconds) && seconds > 0 ? Date.now() + seconds * 1000 : Number.isFinite(reset) && reset > 0 ? reset * 1000 : Date.now() + 60_000 };
|
|
137
|
+
}
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
const split = response.search(/\r?\n\r?\n/);
|
|
141
|
+
if (split < 0) throw new Error("learning transport did not return response headers");
|
|
142
|
+
const headers = response.slice(0, split), items = parseJson(response.slice(split).trim(), LEARNING_PAGE_BYTES);
|
|
143
|
+
if (!Array.isArray(items) || items.length > 100) throw new Error("invalid bounded learning page response");
|
|
144
|
+
return { items, complete: !/rel="next"/.test(headers), rateLimited: false, retryAt: null };
|
|
145
|
+
},
|
|
146
|
+
/** Login of the token in use. */
|
|
147
|
+
user: options => json(["api", "user", "--jq", "{login: .login}"], options).then(u => u.login),
|
|
148
|
+
authStatus: () => run(["auth", "status"]).then(() => true).catch(() => false),
|
|
149
|
+
/** owner/name and default branch for a checkout. */
|
|
150
|
+
repoInfo: dir => json(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"], { cwd: dir })
|
|
151
|
+
.then(r => ({ nameWithOwner: r.nameWithOwner, defaultBranch: r.defaultBranchRef?.name })),
|
|
152
|
+
markReady: (repo, n) => run(["pr", "ready", String(n), "--repo", repo]),
|
|
153
|
+
/** Only generated recovery/receipt transport may migrate before prose is corrected. Read fresh, and derive
|
|
154
|
+
* the replacement here: callers cannot use this path to smuggle an arbitrary unguarded body to GitHub. */
|
|
155
|
+
setRecoveryMetadata: async (repo, n, identity, { taskId = null } = {}) => {
|
|
156
|
+
const pr = await json(["pr", "view", String(n), "--repo", repo, "--json", PR_FIELDS]);
|
|
157
|
+
if (pr.isCrossRepository || identity.branch !== pr.headRefName) throw new Error("recovery identity branch differs from the same-repository PR head");
|
|
158
|
+
const validated = parseBlock(renderBlock(identity));
|
|
159
|
+
if (!validated.block) throw new Error(`invalid recovery identity: ${validated.reason}`);
|
|
160
|
+
assertNoNul(pr.body, "PR body");
|
|
161
|
+
const body = normalizePublicBody(pr.body, { identity: validated.block, taskId });
|
|
162
|
+
assertNoNul(body, "PR body");
|
|
163
|
+
if (body !== pr.body) await run(["pr", "edit", String(n), "--repo", repo, "--body", body]);
|
|
164
|
+
return { ...pr, body };
|
|
165
|
+
},
|
|
166
|
+
prView: (repo, n) => json(["pr", "view", String(n), "--repo", repo, "--json", PR_FIELDS]),
|
|
167
|
+
refSha: (repo, ref) => json(["api", `repos/${repo}/commits/${encodeURIComponent(ref)}`]).then(r => r.sha),
|
|
168
|
+
containsCommit: (repo, ref, sha) => json(["api", `repos/${repo}/compare/${encodeURIComponent(sha)}...${encodeURIComponent(ref)}`])
|
|
169
|
+
.then(r => ["ahead", "identical"].includes(r.status) && r.merge_base_commit?.sha === sha),
|
|
170
|
+
// Queue approval must distinguish an absent file from an API failure or an unsupported content response.
|
|
171
|
+
fileAtStrict: async (repo, path, ref) => {
|
|
172
|
+
let r;
|
|
173
|
+
try { r = await json(["api", `repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`]); }
|
|
174
|
+
catch (e) { if (/HTTP 404/.test(e.stderr || e.message)) return null; throw e; }
|
|
175
|
+
if (r?.type !== "file" || r.encoding !== "base64" || typeof r.content !== "string") throw new Error(`cannot read ${repo}:${path} at ${ref} as a text file`);
|
|
176
|
+
return Buffer.from(r.content, "base64").toString("utf8");
|
|
177
|
+
},
|
|
178
|
+
prListByHead: (repo, branch) => json(["pr", "list", "--repo", repo, "--head", branch, "--state", "open", "--json", "number,url,title,state,isDraft,body,headRefName,headRefOid,author,isCrossRepository,createdAt"]),
|
|
179
|
+
prListMine: repo => json(["pr", "list", "--repo", repo, "--author", "@me", "--state", "open", "--limit", "100", "--json", "number,url,title,state,isDraft,body,headRefName,headRefOid,author,isCrossRepository,createdAt,baseRefName"]),
|
|
180
|
+
/** Reviews, REST, so `id` matches `pull_request_review_id` on inline comments. */
|
|
181
|
+
reviews: (repo, n) => pages(`repos/${repo}/pulls/${n}/reviews?per_page=100`),
|
|
182
|
+
/** Inline review comments, REST, filtered server-side by `since`. */
|
|
183
|
+
inlineComments: (repo, n, since) => pages(`repos/${repo}/pulls/${n}/comments?per_page=100${since ? `&since=${encodeURIComponent(since)}` : ""}`),
|
|
184
|
+
/** Issue-level comments, REST, optionally filtered server-side by `since`. */
|
|
185
|
+
issueComments: (repo, n, since) => pages(`repos/${repo}/issues/${n}/comments?per_page=100${since ? `&since=${encodeURIComponent(since)}` : ""}`),
|
|
186
|
+
/** Committer date of one commit; one small object instead of the PR's whole commit list. */
|
|
187
|
+
commitDate: (repo, sha) => json(["api", `repos/${repo}/commits/${sha}`, "--jq", "{date: .commit.committer.date}"]).then(r => r?.date ?? null),
|
|
188
|
+
/** One file's text at a ref, or null when it is not there. */
|
|
189
|
+
fileAt: (repo, path, ref) => json(["api", `repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, "--jq", "{content: .content, encoding: .encoding}"])
|
|
190
|
+
.then(r => (r?.content && r.encoding === "base64" ? Buffer.from(r.content, "base64").toString("utf8") : null)).catch(error => {
|
|
191
|
+
if (error.code === "NO_CREDENTIAL") throw error;
|
|
192
|
+
return null;
|
|
193
|
+
}),
|
|
194
|
+
/**
|
|
195
|
+
* The PR diff for the completion gate's output scan. Failures are thrown, never swallowed: an empty string
|
|
196
|
+
* passes the blocked-pattern scan trivially, so a diff that could not be read must not read as "clean". The
|
|
197
|
+
* gate treats the throw as no decision yet and tries again on the next tick.
|
|
198
|
+
*
|
|
199
|
+
* A diff over the cap is one of those failures. Handing the scan the first megabyte and letting it answer
|
|
200
|
+
* "clean" is the same fail-open as handing it an empty string: a blocked pattern past the cutoff is never
|
|
201
|
+
* looked at. The cap stays, because a diff of any size in memory is its own problem; what it produces is a
|
|
202
|
+
* refusal to decide rather than a partial answer presented as a whole one.
|
|
203
|
+
*/
|
|
204
|
+
prDiff: (repo, n) => run(["pr", "diff", String(n), "--repo", repo]).then(d => {
|
|
205
|
+
// At the cap, not only over it: a diff of exactly the cap is indistinguishable from one the cap cut short.
|
|
206
|
+
const bytes = Buffer.byteLength(d);
|
|
207
|
+
if (bytes >= MAX_DIFF_BYTES) throw new Error(`diff is ${bytes} bytes, at or over the ${MAX_DIFF_BYTES}-byte scan cap; refusing an incomplete output scan`);
|
|
208
|
+
return d;
|
|
209
|
+
}),
|
|
210
|
+
comment: (repo, n, body) => publicWrite(["pr", "comment", String(n), "--repo", repo, "--body", body], body, "comment"),
|
|
211
|
+
/** Create the two labels once per repo; `--force` makes it idempotent. Called when a repo is first watched. */
|
|
212
|
+
ensureLabels,
|
|
213
|
+
addLabel: async (repo, n, label) => {
|
|
214
|
+
const [canonical, ...legacy] = labelAliases(label);
|
|
215
|
+
assertPublicText(canonical, "label");
|
|
216
|
+
if (canonical === READY_LABEL || canonical === NEEDS_LABEL) await ensureLabels(repo);
|
|
217
|
+
for (const alias of legacy) await removeOneLabel(repo, n, alias);
|
|
218
|
+
// A ready state must not coexist with either spelling of the stopped state, or vice versa.
|
|
219
|
+
if (canonical === READY_LABEL || canonical === NEEDS_LABEL) await removeLabel(repo, n, canonical === READY_LABEL ? NEEDS_LABEL : READY_LABEL);
|
|
220
|
+
return run(["pr", "edit", String(n), "--repo", repo, "--add-label", canonical]);
|
|
221
|
+
},
|
|
222
|
+
/**
|
|
223
|
+
* Take a label off, ignoring one that is not there, which is the ordinary case and not a failure. Everything
|
|
224
|
+
* else throws, so a caller that has to know whether the write reached GitHub at all (to retry it, or to decline
|
|
225
|
+
* the step that depended on it) learns that by default.
|
|
226
|
+
*
|
|
227
|
+
* The default used to be the other way round: `strict` was opt-in, and the lenient behaviour swallowed a
|
|
228
|
+
* rate-limited or forbidden write as if the label had merely been absent. Two callers of this one write then
|
|
229
|
+
* disagreed, one passing `{ strict: true }` and one omitting it, which is this codebase's recurring defect shape.
|
|
230
|
+
* A caller that omits an option must get the safe behaviour, so the option is now `lenient` and the dangerous
|
|
231
|
+
* half is the one you have to ask for by name. There are no callers of `lenient` in the tree; it exists so a
|
|
232
|
+
* future one states its intent at the site rather than by omission.
|
|
233
|
+
*/
|
|
234
|
+
removeLabel,
|
|
235
|
+
setBody: (repo, n, body) => publicWrite(["pr", "edit", String(n), "--repo", repo, "--body", body], body, "PR body"),
|
|
236
|
+
setTitle: (repo, n, title) => publicWrite(["pr", "edit", String(n), "--repo", repo, "--title", title], title, "PR title"),
|
|
237
|
+
async publishResultPr({ repo, branch, base, title, body }) {
|
|
238
|
+
assertPublicText(title, "PR title"); assertPublicText(body, "PR body");
|
|
239
|
+
const prs = await json(["pr", "list", "--repo", repo, "--head", branch, "--state", "open", "--json", "number,url,headRefName,isCrossRepository,baseRefName"]);
|
|
240
|
+
if ((prs || []).length > 1) throw new Error("ambiguous result PR ownership; reconcile the recorded branch before publishing");
|
|
241
|
+
const existing = prs?.[0];
|
|
242
|
+
if (existing) {
|
|
243
|
+
if (existing.isCrossRepository || existing.headRefName !== branch || existing.baseRefName !== base) throw new Error("result PR differs from task branch/base ownership");
|
|
244
|
+
await run(["pr", "edit", String(existing.number), "--repo", repo, "--title", title, "--body", body]);
|
|
245
|
+
return existing;
|
|
246
|
+
}
|
|
247
|
+
await run(["pr", "create", "--repo", repo, "--base", base, "--head", branch, "--title", title, "--body", body]);
|
|
248
|
+
const created = await json(["pr", "list", "--repo", repo, "--head", branch, "--state", "open", "--json", "number,url,headRefName,isCrossRepository,baseRefName"]);
|
|
249
|
+
if (created?.length !== 1 || created[0].isCrossRepository || created[0].headRefName !== branch || created[0].baseRefName !== base) throw new Error("PR creation acceptance unresolved; reconcile the owned branch before retrying");
|
|
250
|
+
return created[0];
|
|
251
|
+
},
|
|
252
|
+
rulesets: repo => json(["api", `repos/${repo}/rulesets`]).then(r => r || []),
|
|
253
|
+
/** The default branch of a repo by name, for checks that must name the branch they verify. */
|
|
254
|
+
repoDefaultBranch: repo => json(["repo", "view", repo, "--json", "defaultBranchRef"]).then(r => r?.defaultBranchRef?.name ?? null),
|
|
255
|
+
/**
|
|
256
|
+
* Whether one branch exists on the remote. Asked before anything is cloned, because a base that is not there is
|
|
257
|
+
* something `gh pr create --base` discovers after a session has implemented the whole brief and spent the money.
|
|
258
|
+
*
|
|
259
|
+
* A completed HTTP404 is absent. Other failures retain a bounded, credential-
|
|
260
|
+
* redacted diagnostic so transport/CLI failures cannot masquerade as absence.
|
|
261
|
+
*/
|
|
262
|
+
branchExists: (repo, branch) => json(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`, "--jq", "{name: .name}"]).then(r => r?.name === branch).catch(error => {
|
|
263
|
+
// Credential selection failed before gh ran. Keep its specific remedy;
|
|
264
|
+
// this is neither an absent branch nor a malformed GitHub response.
|
|
265
|
+
if (error.code === "NO_CREDENTIAL") throw error;
|
|
266
|
+
const detail=failureDetails.get(error);
|
|
267
|
+
if (detail && /\bHTTP 404\b/.test(detail)) return false;
|
|
268
|
+
throw Object.assign(new Error(`GitHub branch lookup failed for ${repo}:${branch}: ${detail || 'invalid GitHub branch response'}`),{code:'GH_BRANCH_LOOKUP'});
|
|
269
|
+
}),
|
|
270
|
+
tokenScopes: async () => exec("gh", ["api", "user", "-i"], { env: await env(), timeout: GH_TIMEOUT_MS }).then(({ stdout }) => (stdout.match(/^x-oauth-scopes:\s*(.*)$/mi) || [])[1] ?? null),
|
|
271
|
+
/**
|
|
272
|
+
* Whether the token in use can actually push to one repo, from `repos/<r>` and its `permissions` block.
|
|
273
|
+
* A token that resolves but cannot write is the same defect one step later, so this asks GitHub rather than
|
|
274
|
+
* inferring it from a scope string: a fine-grained PAT carries no scopes at all and would otherwise read as
|
|
275
|
+
* "no permissions", and an org that has not approved the PAT answers here too.
|
|
276
|
+
*/
|
|
277
|
+
repoPermissions: repo => json(["api", `repos/${repo}`, "--jq", "{push: .permissions.push, admin: .permissions.admin, archived: .archived}"]),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** execFile as a promise, with a timeout, and an error that carries stdout, stderr and the exit code. */
|
|
282
|
+
export function execFileAsync(cmd, args, opts = {}) {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, encoding: "utf8", ...opts }, (err, stdout, stderr) => {
|
|
285
|
+
if (err) return reject(Object.assign(new Error(`${cmd} ${args.slice(0, 3).join(" ")}: ${String(stderr || "").trim() || err.message}`), { stdout, stderr, code: err.code, killed: err.killed }));
|
|
286
|
+
resolve({ stdout, stderr });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|