agentwrangler 0.1.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/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/outcomes/github/credential.ts — GitHub token reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads "AgentWrangler-GithubToken" from Windows Credential Manager via a
|
|
5
|
+
* PowerShell P/Invoke call. Never logs the token. Never throws.
|
|
6
|
+
*
|
|
7
|
+
* Pattern mirrors src/oauth/usage.ts: typed {ok:true,data}|{ok:false,reason}.
|
|
8
|
+
*
|
|
9
|
+
* Clean degradation (HARD constraint from plan §4):
|
|
10
|
+
* - AW_GITHUB_TOKEN environment variable → {ok:true,data:trimmed token}
|
|
11
|
+
* - Token absent / empty blob → {ok:false,reason:'github-token-not-found'}
|
|
12
|
+
* - PowerShell unavailable → {ok:false,reason:'powershell-unavailable'}
|
|
13
|
+
* - Non-Windows → {ok:false,reason:'non-windows-platform'}
|
|
14
|
+
* - Any exception → {ok:false,reason:message}
|
|
15
|
+
* NEVER throws; NEVER crashes the daemon.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
const CREDENTIAL_TARGET = "AgentWrangler-GithubToken";
|
|
20
|
+
// execFile's own timeout. Bumped from 10s: a cold `Add-Type` C# compile
|
|
21
|
+
// (csc.exe) under the CPU/IO load right after a full-corpus back-scan can
|
|
22
|
+
// legitimately exceed 10s. Node tries to kill the child at this bound.
|
|
23
|
+
const EXECFILE_TIMEOUT_MS = 15_000;
|
|
24
|
+
// Absolute settle bound. MUST exceed EXECFILE_TIMEOUT_MS so execFile's own
|
|
25
|
+
// kill gets first chance; this is the backstop for the Windows failure mode
|
|
26
|
+
// where execFile's timeout kill does NOT reap the grandchild (csc.exe) that
|
|
27
|
+
// inherited the stdout pipe — the pipe stays open, the execFile callback
|
|
28
|
+
// never fires, and without this guard the Promise (and outcomesRunning) would
|
|
29
|
+
// hang forever. See runPowerShell.
|
|
30
|
+
const HARD_TIMEOUT_MS = 20_000;
|
|
31
|
+
/**
|
|
32
|
+
* PowerShell script that reads the credential blob via advapi32.
|
|
33
|
+
* Outputs only the token on stdout (or nothing on failure).
|
|
34
|
+
* Avoids cmdkey (can't reveal blobs); uses CredRead P/Invoke instead.
|
|
35
|
+
*/
|
|
36
|
+
const PS_SCRIPT = `
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
Add-Type @"
|
|
39
|
+
using System;
|
|
40
|
+
using System.Runtime.InteropServices;
|
|
41
|
+
using System.Text;
|
|
42
|
+
public class CredMgr {
|
|
43
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
44
|
+
public struct CREDENTIAL {
|
|
45
|
+
public int Flags;
|
|
46
|
+
public int Type;
|
|
47
|
+
public string TargetName;
|
|
48
|
+
public string Comment;
|
|
49
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
50
|
+
public int CredentialBlobSize;
|
|
51
|
+
public IntPtr CredentialBlob;
|
|
52
|
+
public int Persist;
|
|
53
|
+
public int AttributeCount;
|
|
54
|
+
public IntPtr Attributes;
|
|
55
|
+
public string TargetAlias;
|
|
56
|
+
public string UserName;
|
|
57
|
+
}
|
|
58
|
+
[DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
|
59
|
+
public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
|
|
60
|
+
[DllImport("advapi32.dll", SetLastError=true)]
|
|
61
|
+
public static extern void CredFree([In] IntPtr buffer);
|
|
62
|
+
}
|
|
63
|
+
"@
|
|
64
|
+
$ptr = [IntPtr]::Zero
|
|
65
|
+
if ([CredMgr]::CredRead("${CREDENTIAL_TARGET}", 1, 0, [ref]$ptr)) {
|
|
66
|
+
$cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][CredMgr+CREDENTIAL])
|
|
67
|
+
if ($cred.CredentialBlobSize -gt 0) {
|
|
68
|
+
$bytes = New-Object byte[] $cred.CredentialBlobSize
|
|
69
|
+
[System.Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
|
|
70
|
+
[CredMgr]::CredFree($ptr)
|
|
71
|
+
$tok = [System.Text.Encoding]::Unicode.GetString($bytes).Trim()
|
|
72
|
+
if ($tok.Length -gt 0) { Write-Output $tok }
|
|
73
|
+
} else {
|
|
74
|
+
[CredMgr]::CredFree($ptr)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`.trim();
|
|
78
|
+
function defaultRawExec(script) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const child = execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: EXECFILE_TIMEOUT_MS, killSignal: "SIGKILL", windowsHide: true }, (err, stdout, stderr) => {
|
|
81
|
+
if (err) {
|
|
82
|
+
reject(new Error(`powershell exit ${err.code}: ${stderr || err.message}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
resolve(stdout.trim());
|
|
86
|
+
});
|
|
87
|
+
child.on("error", (e) => reject(e));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Run a PowerShell script and ALWAYS settle within HARD_TIMEOUT_MS.
|
|
92
|
+
*
|
|
93
|
+
* `execFile`'s own timeout is not a reliable settle guarantee on Windows: if
|
|
94
|
+
* the timeout kill fails to reap a grandchild (e.g. csc.exe spawned by
|
|
95
|
+
* Add-Type) that still holds the inherited stdout pipe, the callback never
|
|
96
|
+
* fires and the underlying Promise hangs forever — which would stick the
|
|
97
|
+
* caller's `outcomesRunning` flag `true` and silently kill all future polls.
|
|
98
|
+
* This wrapper races the raw exec against a hard timer that rejects, so the
|
|
99
|
+
* returned Promise cannot hang regardless of the child's fate.
|
|
100
|
+
*
|
|
101
|
+
* `raw` is injectable for tests.
|
|
102
|
+
*/
|
|
103
|
+
export function runPowerShell(script, raw = defaultRawExec) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let settled = false;
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
if (settled)
|
|
108
|
+
return;
|
|
109
|
+
settled = true;
|
|
110
|
+
reject(new Error(`powershell hard-timeout after ${HARD_TIMEOUT_MS}ms`));
|
|
111
|
+
}, HARD_TIMEOUT_MS);
|
|
112
|
+
raw(script).then((out) => {
|
|
113
|
+
if (settled)
|
|
114
|
+
return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve(out);
|
|
118
|
+
}, (err) => {
|
|
119
|
+
if (settled)
|
|
120
|
+
return;
|
|
121
|
+
settled = true;
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Successful token, cached for the daemon's lifetime. The first read runs at
|
|
128
|
+
// boot when the event loop is free (the caller defers it until after the
|
|
129
|
+
// synchronous back-scan); every later 10-min poll then reuses this value
|
|
130
|
+
// instead of re-spawning powershell.exe + recompiling Add-Type under load —
|
|
131
|
+
// which is precisely the condition that can make the read slow/hang. Only
|
|
132
|
+
// successful reads are cached, so a not-yet-configured token is still picked
|
|
133
|
+
// up on a later poll. (A token rotated while the daemon runs needs a restart.)
|
|
134
|
+
let cachedToken = null;
|
|
135
|
+
/**
|
|
136
|
+
* Read the GitHub PAT from Windows Credential Manager.
|
|
137
|
+
* Returns {ok:true,data:token} or {ok:false,reason} — never throws.
|
|
138
|
+
*/
|
|
139
|
+
export async function readGithubToken(reader) {
|
|
140
|
+
if (reader !== undefined)
|
|
141
|
+
return reader();
|
|
142
|
+
const envToken = process.env.AW_GITHUB_TOKEN;
|
|
143
|
+
if (typeof envToken === "string") {
|
|
144
|
+
const token = envToken.trim();
|
|
145
|
+
if (token.length > 0) {
|
|
146
|
+
return { ok: true, data: token };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cachedToken !== null) {
|
|
150
|
+
return { ok: true, data: cachedToken };
|
|
151
|
+
}
|
|
152
|
+
if (os.platform() !== "win32") {
|
|
153
|
+
return { ok: false, reason: "non-windows-platform" };
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const token = await runPowerShell(PS_SCRIPT);
|
|
157
|
+
if (token.length === 0) {
|
|
158
|
+
return { ok: false, reason: "github-token-not-found" };
|
|
159
|
+
}
|
|
160
|
+
cachedToken = token;
|
|
161
|
+
return { ok: true, data: token };
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
165
|
+
if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
|
|
166
|
+
return { ok: false, reason: "powershell-unavailable" };
|
|
167
|
+
}
|
|
168
|
+
return { ok: false, reason: `credential-read-failed: ${msg}` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Non-secret token status for the Settings UI. Mirrors the O9 usage-reader
|
|
173
|
+
* status pattern (getOAuthStatus): reports whether outcomes sync has a token
|
|
174
|
+
* and from which source, WITHOUT ever returning the token value. When no token
|
|
175
|
+
* is available the reason names the remedy (AW_GITHUB_TOKEN) so the feature is
|
|
176
|
+
* never silently dark. `reader` is injectable for tests.
|
|
177
|
+
*/
|
|
178
|
+
export async function getGithubTokenStatus(reader) {
|
|
179
|
+
if (reader === undefined) {
|
|
180
|
+
const envToken = process.env.AW_GITHUB_TOKEN;
|
|
181
|
+
if (typeof envToken === "string" && envToken.trim().length > 0) {
|
|
182
|
+
return { configured: true, source: "env" };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// reader===undefined here means no env token; delegate to the platform read.
|
|
186
|
+
// An injected reader stands in for the platform credential source.
|
|
187
|
+
const result = await readGithubToken(reader);
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
return { configured: true, source: "credential-manager" };
|
|
190
|
+
}
|
|
191
|
+
const reason = os.platform() === "win32"
|
|
192
|
+
? "outcomes sync: no GitHub token — set AW_GITHUB_TOKEN, or store AgentWrangler-GithubToken in Credential Manager"
|
|
193
|
+
: "outcomes sync: no GitHub token — set AW_GITHUB_TOKEN";
|
|
194
|
+
return { configured: false, source: null, reason };
|
|
195
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/outcomes/github/gh-cli-client.ts — GitHub reads via the `gh` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (T1 round 2 — event-loop starvation):
|
|
5
|
+
* The fetch-based GithubSyncClient does its socket reads AND its
|
|
6
|
+
* AbortSignal.timeout on the daemon's single event loop. During the outcomes
|
|
7
|
+
* pass that loop is monopolised by the synchronous transcript-harvest / SHA
|
|
8
|
+
* loop, so undici's socket callbacks are starved past the abort deadline and
|
|
9
|
+
* even a prompt GitHub reply (~0.25s) aborts at the timeout — the pass never
|
|
10
|
+
* reaches writeObservedOutcomes.
|
|
11
|
+
*
|
|
12
|
+
* GhCliClient moves every GitHub read into a `gh api` child process. That
|
|
13
|
+
* process has its OWN event loop, so the network completes independently of
|
|
14
|
+
* the jammed parent loop; the parent only reads the finished result off an
|
|
15
|
+
* OS-buffered pipe once it drains (linkSessions' setImmediate yields keep jam
|
|
16
|
+
* windows short). No undici timers on the hot loop → no starvation aborts.
|
|
17
|
+
*
|
|
18
|
+
* Same contract as GithubSyncClient (implements GithubClient):
|
|
19
|
+
* - Disabled (token ok:false) → every method returns {ok:false} and spawns
|
|
20
|
+
* ZERO processes.
|
|
21
|
+
* - Never throws; always {ok,data}|{ok:false,reason} (clean degradation).
|
|
22
|
+
* - If `gh` is not installed the spawn error degrades to {ok:false,reason}.
|
|
23
|
+
*
|
|
24
|
+
* Auth: the daemon's own credential (Windows Credential Manager PAT) is passed
|
|
25
|
+
* to `gh` via GH_TOKEN, so AgentWrangler keeps using its configured token
|
|
26
|
+
* rather than whatever account `gh auth login` happens to hold.
|
|
27
|
+
*
|
|
28
|
+
* Note vs the fetch client: no ETag/304 conditional-request caching on this
|
|
29
|
+
* path (gh does not expose it); the sync watermark still bounds volume.
|
|
30
|
+
*/
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import { aggregateCheckConclusion, normalizePRBody } from "../conclusions.js";
|
|
33
|
+
import { projectPRHeadKey, projectPRPage, } from "./client.js";
|
|
34
|
+
/**
|
|
35
|
+
* Termination bound for a single `gh` invocation. `gh` returns in ~0.25s;
|
|
36
|
+
* this generous backstop only fires if the parent loop is wedged continuously.
|
|
37
|
+
* Settlement follows the child's `close` event so cleanup never races open stdio.
|
|
38
|
+
*/
|
|
39
|
+
const GH_HARD_TIMEOUT_MS = 20_000;
|
|
40
|
+
/** gh: "... (HTTP 404)" → 404. */
|
|
41
|
+
function parseHttpStatus(stderr) {
|
|
42
|
+
const m = /\(HTTP (\d+)\)/.exec(stderr);
|
|
43
|
+
return m?.[1] !== undefined ? Number.parseInt(m[1], 10) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Build the shared runner around an injectable spawn implementation. */
|
|
46
|
+
export function createGhRunner(spawnFn = spawn) {
|
|
47
|
+
return (args, opts) => new Promise((resolve) => {
|
|
48
|
+
const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
|
|
49
|
+
if (opts.token !== null)
|
|
50
|
+
env.GH_TOKEN = opts.token;
|
|
51
|
+
let child;
|
|
52
|
+
try {
|
|
53
|
+
child = spawnFn("gh", args, { env, windowsHide: true });
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
resolve({
|
|
57
|
+
ok: false,
|
|
58
|
+
stdout: "",
|
|
59
|
+
stderr: e instanceof Error ? e.message : String(e),
|
|
60
|
+
code: null,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// FIX 2: Collect Buffer chunks and decode once on close to avoid UTF-8
|
|
65
|
+
// corruption at pipe-chunk boundaries (e.g. multibyte chars in PR titles).
|
|
66
|
+
const outChunks = [];
|
|
67
|
+
const errChunks = [];
|
|
68
|
+
let stdoutBytes = 0;
|
|
69
|
+
let termination;
|
|
70
|
+
let settled = false;
|
|
71
|
+
const settle = (r) => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
resolve(r);
|
|
77
|
+
};
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
if (termination !== undefined)
|
|
80
|
+
return;
|
|
81
|
+
termination = "timeout";
|
|
82
|
+
child.kill("SIGKILL");
|
|
83
|
+
// Do not settle here. `close` proves the child and its stdio handles are done.
|
|
84
|
+
}, opts.timeoutMs ?? GH_HARD_TIMEOUT_MS);
|
|
85
|
+
child.stdout?.on("data", (d) => {
|
|
86
|
+
if (termination !== undefined)
|
|
87
|
+
return;
|
|
88
|
+
if (opts.maxStdoutBytes !== undefined && stdoutBytes + d.byteLength > opts.maxStdoutBytes) {
|
|
89
|
+
termination = "stdout-limit";
|
|
90
|
+
child.kill("SIGKILL");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
outChunks.push(d);
|
|
94
|
+
stdoutBytes += d.byteLength;
|
|
95
|
+
});
|
|
96
|
+
child.stderr?.on("data", (d) => {
|
|
97
|
+
errChunks.push(d);
|
|
98
|
+
});
|
|
99
|
+
// ENOENT (gh not installed) and other spawn failures arrive here.
|
|
100
|
+
child.on("error", (e) => {
|
|
101
|
+
if (termination !== undefined)
|
|
102
|
+
return;
|
|
103
|
+
const stdout = Buffer.concat(outChunks).toString("utf8");
|
|
104
|
+
settle({ ok: false, stdout, stderr: e.message, code: null });
|
|
105
|
+
});
|
|
106
|
+
child.on("close", (code) => {
|
|
107
|
+
const stdout = Buffer.concat(outChunks).toString("utf8");
|
|
108
|
+
const stderr = Buffer.concat(errChunks).toString("utf8");
|
|
109
|
+
const httpStatus = parseHttpStatus(stderr);
|
|
110
|
+
const terminationMarker = termination === "timeout"
|
|
111
|
+
? "\n[gh hard-timeout]"
|
|
112
|
+
: termination === "stdout-limit"
|
|
113
|
+
? "\n[gh stdout-limit]"
|
|
114
|
+
: "";
|
|
115
|
+
settle({
|
|
116
|
+
ok: termination === undefined && code === 0,
|
|
117
|
+
stdout,
|
|
118
|
+
stderr: `${stderr}${terminationMarker}`,
|
|
119
|
+
code,
|
|
120
|
+
...(httpStatus !== undefined ? { httpStatus } : {}),
|
|
121
|
+
...(termination !== undefined ? { failure: termination } : {}),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// FIX 1: Guard stdin against EPIPE — if gh exits before reading stdin (e.g.
|
|
125
|
+
// ENOENT when gh is absent, or a fast auth failure), writing to the closed
|
|
126
|
+
// pipe would emit an unhandled 'error' event and crash the daemon.
|
|
127
|
+
child.stdin?.on("error", () => { });
|
|
128
|
+
if (opts.input !== undefined)
|
|
129
|
+
child.stdin?.write(opts.input);
|
|
130
|
+
child.stdin?.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export const defaultGhRunner = createGhRunner();
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Client
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
export class GhCliClient {
|
|
138
|
+
token;
|
|
139
|
+
runner;
|
|
140
|
+
constructor(tokenResult, runner = defaultGhRunner) {
|
|
141
|
+
this.token = tokenResult.ok ? tokenResult.data : null;
|
|
142
|
+
this.runner = runner;
|
|
143
|
+
}
|
|
144
|
+
get enabled() {
|
|
145
|
+
return this.token !== null;
|
|
146
|
+
}
|
|
147
|
+
disabledResult() {
|
|
148
|
+
return { ok: false, reason: "github-token-unset" };
|
|
149
|
+
}
|
|
150
|
+
errReason(prefix, r) {
|
|
151
|
+
if (r.httpStatus !== undefined)
|
|
152
|
+
return `${prefix}:${r.httpStatus}`;
|
|
153
|
+
if (/ENOENT|not recognized|no such file/i.test(r.stderr))
|
|
154
|
+
return `${prefix}:gh-not-found`;
|
|
155
|
+
return `${prefix}:${r.code ?? "spawn"}`;
|
|
156
|
+
}
|
|
157
|
+
run(args, input) {
|
|
158
|
+
return this.runner(args, { token: this.token, ...(input !== undefined ? { input } : {}) });
|
|
159
|
+
}
|
|
160
|
+
async listPRs(owner, repo, since) {
|
|
161
|
+
if (!this.enabled)
|
|
162
|
+
return this.disabledResult();
|
|
163
|
+
const qs = new URLSearchParams({
|
|
164
|
+
state: "all",
|
|
165
|
+
sort: "updated",
|
|
166
|
+
direction: "desc",
|
|
167
|
+
per_page: "100",
|
|
168
|
+
});
|
|
169
|
+
if (since !== undefined)
|
|
170
|
+
qs.set("since", since);
|
|
171
|
+
const r = await this.run(["api", `repos/${owner}/${repo}/pulls?${qs}`]);
|
|
172
|
+
if (!r.ok)
|
|
173
|
+
return { ok: false, reason: this.errReason("github-api-error", r) };
|
|
174
|
+
try {
|
|
175
|
+
return projectPRPage(JSON.parse(r.stdout));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return { ok: false, reason: "github-pr-payload-invalid" };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async getPRHeadKey(owner, repo, prNumber) {
|
|
182
|
+
if (!this.enabled)
|
|
183
|
+
return this.disabledResult();
|
|
184
|
+
const r = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
|
|
185
|
+
if (!r.ok)
|
|
186
|
+
return { ok: false, reason: this.errReason("github-pr-error", r) };
|
|
187
|
+
try {
|
|
188
|
+
return projectPRHeadKey(JSON.parse(r.stdout));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { ok: false, reason: "github-pr-payload-invalid" };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async getCheckConclusion(owner, repo, ref) {
|
|
195
|
+
if (!this.enabled)
|
|
196
|
+
return this.disabledResult();
|
|
197
|
+
const r = await this.run([
|
|
198
|
+
"api",
|
|
199
|
+
`repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=100`,
|
|
200
|
+
]);
|
|
201
|
+
if (!r.ok)
|
|
202
|
+
return { ok: false, reason: this.errReason("github-checks-error", r) };
|
|
203
|
+
try {
|
|
204
|
+
return aggregateCheckConclusion(JSON.parse(r.stdout));
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
reason: `github-checks-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async listPRCommits(owner, repo, prNumber) {
|
|
214
|
+
if (!this.enabled)
|
|
215
|
+
return this.disabledResult();
|
|
216
|
+
// GitHub caps commits per page at 100; PRs with >100 commits truncate the SHA set
|
|
217
|
+
// (pre-existing limitation, same on both transports).
|
|
218
|
+
const r = await this.run([
|
|
219
|
+
"api",
|
|
220
|
+
`repos/${owner}/${repo}/pulls/${prNumber}/commits?per_page=100`,
|
|
221
|
+
]);
|
|
222
|
+
if (!r.ok)
|
|
223
|
+
return { ok: false, reason: this.errReason("github-commits-error", r) };
|
|
224
|
+
try {
|
|
225
|
+
const data = JSON.parse(r.stdout);
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
data: data.map((c) => ({ sha: c.sha, message: c.commit?.message ?? "" })),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
reason: `github-commits-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async listPullsForCommit(owner, repo, sha) {
|
|
239
|
+
if (!this.enabled)
|
|
240
|
+
return this.disabledResult();
|
|
241
|
+
const r = await this.run(["api", `repos/${owner}/${repo}/commits/${sha}/pulls`]);
|
|
242
|
+
// 404 = SHA not found in this repo → no PRs (non-fatal, mirror fetch transport).
|
|
243
|
+
// Any other failure (auth/rate-limit/network) propagates so coverage isn't silently lowered.
|
|
244
|
+
if (!r.ok) {
|
|
245
|
+
if (r.httpStatus === 404)
|
|
246
|
+
return { ok: true, data: [] };
|
|
247
|
+
return { ok: false, reason: this.errReason("github-commit-pulls-error", r) };
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const data = JSON.parse(r.stdout);
|
|
251
|
+
return { ok: true, data: data.map((p) => p.number) };
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
reason: `github-commit-pulls-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async getPRBody(owner, repo, prNumber) {
|
|
261
|
+
if (!this.enabled)
|
|
262
|
+
return this.disabledResult();
|
|
263
|
+
const r = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
|
|
264
|
+
if (!r.ok)
|
|
265
|
+
return { ok: false, reason: this.errReason("github-pr-error", r) };
|
|
266
|
+
try {
|
|
267
|
+
const data = JSON.parse(r.stdout);
|
|
268
|
+
return { ok: true, data: normalizePRBody(data) };
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: `github-pr-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async getPRDiff(owner, repo, prNumber) {
|
|
278
|
+
if (!this.enabled)
|
|
279
|
+
return this.disabledResult();
|
|
280
|
+
const r = await this.run([
|
|
281
|
+
"api",
|
|
282
|
+
`repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
283
|
+
"-H",
|
|
284
|
+
"Accept: application/vnd.github.diff",
|
|
285
|
+
]);
|
|
286
|
+
if (!r.ok)
|
|
287
|
+
return { ok: false, reason: this.errReason("github-diff-error", r) };
|
|
288
|
+
return { ok: true, data: r.stdout };
|
|
289
|
+
}
|
|
290
|
+
async getReviewThreads(owner, repo, prNumber) {
|
|
291
|
+
if (!this.enabled)
|
|
292
|
+
return this.disabledResult();
|
|
293
|
+
const query = `
|
|
294
|
+
query($owner:String!, $repo:String!, $pr:Int!, $cursor:String) {
|
|
295
|
+
repository(owner:$owner, name:$repo) {
|
|
296
|
+
pullRequest(number:$pr) {
|
|
297
|
+
reviewThreads(first:100, after:$cursor) {
|
|
298
|
+
pageInfo { hasNextPage endCursor }
|
|
299
|
+
nodes { id isResolved }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
`;
|
|
305
|
+
const allThreads = [];
|
|
306
|
+
let cursor = null;
|
|
307
|
+
do {
|
|
308
|
+
const input = JSON.stringify({
|
|
309
|
+
query,
|
|
310
|
+
variables: { owner, repo, pr: prNumber, cursor },
|
|
311
|
+
});
|
|
312
|
+
const r = await this.run(["api", "graphql", "--input", "-"], input);
|
|
313
|
+
if (!r.ok)
|
|
314
|
+
return { ok: false, reason: this.errReason("github-graphql-error", r) };
|
|
315
|
+
let body;
|
|
316
|
+
try {
|
|
317
|
+
body = JSON.parse(r.stdout);
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: `github-graphql-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (body.errors !== undefined && body.errors.length > 0) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: `github-graphql-errors: ${body.errors.map((e) => e.message).join("; ")}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const reviewThreads = body.data?.repository?.pullRequest?.reviewThreads;
|
|
332
|
+
allThreads.push(...(reviewThreads?.nodes ?? []));
|
|
333
|
+
const pageInfo = reviewThreads?.pageInfo;
|
|
334
|
+
if (pageInfo?.hasNextPage !== true)
|
|
335
|
+
break;
|
|
336
|
+
cursor = pageInfo.endCursor ?? null;
|
|
337
|
+
} while (cursor !== null);
|
|
338
|
+
return { ok: true, data: allThreads };
|
|
339
|
+
}
|
|
340
|
+
}
|