@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { execFileSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stopping a child and everything it started. Both the Run Recipe (an app under a `make` or `bun
|
|
5
|
+
* run` wrapper) and the Worker Adapters (a coding agent running shells) spawn their child in its
|
|
6
|
+
* own process group (`detached: true`, everywhere but Windows) so that a stop reaches the whole
|
|
7
|
+
* group: SIGTERM first, SIGKILL to whatever is still there after a grace period.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Whether children are put in their own process group on this platform. */
|
|
11
|
+
export const PROCESS_GROUPS = process.platform !== "win32";
|
|
12
|
+
|
|
13
|
+
/** Signals the child's whole process group, or the child alone where groups are unavailable or the
|
|
14
|
+
* group is already gone. */
|
|
15
|
+
export function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
16
|
+
const pid = child.pid;
|
|
17
|
+
try {
|
|
18
|
+
if (PROCESS_GROUPS && pid !== undefined) process.kill(-pid, signal);
|
|
19
|
+
else child.kill(signal);
|
|
20
|
+
} catch {
|
|
21
|
+
try {
|
|
22
|
+
child.kill(signal);
|
|
23
|
+
} catch {
|
|
24
|
+
// Already gone.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether anything in the child's process group is still alive: the shell may be gone while the
|
|
30
|
+
* server it started is still shutting down or ignored the signal. `childDone` is the caller's
|
|
31
|
+
* knowledge that the child itself has exited, for platforms without groups. */
|
|
32
|
+
export function processGroupAlive(child: ChildProcess, childDone: boolean): boolean {
|
|
33
|
+
const pid = child.pid;
|
|
34
|
+
if (pid === undefined || !PROCESS_GROUPS) return !childDone;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SIGTERM to the group, SIGKILL to whatever is still in it after `graceMs`, then wait for the
|
|
45
|
+
* child's own exit (`exited`, which must never reject). Resolves once the group is gone.
|
|
46
|
+
*/
|
|
47
|
+
export async function stopProcessGroup(child: ChildProcess, exited: Promise<unknown>, graceMs: number, isDone: () => boolean): Promise<void> {
|
|
48
|
+
const deadline = Date.now() + graceMs;
|
|
49
|
+
if (!isDone()) signalProcessGroup(child, "SIGTERM");
|
|
50
|
+
while (processGroupAlive(child, isDone()) && Date.now() < deadline) await sleep(50);
|
|
51
|
+
if (processGroupAlive(child, isDone())) signalProcessGroup(child, "SIGKILL");
|
|
52
|
+
await exited;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A stop by pid that could not be carried out safely; nothing was signalled, or the process
|
|
56
|
+
* outlived SIGKILL. The caller reports it and leaves the Process Table as it was. */
|
|
57
|
+
export class StopRefusedError extends Error {}
|
|
58
|
+
|
|
59
|
+
/** What `ps` says about a process, for telling a Worker from a recycled pid. */
|
|
60
|
+
export interface ProcessIdentity {
|
|
61
|
+
/** The process group id; a Worker is spawned detached, so its own pid. */
|
|
62
|
+
pgid: number;
|
|
63
|
+
/** When the process started, epoch milliseconds; undefined when `ps` printed nothing parseable. */
|
|
64
|
+
startedAt?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StopByPidOptions {
|
|
68
|
+
/** When the Process Table row was created (`WorkerRow.startedAt`): a process that started before
|
|
69
|
+
* it cannot be the Worker the row is about. */
|
|
70
|
+
startedAt?: number;
|
|
71
|
+
/** How long the pid may take to disappear after SIGKILL before the stop is given up as refused;
|
|
72
|
+
* `graceMs` by default. */
|
|
73
|
+
killWaitMs?: number;
|
|
74
|
+
/** Test seams: the signal (or probe, with signal 0) and the identity lookup. */
|
|
75
|
+
kill?: (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
76
|
+
identity?: (pid: number) => ProcessIdentity | undefined;
|
|
77
|
+
sleep?: (ms: number) => Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `ps -o pgid=,lstart= -p <pid>`: the group id and the start time, or undefined when the process
|
|
81
|
+
* is gone (ps exits non-zero) or the platform has no ps. */
|
|
82
|
+
export function processIdentity(pid: number): ProcessIdentity | undefined {
|
|
83
|
+
let text: string;
|
|
84
|
+
try {
|
|
85
|
+
text = execFileSync("ps", ["-o", "pgid=,lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const line = text.trim().split("\n")[0]?.trim() ?? "";
|
|
90
|
+
const match = /^(\d+)\s*(.*)$/.exec(line);
|
|
91
|
+
if (!match) return undefined;
|
|
92
|
+
const started = new Date(match[2].trim().replace(/\s+/g, " ")).getTime();
|
|
93
|
+
return { pgid: Number(match[1]), startedAt: Number.isNaN(started) ? undefined : started };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stops a Worker this process does not own, by the pid the Process Table recorded (`foreman
|
|
98
|
+
* attach` and `cancel` run in another process than the daemon): SIGTERM to its process group,
|
|
99
|
+
* SIGKILL to what is still there after `graceMs`, then wait until the pid is gone. Resolves false
|
|
100
|
+
* when there was nothing alive to stop.
|
|
101
|
+
*
|
|
102
|
+
* A pid is only a name, and the OS reuses names: before anything is signalled the process must
|
|
103
|
+
* still look like the Worker (its own group leader, since Workers are spawned detached, and not
|
|
104
|
+
* started before the row was), else it is a stranger wearing a dead Worker's pid, the Worker is
|
|
105
|
+
* gone, and this resolves false without signalling; the caller forgets the pid either way. A pid
|
|
106
|
+
* this user may not signal (EPERM: another user's process) is refused outright rather than waited
|
|
107
|
+
* on, since `kill -0` would report it alive forever; so is a pid that is still there `killWaitMs`
|
|
108
|
+
* after SIGKILL.
|
|
109
|
+
*/
|
|
110
|
+
export async function stopProcessByPid(pid: number, graceMs: number, options: StopByPidOptions = {}): Promise<boolean> {
|
|
111
|
+
const kill = options.kill ?? ((target, signal) => process.kill(target, signal));
|
|
112
|
+
const identity: (pid: number) => ProcessIdentity | undefined = options.identity ?? (PROCESS_GROUPS ? processIdentity : () => ({ pgid: pid }));
|
|
113
|
+
const pause = options.sleep ?? sleep;
|
|
114
|
+
const alive = (): boolean => {
|
|
115
|
+
try {
|
|
116
|
+
kill(pid, 0);
|
|
117
|
+
return true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM") {
|
|
120
|
+
throw new StopRefusedError(`pid ${pid} belongs to another user, so it cannot be the Worker (its pid was reused after the Worker died); nothing was signalled`);
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (!alive()) return false;
|
|
126
|
+
const who = identity(pid);
|
|
127
|
+
if (!who) return false;
|
|
128
|
+
// Not a group leader, or older than the row: not the Worker, whose pid the OS has handed on.
|
|
129
|
+
if (who.pgid !== pid) return false;
|
|
130
|
+
if (options.startedAt !== undefined && who.startedAt !== undefined && who.startedAt < options.startedAt - START_SLACK_MS) return false;
|
|
131
|
+
const signal = (sig: NodeJS.Signals) => {
|
|
132
|
+
try {
|
|
133
|
+
if (PROCESS_GROUPS) kill(-pid, sig);
|
|
134
|
+
else kill(pid, sig);
|
|
135
|
+
} catch {
|
|
136
|
+
try {
|
|
137
|
+
kill(pid, sig);
|
|
138
|
+
} catch {
|
|
139
|
+
// Already gone.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const graceDeadline = Date.now() + graceMs;
|
|
144
|
+
signal("SIGTERM");
|
|
145
|
+
while (alive() && Date.now() < graceDeadline) await pause(50);
|
|
146
|
+
if (!alive()) return true;
|
|
147
|
+
signal("SIGKILL");
|
|
148
|
+
const killDeadline = Date.now() + (options.killWaitMs ?? graceMs);
|
|
149
|
+
while (alive() && Date.now() < killDeadline) await pause(50);
|
|
150
|
+
if (alive()) throw new StopRefusedError(`pid ${pid} is still there ${options.killWaitMs ?? graceMs}ms after SIGKILL; the Worker could not be stopped`);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `ps` prints start times to the second, and the row's clock and the kernel's need not agree
|
|
155
|
+
* exactly, so a process may look a little older than its row. */
|
|
156
|
+
const START_SLACK_MS = 60_000;
|
|
157
|
+
|
|
158
|
+
function sleep(ms: number): Promise<void> {
|
|
159
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
160
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote API seam: the little the Hand-off (see ../CONTEXT.md, ADR-0003) needs from the code host,
|
|
3
|
+
* which is to find, open and update one draft pull request for a branch. Nothing here can merge:
|
|
4
|
+
* the interface has no such method, and foreman-remote-api.test.ts checks that at compile time,
|
|
5
|
+
* so "the Foreman never merges" (story 26) is a shape, not a promise.
|
|
6
|
+
*
|
|
7
|
+
* Two implementations, both through the user's own CLI so the Foreman never holds a token of its
|
|
8
|
+
* own: GitLab through `glab api` and GitHub through `gh api`, chosen from the origin URL. The one
|
|
9
|
+
* write beyond the pull request itself is a comment on it, where the Gates report (OPS-271).
|
|
10
|
+
*/
|
|
11
|
+
import { execToString } from "./core/exec.js";
|
|
12
|
+
|
|
13
|
+
export interface RemotePullRequest {
|
|
14
|
+
/** The number a human sees (GitLab's iid, GitHub's number). */
|
|
15
|
+
number: number;
|
|
16
|
+
url: string;
|
|
17
|
+
title: string;
|
|
18
|
+
draft: boolean;
|
|
19
|
+
headBranch: string;
|
|
20
|
+
baseBranch: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NewPullRequest {
|
|
24
|
+
headBranch: string;
|
|
25
|
+
baseBranch: string;
|
|
26
|
+
title: string;
|
|
27
|
+
body: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a later Hand-off on the same branch rewrites. */
|
|
31
|
+
export type PullRequestPatch = Pick<NewPullRequest, "title" | "body">;
|
|
32
|
+
|
|
33
|
+
export interface RemoteApi {
|
|
34
|
+
/** The open pull request from `headBranch`, if there is one. */
|
|
35
|
+
findPullRequest(headBranch: string): Promise<RemotePullRequest | undefined>;
|
|
36
|
+
/** The open pull request titled exactly `title` (a draft marker the host adds aside), if there
|
|
37
|
+
* is one: how the learnings pull request of an Initiative is found again across Runs, since its
|
|
38
|
+
* branch carries a timestamp (core/learnings.ts). */
|
|
39
|
+
findPullRequestByTitle(title: string): Promise<RemotePullRequest | undefined>;
|
|
40
|
+
openDraftPullRequest(input: NewPullRequest): Promise<RemotePullRequest>;
|
|
41
|
+
updatePullRequest(number: number, patch: PullRequestPatch): Promise<RemotePullRequest>;
|
|
42
|
+
/** A comment (GitLab: a note) on the pull request's thread: where the Gates' summary and the
|
|
43
|
+
* Verdict go. */
|
|
44
|
+
addPullRequestComment(number: number, body: string): Promise<void>;
|
|
45
|
+
/** Puts a file (a Verifier's screenshot) where a comment on the pull request can show it, and
|
|
46
|
+
* returns the markdown that embeds it; undefined when the host has no way to (GitHub has no
|
|
47
|
+
* upload for issue comments), in which case the caller says where the file is kept instead. */
|
|
48
|
+
uploadAttachment(number: number, filePath: string): Promise<Attachment | undefined>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An uploaded file as the host hands it back. */
|
|
52
|
+
export interface Attachment {
|
|
53
|
+
url: string;
|
|
54
|
+
/** What to put in a comment body to show it: `` for an image. */
|
|
55
|
+
markdown: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type RemoteProvider = "gitlab" | "github";
|
|
59
|
+
|
|
60
|
+
export interface ParsedRemote {
|
|
61
|
+
provider: RemoteProvider | undefined;
|
|
62
|
+
host: string;
|
|
63
|
+
/** `group/subgroup/repo` or `owner/repo`, without `.git`. */
|
|
64
|
+
fullName: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reads host and project path out of the usual remote URL forms; undefined for a local path. */
|
|
68
|
+
export function parseRemoteUrl(url: string): ParsedRemote | undefined {
|
|
69
|
+
let host: string | undefined;
|
|
70
|
+
let path: string | undefined;
|
|
71
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(url);
|
|
72
|
+
if (scp) {
|
|
73
|
+
[, host, path] = scp;
|
|
74
|
+
} else {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url);
|
|
77
|
+
if (!parsed.hostname) return undefined;
|
|
78
|
+
host = parsed.hostname;
|
|
79
|
+
path = parsed.pathname;
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const fullName = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
|
|
85
|
+
if (!host || !fullName.includes("/")) return undefined;
|
|
86
|
+
const provider: RemoteProvider | undefined = /(^|\.)github\.com$/.test(host) ? "github" : /gitlab/.test(host) ? "gitlab" : undefined;
|
|
87
|
+
return { provider, host, fullName };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Runs one CLI (`glab` or `gh`) and resolves with its stdout. The seam the tests script. */
|
|
91
|
+
export type CliRunner = (bin: "glab" | "gh", argv: string[], cwd: string) => Promise<string>;
|
|
92
|
+
|
|
93
|
+
export const runCli: CliRunner = (bin, argv, cwd) => execToString(bin, argv, cwd, `${bin} ${argv.slice(0, 5).join(" ")}...`);
|
|
94
|
+
|
|
95
|
+
/** The RemoteApi for an origin URL, or a refusal naming the host when no implementation fits. */
|
|
96
|
+
export function remoteApiFor(originUrl: string, run: CliRunner = runCli, cwd = process.cwd()): RemoteApi {
|
|
97
|
+
const remote = parseRemoteUrl(originUrl);
|
|
98
|
+
if (!remote) throw new Error(`origin "${originUrl}" is not a GitLab or GitHub remote, so the Foreman cannot open a draft pull request there`);
|
|
99
|
+
switch (remote.provider) {
|
|
100
|
+
case "gitlab":
|
|
101
|
+
return new GitLabRemoteApi(remote.host, remote.fullName, run, cwd);
|
|
102
|
+
case "github":
|
|
103
|
+
return new GitHubRemoteApi(remote.host, remote.fullName, run, cwd);
|
|
104
|
+
default:
|
|
105
|
+
throw new Error(`origin host "${remote.host}" is neither GitLab nor GitHub; the Foreman can only open a draft pull request on those`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseJson<T>(bin: string, text: string): T {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(text) as T;
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`${bin} returned something that is not JSON: ${text.slice(0, 200)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface GitLabMergeRequest {
|
|
118
|
+
iid: number;
|
|
119
|
+
web_url: string;
|
|
120
|
+
title: string;
|
|
121
|
+
draft?: boolean;
|
|
122
|
+
source_branch: string;
|
|
123
|
+
target_branch: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const GITLAB_DRAFT_PREFIX = "Draft: ";
|
|
127
|
+
|
|
128
|
+
/** The first `glab` with `api --form` (multipart uploads): its v1.91.0 changelog, "feat: add
|
|
129
|
+
* multipart/form-data support to `glab api` via `--form` flag". An older `glab` rejects the flag,
|
|
130
|
+
* the upload fails, and the Verdict comment names the screenshot's evidence directory instead. */
|
|
131
|
+
export const GLAB_MIN_VERSION_FOR_FORM = "1.91.0";
|
|
132
|
+
|
|
133
|
+
/** GitLab through `glab api`, addressing the project by its URL-encoded path. A draft is a title
|
|
134
|
+
* prefix on GitLab: it is set when the Hand-off opens the request and left as the humans have it
|
|
135
|
+
* afterwards, so a later attempt never puts a request a reviewer marked ready back into draft. */
|
|
136
|
+
export class GitLabRemoteApi implements RemoteApi {
|
|
137
|
+
private readonly project: string;
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly host: string,
|
|
141
|
+
fullName: string,
|
|
142
|
+
private readonly run: CliRunner = runCli,
|
|
143
|
+
private readonly cwd: string = process.cwd(),
|
|
144
|
+
) {
|
|
145
|
+
this.project = encodeURIComponent(fullName);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async api<T>(method: "GET" | "POST" | "PUT", path: string, fields: string[] = []): Promise<T> {
|
|
149
|
+
const out = await this.run("glab", ["api", "--hostname", this.host, "--method", method, path, ...fields], this.cwd);
|
|
150
|
+
return parseJson<T>("glab", out);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private static toPullRequest(mr: GitLabMergeRequest): RemotePullRequest {
|
|
154
|
+
return { number: mr.iid, url: mr.web_url, title: mr.title, draft: mr.draft ?? mr.title.startsWith(GITLAB_DRAFT_PREFIX), headBranch: mr.source_branch, baseBranch: mr.target_branch };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private static draftTitle(title: string): string {
|
|
158
|
+
return title.startsWith(GITLAB_DRAFT_PREFIX) ? title : `${GITLAB_DRAFT_PREFIX}${title}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async findPullRequest(headBranch: string): Promise<RemotePullRequest | undefined> {
|
|
162
|
+
const list = await this.api<GitLabMergeRequest[]>("GET", `projects/${this.project}/merge_requests?state=opened&source_branch=${encodeURIComponent(headBranch)}`);
|
|
163
|
+
return list.length > 0 ? GitLabRemoteApi.toPullRequest(list[0]) : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `search` matches substrings, so the title is compared exactly afterwards, draft prefix aside. */
|
|
167
|
+
async findPullRequestByTitle(title: string): Promise<RemotePullRequest | undefined> {
|
|
168
|
+
const list = await this.api<GitLabMergeRequest[]>("GET", `projects/${this.project}/merge_requests?state=opened&in=title&search=${encodeURIComponent(title)}`);
|
|
169
|
+
const hit = list.find((mr) => mr.title === title || mr.title === GitLabRemoteApi.draftTitle(title));
|
|
170
|
+
return hit ? GitLabRemoteApi.toPullRequest(hit) : undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async openDraftPullRequest(input: NewPullRequest): Promise<RemotePullRequest> {
|
|
174
|
+
const mr = await this.api<GitLabMergeRequest>("POST", `projects/${this.project}/merge_requests`, [
|
|
175
|
+
"--raw-field", `source_branch=${input.headBranch}`,
|
|
176
|
+
"--raw-field", `target_branch=${input.baseBranch}`,
|
|
177
|
+
"--raw-field", `title=${GitLabRemoteApi.draftTitle(input.title)}`,
|
|
178
|
+
"--raw-field", `description=${input.body}`,
|
|
179
|
+
"--field", "remove_source_branch=true",
|
|
180
|
+
]);
|
|
181
|
+
return GitLabRemoteApi.toPullRequest(mr);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async updatePullRequest(number: number, patch: PullRequestPatch): Promise<RemotePullRequest> {
|
|
185
|
+
const current = await this.api<GitLabMergeRequest>("GET", `projects/${this.project}/merge_requests/${number}`);
|
|
186
|
+
const stillDraft = current.draft ?? current.title.startsWith(GITLAB_DRAFT_PREFIX);
|
|
187
|
+
const mr = await this.api<GitLabMergeRequest>("PUT", `projects/${this.project}/merge_requests/${number}`, [
|
|
188
|
+
"--raw-field", `title=${stillDraft ? GitLabRemoteApi.draftTitle(patch.title) : patch.title}`,
|
|
189
|
+
"--raw-field", `description=${patch.body}`,
|
|
190
|
+
]);
|
|
191
|
+
return GitLabRemoteApi.toPullRequest(mr);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async addPullRequestComment(number: number, body: string): Promise<void> {
|
|
195
|
+
await this.api<unknown>("POST", `projects/${this.project}/merge_requests/${number}/notes`, ["--raw-field", `body=${body}`]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `POST /projects/:id/uploads` (https://docs.gitlab.com/api/projects/#upload-a-file): the file
|
|
199
|
+
* as a `multipart/form-data` field, and the response's `markdown` is what a note shows it with.
|
|
200
|
+
* That is `glab api --form file=@<path>` (`GLAB_MIN_VERSION_FOR_FORM` and later): `--field
|
|
201
|
+
* name=@path` reads the file's bytes as a JSON string value, which the uploads endpoint refuses
|
|
202
|
+
* with 400, and `--form` cannot be combined with `--field`, `--raw-field` or `--input`. The
|
|
203
|
+
* project's upload URL is relative to the project on GitLab (`/uploads/<hash>/<name>`);
|
|
204
|
+
* `full_path` is absolute. */
|
|
205
|
+
async uploadAttachment(_number: number, filePath: string): Promise<Attachment> {
|
|
206
|
+
const upload = await this.api<{ url: string; full_path?: string; markdown: string }>("POST", `projects/${this.project}/uploads`, ["--form", `file=@${filePath}`]);
|
|
207
|
+
return { url: upload.full_path ? `https://${this.host}${upload.full_path}` : upload.url, markdown: upload.markdown };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface GitHubPullRequest {
|
|
212
|
+
number: number;
|
|
213
|
+
html_url: string;
|
|
214
|
+
title: string;
|
|
215
|
+
draft: boolean;
|
|
216
|
+
head: { ref: string };
|
|
217
|
+
base: { ref: string };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** GitHub through `gh api`. */
|
|
221
|
+
export class GitHubRemoteApi implements RemoteApi {
|
|
222
|
+
private readonly owner: string;
|
|
223
|
+
|
|
224
|
+
constructor(
|
|
225
|
+
private readonly host: string,
|
|
226
|
+
private readonly fullName: string,
|
|
227
|
+
private readonly run: CliRunner = runCli,
|
|
228
|
+
private readonly cwd: string = process.cwd(),
|
|
229
|
+
) {
|
|
230
|
+
this.owner = fullName.split("/")[0];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async api<T>(method: "GET" | "POST" | "PATCH", path: string, fields: string[] = []): Promise<T> {
|
|
234
|
+
const out = await this.run("gh", ["api", "--hostname", this.host, "--method", method, path, ...fields], this.cwd);
|
|
235
|
+
return parseJson<T>("gh", out);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private static toPullRequest(pr: GitHubPullRequest): RemotePullRequest {
|
|
239
|
+
return { number: pr.number, url: pr.html_url, title: pr.title, draft: pr.draft, headBranch: pr.head.ref, baseBranch: pr.base.ref };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async findPullRequest(headBranch: string): Promise<RemotePullRequest | undefined> {
|
|
243
|
+
const list = await this.api<GitHubPullRequest[]>("GET", `repos/${this.fullName}/pulls?state=open&head=${encodeURIComponent(`${this.owner}:${headBranch}`)}`);
|
|
244
|
+
return list.length > 0 ? GitHubRemoteApi.toPullRequest(list[0]) : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The open pull requests are listed and the title compared exactly here. The issue search
|
|
248
|
+
* would do it in one query, but its index lags a pull request just opened, and a Run that then
|
|
249
|
+
* missed the learnings pull request of the Run before would open a second one. */
|
|
250
|
+
async findPullRequestByTitle(title: string): Promise<RemotePullRequest | undefined> {
|
|
251
|
+
const list = await this.api<GitHubPullRequest[]>("GET", `repos/${this.fullName}/pulls?state=open&per_page=100`);
|
|
252
|
+
const hit = list.find((pr) => pr.title === title);
|
|
253
|
+
return hit ? GitHubRemoteApi.toPullRequest(hit) : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async openDraftPullRequest(input: NewPullRequest): Promise<RemotePullRequest> {
|
|
257
|
+
const pr = await this.api<GitHubPullRequest>("POST", `repos/${this.fullName}/pulls`, [
|
|
258
|
+
"--raw-field", `head=${input.headBranch}`,
|
|
259
|
+
"--raw-field", `base=${input.baseBranch}`,
|
|
260
|
+
"--raw-field", `title=${input.title}`,
|
|
261
|
+
"--raw-field", `body=${input.body}`,
|
|
262
|
+
"--field", "draft=true",
|
|
263
|
+
]);
|
|
264
|
+
return GitHubRemoteApi.toPullRequest(pr);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async updatePullRequest(number: number, patch: PullRequestPatch): Promise<RemotePullRequest> {
|
|
268
|
+
const pr = await this.api<GitHubPullRequest>("PATCH", `repos/${this.fullName}/pulls/${number}`, ["--raw-field", `title=${patch.title}`, "--raw-field", `body=${patch.body}`]);
|
|
269
|
+
return GitHubRemoteApi.toPullRequest(pr);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A pull request's conversation is its issue's on GitHub. */
|
|
273
|
+
async addPullRequestComment(number: number, body: string): Promise<void> {
|
|
274
|
+
await this.api<unknown>("POST", `repos/${this.fullName}/issues/${number}/comments`, ["--raw-field", `body=${body}`]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** GitHub's REST API has no upload for issue or review comments (the web UI's attachments go
|
|
278
|
+
* through a browser-only endpoint), so a screenshot cannot be shown on the pull request from
|
|
279
|
+
* here; the Verifier keeps it on the Foreman's machine and the comment names the path. */
|
|
280
|
+
async uploadAttachment(): Promise<undefined> {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|