hq-jr 0.1.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.
Files changed (53) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +176 -0
  3. package/README.md +207 -0
  4. package/app.yml +17 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.d.ts +11 -0
  9. package/dist/config.js +16 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.js +827 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/schemas/review.d.ts +217 -0
  15. package/dist/schemas/review.js +52 -0
  16. package/dist/schemas/review.js.map +1 -0
  17. package/dist/services/ai.d.ts +58 -0
  18. package/dist/services/ai.js +268 -0
  19. package/dist/services/ai.js.map +1 -0
  20. package/dist/services/context-packager.d.ts +26 -0
  21. package/dist/services/context-packager.js +105 -0
  22. package/dist/services/context-packager.js.map +1 -0
  23. package/dist/services/db.d.ts +31 -0
  24. package/dist/services/db.js +133 -0
  25. package/dist/services/db.js.map +1 -0
  26. package/dist/services/diff-parser.d.ts +43 -0
  27. package/dist/services/diff-parser.js +190 -0
  28. package/dist/services/diff-parser.js.map +1 -0
  29. package/dist/services/discussion.d.ts +20 -0
  30. package/dist/services/discussion.js +78 -0
  31. package/dist/services/discussion.js.map +1 -0
  32. package/dist/services/file-filter.d.ts +10 -0
  33. package/dist/services/file-filter.js +167 -0
  34. package/dist/services/file-filter.js.map +1 -0
  35. package/dist/services/json-repair.d.ts +4 -0
  36. package/dist/services/json-repair.js +100 -0
  37. package/dist/services/json-repair.js.map +1 -0
  38. package/dist/services/remediation.d.ts +41 -0
  39. package/dist/services/remediation.js +370 -0
  40. package/dist/services/remediation.js.map +1 -0
  41. package/dist/services/review-memory.d.ts +44 -0
  42. package/dist/services/review-memory.js +99 -0
  43. package/dist/services/review-memory.js.map +1 -0
  44. package/dist/services/sandbox-runner.d.ts +66 -0
  45. package/dist/services/sandbox-runner.js +144 -0
  46. package/dist/services/sandbox-runner.js.map +1 -0
  47. package/dist/services/scrubber.d.ts +7 -0
  48. package/dist/services/scrubber.js +46 -0
  49. package/dist/services/scrubber.js.map +1 -0
  50. package/dist/services/symbol-cache.d.ts +34 -0
  51. package/dist/services/symbol-cache.js +49 -0
  52. package/dist/services/symbol-cache.js.map +1 -0
  53. package/package.json +61 -0
package/dist/index.js ADDED
@@ -0,0 +1,827 @@
1
+ import { checkAiHealth, runTriage, runDeepReview } from "./services/ai.js";
2
+ import { parseUnifiedDiff, isLineInDiff } from "./services/diff-parser.js";
3
+ import { packageReviewContext } from "./services/context-packager.js";
4
+ import { replyToDiscussion } from "./services/discussion.js";
5
+ import { getPreviousReviewContext, getReviewCommentThread } from "./services/review-memory.js";
6
+ import { executeRemediation } from "./services/remediation.js";
7
+ import { scrubSecrets } from "./services/scrubber.js";
8
+ import { addRemediationCommit, hasRemediationCommit, acquireRunLock, releaseRunLock } from "./services/db.js";
9
+ import { executeSandboxCheckRun } from "./services/sandbox-runner.js";
10
+ export default (app, { getRouter } = {}) => {
11
+ process.on("unhandledRejection", (reason) => {
12
+ app.log.warn({ reason }, "Handled unhandledRejection to ensure daemon stability");
13
+ });
14
+ process.on("uncaughtException", (error) => {
15
+ app.log.error({ error }, "Fatal uncaughtException in worker process");
16
+ });
17
+ app.log.info("hq-jr code reviewer initialized with Vertex AI ADC");
18
+ // Expose health check endpoint on the Probot Express router
19
+ if (getRouter) {
20
+ const router = getRouter();
21
+ router.get("/healthz", async (_req, res) => {
22
+ const aiHealth = await checkAiHealth();
23
+ const healthy = aiHealth.status === "ok";
24
+ res.status(healthy ? 200 : 503).json({
25
+ status: healthy ? "healthy" : "unhealthy",
26
+ uptimeSeconds: process.uptime(),
27
+ vertexAi: aiHealth,
28
+ });
29
+ });
30
+ }
31
+ async function runPullRequestReview(params) {
32
+ const { octokit, owner, repoName, pullNumber, pr } = params;
33
+ // Do not review PRs that are already closed or merged
34
+ if (pr.state === "closed" || pr.merged || pr.merged_at) {
35
+ app.log.info({ pullNumber }, "PR is closed or already merged. Skipping review.");
36
+ return;
37
+ }
38
+ // Do not review commits created by hq-jr autonomous remediation
39
+ if (hasRemediationCommit(pr.head.sha)) {
40
+ app.log.info({ pullNumber, sha: pr.head.sha }, "Commit was generated by hq-jr remediation. Skipping self-review.");
41
+ return;
42
+ }
43
+ const runKey = `${owner}/${repoName}#${pullNumber}`;
44
+ const acquired = acquireRunLock({
45
+ runKey,
46
+ owner,
47
+ repo: repoName,
48
+ pullNumber,
49
+ headSha: pr.head.sha,
50
+ });
51
+ if (!acquired) {
52
+ app.log.warn({ runKey, headSha: pr.head.sha }, "Review run already active for pull request. Skipping duplicate event.");
53
+ return;
54
+ }
55
+ app.log.info({ pullNumber, repo: `${owner}/${repoName}` }, "Starting automated code review");
56
+ let checkRun = null;
57
+ try {
58
+ // 1. Create or update Check Run: in_progress
59
+ try {
60
+ checkRun = await octokit.checks.create({
61
+ owner,
62
+ repo: repoName,
63
+ name: "hq-jr AI Code Review",
64
+ head_sha: pr.head.sha,
65
+ status: "in_progress",
66
+ started_at: new Date().toISOString(),
67
+ output: {
68
+ title: "Analyzing Pull Request",
69
+ summary: "Fetching pull request diff and performing Tier 1 triage...",
70
+ },
71
+ });
72
+ }
73
+ catch (checkErr) {
74
+ app.log.warn({ checkErr }, "Failed to create GitHub check run, proceeding with review");
75
+ }
76
+ // 2. Fetch PR diff
77
+ const diffResponse = await octokit.pulls.get({
78
+ owner,
79
+ repo: repoName,
80
+ pull_number: pullNumber,
81
+ mediaType: {
82
+ format: "diff",
83
+ },
84
+ });
85
+ // 3. Parse diff and build context package
86
+ const diffText = diffResponse.data;
87
+ const parsedDiff = parseUnifiedDiff(diffText);
88
+ const totalHunks = parsedDiff.files.reduce((sum, f) => sum + f.hunks.length, 0);
89
+ if (parsedDiff.files.length === 0 || totalHunks === 0) {
90
+ if (checkRun?.data?.id) {
91
+ await octokit.checks.update({
92
+ owner,
93
+ repo: repoName,
94
+ check_run_id: checkRun.data.id,
95
+ status: "completed",
96
+ conclusion: "neutral",
97
+ output: {
98
+ title: "No Code Changes Detected",
99
+ summary: "Pull request contains no textual diff or code hunks to analyze (e.g. pure file mode changes or binary assets).",
100
+ },
101
+ });
102
+ }
103
+ return;
104
+ }
105
+ const contextPackage = packageReviewContext(parsedDiff);
106
+ if (contextPackage.itemsToReview.length === 0) {
107
+ app.log.info({ pullNumber }, "All modified files excluded by noise filters. Bypassing review.");
108
+ if (checkRun?.data?.id) {
109
+ await octokit.checks.update({
110
+ owner,
111
+ repo: repoName,
112
+ check_run_id: checkRun.data.id,
113
+ status: "completed",
114
+ conclusion: "success",
115
+ output: {
116
+ title: "Triage Passed: Excluded Files Only",
117
+ summary: `Modified files were identified as non-reviewable artifacts (lockfiles, documentation, generated bundles):\n\n${contextPackage.ignoredItems.map((i) => `- \`${i.path}\`: ${i.reason}`).join("\n")}`,
118
+ },
119
+ });
120
+ }
121
+ return;
122
+ }
123
+ // 4. Tier 1: Triage with Gemini 3.8 Flash
124
+ app.log.info({ pullNumber, filesCount: contextPackage.itemsToReview.length }, "Executing Tier 1 triage");
125
+ const triage = await runTriage(contextPackage.promptPayload);
126
+ const filesToReview = triage.files.filter((f) => f.shouldReview);
127
+ if (filesToReview.length === 0) {
128
+ app.log.info({ pullNumber }, "All files classified as low risk. Review complete.");
129
+ if (checkRun?.data?.id) {
130
+ await octokit.checks.update({
131
+ owner,
132
+ repo: repoName,
133
+ check_run_id: checkRun.data.id,
134
+ status: "completed",
135
+ conclusion: "success",
136
+ output: {
137
+ title: "Triage Passed: Low Risk Changes",
138
+ summary: `### Review Summary\n\n${triage.summary}\n\nAll modified files are low risk or auto-generated. Deep review bypassed.`,
139
+ },
140
+ });
141
+ }
142
+ return;
143
+ }
144
+ // 5. Tier 2: Deep Semantic Analysis with Gemini 3.8 Flash (High Thinking)
145
+ app.log.info({ pullNumber, count: filesToReview.length }, "Executing Tier 2 deep review");
146
+ // Retrieve previous review memory if hq-jr has audited this PR before
147
+ const previousReview = await getPreviousReviewContext({
148
+ octokit,
149
+ owner,
150
+ repo: repoName,
151
+ pullNumber,
152
+ });
153
+ if (previousReview) {
154
+ app.log.info({
155
+ pullNumber,
156
+ prevReviewId: previousReview.reviewId,
157
+ prevCommitSha: previousReview.lastCommitSha.slice(0, 7),
158
+ openIssuesCount: previousReview.openIssues.length,
159
+ }, "Incorporating prior review context for incremental verification");
160
+ }
161
+ const reviewPrompt = `Pull Request: #${pullNumber} - ${pr.title}
162
+ Author: ${pr.user?.login}
163
+ Description:
164
+ ${pr.body || "No description provided."}
165
+
166
+ Files requiring deep review:
167
+ ${filesToReview.map((f) => `- ${f.path} (Risk: ${f.risk}, Reason: ${f.reason})`).join("\n")}
168
+
169
+ ${contextPackage.promptPayload}
170
+ `;
171
+ const review = await runDeepReview(reviewPrompt, { previousReview });
172
+ // 6. Post inline review comments via Octokit REST (with line anchor validation)
173
+ const normalizePath = (p) => p.replace(/^[./]+/, "").replace(/\\/g, "/");
174
+ const fileMap = new Map();
175
+ for (const f of parsedDiff.files) {
176
+ if (f.newPath)
177
+ fileMap.set(normalizePath(f.newPath), f);
178
+ if (f.oldPath)
179
+ fileMap.set(normalizePath(f.oldPath), f);
180
+ }
181
+ const validComments = [];
182
+ const orphanComments = [];
183
+ for (const c of review.comments) {
184
+ const normPath = normalizePath(c.path);
185
+ let body = `### [${c.category}] ${c.title}\n\n${c.body}`;
186
+ if (c.suggestedPatch) {
187
+ body += `\n\n\`\`\`suggestion\n${c.suggestedPatch}\n\`\`\``;
188
+ }
189
+ const matchedFile = fileMap.get(normPath);
190
+ const effectiveSide = matchedFile?.isDeleted ? "LEFT" : c.side;
191
+ if (matchedFile &&
192
+ !matchedFile.isSubmodule &&
193
+ !matchedFile.isBinary &&
194
+ isLineInDiff(matchedFile, c.line, effectiveSide)) {
195
+ validComments.push({
196
+ path: matchedFile.newPath || matchedFile.oldPath,
197
+ line: c.line,
198
+ side: effectiveSide,
199
+ body,
200
+ });
201
+ }
202
+ else {
203
+ let orphanBody = `- **${normPath}:${c.line}** [${c.severity}]: ${c.title}\n ${c.body}`;
204
+ if (c.suggestedPatch) {
205
+ orphanBody += `\n \`\`\`\n ${c.suggestedPatch}\n \`\`\``;
206
+ }
207
+ orphanComments.push(orphanBody);
208
+ }
209
+ }
210
+ let reviewBody = `## hq-jr Automated Code Review\n\n${review.summary}\n\n**Verdict:** \`${review.verdict}\``;
211
+ if (review.resolvedPriorIssues && review.resolvedPriorIssues.length > 0) {
212
+ reviewBody += `\n\n### ✅ Resolved Issues from Prior Review\n${review.resolvedPriorIssues.map((i) => `- ${i}`).join("\n")}`;
213
+ }
214
+ if (review.unresolvedPriorIssues && review.unresolvedPriorIssues.length > 0) {
215
+ reviewBody += `\n\n### ⚠️ Remaining Open Issues from Prior Review\n${review.unresolvedPriorIssues.map((i) => `- ${i}`).join("\n")}`;
216
+ }
217
+ if (orphanComments.length > 0) {
218
+ reviewBody += `\n\n### Additional File Observations (Outside Modified Diff Lines)\n${orphanComments.join("\n")}`;
219
+ }
220
+ if (validComments.length > 0 || orphanComments.length > 0 || previousReview) {
221
+ try {
222
+ await octokit.pulls.createReview({
223
+ owner,
224
+ repo: repoName,
225
+ pull_number: pullNumber,
226
+ commit_id: pr.head.sha,
227
+ event: review.verdict === "REQUEST_CHANGES" ? "REQUEST_CHANGES" : "COMMENT",
228
+ body: reviewBody,
229
+ comments: validComments,
230
+ });
231
+ }
232
+ catch (reviewErr) {
233
+ if (reviewErr?.status === 422 && validComments.length > 0) {
234
+ app.log.warn({ reviewErr: reviewErr.message }, "Inline comment position rejected by GitHub (HTTP 422). Falling back to review summary.");
235
+ let fallbackBody = reviewBody + "\n\n### Inline Comments (Attached as Summary due to diff positioning):\n";
236
+ for (const vc of validComments) {
237
+ fallbackBody += `\n- **${vc.path}:${vc.line}** (${vc.side}):\n${vc.body}\n`;
238
+ }
239
+ await octokit.pulls.createReview({
240
+ owner,
241
+ repo: repoName,
242
+ pull_number: pullNumber,
243
+ commit_id: pr.head.sha,
244
+ event: review.verdict === "REQUEST_CHANGES" ? "REQUEST_CHANGES" : "COMMENT",
245
+ body: fallbackBody,
246
+ });
247
+ }
248
+ else {
249
+ throw reviewErr;
250
+ }
251
+ }
252
+ }
253
+ // 7. Complete Check Run
254
+ if (checkRun?.data?.id) {
255
+ const conclusion = review.verdict === "REQUEST_CHANGES"
256
+ ? "failure"
257
+ : review.verdict === "APPROVE"
258
+ ? "success"
259
+ : "neutral";
260
+ await octokit.checks.update({
261
+ owner,
262
+ repo: repoName,
263
+ check_run_id: checkRun.data.id,
264
+ status: "completed",
265
+ conclusion,
266
+ output: {
267
+ title: `Review Completed: ${review.verdict}`,
268
+ summary: `### Summary\n\n${review.summary}\n\nFound **${review.comments.length}** observation(s).`,
269
+ },
270
+ });
271
+ }
272
+ // 8. Tier 3: Trigger asynchronous sandbox check run if requested by deep review
273
+ if (review.requiresSandboxVerification) {
274
+ const goal = review.verificationGoal || "Verify flagged critical issues in isolated container sandbox";
275
+ app.log.info({ pullNumber, goal }, "Triggering Tier 3 asynchronous sandbox check run");
276
+ executeSandboxCheckRun({
277
+ octokit,
278
+ owner,
279
+ repo: repoName,
280
+ pullNumber,
281
+ headSha: pr.head.sha,
282
+ branch: pr.head.ref || "main",
283
+ verificationGoal: goal,
284
+ modifiedFiles: parsedDiff.files.map((f) => f.newPath || f.oldPath),
285
+ probes: review.sandboxProbes,
286
+ }).catch((sandboxErr) => {
287
+ app.log.warn({ sandboxErr, pullNumber }, "Async sandbox verification check run encountered error");
288
+ });
289
+ }
290
+ }
291
+ catch (err) {
292
+ const errorMsg = err instanceof Error ? err.message : String(err);
293
+ app.log.error({ err, pullNumber }, "Error processing review");
294
+ if (checkRun?.data?.id) {
295
+ try {
296
+ await octokit.checks.update({
297
+ owner,
298
+ repo: repoName,
299
+ check_run_id: checkRun.data.id,
300
+ status: "completed",
301
+ conclusion: "failure",
302
+ output: {
303
+ title: "Review Failed",
304
+ summary: `Automated review encountered an error: ${errorMsg}`,
305
+ },
306
+ });
307
+ }
308
+ catch (updateErr) {
309
+ app.log.warn({ updateErr }, "Failed to update check run conclusion on error");
310
+ }
311
+ }
312
+ }
313
+ finally {
314
+ releaseRunLock(runKey);
315
+ }
316
+ }
317
+ // Handle pull request events
318
+ app.on(["pull_request.opened", "pull_request.synchronize", "pull_request.reopened"], async (context) => {
319
+ const { pull_request: pr, repository: repo, sender } = context.payload;
320
+ // 1. Never review PR actions triggered by the bot itself or other automated systems
321
+ if (sender?.type === "Bot" ||
322
+ sender?.login?.includes("hq-jr") ||
323
+ sender?.login?.endsWith("[bot]")) {
324
+ app.log.info({ pullNumber: pr.number, sender: sender?.login }, "Ignoring pull_request event triggered by bot self-action");
325
+ return;
326
+ }
327
+ // 2. Never review PRs that are already merged or closed
328
+ if (pr.state === "closed" || pr.merged || pr.merged_at) {
329
+ app.log.info({ pullNumber: pr.number }, "Ignoring pull_request event for closed or merged pull request");
330
+ return;
331
+ }
332
+ // 3. Never review commits generated by hq-jr autonomous remediation
333
+ if (hasRemediationCommit(pr.head.sha)) {
334
+ app.log.info({ pullNumber: pr.number, sha: pr.head.sha }, "Ignoring pull_request event for hq-jr remediation commit");
335
+ return;
336
+ }
337
+ await runPullRequestReview({
338
+ octokit: context.octokit,
339
+ owner: repo.owner.login,
340
+ repoName: repo.name,
341
+ pullNumber: pr.number,
342
+ pr,
343
+ });
344
+ });
345
+ // Handle interactive developer discussions and @hq-jr mentions
346
+ app.on("pull_request_review_comment.created", async (context) => {
347
+ const comment = context.payload.comment;
348
+ const user = comment.user;
349
+ // Do not respond to self or other bot comments
350
+ if (user.type === "Bot" || user.login.endsWith("[bot]")) {
351
+ return;
352
+ }
353
+ const body = comment.body.trim();
354
+ const mentionsBot = body.includes("@hq-jr") || body.includes("@hq-jr[bot]");
355
+ const isReply = Boolean(comment.in_reply_to_id);
356
+ if (!mentionsBot && !isReply) {
357
+ return;
358
+ }
359
+ try {
360
+ const threadHistory = await getReviewCommentThread({
361
+ octokit: context.octokit,
362
+ owner: context.payload.repository.owner.login,
363
+ repo: context.payload.repository.name,
364
+ pullNumber: context.payload.pull_request.number,
365
+ commentId: comment.id,
366
+ inReplyToId: comment.in_reply_to_id,
367
+ });
368
+ // Avoid thread hijacking: if not explicitly mentioned, only respond if hq-jr is an existing participant in thread
369
+ if (!mentionsBot && isReply) {
370
+ const involvesBot = threadHistory.some((t) => t.isBot || t.author.includes("hq-jr"));
371
+ if (!involvesBot) {
372
+ return;
373
+ }
374
+ }
375
+ let originalComment;
376
+ if (threadHistory.length > 0) {
377
+ originalComment = threadHistory[0].body;
378
+ }
379
+ else if (comment.in_reply_to_id) {
380
+ const parent = await context.octokit.pulls.getReviewComment({
381
+ owner: context.payload.repository.owner.login,
382
+ repo: context.payload.repository.name,
383
+ comment_id: comment.in_reply_to_id,
384
+ });
385
+ originalComment = parent.data.body;
386
+ }
387
+ app.log.info({ commentId: comment.id, user: user.login, threadTurns: threadHistory.length }, "Responding to discussion comment with full thread context");
388
+ const reply = await replyToDiscussion({
389
+ filePath: comment.path,
390
+ diffHunk: comment.diff_hunk,
391
+ originalComment,
392
+ threadHistory,
393
+ userQuery: body,
394
+ authorLogin: user.login,
395
+ commitSha: comment.commit_id,
396
+ });
397
+ await context.octokit.pulls.createReplyForReviewComment({
398
+ owner: context.payload.repository.owner.login,
399
+ repo: context.payload.repository.name,
400
+ pull_number: context.payload.pull_request.number,
401
+ comment_id: comment.id,
402
+ body: reply,
403
+ });
404
+ }
405
+ catch (err) {
406
+ const errorMsg = err instanceof Error ? err.message : String(err);
407
+ app.log.error({ err, commentId: comment.id }, "Failed to reply to discussion comment");
408
+ await context.octokit.pulls.createReplyForReviewComment({
409
+ owner: context.payload.repository.owner.login,
410
+ repo: context.payload.repository.name,
411
+ pull_number: context.payload.pull_request.number,
412
+ comment_id: comment.id,
413
+ body: `*hq-jr encountered an issue analyzing this thread: ${errorMsg}*`,
414
+ });
415
+ }
416
+ });
417
+ // Handle PR conversation comments mentioning @hq-jr
418
+ app.on("issue_comment.created", async (context) => {
419
+ // Only handle comments on pull requests
420
+ if (!context.payload.issue.pull_request) {
421
+ return;
422
+ }
423
+ const comment = context.payload.comment;
424
+ const user = comment.user;
425
+ // Do not respond to self or other bot comments
426
+ if (user.type === "Bot" || user.login.endsWith("[bot]")) {
427
+ return;
428
+ }
429
+ const body = comment.body.trim();
430
+ const mentionsBot = body.includes("@hq-jr") || body.includes("@hq-jr[bot]");
431
+ if (!mentionsBot) {
432
+ return;
433
+ }
434
+ const owner = context.payload.repository.owner.login;
435
+ const repoName = context.payload.repository.name;
436
+ const pullNumber = context.payload.issue.number;
437
+ app.log.info({ pullNumber, user: user.login }, "Responding to main PR thread comment mentioning @hq-jr");
438
+ try {
439
+ const lower = body.toLowerCase();
440
+ const isRemediateCommand = lower.includes("fix") ||
441
+ lower.includes("patch") ||
442
+ lower.includes("remediate") ||
443
+ lower.includes("resolve");
444
+ if (isRemediateCommand) {
445
+ // Enforce RBAC: Verify commenter has write or admin permissions to repo
446
+ let hasWrite = false;
447
+ try {
448
+ const { data: perm } = await context.octokit.repos.getCollaboratorPermissionLevel({
449
+ owner,
450
+ repo: repoName,
451
+ username: user.login,
452
+ });
453
+ hasWrite = perm.permission === "admin" || perm.permission === "write";
454
+ }
455
+ catch (permErr) {
456
+ app.log.warn({ permErr, user: user.login }, "Failed to verify collaborator permissions; failing closed");
457
+ }
458
+ if (!hasWrite) {
459
+ await context.octokit.issues.createComment({
460
+ owner,
461
+ repo: repoName,
462
+ issue_number: pullNumber,
463
+ body: `⛔ **Access Denied:** Only repository collaborators with write or admin permissions can trigger automated remediation or merging.`,
464
+ });
465
+ return;
466
+ }
467
+ app.log.info({ pullNumber, user: user.login }, "Triggering autonomous remediation from comment");
468
+ await context.octokit.issues.createComment({
469
+ owner,
470
+ repo: repoName,
471
+ issue_number: pullNumber,
472
+ body: `🛠️ **hq-jr** received remediation request from @${user.login}. Fetching prior audit findings, synthesizing code fixes with High Thinking, and generating remediation branch/commit...`,
473
+ });
474
+ const prResponse = await context.octokit.pulls.get({
475
+ owner,
476
+ repo: repoName,
477
+ pull_number: pullNumber,
478
+ });
479
+ const prevReview = await getPreviousReviewContext({
480
+ octokit: context.octokit,
481
+ owner,
482
+ repo: repoName,
483
+ pullNumber,
484
+ });
485
+ if (!prevReview || prevReview.openIssues.length === 0) {
486
+ await context.octokit.issues.createComment({
487
+ owner,
488
+ repo: repoName,
489
+ issue_number: pullNumber,
490
+ body: `⚠️ **hq-jr** could not find any open review issues to remediate. Run \`@hq-jr review\` first to audit the PR.`,
491
+ });
492
+ return;
493
+ }
494
+ const hasNegativeMerge = /\b(?:do not|don't|no|never)\s+merge\b/i.test(body);
495
+ const hasExplicitMergeCommand = /\b(?:and\s+merge|auto-?merge)\b/i.test(body);
496
+ const autoMerge = hasExplicitMergeCommand && !hasNegativeMerge;
497
+ const result = await executeRemediation({
498
+ octokit: context.octokit,
499
+ owner,
500
+ repo: repoName,
501
+ pullNumber,
502
+ pr: prResponse.data,
503
+ issues: prevReview.openIssues,
504
+ autoMerge,
505
+ });
506
+ let commentBody = `### 🛠️ hq-jr Remediation Report\n\n${result.message}\n\n`;
507
+ if (result.filesModified.length > 0) {
508
+ commentBody += `**Files Modified:**\n${result.filesModified.map((f) => `- \`${f}\``).join("\n")}\n\n`;
509
+ }
510
+ if (result.commitSha) {
511
+ addRemediationCommit(result.commitSha);
512
+ commentBody += `**Commit SHA:** \`${result.commitSha.slice(0, 7)}\`\n`;
513
+ }
514
+ if (result.prUrl) {
515
+ commentBody += `**Remediation PR:** ${result.prUrl}\n`;
516
+ }
517
+ await context.octokit.issues.createComment({
518
+ owner,
519
+ repo: repoName,
520
+ issue_number: pullNumber,
521
+ body: commentBody,
522
+ });
523
+ return;
524
+ }
525
+ const isReviewCommand = lower.includes("review") ||
526
+ lower.includes("audit") ||
527
+ lower.includes("scan") ||
528
+ lower.includes("diff");
529
+ if (isReviewCommand) {
530
+ app.log.info({ pullNumber, user: user.login }, "Triggering full PR review from comment command");
531
+ await context.octokit.issues.createComment({
532
+ owner,
533
+ repo: repoName,
534
+ issue_number: pullNumber,
535
+ body: `🤖 **hq-jr** received review command from @${user.login}. Starting automated code review with High Thinking — line-by-line diff comments will appear directly under **Files changed**...`,
536
+ });
537
+ const prResponse = await context.octokit.pulls.get({
538
+ owner,
539
+ repo: repoName,
540
+ pull_number: pullNumber,
541
+ });
542
+ await runPullRequestReview({
543
+ octokit: context.octokit,
544
+ owner,
545
+ repoName,
546
+ pullNumber,
547
+ pr: prResponse.data,
548
+ });
549
+ return;
550
+ }
551
+ const hasNegativeMerge = /\b(?:do not|don't|no|never)\s+merge\b/i.test(body);
552
+ const isMergeOnlyCommand = /\b(?:merge(?:\s+it|\s+this)?|auto-?merge)\b/i.test(body) &&
553
+ !hasNegativeMerge;
554
+ if (isMergeOnlyCommand) {
555
+ let hasWrite = false;
556
+ try {
557
+ const { data: perm } = await context.octokit.repos.getCollaboratorPermissionLevel({
558
+ owner,
559
+ repo: repoName,
560
+ username: user.login,
561
+ });
562
+ hasWrite = perm.permission === "admin" || perm.permission === "write";
563
+ }
564
+ catch (permErr) {
565
+ app.log.warn({ permErr, user: user.login }, "Failed to verify collaborator permissions for merge; failing closed");
566
+ }
567
+ if (!hasWrite) {
568
+ await context.octokit.issues.createComment({
569
+ owner,
570
+ repo: repoName,
571
+ issue_number: pullNumber,
572
+ body: `⛔ **Access Denied:** Only repository collaborators with write or admin permissions can merge pull requests.`,
573
+ });
574
+ return;
575
+ }
576
+ const prResponse = await context.octokit.pulls.get({
577
+ owner,
578
+ repo: repoName,
579
+ pull_number: pullNumber,
580
+ });
581
+ const pr = prResponse.data;
582
+ let closedIssueNotice = "";
583
+ if (lower.includes("close") && (lower.includes("issue") || lower.includes("it"))) {
584
+ const issueMatch = body.match(/#(\d+)/) || (pr.body || "").match(/(?:closes|fixes|resolves)\s+#(\d+)/i);
585
+ if (issueMatch) {
586
+ const linkedIssueNum = parseInt(issueMatch[1], 10);
587
+ try {
588
+ await context.octokit.issues.update({
589
+ owner,
590
+ repo: repoName,
591
+ issue_number: linkedIssueNum,
592
+ state: "closed",
593
+ });
594
+ closedIssueNotice = ` and closed linked issue #${linkedIssueNum}`;
595
+ }
596
+ catch (closeErr) {
597
+ app.log.warn({ closeErr, linkedIssueNum }, "Could not close linked issue");
598
+ }
599
+ }
600
+ }
601
+ try {
602
+ await context.octokit.pulls.merge({
603
+ owner,
604
+ repo: repoName,
605
+ pull_number: pullNumber,
606
+ merge_method: "squash",
607
+ });
608
+ await context.octokit.issues.createComment({
609
+ owner,
610
+ repo: repoName,
611
+ issue_number: pullNumber,
612
+ body: `✅ **Merged:** Successfully merged Pull Request #${pullNumber} (squash)${closedIssueNotice} upon request by @${user.login}.`,
613
+ });
614
+ }
615
+ catch (mergeErr) {
616
+ const errorMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
617
+ app.log.warn({ mergeErr, pullNumber }, "Direct merge failed, attempting GraphQL auto-merge");
618
+ try {
619
+ const gqlResult = await context.octokit.graphql(`query ($owner: String!, $repo: String!, $prNumber: Int!) {
620
+ repository(owner: $owner, name: $repo) {
621
+ pullRequest(number: $prNumber) {
622
+ id
623
+ }
624
+ }
625
+ }`, { owner, repo: repoName, prNumber: pullNumber });
626
+ if (gqlResult.repository?.pullRequest?.id) {
627
+ await context.octokit.graphql(`mutation ($input: EnablePullRequestAutoMergeInput!) {
628
+ enablePullRequestAutoMerge(input: $input) {
629
+ pullRequest {
630
+ number
631
+ }
632
+ }
633
+ }`, {
634
+ input: {
635
+ pullRequestId: gqlResult.repository.pullRequest.id,
636
+ mergeMethod: "SQUASH",
637
+ },
638
+ });
639
+ await context.octokit.issues.createComment({
640
+ owner,
641
+ repo: repoName,
642
+ issue_number: pullNumber,
643
+ body: `🔄 **Auto-Merge Enabled:** Direct merge could not complete immediately (${errorMsg}). Auto-merge has been armed for PR #${pullNumber}${closedIssueNotice} upon request by @${user.login}.`,
644
+ });
645
+ return;
646
+ }
647
+ }
648
+ catch (gqlErr) {
649
+ app.log.warn({ gqlErr }, "GraphQL auto-merge also failed");
650
+ }
651
+ await context.octokit.issues.createComment({
652
+ owner,
653
+ repo: repoName,
654
+ issue_number: pullNumber,
655
+ body: `⚠️ **Merge Blocked:** Could not merge PR #${pullNumber}: ${errorMsg}. Please ensure all required check runs are passing and mergeable state is clean.`,
656
+ });
657
+ }
658
+ return;
659
+ }
660
+ // Fetch PR diff for context
661
+ const diffResponse = await context.octokit.pulls.get({
662
+ owner,
663
+ repo: repoName,
664
+ pull_number: pullNumber,
665
+ mediaType: {
666
+ format: "diff",
667
+ },
668
+ });
669
+ const diffText = diffResponse.data;
670
+ const reply = await replyToDiscussion({
671
+ filePath: `Pull Request #${pullNumber}`,
672
+ diffHunk: scrubSecrets(diffText.slice(0, 15000)).scrubbed,
673
+ userQuery: scrubSecrets(body).scrubbed,
674
+ authorLogin: user.login,
675
+ });
676
+ await context.octokit.issues.createComment({
677
+ owner,
678
+ repo: repoName,
679
+ issue_number: pullNumber,
680
+ body: reply,
681
+ });
682
+ }
683
+ catch (err) {
684
+ const errorMsg = err instanceof Error ? err.message : String(err);
685
+ app.log.error({ err, pullNumber }, "Failed to reply to PR issue comment");
686
+ await context.octokit.issues.createComment({
687
+ owner,
688
+ repo: repoName,
689
+ issue_number: pullNumber,
690
+ body: `*hq-jr encountered an issue analyzing this thread: ${errorMsg}*`,
691
+ });
692
+ }
693
+ });
694
+ // Handle re-requesting review from Checks UI
695
+ app.on("check_run.rerequested", async (context) => {
696
+ const checkRun = context.payload.check_run;
697
+ const pullRequests = checkRun.pull_requests;
698
+ if (!pullRequests || pullRequests.length === 0) {
699
+ app.log.info("No pull requests attached to re-requested check run");
700
+ return;
701
+ }
702
+ const prRef = pullRequests[0];
703
+ const owner = context.payload.repository.owner.login;
704
+ const repoName = context.payload.repository.name;
705
+ app.log.info({ pullNumber: prRef.number, checkRunId: checkRun.id }, "Check run re-requested by developer");
706
+ const prResponse = await context.octokit.pulls.get({
707
+ owner,
708
+ repo: repoName,
709
+ pull_number: prRef.number,
710
+ });
711
+ await runPullRequestReview({
712
+ octokit: context.octokit,
713
+ owner,
714
+ repoName,
715
+ pullNumber: prRef.number,
716
+ pr: prResponse.data,
717
+ });
718
+ });
719
+ // Handle Check Run action buttons (e.g. Commit Repro Test)
720
+ app.on("check_run.requested_action", async (context) => {
721
+ const { check_run: checkRun, requested_action: requestedAction } = context.payload;
722
+ const identifier = requestedAction.identifier;
723
+ const owner = context.payload.repository.owner.login;
724
+ const repoName = context.payload.repository.name;
725
+ const sender = context.payload.sender.login;
726
+ if (identifier === "commit_repro_test") {
727
+ let hasWrite = false;
728
+ try {
729
+ const { data: perm } = await context.octokit.repos.getCollaboratorPermissionLevel({
730
+ owner,
731
+ repo: repoName,
732
+ username: sender,
733
+ });
734
+ hasWrite = perm.permission === "admin" || perm.permission === "write";
735
+ }
736
+ catch (permErr) {
737
+ app.log.warn({ permErr, sender }, "Failed to verify collaborator permissions for check action; failing closed");
738
+ }
739
+ const pullRequests = checkRun.pull_requests;
740
+ if (!pullRequests || pullRequests.length === 0) {
741
+ app.log.warn({ checkRunId: checkRun.id }, "No pull request attached to check run action");
742
+ return;
743
+ }
744
+ const pullNumber = pullRequests[0].number;
745
+ if (!hasWrite) {
746
+ await context.octokit.issues.createComment({
747
+ owner,
748
+ repo: repoName,
749
+ issue_number: pullNumber,
750
+ body: `⛔ **Access Denied:** Only repository collaborators with write or admin permissions can commit reproduction tests.`,
751
+ });
752
+ return;
753
+ }
754
+ const prResponse = await context.octokit.pulls.get({
755
+ owner,
756
+ repo: repoName,
757
+ pull_number: pullNumber,
758
+ });
759
+ const pr = prResponse.data;
760
+ const targetBranch = pr.head.ref;
761
+ const outputText = checkRun.output?.summary || "";
762
+ const testCodeMatch = outputText.match(/```(?:rust|typescript|javascript|python)?\s*([\s\S]*?)```/);
763
+ const testCode = testCodeMatch ? testCodeMatch[1].trim() : null;
764
+ if (!testCode) {
765
+ await context.octokit.issues.createComment({
766
+ owner,
767
+ repo: repoName,
768
+ issue_number: pullNumber,
769
+ body: `⚠️ **hq-jr** could not extract a synthesized reproduction test from this check run output.`,
770
+ });
771
+ return;
772
+ }
773
+ const isRust = outputText.includes("cargo") || outputText.includes(".rs");
774
+ const isPython = outputText.includes("pytest") || outputText.includes(".py");
775
+ const testFileName = isRust
776
+ ? `tests/repro_issue_${pullNumber}.rs`
777
+ : isPython
778
+ ? `tests/test_repro_issue_${pullNumber}.py`
779
+ : `tests/repro_issue_${pullNumber}.test.ts`;
780
+ const { data: blob } = await context.octokit.git.createBlob({
781
+ owner,
782
+ repo: repoName,
783
+ content: Buffer.from(testCode).toString("base64"),
784
+ encoding: "base64",
785
+ });
786
+ const { data: baseCommit } = await context.octokit.git.getCommit({
787
+ owner,
788
+ repo: repoName,
789
+ commit_sha: pr.head.sha,
790
+ });
791
+ const { data: newTree } = await context.octokit.git.createTree({
792
+ owner,
793
+ repo: repoName,
794
+ base_tree: baseCommit.tree.sha,
795
+ tree: [
796
+ {
797
+ path: testFileName,
798
+ mode: "100644",
799
+ type: "blob",
800
+ sha: blob.sha,
801
+ },
802
+ ],
803
+ });
804
+ const { data: newCommit } = await context.octokit.git.createCommit({
805
+ owner,
806
+ repo: repoName,
807
+ message: `test: add synthesized adversarial reproduction test for PR #${pullNumber} [skip ci]`,
808
+ tree: newTree.sha,
809
+ parents: [pr.head.sha],
810
+ });
811
+ addRemediationCommit(newCommit.sha);
812
+ await context.octokit.git.updateRef({
813
+ owner,
814
+ repo: repoName,
815
+ ref: `heads/${targetBranch}`,
816
+ sha: newCommit.sha,
817
+ });
818
+ await context.octokit.issues.createComment({
819
+ owner,
820
+ repo: repoName,
821
+ issue_number: pullNumber,
822
+ body: `✅ **Reproduction Test Committed:** \`${testFileName}\` has been committed directly to branch \`${targetBranch}\` (Commit: \`${newCommit.sha.slice(0, 7)}\`) by @${sender}.`,
823
+ });
824
+ }
825
+ });
826
+ };
827
+ //# sourceMappingURL=index.js.map