edsger 0.13.1 → 0.13.3

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.
@@ -20,6 +20,7 @@ export interface PRReviewComment {
20
20
  }
21
21
  export interface PRReview {
22
22
  id: number;
23
+ node_id: string;
23
24
  user: {
24
25
  login: string;
25
26
  };
@@ -30,8 +30,19 @@ export async function fetchPRReviews(octokit, owner, repo, prNumber, verbose) {
30
30
  repo,
31
31
  pull_number: prNumber,
32
32
  });
33
- // Filter for reviews that request changes
34
- const requestChangesReviews = reviews.filter((review) => review.state === 'CHANGES_REQUESTED');
33
+ // Filter for reviews that request changes and map to our interface
34
+ const requestChangesReviews = reviews
35
+ .filter((review) => review.state === 'CHANGES_REQUESTED')
36
+ .map((review) => ({
37
+ id: review.id,
38
+ node_id: review.node_id, // REST API also returns node_id
39
+ user: {
40
+ login: review.user?.login || 'unknown',
41
+ },
42
+ body: review.body ?? null,
43
+ state: review.state,
44
+ submitted_at: review.submitted_at ?? null,
45
+ }));
35
46
  if (verbose) {
36
47
  console.log(`✅ Found ${requestChangesReviews.length} reviews requesting changes`);
37
48
  }
@@ -117,6 +128,7 @@ export async function fetchPRDataGraphQL(octokit, owner, repo, prNumber, verbose
117
128
  // Transform GraphQL reviews to our interface
118
129
  const reviews = (pullRequest.reviews.nodes || []).map((review) => ({
119
130
  id: review.databaseId,
131
+ node_id: review.id, // GraphQL Node ID for mutations
120
132
  user: {
121
133
  login: review.author?.login || 'unknown',
122
134
  },
@@ -8,7 +8,9 @@ import { execSync } from 'child_process';
8
8
  import { fetchCodeRefineContext, } from './context.js';
9
9
  import { getFeedbacksForPhase, formatFeedbacksForContext, } from '../../services/feedbacks.js';
10
10
  import { createSystemPrompt, createCodeRefinePrompt } from './prompts.js';
11
- import { preparePhaseGitEnvironment, hasUncommittedChanges, getUncommittedFiles, } from '../../utils/git-branch-manager.js';
11
+ import { preparePhaseGitEnvironment, hasUncommittedChanges, getUncommittedFiles, syncFeatBranchWithMain, } from '../../utils/git-branch-manager.js';
12
+ import { getFeature } from '../../api/features/get-feature.js';
13
+ import { parsePullRequestUrl } from './context.js';
12
14
  function userMessage(content) {
13
15
  return {
14
16
  type: 'user',
@@ -62,6 +64,23 @@ export const refineCodeFromPRFeedback = async (options, config) => {
62
64
  if (verbose) {
63
65
  logInfo(`Starting code refine for feature ID: ${featureId}`);
64
66
  }
67
+ // Sync feat branch with main before preparing git environment
68
+ // This prevents extra commits from appearing in PR when dev branch is rebased
69
+ try {
70
+ const feature = await getFeature(featureId, verbose);
71
+ if (feature.pull_request_url) {
72
+ const prInfo = parsePullRequestUrl(feature.pull_request_url);
73
+ if (prInfo) {
74
+ await syncFeatBranchWithMain(featureId, githubToken, prInfo.owner, prInfo.repo, 'main', verbose);
75
+ }
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (verbose) {
80
+ logInfo(`⚠️ Could not sync feat branch: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ // Continue even if sync fails - it's not critical
83
+ }
65
84
  // Prepare git environment: switch to feature branch and rebase with main
66
85
  const cleanupGit = preparePhaseGitEnvironment(featureId, 'main', verbose);
67
86
  try {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Octokit } from '@octokit/rest';
6
6
  import { ReviewThread, PRFileChange } from './types.js';
7
+ import { PRReview } from '../code-refine/context.js';
7
8
  /**
8
9
  * Fetch complete file content from a specific ref (branch/commit)
9
10
  */
@@ -21,3 +22,9 @@ export declare function fetchUnresolvedReviewThreads(octokit: Octokit, owner: st
21
22
  * Mark review threads as resolved using GraphQL API
22
23
  */
23
24
  export declare function resolveReviewThreads(octokit: Octokit, threads: ReviewThread[], verbose?: boolean): Promise<number>;
25
+ /**
26
+ * Dismiss pull request reviews using GraphQL API
27
+ * This is used to automatically dismiss "CHANGES_REQUESTED" reviews
28
+ * after all feedback has been addressed by code refine
29
+ */
30
+ export declare function dismissReviews(octokit: Octokit, reviews: PRReview[], resolvedCommentsCount: number, verbose?: boolean): Promise<number>;
@@ -188,3 +188,49 @@ export async function resolveReviewThreads(octokit, threads, verbose) {
188
188
  }
189
189
  return markedCount;
190
190
  }
191
+ /**
192
+ * Dismiss pull request reviews using GraphQL API
193
+ * This is used to automatically dismiss "CHANGES_REQUESTED" reviews
194
+ * after all feedback has been addressed by code refine
195
+ */
196
+ export async function dismissReviews(octokit, reviews, resolvedCommentsCount, verbose) {
197
+ let dismissedCount = 0;
198
+ for (const review of reviews) {
199
+ try {
200
+ if (verbose) {
201
+ logInfo(`🔄 Dismissing review ${review.id} by @${review.user.login}...`);
202
+ }
203
+ // Build dismiss message explaining what was resolved
204
+ const message = `All feedback has been addressed by automated code refine.\n\n` +
205
+ `✅ Resolved ${resolvedCommentsCount} review comment(s).\n\n` +
206
+ `Please re-review if you disagree with any changes.`;
207
+ const mutation = `
208
+ mutation($pullRequestReviewId: ID!, $message: String!) {
209
+ dismissPullRequestReview(input: {
210
+ pullRequestReviewId: $pullRequestReviewId
211
+ message: $message
212
+ }) {
213
+ pullRequestReview {
214
+ id
215
+ state
216
+ }
217
+ }
218
+ }
219
+ `;
220
+ await octokit.graphql(mutation, {
221
+ pullRequestReviewId: review.node_id,
222
+ message,
223
+ });
224
+ dismissedCount++;
225
+ if (verbose) {
226
+ logInfo(`✅ Dismissed review ${review.id} by @${review.user.login}`);
227
+ }
228
+ }
229
+ catch (error) {
230
+ if (verbose) {
231
+ logError(`Failed to dismiss review ${review.id} by @${review.user.login}: ${error}`);
232
+ }
233
+ }
234
+ }
235
+ return dismissedCount;
236
+ }
@@ -7,7 +7,7 @@ import { Octokit } from '@octokit/rest';
7
7
  import { logInfo, logError } from '../../utils/logger.js';
8
8
  import { parsePullRequestUrl, fetchPRReviews } from '../code-refine/context.js';
9
9
  import { getFeature } from '../../api/features/get-feature.js';
10
- import { fetchPRFileChanges, fetchUnresolvedReviewThreads, resolveReviewThreads, } from './github.js';
10
+ import { fetchPRFileChanges, fetchUnresolvedReviewThreads, resolveReviewThreads, dismissReviews, } from './github.js';
11
11
  import { analyzeAllThreads } from './llm-analyzer.js';
12
12
  // Re-export types for backward compatibility
13
13
  export * from './types.js';
@@ -65,31 +65,52 @@ export async function verifyAndResolveComments(options) {
65
65
  const addressedThreads = threadAnalysisResults.filter((result) => result.analysis.isAddressed);
66
66
  const trulyUnresolvedThreads = threadAnalysisResults.filter((result) => !result.analysis.isAddressed);
67
67
  // Auto-resolve threads that LLM determined are addressed
68
+ let totalResolvedComments = 0;
68
69
  if (addressedThreads.length > 0) {
69
70
  if (verbose) {
70
71
  logInfo(`✅ Auto-resolving ${addressedThreads.length} threads that have been addressed...`);
71
72
  }
72
73
  const resolvedCount = await resolveReviewThreads(octokit, addressedThreads.map((r) => r.thread), verbose);
74
+ totalResolvedComments = resolvedCount;
73
75
  if (verbose) {
74
76
  logInfo(`✅ Successfully resolved ${resolvedCount} threads`);
75
77
  }
76
78
  }
77
- // Check reviews - they need to be dismissed or re-reviewed
78
- const unresolvedReviews = reviews.filter((review) => review.state === 'CHANGES_REQUESTED');
79
+ // Check reviews - they need to be dismissed if all comments are addressed
80
+ const changesRequestedReviews = reviews.filter((review) => review.state === 'CHANGES_REQUESTED');
79
81
  if (verbose) {
80
82
  logInfo(`📊 Review Threads: ${trulyUnresolvedThreads.length} still unresolved (after LLM analysis)`);
81
83
  if (reviews.length > 0) {
82
- logInfo(`📊 Reviews: ${reviews.length - unresolvedReviews.length} addressed, ${unresolvedReviews.length} still requesting changes`);
84
+ logInfo(`📊 Reviews: ${changesRequestedReviews.length} requesting changes`);
83
85
  }
84
86
  }
85
- // If all threads are truly resolved (after LLM analysis) AND no reviews requesting changes, success
86
- if (trulyUnresolvedThreads.length === 0 && unresolvedReviews.length === 0) {
87
+ // If all threads are truly resolved, dismiss the "CHANGES_REQUESTED" reviews
88
+ let dismissedReviewsCount = 0;
89
+ if (trulyUnresolvedThreads.length === 0 &&
90
+ changesRequestedReviews.length > 0) {
91
+ if (verbose) {
92
+ logInfo(`🔄 All comments addressed. Dismissing ${changesRequestedReviews.length} review(s) requesting changes...`);
93
+ }
94
+ dismissedReviewsCount = await dismissReviews(octokit, changesRequestedReviews, totalResolvedComments, verbose);
95
+ if (verbose) {
96
+ logInfo(`✅ Successfully dismissed ${dismissedReviewsCount} review(s)`);
97
+ }
98
+ }
99
+ // If all threads are truly resolved (after LLM analysis) AND reviews are dismissed, success
100
+ const allReviewsDismissed = changesRequestedReviews.length === 0 ||
101
+ dismissedReviewsCount === changesRequestedReviews.length;
102
+ if (trulyUnresolvedThreads.length === 0 && allReviewsDismissed) {
87
103
  if (verbose) {
88
104
  logInfo('✅ All comments have been addressed! All review threads are resolved.');
89
105
  }
90
- const successMessage = reviews.length > 0
91
- ? 'All reviews and review comments have been addressed and resolved'
92
- : 'All review comments have been addressed and resolved';
106
+ let successMessage = 'All review comments have been addressed and resolved';
107
+ if (dismissedReviewsCount > 0) {
108
+ successMessage = `All reviews and review comments have been addressed. ${dismissedReviewsCount} review(s) dismissed.`;
109
+ }
110
+ else if (reviews.length > 0) {
111
+ successMessage =
112
+ 'All reviews and review comments have been addressed and resolved';
113
+ }
93
114
  return {
94
115
  status: 'success',
95
116
  message: successMessage,
@@ -101,15 +122,18 @@ export async function verifyAndResolveComments(options) {
101
122
  resolvedComments: addressedThreads.length,
102
123
  unresolvedComments: 0,
103
124
  commentsMarkedResolved: addressedThreads.length,
125
+ dismissedReviews: dismissedReviewsCount,
104
126
  },
105
127
  };
106
128
  }
107
129
  else {
108
130
  // Verification failed - build detailed info with specific failure reasons from LLM analysis
131
+ // Calculate remaining reviews that weren't dismissed
132
+ const remainingReviewsCount = changesRequestedReviews.length - dismissedReviewsCount;
109
133
  if (verbose) {
110
- if (unresolvedReviews.length > 0) {
111
- logInfo(`⚠️ ${unresolvedReviews.length} reviews still requesting changes`);
112
- unresolvedReviews.forEach((review) => {
134
+ if (remainingReviewsCount > 0) {
135
+ logInfo(`⚠️ ${remainingReviewsCount} reviews still requesting changes (failed to dismiss)`);
136
+ changesRequestedReviews.forEach((review) => {
113
137
  logInfo(` - Review ${review.id} by @${review.user.login}`);
114
138
  if (review.body) {
115
139
  logInfo(` ${review.body.substring(0, 100)}...`);
@@ -138,10 +162,10 @@ export async function verifyAndResolveComments(options) {
138
162
  suggestions.push(`${index + 1}. [${firstComment.path}:${firstComment.line || '?'}] by @${firstComment.author.login}: ${result.analysis.reason}`);
139
163
  }
140
164
  });
141
- // Add review-specific suggestions if any
142
- if (unresolvedReviews.length > 0) {
143
- suggestions.push(`\n${unresolvedReviews.length} review(s) requesting changes need to be addressed:`);
144
- unresolvedReviews.forEach((review) => {
165
+ // Add review-specific suggestions if any reviews failed to dismiss
166
+ if (remainingReviewsCount > 0) {
167
+ suggestions.push(`\n${remainingReviewsCount} review(s) requesting changes could not be automatically dismissed:`);
168
+ changesRequestedReviews.forEach((review) => {
145
169
  suggestions.push(` - @${review.user.login}: ${review.body ? review.body.substring(0, 150) : 'No details provided'}${review.body && review.body.length > 150 ? '...' : ''}`);
146
170
  });
147
171
  }
@@ -158,7 +182,7 @@ export async function verifyAndResolveComments(options) {
158
182
  url: firstComment.url,
159
183
  };
160
184
  });
161
- const unresolvedReviewDetails = unresolvedReviews.map((review) => ({
185
+ const unresolvedReviewDetails = changesRequestedReviews.map((review) => ({
162
186
  reviewId: review.id,
163
187
  author: review.user.login,
164
188
  state: review.state,
@@ -166,11 +190,11 @@ export async function verifyAndResolveComments(options) {
166
190
  submittedAt: review.submitted_at,
167
191
  }));
168
192
  let errorMessage = '';
169
- if (unresolvedReviews.length > 0 && trulyUnresolvedThreads.length > 0) {
170
- errorMessage = `${unresolvedReviews.length} reviews and ${trulyUnresolvedThreads.length} review threads still need to be addressed (based on LLM analysis)`;
193
+ if (remainingReviewsCount > 0 && trulyUnresolvedThreads.length > 0) {
194
+ errorMessage = `${remainingReviewsCount} reviews and ${trulyUnresolvedThreads.length} review threads still need to be addressed (based on LLM analysis)`;
171
195
  }
172
- else if (unresolvedReviews.length > 0) {
173
- errorMessage = `${unresolvedReviews.length} reviews still requesting changes`;
196
+ else if (remainingReviewsCount > 0) {
197
+ errorMessage = `${remainingReviewsCount} reviews could not be dismissed`;
174
198
  }
175
199
  else {
176
200
  errorMessage = `${trulyUnresolvedThreads.length} review comments still need to be addressed (based on LLM analysis)`;
@@ -181,11 +205,12 @@ export async function verifyAndResolveComments(options) {
181
205
  data: {
182
206
  featureId,
183
207
  totalReviews: reviews.length,
184
- unresolvedReviews: unresolvedReviews.length,
208
+ unresolvedReviews: remainingReviewsCount,
185
209
  totalComments: unresolvedThreads.length, // Original count before LLM analysis
186
210
  resolvedComments: addressedThreads.length, // LLM determined these are addressed
187
211
  unresolvedComments: trulyUnresolvedThreads.length, // LLM determined these still need work
188
212
  commentsMarkedResolved: addressedThreads.length,
213
+ dismissedReviews: dismissedReviewsCount,
189
214
  suggestions,
190
215
  unresolvedReviewDetails,
191
216
  unresolvedCommentDetails,
@@ -43,6 +43,7 @@ export interface CodeRefineVerificationData {
43
43
  resolvedComments: number;
44
44
  unresolvedComments: number;
45
45
  commentsMarkedResolved?: number;
46
+ dismissedReviews?: number;
46
47
  suggestions?: string[];
47
48
  unresolvedReviewDetails?: Array<{
48
49
  reviewId: number;
@@ -71,3 +71,16 @@ export declare function returnToMainBranch(baseBranch?: string, verbose?: boolea
71
71
  * @returns Cleanup function that will return to main branch
72
72
  */
73
73
  export declare function preparePhaseGitEnvironment(featureId: string, baseBranch?: string, verbose?: boolean): () => void;
74
+ /**
75
+ * Sync feat branch with main using GitHub API
76
+ * This ensures the feat branch (PR base) is up to date with main,
77
+ * preventing extra commits from appearing in PRs when dev branch is rebased.
78
+ *
79
+ * @param featureId - The feature ID (will be used to construct branch name "feat/{featureId}")
80
+ * @param githubToken - GitHub personal access token or app token
81
+ * @param owner - Repository owner
82
+ * @param repo - Repository name
83
+ * @param baseBranch - The base branch to sync from (default: "main")
84
+ * @param verbose - Whether to log verbose output
85
+ */
86
+ export declare function syncFeatBranchWithMain(featureId: string, githubToken: string, owner: string, repo: string, baseBranch?: string, verbose?: boolean): Promise<boolean>;
@@ -3,6 +3,7 @@
3
3
  * Shared utilities for consistent git branch management across all phases
4
4
  */
5
5
  import { execSync } from 'child_process';
6
+ import { Octokit } from '@octokit/rest';
6
7
  import { logInfo, logError } from './logger.js';
7
8
  /**
8
9
  * Get current Git branch name
@@ -358,3 +359,100 @@ export function preparePhaseGitEnvironment(featureId, baseBranch = 'main', verbo
358
359
  // Return cleanup function
359
360
  return cleanup;
360
361
  }
362
+ /**
363
+ * Sync feat branch with main using GitHub API
364
+ * This ensures the feat branch (PR base) is up to date with main,
365
+ * preventing extra commits from appearing in PRs when dev branch is rebased.
366
+ *
367
+ * @param featureId - The feature ID (will be used to construct branch name "feat/{featureId}")
368
+ * @param githubToken - GitHub personal access token or app token
369
+ * @param owner - Repository owner
370
+ * @param repo - Repository name
371
+ * @param baseBranch - The base branch to sync from (default: "main")
372
+ * @param verbose - Whether to log verbose output
373
+ */
374
+ export async function syncFeatBranchWithMain(featureId, githubToken, owner, repo, baseBranch = 'main', verbose) {
375
+ const featBranch = `feat/${featureId}`;
376
+ try {
377
+ const octokit = new Octokit({ auth: githubToken });
378
+ // Check if feat branch exists
379
+ if (verbose) {
380
+ logInfo(`🔍 Checking if ${featBranch} branch exists...`);
381
+ }
382
+ try {
383
+ await octokit.repos.getBranch({
384
+ owner,
385
+ repo,
386
+ branch: featBranch,
387
+ });
388
+ }
389
+ catch (error) {
390
+ if (error.status === 404) {
391
+ if (verbose) {
392
+ logInfo(`ℹ️ ${featBranch} branch does not exist, skipping sync`);
393
+ }
394
+ return true; // Not an error, just no feat branch yet
395
+ }
396
+ throw error;
397
+ }
398
+ // Get the latest SHA of the base branch
399
+ const { data: baseBranchData } = await octokit.repos.getBranch({
400
+ owner,
401
+ repo,
402
+ branch: baseBranch,
403
+ });
404
+ const mainSha = baseBranchData.commit.sha;
405
+ // Get the current SHA of the feat branch
406
+ const { data: featBranchData } = await octokit.repos.getBranch({
407
+ owner,
408
+ repo,
409
+ branch: featBranch,
410
+ });
411
+ const featSha = featBranchData.commit.sha;
412
+ // Check if feat branch is already up to date (same as main or ahead)
413
+ // We need to merge main into feat to keep it updated
414
+ if (verbose) {
415
+ logInfo(`📥 Syncing ${featBranch} with ${baseBranch}...`);
416
+ logInfo(` ${baseBranch} SHA: ${mainSha.substring(0, 7)}`);
417
+ logInfo(` ${featBranch} SHA: ${featSha.substring(0, 7)}`);
418
+ }
419
+ // Use GitHub merge API to merge main into feat branch
420
+ try {
421
+ await octokit.repos.merge({
422
+ owner,
423
+ repo,
424
+ base: featBranch,
425
+ head: baseBranch,
426
+ commit_message: `chore: sync ${featBranch} with ${baseBranch}`,
427
+ });
428
+ if (verbose) {
429
+ logInfo(`✅ Successfully synced ${featBranch} with ${baseBranch}`);
430
+ }
431
+ }
432
+ catch (mergeError) {
433
+ // 409 means nothing to merge (already up to date)
434
+ if (mergeError.status === 409) {
435
+ if (verbose) {
436
+ logInfo(`ℹ️ ${featBranch} is already up to date with ${baseBranch}`);
437
+ }
438
+ return true;
439
+ }
440
+ // 404 means branch doesn't exist (shouldn't happen since we checked above)
441
+ if (mergeError.status === 404) {
442
+ if (verbose) {
443
+ logInfo(`ℹ️ ${featBranch} branch not found, skipping sync`);
444
+ }
445
+ return true;
446
+ }
447
+ throw mergeError;
448
+ }
449
+ return true;
450
+ }
451
+ catch (error) {
452
+ if (verbose) {
453
+ logError(`⚠️ Failed to sync ${featBranch} with ${baseBranch}: ${error instanceof Error ? error.message : String(error)}`);
454
+ }
455
+ // Don't fail the whole process if sync fails, just log warning
456
+ return false;
457
+ }
458
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -53,4 +53,4 @@
53
53
  "optional": false
54
54
  }
55
55
  }
56
- }
56
+ }