@brainervirus/workit-core 2.3.0 → 2.4.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/package.json +1 -1
- package/src/forge/exec.ts +181 -0
- package/src/forge/github.ts +446 -0
- package/src/forge/gitlab.ts +455 -0
- package/src/forge/redact.ts +191 -0
- package/src/forge/report.ts +618 -0
- package/src/forge/reruns.ts +127 -0
- package/src/forge/resolve.ts +240 -0
- package/src/forge/types.ts +136 -0
- package/src/git/rev.ts +96 -4
package/package.json
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Bounded gh/glab invocation (design §0 #8: `gh api` / `glab api` only, never
|
|
2
|
+
// porcelain whose flags vary by CLI version). Every call has a timeout, runs
|
|
3
|
+
// with prompts and update checks disabled, and targets the API host derived
|
|
4
|
+
// from the push remote (GH_HOST / GITLAB_HOST). Failures map to the envelope
|
|
5
|
+
// codes; stderr is redacted before it reaches a message.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import type { ForgeKind } from "../git/rev";
|
|
10
|
+
import { redactText } from "./redact";
|
|
11
|
+
import { failure, type ForgeResult } from "./types";
|
|
12
|
+
|
|
13
|
+
export type CliBin = "gh" | "glab";
|
|
14
|
+
|
|
15
|
+
export type CliRun = {
|
|
16
|
+
status: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
timedOut: boolean;
|
|
20
|
+
/** The binary is not on PATH. */
|
|
21
|
+
missing: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
|
|
25
|
+
export type ForgeRunner = (
|
|
26
|
+
bin: CliBin,
|
|
27
|
+
args: readonly string[],
|
|
28
|
+
options: {
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
/** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
|
|
31
|
+
token?: string;
|
|
32
|
+
},
|
|
33
|
+
) => CliRun;
|
|
34
|
+
|
|
35
|
+
/** Default per-call bounds (ms). */
|
|
36
|
+
export const FORGE_TIMEOUTS = { api: 20_000, log: 30_000 } as const;
|
|
37
|
+
|
|
38
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
/** Locate `bin` on `env.PATH` without running a shell. */
|
|
41
|
+
export function findExecutable(bin: string, env: NodeJS.ProcessEnv): string | null {
|
|
42
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
43
|
+
// Only real executables: spawning a .cmd/.bat shim would need a shell.
|
|
44
|
+
const exts = process.platform === "win32" ? [".exe"] : [""];
|
|
45
|
+
for (const dir of dirs)
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const candidate = path.join(dir, `${bin}${ext}`);
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.statSync(candidate);
|
|
50
|
+
if (stat.isFile()) return candidate;
|
|
51
|
+
} catch {
|
|
52
|
+
// not here
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The real gh/glab, bound to one API host. */
|
|
59
|
+
export function systemRunner(
|
|
60
|
+
kind: ForgeKind,
|
|
61
|
+
apiHost: string,
|
|
62
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
63
|
+
): ForgeRunner {
|
|
64
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
65
|
+
...env,
|
|
66
|
+
...(kind === "github" ? { GH_HOST: apiHost } : { GITLAB_HOST: apiHost }),
|
|
67
|
+
GH_PROMPT_DISABLED: "1",
|
|
68
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
69
|
+
GLAB_CHECK_UPDATE: "false",
|
|
70
|
+
NO_PROMPT: "1",
|
|
71
|
+
NO_COLOR: "1",
|
|
72
|
+
GH_PAGER: "",
|
|
73
|
+
GLAB_PAGER: "",
|
|
74
|
+
PAGER: "",
|
|
75
|
+
};
|
|
76
|
+
return (bin, args, options) => {
|
|
77
|
+
const exe = findExecutable(bin, env);
|
|
78
|
+
if (!exe) return { status: null, stdout: "", stderr: "", timedOut: false, missing: true };
|
|
79
|
+
const token = options.token
|
|
80
|
+
? kind === "github"
|
|
81
|
+
? { GH_TOKEN: options.token, GH_ENTERPRISE_TOKEN: options.token }
|
|
82
|
+
: { GITLAB_TOKEN: options.token }
|
|
83
|
+
: {};
|
|
84
|
+
const result = spawnSync(exe, [...args], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
env: { ...childEnv, ...token },
|
|
87
|
+
maxBuffer: MAX_BUFFER,
|
|
88
|
+
timeout: options.timeoutMs,
|
|
89
|
+
killSignal: "SIGKILL",
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
+
});
|
|
93
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
94
|
+
return {
|
|
95
|
+
status: result.status,
|
|
96
|
+
stdout: result.stdout ?? "",
|
|
97
|
+
stderr: result.stderr ?? "",
|
|
98
|
+
timedOut: error?.code === "ETIMEDOUT",
|
|
99
|
+
missing: error?.code === "ENOENT",
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const installHint = (bin: CliBin): string =>
|
|
105
|
+
bin === "gh"
|
|
106
|
+
? "install the GitHub CLI (https://cli.github.com) and run: gh auth login"
|
|
107
|
+
: "install glab (https://gitlab.com/gitlab-org/cli) and run: glab auth login";
|
|
108
|
+
|
|
109
|
+
export const loginHint = (bin: CliBin, apiHost: string): string =>
|
|
110
|
+
`${bin} auth login --hostname ${apiHost}`;
|
|
111
|
+
|
|
112
|
+
/** Map a failed CLI run to an envelope error. */
|
|
113
|
+
export function cliFailure<T>(
|
|
114
|
+
bin: CliBin,
|
|
115
|
+
apiHost: string,
|
|
116
|
+
run: CliRun,
|
|
117
|
+
what: string,
|
|
118
|
+
timeoutMs: number,
|
|
119
|
+
): ForgeResult<T> {
|
|
120
|
+
if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
|
|
121
|
+
if (run.timedOut)
|
|
122
|
+
return failure("unavailable", `${bin} api ${what} timed out after ${timeoutMs} ms`);
|
|
123
|
+
const stderr = redactText(run.stderr).trim();
|
|
124
|
+
if (
|
|
125
|
+
/auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
|
|
126
|
+
stderr,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return failure(
|
|
130
|
+
"unavailable",
|
|
131
|
+
`${bin} is not authenticated for ${apiHost}`,
|
|
132
|
+
loginHint(bin, apiHost),
|
|
133
|
+
);
|
|
134
|
+
if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
|
|
135
|
+
const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
|
|
136
|
+
return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Run and parse a JSON response. */
|
|
140
|
+
export function apiJson<T>(
|
|
141
|
+
runner: ForgeRunner,
|
|
142
|
+
bin: CliBin,
|
|
143
|
+
apiHost: string,
|
|
144
|
+
args: readonly string[],
|
|
145
|
+
what: string,
|
|
146
|
+
timeoutMs: number = FORGE_TIMEOUTS.api,
|
|
147
|
+
options: { graphql?: boolean } = {},
|
|
148
|
+
): ForgeResult<T> {
|
|
149
|
+
const run = runner(bin, args, { timeoutMs });
|
|
150
|
+
if (run.status !== 0) {
|
|
151
|
+
// `gh api graphql` exits 1 on GraphQL errors but still prints the
|
|
152
|
+
// response; the caller reads its `errors` array.
|
|
153
|
+
if (options.graphql && !run.missing && !run.timedOut)
|
|
154
|
+
try {
|
|
155
|
+
const body = JSON.parse(run.stdout) as { errors?: unknown };
|
|
156
|
+
if (Array.isArray(body.errors)) return { ok: true, data: body as T };
|
|
157
|
+
} catch {
|
|
158
|
+
// fall through
|
|
159
|
+
}
|
|
160
|
+
return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return { ok: true, data: JSON.parse(run.stdout) as T };
|
|
164
|
+
} catch {
|
|
165
|
+
return failure("failed", `${bin} api ${what} returned invalid JSON`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Run and return raw text (job logs). */
|
|
170
|
+
export function apiText(
|
|
171
|
+
runner: ForgeRunner,
|
|
172
|
+
bin: CliBin,
|
|
173
|
+
apiHost: string,
|
|
174
|
+
args: readonly string[],
|
|
175
|
+
what: string,
|
|
176
|
+
timeoutMs: number = FORGE_TIMEOUTS.log,
|
|
177
|
+
): ForgeResult<string> {
|
|
178
|
+
const run = runner(bin, args, { timeoutMs });
|
|
179
|
+
if (run.status !== 0) return cliFailure(bin, apiHost, run, what, timeoutMs);
|
|
180
|
+
return { ok: true, data: run.stdout };
|
|
181
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// GitHub adapter: `gh api graphql` for PR state, checks and review threads,
|
|
2
|
+
// `gh api` REST for required checks, job logs and reruns (design §2.0).
|
|
3
|
+
import { apiJson, apiText, cliFailure, FORGE_TIMEOUTS, type ForgeRunner } from "./exec";
|
|
4
|
+
import { logTail, redactText, shortBody } from "./redact";
|
|
5
|
+
import {
|
|
6
|
+
failure,
|
|
7
|
+
success,
|
|
8
|
+
type Forge,
|
|
9
|
+
type ForgeCheck,
|
|
10
|
+
type ForgePrStatus,
|
|
11
|
+
type ForgeResult,
|
|
12
|
+
type ForgeThread,
|
|
13
|
+
type PrRef,
|
|
14
|
+
type PrState,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
/** Page cap for review threads and check contexts (100 per page). */
|
|
18
|
+
export const GITHUB_MAX_PAGES = 10;
|
|
19
|
+
|
|
20
|
+
const PR_FIELDS = `number url state isDraft isInMergeQueue mergeable mergeStateStatus baseRefName baseRefOid baseRef { target { oid } } headRefName headRefOid reviewDecision`;
|
|
21
|
+
|
|
22
|
+
const THREADS = `reviewThreads(first: 100, after: $threads) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line comments(first: 1) { nodes { author { login __typename } body url } } } }`;
|
|
23
|
+
|
|
24
|
+
// isRequired: only checks branch protection requires gate the merge.
|
|
25
|
+
const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId isRequired(pullRequestNumber: $number) checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $number) } } }`;
|
|
26
|
+
|
|
27
|
+
export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
|
|
28
|
+
|
|
29
|
+
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
30
|
+
|
|
31
|
+
type Page<T> = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] };
|
|
32
|
+
|
|
33
|
+
type GqlThread = {
|
|
34
|
+
id: string;
|
|
35
|
+
isResolved: boolean;
|
|
36
|
+
isOutdated: boolean;
|
|
37
|
+
path: string | null;
|
|
38
|
+
line: number | null;
|
|
39
|
+
comments: {
|
|
40
|
+
nodes: Array<{
|
|
41
|
+
author: { login: string; __typename: string } | null;
|
|
42
|
+
body: string;
|
|
43
|
+
url: string;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type GqlContext =
|
|
49
|
+
| {
|
|
50
|
+
__typename: "CheckRun";
|
|
51
|
+
name: string;
|
|
52
|
+
status: string;
|
|
53
|
+
conclusion: string | null;
|
|
54
|
+
detailsUrl: string | null;
|
|
55
|
+
databaseId: number | null;
|
|
56
|
+
isRequired?: boolean | null;
|
|
57
|
+
checkSuite: {
|
|
58
|
+
workflowRun: { databaseId: number; workflow: { name: string } | null } | null;
|
|
59
|
+
} | null;
|
|
60
|
+
}
|
|
61
|
+
| {
|
|
62
|
+
__typename: "StatusContext";
|
|
63
|
+
context: string;
|
|
64
|
+
state: string;
|
|
65
|
+
targetUrl: string | null;
|
|
66
|
+
isRequired?: boolean | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type GqlPr = {
|
|
70
|
+
number: number;
|
|
71
|
+
url: string;
|
|
72
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
73
|
+
isDraft: boolean;
|
|
74
|
+
isInMergeQueue?: boolean;
|
|
75
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
76
|
+
mergeStateStatus: string;
|
|
77
|
+
baseRefName: string;
|
|
78
|
+
/** baseRefOid is the base at the PR's last sync; baseRef.target is the live tip. */
|
|
79
|
+
baseRefOid: string | null;
|
|
80
|
+
baseRef?: { target: { oid: string } | null } | null;
|
|
81
|
+
headRefName: string;
|
|
82
|
+
headRefOid: string;
|
|
83
|
+
reviewDecision: string | null;
|
|
84
|
+
reviewThreads: Page<GqlThread>;
|
|
85
|
+
commits: {
|
|
86
|
+
nodes: Array<{
|
|
87
|
+
commit: {
|
|
88
|
+
oid: string;
|
|
89
|
+
statusCheckRollup: { state: string; contexts: Page<GqlContext> } | null;
|
|
90
|
+
};
|
|
91
|
+
}>;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
|
|
96
|
+
|
|
97
|
+
const prState = (value: string): PrState =>
|
|
98
|
+
value === "MERGED" ? "merged" : value === "CLOSED" ? "closed" : "open";
|
|
99
|
+
|
|
100
|
+
// GitHub Actions conclusions that fail a PR. NEUTRAL/SKIPPED/SUCCESS pass;
|
|
101
|
+
// STALE is superseded by a newer run and is ignored.
|
|
102
|
+
const FAILING = new Set([
|
|
103
|
+
"FAILURE",
|
|
104
|
+
"TIMED_OUT",
|
|
105
|
+
"CANCELLED",
|
|
106
|
+
"ACTION_REQUIRED",
|
|
107
|
+
"STARTUP_FAILURE",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
|
|
111
|
+
|
|
112
|
+
export function checkFromContext(node: GqlContext): ForgeCheck | null {
|
|
113
|
+
const required = node.isRequired ?? null;
|
|
114
|
+
if (node.__typename === "StatusContext") {
|
|
115
|
+
const state = node.state.toUpperCase();
|
|
116
|
+
return {
|
|
117
|
+
name: node.context,
|
|
118
|
+
context: node.context,
|
|
119
|
+
state:
|
|
120
|
+
state === "SUCCESS"
|
|
121
|
+
? "passing"
|
|
122
|
+
: state === "FAILURE" || state === "ERROR"
|
|
123
|
+
? "failing"
|
|
124
|
+
: "pending",
|
|
125
|
+
conclusion: state.toLowerCase(),
|
|
126
|
+
url: safeUrl(node.targetUrl),
|
|
127
|
+
runId: null,
|
|
128
|
+
jobId: null,
|
|
129
|
+
scope: null,
|
|
130
|
+
required,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (node.conclusion === "STALE") return null;
|
|
134
|
+
const run = node.checkSuite?.workflowRun ?? null;
|
|
135
|
+
const workflow = run?.workflow?.name;
|
|
136
|
+
const completed = node.status === "COMPLETED";
|
|
137
|
+
const conclusion = node.conclusion?.toUpperCase() ?? null;
|
|
138
|
+
return {
|
|
139
|
+
name: workflow ? `${workflow} / ${node.name}` : node.name,
|
|
140
|
+
context: node.name,
|
|
141
|
+
state: !completed
|
|
142
|
+
? "pending"
|
|
143
|
+
: conclusion && FAILING.has(conclusion)
|
|
144
|
+
? "failing"
|
|
145
|
+
: conclusion === "SKIPPED" || conclusion === "NEUTRAL"
|
|
146
|
+
? "skipped"
|
|
147
|
+
: "passing",
|
|
148
|
+
conclusion: completed ? (conclusion?.toLowerCase() ?? null) : node.status.toLowerCase(),
|
|
149
|
+
url: safeUrl(node.detailsUrl),
|
|
150
|
+
runId: run?.databaseId ?? null,
|
|
151
|
+
// Only Actions check runs have a job log behind their database id.
|
|
152
|
+
jobId: run ? (node.databaseId ?? null) : null,
|
|
153
|
+
scope: null,
|
|
154
|
+
required,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One check per name: a re-run check replaces its earlier attempt. */
|
|
159
|
+
export function latestPerName(checks: readonly ForgeCheck[]): ForgeCheck[] {
|
|
160
|
+
const byName = new Map<string, ForgeCheck>();
|
|
161
|
+
for (const check of checks) {
|
|
162
|
+
const seen = byName.get(check.name);
|
|
163
|
+
if (!seen || (check.jobId ?? 0) >= (seen.jobId ?? 0)) byName.set(check.name, check);
|
|
164
|
+
}
|
|
165
|
+
return [...byName.values()];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function threadFrom(node: GqlThread): ForgeThread {
|
|
169
|
+
const first = node.comments.nodes[0];
|
|
170
|
+
return {
|
|
171
|
+
id: node.id,
|
|
172
|
+
path: node.path,
|
|
173
|
+
line: node.line,
|
|
174
|
+
author: first?.author?.login ?? null,
|
|
175
|
+
isBot: first?.author?.__typename === "Bot",
|
|
176
|
+
outdated: node.isOutdated,
|
|
177
|
+
url: safeUrl(first?.url ?? null),
|
|
178
|
+
body: shortBody(first?.body ?? ""),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const encodeRef = (ref: string): string => ref.split("/").map(encodeURIComponent).join("/");
|
|
183
|
+
|
|
184
|
+
export function createGitHubForge(options: {
|
|
185
|
+
apiHost: string;
|
|
186
|
+
repo: string;
|
|
187
|
+
runner: ForgeRunner;
|
|
188
|
+
maxPages?: number;
|
|
189
|
+
}): Forge {
|
|
190
|
+
const { apiHost, repo, runner } = options;
|
|
191
|
+
const maxPages = options.maxPages ?? GITHUB_MAX_PAGES;
|
|
192
|
+
const [owner, name] = repo.split("/");
|
|
193
|
+
|
|
194
|
+
const rest = <T>(endpoint: string, what: string): ForgeResult<T> =>
|
|
195
|
+
apiJson<T>(runner, "gh", apiHost, ["api", endpoint], what);
|
|
196
|
+
|
|
197
|
+
const graphql = <T>(
|
|
198
|
+
query: string,
|
|
199
|
+
variables: Record<string, string | number | null>,
|
|
200
|
+
what: string,
|
|
201
|
+
): ForgeResult<T> => {
|
|
202
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
203
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
204
|
+
if (value === null) continue;
|
|
205
|
+
args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
|
|
206
|
+
}
|
|
207
|
+
const response = apiJson<GqlResponse<T>>(
|
|
208
|
+
runner,
|
|
209
|
+
"gh",
|
|
210
|
+
apiHost,
|
|
211
|
+
args,
|
|
212
|
+
what,
|
|
213
|
+
FORGE_TIMEOUTS.api,
|
|
214
|
+
{ graphql: true },
|
|
215
|
+
);
|
|
216
|
+
if (!response.ok) return response;
|
|
217
|
+
if (response.data.errors?.length) {
|
|
218
|
+
const message = redactText(response.data.errors[0]?.message ?? "GraphQL error").slice(0, 300);
|
|
219
|
+
return /could not resolve to a (pullrequest|repository)/iu.test(message)
|
|
220
|
+
? failure("not_found", `${what}: ${message}`)
|
|
221
|
+
: failure("failed", `${what}: ${message}`);
|
|
222
|
+
}
|
|
223
|
+
if (!response.data.data) return failure("failed", `${what}: empty GraphQL response`);
|
|
224
|
+
return success(response.data.data);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const fetchPr = (
|
|
228
|
+
number: number,
|
|
229
|
+
cursors: { threads: string | null; contexts: string | null },
|
|
230
|
+
): ForgeResult<GqlPr> => {
|
|
231
|
+
const data = graphql<{ repository: { pullRequest: GqlPr | null } | null }>(
|
|
232
|
+
PR_STATUS_QUERY,
|
|
233
|
+
{ owner, name, number, threads: cursors.threads, contexts: cursors.contexts },
|
|
234
|
+
`pull request #${number}`,
|
|
235
|
+
);
|
|
236
|
+
if (!data.ok) return data;
|
|
237
|
+
const pr = data.data.repository?.pullRequest;
|
|
238
|
+
return pr ? success(pr) : failure("not_found", `pull request #${number} was not found`);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Contexts the base branch requires: classic branch protection (readable
|
|
243
|
+
* through the branch endpoint without admin) plus branch rulesets. Null
|
|
244
|
+
* when either source could not be read.
|
|
245
|
+
*/
|
|
246
|
+
const requiredContexts = (base: string): string[] | null => {
|
|
247
|
+
const branch = rest<{
|
|
248
|
+
protected?: boolean;
|
|
249
|
+
protection?: {
|
|
250
|
+
required_status_checks?: { contexts?: string[]; checks?: Array<{ context?: string }> };
|
|
251
|
+
};
|
|
252
|
+
}>(`repos/${repo}/branches/${encodeRef(base)}`, `branch ${base}`);
|
|
253
|
+
const rules = rest<Array<{ type?: string; parameters?: Record<string, unknown> }>>(
|
|
254
|
+
`repos/${repo}/rules/branches/${encodeRef(base)}`,
|
|
255
|
+
`rules for ${base}`,
|
|
256
|
+
);
|
|
257
|
+
if (!branch.ok || !rules.ok || !Array.isArray(rules.data)) return null;
|
|
258
|
+
const contexts = new Set<string>();
|
|
259
|
+
const classic = branch.data.protection?.required_status_checks;
|
|
260
|
+
for (const context of classic?.contexts ?? []) contexts.add(context);
|
|
261
|
+
for (const check of classic?.checks ?? []) if (check.context) contexts.add(check.context);
|
|
262
|
+
for (const rule of rules.data)
|
|
263
|
+
if (rule.type === "required_status_checks") {
|
|
264
|
+
const listed = rule.parameters?.required_status_checks;
|
|
265
|
+
if (Array.isArray(listed))
|
|
266
|
+
for (const item of listed as Array<{ context?: unknown }>)
|
|
267
|
+
if (typeof item.context === "string") contexts.add(item.context);
|
|
268
|
+
}
|
|
269
|
+
return [...contexts];
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
kind: "github",
|
|
274
|
+
apiHost,
|
|
275
|
+
repo,
|
|
276
|
+
|
|
277
|
+
identity(expected) {
|
|
278
|
+
const run = runner("gh", ["api", "user"], { timeoutMs: FORGE_TIMEOUTS.api });
|
|
279
|
+
if (run.status !== 0) {
|
|
280
|
+
// App/Actions installation tokens cannot read /user; the token is
|
|
281
|
+
// still valid for reads, so the check is skipped, not failed.
|
|
282
|
+
if (/HTTP 403|resource not accessible by integration/iu.test(run.stderr))
|
|
283
|
+
return success({
|
|
284
|
+
login: null,
|
|
285
|
+
expected,
|
|
286
|
+
matches: null,
|
|
287
|
+
note: "the credential cannot read /user (app or Actions token); identity not checked",
|
|
288
|
+
});
|
|
289
|
+
return cliFailure("gh", apiHost, run, "user", FORGE_TIMEOUTS.api);
|
|
290
|
+
}
|
|
291
|
+
let login = "";
|
|
292
|
+
try {
|
|
293
|
+
const user = JSON.parse(run.stdout) as { login?: unknown };
|
|
294
|
+
login = typeof user.login === "string" ? user.login : "";
|
|
295
|
+
} catch {
|
|
296
|
+
// handled below
|
|
297
|
+
}
|
|
298
|
+
if (!login) return failure("failed", "gh api user returned no login");
|
|
299
|
+
return success({
|
|
300
|
+
login,
|
|
301
|
+
expected,
|
|
302
|
+
matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
repoInfo(target) {
|
|
307
|
+
const info = rest<{ id?: number; fork?: boolean; parent?: { full_name?: string } }>(
|
|
308
|
+
`repos/${target}`,
|
|
309
|
+
`repository ${target}`,
|
|
310
|
+
);
|
|
311
|
+
if (!info.ok) return info;
|
|
312
|
+
return success({
|
|
313
|
+
id: typeof info.data.id === "number" ? info.data.id : null,
|
|
314
|
+
parent: info.data.fork && info.data.parent?.full_name ? info.data.parent.full_name : null,
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
findPr(head, match) {
|
|
319
|
+
const data = graphql<{
|
|
320
|
+
repository: {
|
|
321
|
+
pullRequests: {
|
|
322
|
+
nodes: Array<{
|
|
323
|
+
number: number;
|
|
324
|
+
url: string;
|
|
325
|
+
state: string;
|
|
326
|
+
headRefName: string;
|
|
327
|
+
headRefOid: string | null;
|
|
328
|
+
headRepositoryOwner: { login: string } | null;
|
|
329
|
+
}>;
|
|
330
|
+
};
|
|
331
|
+
} | null;
|
|
332
|
+
}>(FIND_PR_QUERY, { owner, name, head }, `pull requests for ${head}`);
|
|
333
|
+
if (!data.ok) return data;
|
|
334
|
+
const nodes = data.data.repository?.pullRequests.nodes ?? [];
|
|
335
|
+
// A branch name alone proves nothing: anyone can open a PR from a
|
|
336
|
+
// same-named branch in their fork. The head owner or head sha must match.
|
|
337
|
+
const ours = nodes.filter(
|
|
338
|
+
(node) =>
|
|
339
|
+
node.headRefName === head &&
|
|
340
|
+
((match.owner !== null &&
|
|
341
|
+
node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
|
|
342
|
+
(match.sha !== null && node.headRefOid === match.sha)),
|
|
343
|
+
);
|
|
344
|
+
const pick = ours.find((node) => node.state === "OPEN") ?? ours[0];
|
|
345
|
+
if (!pick) return success(null);
|
|
346
|
+
const ref: PrRef = {
|
|
347
|
+
number: pick.number,
|
|
348
|
+
url: pick.url,
|
|
349
|
+
state: prState(pick.state),
|
|
350
|
+
headBranch: pick.headRefName,
|
|
351
|
+
headSha: pick.headRefOid,
|
|
352
|
+
};
|
|
353
|
+
return success(ref);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
prStatus(number) {
|
|
357
|
+
const first = fetchPr(number, { threads: null, contexts: null });
|
|
358
|
+
if (!first.ok) return first;
|
|
359
|
+
const pr = first.data;
|
|
360
|
+
const threads = [...pr.reviewThreads.nodes];
|
|
361
|
+
const rollup = pr.commits.nodes[0]?.commit.statusCheckRollup ?? null;
|
|
362
|
+
const contexts = [...(rollup?.contexts.nodes ?? [])];
|
|
363
|
+
let threadPage = pr.reviewThreads.pageInfo;
|
|
364
|
+
let contextPage = rollup?.contexts.pageInfo ?? { hasNextPage: false, endCursor: null };
|
|
365
|
+
let pages = 1;
|
|
366
|
+
// Each follow-up query advances whichever list still has pages.
|
|
367
|
+
while ((threadPage.hasNextPage || contextPage.hasNextPage) && pages < maxPages) {
|
|
368
|
+
pages += 1;
|
|
369
|
+
const next = fetchPr(number, {
|
|
370
|
+
threads: threadPage.hasNextPage ? threadPage.endCursor : null,
|
|
371
|
+
contexts: contextPage.hasNextPage ? contextPage.endCursor : null,
|
|
372
|
+
});
|
|
373
|
+
if (!next.ok) return next;
|
|
374
|
+
if (next.data.headRefOid !== pr.headRefOid)
|
|
375
|
+
return failure("failed", `pull request #${number} head moved while paging; retry`);
|
|
376
|
+
if (threadPage.hasNextPage) {
|
|
377
|
+
threads.push(...next.data.reviewThreads.nodes);
|
|
378
|
+
threadPage = next.data.reviewThreads.pageInfo;
|
|
379
|
+
}
|
|
380
|
+
const nextRollup = next.data.commits.nodes[0]?.commit.statusCheckRollup;
|
|
381
|
+
if (contextPage.hasNextPage && nextRollup) {
|
|
382
|
+
contexts.push(...nextRollup.contexts.nodes);
|
|
383
|
+
contextPage = nextRollup.contexts.pageInfo;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const checks = latestPerName(
|
|
387
|
+
contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
|
|
388
|
+
);
|
|
389
|
+
const open = pr.state === "OPEN";
|
|
390
|
+
const status: ForgePrStatus = {
|
|
391
|
+
number: pr.number,
|
|
392
|
+
url: pr.url,
|
|
393
|
+
state: prState(pr.state),
|
|
394
|
+
draft: pr.isDraft,
|
|
395
|
+
base: pr.baseRefName,
|
|
396
|
+
baseSha: pr.baseRef?.target?.oid ?? pr.baseRefOid,
|
|
397
|
+
head: { branch: pr.headRefName, sha: pr.headRefOid },
|
|
398
|
+
mergeable:
|
|
399
|
+
pr.mergeable === "MERGEABLE" ? "yes" : pr.mergeable === "CONFLICTING" ? "no" : "unknown",
|
|
400
|
+
conflicts: pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY",
|
|
401
|
+
// BEHIND: branch protection requires an up-to-date head and it is not.
|
|
402
|
+
rebaseRequired: pr.mergeStateStatus === "BEHIND",
|
|
403
|
+
reviewDecision: pr.reviewDecision?.toLowerCase() ?? null,
|
|
404
|
+
mergeState: pr.mergeStateStatus?.toLowerCase() ?? null,
|
|
405
|
+
inMergeQueue: pr.isInMergeQueue === true,
|
|
406
|
+
checks,
|
|
407
|
+
requiredContexts: open ? requiredContexts(pr.baseRefName) : null,
|
|
408
|
+
threads: threads.filter((node) => !node.isResolved).map(threadFrom),
|
|
409
|
+
truncated: threadPage.hasNextPage || contextPage.hasNextPage,
|
|
410
|
+
};
|
|
411
|
+
return success(status);
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
jobLogTail(job, lines) {
|
|
415
|
+
const text = apiText(
|
|
416
|
+
runner,
|
|
417
|
+
"gh",
|
|
418
|
+
apiHost,
|
|
419
|
+
["api", `repos/${repo}/actions/jobs/${job.jobId}/logs`],
|
|
420
|
+
`job ${job.jobId} log`,
|
|
421
|
+
FORGE_TIMEOUTS.log,
|
|
422
|
+
);
|
|
423
|
+
return text.ok ? success(logTail(text.data, lines)) : text;
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
rerun(target) {
|
|
427
|
+
const endpoint =
|
|
428
|
+
target.kind === "failed_in_run"
|
|
429
|
+
? `repos/${repo}/actions/runs/${target.runId}/rerun-failed-jobs`
|
|
430
|
+
: `repos/${repo}/actions/jobs/${target.jobId}/rerun`;
|
|
431
|
+
const what =
|
|
432
|
+
target.kind === "failed_in_run"
|
|
433
|
+
? `rerun of run ${target.runId}`
|
|
434
|
+
: `rerun of job ${target.jobId}`;
|
|
435
|
+
const run = apiText(
|
|
436
|
+
runner,
|
|
437
|
+
"gh",
|
|
438
|
+
apiHost,
|
|
439
|
+
["api", "-X", "POST", endpoint],
|
|
440
|
+
what,
|
|
441
|
+
FORGE_TIMEOUTS.api,
|
|
442
|
+
);
|
|
443
|
+
return run.ok ? success(undefined) : run;
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|