@senad-d/branchme 0.3.6 → 0.3.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/CHANGELOG.md +17 -0
- package/README.md +9 -8
- package/SECURITY.md +7 -7
- package/docs/PROJECT_DEFINITION_BRIEF.md +6 -5
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +11 -5
- package/docs/TUI_CAPTURE.md +1 -0
- package/package.json +1 -1
- package/src/blocker-guidance.ts +154 -0
- package/src/commands/branchme-command.ts +1 -0
- package/src/constants.ts +11 -0
- package/src/extension.ts +2 -1
- package/src/git-integration.ts +10 -2
- package/src/git-landing.ts +67 -8
- package/src/git-transport.ts +133 -0
- package/src/git-workflow.ts +8 -10
- package/src/git.ts +31 -9
- package/src/github-feedback.ts +252 -0
- package/src/github.ts +10 -10
- package/src/tools/branchme-tools.ts +8 -1
- package/src/tools/workflow-tools.ts +34 -3
- package/src/types.ts +40 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GITHUB_API_BASE_URL, GITHUB_GRAPHQL_TIMEOUT_MS, GITHUB_JOB_LOG_TIMEOUT_MS, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS,
|
|
3
|
+
PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT, PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES,
|
|
4
|
+
PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS,
|
|
5
|
+
} from "./constants.ts";
|
|
6
|
+
import {
|
|
7
|
+
encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
|
|
8
|
+
readBoundedResponseText, requireFetchImplementation, requireGitHubResponseObject, validateGitHubRepository,
|
|
9
|
+
type PullRequestFetchOptions,
|
|
10
|
+
} from "./github.ts";
|
|
11
|
+
import { redactSecrets } from "./redaction.ts";
|
|
12
|
+
import type {
|
|
13
|
+
GitHubRepository, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
14
|
+
PullRequestFeedbackReview, PullRequestFeedbackThread,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// One GraphQL request reads review threads with their resolution state, review summaries, conversation
|
|
18
|
+
// comments and the head commit's check rollup; REST has no thread resolution state.
|
|
19
|
+
const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
20
|
+
repository(owner: $owner, name: $repo) {
|
|
21
|
+
pullRequest(number: $number) {
|
|
22
|
+
number url headRefOid
|
|
23
|
+
reviewThreads(first: 100) { totalCount nodes { isResolved isOutdated path line comments(first: 20) { nodes { author { login } body url } } } }
|
|
24
|
+
reviews(last: 50) { nodes { author { login } state body url } }
|
|
25
|
+
comments(last: 50) { totalCount nodes { author { login } body url } }
|
|
26
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { totalCount nodes {
|
|
27
|
+
__typename
|
|
28
|
+
... on CheckRun { name conclusion databaseId detailsUrl }
|
|
29
|
+
... on StatusContext { context state targetUrl }
|
|
30
|
+
} } } } } }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}`;
|
|
34
|
+
|
|
35
|
+
const FAILING_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "CANCELLED"]);
|
|
36
|
+
const FAILING_STATUS_STATES = new Set(["FAILURE", "ERROR"]);
|
|
37
|
+
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/gu;
|
|
38
|
+
const LOG_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z ?/u;
|
|
39
|
+
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu;
|
|
40
|
+
|
|
41
|
+
function safeText(value: unknown, token: string, limit: number): string {
|
|
42
|
+
if (typeof value !== "string") return "";
|
|
43
|
+
const text = redactSecrets(value, [token]).replace(UNSAFE_TEXT_PATTERN, (character) => (character === "\n" ? "\n" : " ")).trim();
|
|
44
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nodes(connection: unknown): Record<string, unknown>[] {
|
|
48
|
+
if (!isRecord(connection) || !Array.isArray(connection.nodes)) return [];
|
|
49
|
+
return connection.nodes.filter(isRecord);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function totalCount(connection: unknown): number {
|
|
53
|
+
return isRecord(connection) && typeof connection.totalCount === "number" ? connection.totalCount : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseComment(node: Record<string, unknown>, token: string): PullRequestFeedbackComment {
|
|
57
|
+
const author = isRecord(node.author) ? safeText(node.author.login, token, 100) : "";
|
|
58
|
+
return {
|
|
59
|
+
author: author || "ghost",
|
|
60
|
+
body: safeText(node.body, token, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS),
|
|
61
|
+
url: safeText(node.url, token, 300),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
66
|
+
const unresolvedThreads: PullRequestFeedbackThread[] = [];
|
|
67
|
+
let resolvedThreadCount = 0;
|
|
68
|
+
let outdatedThreadCount = 0;
|
|
69
|
+
for (const thread of nodes(connection)) {
|
|
70
|
+
if (thread.isResolved === true) resolvedThreadCount += 1;
|
|
71
|
+
else if (thread.isOutdated === true) outdatedThreadCount += 1;
|
|
72
|
+
else {
|
|
73
|
+
unresolvedThreads.push({
|
|
74
|
+
path: safeText(thread.path, token, 300),
|
|
75
|
+
line: typeof thread.line === "number" ? thread.line : null,
|
|
76
|
+
comments: nodes(thread.comments).map((comment) => parseComment(comment, token)),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { unresolvedThreads, resolvedThreadCount, outdatedThreadCount };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseReviews(connection: unknown, token: string): PullRequestFeedbackReview[] {
|
|
84
|
+
return nodes(connection)
|
|
85
|
+
.map((review) => ({ ...parseComment(review, token), state: safeText(review.state, token, 40) }))
|
|
86
|
+
.filter((review) => review.body !== "" || review.state === "CHANGES_REQUESTED");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCheck(node: Record<string, unknown>, token: string): PullRequestFeedbackCheck | null {
|
|
90
|
+
if (node.__typename === "CheckRun") {
|
|
91
|
+
const conclusion = typeof node.conclusion === "string" ? node.conclusion : "";
|
|
92
|
+
if (!FAILING_CHECK_CONCLUSIONS.has(conclusion)) return null;
|
|
93
|
+
const detailsUrl = safeText(node.detailsUrl, token, 300);
|
|
94
|
+
const isActionsJob = detailsUrl.includes("/actions/runs/") && typeof node.databaseId === "number";
|
|
95
|
+
return { name: safeText(node.name, token, 200), conclusion, url: detailsUrl, jobId: isActionsJob ? node.databaseId as number : null };
|
|
96
|
+
}
|
|
97
|
+
const state = typeof node.state === "string" ? node.state : "";
|
|
98
|
+
if (!FAILING_STATUS_STATES.has(state)) return null;
|
|
99
|
+
return { name: safeText(node.context, token, 200), conclusion: state, url: safeText(node.targetUrl, token, 300), jobId: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function postFeedbackQuery(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions): Promise<Record<string, unknown>> {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timer = setTimeout(controller.abort.bind(controller), GITHUB_GRAPHQL_TIMEOUT_MS);
|
|
105
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), `${GITHUB_API_BASE_URL}/graphql`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: gitHubJsonHeaders(token),
|
|
110
|
+
body: JSON.stringify({ query: FEEDBACK_QUERY, variables: { owner: repository.owner, repo: repository.repo, number } }),
|
|
111
|
+
signal,
|
|
112
|
+
}, "GitHub pull request feedback lookup failed", token);
|
|
113
|
+
if (!response.ok) throw new Error(`GitHub pull request feedback lookup returned HTTP ${response.status}.`);
|
|
114
|
+
const body = await readBoundedResponseText(response, signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
115
|
+
if (body.truncated) throw new Error("GitHub pull request feedback exceeded the response byte limit.");
|
|
116
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(body.text, "GitHub pull request feedback response", token), "Pull request feedback");
|
|
117
|
+
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
118
|
+
const messages = payload.errors.map((error) => (isRecord(error) ? safeText(error.message, token, 300) : "")).filter(Boolean);
|
|
119
|
+
throw new Error(`GitHub pull request feedback query failed: ${messages.join("; ") || "unknown GraphQL error"}`);
|
|
120
|
+
}
|
|
121
|
+
return payload;
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readResponseTail(response: Response, signal: AbortSignal): Promise<string> {
|
|
128
|
+
if (!response.body) return "";
|
|
129
|
+
const reader = response.body.getReader();
|
|
130
|
+
let tail = Buffer.alloc(0);
|
|
131
|
+
try {
|
|
132
|
+
while (true) {
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
if (!value || value.byteLength === 0) continue;
|
|
137
|
+
tail = Buffer.concat([tail, Buffer.from(value)]);
|
|
138
|
+
if (tail.byteLength > PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES) tail = tail.subarray(tail.byteLength - PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
reader.releaseLock();
|
|
142
|
+
}
|
|
143
|
+
return tail.toString("utf8");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Keep the lines leading up to the last error annotation; without one, the end of the log.
|
|
147
|
+
export function extractLogTail(log: string, token: string): string {
|
|
148
|
+
const lines = log.split(/\r?\n/u).map((line) => line.replace(ANSI_ESCAPE_PATTERN, "").replace(LOG_TIMESTAMP_PATTERN, "")).filter((line) => line.trim() !== "");
|
|
149
|
+
let end = lines.length;
|
|
150
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
151
|
+
if (lines[index].includes("##[error]")) {
|
|
152
|
+
end = index + 1;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const excerpt = lines.slice(Math.max(0, end - PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES), end).join("\n");
|
|
157
|
+
const text = safeText(excerpt, token, Number.MAX_SAFE_INTEGER);
|
|
158
|
+
return text.length <= PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS ? text : `…${text.slice(text.length - PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS + 1)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function attachJobLog(repository: GitHubRepository, check: PullRequestFeedbackCheck, token: string, options: PullRequestFetchOptions): Promise<void> {
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timer = setTimeout(controller.abort.bind(controller), GITHUB_JOB_LOG_TIMEOUT_MS);
|
|
164
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
165
|
+
try {
|
|
166
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/actions/jobs/${check.jobId}/logs`;
|
|
167
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
168
|
+
method: "GET", headers: gitHubJsonHeaders(token), signal,
|
|
169
|
+
}, "GitHub job log lookup failed", token);
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
check.logError = `job log returned HTTP ${response.status}`;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
check.logTail = extractLogTail(await readResponseTail(response, signal), token);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
check.logError = safeText(error instanceof Error ? error.message : String(error), token, 300);
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function getPullRequestFeedback(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestFeedbackDetails> {
|
|
183
|
+
validateGitHubRepository(repository);
|
|
184
|
+
pullRequestNumberField(number);
|
|
185
|
+
const payload = await postFeedbackQuery(repository, number, token, options);
|
|
186
|
+
const data = isRecord(payload.data) ? payload.data : {};
|
|
187
|
+
const pullRequest = isRecord(data.repository) && isRecord(data.repository.pullRequest) ? data.repository.pullRequest : null;
|
|
188
|
+
if (!pullRequest) throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
|
|
189
|
+
if (pullRequestNumberField(pullRequest.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
190
|
+
|
|
191
|
+
const commit = nodes(pullRequest.commits)[0];
|
|
192
|
+
const rollup = isRecord(commit?.commit) && isRecord(commit.commit.statusCheckRollup) ? commit.commit.statusCheckRollup : null;
|
|
193
|
+
const contexts = rollup ? nodes(rollup.contexts) : [];
|
|
194
|
+
const failingChecks = contexts.map((node) => parseCheck(node, token)).filter((check): check is PullRequestFeedbackCheck => check !== null);
|
|
195
|
+
// ponytail: logs for the first few failing Actions jobs only; raise the limit if wide matrices hide the cause.
|
|
196
|
+
const logChecks = failingChecks.filter((item) => item.jobId !== null).slice(0, PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT);
|
|
197
|
+
await Promise.all(logChecks.map((check) => attachJobLog(repository, check, token, options)));
|
|
198
|
+
|
|
199
|
+
const threadNodes = nodes(pullRequest.reviewThreads);
|
|
200
|
+
const commentNodes = nodes(pullRequest.comments);
|
|
201
|
+
return {
|
|
202
|
+
repository,
|
|
203
|
+
number,
|
|
204
|
+
url: safeText(pullRequest.url, token, 300),
|
|
205
|
+
headSha: safeText(pullRequest.headRefOid, token, 64),
|
|
206
|
+
checksState: rollup ? safeText(rollup.state, token, 40) : null,
|
|
207
|
+
...parseThreads(pullRequest.reviewThreads, token),
|
|
208
|
+
reviews: parseReviews(pullRequest.reviews, token),
|
|
209
|
+
comments: commentNodes.map((comment) => parseComment(comment, token)).filter((comment) => comment.body !== ""),
|
|
210
|
+
failingChecks,
|
|
211
|
+
omitted: {
|
|
212
|
+
threads: Math.max(0, totalCount(pullRequest.reviewThreads) - threadNodes.length),
|
|
213
|
+
comments: Math.max(0, totalCount(pullRequest.comments) - commentNodes.length),
|
|
214
|
+
checks: rollup ? Math.max(0, totalCount(rollup.contexts) - contexts.length) : 0,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function indent(text: string): string {
|
|
220
|
+
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatThread(thread: PullRequestFeedbackThread): string {
|
|
224
|
+
const location = thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
|
|
225
|
+
return [`- ${location}`, ...thread.comments.map((comment) => ` @${comment.author}: ${comment.body}`)].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatCheck(check: PullRequestFeedbackCheck): string {
|
|
229
|
+
const lines = [`- ${check.name}: ${check.conclusion} ${check.url}`.trimEnd()];
|
|
230
|
+
if (check.logTail) lines.push(` Log tail (job ${check.jobId}):`, indent(check.logTail));
|
|
231
|
+
if (check.logError) lines.push(` Log unavailable: ${check.logError}`);
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function formatPullRequestFeedback(details: PullRequestFeedbackDetails): string {
|
|
236
|
+
const omittedThreads = details.omitted.threads ? `; ${details.omitted.threads} beyond the first 100 not read` : "";
|
|
237
|
+
const omittedComments = details.omitted.comments ? ` (latest 50; ${details.omitted.comments} older not read)` : "";
|
|
238
|
+
const omittedChecks = details.omitted.checks ? ` (${details.omitted.checks} checks beyond the first 100 not read)` : "";
|
|
239
|
+
const sections = [
|
|
240
|
+
`PR #${details.number} feedback at head ${details.headSha.slice(0, 12)}; checks ${details.checksState ?? "not reported"}. ${details.url}`,
|
|
241
|
+
`Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
|
|
242
|
+
...details.unresolvedThreads.map(formatThread),
|
|
243
|
+
`Reviews with a body or requested changes: ${details.reviews.length}.`,
|
|
244
|
+
...details.reviews.map((review) => `- @${review.author} ${review.state}: ${review.body}`),
|
|
245
|
+
`Conversation comments: ${details.comments.length}${omittedComments}.`,
|
|
246
|
+
...details.comments.map((comment) => `- @${comment.author}: ${comment.body}`),
|
|
247
|
+
`Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
|
|
248
|
+
...details.failingChecks.map(formatCheck),
|
|
249
|
+
];
|
|
250
|
+
const text = sections.join("\n");
|
|
251
|
+
return text.length <= PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS ? text : `${text.slice(0, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS - 40)}\n… [truncated; full data in details]`;
|
|
252
|
+
}
|
package/src/github.ts
CHANGED
|
@@ -380,7 +380,7 @@ function truncate(value: string): string {
|
|
|
380
380
|
|
|
381
381
|
export { redactSecrets } from "./redaction.ts";
|
|
382
382
|
|
|
383
|
-
function encodePathSegment(value: string): string {
|
|
383
|
+
export function encodePathSegment(value: string): string {
|
|
384
384
|
return encodeURIComponent(value);
|
|
385
385
|
}
|
|
386
386
|
|
|
@@ -433,7 +433,7 @@ function validatePullRequestInput(input: unknown): asserts input is PullRequestI
|
|
|
433
433
|
if (typeof input.draft !== "boolean") throw new Error("draft must be a boolean.");
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
436
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
437
437
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
438
438
|
}
|
|
439
439
|
|
|
@@ -442,7 +442,7 @@ function stringField(value: unknown, field: string): string {
|
|
|
442
442
|
return value;
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
function pullRequestNumberField(value: unknown): number {
|
|
445
|
+
export function pullRequestNumberField(value: unknown): number {
|
|
446
446
|
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) {
|
|
447
447
|
throw new Error("GitHub response pull request number must be a finite positive safe integer.");
|
|
448
448
|
}
|
|
@@ -458,7 +458,7 @@ function throwIfResponseReadAborted(signal: AbortSignal | undefined): void {
|
|
|
458
458
|
if (signal?.aborted) throw new Error("GitHub pull request response read was aborted.");
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
-
async function readBoundedResponseText(response: Response, signal: AbortSignal | undefined): Promise<BoundedResponseBody> {
|
|
461
|
+
export async function readBoundedResponseText(response: Response, signal: AbortSignal | undefined, limitBytes = GITHUB_RESPONSE_BODY_LIMIT_BYTES): Promise<BoundedResponseBody> {
|
|
462
462
|
throwIfResponseReadAborted(signal);
|
|
463
463
|
if (!response.body) return { text: "", truncated: false };
|
|
464
464
|
|
|
@@ -474,7 +474,7 @@ async function readBoundedResponseText(response: Response, signal: AbortSignal |
|
|
|
474
474
|
if (done) break;
|
|
475
475
|
if (!value || value.byteLength === 0) continue;
|
|
476
476
|
|
|
477
|
-
const remainingBytes =
|
|
477
|
+
const remainingBytes = limitBytes - capturedBytes;
|
|
478
478
|
if (remainingBytes > 0) {
|
|
479
479
|
const capturedChunk = value.byteLength <= remainingBytes ? value : value.subarray(0, remainingBytes);
|
|
480
480
|
chunks.push(Buffer.from(capturedChunk));
|
|
@@ -495,7 +495,7 @@ async function readBoundedResponseText(response: Response, signal: AbortSignal |
|
|
|
495
495
|
return { text: truncated ? `${text}… [truncated]` : text, truncated };
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
498
|
+
export function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
499
499
|
return {
|
|
500
500
|
Accept: "application/vnd.github+json",
|
|
501
501
|
Authorization: `Bearer ${token}`,
|
|
@@ -505,7 +505,7 @@ function gitHubJsonHeaders(token: string): Record<string, string> {
|
|
|
505
505
|
};
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
-
function requireFetchImplementation(fetchImpl: typeof fetch | undefined): typeof fetch {
|
|
508
|
+
export function requireFetchImplementation(fetchImpl: typeof fetch | undefined): typeof fetch {
|
|
509
509
|
if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node.js runtime.");
|
|
510
510
|
return fetchImpl;
|
|
511
511
|
}
|
|
@@ -515,7 +515,7 @@ function redactedErrorMessage(error: unknown, tokens: readonly string[]): string
|
|
|
515
515
|
return redactSecrets(message, tokens);
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
async function fetchGitHubResponse(
|
|
518
|
+
export async function fetchGitHubResponse(
|
|
519
519
|
fetchImpl: typeof fetch,
|
|
520
520
|
url: string,
|
|
521
521
|
init: RequestInit,
|
|
@@ -542,7 +542,7 @@ async function readGitHubResponseBody(
|
|
|
542
542
|
}
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
function parseGitHubJson(text: string, responseContext: string, token: string): unknown {
|
|
545
|
+
export function parseGitHubJson(text: string, responseContext: string, token: string): unknown {
|
|
546
546
|
try {
|
|
547
547
|
return JSON.parse(text);
|
|
548
548
|
} catch (error) {
|
|
@@ -550,7 +550,7 @@ function parseGitHubJson(text: string, responseContext: string, token: string):
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
function requireGitHubResponseObject(payload: unknown, responseContext: string): Record<string, unknown> {
|
|
553
|
+
export function requireGitHubResponseObject(payload: unknown, responseContext: string): Record<string, unknown> {
|
|
554
554
|
if (!isRecord(payload)) throw new Error(`${responseContext} was not an object.`);
|
|
555
555
|
return payload;
|
|
556
556
|
}
|
|
@@ -52,6 +52,7 @@ import { collectGitContext, formatGitContext } from "../git-context.ts";
|
|
|
52
52
|
import { fetchRemote } from "../git-discovery.ts";
|
|
53
53
|
import { formatConflictPathList, integrateBranch } from "../git-integration.ts";
|
|
54
54
|
import { retireBranch } from "../git-retirement.ts";
|
|
55
|
+
import { withBlockerGuidance } from "../blocker-guidance.ts";
|
|
55
56
|
import { formatLandBranch, landBranch } from "../git-landing.ts";
|
|
56
57
|
import {
|
|
57
58
|
createOrReuseGitHubPullRequest,
|
|
@@ -546,7 +547,13 @@ async function requireGitHubHeadMatchesLocalBranch(
|
|
|
546
547
|
);
|
|
547
548
|
}
|
|
548
549
|
|
|
549
|
-
|
|
550
|
+
function registerGuidedTool(host: Pick<ExtensionAPI, "registerTool">, tool: Parameters<ExtensionAPI["registerTool"]>[0]): void {
|
|
551
|
+
host.registerTool(withBlockerGuidance(tool));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function registerBranchMeTools(host: Pick<ExtensionAPI, "registerTool" | "exec">, options: BranchMeToolOptions = {}): void {
|
|
555
|
+
// Every tool error carries Blocked/Resolve lines for known blockers.
|
|
556
|
+
const pi = { exec: host.exec.bind(host), registerTool: registerGuidedTool.bind(undefined, host) } as Pick<ExtensionAPI, "registerTool" | "exec">;
|
|
550
557
|
registerWorkflowTools(pi, options);
|
|
551
558
|
pi.registerTool({
|
|
552
559
|
name: LIST_BRANCHES_TOOL_NAME,
|
|
@@ -2,12 +2,13 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import {
|
|
5
|
-
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
6
|
-
UPDATE_FROM_BASE_TOOL_NAME,
|
|
5
|
+
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_STATUS_TOOL_NAME,
|
|
6
|
+
TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
|
|
7
7
|
} from "../constants.ts";
|
|
8
8
|
import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
|
|
9
9
|
import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
|
|
10
10
|
import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
|
|
11
|
+
import { formatPullRequestFeedback, getPullRequestFeedback } from "../github-feedback.ts";
|
|
11
12
|
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
12
13
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
13
14
|
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
@@ -42,7 +43,7 @@ export function formatUpdateFromBase(details: UpdateFromBaseDetails): string {
|
|
|
42
43
|
export function formatConcludeMerge(details: ConcludeMergeDetails): string {
|
|
43
44
|
const branch = branchLabel(details.branch);
|
|
44
45
|
if (details.status === "aborted") {
|
|
45
|
-
return `conclude_merge: aborted. git merge --abort restored ${branch} to HEAD ${shortCommit(details.head)}; MERGE_HEAD ${shortCommit(details.mergeHead)} is cleared, no Git operation is in progress, and
|
|
46
|
+
return `conclude_merge: aborted. git merge --abort restored ${branch} to HEAD ${shortCommit(details.head)}; MERGE_HEAD ${shortCommit(details.mergeHead)} is cleared, no Git operation is in progress, and tracked files are clean.`;
|
|
46
47
|
}
|
|
47
48
|
const count = details.resolvedPaths.length;
|
|
48
49
|
return formatConflictPathList(
|
|
@@ -90,6 +91,36 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
90
91
|
};
|
|
91
92
|
},
|
|
92
93
|
});
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
96
|
+
label: "Pull Request Feedback",
|
|
97
|
+
description: "pull_request_feedback reads what a same-repository GitHub PR asks to be fixed: unresolved review threads with their comments, review summaries, conversation comments, and the head commit's failing checks with the log tail of up to three failing GitHub Actions jobs. Resolved and outdated threads are counted, not listed. No mutations.",
|
|
98
|
+
promptSnippet: "pull_request_feedback: read a PR's unresolved review comments and failing checks with their log tails",
|
|
99
|
+
promptGuidelines: [
|
|
100
|
+
"Use pull_request_feedback with number to collect the review comments and failing checks a follow-up must address; number and headBranch are mutually exclusive.",
|
|
101
|
+
"Without number, pull_request_feedback reads the most recently updated PR for headBranch or the current branch.",
|
|
102
|
+
"pull_request_feedback never comments, resolves threads, reruns checks, or edits the PR; a job log that cannot be read is reported per check, not as a failure of the call.",
|
|
103
|
+
],
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
number: Type.Optional(Type.Integer({ minimum: 1, description: "Exact PR number; mutually exclusive with headBranch." })),
|
|
106
|
+
headBranch: Type.Optional(Type.String({ minLength: 1, description: "Head branch to look up; defaults to current branch when number is omitted." })),
|
|
107
|
+
}, { additionalProperties: false }),
|
|
108
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
109
|
+
if (params.number !== undefined && params.headBranch !== undefined) throw new Error("Supply number or headBranch, not both.");
|
|
110
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
111
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
112
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
113
|
+
const requestOptions = { fetchImpl: options.fetchImpl, signal };
|
|
114
|
+
let number = params.number;
|
|
115
|
+
if (number === undefined) {
|
|
116
|
+
const found = await findGitHubPullRequest(repository, params.headBranch ?? await requireCurrentBranch(pi, rootCtx, signal), token, requestOptions);
|
|
117
|
+
if (!found) return { content: [{ type: "text", text: "No matching pull request found." }], details: { action: "pull_request_feedback", feedback: null } };
|
|
118
|
+
number = found.number;
|
|
119
|
+
}
|
|
120
|
+
const feedback = await getPullRequestFeedback(repository, number, token, requestOptions);
|
|
121
|
+
return { content: [{ type: "text", text: formatPullRequestFeedback(feedback) }], details: { action: "pull_request_feedback", feedback } };
|
|
122
|
+
},
|
|
123
|
+
});
|
|
93
124
|
pi.registerTool({
|
|
94
125
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
95
126
|
label: "Update From Base",
|
package/src/types.ts
CHANGED
|
@@ -601,6 +601,46 @@ export interface PullRequestStatusDetails extends PullRequestDetails {
|
|
|
601
601
|
mergeCommitSha: string | null;
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
+
export interface PullRequestFeedbackComment {
|
|
605
|
+
author: string;
|
|
606
|
+
body: string;
|
|
607
|
+
url: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface PullRequestFeedbackThread {
|
|
611
|
+
path: string;
|
|
612
|
+
line: number | null;
|
|
613
|
+
comments: PullRequestFeedbackComment[];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface PullRequestFeedbackReview extends PullRequestFeedbackComment {
|
|
617
|
+
state: string;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface PullRequestFeedbackCheck {
|
|
621
|
+
name: string;
|
|
622
|
+
conclusion: string;
|
|
623
|
+
url: string;
|
|
624
|
+
jobId: number | null;
|
|
625
|
+
logTail?: string;
|
|
626
|
+
logError?: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export interface PullRequestFeedbackDetails {
|
|
630
|
+
repository: GitHubRepository;
|
|
631
|
+
number: number;
|
|
632
|
+
url: string;
|
|
633
|
+
headSha: string;
|
|
634
|
+
checksState: string | null;
|
|
635
|
+
unresolvedThreads: PullRequestFeedbackThread[];
|
|
636
|
+
resolvedThreadCount: number;
|
|
637
|
+
outdatedThreadCount: number;
|
|
638
|
+
reviews: PullRequestFeedbackReview[];
|
|
639
|
+
comments: PullRequestFeedbackComment[];
|
|
640
|
+
failingChecks: PullRequestFeedbackCheck[];
|
|
641
|
+
omitted: { threads: number; comments: number; checks: number };
|
|
642
|
+
}
|
|
643
|
+
|
|
604
644
|
export interface PullRequestInput {
|
|
605
645
|
headBranch: string;
|
|
606
646
|
baseBranch: string;
|