@narumitw/pi-github-pr 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/package.json +42 -0
- package/src/github-pr.ts +415 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 🔎 pi-github-pr — GitHub Pull Request Statusline for Pi Agents
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@narumitw/pi-github-pr) [](https://pi.dev) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
`@narumitw/pi-github-pr` is a passive [Pi coding agent](https://pi.dev) extension that shows the current branch GitHub pull request status in Pi's statusline.
|
|
6
|
+
|
|
7
|
+
It only reads PR metadata for the current branch. It counts comments and reviews, but does not fetch or display comment bodies, review text, or review-thread content.
|
|
8
|
+
|
|
9
|
+
It is intentionally ambient: no slash command, no custom tool, no widget, and no comment injection.
|
|
10
|
+
|
|
11
|
+
## ✨ Features
|
|
12
|
+
|
|
13
|
+
- Automatically shows compact PR status in Pi's statusline.
|
|
14
|
+
- Refreshes the current branch PR after agent turns.
|
|
15
|
+
- Shows PR number, GitHub checks state, review state, and comment/review count.
|
|
16
|
+
- Does not read or expose PR discussion text; use `gh pr view --comments` or GitHub directly when you need the conversation.
|
|
17
|
+
- Uses GitHub CLI auth and repository resolution; the extension stores no GitHub token.
|
|
18
|
+
- No slash commands, LLM tools, widgets, polling loop, webhook server, or runtime service.
|
|
19
|
+
|
|
20
|
+
Example statusline text:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
PR #123: checks passing, approved, 7 comments
|
|
24
|
+
PR #123: checks failing (2), changes requested, 3 comments
|
|
25
|
+
PR #123: checks pending (5), commented, 12 comments
|
|
26
|
+
PR #123: no checks, draft, no comments
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The check wording follows GitHub's Checks terminology. The trailing comment count is the
|
|
30
|
+
combined comments + reviews count. When rendered by `pi-statusline`, the `github-pr` icon comes
|
|
31
|
+
from pi-statusline icon settings.
|
|
32
|
+
|
|
33
|
+
## 📦 Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pi install npm:@narumitw/pi-github-pr
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Try without installing permanently:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pi -e npm:@narumitw/pi-github-pr
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Try this package locally from the repository root:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pi -e ./extensions/pi-github-pr
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## ⚙️ Prerequisites
|
|
52
|
+
|
|
53
|
+
Install and authenticate GitHub CLI yourself:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
brew install gh
|
|
57
|
+
gh auth login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The extension shells out to `gh pr view`; GitHub Enterprise hosts and credential storage are delegated to `gh`.
|
|
61
|
+
|
|
62
|
+
## 💬 Behavior
|
|
63
|
+
|
|
64
|
+
The extension runs passively:
|
|
65
|
+
|
|
66
|
+
- On session start, it checks the current branch PR and sets a compact statusline entry.
|
|
67
|
+
- After each agent turn, it refreshes that same current branch PR status.
|
|
68
|
+
- On session shutdown, it clears the statusline entry.
|
|
69
|
+
- If the directory has no GitHub PR, the statusline entry stays empty.
|
|
70
|
+
- If `gh` is missing or unauthenticated, the statusline shows a short hint such as `PR gh missing` or `PR gh auth`.
|
|
71
|
+
|
|
72
|
+
## Known limits
|
|
73
|
+
|
|
74
|
+
- Requires `gh`; there is no direct GitHub API or `GITHUB_TOKEN` fallback.
|
|
75
|
+
- Only the current branch PR is shown; there is no command or tool for arbitrary PR lookup.
|
|
76
|
+
- Comment count uses `gh pr view` comments and reviews, not precise unresolved review-thread counts.
|
|
77
|
+
- It does not read PR comment bodies, review bodies, inline diff comments, or unresolved review-thread text.
|
|
78
|
+
- No continuous polling; refresh happens on session start and after agent turns.
|
|
79
|
+
|
|
80
|
+
## 📁 Package layout
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
extensions/pi-github-pr/
|
|
84
|
+
├── src/github-pr.ts
|
|
85
|
+
├── test/github-pr.test.ts
|
|
86
|
+
├── package.json
|
|
87
|
+
├── README.md
|
|
88
|
+
├── LICENSE
|
|
89
|
+
└── tsconfig.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 🏷️ Keywords
|
|
93
|
+
|
|
94
|
+
`pi-package`, `pi-extension`, `github`, `pull-request`, `statusline`, `gh`
|
|
95
|
+
|
|
96
|
+
## 📄 License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@narumitw/pi-github-pr",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Pi extension that shows GitHub pull request review, checks, and comment status.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"github",
|
|
13
|
+
"pull-request",
|
|
14
|
+
"gh"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/github-pr.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && npm run typecheck",
|
|
28
|
+
"format": "biome check --write .",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "2.4.14",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.79.8",
|
|
34
|
+
"@types/node": "25.6.0",
|
|
35
|
+
"typescript": "6.0.3"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
40
|
+
"directory": "extensions/pi-github-pr"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/github-pr.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import type { ExecResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "UNKNOWN";
|
|
4
|
+
export type CheckState = "pass" | "fail" | "pending" | "none";
|
|
5
|
+
|
|
6
|
+
type JsonRecord = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface CheckSummary {
|
|
9
|
+
passed: number;
|
|
10
|
+
failed: number;
|
|
11
|
+
pending: number;
|
|
12
|
+
total: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReviewSummary {
|
|
16
|
+
decision: ReviewDecision;
|
|
17
|
+
approvedBy: string[];
|
|
18
|
+
changesRequestedBy: string[];
|
|
19
|
+
commentedBy: string[];
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommentSummary {
|
|
24
|
+
issue: number;
|
|
25
|
+
reviews: number;
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PullRequestStatus {
|
|
30
|
+
number: number;
|
|
31
|
+
isDraft: boolean;
|
|
32
|
+
review: ReviewSummary;
|
|
33
|
+
checks: CheckSummary;
|
|
34
|
+
comments: CommentSummary;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const STATUS_KEY = "github-pr";
|
|
38
|
+
const GH_TIMEOUT_MS = 10_000;
|
|
39
|
+
const GH_PR_FIELDS = [
|
|
40
|
+
"number",
|
|
41
|
+
"isDraft",
|
|
42
|
+
"url",
|
|
43
|
+
"reviewDecision",
|
|
44
|
+
"latestReviews",
|
|
45
|
+
"statusCheckRollup",
|
|
46
|
+
];
|
|
47
|
+
const GH_PR_COUNT_QUERY = `
|
|
48
|
+
query PullRequestCounts($owner: String!, $name: String!, $number: Int!) {
|
|
49
|
+
repository(owner: $owner, name: $name) {
|
|
50
|
+
pullRequest(number: $number) {
|
|
51
|
+
comments {
|
|
52
|
+
totalCount
|
|
53
|
+
}
|
|
54
|
+
reviews {
|
|
55
|
+
totalCount
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export default function githubPr(pi: ExtensionAPI) {
|
|
63
|
+
const refreshStatus = async (ctx: ExtensionContext, signal?: AbortSignal) => {
|
|
64
|
+
try {
|
|
65
|
+
const status = await runGhPrView(pi, ctx.cwd, signal);
|
|
66
|
+
renderStatus(ctx, status);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
renderAmbientFailure(ctx, error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
73
|
+
await refreshStatus(ctx, ctx.signal);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
77
|
+
await refreshStatus(ctx, ctx.signal);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
81
|
+
clearStatus(ctx);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runGhPrView(
|
|
86
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
87
|
+
cwd: string,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<PullRequestStatus> {
|
|
90
|
+
const args = ["pr", "view", "--json", GH_PR_FIELDS.join(",")];
|
|
91
|
+
const result = await execGh(pi, args, cwd, signal, "gh pr view");
|
|
92
|
+
if (result.killed) throw new Error("gh pr view timed out or was cancelled.");
|
|
93
|
+
if (result.code !== 0) throw new Error(formatGhFailure("gh pr view", result));
|
|
94
|
+
|
|
95
|
+
let pr: JsonRecord;
|
|
96
|
+
try {
|
|
97
|
+
pr = objectRecord(JSON.parse(result.stdout));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`Failed to parse gh pr view output: ${formatError(error)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const counts = await runGhPrCountQuery(pi, cwd, pr, signal);
|
|
103
|
+
return normalizeGhPrView({ ...pr, ...counts });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeGhPrView(value: unknown): PullRequestStatus {
|
|
107
|
+
const pr = objectRecord(value);
|
|
108
|
+
const reviews = arrayValue(pr.reviews);
|
|
109
|
+
const latestReviews = arrayValue(pr.latestReviews);
|
|
110
|
+
const comments = summarizeComments(pr.comments, countValue(pr.reviews));
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
number: requiredNumber(pr.number, "number"),
|
|
114
|
+
isDraft: pr.isDraft === true,
|
|
115
|
+
review: summarizeReviews(pr.reviewDecision, latestReviews.length > 0 ? latestReviews : reviews),
|
|
116
|
+
checks: summarizeChecks(pr.statusCheckRollup),
|
|
117
|
+
comments,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeChecks(value: unknown): CheckSummary {
|
|
122
|
+
const checks = arrayValue(value);
|
|
123
|
+
const summary: CheckSummary = { passed: 0, failed: 0, pending: 0, total: checks.length };
|
|
124
|
+
|
|
125
|
+
for (const check of checks) {
|
|
126
|
+
const state = checkState(check);
|
|
127
|
+
if (state === "pass") summary.passed += 1;
|
|
128
|
+
else if (state === "fail") summary.failed += 1;
|
|
129
|
+
else summary.pending += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return summary;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function checkState(value: unknown): Exclude<CheckState, "none"> {
|
|
136
|
+
const check = objectRecord(value);
|
|
137
|
+
const state = optionalString(check.state)?.toUpperCase();
|
|
138
|
+
const status = optionalString(check.status)?.toUpperCase();
|
|
139
|
+
const conclusion = optionalString(check.conclusion)?.toUpperCase();
|
|
140
|
+
|
|
141
|
+
if (state === "SUCCESS") return "pass";
|
|
142
|
+
if (state === "FAILURE" || state === "ERROR") return "fail";
|
|
143
|
+
if (state === "PENDING" || state === "EXPECTED") return "pending";
|
|
144
|
+
|
|
145
|
+
if (status && status !== "COMPLETED") return "pending";
|
|
146
|
+
if (conclusion === "SUCCESS" || conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
|
|
147
|
+
return "pass";
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
conclusion === "FAILURE" ||
|
|
151
|
+
conclusion === "CANCELLED" ||
|
|
152
|
+
conclusion === "TIMED_OUT" ||
|
|
153
|
+
conclusion === "ACTION_REQUIRED" ||
|
|
154
|
+
conclusion === "STARTUP_FAILURE"
|
|
155
|
+
) {
|
|
156
|
+
return "fail";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return "pending";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function summarizeReviews(decisionValue: unknown, reviewValues: unknown[]): ReviewSummary {
|
|
163
|
+
const latestByAuthor = new Map<string, JsonRecord>();
|
|
164
|
+
let anonymousIndex = 0;
|
|
165
|
+
|
|
166
|
+
for (const reviewValue of reviewValues) {
|
|
167
|
+
const review = objectRecord(reviewValue);
|
|
168
|
+
const author = authorLogin(review) ?? `review-${anonymousIndex++}`;
|
|
169
|
+
latestByAuthor.set(author, review);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const summary: ReviewSummary = {
|
|
173
|
+
decision: reviewDecision(decisionValue),
|
|
174
|
+
approvedBy: [],
|
|
175
|
+
changesRequestedBy: [],
|
|
176
|
+
commentedBy: [],
|
|
177
|
+
total: reviewValues.length,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const [author, review] of latestByAuthor) {
|
|
181
|
+
const state = optionalString(review.state)?.toUpperCase();
|
|
182
|
+
if (state === "APPROVED") summary.approvedBy.push(author);
|
|
183
|
+
else if (state === "CHANGES_REQUESTED") summary.changesRequestedBy.push(author);
|
|
184
|
+
else if (state === "COMMENTED") summary.commentedBy.push(author);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return summary;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function summarizeComments(commentsValue: unknown, reviewCount: number): CommentSummary {
|
|
191
|
+
const issue = countValue(commentsValue);
|
|
192
|
+
return { issue, reviews: reviewCount, total: issue + reviewCount };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function countValue(value: unknown): number {
|
|
196
|
+
if (Array.isArray(value)) return value.length;
|
|
197
|
+
const object = objectRecord(value);
|
|
198
|
+
const totalCount = object.totalCount;
|
|
199
|
+
if (typeof totalCount === "number") return totalCount;
|
|
200
|
+
const nodes = object.nodes;
|
|
201
|
+
return Array.isArray(nodes) ? nodes.length : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function reviewDecision(value: unknown): ReviewDecision {
|
|
205
|
+
if (value === "APPROVED" || value === "CHANGES_REQUESTED" || value === "REVIEW_REQUIRED") {
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
return "UNKNOWN";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function authorLogin(review: JsonRecord): string | undefined {
|
|
212
|
+
const author = objectRecord(review.author);
|
|
213
|
+
return optionalString(author.login);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function checkOverall(checks: CheckSummary): CheckState {
|
|
217
|
+
if (checks.total === 0) return "none";
|
|
218
|
+
if (checks.failed > 0) return "fail";
|
|
219
|
+
if (checks.pending > 0) return "pending";
|
|
220
|
+
return "pass";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function formatCompactStatus(status: PullRequestStatus): string {
|
|
224
|
+
return `PR #${status.number}: ${[
|
|
225
|
+
formatCheckCompact(status.checks),
|
|
226
|
+
formatReviewCompact(status),
|
|
227
|
+
formatCommentCompact(status.comments),
|
|
228
|
+
].join(", ")}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatCheckCompact(checks: CheckSummary): string {
|
|
232
|
+
switch (checkOverall(checks)) {
|
|
233
|
+
case "pass":
|
|
234
|
+
return "checks passing";
|
|
235
|
+
case "fail":
|
|
236
|
+
return `checks failing (${checks.failed})`;
|
|
237
|
+
case "pending":
|
|
238
|
+
return `checks pending (${checks.pending})`;
|
|
239
|
+
case "none":
|
|
240
|
+
return "no checks";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatCommentCompact(comments: CommentSummary): string {
|
|
245
|
+
const count = comments.total;
|
|
246
|
+
if (count === 0) return "no comments";
|
|
247
|
+
return `${count} ${count === 1 ? "comment" : "comments"}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatReviewCompact(status: PullRequestStatus): string {
|
|
251
|
+
if (status.isDraft) return "draft";
|
|
252
|
+
const review = status.review;
|
|
253
|
+
switch (review.decision) {
|
|
254
|
+
case "APPROVED":
|
|
255
|
+
return "approved";
|
|
256
|
+
case "CHANGES_REQUESTED":
|
|
257
|
+
return "changes requested";
|
|
258
|
+
case "REVIEW_REQUIRED":
|
|
259
|
+
return review.commentedBy.length > 0 ? "commented" : "review required";
|
|
260
|
+
case "UNKNOWN":
|
|
261
|
+
return review.commentedBy.length > 0 ? "commented" : "review ?";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderStatus(ctx: ExtensionContext, status: PullRequestStatus) {
|
|
266
|
+
ctx.ui.setStatus(STATUS_KEY, formatCompactStatus(status));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function clearStatus(ctx: ExtensionContext) {
|
|
270
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderAmbientFailure(ctx: ExtensionContext, error: unknown) {
|
|
274
|
+
const message = formatError(error);
|
|
275
|
+
const lower = message.toLowerCase();
|
|
276
|
+
|
|
277
|
+
if (isGhExecutableMissingMessage(lower)) {
|
|
278
|
+
ctx.ui.setStatus(STATUS_KEY, "PR gh missing");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (/not authenticated|auth login|authentication/.test(lower)) {
|
|
282
|
+
ctx.ui.setStatus(STATUS_KEY, "PR gh auth");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
clearStatus(ctx);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function runGhPrCountQuery(
|
|
290
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
291
|
+
cwd: string,
|
|
292
|
+
pr: JsonRecord,
|
|
293
|
+
signal?: AbortSignal,
|
|
294
|
+
): Promise<Pick<JsonRecord, "comments" | "reviews">> {
|
|
295
|
+
const { owner, name, number } = parsePrCoordinates(pr);
|
|
296
|
+
const result = await execGh(
|
|
297
|
+
pi,
|
|
298
|
+
[
|
|
299
|
+
"api",
|
|
300
|
+
"graphql",
|
|
301
|
+
"-f",
|
|
302
|
+
`query=${GH_PR_COUNT_QUERY}`,
|
|
303
|
+
"-F",
|
|
304
|
+
`owner=${owner}`,
|
|
305
|
+
"-F",
|
|
306
|
+
`name=${name}`,
|
|
307
|
+
"-F",
|
|
308
|
+
`number=${number}`,
|
|
309
|
+
],
|
|
310
|
+
cwd,
|
|
311
|
+
signal,
|
|
312
|
+
"gh api graphql",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (result.killed) throw new Error("gh api graphql timed out or was cancelled.");
|
|
316
|
+
if (result.code !== 0) throw new Error(formatGhFailure("gh api graphql", result));
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const payload = objectRecord(JSON.parse(result.stdout));
|
|
320
|
+
const data = objectRecord(payload.data);
|
|
321
|
+
const repository = objectRecord(data.repository);
|
|
322
|
+
const pullRequest = objectRecord(repository.pullRequest);
|
|
323
|
+
return { comments: pullRequest.comments, reviews: pullRequest.reviews };
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw new Error(`Failed to parse gh api graphql output: ${formatError(error)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function execGh(
|
|
330
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
331
|
+
args: string[],
|
|
332
|
+
cwd: string,
|
|
333
|
+
signal: AbortSignal | undefined,
|
|
334
|
+
command: string,
|
|
335
|
+
): Promise<ExecResult> {
|
|
336
|
+
try {
|
|
337
|
+
return await pi.exec(
|
|
338
|
+
"gh",
|
|
339
|
+
args,
|
|
340
|
+
{ cwd, signal, timeout: GH_TIMEOUT_MS },
|
|
341
|
+
);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const message = formatError(error);
|
|
344
|
+
if (isGhExecutableMissingMessage(message.toLowerCase())) {
|
|
345
|
+
throw new Error(`GitHub CLI not found. Install gh and run: gh auth login. ${message}`);
|
|
346
|
+
}
|
|
347
|
+
throw new Error(`${command} could not start: ${message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function formatGhFailure(command: string, result: ExecResult): string {
|
|
352
|
+
const output = (result.stderr || result.stdout).trim();
|
|
353
|
+
const lower = output.toLowerCase();
|
|
354
|
+
if (isGhExecutableMissingMessage(lower)) {
|
|
355
|
+
return "GitHub CLI not found. Install gh and run: gh auth login.";
|
|
356
|
+
}
|
|
357
|
+
if (/not logged in|authentication|auth login|gh auth/.test(lower)) {
|
|
358
|
+
return `GitHub CLI is not authenticated. Run: gh auth login. ${output}`;
|
|
359
|
+
}
|
|
360
|
+
if (/no pull requests|could not resolve|not a github repository/.test(lower)) {
|
|
361
|
+
return `No GitHub pull request found. ${output}`;
|
|
362
|
+
}
|
|
363
|
+
return `${command} failed (${result.code}): ${output || "no output"}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isGhExecutableMissingMessage(lowerMessage: string): boolean {
|
|
367
|
+
return (
|
|
368
|
+
/\bgithub cli (?:not available|not found)\b/.test(lowerMessage) ||
|
|
369
|
+
/\b(?:gh|gh\.exe)\b.*\benoent\b|\benoent\b.*\b(?:gh|gh\.exe)\b/.test(lowerMessage) ||
|
|
370
|
+
/\b(?:gh|gh\.exe): (?:command )?not found\b/.test(lowerMessage) ||
|
|
371
|
+
/\bcommand not found: (?:gh|gh\.exe)\b/.test(lowerMessage) ||
|
|
372
|
+
/\b(?:gh|gh\.exe): no such file or directory\b/.test(lowerMessage)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function formatError(error: unknown): string {
|
|
377
|
+
return error instanceof Error ? error.message : String(error);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function parsePrCoordinates(pr: JsonRecord): { owner: string; name: string; number: number } {
|
|
381
|
+
const number = requiredNumber(pr.number, "number");
|
|
382
|
+
const url = optionalString(pr.url);
|
|
383
|
+
if (!url) throw new Error("Missing PR url");
|
|
384
|
+
|
|
385
|
+
let parsed: URL;
|
|
386
|
+
try {
|
|
387
|
+
parsed = new URL(url);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
throw new Error(`Invalid PR url: ${formatError(error)}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const match = /^\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/.exec(parsed.pathname);
|
|
393
|
+
if (!match) throw new Error(`Unsupported PR url: ${url}`);
|
|
394
|
+
|
|
395
|
+
return { owner: match[1], name: match[2], number };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function arrayValue(value: unknown): unknown[] {
|
|
399
|
+
if (Array.isArray(value)) return value;
|
|
400
|
+
const object = objectRecord(value);
|
|
401
|
+
return Array.isArray(object.nodes) ? object.nodes : [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function objectRecord(value: unknown): JsonRecord {
|
|
405
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function requiredNumber(value: unknown, name: string): number {
|
|
409
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
410
|
+
throw new Error(`Missing numeric PR ${name}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function optionalString(value: unknown): string | undefined {
|
|
414
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
415
|
+
}
|