claude-teammate 0.1.37 → 0.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-teammate",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "CLI bootstrapper for Claude Teammate.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/claude.js CHANGED
@@ -139,6 +139,34 @@ const GITHUB_PR_COMMENT_REVIEW_SCHEMA = {
139
139
  required: ["decision", "comment_body", "epic_facts", "epic_guardrails"]
140
140
  };
141
141
 
142
+ const GITHUB_PR_REVIEW_SCHEMA = {
143
+ type: "object",
144
+ additionalProperties: false,
145
+ properties: {
146
+ summary: {
147
+ type: "string"
148
+ },
149
+ suggestions: {
150
+ type: "array",
151
+ items: {
152
+ type: "object",
153
+ additionalProperties: false,
154
+ properties: {
155
+ file: { type: "string" },
156
+ line: { type: "number" },
157
+ side: { type: "string" },
158
+ body: { type: "string" },
159
+ classification: { type: "string", enum: ["Minor", "Major"] },
160
+ committable: { type: "boolean" },
161
+ suggestion: { type: "string" }
162
+ },
163
+ required: ["file", "line", "side", "body", "classification", "committable"]
164
+ }
165
+ }
166
+ },
167
+ required: ["summary", "suggestions"]
168
+ };
169
+
142
170
  const EPIC_MEMORY_CLEANUP_SCHEMA = {
143
171
  type: "object",
144
172
  additionalProperties: false,
@@ -502,6 +530,50 @@ export async function runClaudePullRequestCommentReview(input) {
502
530
  return validateGitHubPullRequestCommentReviewResult(parseClaudeOutput(stdout));
503
531
  }
504
532
 
533
+ export async function runClaudePrReview(input) {
534
+ if (!input.repoPath) {
535
+ throw new Error("PR review requires a local repository path.");
536
+ }
537
+
538
+ const repoPaths = [input.repoPath];
539
+ await Promise.all(repoPaths.map((repoPath) => clearLocalPlaywrightMcpConfig(repoPath)));
540
+
541
+ const args = [
542
+ "--print",
543
+ "--model",
544
+ input.model || DEFAULT_MODEL,
545
+ "--permission-mode",
546
+ "default",
547
+ "--strict-mcp-config",
548
+ "--tools",
549
+ "Read,Grep,Glob",
550
+ "--effort",
551
+ "medium",
552
+ "--output-format",
553
+ "json",
554
+ "--json-schema",
555
+ JSON.stringify(GITHUB_PR_REVIEW_SCHEMA),
556
+ "--append-system-prompt",
557
+ buildGitHubPRReviewSystemPrompt(),
558
+ buildGitHubPRReviewUserPrompt(input)
559
+ ];
560
+
561
+ let stdout;
562
+
563
+ try {
564
+ ({ stdout } = await runClaudeCommand("claude", args, {
565
+ cwd: input.repoPath,
566
+ maxBuffer: 10 * 1024 * 1024,
567
+ timeout: input.timeoutMs || DEFAULT_TIMEOUT_MS,
568
+ onSpawn: input.onSpawn
569
+ }));
570
+ } catch (error) {
571
+ throw new Error(formatClaudeInvocationError(error, input.timeoutMs || DEFAULT_TIMEOUT_MS));
572
+ }
573
+
574
+ return validateGitHubPrReviewResult(parseClaudeOutput(stdout));
575
+ }
576
+
505
577
  export function parseClaudeOutput(output) {
506
578
  const trimmed = output.trim();
507
579
  if (!trimmed) {
@@ -522,6 +594,10 @@ export function parseClaudeOutput(output) {
522
594
  return direct;
523
595
  }
524
596
 
597
+ if ("summary" in direct && "suggestions" in direct) {
598
+ return direct;
599
+ }
600
+
525
601
  if ("result" in direct && typeof direct.result === "string") {
526
602
  return JSON.parse(direct.result);
527
603
  }
@@ -634,6 +710,27 @@ function validateGitHubPullRequestCommentReviewResult(result) {
634
710
  };
635
711
  }
636
712
 
713
+ function validateGitHubPrReviewResult(result) {
714
+ if (!result || typeof result !== "object") {
715
+ throw new Error("Claude CLI returned an invalid GitHub PR review payload.");
716
+ }
717
+
718
+ return {
719
+ summary: String(result.summary ?? "").trim(),
720
+ suggestions: Array.isArray(result.suggestions)
721
+ ? result.suggestions.map((s) => ({
722
+ file: String(s.file ?? "").trim(),
723
+ line: Number(s.line) || 1,
724
+ side: String(s.side ?? "RIGHT").trim(),
725
+ body: String(s.body ?? "").trim(),
726
+ classification: ["Minor", "Major"].includes(s.classification) ? s.classification : "Minor",
727
+ committable: Boolean(s.committable),
728
+ suggestion: s.suggestion != null ? String(s.suggestion).trim() : undefined
729
+ }))
730
+ : []
731
+ };
732
+ }
733
+
637
734
  function validateEpicMemoryCleanupResult(result) {
638
735
  if (!result || typeof result !== "object") {
639
736
  throw new Error("Claude CLI returned an invalid epic memory cleanup payload.");
@@ -931,6 +1028,20 @@ function buildGitHubPRCommentReviewSystemPrompt() {
931
1028
  ].join(" ");
932
1029
  }
933
1030
 
1031
+ function buildGitHubPRReviewSystemPrompt() {
1032
+ return [
1033
+ "You are reviewing a GitHub pull request as a code reviewer.",
1034
+ "The primary input is the unified diff provided inline.",
1035
+ "You may also use Read, Grep, and Glob to read related code when the diff is ambiguous.",
1036
+ "Do not edit files, do not run mutating commands, and do not implement anything.",
1037
+ "Skip trivial style-only issues unless they introduce a real risk.",
1038
+ "Classify each suggestion as Minor (non-blocking) or Major (should be addressed before merge).",
1039
+ "Where possible, produce a committable suggestion with the corrected code.",
1040
+ "Return a summary paragraph describing what the PR does, followed by a summary of all suggestions.",
1041
+ "Return only structured output matching the provided schema."
1042
+ ].join(" ");
1043
+ }
1044
+
934
1045
  function buildEpicMemoryCleanupSystemPrompt() {
935
1046
  return [
936
1047
  "You are updating epic memory for an autonomous engineering workflow.",
@@ -1082,6 +1193,22 @@ Instructions:
1082
1193
  - If blocked, return result=stuck with that summary explaining why it did not work.`;
1083
1194
  }
1084
1195
 
1196
+ function buildGitHubPRReviewUserPrompt(input) {
1197
+ return `Review this GitHub pull request diff.
1198
+
1199
+ Pull request number: ${input.pr?.number ?? ""}
1200
+ Pull request title:
1201
+ ${input.pr?.title ?? ""}
1202
+
1203
+ Repository path:
1204
+ ${input.repoPath}
1205
+
1206
+ Unified diff:
1207
+ ${input.diff || "(empty)"}
1208
+
1209
+ Review the diff carefully. Read related code files with Read, Grep, or Glob only when the diff alone is insufficient to judge correctness. Return a summary describing the PR and its suggestions.`;
1210
+ }
1211
+
1085
1212
  function buildEpicMemoryCleanupUserPrompt(input) {
1086
1213
  return `Update this epic memory facts and guardrails and clean it up.
1087
1214
 
@@ -8,6 +8,7 @@ import {
8
8
  runClaudeEpicMemoryCleanup,
9
9
  runClaudeEpicMemorySummarize,
10
10
  runClaudeImplementation,
11
+ runClaudePrReview,
11
12
  runClaudePullRequestCommentReview,
12
13
  runClaudeGitHubIssueReview
13
14
  } from "../claude.js";
@@ -26,8 +27,10 @@ import {
26
27
  saveIssueMemory
27
28
  } from "../memory.js";
28
29
  import {
30
+ checkoutPullRequestBranch,
29
31
  commitAndPushRepoChanges,
30
32
  ensureBranchFromDefault,
33
+ ensureReviewRepo,
31
34
  hasUsableGitCheckout,
32
35
  listMissingRepoPaths,
33
36
  parseGitHubRepoUrl,
@@ -116,7 +119,12 @@ export async function runWorkerCommand({ projectRoot }) {
116
119
  draftPrCount: 0,
117
120
  draftPrs: [],
118
121
  prCommentReview: buildPrSubtaskState(previousState.prCommentReview),
119
- prImplementation: buildPrSubtaskState(previousState.prImplementation)
122
+ prImplementation: buildPrSubtaskState(previousState.prImplementation),
123
+ lastReviewPollAt: null,
124
+ lastReviewSuccessAt: null,
125
+ lastReviewError: null,
126
+ reviewPrCount: 0,
127
+ reviewPrs: []
120
128
  };
121
129
 
122
130
  const updatePrSubtaskState = async (key, updates) => {
@@ -136,6 +144,7 @@ export async function runWorkerCommand({ projectRoot }) {
136
144
  let jiraPolling = false;
137
145
  let githubPolling = false;
138
146
  let prPolling = false;
147
+ let reviewPolling = false;
139
148
 
140
149
  const shutdown = async (signal) => {
141
150
  if (stopping) {
@@ -344,9 +353,90 @@ export async function runWorkerCommand({ projectRoot }) {
344
353
  }
345
354
  };
346
355
 
356
+ const pollReviewPrs = async () => {
357
+ if (reviewPolling || stopping) {
358
+ return;
359
+ }
360
+
361
+ reviewPolling = true;
362
+ state.lastReviewPollAt = new Date().toISOString();
363
+
364
+ try {
365
+ const repos = await listKnownRepos(projectRoot);
366
+ const processedPrs = [];
367
+ let reviewedPrCount = 0;
368
+
369
+ for (const repo of repos) {
370
+ const prs = await github.listPrsNeedingReview(repo.url, githubBotUser.login);
371
+ reviewedPrCount += prs.length;
372
+
373
+ for (const pr of prs) {
374
+ try {
375
+ const repoPath = await ensureReviewRepo(repo.url, runtimePaths.reviewReposDir);
376
+ const prDetail = await github.fetchPullRequest(repo.url, pr.number);
377
+ await checkoutPullRequestBranch(repoPath, prDetail.headRef, prDetail.headRepoCloneUrl);
378
+
379
+ const diff = await github.fetchPullRequestDiff(repo.url, pr.number);
380
+ const result = await runClaudePrReview({
381
+ diff,
382
+ repoPath,
383
+ pr: prDetail,
384
+ timeoutMs: parseOptionalInt(values.CLAUDE_TIMEOUT_MS)
385
+ });
386
+
387
+ await github.createPullRequestReview(
388
+ repo.url,
389
+ pr.number,
390
+ result.summary,
391
+ result.suggestions,
392
+ "COMMENT"
393
+ );
394
+
395
+ await github.addLabelsToPullRequest(repo.url, pr.number, ["AI-reviewed"]);
396
+
397
+ processedPrs.push({
398
+ repoUrl: repo.url,
399
+ pullRequestNumber: String(pr.number),
400
+ pullRequestUrl: prDetail.url,
401
+ suggestionsCount: result.suggestions.length
402
+ });
403
+
404
+ await logger.info("PR review submitted", {
405
+ repo: repo.url,
406
+ pr: pr.number,
407
+ suggestions: result.suggestions.length
408
+ });
409
+ } catch (error) {
410
+ await logger.error("PR review failed", {
411
+ repo: repo.url,
412
+ pr: pr.number,
413
+ error
414
+ });
415
+ }
416
+ }
417
+ }
418
+
419
+ state.lastReviewSuccessAt = new Date().toISOString();
420
+ state.lastReviewError = null;
421
+ state.reviewPrCount = reviewedPrCount;
422
+ state.reviewPrs = processedPrs.slice(0, 20);
423
+ await writeState(runtimePaths.stateFile, state);
424
+ await logger.info("PR review poll complete", {
425
+ reviewed: reviewedPrCount
426
+ });
427
+ } catch (error) {
428
+ state.lastReviewError = error instanceof Error ? error.message : String(error);
429
+ await writeState(runtimePaths.stateFile, state);
430
+ await logger.error("PR review poll failed", { error });
431
+ } finally {
432
+ reviewPolling = false;
433
+ }
434
+ };
435
+
347
436
  await pollJira();
348
437
  await pollGitHub();
349
438
  await pollDraftPrs();
439
+ await pollReviewPrs();
350
440
  setInterval(() => {
351
441
  void pollJira();
352
442
  }, POLL_INTERVAL_MS);
@@ -356,6 +446,9 @@ export async function runWorkerCommand({ projectRoot }) {
356
446
  setInterval(() => {
357
447
  void pollDraftPrs();
358
448
  }, POLL_INTERVAL_MS);
449
+ setInterval(() => {
450
+ void pollReviewPrs();
451
+ }, POLL_INTERVAL_MS);
359
452
  }
360
453
 
361
454
  async function processJiraIssue({ issue, jira, github, botUser, config, projectRoot, runtimePaths, logger }) {
package/src/github.js CHANGED
@@ -290,6 +290,85 @@ export function createGitHubClient(config) {
290
290
  return payload.default_branch;
291
291
  },
292
292
 
293
+ async listPrsNeedingReview(repoUrl, botLogin) {
294
+ const repo = parseGitHubRepoUrl(repoUrl);
295
+ const query = `repo:${repo.owner}/${repo.name}+is:pr+is:open+review-requested:${botLogin}+-label:AI-reviewed`;
296
+ const url = new URL("https://api.github.com/search/issues");
297
+ url.searchParams.set("q", query);
298
+ url.searchParams.set("per_page", "10");
299
+
300
+ const payload = await requestGitHub(url, config, { method: "GET" }, repo);
301
+ return Array.isArray(payload.items)
302
+ ? payload.items.map((item) => ({
303
+ number: item.number,
304
+ title: item.title ?? ""
305
+ }))
306
+ : [];
307
+ },
308
+
309
+ async addLabelsToPullRequest(repoUrl, prNumber, labels) {
310
+ const repo = parseGitHubRepoUrl(repoUrl);
311
+ return requestGitHub(
312
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${prNumber}/labels`,
313
+ config,
314
+ {
315
+ method: "POST",
316
+ body: JSON.stringify({ labels })
317
+ },
318
+ repo
319
+ );
320
+ },
321
+
322
+ async createPullRequestReview(repoUrl, prNumber, body, suggestions, event) {
323
+ const repo = parseGitHubRepoUrl(repoUrl);
324
+ const comments = Array.isArray(suggestions)
325
+ ? suggestions.map((s) => {
326
+ let commentBody = String(s.body || "").trim();
327
+ if (s.committable && s.suggestion) {
328
+ commentBody = `${commentBody}\n\n\`\`\`suggestion\n${s.suggestion}\n\`\`\``.trim();
329
+ }
330
+ return {
331
+ path: String(s.file || ""),
332
+ line: Number(s.line) || 1,
333
+ side: String(s.side || "RIGHT").toUpperCase(),
334
+ body: commentBody
335
+ };
336
+ })
337
+ : [];
338
+
339
+ return requestGitHub(
340
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/pulls/${prNumber}/reviews`,
341
+ config,
342
+ {
343
+ method: "POST",
344
+ body: JSON.stringify({ body: String(body || "").trim(), event: event || "COMMENT", comments })
345
+ },
346
+ repo
347
+ );
348
+ },
349
+
350
+ async fetchPullRequestDiff(repoUrl, prNumber) {
351
+ const repo = parseGitHubRepoUrl(repoUrl);
352
+ const response = await fetch(
353
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/pulls/${prNumber}`,
354
+ {
355
+ headers: {
356
+ Accept: "application/vnd.github.v3.diff",
357
+ Authorization: `Bearer ${config.GITHUB_PAT}`,
358
+ "User-Agent": "claude-teammate"
359
+ }
360
+ }
361
+ );
362
+ if (!response.ok) {
363
+ const errorBody = await response.text();
364
+ throw new Error(buildGitHubRequestError(response.status, repo, errorBody, {
365
+ method: "GET",
366
+ path: `/repos/${repo.owner}/${repo.name}/pulls/${prNumber}`
367
+ }));
368
+ }
369
+ return response.text();
370
+ },
371
+
293
372
  async createPullRequest(repoUrl, pullRequest) {
294
373
  const repo = parseGitHubRepoUrl(repoUrl);
295
374
  const payload = await requestGitHub(
@@ -347,7 +426,8 @@ function mapGitHubPullRequestSummary(payload) {
347
426
  id: payload.user?.id ?? null
348
427
  },
349
428
  headRef: payload.head?.ref ?? "",
350
- baseRef: payload.base?.ref ?? ""
429
+ baseRef: payload.base?.ref ?? "",
430
+ headRepoCloneUrl: payload.head?.repo?.clone_url ?? null
351
431
  };
352
432
  }
353
433
 
package/src/repo.js CHANGED
@@ -54,6 +54,66 @@ export async function validateOrEnsureLocalRepo(repoUrl, reposDir, localPath = "
54
54
  return ensureLocalRepo(repoUrl, reposDir);
55
55
  }
56
56
 
57
+ export async function ensureReviewRepo(repoUrl, reviewReposDir) {
58
+ const repo = parseGitHubRepoUrl(repoUrl);
59
+ const checkoutPath = path.join(reviewReposDir, repo.owner, repo.name);
60
+
61
+ if (await isUsableGitCheckout(checkoutPath)) {
62
+ await execGit(checkoutPath, ["checkout", "--", "."]);
63
+ await execGit(checkoutPath, ["clean", "-fd"]);
64
+ return checkoutPath;
65
+ }
66
+
67
+ await mkdir(path.dirname(checkoutPath), { recursive: true });
68
+ await execFileAsync("git", ["clone", repo.cloneUrl, checkoutPath], {
69
+ maxBuffer: 10 * 1024 * 1024,
70
+ env: buildGitEnv(repo.cloneUrl)
71
+ });
72
+
73
+ return checkoutPath;
74
+ }
75
+
76
+ export async function checkoutPullRequestBranch(repoPath, branchName, headRepoCloneUrl = null) {
77
+ const originUrl = await execGitOutput(repoPath, ["remote", "get-url", "origin"]);
78
+ const normalizedOrigin = originUrl.trim().replace(/\.git$/u, "").toLowerCase();
79
+ const normalizedHead = headRepoCloneUrl
80
+ ? headRepoCloneUrl.trim().replace(/\.git$/u, "").toLowerCase()
81
+ : null;
82
+
83
+ const isFork = normalizedHead && normalizedHead !== normalizedOrigin;
84
+
85
+ if (isFork) {
86
+ const remoteName = "pr-head";
87
+ let remoteExists = false;
88
+ try {
89
+ await execGitOutput(repoPath, ["remote", "get-url", remoteName]);
90
+ remoteExists = true;
91
+ } catch {
92
+ remoteExists = false;
93
+ }
94
+
95
+ if (remoteExists) {
96
+ await execGit(repoPath, ["remote", "set-url", remoteName, headRepoCloneUrl]);
97
+ } else {
98
+ await execFileAsync("git", ["remote", "add", remoteName, headRepoCloneUrl], {
99
+ cwd: repoPath,
100
+ maxBuffer: 10 * 1024 * 1024,
101
+ env: buildGitEnv(headRepoCloneUrl)
102
+ });
103
+ }
104
+
105
+ await execFileAsync("git", ["fetch", remoteName], {
106
+ cwd: repoPath,
107
+ maxBuffer: 10 * 1024 * 1024,
108
+ env: buildGitEnv(headRepoCloneUrl)
109
+ });
110
+ await execGit(repoPath, ["checkout", "-B", branchName, `${remoteName}/${branchName}`]);
111
+ } else {
112
+ await execGit(repoPath, ["fetch", "origin"]);
113
+ await execGit(repoPath, ["checkout", "-B", branchName, `origin/${branchName}`]);
114
+ }
115
+ }
116
+
57
117
  export async function ensureBranchFromDefault(repoPath, branchName, baseBranch) {
58
118
  try {
59
119
  await execGit(repoPath, ["fetch", "origin", baseBranch, "--prune"]);
package/src/runtime.js CHANGED
@@ -11,7 +11,8 @@ export function getRuntimePaths(projectRoot) {
11
11
  pidFile: path.join(runtimeDir, "worker.pid"),
12
12
  stateFile: path.join(runtimeDir, "state.json"),
13
13
  logFile: path.join(runtimeDir, "worker.log"),
14
- reposDir: path.join(runtimeDir, "repos")
14
+ reposDir: path.join(runtimeDir, "repos"),
15
+ reviewReposDir: path.join(runtimeDir, "review-repos")
15
16
  };
16
17
  }
17
18