@senad-d/branchme 0.3.8 → 0.3.10
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 +14 -0
- package/README.md +8 -7
- package/SECURITY.md +7 -7
- package/docs/PROJECT_DEFINITION_BRIEF.md +7 -6
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +10 -4
- 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 +3 -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 +169 -27
- package/src/tools/branchme-tools.ts +8 -1
- package/src/tools/workflow-tools.ts +30 -5
- package/src/types.ts +36 -0
package/src/github-feedback.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
GITHUB_API_BASE_URL, GITHUB_GRAPHQL_TIMEOUT_MS, GITHUB_JOB_LOG_TIMEOUT_MS, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS,
|
|
3
3
|
PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT, PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES,
|
|
4
4
|
PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS,
|
|
5
|
+
PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
5
6
|
} from "./constants.ts";
|
|
6
7
|
import {
|
|
7
8
|
encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
|
|
@@ -10,22 +11,24 @@ import {
|
|
|
10
11
|
} from "./github.ts";
|
|
11
12
|
import { redactSecrets } from "./redaction.ts";
|
|
12
13
|
import type {
|
|
13
|
-
GitHubRepository, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
14
|
-
PullRequestFeedbackReview, PullRequestFeedbackThread,
|
|
14
|
+
GitHubRepository, PullRequestFeedbackAnnotation, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
|
|
15
|
+
PullRequestFeedbackReview, PullRequestFeedbackThread, PullRequestReplyDetails, PullRequestReplyInput,
|
|
15
16
|
} from "./types.ts";
|
|
16
17
|
|
|
17
18
|
// One GraphQL request reads review threads with their resolution state, review summaries, conversation
|
|
18
19
|
// comments and the head commit's check rollup; REST has no thread resolution state.
|
|
20
|
+
// Ids, times and the viewer let an agent reply to a thread and skip its own earlier replies.
|
|
19
21
|
const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
22
|
+
viewer { login }
|
|
20
23
|
repository(owner: $owner, name: $repo) {
|
|
21
24
|
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 } }
|
|
25
|
+
id number url headRefOid
|
|
26
|
+
reviewThreads(first: 100) { totalCount nodes { id isResolved isOutdated path line comments(first: 20) { nodes { id author { login } createdAt body url } } } }
|
|
27
|
+
reviews(last: 50) { nodes { id author { login } submittedAt state body url } }
|
|
28
|
+
comments(last: 50) { totalCount nodes { id author { login } createdAt body url } }
|
|
26
29
|
commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) { totalCount nodes {
|
|
27
30
|
__typename
|
|
28
|
-
... on CheckRun { name conclusion databaseId detailsUrl }
|
|
31
|
+
... on CheckRun { name conclusion databaseId detailsUrl annotations(first: 20) { nodes { path location { start { line } } annotationLevel message } } }
|
|
29
32
|
... on StatusContext { context state targetUrl }
|
|
30
33
|
} } } } } }
|
|
31
34
|
}
|
|
@@ -34,6 +37,8 @@ const FEEDBACK_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
|
34
37
|
|
|
35
38
|
const FAILING_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "CANCELLED"]);
|
|
36
39
|
const FAILING_STATUS_STATES = new Set(["FAILURE", "ERROR"]);
|
|
40
|
+
const REPORTED_ANNOTATION_LEVELS = new Set(["FAILURE", "WARNING"]);
|
|
41
|
+
const ANNOTATION_MESSAGE_LIMIT_CHARS = 500;
|
|
37
42
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/gu;
|
|
38
43
|
const LOG_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z ?/u;
|
|
39
44
|
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\u2028\u2029]/gu;
|
|
@@ -53,16 +58,19 @@ function totalCount(connection: unknown): number {
|
|
|
53
58
|
return isRecord(connection) && typeof connection.totalCount === "number" ? connection.totalCount : 0;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
function parseComment(node: Record<string, unknown
|
|
61
|
+
function parseComment(viewer: string | null, token: string, node: Record<string, unknown>): PullRequestFeedbackComment {
|
|
57
62
|
const author = isRecord(node.author) ? safeText(node.author.login, token, 100) : "";
|
|
58
63
|
return {
|
|
64
|
+
id: safeText(node.id, token, 200),
|
|
59
65
|
author: author || "ghost",
|
|
66
|
+
byViewer: viewer !== null && author === viewer,
|
|
67
|
+
createdAt: safeText(node.createdAt ?? node.submittedAt, token, 40),
|
|
60
68
|
body: safeText(node.body, token, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS),
|
|
61
69
|
url: safeText(node.url, token, 300),
|
|
62
70
|
};
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
73
|
+
function parseThreads(connection: unknown, viewer: string | null, token: string): Pick<PullRequestFeedbackDetails, "unresolvedThreads" | "resolvedThreadCount" | "outdatedThreadCount"> {
|
|
66
74
|
const unresolvedThreads: PullRequestFeedbackThread[] = [];
|
|
67
75
|
let resolvedThreadCount = 0;
|
|
68
76
|
let outdatedThreadCount = 0;
|
|
@@ -71,35 +79,55 @@ function parseThreads(connection: unknown, token: string): Pick<PullRequestFeedb
|
|
|
71
79
|
else if (thread.isOutdated === true) outdatedThreadCount += 1;
|
|
72
80
|
else {
|
|
73
81
|
unresolvedThreads.push({
|
|
82
|
+
id: safeText(thread.id, token, 200),
|
|
74
83
|
path: safeText(thread.path, token, 300),
|
|
75
84
|
line: typeof thread.line === "number" ? thread.line : null,
|
|
76
|
-
comments: nodes(thread.comments).map(
|
|
85
|
+
comments: nodes(thread.comments).map(parseComment.bind(undefined, viewer, token)),
|
|
77
86
|
});
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
return { unresolvedThreads, resolvedThreadCount, outdatedThreadCount };
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
function parseReviews(connection: unknown, token: string): PullRequestFeedbackReview[] {
|
|
92
|
+
function parseReviews(connection: unknown, viewer: string | null, token: string): PullRequestFeedbackReview[] {
|
|
84
93
|
return nodes(connection)
|
|
85
|
-
.map((review) => ({ ...parseComment(
|
|
94
|
+
.map((review) => ({ ...parseComment(viewer, token, review), state: safeText(review.state, token, 40) }))
|
|
86
95
|
.filter((review) => review.body !== "" || review.state === "CHANGES_REQUESTED");
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
function parseAnnotation(token: string, node: Record<string, unknown>): PullRequestFeedbackAnnotation | null {
|
|
99
|
+
const level = typeof node.annotationLevel === "string" ? node.annotationLevel : "";
|
|
100
|
+
if (!REPORTED_ANNOTATION_LEVELS.has(level)) return null;
|
|
101
|
+
const start = isRecord(node.location) && isRecord(node.location.start) ? node.location.start : null;
|
|
102
|
+
return {
|
|
103
|
+
path: safeText(node.path, token, 300),
|
|
104
|
+
line: typeof start?.line === "number" ? start.line : null,
|
|
105
|
+
level,
|
|
106
|
+
message: safeText(node.message, token, ANNOTATION_MESSAGE_LIMIT_CHARS),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
function parseCheck(node: Record<string, unknown>, token: string): PullRequestFeedbackCheck | null {
|
|
90
111
|
if (node.__typename === "CheckRun") {
|
|
91
112
|
const conclusion = typeof node.conclusion === "string" ? node.conclusion : "";
|
|
92
113
|
if (!FAILING_CHECK_CONCLUSIONS.has(conclusion)) return null;
|
|
93
114
|
const detailsUrl = safeText(node.detailsUrl, token, 300);
|
|
94
115
|
const isActionsJob = detailsUrl.includes("/actions/runs/") && typeof node.databaseId === "number";
|
|
95
|
-
|
|
116
|
+
const annotations = nodes(node.annotations)
|
|
117
|
+
.map(parseAnnotation.bind(undefined, token))
|
|
118
|
+
.filter((annotation): annotation is PullRequestFeedbackAnnotation => annotation !== null);
|
|
119
|
+
return { name: safeText(node.name, token, 200), conclusion, url: detailsUrl, jobId: isActionsJob ? node.databaseId as number : null, annotations };
|
|
96
120
|
}
|
|
97
121
|
const state = typeof node.state === "string" ? node.state : "";
|
|
98
122
|
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 };
|
|
123
|
+
return { name: safeText(node.context, token, 200), conclusion: state, url: safeText(node.targetUrl, token, 300), jobId: null, annotations: [] };
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
async function postFeedbackQuery(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions): Promise<Record<string, unknown>> {
|
|
127
|
+
return postGraphQL(FEEDBACK_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request feedback");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function postGraphQL(query: string, variables: Record<string, unknown>, token: string, options: PullRequestFetchOptions, label: string): Promise<Record<string, unknown>> {
|
|
103
131
|
const controller = new AbortController();
|
|
104
132
|
const timer = setTimeout(controller.abort.bind(controller), GITHUB_GRAPHQL_TIMEOUT_MS);
|
|
105
133
|
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
@@ -107,16 +135,16 @@ async function postFeedbackQuery(repository: GitHubRepository, number: number, t
|
|
|
107
135
|
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), `${GITHUB_API_BASE_URL}/graphql`, {
|
|
108
136
|
method: "POST",
|
|
109
137
|
headers: gitHubJsonHeaders(token),
|
|
110
|
-
body: JSON.stringify({ query
|
|
138
|
+
body: JSON.stringify({ query, variables }),
|
|
111
139
|
signal,
|
|
112
|
-
},
|
|
113
|
-
if (!response.ok) throw new Error(
|
|
140
|
+
}, `${label} lookup failed`, token);
|
|
141
|
+
if (!response.ok) throw new Error(`${label} lookup returned HTTP ${response.status}.`);
|
|
114
142
|
const body = await readBoundedResponseText(response, signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
115
|
-
if (body.truncated) throw new Error(
|
|
116
|
-
const payload = requireGitHubResponseObject(parseGitHubJson(body.text,
|
|
143
|
+
if (body.truncated) throw new Error(`${label} exceeded the response byte limit.`);
|
|
144
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(body.text, `${label} response`, token), label);
|
|
117
145
|
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
|
118
146
|
const messages = payload.errors.map((error) => (isRecord(error) ? safeText(error.message, token, 300) : "")).filter(Boolean);
|
|
119
|
-
throw new Error(
|
|
147
|
+
throw new Error(`${label} query failed: ${messages.join("; ") || "unknown GraphQL error"}`);
|
|
120
148
|
}
|
|
121
149
|
return payload;
|
|
122
150
|
} finally {
|
|
@@ -184,6 +212,7 @@ export async function getPullRequestFeedback(repository: GitHubRepository, numbe
|
|
|
184
212
|
pullRequestNumberField(number);
|
|
185
213
|
const payload = await postFeedbackQuery(repository, number, token, options);
|
|
186
214
|
const data = isRecord(payload.data) ? payload.data : {};
|
|
215
|
+
const viewer = isRecord(data.viewer) ? safeText(data.viewer.login, token, 100) || null : null;
|
|
187
216
|
const pullRequest = isRecord(data.repository) && isRecord(data.repository.pullRequest) ? data.repository.pullRequest : null;
|
|
188
217
|
if (!pullRequest) throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
|
|
189
218
|
if (pullRequestNumberField(pullRequest.number) !== number) throw new Error("GitHub returned a different pull request number.");
|
|
@@ -201,12 +230,14 @@ export async function getPullRequestFeedback(repository: GitHubRepository, numbe
|
|
|
201
230
|
return {
|
|
202
231
|
repository,
|
|
203
232
|
number,
|
|
233
|
+
id: safeText(pullRequest.id, token, 200),
|
|
234
|
+
viewer,
|
|
204
235
|
url: safeText(pullRequest.url, token, 300),
|
|
205
236
|
headSha: safeText(pullRequest.headRefOid, token, 64),
|
|
206
237
|
checksState: rollup ? safeText(rollup.state, token, 40) : null,
|
|
207
|
-
...parseThreads(pullRequest.reviewThreads, token),
|
|
208
|
-
reviews: parseReviews(pullRequest.reviews, token),
|
|
209
|
-
comments: commentNodes.map(
|
|
238
|
+
...parseThreads(pullRequest.reviewThreads, viewer, token),
|
|
239
|
+
reviews: parseReviews(pullRequest.reviews, viewer, token),
|
|
240
|
+
comments: commentNodes.map(parseComment.bind(undefined, viewer, token)).filter((comment) => comment.body !== ""),
|
|
210
241
|
failingChecks,
|
|
211
242
|
omitted: {
|
|
212
243
|
threads: Math.max(0, totalCount(pullRequest.reviewThreads) - threadNodes.length),
|
|
@@ -220,13 +251,31 @@ function indent(text: string): string {
|
|
|
220
251
|
return text.split("\n").map((line) => ` ${line}`).join("\n");
|
|
221
252
|
}
|
|
222
253
|
|
|
254
|
+
function location(path: string, line: number | null): string {
|
|
255
|
+
return line === null ? path : `${path}:${line}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// "@alice (you) 2026-10-14T19:50:15Z" — the marker and time decide what is new.
|
|
259
|
+
function byline(comment: PullRequestFeedbackComment): string {
|
|
260
|
+
return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt].filter(Boolean).join(" ");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function idTag(kind: string, id: string): string {
|
|
264
|
+
return id ? `[${kind} ${id}] ` : "";
|
|
265
|
+
}
|
|
266
|
+
|
|
223
267
|
function formatThread(thread: PullRequestFeedbackThread): string {
|
|
224
|
-
|
|
225
|
-
|
|
268
|
+
return [
|
|
269
|
+
`- ${idTag("thread", thread.id)}${location(thread.path, thread.line)}`,
|
|
270
|
+
...thread.comments.map((comment) => ` ${byline(comment)}: ${comment.body}`),
|
|
271
|
+
].join("\n");
|
|
226
272
|
}
|
|
227
273
|
|
|
228
274
|
function formatCheck(check: PullRequestFeedbackCheck): string {
|
|
229
275
|
const lines = [`- ${check.name}: ${check.conclusion} ${check.url}`.trimEnd()];
|
|
276
|
+
if (check.annotations.length > 0) {
|
|
277
|
+
lines.push(" Annotations:", ...check.annotations.map((item) => ` ${location(item.path, item.line)} ${item.level}: ${item.message}`));
|
|
278
|
+
}
|
|
230
279
|
if (check.logTail) lines.push(` Log tail (job ${check.jobId}):`, indent(check.logTail));
|
|
231
280
|
if (check.logError) lines.push(` Log unavailable: ${check.logError}`);
|
|
232
281
|
return lines.join("\n");
|
|
@@ -238,15 +287,108 @@ export function formatPullRequestFeedback(details: PullRequestFeedbackDetails):
|
|
|
238
287
|
const omittedChecks = details.omitted.checks ? ` (${details.omitted.checks} checks beyond the first 100 not read)` : "";
|
|
239
288
|
const sections = [
|
|
240
289
|
`PR #${details.number} feedback at head ${details.headSha.slice(0, 12)}; checks ${details.checksState ?? "not reported"}. ${details.url}`,
|
|
290
|
+
...(details.viewer ? [`Token user: @${details.viewer}; comments marked (you) are its own earlier replies.`] : []),
|
|
241
291
|
`Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
|
|
242
292
|
...details.unresolvedThreads.map(formatThread),
|
|
243
293
|
`Reviews with a body or requested changes: ${details.reviews.length}.`,
|
|
244
|
-
...details.reviews.map((review) => `-
|
|
294
|
+
...details.reviews.map((review) => `- ${byline(review)} ${review.state}: ${review.body}`),
|
|
245
295
|
`Conversation comments: ${details.comments.length}${omittedComments}.`,
|
|
246
|
-
...details.comments.map((comment) => `-
|
|
296
|
+
...details.comments.map((comment) => `- ${idTag("comment", comment.id)}${byline(comment)}: ${comment.body}`),
|
|
247
297
|
`Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
|
|
248
298
|
...details.failingChecks.map(formatCheck),
|
|
249
299
|
];
|
|
250
300
|
const text = sections.join("\n");
|
|
251
301
|
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
302
|
}
|
|
303
|
+
|
|
304
|
+
const THREAD_OWNER_QUERY = `query($id: ID!) {
|
|
305
|
+
node(id: $id) { ... on PullRequestReviewThread { id isResolved pullRequest { number repository { name owner { login } } } } }
|
|
306
|
+
}`;
|
|
307
|
+
const PULL_REQUEST_EXISTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
308
|
+
repository(owner: $owner, name: $repo) { pullRequest(number: $number) { number } }
|
|
309
|
+
}`;
|
|
310
|
+
const THREAD_REPLY_MUTATION = `mutation($threadId: ID!, $body: String!) {
|
|
311
|
+
addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { comment { url } }
|
|
312
|
+
}`;
|
|
313
|
+
const RESOLVE_THREAD_MUTATION = `mutation($threadId: ID!) {
|
|
314
|
+
resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
|
|
315
|
+
}`;
|
|
316
|
+
|
|
317
|
+
function validateReplyInput(input: PullRequestReplyInput): string {
|
|
318
|
+
pullRequestNumberField(input.number);
|
|
319
|
+
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
320
|
+
if (body === "") throw new Error("body is required and cannot be blank.");
|
|
321
|
+
if (body.length > PULL_REQUEST_REPLY_BODY_LIMIT_CHARS) throw new Error(`body exceeds ${PULL_REQUEST_REPLY_BODY_LIMIT_CHARS} characters.`);
|
|
322
|
+
if (input.resolve === true && !input.threadId) throw new Error("resolve requires threadId; conversation comments cannot be resolved.");
|
|
323
|
+
return body;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The thread must belong to this pull request of this repository before anything is posted.
|
|
327
|
+
async function requireThreadOfPullRequest(repository: GitHubRepository, input: PullRequestReplyInput, token: string, options: PullRequestFetchOptions): Promise<boolean> {
|
|
328
|
+
const payload = await postGraphQL(THREAD_OWNER_QUERY, { id: input.threadId }, token, options, "GitHub review thread");
|
|
329
|
+
const node = isRecord(payload.data) && isRecord(payload.data.node) ? payload.data.node : null;
|
|
330
|
+
const pullRequest = node && isRecord(node.pullRequest) ? node.pullRequest : null;
|
|
331
|
+
const owner = pullRequest && isRecord(pullRequest.repository) ? pullRequest.repository : null;
|
|
332
|
+
const ownerLogin = owner && isRecord(owner.owner) ? owner.owner.login : null;
|
|
333
|
+
if (!node || node.id !== input.threadId || pullRequest?.number !== input.number ||
|
|
334
|
+
typeof ownerLogin !== "string" || ownerLogin.toLowerCase() !== repository.owner.toLowerCase() ||
|
|
335
|
+
typeof owner?.name !== "string" || owner.name.toLowerCase() !== repository.repo.toLowerCase()) {
|
|
336
|
+
throw new Error(`Review thread ${safeText(input.threadId, token, 200)} is not a thread of pull request #${input.number} in ${repository.owner}/${repository.repo}; nothing was posted.`);
|
|
337
|
+
}
|
|
338
|
+
return node.isResolved === true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function replyToThread(repository: GitHubRepository, input: PullRequestReplyInput, body: string, token: string, options: PullRequestFetchOptions): Promise<PullRequestReplyDetails> {
|
|
342
|
+
const alreadyResolved = await requireThreadOfPullRequest(repository, input, token, options);
|
|
343
|
+
const reply = await postGraphQL(THREAD_REPLY_MUTATION, { threadId: input.threadId, body }, token, options, "GitHub review thread reply");
|
|
344
|
+
const added = isRecord(reply.data) && isRecord(reply.data.addPullRequestReviewThreadReply) ? reply.data.addPullRequestReviewThreadReply : null;
|
|
345
|
+
const commentUrl = added && isRecord(added.comment) ? safeText(added.comment.url, token, 300) : "";
|
|
346
|
+
if (!commentUrl) throw new Error("GitHub did not confirm the review thread reply.");
|
|
347
|
+
let resolved: boolean | null = null;
|
|
348
|
+
if (input.resolve === true) {
|
|
349
|
+
resolved = alreadyResolved;
|
|
350
|
+
if (!alreadyResolved) {
|
|
351
|
+
const result = await postGraphQL(RESOLVE_THREAD_MUTATION, { threadId: input.threadId }, token, options, "GitHub review thread resolution");
|
|
352
|
+
const thread = isRecord(result.data) && isRecord(result.data.resolveReviewThread) && isRecord(result.data.resolveReviewThread.thread)
|
|
353
|
+
? result.data.resolveReviewThread.thread : null;
|
|
354
|
+
resolved = thread?.isResolved === true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { action: "reply_pull_request_feedback", repository, number: input.number, threadId: input.threadId ?? null, commentUrl, resolved };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function commentOnPullRequest(repository: GitHubRepository, input: PullRequestReplyInput, body: string, token: string, options: PullRequestFetchOptions): Promise<PullRequestReplyDetails> {
|
|
361
|
+
// Issues and pull requests share numbers; confirming a pull request first keeps the comment off an issue.
|
|
362
|
+
// GraphQL, not the REST status read: a pull request from a fork is still this repository's pull request.
|
|
363
|
+
const found = await postGraphQL(PULL_REQUEST_EXISTS_QUERY, { owner: repository.owner, repo: repository.repo, number: input.number }, token, options, "GitHub pull request");
|
|
364
|
+
const pullRequest = isRecord(found.data) && isRecord(found.data.repository) ? found.data.repository.pullRequest : null;
|
|
365
|
+
if (!isRecord(pullRequest) || pullRequest.number !== input.number) {
|
|
366
|
+
throw new Error(`Pull request #${input.number} was not found in ${repository.owner}/${repository.repo}; nothing was posted.`);
|
|
367
|
+
}
|
|
368
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/issues/${input.number}/comments`;
|
|
369
|
+
const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
|
|
370
|
+
method: "POST", headers: gitHubJsonHeaders(token), body: JSON.stringify({ body }), signal: options.signal,
|
|
371
|
+
}, "GitHub pull request comment failed", token);
|
|
372
|
+
if (!response.ok) throw new Error(`GitHub pull request comment returned HTTP ${response.status}.`);
|
|
373
|
+
const text = await readBoundedResponseText(response, options.signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
|
|
374
|
+
const payload = requireGitHubResponseObject(parseGitHubJson(text.text, "GitHub pull request comment response", token), "Pull request comment");
|
|
375
|
+
const commentUrl = safeText(payload.html_url, token, 300);
|
|
376
|
+
if (!commentUrl) throw new Error("GitHub did not confirm the pull request comment.");
|
|
377
|
+
return { action: "reply_pull_request_feedback", repository, number: input.number, threadId: null, commentUrl, resolved: null };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Replies to one review thread (and optionally resolves it), or posts one conversation comment. */
|
|
381
|
+
export async function replyPullRequestFeedback(repository: GitHubRepository, input: PullRequestReplyInput, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestReplyDetails> {
|
|
382
|
+
validateGitHubRepository(repository);
|
|
383
|
+
const body = validateReplyInput(input);
|
|
384
|
+
return input.threadId
|
|
385
|
+
? replyToThread(repository, input, body, token, options)
|
|
386
|
+
: commentOnPullRequest(repository, input, body, token, options);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function formatPullRequestReply(details: PullRequestReplyDetails): string {
|
|
390
|
+
if (details.threadId === null) return `Commented on PR #${details.number}: ${details.commentUrl}`;
|
|
391
|
+
let resolution = "";
|
|
392
|
+
if (details.resolved !== null) resolution = details.resolved ? "; thread resolved" : "; thread NOT resolved";
|
|
393
|
+
return `Replied to thread ${details.threadId} on PR #${details.number}${resolution}: ${details.commentUrl}`;
|
|
394
|
+
}
|
|
@@ -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,13 +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, PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
6
|
-
TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
|
|
5
|
+
CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
|
|
6
|
+
PULL_REQUEST_STATUS_TOOL_NAME, REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME, 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
|
+
import { formatPullRequestFeedback, formatPullRequestReply, getPullRequestFeedback, replyPullRequestFeedback } from "../github-feedback.ts";
|
|
12
12
|
import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
|
|
13
13
|
import type { BranchMeToolOptions } from "./branchme-tools.ts";
|
|
14
14
|
import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
|
|
@@ -43,7 +43,7 @@ export function formatUpdateFromBase(details: UpdateFromBaseDetails): string {
|
|
|
43
43
|
export function formatConcludeMerge(details: ConcludeMergeDetails): string {
|
|
44
44
|
const branch = branchLabel(details.branch);
|
|
45
45
|
if (details.status === "aborted") {
|
|
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
|
|
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.`;
|
|
47
47
|
}
|
|
48
48
|
const count = details.resolvedPaths.length;
|
|
49
49
|
return formatConflictPathList(
|
|
@@ -94,7 +94,7 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
94
94
|
pi.registerTool({
|
|
95
95
|
name: PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
96
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.",
|
|
97
|
+
description: "pull_request_feedback reads what a same-repository GitHub PR asks to be fixed: unresolved review threads (with thread ids) and their comments, review summaries, conversation comments (with ids), each with author and time and comments by the token's own user marked (you), and the head commit's failing checks with their file:line annotations and the log tail of up to three failing GitHub Actions jobs. Resolved and outdated threads are counted, not listed. No mutations.",
|
|
98
98
|
promptSnippet: "pull_request_feedback: read a PR's unresolved review comments and failing checks with their log tails",
|
|
99
99
|
promptGuidelines: [
|
|
100
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.",
|
|
@@ -121,6 +121,31 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
|
|
|
121
121
|
return { content: [{ type: "text", text: formatPullRequestFeedback(feedback) }], details: { action: "pull_request_feedback", feedback } };
|
|
122
122
|
},
|
|
123
123
|
});
|
|
124
|
+
pi.registerTool({
|
|
125
|
+
name: REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME,
|
|
126
|
+
label: "Reply To Pull Request Feedback",
|
|
127
|
+
description: "reply_pull_request_feedback posts one reply on a pull request of this GitHub repository (including one opened from a fork): with threadId it replies in that review thread and, with resolve: true, resolves the thread; without threadId it posts one conversation comment. Works on open, merged and closed PRs. The thread must belong to the PR; nothing else is changed.",
|
|
128
|
+
promptSnippet: "reply_pull_request_feedback: answer a PR review thread (optionally resolving it) or post a PR comment",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Use reply_pull_request_feedback with the thread ids pull_request_feedback reports ([thread <id>]); one call per thread, the body states what was done or why not.",
|
|
131
|
+
"Pass reply_pull_request_feedback resolve: true only for a thread whose request is fixed or already addressed; leave a thread open when it needs a person's decision.",
|
|
132
|
+
"Without threadId, reply_pull_request_feedback posts one conversation comment on the PR; conversation comments cannot be resolved.",
|
|
133
|
+
"reply_pull_request_feedback never edits or deletes comments, approves, requests changes, merges, or closes a PR.",
|
|
134
|
+
],
|
|
135
|
+
parameters: Type.Object({
|
|
136
|
+
number: Type.Integer({ minimum: 1, description: "PR number." }),
|
|
137
|
+
body: Type.String({ minLength: 1, maxLength: PULL_REQUEST_REPLY_BODY_LIMIT_CHARS, description: "Markdown reply." }),
|
|
138
|
+
threadId: Type.Optional(Type.String({ minLength: 1, description: "Review thread id from pull_request_feedback; omit for a conversation comment." })),
|
|
139
|
+
resolve: Type.Optional(Type.Boolean({ description: "Resolve the thread after replying; requires threadId." })),
|
|
140
|
+
}, { additionalProperties: false }),
|
|
141
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
142
|
+
const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
|
|
143
|
+
const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
|
|
144
|
+
const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
|
|
145
|
+
const details = await replyPullRequestFeedback(repository, params, token, { fetchImpl: options.fetchImpl, signal });
|
|
146
|
+
return { content: [{ type: "text", text: formatPullRequestReply(details) }], details };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
124
149
|
pi.registerTool({
|
|
125
150
|
name: UPDATE_FROM_BASE_TOOL_NAME,
|
|
126
151
|
label: "Update From Base",
|
package/src/types.ts
CHANGED
|
@@ -602,12 +602,19 @@ export interface PullRequestStatusDetails extends PullRequestDetails {
|
|
|
602
602
|
}
|
|
603
603
|
|
|
604
604
|
export interface PullRequestFeedbackComment {
|
|
605
|
+
/** GraphQL node id; empty when GitHub did not return one. */
|
|
606
|
+
id: string;
|
|
605
607
|
author: string;
|
|
608
|
+
/** True when the token's own user wrote it (Mission's earlier replies). */
|
|
609
|
+
byViewer: boolean;
|
|
610
|
+
createdAt: string;
|
|
606
611
|
body: string;
|
|
607
612
|
url: string;
|
|
608
613
|
}
|
|
609
614
|
|
|
610
615
|
export interface PullRequestFeedbackThread {
|
|
616
|
+
/** Review thread node id, the target for replies and resolution. */
|
|
617
|
+
id: string;
|
|
611
618
|
path: string;
|
|
612
619
|
line: number | null;
|
|
613
620
|
comments: PullRequestFeedbackComment[];
|
|
@@ -622,13 +629,25 @@ export interface PullRequestFeedbackCheck {
|
|
|
622
629
|
conclusion: string;
|
|
623
630
|
url: string;
|
|
624
631
|
jobId: number | null;
|
|
632
|
+
annotations: PullRequestFeedbackAnnotation[];
|
|
625
633
|
logTail?: string;
|
|
626
634
|
logError?: string;
|
|
627
635
|
}
|
|
628
636
|
|
|
637
|
+
export interface PullRequestFeedbackAnnotation {
|
|
638
|
+
path: string;
|
|
639
|
+
line: number | null;
|
|
640
|
+
level: string;
|
|
641
|
+
message: string;
|
|
642
|
+
}
|
|
643
|
+
|
|
629
644
|
export interface PullRequestFeedbackDetails {
|
|
630
645
|
repository: GitHubRepository;
|
|
631
646
|
number: number;
|
|
647
|
+
/** Pull request node id. */
|
|
648
|
+
id: string;
|
|
649
|
+
/** Login of the token's user; null when GitHub did not return it. */
|
|
650
|
+
viewer: string | null;
|
|
632
651
|
url: string;
|
|
633
652
|
headSha: string;
|
|
634
653
|
checksState: string | null;
|
|
@@ -641,6 +660,23 @@ export interface PullRequestFeedbackDetails {
|
|
|
641
660
|
omitted: { threads: number; comments: number; checks: number };
|
|
642
661
|
}
|
|
643
662
|
|
|
663
|
+
export interface PullRequestReplyInput {
|
|
664
|
+
number: number;
|
|
665
|
+
body: string;
|
|
666
|
+
threadId?: string;
|
|
667
|
+
resolve?: boolean;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface PullRequestReplyDetails {
|
|
671
|
+
action: "reply_pull_request_feedback";
|
|
672
|
+
repository: GitHubRepository;
|
|
673
|
+
number: number;
|
|
674
|
+
threadId: string | null;
|
|
675
|
+
commentUrl: string;
|
|
676
|
+
/** Thread state after the call; null when resolution was not requested or this was a conversation comment. */
|
|
677
|
+
resolved: boolean | null;
|
|
678
|
+
}
|
|
679
|
+
|
|
644
680
|
export interface PullRequestInput {
|
|
645
681
|
headBranch: string;
|
|
646
682
|
baseBranch: string;
|