ramstack 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 +21 -0
- package/README.md +44 -0
- package/agents/comment-sicko.md +32 -0
- package/agents/poteto-agent.md +9 -0
- package/cli/rstack.mjs +539 -0
- package/package.json +29 -0
- package/skills/architect/README.md +25 -0
- package/skills/architect/SKILL.md +83 -0
- package/skills/architect/agents/openai.yaml +5 -0
- package/skills/architect/references/design-red-flags.md +33 -0
- package/skills/architect/references/rationale-template.md +35 -0
- package/skills/architect/references/runner-prompt.md +20 -0
- package/skills/arena/README.md +21 -0
- package/skills/arena/SKILL.md +71 -0
- package/skills/arena/agents/openai.yaml +5 -0
- package/skills/automate-me/README.md +21 -0
- package/skills/automate-me/SKILL.md +109 -0
- package/skills/automate-me/agents/openai.yaml +5 -0
- package/skills/blast-radius/README.md +21 -0
- package/skills/blast-radius/SKILL.md +50 -0
- package/skills/blast-radius/agents/openai.yaml +5 -0
- package/skills/bro/README.md +16 -0
- package/skills/bro/SKILL.md +7 -0
- package/skills/bro/agents/openai.yaml +5 -0
- package/skills/create-verification-skill/README.md +22 -0
- package/skills/create-verification-skill/SKILL.md +44 -0
- package/skills/create-verification-skill/agents/openai.yaml +5 -0
- package/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
- package/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
- package/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
- package/skills/figure-it-out/README.md +22 -0
- package/skills/figure-it-out/SKILL.md +55 -0
- package/skills/figure-it-out/agents/openai.yaml +5 -0
- package/skills/how/README.md +25 -0
- package/skills/how/SKILL.md +134 -0
- package/skills/how/agents/openai.yaml +3 -0
- package/skills/how/references/critic-prompt.md +59 -0
- package/skills/how/references/critique-rubric.md +58 -0
- package/skills/how/references/explainer-prompt.md +55 -0
- package/skills/how/references/explorer-prompt.md +52 -0
- package/skills/interrogate/README.md +21 -0
- package/skills/interrogate/SKILL.md +112 -0
- package/skills/interrogate/agents/openai.yaml +5 -0
- package/skills/interrogate/references/code-quality-review.md +47 -0
- package/skills/interrogate/references/lead-judgment.md +58 -0
- package/skills/interrogate/references/reviewer-prompt.md +72 -0
- package/skills/interrogate/references/rubric.md +77 -0
- package/skills/maintain-verification-skill/README.md +21 -0
- package/skills/maintain-verification-skill/SKILL.md +39 -0
- package/skills/maintain-verification-skill/agents/openai.yaml +5 -0
- package/skills/no-comments/README.md +22 -0
- package/skills/no-comments/SKILL.md +24 -0
- package/skills/no-comments/agents/openai.yaml +5 -0
- package/skills/poteto-mode/README.md +26 -0
- package/skills/poteto-mode/SKILL.md +140 -0
- package/skills/poteto-mode/agents/openai.yaml +5 -0
- package/skills/poteto-mode/playbooks/authoring-a-skill.md +13 -0
- package/skills/poteto-mode/playbooks/autonomous-run.md +13 -0
- package/skills/poteto-mode/playbooks/autopilot-full.md +13 -0
- package/skills/poteto-mode/playbooks/autopilot-stack.md +16 -0
- package/skills/poteto-mode/playbooks/babysit.md +27 -0
- package/skills/poteto-mode/playbooks/bug-fix.md +17 -0
- package/skills/poteto-mode/playbooks/eval.md +27 -0
- package/skills/poteto-mode/playbooks/feature.md +21 -0
- package/skills/poteto-mode/playbooks/hillclimb.md +21 -0
- package/skills/poteto-mode/playbooks/investigation.md +14 -0
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +3 -0
- package/skills/poteto-mode/playbooks/opening-a-pr.md +11 -0
- package/skills/poteto-mode/playbooks/orchestrate.md +113 -0
- package/skills/poteto-mode/playbooks/pause-safely.md +10 -0
- package/skills/poteto-mode/playbooks/perf-issue.md +24 -0
- package/skills/poteto-mode/playbooks/prototype.md +14 -0
- package/skills/poteto-mode/playbooks/refactoring.md +16 -0
- package/skills/poteto-mode/playbooks/runtime-forensics.md +11 -0
- package/skills/poteto-mode/playbooks/session-pickup.md +13 -0
- package/skills/poteto-mode/playbooks/shipping.md +20 -0
- package/skills/poteto-mode/playbooks/trace-forensics.md +14 -0
- package/skills/poteto-mode/playbooks/visual-parity.md +11 -0
- package/skills/poteto-mode/playbooks/worktree-cleanup.md +14 -0
- package/skills/poteto-mode/references/bugbot-triage.md +142 -0
- package/skills/poteto-mode/references/plan.md +105 -0
- package/skills/poteto-mode/scripts/bootstrap.ts +62 -0
- package/skills/poteto-mode/scripts/bun.lock +67 -0
- package/skills/poteto-mode/scripts/orch/orch.test.ts +634 -0
- package/skills/poteto-mode/scripts/orch/orch.ts +578 -0
- package/skills/poteto-mode/scripts/orch/store.ts +1607 -0
- package/skills/poteto-mode/scripts/package.json +16 -0
- package/skills/poteto-mode/scripts/watch-pr/cli.test.ts +224 -0
- package/skills/poteto-mode/scripts/watch-pr/cli.ts +223 -0
- package/skills/poteto-mode/scripts/watch-pr/fakes.test-helper.ts +118 -0
- package/skills/poteto-mode/scripts/watch-pr/github.test.ts +306 -0
- package/skills/poteto-mode/scripts/watch-pr/github.ts +699 -0
- package/skills/poteto-mode/scripts/watch-pr/policy.test.ts +420 -0
- package/skills/poteto-mode/scripts/watch-pr/policy.ts +832 -0
- package/skills/poteto-mode/scripts/watch-pr/render.ts +169 -0
- package/skills/poteto-mode/scripts/watch-pr/tsconfig.json +13 -0
- package/skills/poteto-mode/scripts/watch-pr/types.compile.ts +93 -0
- package/skills/poteto-mode/scripts/watch-pr/types.ts +401 -0
- package/skills/poteto-mode/scripts/watch-pr/watch-pr +6 -0
- package/skills/poteto-mode/scripts/worktree-audit.sh +86 -0
- package/skills/principle-boundary-discipline/README.md +17 -0
- package/skills/principle-boundary-discipline/SKILL.md +34 -0
- package/skills/principle-boundary-discipline/agents/openai.yaml +5 -0
- package/skills/principle-build-the-lever/README.md +17 -0
- package/skills/principle-build-the-lever/SKILL.md +23 -0
- package/skills/principle-build-the-lever/agents/openai.yaml +5 -0
- package/skills/principle-encode-lessons-in-structure/README.md +17 -0
- package/skills/principle-encode-lessons-in-structure/SKILL.md +31 -0
- package/skills/principle-encode-lessons-in-structure/agents/openai.yaml +5 -0
- package/skills/principle-exhaust-the-design-space/README.md +17 -0
- package/skills/principle-exhaust-the-design-space/SKILL.md +21 -0
- package/skills/principle-exhaust-the-design-space/agents/openai.yaml +5 -0
- package/skills/principle-experience-first/README.md +17 -0
- package/skills/principle-experience-first/SKILL.md +19 -0
- package/skills/principle-experience-first/agents/openai.yaml +5 -0
- package/skills/principle-fix-root-causes/README.md +17 -0
- package/skills/principle-fix-root-causes/SKILL.md +23 -0
- package/skills/principle-fix-root-causes/agents/openai.yaml +5 -0
- package/skills/principle-foundational-thinking/README.md +17 -0
- package/skills/principle-foundational-thinking/SKILL.md +21 -0
- package/skills/principle-foundational-thinking/agents/openai.yaml +5 -0
- package/skills/principle-guard-the-context-window/README.md +17 -0
- package/skills/principle-guard-the-context-window/SKILL.md +17 -0
- package/skills/principle-guard-the-context-window/agents/openai.yaml +5 -0
- package/skills/principle-laziness-protocol/README.md +17 -0
- package/skills/principle-laziness-protocol/SKILL.md +18 -0
- package/skills/principle-laziness-protocol/agents/openai.yaml +5 -0
- package/skills/principle-make-operations-idempotent/README.md +17 -0
- package/skills/principle-make-operations-idempotent/SKILL.md +24 -0
- package/skills/principle-make-operations-idempotent/agents/openai.yaml +5 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/README.md +17 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +22 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/agents/openai.yaml +5 -0
- package/skills/principle-minimize-reader-load/README.md +17 -0
- package/skills/principle-minimize-reader-load/SKILL.md +23 -0
- package/skills/principle-minimize-reader-load/agents/openai.yaml +5 -0
- package/skills/principle-model-the-domain/README.md +17 -0
- package/skills/principle-model-the-domain/SKILL.md +26 -0
- package/skills/principle-model-the-domain/agents/openai.yaml +5 -0
- package/skills/principle-never-block-on-the-human/README.md +16 -0
- package/skills/principle-never-block-on-the-human/SKILL.md +23 -0
- package/skills/principle-never-block-on-the-human/agents/openai.yaml +5 -0
- package/skills/principle-outcome-oriented-execution/README.md +16 -0
- package/skills/principle-outcome-oriented-execution/SKILL.md +22 -0
- package/skills/principle-outcome-oriented-execution/agents/openai.yaml +5 -0
- package/skills/principle-prove-it-works/README.md +18 -0
- package/skills/principle-prove-it-works/SKILL.md +33 -0
- package/skills/principle-prove-it-works/agents/openai.yaml +5 -0
- package/skills/principle-redesign-from-first-principles/README.md +16 -0
- package/skills/principle-redesign-from-first-principles/SKILL.md +16 -0
- package/skills/principle-redesign-from-first-principles/agents/openai.yaml +5 -0
- package/skills/principle-separate-before-serializing-shared-state/README.md +16 -0
- package/skills/principle-separate-before-serializing-shared-state/SKILL.md +16 -0
- package/skills/principle-separate-before-serializing-shared-state/agents/openai.yaml +5 -0
- package/skills/principle-sequence-verifiable-units/README.md +18 -0
- package/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/skills/principle-sequence-verifiable-units/agents/openai.yaml +5 -0
- package/skills/principle-subtract-before-you-add/README.md +17 -0
- package/skills/principle-subtract-before-you-add/SKILL.md +22 -0
- package/skills/principle-subtract-before-you-add/agents/openai.yaml +5 -0
- package/skills/principle-type-system-discipline/README.md +19 -0
- package/skills/principle-type-system-discipline/SKILL.md +31 -0
- package/skills/principle-type-system-discipline/agents/openai.yaml +5 -0
- package/skills/recall/README.md +23 -0
- package/skills/recall/SKILL.md +35 -0
- package/skills/recall/agents/openai.yaml +5 -0
- package/skills/reflect/README.md +24 -0
- package/skills/reflect/SKILL.md +77 -0
- package/skills/reflect/agents/openai.yaml +5 -0
- package/skills/reflect/references/divergent-reviewer.md +43 -0
- package/skills/reflect/references/judgment-reviewer.md +42 -0
- package/skills/reflect/references/synthesizer.md +56 -0
- package/skills/reflect/references/tooling-reviewer.md +57 -0
- package/skills/setup-rstack/README.md +25 -0
- package/skills/setup-rstack/SKILL.md +81 -0
- package/skills/setup-rstack/agents/openai.yaml +3 -0
- package/skills/show-me-your-work/README.md +23 -0
- package/skills/show-me-your-work/SKILL.md +82 -0
- package/skills/show-me-your-work/agents/openai.yaml +5 -0
- package/skills/show-me-your-work/references/decision-log-template.tsv +1 -0
- package/skills/show-me-your-work/scripts/log.sh +40 -0
- package/skills/swarm/README.md +21 -0
- package/skills/swarm/SKILL.md +46 -0
- package/skills/swarm/agents/openai.yaml +5 -0
- package/skills/tdd/README.md +21 -0
- package/skills/tdd/SKILL.md +44 -0
- package/skills/tdd/agents/openai.yaml +5 -0
- package/skills/teach/README.md +22 -0
- package/skills/teach/SKILL.md +21 -0
- package/skills/teach/agents/openai.yaml +5 -0
- package/skills/technical-writing/README.md +23 -0
- package/skills/technical-writing/SKILL.md +130 -0
- package/skills/technical-writing/agents/openai.yaml +5 -0
- package/skills/typescript-best-practices/README.md +22 -0
- package/skills/typescript-best-practices/SKILL.md +28 -0
- package/skills/typescript-best-practices/agents/openai.yaml +3 -0
- package/skills/typescript-best-practices/references/patterns.md +292 -0
- package/skills/unslop/README.md +21 -0
- package/skills/unslop/SKILL.md +80 -0
- package/skills/unslop/agents/openai.yaml +3 -0
- package/skills/why/README.md +26 -0
- package/skills/why/SKILL.md +229 -0
- package/skills/why/agents/openai.yaml +3 -0
- package/skills/why/references/epistemics.md +144 -0
- package/skills/why/references/investigator-prompt.md +103 -0
- package/skills/why/references/source-playbook.md +17 -0
- package/skills/why/references/sources/code-archaeology.md +88 -0
- package/skills/why/references/sources/databricks.md +70 -0
- package/skills/why/references/sources/datadog.md +99 -0
- package/skills/why/references/sources/incident-postmortem.md +15 -0
- package/skills/why/references/sources/linear.md +48 -0
- package/skills/why/references/sources/notion.md +55 -0
- package/skills/why/references/sources/sentry.md +100 -0
- package/skills/why/references/sources/slack.md +54 -0
- package/skills/why/references/synthesizer-prompt.md +135 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type * as T from "./types.ts";
|
|
3
|
+
import { nonEmpty, parsePrNumber } from "./types.ts";
|
|
4
|
+
export const REVIEW_THREADS_QUERY =
|
|
5
|
+
"\nquery ReviewThreads($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n reviewThreads(first: 100) {\n nodes {\n id\n isResolved\n comments(first: 10) {\n nodes {\n body\n createdAt\n path\n line\n author { login }\n }\n }\n }\n }\n }\n }\n}\n";
|
|
6
|
+
export const PR_COMMIT_STATUS_QUERY =
|
|
7
|
+
"\nquery PrCommitStatuses($owner: String!, $repo: String!, $pr: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n commits(last: 50) {\n nodes {\n commit {\n oid\n statusCheckRollup {\n state\n }\n }\n }\n }\n }\n }\n}\n";
|
|
8
|
+
export const PR_CHECK_ROLLUP_QUERY =
|
|
9
|
+
"\nquery PrCheckRollup($owner: String!, $repo: String!, $pr: Int!, $after: String) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $pr) {\n commits(last: 1) {\n nodes {\n commit {\n statusCheckRollup {\n contexts(first: 100, after: $after) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n __typename\n ... on CheckRun {\n name\n status\n conclusion\n detailsUrl\n }\n ... on StatusContext {\n context\n state\n targetUrl\n }\n }\n }\n }\n }\n }\n }\n }\n }\n}\n";
|
|
10
|
+
|
|
11
|
+
interface CommandResult {
|
|
12
|
+
readonly code: number;
|
|
13
|
+
readonly stdout: string;
|
|
14
|
+
readonly stderr: string;
|
|
15
|
+
}
|
|
16
|
+
export class WatcherQueryError extends Error {
|
|
17
|
+
readonly failure: T.QueryFailure;
|
|
18
|
+
constructor(failure: T.QueryFailure) {
|
|
19
|
+
super(failure.detail);
|
|
20
|
+
this.name = "WatcherQueryError";
|
|
21
|
+
this.failure = failure;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class ChecksUnavailable extends WatcherQueryError {
|
|
25
|
+
constructor(detail: string) {
|
|
26
|
+
super({ kind: "checks-unavailable", retryable: true, detail });
|
|
27
|
+
this.name = "ChecksUnavailable";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const firstLine = (value: string): string =>
|
|
31
|
+
value.trim().split(/\r?\n/, 1)[0]?.slice(0, 240) ?? "";
|
|
32
|
+
function run(argv: readonly [string, ...string[]]): Promise<CommandResult> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
35
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
36
|
+
});
|
|
37
|
+
let stdout = "";
|
|
38
|
+
let stderr = "";
|
|
39
|
+
child.stdout.setEncoding("utf8");
|
|
40
|
+
child.stderr.setEncoding("utf8");
|
|
41
|
+
child.stdout.on("data", (chunk: string) => {
|
|
42
|
+
stdout += chunk;
|
|
43
|
+
});
|
|
44
|
+
child.stderr.on("data", (chunk: string) => {
|
|
45
|
+
stderr += chunk;
|
|
46
|
+
});
|
|
47
|
+
child.on("error", reject);
|
|
48
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function parseJson(text: string, label: string): unknown {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(text);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new WatcherQueryError({
|
|
56
|
+
kind: "json-parse",
|
|
57
|
+
retryable: true,
|
|
58
|
+
detail: `${label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function runJson(argv: readonly [string, ...string[]]): Promise<unknown> {
|
|
63
|
+
const result = await run(argv);
|
|
64
|
+
if (result.code !== 0)
|
|
65
|
+
throw new WatcherQueryError({
|
|
66
|
+
kind: "command-exit",
|
|
67
|
+
retryable: true,
|
|
68
|
+
code: result.code,
|
|
69
|
+
detail:
|
|
70
|
+
firstLine(result.stderr) || `${argv.join(" ")} exited ${result.code}`,
|
|
71
|
+
});
|
|
72
|
+
return parseJson(result.stdout, argv.join(" "));
|
|
73
|
+
}
|
|
74
|
+
function raw(value: unknown): string {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(value);
|
|
77
|
+
} catch {
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function missing(path: string, value?: unknown): never {
|
|
82
|
+
throw new WatcherQueryError({
|
|
83
|
+
kind: "missing-key",
|
|
84
|
+
retryable: true,
|
|
85
|
+
detail:
|
|
86
|
+
value === undefined
|
|
87
|
+
? `missing ${path}`
|
|
88
|
+
: `invalid ${path}: ${raw(value)}`,
|
|
89
|
+
...(value === undefined ? {} : { rawValue: raw(value) }),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
96
|
+
if (!isRecord(value)) missing(path, value);
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
function list(value: unknown, path: string): readonly unknown[] {
|
|
100
|
+
if (!Array.isArray(value)) missing(path, value);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function at(value: unknown, path: readonly string[]): unknown {
|
|
104
|
+
let current = value;
|
|
105
|
+
for (const key of path) {
|
|
106
|
+
const object = record(current, path.join("."));
|
|
107
|
+
if (!(key in object)) missing(path.join("."));
|
|
108
|
+
current = object[key];
|
|
109
|
+
}
|
|
110
|
+
return current;
|
|
111
|
+
}
|
|
112
|
+
function string(value: unknown, path: string): string {
|
|
113
|
+
if (typeof value !== "string") missing(path, value);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
const optionalString = (value: unknown, path: string): string | null =>
|
|
117
|
+
value === null ? null : string(value, path);
|
|
118
|
+
function enumValue<const V extends readonly string[]>(
|
|
119
|
+
value: unknown,
|
|
120
|
+
values: V,
|
|
121
|
+
path: string
|
|
122
|
+
): V[number] {
|
|
123
|
+
if (typeof value === "string")
|
|
124
|
+
for (const candidate of values) if (candidate === value) return candidate;
|
|
125
|
+
return missing(path, value);
|
|
126
|
+
}
|
|
127
|
+
const nullableEnum = <const V extends readonly string[]>(
|
|
128
|
+
value: unknown,
|
|
129
|
+
values: V,
|
|
130
|
+
path: string
|
|
131
|
+
): V[number] | null => (value === null ? null : enumValue(value, values, path));
|
|
132
|
+
const MERGE_STATES = [
|
|
133
|
+
"BEHIND",
|
|
134
|
+
"BLOCKED",
|
|
135
|
+
"CLEAN",
|
|
136
|
+
"CONFLICTING",
|
|
137
|
+
"DIRTY",
|
|
138
|
+
"DRAFT",
|
|
139
|
+
"HAS_HOOKS",
|
|
140
|
+
"UNKNOWN",
|
|
141
|
+
"UNSTABLE",
|
|
142
|
+
] as const satisfies readonly T.MergeStateStatus[];
|
|
143
|
+
const ROLLUP_STATES = [
|
|
144
|
+
"ERROR",
|
|
145
|
+
"EXPECTED",
|
|
146
|
+
"FAILURE",
|
|
147
|
+
"PENDING",
|
|
148
|
+
"SUCCESS",
|
|
149
|
+
] as const;
|
|
150
|
+
const REVIEW_DECISIONS = [
|
|
151
|
+
"APPROVED",
|
|
152
|
+
"CHANGES_REQUESTED",
|
|
153
|
+
"REVIEW_REQUIRED",
|
|
154
|
+
] as const;
|
|
155
|
+
// `gh pr view` reports no review decision as "", not null. Only this field does
|
|
156
|
+
// it, so the normalization stays here rather than in nullableEnum, where it
|
|
157
|
+
// would stop a genuinely unexpected rollup state from failing closed.
|
|
158
|
+
const reviewDecision = (value: unknown): T.ReviewDecision =>
|
|
159
|
+
nullableEnum(
|
|
160
|
+
value === "" ? null : value,
|
|
161
|
+
REVIEW_DECISIONS,
|
|
162
|
+
"pull request.reviewDecision"
|
|
163
|
+
);
|
|
164
|
+
function parseRemote(value: string): T.Repository | null {
|
|
165
|
+
let normalized = value.trim();
|
|
166
|
+
if (normalized.startsWith("git@github.com:"))
|
|
167
|
+
normalized = `https://github.com/${normalized.slice(15)}`;
|
|
168
|
+
if (normalized.startsWith("ssh://git@github.com/"))
|
|
169
|
+
normalized = `https://github.com/${normalized.slice(21)}`;
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(normalized);
|
|
172
|
+
const parts = url.pathname
|
|
173
|
+
.replace(/\.git$/, "")
|
|
174
|
+
.split("/")
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
if (
|
|
177
|
+
url.protocol !== "https:" ||
|
|
178
|
+
url.hostname !== "github.com" ||
|
|
179
|
+
url.port ||
|
|
180
|
+
url.username ||
|
|
181
|
+
url.password ||
|
|
182
|
+
url.search ||
|
|
183
|
+
url.hash ||
|
|
184
|
+
parts.length !== 2
|
|
185
|
+
)
|
|
186
|
+
return null;
|
|
187
|
+
return { owner: parts[0], repo: parts[1] };
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function parsePrUrl(value: string): T.PrContext {
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(value);
|
|
195
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
196
|
+
if (
|
|
197
|
+
url.protocol !== "https:" ||
|
|
198
|
+
url.hostname !== "github.com" ||
|
|
199
|
+
url.port ||
|
|
200
|
+
url.username ||
|
|
201
|
+
url.password ||
|
|
202
|
+
url.search ||
|
|
203
|
+
url.hash ||
|
|
204
|
+
parts.length !== 4 ||
|
|
205
|
+
parts[2] !== "pull"
|
|
206
|
+
)
|
|
207
|
+
throw new Error("not a canonical GitHub pull URL");
|
|
208
|
+
return {
|
|
209
|
+
owner: parts[0],
|
|
210
|
+
repo: parts[1],
|
|
211
|
+
number: parsePrNumber(Number(parts[3])),
|
|
212
|
+
};
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new WatcherQueryError({
|
|
215
|
+
kind: "invalid-context-url",
|
|
216
|
+
retryable: false,
|
|
217
|
+
rawValue: value,
|
|
218
|
+
detail: `could not infer owner/repo from PR URL: ${value} (${error instanceof Error ? error.message : String(error)})`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function checkDetails(value: Record<string, unknown>, nameKey: string) {
|
|
223
|
+
return {
|
|
224
|
+
name: string(value[nameKey], nameKey),
|
|
225
|
+
description: typeof value.description === "string" ? value.description : "",
|
|
226
|
+
link:
|
|
227
|
+
typeof value.link === "string"
|
|
228
|
+
? value.link
|
|
229
|
+
: typeof value.detailsUrl === "string"
|
|
230
|
+
? value.detailsUrl
|
|
231
|
+
: "",
|
|
232
|
+
workflow: typeof value.workflow === "string" ? value.workflow : "",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function parseFastCheck(value: unknown): T.Check {
|
|
236
|
+
const object = record(value, "check");
|
|
237
|
+
const details = checkDetails(object, "name");
|
|
238
|
+
const state = string(object.state, "check.state").toUpperCase();
|
|
239
|
+
const bucket = string(object.bucket, "check.bucket");
|
|
240
|
+
if (
|
|
241
|
+
bucket === "fail" ||
|
|
242
|
+
["FAILURE", "ERROR", "ACTION_REQUIRED"].includes(state)
|
|
243
|
+
)
|
|
244
|
+
return { ...details, kind: "failed", reportedState: state };
|
|
245
|
+
if (bucket === "pending") return pendingOrGate(details, state);
|
|
246
|
+
if (bucket === "pass")
|
|
247
|
+
return { ...details, kind: "passed", reportedState: state };
|
|
248
|
+
if (bucket === "skipping")
|
|
249
|
+
return { ...details, kind: "skipped", reportedState: state };
|
|
250
|
+
return { ...details, kind: "failed", reportedState: state };
|
|
251
|
+
}
|
|
252
|
+
// The owner-approval gate is excluded from pending everywhere, so the rule has
|
|
253
|
+
// one home. Classifying it as pending on either read path makes the watcher
|
|
254
|
+
// wait on a human, which is the behaviour #172004 removed from the Python.
|
|
255
|
+
function pendingOrGate(
|
|
256
|
+
details: {
|
|
257
|
+
readonly name: string;
|
|
258
|
+
readonly description: string;
|
|
259
|
+
readonly link: string;
|
|
260
|
+
readonly workflow: string;
|
|
261
|
+
},
|
|
262
|
+
reportedState: string
|
|
263
|
+
): T.Check {
|
|
264
|
+
return details.name === "Code Review Gate"
|
|
265
|
+
? {
|
|
266
|
+
...details,
|
|
267
|
+
kind: "code-review-gate",
|
|
268
|
+
name: "Code Review Gate",
|
|
269
|
+
reportedState,
|
|
270
|
+
}
|
|
271
|
+
: { ...details, kind: "pending", reportedState };
|
|
272
|
+
}
|
|
273
|
+
export function mapRollupNode(value: unknown): T.Check | null {
|
|
274
|
+
const object = record(value, "rollup node");
|
|
275
|
+
const typename = object.__typename;
|
|
276
|
+
if (typename !== "CheckRun" && typename !== "StatusContext") return null;
|
|
277
|
+
const details = checkDetails(
|
|
278
|
+
object,
|
|
279
|
+
typename === "CheckRun" ? "name" : "context"
|
|
280
|
+
);
|
|
281
|
+
const link =
|
|
282
|
+
typeof object.targetUrl === "string" ? object.targetUrl : details.link;
|
|
283
|
+
if (typename === "CheckRun") {
|
|
284
|
+
const status =
|
|
285
|
+
typeof object.status === "string" ? object.status.toUpperCase() : "";
|
|
286
|
+
const conclusion =
|
|
287
|
+
typeof object.conclusion === "string"
|
|
288
|
+
? object.conclusion.toUpperCase()
|
|
289
|
+
: "";
|
|
290
|
+
if (status !== "COMPLETED")
|
|
291
|
+
return pendingOrGate({ ...details, link }, "PENDING");
|
|
292
|
+
if (conclusion === "SUCCESS")
|
|
293
|
+
return { ...details, link, kind: "passed", reportedState: "SUCCESS" };
|
|
294
|
+
if (conclusion === "NEUTRAL" || conclusion === "SKIPPED")
|
|
295
|
+
return { ...details, link, kind: "skipped", reportedState: conclusion };
|
|
296
|
+
return {
|
|
297
|
+
...details,
|
|
298
|
+
link,
|
|
299
|
+
kind: "failed",
|
|
300
|
+
reportedState: conclusion === "ACTION_REQUIRED" ? conclusion : "FAILURE",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const state =
|
|
304
|
+
typeof object.state === "string" ? object.state.toUpperCase() : "";
|
|
305
|
+
if (state === "PENDING" || state === "EXPECTED")
|
|
306
|
+
return pendingOrGate({ ...details, link }, "PENDING");
|
|
307
|
+
return state === "SUCCESS"
|
|
308
|
+
? { ...details, link, kind: "passed", reportedState: state }
|
|
309
|
+
: { ...details, link, kind: "failed", reportedState: state || "FAILURE" };
|
|
310
|
+
}
|
|
311
|
+
function parseComment(value: unknown): T.ReviewComment {
|
|
312
|
+
const object = record(value, "review comment");
|
|
313
|
+
const author =
|
|
314
|
+
object.author === null
|
|
315
|
+
? null
|
|
316
|
+
: record(object.author, "review comment.author");
|
|
317
|
+
return {
|
|
318
|
+
authorLogin:
|
|
319
|
+
author === null
|
|
320
|
+
? null
|
|
321
|
+
: optionalString(author.login, "review comment.author.login"),
|
|
322
|
+
body: string(object.body, "review comment.body"),
|
|
323
|
+
path: optionalString(object.path, "review comment.path"),
|
|
324
|
+
line:
|
|
325
|
+
object.line === null
|
|
326
|
+
? null
|
|
327
|
+
: Number.isInteger(object.line)
|
|
328
|
+
? Number(object.line)
|
|
329
|
+
: missing("review comment.line", object.line),
|
|
330
|
+
createdAt: string(object.createdAt, "review comment.createdAt"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function isBugbot(comment: T.ReviewComment | null): boolean {
|
|
334
|
+
if (comment === null) return false;
|
|
335
|
+
const author = (comment.authorLogin ?? "").toLowerCase();
|
|
336
|
+
const body = comment.body.toLowerCase();
|
|
337
|
+
return (
|
|
338
|
+
author.includes("bugbot") ||
|
|
339
|
+
(author === "cursor" &&
|
|
340
|
+
[
|
|
341
|
+
"bugbot",
|
|
342
|
+
"cursor_automation_id",
|
|
343
|
+
"agentic security review",
|
|
344
|
+
"description start",
|
|
345
|
+
"severity",
|
|
346
|
+
].some((token) => body.includes(token)))
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
function passKey(comment: T.ReviewComment | null): string | null {
|
|
350
|
+
if (comment === null) return null;
|
|
351
|
+
for (const pattern of [
|
|
352
|
+
/RUN_ID:\s*([a-zA-Z0-9_.:-]+)/,
|
|
353
|
+
/CURSOR_AUTOMATION_ID:\s*([a-zA-Z0-9_.:-]+)/,
|
|
354
|
+
]) {
|
|
355
|
+
const match = pattern.exec(comment.body);
|
|
356
|
+
if (match?.[1]) return match[1];
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
export function parseReviewThreads(value: unknown): readonly T.ReviewThread[] {
|
|
361
|
+
const nodes = list(
|
|
362
|
+
at(value, ["data", "repository", "pullRequest", "reviewThreads", "nodes"]),
|
|
363
|
+
"reviewThreads.nodes"
|
|
364
|
+
);
|
|
365
|
+
const threads: {
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly firstComment: T.ReviewComment | null;
|
|
368
|
+
readonly resolved: boolean;
|
|
369
|
+
}[] = [];
|
|
370
|
+
for (const node of nodes) {
|
|
371
|
+
const thread = record(node, "review thread");
|
|
372
|
+
if (typeof thread.isResolved !== "boolean")
|
|
373
|
+
missing("review thread.isResolved", thread.isResolved);
|
|
374
|
+
const comments = list(
|
|
375
|
+
at(thread, ["comments", "nodes"]),
|
|
376
|
+
"review thread.comments.nodes"
|
|
377
|
+
);
|
|
378
|
+
threads.push({
|
|
379
|
+
id: string(thread.id, "review thread.id"),
|
|
380
|
+
firstComment: comments.length === 0 ? null : parseComment(comments[0]),
|
|
381
|
+
resolved: thread.isResolved,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const keys = new Set<string>();
|
|
385
|
+
let keyless = false;
|
|
386
|
+
for (const thread of threads) {
|
|
387
|
+
if (!isBugbot(thread.firstComment)) continue;
|
|
388
|
+
const key = passKey(thread.firstComment);
|
|
389
|
+
if (key === null) keyless = true;
|
|
390
|
+
else keys.add(key);
|
|
391
|
+
}
|
|
392
|
+
const passes = keys.size > 0 ? keys.size : keyless ? 1 : 0;
|
|
393
|
+
return threads
|
|
394
|
+
.filter((thread) => !thread.resolved)
|
|
395
|
+
.map(({ id, firstComment }) => ({
|
|
396
|
+
id,
|
|
397
|
+
firstComment,
|
|
398
|
+
isBugbot: isBugbot(firstComment),
|
|
399
|
+
bugbotReviewPasses: passes,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
export function parsePullRequest(
|
|
403
|
+
value: unknown,
|
|
404
|
+
context: T.PrContext
|
|
405
|
+
): T.PullRequestFacts {
|
|
406
|
+
const object = record(value, "pull request");
|
|
407
|
+
if (typeof object.isDraft !== "boolean")
|
|
408
|
+
missing("pull request.isDraft", object.isDraft);
|
|
409
|
+
return {
|
|
410
|
+
context,
|
|
411
|
+
mergeable: enumValue(
|
|
412
|
+
object.mergeable,
|
|
413
|
+
["MERGEABLE", "CONFLICTING", "UNKNOWN"] as const,
|
|
414
|
+
"pull request.mergeable"
|
|
415
|
+
),
|
|
416
|
+
mergeStateStatus: enumValue(
|
|
417
|
+
object.mergeStateStatus,
|
|
418
|
+
MERGE_STATES,
|
|
419
|
+
"pull request.mergeStateStatus"
|
|
420
|
+
),
|
|
421
|
+
reviewDecision: reviewDecision(object.reviewDecision),
|
|
422
|
+
headRefOid: optionalString(object.headRefOid, "pull request.headRefOid"),
|
|
423
|
+
headRefName: string(object.headRefName, "pull request.headRefName"),
|
|
424
|
+
baseRefName: string(object.baseRefName, "pull request.baseRefName"),
|
|
425
|
+
state: enumValue(
|
|
426
|
+
object.state,
|
|
427
|
+
["OPEN", "CLOSED", "MERGED"] as const,
|
|
428
|
+
"pull request.state"
|
|
429
|
+
),
|
|
430
|
+
mergedAt: optionalString(object.mergedAt, "pull request.mergedAt"),
|
|
431
|
+
isDraft: object.isDraft,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function graphqlArgs(
|
|
435
|
+
query: string,
|
|
436
|
+
context: T.PrContext
|
|
437
|
+
): [string, ...string[]] {
|
|
438
|
+
return [
|
|
439
|
+
"gh",
|
|
440
|
+
"api",
|
|
441
|
+
"graphql",
|
|
442
|
+
"-f",
|
|
443
|
+
`query=${query}`,
|
|
444
|
+
"-f",
|
|
445
|
+
`owner=${context.owner}`,
|
|
446
|
+
"-f",
|
|
447
|
+
`repo=${context.repo}`,
|
|
448
|
+
"-F",
|
|
449
|
+
`pr=${context.number}`,
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export class GhGitHubReader implements T.GitHubReader {
|
|
454
|
+
async originRepo(): Promise<T.Repository | null> {
|
|
455
|
+
const result = await run(["git", "remote", "get-url", "origin"]);
|
|
456
|
+
return result.code === 0 ? parseRemote(result.stdout) : null;
|
|
457
|
+
}
|
|
458
|
+
async currentPr(pr: T.PrNumber | null): Promise<T.PrContext> {
|
|
459
|
+
const argv: [string, ...string[]] = ["gh", "pr", "view"];
|
|
460
|
+
if (pr !== null) argv.push(String(pr));
|
|
461
|
+
argv.push("--json", "number,url");
|
|
462
|
+
const object = record(await runJson(argv), "current PR");
|
|
463
|
+
const parsed = parsePrUrl(string(object.url, "current PR.url"));
|
|
464
|
+
return {
|
|
465
|
+
...parsed,
|
|
466
|
+
number: pr ?? parsePrNumber(object.number, "current PR.number"),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
async pullRequest(context: T.PrContext): Promise<T.PullRequestFacts> {
|
|
470
|
+
return parsePullRequest(
|
|
471
|
+
await runJson([
|
|
472
|
+
"gh",
|
|
473
|
+
"pr",
|
|
474
|
+
"view",
|
|
475
|
+
String(context.number),
|
|
476
|
+
"--repo",
|
|
477
|
+
`${context.owner}/${context.repo}`,
|
|
478
|
+
"--json",
|
|
479
|
+
"mergeable,mergeStateStatus,reviewDecision,headRefOid,headRefName,baseRefName,state,mergedAt,isDraft",
|
|
480
|
+
]),
|
|
481
|
+
context
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
async openPullRequests(
|
|
485
|
+
repository: T.Repository
|
|
486
|
+
): Promise<readonly T.OpenPullRequest[]> {
|
|
487
|
+
const value = await runJson([
|
|
488
|
+
"gh",
|
|
489
|
+
"pr",
|
|
490
|
+
"list",
|
|
491
|
+
"--repo",
|
|
492
|
+
`${repository.owner}/${repository.repo}`,
|
|
493
|
+
"--state",
|
|
494
|
+
"open",
|
|
495
|
+
"--limit",
|
|
496
|
+
"300",
|
|
497
|
+
"--json",
|
|
498
|
+
"number,headRefName,baseRefName",
|
|
499
|
+
]);
|
|
500
|
+
return list(value, "open PRs").map((item, index) => {
|
|
501
|
+
const object = record(item, `open PRs[${index}]`);
|
|
502
|
+
return {
|
|
503
|
+
number: parsePrNumber(object.number, `open PRs[${index}].number`),
|
|
504
|
+
headRefName: string(
|
|
505
|
+
object.headRefName,
|
|
506
|
+
`open PRs[${index}].headRefName`
|
|
507
|
+
),
|
|
508
|
+
baseRefName: string(
|
|
509
|
+
object.baseRefName,
|
|
510
|
+
`open PRs[${index}].baseRefName`
|
|
511
|
+
),
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async checksFastPath(context: T.PrContext): Promise<T.ChecksFastPath> {
|
|
516
|
+
const result = await run([
|
|
517
|
+
"gh",
|
|
518
|
+
"pr",
|
|
519
|
+
"checks",
|
|
520
|
+
String(context.number),
|
|
521
|
+
"--repo",
|
|
522
|
+
`${context.owner}/${context.repo}`,
|
|
523
|
+
"--json",
|
|
524
|
+
"name,state,description,link,workflow,bucket",
|
|
525
|
+
]);
|
|
526
|
+
if ([0, 1, 8].includes(result.code) && result.stdout.trim()) {
|
|
527
|
+
try {
|
|
528
|
+
const value = parseJson(result.stdout, "gh pr checks");
|
|
529
|
+
if (Array.isArray(value))
|
|
530
|
+
return { kind: "checks", checks: value.map(parseFastCheck) };
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (!(error instanceof WatcherQueryError)) throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { kind: "unusable", exitCode: result.code, stderr: result.stderr };
|
|
536
|
+
}
|
|
537
|
+
async checkRollupPage(
|
|
538
|
+
context: T.PrContext,
|
|
539
|
+
after: string | null
|
|
540
|
+
): Promise<T.RollupPage> {
|
|
541
|
+
const argv = graphqlArgs(PR_CHECK_ROLLUP_QUERY, context);
|
|
542
|
+
if (after !== null) argv.push("-f", `after=${after}`);
|
|
543
|
+
const value = await runJson(argv);
|
|
544
|
+
const commits = list(
|
|
545
|
+
at(value, ["data", "repository", "pullRequest", "commits", "nodes"]),
|
|
546
|
+
"commits.nodes"
|
|
547
|
+
);
|
|
548
|
+
if (commits.length === 0) return { checks: [], endCursor: null };
|
|
549
|
+
const commit = record(
|
|
550
|
+
at(commits[commits.length - 1], ["commit"]),
|
|
551
|
+
"commit"
|
|
552
|
+
);
|
|
553
|
+
if (commit.statusCheckRollup === null)
|
|
554
|
+
return { checks: [], endCursor: null };
|
|
555
|
+
const contexts = record(
|
|
556
|
+
at(commit, ["statusCheckRollup", "contexts"]),
|
|
557
|
+
"contexts"
|
|
558
|
+
);
|
|
559
|
+
const checks = list(contexts.nodes, "contexts.nodes")
|
|
560
|
+
.map(mapRollupNode)
|
|
561
|
+
.filter((check): check is T.Check => check !== null);
|
|
562
|
+
const page = record(contexts.pageInfo, "contexts.pageInfo");
|
|
563
|
+
if (typeof page.hasNextPage !== "boolean")
|
|
564
|
+
missing("contexts.pageInfo.hasNextPage", page.hasNextPage);
|
|
565
|
+
const cursor = optionalString(
|
|
566
|
+
page.endCursor,
|
|
567
|
+
"contexts.pageInfo.endCursor"
|
|
568
|
+
);
|
|
569
|
+
return { checks, endCursor: page.hasNextPage && cursor ? cursor : null };
|
|
570
|
+
}
|
|
571
|
+
async reviewThreads(
|
|
572
|
+
context: T.PrContext
|
|
573
|
+
): Promise<readonly T.ReviewThread[]> {
|
|
574
|
+
return parseReviewThreads(
|
|
575
|
+
await runJson(graphqlArgs(REVIEW_THREADS_QUERY, context))
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
async commitRollups(
|
|
579
|
+
context: T.PrContext
|
|
580
|
+
): Promise<readonly T.CommitRollup[]> {
|
|
581
|
+
const value = await runJson(graphqlArgs(PR_COMMIT_STATUS_QUERY, context));
|
|
582
|
+
const commits = list(
|
|
583
|
+
at(value, ["data", "repository", "pullRequest", "commits", "nodes"]),
|
|
584
|
+
"commits.nodes"
|
|
585
|
+
);
|
|
586
|
+
return commits.map((item, index) => {
|
|
587
|
+
const commit = record(at(item, ["commit"]), `commits[${index}].commit`);
|
|
588
|
+
const rollup = commit.statusCheckRollup;
|
|
589
|
+
return {
|
|
590
|
+
oid: string(commit.oid, `commits[${index}].oid`),
|
|
591
|
+
state:
|
|
592
|
+
rollup === null
|
|
593
|
+
? null
|
|
594
|
+
: nullableEnum(
|
|
595
|
+
at(rollup, ["state"]),
|
|
596
|
+
ROLLUP_STATES,
|
|
597
|
+
`commits[${index}].statusCheckRollup.state`
|
|
598
|
+
),
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export async function resolveChecks(
|
|
605
|
+
reader: T.GitHubReader,
|
|
606
|
+
context: T.PrContext
|
|
607
|
+
): Promise<T.CheckRead> {
|
|
608
|
+
const fast = await reader.checksFastPath(context);
|
|
609
|
+
const direct = fast.kind === "checks" ? nonEmpty(fast.checks) : null;
|
|
610
|
+
if (direct !== null) return { source: "gh-pr-checks", checks: direct };
|
|
611
|
+
const checks: T.Check[] = [];
|
|
612
|
+
let after: string | null = null;
|
|
613
|
+
do {
|
|
614
|
+
const page = await reader.checkRollupPage(context, after);
|
|
615
|
+
checks.push(...page.checks);
|
|
616
|
+
after = page.endCursor;
|
|
617
|
+
} while (after !== null);
|
|
618
|
+
const fallback = nonEmpty(checks);
|
|
619
|
+
if (fallback !== null) return { source: "graphql-rollup", checks: fallback };
|
|
620
|
+
const suffix =
|
|
621
|
+
fast.kind === "unusable"
|
|
622
|
+
? `fast path exit=${fast.exitCode}; GraphQL rollup was empty${firstLine(fast.stderr) ? `; ${firstLine(fast.stderr)}` : ""}`
|
|
623
|
+
: "fast path and GraphQL rollup were empty";
|
|
624
|
+
throw new ChecksUnavailable(`could not read PR checks: ${suffix}`);
|
|
625
|
+
}
|
|
626
|
+
export async function resolveContext(args: {
|
|
627
|
+
readonly reader: T.GitHubReader;
|
|
628
|
+
readonly owner: string | null;
|
|
629
|
+
readonly repo: string | null;
|
|
630
|
+
readonly pr: T.PrNumber | null;
|
|
631
|
+
}): Promise<T.PrContext> {
|
|
632
|
+
if (args.pr !== null && args.owner !== null && args.repo !== null)
|
|
633
|
+
return { owner: args.owner, repo: args.repo, number: args.pr };
|
|
634
|
+
if (args.pr !== null) {
|
|
635
|
+
const origin = await args.reader.originRepo();
|
|
636
|
+
if (origin !== null)
|
|
637
|
+
return {
|
|
638
|
+
owner: args.owner ?? origin.owner,
|
|
639
|
+
repo: args.repo ?? origin.repo,
|
|
640
|
+
number: args.pr,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const inferred = await args.reader.currentPr(args.pr);
|
|
644
|
+
return {
|
|
645
|
+
owner: args.owner ?? inferred.owner,
|
|
646
|
+
repo: args.repo ?? inferred.repo,
|
|
647
|
+
number: args.pr ?? inferred.number,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
export function orderStack(
|
|
651
|
+
context: T.PrContext,
|
|
652
|
+
open: readonly T.OpenPullRequest[]
|
|
653
|
+
): T.NonEmpty<T.PrContext> {
|
|
654
|
+
const byNumber = new Map(open.map((pr) => [pr.number, pr]));
|
|
655
|
+
const byHead = new Map(open.map((pr) => [pr.headRefName, pr]));
|
|
656
|
+
const children = new Map<string, T.OpenPullRequest[]>();
|
|
657
|
+
for (const pr of open)
|
|
658
|
+
children.set(pr.baseRefName, [...(children.get(pr.baseRefName) ?? []), pr]);
|
|
659
|
+
for (const values of children.values())
|
|
660
|
+
values.sort((a, b) => a.number - b.number);
|
|
661
|
+
const start = byNumber.get(context.number);
|
|
662
|
+
if (start === undefined) return [context];
|
|
663
|
+
const down: T.OpenPullRequest[] = [];
|
|
664
|
+
let current = start;
|
|
665
|
+
while (byHead.has(current.baseRefName)) {
|
|
666
|
+
const parent = byHead.get(current.baseRefName);
|
|
667
|
+
if (parent === undefined) break;
|
|
668
|
+
down.push(parent);
|
|
669
|
+
current = parent;
|
|
670
|
+
}
|
|
671
|
+
const seen = new Set<T.PrNumber>([
|
|
672
|
+
...down.map((pr) => pr.number),
|
|
673
|
+
start.number,
|
|
674
|
+
]);
|
|
675
|
+
const up: T.OpenPullRequest[] = [];
|
|
676
|
+
const visit = (parent: T.OpenPullRequest): void => {
|
|
677
|
+
for (const child of children.get(parent.headRefName) ?? []) {
|
|
678
|
+
if (seen.has(child.number)) continue;
|
|
679
|
+
seen.add(child.number);
|
|
680
|
+
up.push(child);
|
|
681
|
+
visit(child);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
visit(start);
|
|
685
|
+
return (
|
|
686
|
+
nonEmpty(
|
|
687
|
+
[...down.reverse(), start, ...up].map((pr) => ({
|
|
688
|
+
...context,
|
|
689
|
+
number: pr.number,
|
|
690
|
+
}))
|
|
691
|
+
) ?? [context]
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
export async function discoverStack(
|
|
695
|
+
reader: T.GitHubReader,
|
|
696
|
+
context: T.PrContext
|
|
697
|
+
): Promise<T.NonEmpty<T.PrContext>> {
|
|
698
|
+
return orderStack(context, await reader.openPullRequests(context));
|
|
699
|
+
}
|