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,20 @@
|
|
|
1
|
+
export async function adjudicatePacket(entries, judge) {
|
|
2
|
+
const adjudicatedEntries = [];
|
|
3
|
+
for (const entry of entries) {
|
|
4
|
+
const result = await judge({
|
|
5
|
+
findingAlias: entry.findingAlias,
|
|
6
|
+
evidenceKind: entry.evidenceKind,
|
|
7
|
+
evidence: entry.evidence,
|
|
8
|
+
});
|
|
9
|
+
if (!result.ok) {
|
|
10
|
+
return { ok: false, reason: result.reason, findingAlias: entry.findingAlias };
|
|
11
|
+
}
|
|
12
|
+
adjudicatedEntries.push({
|
|
13
|
+
findingAlias: entry.findingAlias,
|
|
14
|
+
verdict: result.verdict,
|
|
15
|
+
confidence: result.confidence,
|
|
16
|
+
rationaleTag: result.rationaleTag,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, entries: adjudicatedEntries };
|
|
20
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { openDb } from "../../db/open.js";
|
|
7
|
+
import { readAnthropicApiKey } from "../../oauth/anthropic-api-key.js";
|
|
8
|
+
import { claudeJudgeClient } from "../../oauth/judge-g2-client.js";
|
|
9
|
+
import { G2_KAPPA_GATE, G2_MIN_SEED_N } from "./kappa.js";
|
|
10
|
+
import { runG2JudgePipeline } from "./pipeline.js";
|
|
11
|
+
import { isG2JudgeOptIn } from "./store.js";
|
|
12
|
+
function defaultLiveDb() {
|
|
13
|
+
return path.join(os.homedir(), ".agentwrangler", "db.sqlite");
|
|
14
|
+
}
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
let execute = false;
|
|
17
|
+
let liveDb = defaultLiveDb();
|
|
18
|
+
let scratchState = "";
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === "--execute") {
|
|
22
|
+
execute = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (arg === "--live-db") {
|
|
26
|
+
const val = argv[i + 1];
|
|
27
|
+
if (val === undefined)
|
|
28
|
+
throw new Error("--live-db requires a value");
|
|
29
|
+
liveDb = val;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--scratch-state") {
|
|
34
|
+
const val = argv[i + 1];
|
|
35
|
+
if (val === undefined)
|
|
36
|
+
throw new Error("--scratch-state requires a value");
|
|
37
|
+
scratchState = val;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { execute, liveDb, scratchState };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load G2 artifacts from the scratch state directory produced by `evidence:prepare-g2 -- --execute`.
|
|
45
|
+
*
|
|
46
|
+
* Expected files:
|
|
47
|
+
* <scratchState>/manifest.json — Cond1CorpusManifest
|
|
48
|
+
* <scratchState>/packet.json — Cond1BlindedPacket (entries mapped to G2PacketEntry)
|
|
49
|
+
* <scratchState>/key.json — Cond1SealedKey
|
|
50
|
+
* <scratchState>/seed.json — G2SeedLabel[] (~10 hand-labeled findings for kappa calibration)
|
|
51
|
+
*/
|
|
52
|
+
function loadArtifactsFromState(scratchState) {
|
|
53
|
+
const manifestBytes = fs.readFileSync(path.join(scratchState, "manifest.json"));
|
|
54
|
+
const corpusManifestFileSha256 = createHash("sha256").update(manifestBytes).digest("hex");
|
|
55
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
56
|
+
const packet = JSON.parse(fs.readFileSync(path.join(scratchState, "packet.json"), "utf8"));
|
|
57
|
+
const packetEntries = packet.entries.map(({ findingAlias, evidenceKind, evidence }) => ({
|
|
58
|
+
findingAlias,
|
|
59
|
+
evidenceKind,
|
|
60
|
+
evidence,
|
|
61
|
+
}));
|
|
62
|
+
const key = JSON.parse(fs.readFileSync(path.join(scratchState, "key.json"), "utf8"));
|
|
63
|
+
const seed = JSON.parse(fs.readFileSync(path.join(scratchState, "seed.json"), "utf8"));
|
|
64
|
+
return { packetEntries, key, manifest, corpusManifestFileSha256, seed };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Testable core of the evidence:judge-g2 CLI.
|
|
68
|
+
*
|
|
69
|
+
* Deps are optional so tests can inject fakes without touching disk or the network.
|
|
70
|
+
* When execute=false (no --execute flag), prints a plan and returns 0 without any DB or API calls.
|
|
71
|
+
*
|
|
72
|
+
* SEC-101: rationaleTag from judge verdicts is never written to disk, DB, or output.
|
|
73
|
+
*/
|
|
74
|
+
export async function runG2JudgeCli(argv, deps = {}) {
|
|
75
|
+
const writeOut = deps.stdout ?? ((line) => process.stdout.write(`${line}\n`));
|
|
76
|
+
const writeErr = deps.stderr ?? ((line) => process.stderr.write(`${line}\n`));
|
|
77
|
+
const args = parseArgs(argv);
|
|
78
|
+
if (!args.execute) {
|
|
79
|
+
writeOut(`evidence:judge-g2 — G2 Claude-judge pipeline
|
|
80
|
+
|
|
81
|
+
What leaves your machine:
|
|
82
|
+
On --execute (with a valid seed), ALL blinded evidence excerpts in the packet are sent to the
|
|
83
|
+
Claude API (Anthropic) in one offline batch — not just the seed. The κ gate below decides whether
|
|
84
|
+
labels are persisted and scored; it does NOT reduce what is transmitted. An invalid seed is
|
|
85
|
+
rejected up front and sends nothing.
|
|
86
|
+
|
|
87
|
+
Plan:
|
|
88
|
+
1. ADJUDICATE — Judge every blinded packet finding (CONFIRMED/REJECTED + confidence).
|
|
89
|
+
Rationale text is never written to disk (SEC-101).
|
|
90
|
+
2. CALIBRATE — Compute Cohen's κ over the hand-labeled human seed (seed.json in the scratch
|
|
91
|
+
state directory) vs the judge's verdicts on those same findings.
|
|
92
|
+
3. KAPPA GATE (κ ≥ ${G2_KAPPA_GATE}, seed ≥ ${G2_MIN_SEED_N}) — If calibration fails the run is
|
|
93
|
+
BLOCKED_LOW_KAPPA: no verdicts are persisted and nothing is scored.
|
|
94
|
+
4. LABEL + SCORE — On pass, de-blind against the sealed key, persist verdicts to the live DB,
|
|
95
|
+
and score precision.
|
|
96
|
+
|
|
97
|
+
Opt-in required:
|
|
98
|
+
The key "g2_claude_judge_opt_in" in user_config must be set to "true" in the live DB.
|
|
99
|
+
This confirms consent to sending the blinded PR evidence excerpts to the Claude API.
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
npm run evidence:judge-g2 -- --execute --scratch-state <path/to/state>
|
|
103
|
+
npm run evidence:judge-g2 -- --execute --scratch-state <path> --live-db <path/to/db.sqlite>`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const db = deps.db ?? openDb(args.liveDb);
|
|
107
|
+
if (!isG2JudgeOptIn(db)) {
|
|
108
|
+
writeErr(`[judge-g2] REFUSED: g2_claude_judge_opt_in is not set to "true" in user_config.
|
|
109
|
+
|
|
110
|
+
Set it with:
|
|
111
|
+
INSERT OR REPLACE INTO user_config (key, value)
|
|
112
|
+
VALUES ('g2_claude_judge_opt_in', 'true');
|
|
113
|
+
|
|
114
|
+
This confirms consent to sending blinded PR evidence excerpts to the Claude API (Anthropic).
|
|
115
|
+
The evidence leaves your machine. No rationale text is written to disk (SEC-101).`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
const loadArtifacts = deps.loadArtifacts ?? (() => loadArtifactsFromState(args.scratchState));
|
|
119
|
+
let artifacts;
|
|
120
|
+
try {
|
|
121
|
+
artifacts = loadArtifacts();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Do NOT echo the underlying error — a JSON parse error can embed a fragment
|
|
125
|
+
// of the evidence payload (SEC-101).
|
|
126
|
+
writeErr("[judge-g2] Could not load G2 artifacts. Expected manifest.json, packet.json, key.json, " +
|
|
127
|
+
"and a hand-authored seed.json in the --scratch-state directory (from evidence:prepare-g2).");
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
let judge = deps.judge;
|
|
131
|
+
if (judge === undefined) {
|
|
132
|
+
// Prefer a raw Anthropic API key (separate rate-limit pool) over the shared
|
|
133
|
+
// Claude Code subscription OAuth token. SEC-101: log the source, never the key.
|
|
134
|
+
const keyResult = await readAnthropicApiKey();
|
|
135
|
+
if (keyResult.ok) {
|
|
136
|
+
writeOut(`[judge-g2] auth: api-key (${keyResult.source})`);
|
|
137
|
+
judge = claudeJudgeClient({ apiKey: keyResult.data });
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
writeOut(`[judge-g2] auth: oauth (subscription; no API key: ${keyResult.reason})`);
|
|
141
|
+
judge = claudeJudgeClient();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const result = await runG2JudgePipeline({
|
|
145
|
+
db,
|
|
146
|
+
judge,
|
|
147
|
+
packetEntries: artifacts.packetEntries,
|
|
148
|
+
seed: artifacts.seed,
|
|
149
|
+
key: artifacts.key,
|
|
150
|
+
manifest: artifacts.manifest,
|
|
151
|
+
corpusManifestFileSha256: artifacts.corpusManifestFileSha256,
|
|
152
|
+
}, deps.pipelineDeps);
|
|
153
|
+
if (result.status === "JUDGE_ERROR") {
|
|
154
|
+
writeErr(`[judge-g2] JUDGE_ERROR on ${result.findingAlias}: ${result.reason}`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
if (result.status === "INVALID_SEED") {
|
|
158
|
+
writeErr(`[judge-g2] INVALID_SEED: ${result.reason}
|
|
159
|
+
seedN: ${result.seedN} matchedN: ${result.matchedN} minSeedN: ${result.minSeedN}
|
|
160
|
+
No evidence was sent, no verdicts were persisted, and nothing was scored.`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
if (result.status === "BLOCKED_LOW_KAPPA") {
|
|
164
|
+
writeOut(`status: BLOCKED_LOW_KAPPA
|
|
165
|
+
kappa: ${result.kappa.toFixed(4)}
|
|
166
|
+
rawAgreement: ${result.rawAgreement.toFixed(4)}
|
|
167
|
+
seedN: ${result.seedN}
|
|
168
|
+
gate: ${result.gate}
|
|
169
|
+
|
|
170
|
+
Calibration failed (κ < ${result.gate}). Auto-labeling was skipped; no DB writes occurred.`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
// SCORED — print aggregate summary (SEC-101: no rationaleTag)
|
|
174
|
+
const { score } = result;
|
|
175
|
+
const extLines = ["E1", "E2", "E3"]
|
|
176
|
+
.map((id) => {
|
|
177
|
+
const ex = score.extractors[id];
|
|
178
|
+
if (ex === undefined)
|
|
179
|
+
return ` ${id}: not scored`;
|
|
180
|
+
const p = ex.precision !== null ? ex.precision.toFixed(4) : "null";
|
|
181
|
+
return ` ${id}: status=${ex.status} precision=${p} emittedN=${ex.emittedN}`;
|
|
182
|
+
})
|
|
183
|
+
.join("\n");
|
|
184
|
+
writeOut(`status: SCORED
|
|
185
|
+
kappa: ${result.kappa.toFixed(4)}
|
|
186
|
+
rawAgreement: ${result.rawAgreement.toFixed(4)}
|
|
187
|
+
seedN: ${result.seedN}
|
|
188
|
+
labeledN: ${result.labeledN}
|
|
189
|
+
overallStatus: ${score.overallStatus}
|
|
190
|
+
extractors:
|
|
191
|
+
${extLines}`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const invokedPath = process.argv[1];
|
|
195
|
+
if (invokedPath !== undefined &&
|
|
196
|
+
pathToFileURL(path.resolve(invokedPath)).href ===
|
|
197
|
+
pathToFileURL(fileURLToPath(import.meta.url)).href) {
|
|
198
|
+
runG2JudgeCli(process.argv.slice(2))
|
|
199
|
+
.then((code) => {
|
|
200
|
+
process.exitCode = code;
|
|
201
|
+
})
|
|
202
|
+
.catch(() => {
|
|
203
|
+
// Never surface a raw stack — it could echo evidence (SEC-101). Fail closed.
|
|
204
|
+
process.stderr.write("[judge-g2] fatal error\n");
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const G2_KAPPA_GATE = 0.6;
|
|
2
|
+
// Minimum human-seed size for a meaningful κ calibration. Below this, κ is too
|
|
3
|
+
// noisy to anchor on (a single agreeing pair yields κ=1), so the pipeline
|
|
4
|
+
// refuses rather than auto-labeling on a degenerate seed.
|
|
5
|
+
export const G2_MIN_SEED_N = 8;
|
|
6
|
+
export function cohenKappa(a, b) {
|
|
7
|
+
if (a.length !== b.length) {
|
|
8
|
+
throw new Error("Judge label arrays must have the same length.");
|
|
9
|
+
}
|
|
10
|
+
if (a.length === 0) {
|
|
11
|
+
throw new Error("Judge label arrays must not be empty.");
|
|
12
|
+
}
|
|
13
|
+
let agreements = 0;
|
|
14
|
+
let aConfirmed = 0;
|
|
15
|
+
let bConfirmed = 0;
|
|
16
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
17
|
+
if (a[index] === b[index]) {
|
|
18
|
+
agreements += 1;
|
|
19
|
+
}
|
|
20
|
+
if (a[index] === "CONFIRMED") {
|
|
21
|
+
aConfirmed += 1;
|
|
22
|
+
}
|
|
23
|
+
if (b[index] === "CONFIRMED") {
|
|
24
|
+
bConfirmed += 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const n = a.length;
|
|
28
|
+
const rawAgreement = agreements / n;
|
|
29
|
+
const aConfirmedFraction = aConfirmed / n;
|
|
30
|
+
const bConfirmedFraction = bConfirmed / n;
|
|
31
|
+
const expectedAgreement = aConfirmedFraction * bConfirmedFraction + (1 - aConfirmedFraction) * (1 - bConfirmedFraction);
|
|
32
|
+
const denominator = 1 - expectedAgreement;
|
|
33
|
+
const kappa = denominator === 0
|
|
34
|
+
? rawAgreement === 1
|
|
35
|
+
? 1
|
|
36
|
+
: 0
|
|
37
|
+
: (rawAgreement - expectedAgreement) / denominator;
|
|
38
|
+
return { n, rawAgreement, kappa };
|
|
39
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { scoreCond1 } from "../cond1/score.js";
|
|
2
|
+
import { adjudicatePacket as adjudicate } from "./adjudicate.js";
|
|
3
|
+
import { G2_KAPPA_GATE, G2_MIN_SEED_N, cohenKappa } from "./kappa.js";
|
|
4
|
+
import { persistJudgeVerdicts } from "./store.js";
|
|
5
|
+
export async function runG2JudgePipeline(input, deps = {}) {
|
|
6
|
+
// Guard the calibration anchor BEFORE sending anything to the judge: refuse to
|
|
7
|
+
// run on a degenerate seed. Every seed alias must be present in the packet (no
|
|
8
|
+
// silent drop — a stale seed reused against a re-randomized packet would
|
|
9
|
+
// otherwise "pass" on a tiny matched subset), and the seed must clear the
|
|
10
|
+
// minimum-N floor. Checking up front means an invalid seed sends no evidence.
|
|
11
|
+
const minSeedN = deps.minSeedN ?? G2_MIN_SEED_N;
|
|
12
|
+
const packetAliases = new Set(input.packetEntries.map((entry) => entry.findingAlias));
|
|
13
|
+
const matchedSeed = input.seed.filter((seed) => packetAliases.has(seed.findingAlias));
|
|
14
|
+
if (matchedSeed.length !== input.seed.length || matchedSeed.length < minSeedN) {
|
|
15
|
+
return {
|
|
16
|
+
status: "INVALID_SEED",
|
|
17
|
+
reason: matchedSeed.length !== input.seed.length
|
|
18
|
+
? "One or more seed aliases are not present in the packet (stale seed?)."
|
|
19
|
+
: `Seed size ${matchedSeed.length} is below the minimum ${minSeedN} for calibration.`,
|
|
20
|
+
seedN: input.seed.length,
|
|
21
|
+
matchedN: matchedSeed.length,
|
|
22
|
+
minSeedN,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const adjudication = await adjudicate(input.packetEntries, input.judge);
|
|
26
|
+
if (!adjudication.ok) {
|
|
27
|
+
return {
|
|
28
|
+
status: "JUDGE_ERROR",
|
|
29
|
+
reason: adjudication.reason,
|
|
30
|
+
findingAlias: adjudication.findingAlias,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const verdictByAlias = new Map(adjudication.entries.map((entry) => [entry.findingAlias, entry]));
|
|
34
|
+
const seedPairs = input.seed.flatMap((seed) => {
|
|
35
|
+
const verdict = verdictByAlias.get(seed.findingAlias);
|
|
36
|
+
return verdict === undefined ? [] : [[seed.label, verdict.verdict]];
|
|
37
|
+
});
|
|
38
|
+
const calibration = cohenKappa(seedPairs.map(([seedLabel]) => seedLabel), seedPairs.map(([, judgeLabel]) => judgeLabel));
|
|
39
|
+
if (calibration.kappa < G2_KAPPA_GATE) {
|
|
40
|
+
return {
|
|
41
|
+
status: "BLOCKED_LOW_KAPPA",
|
|
42
|
+
kappa: calibration.kappa,
|
|
43
|
+
rawAgreement: calibration.rawAgreement,
|
|
44
|
+
seedN: calibration.n,
|
|
45
|
+
gate: G2_KAPPA_GATE,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const answerByAlias = new Map(input.key.answers.map((answer) => [answer.findingAlias, answer]));
|
|
49
|
+
const labeledEntries = adjudication.entries.flatMap((entry) => {
|
|
50
|
+
const answer = answerByAlias.get(entry.findingAlias);
|
|
51
|
+
return answer === undefined ? [] : [[entry, answer]];
|
|
52
|
+
});
|
|
53
|
+
// Persist only findings the scorer counts (evidenceSufficient); redaction-failed
|
|
54
|
+
// findings score as UNCERTAIN, so writing a confident human_state for them would
|
|
55
|
+
// make the live DB disagree with the score.
|
|
56
|
+
persistJudgeVerdicts(input.db, labeledEntries
|
|
57
|
+
.filter(([, answer]) => answer.evidenceSufficient)
|
|
58
|
+
.map(([entry, answer]) => ({
|
|
59
|
+
findingId: answer.sourceFindingId,
|
|
60
|
+
verdict: entry.verdict,
|
|
61
|
+
confidence: entry.confidence,
|
|
62
|
+
})));
|
|
63
|
+
const verdicts = labeledEntries.map(([entry, answer]) => ({
|
|
64
|
+
findingAlias: entry.findingAlias,
|
|
65
|
+
verdict: !answer.evidenceSufficient
|
|
66
|
+
? "UNCERTAIN"
|
|
67
|
+
: entry.verdict === "CONFIRMED"
|
|
68
|
+
? "TRUE_POSITIVE"
|
|
69
|
+
: "FALSE_POSITIVE",
|
|
70
|
+
adjudicatorAlias: "g2-judge",
|
|
71
|
+
reasonCode: !answer.evidenceSufficient
|
|
72
|
+
? "INSUFFICIENT_EVIDENCE"
|
|
73
|
+
: entry.verdict === "CONFIRMED"
|
|
74
|
+
? "EVIDENCE_SUPPORTS"
|
|
75
|
+
: "CONTEXT_NEGATES",
|
|
76
|
+
}));
|
|
77
|
+
const scorer = deps.scorer ?? scoreCond1;
|
|
78
|
+
const score = scorer({
|
|
79
|
+
manifest: input.manifest,
|
|
80
|
+
corpusManifestFileSha256: input.corpusManifestFileSha256,
|
|
81
|
+
answers: input.key.answers,
|
|
82
|
+
verdicts,
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
status: "SCORED",
|
|
86
|
+
kappa: calibration.kappa,
|
|
87
|
+
rawAgreement: calibration.rawAgreement,
|
|
88
|
+
seedN: calibration.n,
|
|
89
|
+
labeledN: verdicts.length,
|
|
90
|
+
score,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isG2JudgeOptIn(db) {
|
|
2
|
+
const row = db
|
|
3
|
+
.prepare("SELECT value FROM user_config WHERE key = 'g2_claude_judge_opt_in'")
|
|
4
|
+
.get();
|
|
5
|
+
return row?.value === "true";
|
|
6
|
+
}
|
|
7
|
+
export function persistJudgeVerdicts(db, verdicts) {
|
|
8
|
+
const updateVerdict = db.prepare("UPDATE review_findings SET human_state = ?, confidence = ?, source = 'LLM' WHERE finding_id = ?");
|
|
9
|
+
db.transaction(() => {
|
|
10
|
+
for (const verdict of verdicts) {
|
|
11
|
+
updateVerdict.run(verdict.verdict, verdict.confidence, verdict.findingId);
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { projectPRHeadKey } from "../../outcomes/github/client.js";
|
|
2
|
+
import { defaultGhRunner, } from "../../outcomes/github/gh-cli-client.js";
|
|
3
|
+
const MAX_PAGES = 1_000;
|
|
4
|
+
export const EVIDENCE_DIFF_MAX_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
6
|
+
const SAFE_ALIAS = /^[A-Za-z0-9_.-]+$/;
|
|
7
|
+
const STRICT_UTC_RFC3339 = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/;
|
|
8
|
+
const REVIEW_THREADS_QUERY = `query($owner:String!, $repo:String!, $pr:Int!, $cursor:String) {
|
|
9
|
+
repository(owner:$owner, name:$repo) {
|
|
10
|
+
pullRequest(number:$pr) {
|
|
11
|
+
reviewThreads(first:100, after:$cursor) {
|
|
12
|
+
pageInfo { hasNextPage endCursor }
|
|
13
|
+
nodes { id isResolved }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}`;
|
|
18
|
+
function fail(reason) {
|
|
19
|
+
return { ok: false, reason };
|
|
20
|
+
}
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function isValidRepo(owner, repo) {
|
|
25
|
+
return (owner.length > 0 &&
|
|
26
|
+
repo.length > 0 &&
|
|
27
|
+
owner !== "." &&
|
|
28
|
+
owner !== ".." &&
|
|
29
|
+
repo !== "." &&
|
|
30
|
+
repo !== ".." &&
|
|
31
|
+
SAFE_SEGMENT.test(owner) &&
|
|
32
|
+
SAFE_SEGMENT.test(repo));
|
|
33
|
+
}
|
|
34
|
+
function isValidPrNumber(value) {
|
|
35
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
36
|
+
}
|
|
37
|
+
/** Fixed-width UTC key retains the approved timestamp's nanosecond ordering. */
|
|
38
|
+
function utcSortKey(value) {
|
|
39
|
+
const match = STRICT_UTC_RFC3339.exec(value);
|
|
40
|
+
const seconds = match?.[1];
|
|
41
|
+
if (seconds === undefined)
|
|
42
|
+
return null;
|
|
43
|
+
const parsed = new Date(`${seconds}Z`);
|
|
44
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 19) !== seconds) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return `${seconds}.${(match?.[2] ?? "").padEnd(9, "0")}Z`;
|
|
48
|
+
}
|
|
49
|
+
function binaryCompare(left, right) {
|
|
50
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
51
|
+
}
|
|
52
|
+
function isRunnerResult(value) {
|
|
53
|
+
if (!isRecord(value))
|
|
54
|
+
return false;
|
|
55
|
+
return (typeof value.ok === "boolean" &&
|
|
56
|
+
typeof value.stdout === "string" &&
|
|
57
|
+
typeof value.stderr === "string" &&
|
|
58
|
+
(typeof value.code === "number" || value.code === null));
|
|
59
|
+
}
|
|
60
|
+
export class EvidenceGhCliClient {
|
|
61
|
+
token;
|
|
62
|
+
runner;
|
|
63
|
+
constructor(tokenResult, runner = defaultGhRunner) {
|
|
64
|
+
this.token = tokenResult.ok ? tokenResult.data : null;
|
|
65
|
+
this.runner = runner;
|
|
66
|
+
}
|
|
67
|
+
get enabled() {
|
|
68
|
+
return this.token !== null;
|
|
69
|
+
}
|
|
70
|
+
async run(args, input, maxStdoutBytes) {
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return fail("EVIDENCE_GITHUB_DISABLED");
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = await this.runner(args, {
|
|
76
|
+
token: this.token,
|
|
77
|
+
...(input === undefined ? {} : { input }),
|
|
78
|
+
...(maxStdoutBytes === undefined ? {} : { maxStdoutBytes }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return fail("EVIDENCE_GITHUB_COMMAND_FAILED");
|
|
83
|
+
}
|
|
84
|
+
if (isRunnerResult(result) && result.failure === "stdout-limit") {
|
|
85
|
+
return fail("EVIDENCE_GITHUB_RESPONSE_TOO_LARGE");
|
|
86
|
+
}
|
|
87
|
+
if (!isRunnerResult(result) || !result.ok) {
|
|
88
|
+
return fail("EVIDENCE_GITHUB_COMMAND_FAILED");
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, data: result };
|
|
91
|
+
}
|
|
92
|
+
async getPRHeadKey(owner, repo, prNumber) {
|
|
93
|
+
if (!this.enabled)
|
|
94
|
+
return { ok: false, reason: "EVIDENCE_GITHUB_DISABLED" };
|
|
95
|
+
if (!isValidRepo(owner, repo) || !isValidPrNumber(prNumber)) {
|
|
96
|
+
return { ok: false, reason: "EVIDENCE_GITHUB_REQUEST_INVALID" };
|
|
97
|
+
}
|
|
98
|
+
const result = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
|
|
99
|
+
if (!result.ok)
|
|
100
|
+
return result;
|
|
101
|
+
let payload;
|
|
102
|
+
try {
|
|
103
|
+
payload = JSON.parse(result.data.stdout);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { ok: false, reason: "EVIDENCE_GITHUB_PAYLOAD_INVALID" };
|
|
107
|
+
}
|
|
108
|
+
const projected = projectPRHeadKey(payload);
|
|
109
|
+
return projected.ok ? projected : { ok: false, reason: "EVIDENCE_GITHUB_PAYLOAD_INVALID" };
|
|
110
|
+
}
|
|
111
|
+
async listMergedPRs(repositories, asOf) {
|
|
112
|
+
if (!this.enabled)
|
|
113
|
+
return fail("EVIDENCE_GITHUB_DISABLED");
|
|
114
|
+
const asOfKey = utcSortKey(asOf);
|
|
115
|
+
if (asOfKey === null || repositories.length === 0) {
|
|
116
|
+
return fail("EVIDENCE_GITHUB_REQUEST_INVALID");
|
|
117
|
+
}
|
|
118
|
+
const aliases = new Set();
|
|
119
|
+
for (const repository of repositories) {
|
|
120
|
+
if (!isValidRepo(repository.owner, repository.repo) ||
|
|
121
|
+
!SAFE_ALIAS.test(repository.reportAlias) ||
|
|
122
|
+
repository.reportAlias.length === 0 ||
|
|
123
|
+
aliases.has(repository.reportAlias)) {
|
|
124
|
+
return fail("EVIDENCE_GITHUB_REQUEST_INVALID");
|
|
125
|
+
}
|
|
126
|
+
aliases.add(repository.reportAlias);
|
|
127
|
+
}
|
|
128
|
+
const orderedRepos = [...repositories].sort((a, b) => binaryCompare(a.reportAlias, b.reportAlias));
|
|
129
|
+
const rows = [];
|
|
130
|
+
for (const repository of orderedRepos) {
|
|
131
|
+
const seen = new Map();
|
|
132
|
+
let completed = false;
|
|
133
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
134
|
+
const path = `repos/${repository.owner}/${repository.repo}/pulls?` +
|
|
135
|
+
`state=closed&sort=updated&direction=desc&per_page=100&page=${page}`;
|
|
136
|
+
const result = await this.run(["api", path]);
|
|
137
|
+
if (!result.ok)
|
|
138
|
+
return result;
|
|
139
|
+
let payload;
|
|
140
|
+
try {
|
|
141
|
+
payload = JSON.parse(result.data.stdout);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(payload))
|
|
147
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
148
|
+
if (payload.length > 100)
|
|
149
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
150
|
+
if (payload.length === 0) {
|
|
151
|
+
completed = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
for (const item of payload) {
|
|
155
|
+
if (!isRecord(item) ||
|
|
156
|
+
!isValidPrNumber(item.number) ||
|
|
157
|
+
!(typeof item.merged_at === "string" || item.merged_at === null)) {
|
|
158
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
159
|
+
}
|
|
160
|
+
const number = item.number;
|
|
161
|
+
const mergedAt = item.merged_at;
|
|
162
|
+
const mergedAtKey = mergedAt === null ? null : utcSortKey(mergedAt);
|
|
163
|
+
if (mergedAt !== null && mergedAtKey === null) {
|
|
164
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
165
|
+
}
|
|
166
|
+
if (seen.has(number)) {
|
|
167
|
+
return fail(seen.get(number) === mergedAt
|
|
168
|
+
? "EVIDENCE_GITHUB_DUPLICATE"
|
|
169
|
+
: "EVIDENCE_GITHUB_DUPLICATE_CONFLICT");
|
|
170
|
+
}
|
|
171
|
+
seen.set(number, mergedAt);
|
|
172
|
+
if (mergedAt !== null && mergedAtKey !== null && mergedAtKey <= asOfKey) {
|
|
173
|
+
rows.push({ reportAlias: repository.reportAlias, number, mergedAt });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!completed)
|
|
178
|
+
return fail("EVIDENCE_GITHUB_PAGINATION_LIMIT");
|
|
179
|
+
}
|
|
180
|
+
rows.sort((a, b) => binaryCompare(a.reportAlias, b.reportAlias) || a.number - b.number);
|
|
181
|
+
return { ok: true, data: rows };
|
|
182
|
+
}
|
|
183
|
+
async getPRBody(owner, repo, prNumber) {
|
|
184
|
+
if (!this.enabled)
|
|
185
|
+
return fail("EVIDENCE_GITHUB_DISABLED");
|
|
186
|
+
if (!isValidRepo(owner, repo) || !isValidPrNumber(prNumber)) {
|
|
187
|
+
return fail("EVIDENCE_GITHUB_REQUEST_INVALID");
|
|
188
|
+
}
|
|
189
|
+
const result = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
|
|
190
|
+
if (!result.ok)
|
|
191
|
+
return result;
|
|
192
|
+
let payload;
|
|
193
|
+
try {
|
|
194
|
+
payload = JSON.parse(result.data.stdout);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
198
|
+
}
|
|
199
|
+
if (!isRecord(payload) || !(typeof payload.body === "string" || payload.body === null)) {
|
|
200
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, data: payload.body };
|
|
203
|
+
}
|
|
204
|
+
async getPRDiff(owner, repo, prNumber) {
|
|
205
|
+
if (!this.enabled)
|
|
206
|
+
return fail("EVIDENCE_GITHUB_DISABLED");
|
|
207
|
+
if (!isValidRepo(owner, repo) || !isValidPrNumber(prNumber)) {
|
|
208
|
+
return fail("EVIDENCE_GITHUB_REQUEST_INVALID");
|
|
209
|
+
}
|
|
210
|
+
const result = await this.run([
|
|
211
|
+
"api",
|
|
212
|
+
`repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
213
|
+
"-H",
|
|
214
|
+
"Accept: application/vnd.github.diff",
|
|
215
|
+
], undefined, EVIDENCE_DIFF_MAX_BYTES);
|
|
216
|
+
return result.ok ? { ok: true, data: result.data.stdout } : result;
|
|
217
|
+
}
|
|
218
|
+
async getReviewThreads(owner, repo, prNumber) {
|
|
219
|
+
if (!this.enabled)
|
|
220
|
+
return fail("EVIDENCE_GITHUB_DISABLED");
|
|
221
|
+
if (!isValidRepo(owner, repo) || !isValidPrNumber(prNumber)) {
|
|
222
|
+
return fail("EVIDENCE_GITHUB_REQUEST_INVALID");
|
|
223
|
+
}
|
|
224
|
+
const threads = [];
|
|
225
|
+
const threadStates = new Map();
|
|
226
|
+
const cursors = new Set();
|
|
227
|
+
let cursor = null;
|
|
228
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
229
|
+
const input = JSON.stringify({
|
|
230
|
+
query: REVIEW_THREADS_QUERY,
|
|
231
|
+
variables: { owner, repo, pr: prNumber, cursor },
|
|
232
|
+
});
|
|
233
|
+
const result = await this.run(["api", "graphql", "--input", "-"], input);
|
|
234
|
+
if (!result.ok)
|
|
235
|
+
return result;
|
|
236
|
+
let payload;
|
|
237
|
+
try {
|
|
238
|
+
payload = JSON.parse(result.data.stdout);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
242
|
+
}
|
|
243
|
+
if (!isRecord(payload))
|
|
244
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
245
|
+
if (payload.errors !== undefined) {
|
|
246
|
+
if (!Array.isArray(payload.errors))
|
|
247
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
248
|
+
if (payload.errors.length > 0)
|
|
249
|
+
return fail("EVIDENCE_GITHUB_COMMAND_FAILED");
|
|
250
|
+
}
|
|
251
|
+
const data = payload.data;
|
|
252
|
+
if (!isRecord(data))
|
|
253
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
254
|
+
const repository = data.repository;
|
|
255
|
+
if (!isRecord(repository))
|
|
256
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
257
|
+
const pullRequest = repository.pullRequest;
|
|
258
|
+
if (!isRecord(pullRequest))
|
|
259
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
260
|
+
const reviewThreads = pullRequest.reviewThreads;
|
|
261
|
+
if (!isRecord(reviewThreads) || !Array.isArray(reviewThreads.nodes)) {
|
|
262
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
263
|
+
}
|
|
264
|
+
if (reviewThreads.nodes.length > 100)
|
|
265
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
266
|
+
const pageInfo = reviewThreads.pageInfo;
|
|
267
|
+
if (!isRecord(pageInfo) ||
|
|
268
|
+
typeof pageInfo.hasNextPage !== "boolean" ||
|
|
269
|
+
!(typeof pageInfo.endCursor === "string" || pageInfo.endCursor === null)) {
|
|
270
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
271
|
+
}
|
|
272
|
+
for (const node of reviewThreads.nodes) {
|
|
273
|
+
if (!isRecord(node) ||
|
|
274
|
+
typeof node.id !== "string" ||
|
|
275
|
+
node.id.length === 0 ||
|
|
276
|
+
typeof node.isResolved !== "boolean") {
|
|
277
|
+
return fail("EVIDENCE_GITHUB_PAYLOAD_INVALID");
|
|
278
|
+
}
|
|
279
|
+
if (threadStates.has(node.id)) {
|
|
280
|
+
return fail(threadStates.get(node.id) === node.isResolved
|
|
281
|
+
? "EVIDENCE_GITHUB_DUPLICATE"
|
|
282
|
+
: "EVIDENCE_GITHUB_DUPLICATE_CONFLICT");
|
|
283
|
+
}
|
|
284
|
+
threadStates.set(node.id, node.isResolved);
|
|
285
|
+
threads.push({ id: node.id, isResolved: node.isResolved });
|
|
286
|
+
}
|
|
287
|
+
if (!pageInfo.hasNextPage) {
|
|
288
|
+
threads.sort((a, b) => binaryCompare(a.id, b.id));
|
|
289
|
+
return { ok: true, data: threads };
|
|
290
|
+
}
|
|
291
|
+
if (pageInfo.endCursor === null ||
|
|
292
|
+
pageInfo.endCursor.length === 0 ||
|
|
293
|
+
cursors.has(pageInfo.endCursor)) {
|
|
294
|
+
return fail("EVIDENCE_GITHUB_CURSOR_INVALID");
|
|
295
|
+
}
|
|
296
|
+
cursors.add(pageInfo.endCursor);
|
|
297
|
+
cursor = pageInfo.endCursor;
|
|
298
|
+
}
|
|
299
|
+
return fail("EVIDENCE_GITHUB_PAGINATION_LIMIT");
|
|
300
|
+
}
|
|
301
|
+
}
|