@brainervirus/workit-core 2.3.0 → 2.5.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/autonomy.ts +135 -0
- package/src/core/external-action-effects.ts +4 -9
- package/src/core/pr-create.ts +2 -71
- package/src/forge/exec.ts +221 -0
- package/src/forge/github.ts +529 -0
- package/src/forge/gitlab.ts +556 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -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 +166 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +120 -11
- package/src/ledger.ts +17 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The rerun-once record (design §2.1 `ci rerun`): one row per check rerun on
|
|
2
|
+
// a given PR head, so a second rerun of the same check on the same head needs
|
|
3
|
+
// `--force`. Rows use the ledger row shape (`type: "ci.rerun"`) so S13 can
|
|
4
|
+
// fold this file into `ledger.jsonl`.
|
|
5
|
+
//
|
|
6
|
+
// It lives in the git common dir (`<common>/workit/ci-reruns.jsonl`, design
|
|
7
|
+
// §0 #5) so every worktree of the repository shares it and `git clean` does
|
|
8
|
+
// not drop it. Append-only; unreadable lines are skipped.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { gitCommonDir } from "../git/rev";
|
|
12
|
+
|
|
13
|
+
export type RerunRow = {
|
|
14
|
+
v: 1;
|
|
15
|
+
at: string;
|
|
16
|
+
type: "ci.rerun";
|
|
17
|
+
forge: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
pr: number;
|
|
20
|
+
head: string;
|
|
21
|
+
check: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
forced: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function rerunLogPath(cwd: string): string | null {
|
|
27
|
+
const common = gitCommonDir(cwd);
|
|
28
|
+
return common ? path.join(common, "workit", "ci-reruns.jsonl") : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRow = (value: unknown): value is RerunRow => {
|
|
32
|
+
const row = value as Partial<RerunRow> | null;
|
|
33
|
+
return (
|
|
34
|
+
!!row &&
|
|
35
|
+
row.type === "ci.rerun" &&
|
|
36
|
+
typeof row.pr === "number" &&
|
|
37
|
+
typeof row.head === "string" &&
|
|
38
|
+
typeof row.check === "string" &&
|
|
39
|
+
typeof row.repo === "string"
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** How often each check was rerun on `head` of PR `pr` in `repo`. */
|
|
44
|
+
export function rerunCounts(
|
|
45
|
+
cwd: string,
|
|
46
|
+
key: { repo: string; pr: number; head: string },
|
|
47
|
+
): Map<string, number> {
|
|
48
|
+
const counts = new Map<string, number>();
|
|
49
|
+
const file = rerunLogPath(cwd);
|
|
50
|
+
if (!file) return counts;
|
|
51
|
+
let text: string;
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(file, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return counts;
|
|
56
|
+
}
|
|
57
|
+
for (const raw of text.split("\n")) {
|
|
58
|
+
if (!raw.trim()) continue;
|
|
59
|
+
let row: unknown;
|
|
60
|
+
try {
|
|
61
|
+
row = JSON.parse(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (isRow(row) && row.repo === key.repo && row.pr === key.pr && row.head === key.head)
|
|
66
|
+
counts.set(row.check, (counts.get(row.check) ?? 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
return counts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Append rows (one write, O_APPEND). Returns false when the file cannot be written. */
|
|
72
|
+
export function recordReruns(cwd: string, rows: readonly RerunRow[]): boolean {
|
|
73
|
+
if (rows.length === 0) return true;
|
|
74
|
+
const file = rerunLogPath(cwd);
|
|
75
|
+
if (!file) return false;
|
|
76
|
+
try {
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
fs.appendFileSync(file, rows.map((row) => `${JSON.stringify(row)}\n`).join(""));
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const LOCK_WAIT_MS = 2_000;
|
|
86
|
+
const LOCK_STALE_MS = 60_000;
|
|
87
|
+
|
|
88
|
+
const pause = (ms: number): void => {
|
|
89
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run `fn` holding `<common>/workit/ci-reruns.lock` (atomic create). Two
|
|
94
|
+
* concurrent `ci rerun`s therefore cannot both pass the once-per-head check.
|
|
95
|
+
* A lock older than a minute is from a dead process and is taken over.
|
|
96
|
+
* Null when the lock stays held past the wait (the caller reports busy).
|
|
97
|
+
*/
|
|
98
|
+
export function withRerunLock<T>(cwd: string, fn: () => T): T | null {
|
|
99
|
+
const file = rerunLogPath(cwd);
|
|
100
|
+
if (!file) return fn();
|
|
101
|
+
const lock = `${file.slice(0, -".jsonl".length)}.lock`;
|
|
102
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
103
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
104
|
+
for (;;) {
|
|
105
|
+
try {
|
|
106
|
+
fs.closeSync(fs.openSync(lock, "wx"));
|
|
107
|
+
break;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
110
|
+
try {
|
|
111
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
112
|
+
fs.rmSync(lock, { force: true });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (Date.now() >= until) return null;
|
|
119
|
+
pause(50);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return fn();
|
|
124
|
+
} finally {
|
|
125
|
+
fs.rmSync(lock, { force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Which forge, which repositories, and which credential (D16, design §0 #6,
|
|
2
|
+
// §2.0 Identity).
|
|
3
|
+
//
|
|
4
|
+
// - The push remote host decides GitHub vs GitLab. A workspace provider that
|
|
5
|
+
// disagrees is `blocked`, never silently overridden.
|
|
6
|
+
// - PRs/MRs live in the base repository: an `upstream` remote on the same
|
|
7
|
+
// forge, else the push repo's fork parent, else the push repo itself.
|
|
8
|
+
// - Every API call carries the workspace's credential explicitly, so the
|
|
9
|
+
// machine-wide active gh/glab account is never switched or relied on:
|
|
10
|
+
// the workspace `vcs.tokenFile`, else (GitHub) `gh auth token --user
|
|
11
|
+
// <vcs.account>`, else the CLI's default login. The token is never printed.
|
|
12
|
+
// Config is read, never written.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { readVcsConfig } from "../core/vcs-config";
|
|
15
|
+
import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
|
|
16
|
+
import {
|
|
17
|
+
deriveForge,
|
|
18
|
+
forgeConflict,
|
|
19
|
+
pushForge,
|
|
20
|
+
pushUrl,
|
|
21
|
+
remoteNames,
|
|
22
|
+
type DerivedForge,
|
|
23
|
+
type ForgeHosts,
|
|
24
|
+
type SshConfig,
|
|
25
|
+
} from "../git/rev";
|
|
26
|
+
import { FORGE_TIMEOUTS, systemRunner, type ForgeRunner } from "./exec";
|
|
27
|
+
import { createGitHubForge } from "./github";
|
|
28
|
+
import { createGitLabForge } from "./gitlab";
|
|
29
|
+
import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
|
|
30
|
+
|
|
31
|
+
export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
|
|
32
|
+
|
|
33
|
+
export type ResolvedForge = {
|
|
34
|
+
/** Bound to the base repository (where the PR/MR lives). */
|
|
35
|
+
forge: Forge;
|
|
36
|
+
/** Push remote name. */
|
|
37
|
+
remote: string;
|
|
38
|
+
/** Redacted push URL. */
|
|
39
|
+
url: string;
|
|
40
|
+
derived: DerivedForge;
|
|
41
|
+
workspace: string | null;
|
|
42
|
+
/** The workspace `vcs.account`, when configured. */
|
|
43
|
+
expectedAccount: string | null;
|
|
44
|
+
credential: CredentialSource;
|
|
45
|
+
/** The push (head) repository; differs from forge.repo for a fork. */
|
|
46
|
+
headRepo: string;
|
|
47
|
+
/** GitLab project id of the push repository (MR source project). */
|
|
48
|
+
headProjectId: number | null;
|
|
49
|
+
fork: boolean;
|
|
50
|
+
/** Remote to fetch base commits from (the base repo's remote when one exists). */
|
|
51
|
+
baseRemote: string;
|
|
52
|
+
/** Absolute deadline (ms, `now()` clock) every API call is clamped to; null = none. */
|
|
53
|
+
limits: { deadline: number | null };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ResolveOptions = {
|
|
57
|
+
branch?: string | null;
|
|
58
|
+
env?: NodeJS.ProcessEnv;
|
|
59
|
+
/** Replaces the real gh/glab (tests replay recorded API fixtures). */
|
|
60
|
+
runner?: ForgeRunner;
|
|
61
|
+
/** Clock for the deadline (tests use a virtual one). */
|
|
62
|
+
now?: () => number;
|
|
63
|
+
/** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
|
|
64
|
+
sshConfig?: string | SshConfig;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const configuredHosts = (): ForgeHosts => {
|
|
68
|
+
const vcs = readVcsConfig();
|
|
69
|
+
if (vcs.status !== "valid") return {};
|
|
70
|
+
const host = (kind: "github" | "gitlab"): string[] => {
|
|
71
|
+
const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
|
|
72
|
+
return typeof value === "string" && value.trim() ? [value.trim()] : [];
|
|
73
|
+
};
|
|
74
|
+
return { github: host("github"), gitlab: host("gitlab") };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
|
|
78
|
+
derived.kind === "github"
|
|
79
|
+
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
80
|
+
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
81
|
+
|
|
82
|
+
export function resolveForge(
|
|
83
|
+
cwd: string,
|
|
84
|
+
options: ResolveOptions = {},
|
|
85
|
+
): ForgeResult<ResolvedForge> {
|
|
86
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
87
|
+
try {
|
|
88
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return failure(
|
|
91
|
+
"blocked",
|
|
92
|
+
error instanceof Error ? error.message : String(error),
|
|
93
|
+
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const hosts = configuredHosts();
|
|
97
|
+
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
98
|
+
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
99
|
+
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
100
|
+
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
101
|
+
const derived = pushed.forge;
|
|
102
|
+
const { kind, apiHost } = derived;
|
|
103
|
+
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
104
|
+
const now = options.now ?? Date.now;
|
|
105
|
+
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
106
|
+
|
|
107
|
+
// The credential for every call.
|
|
108
|
+
let token: string | undefined;
|
|
109
|
+
let credential: CredentialSource = "cli_login";
|
|
110
|
+
const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
|
|
111
|
+
if (typeof tokenFile === "string" && tokenFile.trim()) {
|
|
112
|
+
// A stored token goes only to the host it is for: the public forge (also
|
|
113
|
+
// reached through an ~/.ssh/config alias) or a host configured
|
|
114
|
+
// explicitly (vcs.json github.host/gitlab.host, workspace vcs.host).
|
|
115
|
+
// A host merely *named* like a forge (github.evil.com) never gets it.
|
|
116
|
+
const workspaceHost = (workspace?.vcs as { host?: unknown } | undefined)?.host;
|
|
117
|
+
const explicit =
|
|
118
|
+
typeof workspaceHost === "string" &&
|
|
119
|
+
workspaceHost
|
|
120
|
+
.trim()
|
|
121
|
+
.toLowerCase()
|
|
122
|
+
.replace(/^https?:\/\//u, "")
|
|
123
|
+
.replace(/\/.*$/u, "") === apiHost;
|
|
124
|
+
if (derived.via === "host_name" && !explicit)
|
|
125
|
+
return failure(
|
|
126
|
+
"blocked",
|
|
127
|
+
`token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
|
|
128
|
+
`if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
|
|
129
|
+
);
|
|
130
|
+
try {
|
|
131
|
+
token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
|
|
132
|
+
} catch {
|
|
133
|
+
return failure(
|
|
134
|
+
"blocked",
|
|
135
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile cannot be read`,
|
|
136
|
+
"fix vcs.tokenFile in ~/.config/workit/workspaces.json",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!token)
|
|
140
|
+
return failure(
|
|
141
|
+
"blocked",
|
|
142
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
|
|
143
|
+
"fix vcs.tokenFile in ~/.config/workit/workspaces.json",
|
|
144
|
+
);
|
|
145
|
+
credential = "workspace_token_file";
|
|
146
|
+
} else if (expectedAccount && kind === "github") {
|
|
147
|
+
const issued = base("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
|
|
148
|
+
timeoutMs: FORGE_TIMEOUTS.api,
|
|
149
|
+
});
|
|
150
|
+
if (issued.missing)
|
|
151
|
+
return failure(
|
|
152
|
+
"unavailable",
|
|
153
|
+
"gh is not installed",
|
|
154
|
+
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
155
|
+
);
|
|
156
|
+
token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
157
|
+
if (!token)
|
|
158
|
+
return failure(
|
|
159
|
+
"blocked",
|
|
160
|
+
`identity_unavailable: gh has no login for ${expectedAccount} on ${apiHost} (workspace ${workspace?.name ?? "?"})`,
|
|
161
|
+
`gh auth login --hostname ${apiHost} # sign in as ${expectedAccount}; workit passes that account's token per call and never switches the active account`,
|
|
162
|
+
);
|
|
163
|
+
credential = "gh_account_token";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const limits: { deadline: number | null } = { deadline: null };
|
|
167
|
+
const runner: ForgeRunner = (bin, args, callOptions) => {
|
|
168
|
+
let timeoutMs = callOptions.timeoutMs;
|
|
169
|
+
if (limits.deadline !== null) {
|
|
170
|
+
const remaining = limits.deadline - now();
|
|
171
|
+
if (remaining <= 0)
|
|
172
|
+
return { status: null, stdout: "", stderr: "", timedOut: true, missing: false };
|
|
173
|
+
timeoutMs = Math.min(timeoutMs, remaining);
|
|
174
|
+
}
|
|
175
|
+
return base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Base repository: an `upstream` remote on the same forge, else the fork parent.
|
|
179
|
+
const headRepo = derived.repo;
|
|
180
|
+
const probe = createForge(derived, headRepo, runner);
|
|
181
|
+
const info = probe.repoInfo(headRepo);
|
|
182
|
+
if (!info.ok && info.code !== "not_found") return info;
|
|
183
|
+
const headProjectId = info.ok ? info.data.id : null;
|
|
184
|
+
const remotes = remoteNames(cwd);
|
|
185
|
+
const repoOfRemote = (name: string): string | null => {
|
|
186
|
+
const url = pushUrl(cwd, name);
|
|
187
|
+
const other = url ? deriveForge(url, { hosts, sshConfig: options.sshConfig }) : null;
|
|
188
|
+
return other && other.kind === kind && other.apiHost === apiHost ? other.repo : null;
|
|
189
|
+
};
|
|
190
|
+
const upstream =
|
|
191
|
+
pushed.remote !== "upstream" && remotes.includes("upstream") ? repoOfRemote("upstream") : null;
|
|
192
|
+
const baseRepo =
|
|
193
|
+
upstream && upstream !== headRepo
|
|
194
|
+
? upstream
|
|
195
|
+
: info.ok && info.data.parent
|
|
196
|
+
? info.data.parent
|
|
197
|
+
: headRepo;
|
|
198
|
+
const baseRemote =
|
|
199
|
+
baseRepo === headRepo
|
|
200
|
+
? pushed.remote
|
|
201
|
+
: (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
|
|
202
|
+
|
|
203
|
+
return success({
|
|
204
|
+
forge: baseRepo === headRepo ? probe : createForge(derived, baseRepo, runner),
|
|
205
|
+
remote: pushed.remote,
|
|
206
|
+
url: pushed.url,
|
|
207
|
+
derived,
|
|
208
|
+
workspace: workspace?.name ?? null,
|
|
209
|
+
expectedAccount,
|
|
210
|
+
credential,
|
|
211
|
+
headRepo,
|
|
212
|
+
headProjectId,
|
|
213
|
+
fork: baseRepo !== headRepo,
|
|
214
|
+
baseRemote,
|
|
215
|
+
limits,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The credential in use must belong to the workspace account. A mismatch is
|
|
221
|
+
* `blocked` with the fix (never a global account switch); no configured
|
|
222
|
+
* account passes; a credential that cannot read /user passes with a note.
|
|
223
|
+
*/
|
|
224
|
+
export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
|
|
225
|
+
const { forge, expectedAccount } = resolved;
|
|
226
|
+
const identity = forge.identity(expectedAccount);
|
|
227
|
+
if (!identity.ok) return identity;
|
|
228
|
+
if (identity.data.matches === false) {
|
|
229
|
+
const bin = forge.kind === "github" ? "gh" : "glab";
|
|
230
|
+
const where = `workspace ${resolved.workspace ?? "?"}`;
|
|
231
|
+
return failure(
|
|
232
|
+
"blocked",
|
|
233
|
+
`identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${identity.data.login} but ${where} expects ${expectedAccount}`,
|
|
234
|
+
resolved.credential === "workspace_token_file"
|
|
235
|
+
? `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`
|
|
236
|
+
: `${bin} auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return identity;
|
|
240
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Forge-neutral shapes for the GitHub and GitLab adapters (design §2.0, S10).
|
|
2
|
+
// Plain TS: the CLI verbs load this on their path.
|
|
3
|
+
import type { ForgeKind } from "../git/rev";
|
|
4
|
+
|
|
5
|
+
export type { ForgeKind };
|
|
6
|
+
|
|
7
|
+
export type ForgeErrorCode =
|
|
8
|
+
| "unavailable"
|
|
9
|
+
| "not_found"
|
|
10
|
+
| "failed"
|
|
11
|
+
| "blocked"
|
|
12
|
+
| "busy"
|
|
13
|
+
| "invalid_input";
|
|
14
|
+
|
|
15
|
+
export type ForgeResult<T> =
|
|
16
|
+
| { ok: true; data: T }
|
|
17
|
+
| { ok: false; code: ForgeErrorCode; error: string; unblock?: string };
|
|
18
|
+
|
|
19
|
+
export const success = <T>(data: T): ForgeResult<T> => ({ ok: true, data });
|
|
20
|
+
|
|
21
|
+
export const failure = <T = never>(
|
|
22
|
+
code: ForgeErrorCode,
|
|
23
|
+
error: string,
|
|
24
|
+
unblock?: string,
|
|
25
|
+
): ForgeResult<T> => ({ ok: false, code, error, ...(unblock ? { unblock } : {}) });
|
|
26
|
+
|
|
27
|
+
export type PrState = "open" | "closed" | "merged";
|
|
28
|
+
|
|
29
|
+
export type PrRef = {
|
|
30
|
+
number: number;
|
|
31
|
+
url: string;
|
|
32
|
+
state: PrState;
|
|
33
|
+
headBranch: string;
|
|
34
|
+
headSha: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** How a branch-name lookup proves a PR is ours (M5): head owner/project or head sha. */
|
|
38
|
+
export type PrMatch = {
|
|
39
|
+
/** GitHub: the head repository owner (the push repo's owner). */
|
|
40
|
+
owner: string | null;
|
|
41
|
+
/** GitLab: the source project id (the push project). */
|
|
42
|
+
projectId: number | null;
|
|
43
|
+
/** A PR whose head is exactly this commit is ours whatever its owner. */
|
|
44
|
+
sha: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** One CI check (GitHub check run / commit status, GitLab job or bridge). */
|
|
48
|
+
export type ForgeCheck = {
|
|
49
|
+
/** `<workflow> / <job>` on GitHub, `<stage> / <job>` on GitLab, or the status context. */
|
|
50
|
+
name: string;
|
|
51
|
+
/** The raw context/job name branch protection refers to. */
|
|
52
|
+
context: string;
|
|
53
|
+
state: "failing" | "pending" | "passing" | "skipped";
|
|
54
|
+
/** The forge's own verdict, lower-cased (failure, timed_out, failed, canceled…). */
|
|
55
|
+
conclusion: string | null;
|
|
56
|
+
url: string | null;
|
|
57
|
+
/** GitHub workflow run id / GitLab pipeline id. */
|
|
58
|
+
runId: number | null;
|
|
59
|
+
/** GitHub Actions job id / GitLab job id; null for external statuses and bridges. */
|
|
60
|
+
jobId: number | null;
|
|
61
|
+
/** GitLab: the project that owns the pipeline (forks run elsewhere). */
|
|
62
|
+
scope: string | null;
|
|
63
|
+
/** Required by branch protection (GitHub isRequired); null when the forge cannot say. */
|
|
64
|
+
required: boolean | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ForgeThread = {
|
|
68
|
+
id: string;
|
|
69
|
+
path: string | null;
|
|
70
|
+
line: number | null;
|
|
71
|
+
author: string | null;
|
|
72
|
+
isBot: boolean;
|
|
73
|
+
outdated: boolean;
|
|
74
|
+
url: string | null;
|
|
75
|
+
/** First comment, redacted and cut to 300 chars. Untrusted data. */
|
|
76
|
+
body: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type ForgePrStatus = {
|
|
80
|
+
number: number;
|
|
81
|
+
url: string;
|
|
82
|
+
state: PrState;
|
|
83
|
+
draft: boolean;
|
|
84
|
+
base: string;
|
|
85
|
+
/** The current tip of the base branch, from the API. */
|
|
86
|
+
baseSha: string | null;
|
|
87
|
+
head: { branch: string; sha: string };
|
|
88
|
+
mergeable: "yes" | "no" | "unknown";
|
|
89
|
+
conflicts: boolean;
|
|
90
|
+
/** Branch protection requires the head to be up to date and it is not. */
|
|
91
|
+
rebaseRequired: boolean;
|
|
92
|
+
/** approved | changes_requested | review_required | null. */
|
|
93
|
+
reviewDecision: string | null;
|
|
94
|
+
/** GitHub mergeStateStatus / GitLab detailed_merge_status, lower-cased. */
|
|
95
|
+
mergeState: string | null;
|
|
96
|
+
inMergeQueue: boolean;
|
|
97
|
+
checks: ForgeCheck[];
|
|
98
|
+
/**
|
|
99
|
+
* Contexts branch protection requires (GitHub branch protection +
|
|
100
|
+
* rulesets); null when they could not be read.
|
|
101
|
+
*/
|
|
102
|
+
requiredContexts: string[] | null;
|
|
103
|
+
threads: ForgeThread[];
|
|
104
|
+
/** True when a list was cut at the page cap (the counts are lower bounds). */
|
|
105
|
+
truncated: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type JobRef = { jobId: number; scope: string | null };
|
|
109
|
+
|
|
110
|
+
export type RerunTarget =
|
|
111
|
+
| { kind: "failed_in_run"; runId: number; scope: string | null }
|
|
112
|
+
| { kind: "job"; jobId: number; scope: string | null };
|
|
113
|
+
|
|
114
|
+
export type Identity = {
|
|
115
|
+
login: string | null;
|
|
116
|
+
expected: string | null;
|
|
117
|
+
matches: boolean | null;
|
|
118
|
+
/** Why the login could not be checked (e.g. an app token without /user). */
|
|
119
|
+
note?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type MergeMethod = "squash" | "merge" | "rebase";
|
|
123
|
+
|
|
124
|
+
/** What `workit pr create` asks the forge for (S11). */
|
|
125
|
+
export type CreatePrInput = {
|
|
126
|
+
/** Source branch in the push (head) repository. */
|
|
127
|
+
head: string;
|
|
128
|
+
/** owner/name (GitHub) or group/project (GitLab) of the push repository. */
|
|
129
|
+
headRepo: string;
|
|
130
|
+
/** GitLab: the push project id (MRs from a fork are opened from it). */
|
|
131
|
+
headProjectId: number | null;
|
|
132
|
+
base: string;
|
|
133
|
+
title: string;
|
|
134
|
+
body: string;
|
|
135
|
+
draft: boolean;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export type MergeResult = {
|
|
139
|
+
/** The merge (or squash/rebase tip) commit the forge reported, when it did. */
|
|
140
|
+
mergeSha: string | null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** A repository as the forge sees it: id and the repo it was forked from. */
|
|
144
|
+
export type RepoInfo = { id: number | null; parent: string | null };
|
|
145
|
+
|
|
146
|
+
export interface Forge {
|
|
147
|
+
kind: ForgeKind;
|
|
148
|
+
apiHost: string;
|
|
149
|
+
/** The base repository PRs/MRs live in. */
|
|
150
|
+
repo: string;
|
|
151
|
+
identity(expected: string | null): ForgeResult<Identity>;
|
|
152
|
+
repoInfo(repo: string): ForgeResult<RepoInfo>;
|
|
153
|
+
findPr(head: string, match: PrMatch): ForgeResult<PrRef | null>;
|
|
154
|
+
prStatus(n: number): ForgeResult<ForgePrStatus>;
|
|
155
|
+
jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
|
|
156
|
+
rerun(target: RerunTarget): ForgeResult<void>;
|
|
157
|
+
/** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
|
|
158
|
+
createPr(input: CreatePrInput): ForgeResult<PrRef>;
|
|
159
|
+
/**
|
|
160
|
+
* Merge with the head guard: the forge refuses (`blocked`, head_moved) when
|
|
161
|
+
* the PR head is no longer `sha`.
|
|
162
|
+
*/
|
|
163
|
+
merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
|
|
164
|
+
/** Retarget a PR/MR to another base branch (S12 stacks). */
|
|
165
|
+
updateBase(n: number, base: string): ForgeResult<void>;
|
|
166
|
+
}
|