@henryqw/pi-pr 0.3.7 → 1.0.1
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 +56 -23
- package/docs/pr-routing.svg +127 -0
- package/extensions/pr-command.ts +154 -0
- package/extensions/pr-github.ts +953 -0
- package/extensions/pr-merge.ts +288 -0
- package/extensions/pr-routing.ts +59 -0
- package/extensions/pr-ui.ts +98 -0
- package/extensions/pr.ts +82 -259
- package/package.json +4 -3
- package/skills/pi-pr-comment-sweep/SKILL.md +79 -0
- package/skills/pi-pr-comment-sweep/references/recovery.md +49 -0
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +8 -0
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1672 -0
- package/skills/pi-pr-create/SKILL.md +25 -1
- package/skills/pi-pr-fix-ci/SKILL.md +59 -0
- package/skills/pi-pr-update-branch/SKILL.md +116 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { LocalMergeSafety } from "./pr-routing.ts";
|
|
4
|
+
|
|
5
|
+
export type ExecResult = {
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
code: number;
|
|
9
|
+
killed?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Exec = (
|
|
13
|
+
command: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
options: { cwd: string },
|
|
16
|
+
) => Promise<ExecResult>;
|
|
17
|
+
|
|
18
|
+
export type MergeMethod = "merge" | "rebase" | "squash";
|
|
19
|
+
|
|
20
|
+
const MERGE_PULL_REQUEST_MUTATION = "mutation($pullRequestId:ID!,$expectedHeadOid:GitObjectID!,$mergeMethod:PullRequestMergeMethod!){mergePullRequest(input:{pullRequestId:$pullRequestId,expectedHeadOid:$expectedHeadOid,mergeMethod:$mergeMethod}){pullRequest{id state}}}";
|
|
21
|
+
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
22
|
+
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
23
|
+
|
|
24
|
+
export type InspectLocalMergeSafetyInput = {
|
|
25
|
+
exec: Exec;
|
|
26
|
+
cwd: string;
|
|
27
|
+
expectedHead: string;
|
|
28
|
+
headFetchSource: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MergeMethodSelectionInput = {
|
|
32
|
+
allowedMergeMethods: readonly MergeMethod[];
|
|
33
|
+
viewerDefaultMergeMethod?: MergeMethod | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type InspectedLocalMergeSafety = LocalMergeSafety & {
|
|
37
|
+
headOid: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ExecuteGitHubMergeInput = MergeMethodSelectionInput & InspectLocalMergeSafetyInput & {
|
|
41
|
+
pullRequestId: string;
|
|
42
|
+
hostname: string;
|
|
43
|
+
expectedBase: {
|
|
44
|
+
repository: string;
|
|
45
|
+
ref: string;
|
|
46
|
+
oid: string;
|
|
47
|
+
};
|
|
48
|
+
revalidateReadiness: (local: InspectedLocalMergeSafety) => Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function commandText(command: string, args: string[]): string {
|
|
52
|
+
return [command, ...args].join(" ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function errorText(error: unknown): string {
|
|
56
|
+
return error instanceof Error ? error.message : String(error);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commandFailure(command: string, args: string[], detail: string): Error {
|
|
60
|
+
return new Error(`${commandText(command, args)} failed: ${detail}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runCommand(
|
|
64
|
+
exec: Exec,
|
|
65
|
+
cwd: string,
|
|
66
|
+
command: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
allowedCodes: readonly number[] = [0],
|
|
69
|
+
): Promise<ExecResult> {
|
|
70
|
+
let result: ExecResult;
|
|
71
|
+
try {
|
|
72
|
+
result = await exec(command, args, { cwd });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw commandFailure(command, args, errorText(error));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (result.killed || !allowedCodes.includes(result.code)) {
|
|
78
|
+
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was killed" : `exit code ${result.code}`);
|
|
79
|
+
throw commandFailure(command, args, detail);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requiredText(value: unknown, label: string): string {
|
|
85
|
+
if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
86
|
+
throw new TypeError(`${label} must be a non-empty string`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requiredOid(value: unknown, label: string): string {
|
|
92
|
+
const parsed = requiredText(value, label);
|
|
93
|
+
if (!OID.test(parsed)) throw new TypeError(`${label} must be a full Git OID`);
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requiredOutput(result: ExecResult, label: string): string {
|
|
102
|
+
const output = result.stdout.trim();
|
|
103
|
+
if (!output) throw new Error(`${label} returned no output`);
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateInspectionInput(input: InspectLocalMergeSafetyInput): void {
|
|
108
|
+
requiredText(input.cwd, "cwd");
|
|
109
|
+
requiredOid(input.expectedHead, "expected PR head");
|
|
110
|
+
requiredText(input.headFetchSource, "PR head fetch source");
|
|
111
|
+
if (typeof input.exec !== "function") throw new TypeError("exec must be a function");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function inspectWorktree(exec: Exec, cwd: string): Promise<"clean" | "dirty"> {
|
|
115
|
+
const status = await runCommand(exec, cwd, "git", ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
116
|
+
const stateOutput = requiredOutput(
|
|
117
|
+
await runCommand(exec, cwd, "git", [
|
|
118
|
+
"rev-parse",
|
|
119
|
+
...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
|
|
120
|
+
]),
|
|
121
|
+
"Git operation state paths",
|
|
122
|
+
);
|
|
123
|
+
const statePaths = stateOutput.replace(/\r\n/g, "\n").split("\n");
|
|
124
|
+
if (statePaths.length !== GIT_OPERATION_STATES.length || statePaths.some((path) => !path)) {
|
|
125
|
+
throw new Error("Git operation state path resolution returned invalid output");
|
|
126
|
+
}
|
|
127
|
+
let operationInProgress = false;
|
|
128
|
+
for (const [index, path] of statePaths.entries()) {
|
|
129
|
+
try {
|
|
130
|
+
await lstat(resolve(cwd, path));
|
|
131
|
+
operationInProgress = true;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (isRecord(error) && error.code === "ENOENT") continue;
|
|
134
|
+
const detail = isRecord(error) && typeof error.code === "string" ? error.code : errorText(error);
|
|
135
|
+
throw new Error(`Git operation state inspection failed for ${GIT_OPERATION_STATES[index]}: ${detail}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return status.stdout.length === 0 && !operationInProgress ? "clean" : "dirty";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readLocalHead(exec: Exec, cwd: string): Promise<string> {
|
|
142
|
+
return requiredOid(
|
|
143
|
+
requiredOutput(await runCommand(exec, cwd, "git", ["rev-parse", "--verify", "HEAD^{commit}"]), "local HEAD"),
|
|
144
|
+
"local HEAD",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Inspect local state without changing branches, the index, or the worktree. */
|
|
149
|
+
export async function inspectLocalMergeSafety(input: InspectLocalMergeSafetyInput): Promise<InspectedLocalMergeSafety> {
|
|
150
|
+
validateInspectionInput(input);
|
|
151
|
+
|
|
152
|
+
const worktree = await inspectWorktree(input.exec, input.cwd);
|
|
153
|
+
await runCommand(input.exec, input.cwd, "git", [
|
|
154
|
+
"fetch",
|
|
155
|
+
"--no-write-fetch-head",
|
|
156
|
+
"--no-tags",
|
|
157
|
+
"--no-recurse-submodules",
|
|
158
|
+
input.headFetchSource,
|
|
159
|
+
input.expectedHead,
|
|
160
|
+
]);
|
|
161
|
+
await runCommand(input.exec, input.cwd, "git", ["cat-file", "-e", `${input.expectedHead}^{commit}`]);
|
|
162
|
+
|
|
163
|
+
const headOid = await readLocalHead(input.exec, input.cwd);
|
|
164
|
+
if (headOid === input.expectedHead) return { worktree, head: "equal", headOid };
|
|
165
|
+
|
|
166
|
+
const localAncestor = await runCommand(
|
|
167
|
+
input.exec,
|
|
168
|
+
input.cwd,
|
|
169
|
+
"git",
|
|
170
|
+
["merge-base", "--is-ancestor", headOid, input.expectedHead],
|
|
171
|
+
[0, 1],
|
|
172
|
+
);
|
|
173
|
+
if (localAncestor.code === 0) return { worktree, head: "behind", headOid };
|
|
174
|
+
|
|
175
|
+
const expectedAncestor = await runCommand(
|
|
176
|
+
input.exec,
|
|
177
|
+
input.cwd,
|
|
178
|
+
"git",
|
|
179
|
+
["merge-base", "--is-ancestor", input.expectedHead, headOid],
|
|
180
|
+
[0, 1],
|
|
181
|
+
);
|
|
182
|
+
return { worktree, head: expectedAncestor.code === 0 ? "ahead" : "diverged", headOid };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateMergeMethods(input: MergeMethodSelectionInput): void {
|
|
186
|
+
if (!Array.isArray(input.allowedMergeMethods)) throw new TypeError("allowedMergeMethods must be an array");
|
|
187
|
+
if (input.allowedMergeMethods.some((method) => method !== "merge" && method !== "rebase" && method !== "squash")) {
|
|
188
|
+
throw new TypeError("allowedMergeMethods contains an unsupported method");
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
input.viewerDefaultMergeMethod !== undefined &&
|
|
192
|
+
input.viewerDefaultMergeMethod !== null &&
|
|
193
|
+
input.viewerDefaultMergeMethod !== "merge" &&
|
|
194
|
+
input.viewerDefaultMergeMethod !== "rebase" &&
|
|
195
|
+
input.viewerDefaultMergeMethod !== "squash"
|
|
196
|
+
) throw new TypeError("viewerDefaultMergeMethod is unsupported");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function selectMergeMethod(input: MergeMethodSelectionInput): MergeMethod {
|
|
200
|
+
validateMergeMethods(input);
|
|
201
|
+
if (input.allowedMergeMethods.length === 1) return input.allowedMergeMethods[0]!;
|
|
202
|
+
if (input.allowedMergeMethods.includes("squash")) return "squash";
|
|
203
|
+
if (
|
|
204
|
+
input.viewerDefaultMergeMethod !== undefined &&
|
|
205
|
+
input.viewerDefaultMergeMethod !== null &&
|
|
206
|
+
input.allowedMergeMethods.includes(input.viewerDefaultMergeMethod)
|
|
207
|
+
) return input.viewerDefaultMergeMethod;
|
|
208
|
+
throw new Error("No deterministic GitHub merge method is available");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function validateExecuteInput(input: ExecuteGitHubMergeInput): void {
|
|
212
|
+
validateInspectionInput(input);
|
|
213
|
+
requiredText(input.pullRequestId, "pullRequestId");
|
|
214
|
+
requiredText(input.hostname, "hostname");
|
|
215
|
+
if (!isRecord(input.expectedBase)) throw new TypeError("expectedBase must be an object");
|
|
216
|
+
requiredText(input.expectedBase.repository, "expected base repository");
|
|
217
|
+
requiredText(input.expectedBase.ref, "expected base ref");
|
|
218
|
+
requiredText(input.expectedBase.oid, "expected base OID");
|
|
219
|
+
if (typeof input.revalidateReadiness !== "function") throw new TypeError("revalidateReadiness must be a function");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseGraphQLResponse(output: string, action: string): Record<string, unknown> {
|
|
223
|
+
let value: unknown;
|
|
224
|
+
try {
|
|
225
|
+
value = JSON.parse(output);
|
|
226
|
+
} catch {
|
|
227
|
+
throw new Error(`${action}: invalid GraphQL output`);
|
|
228
|
+
}
|
|
229
|
+
if (!isRecord(value)) throw new Error(`${action}: invalid GraphQL output`);
|
|
230
|
+
const errors = value.errors;
|
|
231
|
+
if (errors !== undefined) {
|
|
232
|
+
if (!Array.isArray(errors)) throw new Error(`${action}: invalid GraphQL output`);
|
|
233
|
+
if (errors.length > 0) {
|
|
234
|
+
const messages = errors.map((error) => isRecord(error) && typeof error.message === "string" && error.message ? error.message : undefined);
|
|
235
|
+
if (messages.some((message) => message === undefined)) throw new Error(`${action}: invalid GraphQL errors`);
|
|
236
|
+
throw new Error(`${action}: ${messages.join("; ")}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseMergeResponse(output: string, expectedId: string): void {
|
|
243
|
+
const value = parseGraphQLResponse(output, "GitHub merge failed");
|
|
244
|
+
const data = value.data;
|
|
245
|
+
const mutation = isRecord(data) ? data.mergePullRequest : undefined;
|
|
246
|
+
const pullRequest = isRecord(mutation) ? mutation.pullRequest : undefined;
|
|
247
|
+
if (!isRecord(pullRequest)) throw new Error("GitHub merge failed: invalid GraphQL output");
|
|
248
|
+
const id = pullRequest.id;
|
|
249
|
+
const state = pullRequest.state;
|
|
250
|
+
if (typeof id !== "string" || !id || typeof state !== "string" || !state) {
|
|
251
|
+
throw new Error("GitHub merge failed: invalid GraphQL output");
|
|
252
|
+
}
|
|
253
|
+
if (id !== expectedId) throw new Error(`GitHub merge returned unexpected pull request id ${id}`);
|
|
254
|
+
if (state !== "MERGED") throw new Error(`GitHub merge returned pull request ${id} in state ${state}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function executeGitHubMerge(input: ExecuteGitHubMergeInput): Promise<void> {
|
|
258
|
+
validateExecuteInput(input);
|
|
259
|
+
const method = selectMergeMethod(input);
|
|
260
|
+
const local = await inspectLocalMergeSafety(input);
|
|
261
|
+
if (local.worktree !== "clean" || (local.head !== "equal" && local.head !== "behind")) {
|
|
262
|
+
throw new Error(`Local merge safety check failed: worktree is ${local.worktree}, HEAD is ${local.head}`);
|
|
263
|
+
}
|
|
264
|
+
await input.revalidateReadiness(local);
|
|
265
|
+
const finalWorktree = await inspectWorktree(input.exec, input.cwd);
|
|
266
|
+
if (finalWorktree !== "clean") {
|
|
267
|
+
throw new Error("Final local merge safety check failed: worktree is dirty");
|
|
268
|
+
}
|
|
269
|
+
const finalHead = await readLocalHead(input.exec, input.cwd);
|
|
270
|
+
if (finalHead !== local.headOid) {
|
|
271
|
+
throw new Error(`Final local merge safety check failed: HEAD changed from ${local.headOid} to ${finalHead}`);
|
|
272
|
+
}
|
|
273
|
+
const merged = await runCommand(input.exec, input.cwd, "gh", [
|
|
274
|
+
"api",
|
|
275
|
+
"graphql",
|
|
276
|
+
"--hostname",
|
|
277
|
+
input.hostname,
|
|
278
|
+
"-f",
|
|
279
|
+
`query=${MERGE_PULL_REQUEST_MUTATION}`,
|
|
280
|
+
"-F",
|
|
281
|
+
`pullRequestId=${input.pullRequestId}`,
|
|
282
|
+
"-F",
|
|
283
|
+
`expectedHeadOid=${input.expectedHead}`,
|
|
284
|
+
"-F",
|
|
285
|
+
`mergeMethod=${method.toUpperCase()}`,
|
|
286
|
+
]);
|
|
287
|
+
parseMergeResponse(requiredOutput(merged, "GitHub merge"), input.pullRequestId);
|
|
288
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type PullRequestLifecycle = "open" | "merged" | "closed";
|
|
2
|
+
export type CiStatus = "none" | "running" | "success" | "failure";
|
|
3
|
+
export type ReviewReadiness = "ready" | "pending";
|
|
4
|
+
export type PolicyReadiness = "ready" | "pending";
|
|
5
|
+
export type LocalWorktree = "clean" | "dirty";
|
|
6
|
+
export type LocalHeadRelation = "equal" | "behind" | "ahead" | "diverged";
|
|
7
|
+
|
|
8
|
+
export type PullRequestConditions = {
|
|
9
|
+
draft: boolean;
|
|
10
|
+
baseUpdateRequired: boolean;
|
|
11
|
+
conflict: boolean;
|
|
12
|
+
changesRequested: boolean;
|
|
13
|
+
unresolvedThreads: number;
|
|
14
|
+
ci: CiStatus;
|
|
15
|
+
review: ReviewReadiness;
|
|
16
|
+
policy: PolicyReadiness;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LocalMergeSafety = {
|
|
20
|
+
worktree: LocalWorktree;
|
|
21
|
+
head: LocalHeadRelation;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type PullRequest = {
|
|
25
|
+
lifecycle: PullRequestLifecycle;
|
|
26
|
+
conditions: PullRequestConditions;
|
|
27
|
+
local: LocalMergeSafety;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NextStep = "create" | "none" | "update-branch" | "sweep" | "fix-ci" | "merge";
|
|
31
|
+
|
|
32
|
+
function localMutationSafe(local: LocalMergeSafety): boolean {
|
|
33
|
+
return local.worktree === "clean" && local.head === "equal";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function localMergeSafe(local: LocalMergeSafety): boolean {
|
|
37
|
+
return local.worktree === "clean" && (local.head === "equal" || local.head === "behind");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function deriveNextStep(pullRequest: PullRequest | null): NextStep {
|
|
41
|
+
if (pullRequest === null) return "create";
|
|
42
|
+
|
|
43
|
+
const { lifecycle, conditions, local } = pullRequest;
|
|
44
|
+
if (lifecycle !== "open" || conditions.draft) return "none";
|
|
45
|
+
if (conditions.baseUpdateRequired || conditions.conflict) {
|
|
46
|
+
return localMutationSafe(local) ? "update-branch" : "none";
|
|
47
|
+
}
|
|
48
|
+
if (conditions.changesRequested || conditions.unresolvedThreads > 0) {
|
|
49
|
+
return localMutationSafe(local) ? "sweep" : "none";
|
|
50
|
+
}
|
|
51
|
+
if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
|
|
52
|
+
if (
|
|
53
|
+
conditions.ci === "running" ||
|
|
54
|
+
conditions.review === "pending" ||
|
|
55
|
+
conditions.policy === "pending" ||
|
|
56
|
+
!localMergeSafe(local)
|
|
57
|
+
) return "none";
|
|
58
|
+
return "merge";
|
|
59
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
deriveNextStep,
|
|
4
|
+
type NextStep,
|
|
5
|
+
type PullRequest,
|
|
6
|
+
} from "./pr-routing.ts";
|
|
7
|
+
|
|
8
|
+
export type PrDisplayInput = PullRequest & {
|
|
9
|
+
number: number;
|
|
10
|
+
url: string | URL;
|
|
11
|
+
approved: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PrStatusColor = "accent" | "warning" | "success" | "error" | "dim";
|
|
15
|
+
|
|
16
|
+
export type PrFooter = {
|
|
17
|
+
number: number;
|
|
18
|
+
url: string;
|
|
19
|
+
text: string;
|
|
20
|
+
color: PrStatusColor;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type PrDisplay = {
|
|
24
|
+
nextStep: NextStep;
|
|
25
|
+
footer?: PrFooter;
|
|
26
|
+
widget?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type PrTheme = {
|
|
30
|
+
fg(color: PrStatusColor | "text", text: string): string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function footerStatus(input: PrDisplayInput, nextStep: NextStep): Pick<PrFooter, "text" | "color"> {
|
|
34
|
+
if (input.lifecycle === "merged") return { text: "merged", color: "success" };
|
|
35
|
+
if (input.lifecycle === "closed") return { text: "closed", color: "dim" };
|
|
36
|
+
|
|
37
|
+
const { conditions } = input;
|
|
38
|
+
if (conditions.draft) return { text: "draft", color: "warning" };
|
|
39
|
+
if (conditions.conflict) return { text: "merge conflict", color: "error" };
|
|
40
|
+
if (conditions.baseUpdateRequired) return { text: "base update required", color: "warning" };
|
|
41
|
+
if (conditions.unresolvedThreads > 0) return { text: `${conditions.unresolvedThreads} unresolved`, color: "warning" };
|
|
42
|
+
if (conditions.changesRequested) return { text: "changes requested", color: "error" };
|
|
43
|
+
if (conditions.ci === "failure") return { text: "CI failed", color: "error" };
|
|
44
|
+
if (conditions.ci === "running") return { text: "CI running", color: "warning" };
|
|
45
|
+
if (nextStep === "merge") return { text: "merge-ready", color: "success" };
|
|
46
|
+
if (input.approved) return { text: "approved", color: "success" };
|
|
47
|
+
return { text: "open", color: "accent" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function widgetText(input: PrDisplayInput, nextStep: NextStep): string | undefined {
|
|
51
|
+
switch (nextStep) {
|
|
52
|
+
case "update-branch":
|
|
53
|
+
return input.conditions.conflict
|
|
54
|
+
? "Run /pr to resolve merge conflict"
|
|
55
|
+
: "Run /pr to update branch";
|
|
56
|
+
case "sweep":
|
|
57
|
+
return "Run /pr to address review feedback";
|
|
58
|
+
case "fix-ci":
|
|
59
|
+
return "Run /pr to fix CI";
|
|
60
|
+
case "merge":
|
|
61
|
+
return "Run /pr to merge pull request";
|
|
62
|
+
case "create":
|
|
63
|
+
case "none":
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function projectPrDisplay(input: PrDisplayInput | null, hasLocalCommit = false): PrDisplay {
|
|
69
|
+
const nextStep = deriveNextStep(input);
|
|
70
|
+
if (input === null) {
|
|
71
|
+
return {
|
|
72
|
+
nextStep,
|
|
73
|
+
widget: hasLocalCommit ? "Run /pr to create pull request" : undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const status = footerStatus(input, nextStep);
|
|
78
|
+
return {
|
|
79
|
+
nextStep,
|
|
80
|
+
footer: {
|
|
81
|
+
number: input.number,
|
|
82
|
+
url: typeof input.url === "string" ? input.url : input.url.href,
|
|
83
|
+
...status,
|
|
84
|
+
},
|
|
85
|
+
widget: widgetText(input, nextStep),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatPrFooter(display: PrDisplay, theme: PrTheme): string | undefined {
|
|
90
|
+
if (!display.footer) return undefined;
|
|
91
|
+
const prText = theme.fg("text", `PR #${display.footer.number}`);
|
|
92
|
+
const link = getCapabilities().hyperlinks ? hyperlink(prText, display.footer.url) : prText;
|
|
93
|
+
return `${link} · ${theme.fg(display.footer.color, display.footer.text)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatPrWidget(display: PrDisplay): string | undefined {
|
|
97
|
+
return display.widget;
|
|
98
|
+
}
|