edsger 0.40.1 → 0.41.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 (42) hide show
  1. package/dist/commands/pr-resolve/index.d.ts +10 -0
  2. package/dist/commands/pr-resolve/index.js +44 -0
  3. package/dist/commands/pr-review/index.d.ts +10 -0
  4. package/dist/commands/pr-review/index.js +43 -0
  5. package/dist/index.js +38 -0
  6. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
  7. package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
  8. package/dist/phases/code-review/diff-utils.d.ts +36 -0
  9. package/dist/phases/code-review/diff-utils.js +100 -0
  10. package/dist/phases/code-review/index.js +17 -153
  11. package/dist/phases/pr-execution/index.js +4 -2
  12. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
  13. package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
  14. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
  15. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
  16. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
  17. package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
  18. package/dist/phases/pr-resolve/github-reply.d.ts +13 -0
  19. package/dist/phases/pr-resolve/github-reply.js +59 -0
  20. package/dist/phases/pr-resolve/index.d.ts +27 -0
  21. package/dist/phases/pr-resolve/index.js +213 -0
  22. package/dist/phases/pr-resolve/prompts.d.ts +17 -0
  23. package/dist/phases/pr-resolve/prompts.js +106 -0
  24. package/dist/phases/pr-resolve/workspace.d.ts +29 -0
  25. package/dist/phases/pr-resolve/workspace.js +145 -0
  26. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
  27. package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
  28. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
  29. package/dist/phases/pr-review/__tests__/review-comments.test.js +108 -0
  30. package/dist/phases/pr-review/index.d.ts +25 -0
  31. package/dist/phases/pr-review/index.js +213 -0
  32. package/dist/phases/pr-review/prompts.d.ts +5 -0
  33. package/dist/phases/pr-review/prompts.js +87 -0
  34. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
  35. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
  36. package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
  37. package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
  38. package/dist/phases/pr-shared/agent-utils.d.ts +39 -0
  39. package/dist/phases/pr-shared/agent-utils.js +69 -0
  40. package/dist/phases/pr-shared/context.d.ts +24 -0
  41. package/dist/phases/pr-shared/context.js +78 -0
  42. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { createResolveSystemPrompt, createResolveUserPrompt, } from '../prompts.js';
4
+ function makeThread(id, overrides) {
5
+ const nodes = [
6
+ {
7
+ id: `${id}-comment-1`,
8
+ author: { login: overrides?.author || 'reviewer' },
9
+ body: overrides?.body || 'Please fix this',
10
+ path: overrides?.path || 'src/index.ts',
11
+ line: overrides?.line ?? 10,
12
+ url: `https://github.com/owner/repo/pull/1#discussion_${id}`,
13
+ },
14
+ ];
15
+ if (overrides?.followUps) {
16
+ for (const fu of overrides.followUps) {
17
+ nodes.push({
18
+ id: `${id}-followup`,
19
+ author: { login: fu.author },
20
+ body: fu.body,
21
+ path: overrides?.path || 'src/index.ts',
22
+ line: overrides?.line ?? 10,
23
+ url: `https://github.com/owner/repo/pull/1#discussion_${id}_fu`,
24
+ });
25
+ }
26
+ }
27
+ return {
28
+ id,
29
+ isResolved: false,
30
+ isOutdated: false,
31
+ comments: {
32
+ totalCount: nodes.length,
33
+ nodes,
34
+ },
35
+ };
36
+ }
37
+ describe('createResolveSystemPrompt', () => {
38
+ it('includes decision criteria', () => {
39
+ const prompt = createResolveSystemPrompt();
40
+ assert.ok(prompt.includes('Make the change when'));
41
+ assert.ok(prompt.includes('Skip the change when'));
42
+ });
43
+ it('specifies comment_id format', () => {
44
+ const prompt = createResolveSystemPrompt();
45
+ assert.ok(prompt.includes('comment_id'));
46
+ assert.ok(prompt.includes('comment_1'));
47
+ });
48
+ it('includes result format', () => {
49
+ const prompt = createResolveSystemPrompt();
50
+ assert.ok(prompt.includes('resolve_result'));
51
+ assert.ok(prompt.includes('"action"'));
52
+ assert.ok(prompt.includes('"reply"'));
53
+ });
54
+ });
55
+ describe('createResolveUserPrompt', () => {
56
+ it('uses sequential comment IDs not thread IDs', () => {
57
+ const threads = [makeThread('PRRT_kwDOxx_1'), makeThread('PRRT_kwDOxx_2')];
58
+ const { prompt, commentIdToThreadId } = createResolveUserPrompt(threads);
59
+ // Should use comment_1, comment_2 in the prompt
60
+ assert.ok(prompt.includes('## comment_1'));
61
+ assert.ok(prompt.includes('## comment_2'));
62
+ // Should NOT include opaque GraphQL IDs
63
+ assert.ok(!prompt.includes('PRRT_kwDOxx_1'));
64
+ assert.ok(!prompt.includes('PRRT_kwDOxx_2'));
65
+ // Mapping should be correct
66
+ assert.strictEqual(commentIdToThreadId.get('comment_1'), 'PRRT_kwDOxx_1');
67
+ assert.strictEqual(commentIdToThreadId.get('comment_2'), 'PRRT_kwDOxx_2');
68
+ assert.strictEqual(commentIdToThreadId.size, 2);
69
+ });
70
+ it('includes file path and line number', () => {
71
+ const threads = [makeThread('t1', { path: 'src/auth.ts', line: 42 })];
72
+ const { prompt } = createResolveUserPrompt(threads);
73
+ assert.ok(prompt.includes('src/auth.ts'));
74
+ assert.ok(prompt.includes('42'));
75
+ });
76
+ it('includes comment body', () => {
77
+ const threads = [
78
+ makeThread('t1', { body: 'This should use a const instead of let' }),
79
+ ];
80
+ const { prompt } = createResolveUserPrompt(threads);
81
+ assert.ok(prompt.includes('This should use a const instead of let'));
82
+ });
83
+ it('includes follow-up comments', () => {
84
+ const threads = [
85
+ makeThread('t1', {
86
+ body: 'Main comment',
87
+ followUps: [{ author: 'dev', body: 'I disagree because...' }],
88
+ }),
89
+ ];
90
+ const { prompt } = createResolveUserPrompt(threads);
91
+ assert.ok(prompt.includes('Main comment'));
92
+ assert.ok(prompt.includes('I disagree because...'));
93
+ assert.ok(prompt.includes('@dev'));
94
+ });
95
+ it('includes instruction to use exact comment IDs', () => {
96
+ const threads = [makeThread('t1'), makeThread('t2'), makeThread('t3')];
97
+ const { prompt } = createResolveUserPrompt(threads);
98
+ assert.ok(prompt.includes('comment_1, comment_2, comment_3'));
99
+ });
100
+ it('handles threads with no comments gracefully', () => {
101
+ const emptyThread = {
102
+ id: 'empty',
103
+ isResolved: false,
104
+ isOutdated: false,
105
+ comments: { totalCount: 0, nodes: [] },
106
+ };
107
+ const { commentIdToThreadId } = createResolveUserPrompt([emptyThread]);
108
+ // Empty thread should be skipped (no comment nodes to index)
109
+ assert.strictEqual(commentIdToThreadId.size, 0);
110
+ });
111
+ it('returns correct count in header', () => {
112
+ const threads = [makeThread('t1'), makeThread('t2')];
113
+ const { prompt } = createResolveUserPrompt(threads);
114
+ assert.ok(prompt.includes('2 unresolved review comment(s)'));
115
+ });
116
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Integration-level tests for the resolve comment→thread mapping logic.
3
+ * Tests the flow: agent JSON result → comment_id to thread_id mapping → reply decisions.
4
+ */
5
+ import assert from 'node:assert';
6
+ import { describe, it } from 'node:test';
7
+ import { createResolveUserPrompt } from '../prompts.js';
8
+ // Simulate the mapping logic from resolveStandalonePR
9
+ function processResolveResult(comments, commentIdToThreadId) {
10
+ const addressed = [];
11
+ const skipped = [];
12
+ const errors = [];
13
+ for (const comment of comments) {
14
+ const threadId = commentIdToThreadId.get(comment.comment_id);
15
+ if (!threadId) {
16
+ errors.push(`Unknown comment_id "${comment.comment_id}"`);
17
+ continue;
18
+ }
19
+ if (comment.action === 'changed') {
20
+ addressed.push({ threadId, reply: comment.reply });
21
+ }
22
+ else {
23
+ skipped.push({ threadId, reply: comment.reply });
24
+ }
25
+ }
26
+ return { addressed, skipped, errors };
27
+ }
28
+ function makeThread(id, body) {
29
+ return {
30
+ id,
31
+ isResolved: false,
32
+ isOutdated: false,
33
+ comments: {
34
+ totalCount: 1,
35
+ nodes: [
36
+ {
37
+ id: `${id}-c1`,
38
+ author: { login: 'reviewer' },
39
+ body,
40
+ path: 'src/index.ts',
41
+ line: 10,
42
+ url: '',
43
+ },
44
+ ],
45
+ },
46
+ };
47
+ }
48
+ describe('resolve comment→thread mapping (integration)', () => {
49
+ it('correctly maps comment_ids to thread IDs', () => {
50
+ const threads = [
51
+ makeThread('PRRT_aaa', 'Use const'),
52
+ makeThread('PRRT_bbb', 'Add error handling'),
53
+ makeThread('PRRT_ccc', 'Remove unused import'),
54
+ ];
55
+ const { commentIdToThreadId } = createResolveUserPrompt(threads);
56
+ const agentResult = [
57
+ {
58
+ comment_id: 'comment_1',
59
+ action: 'changed',
60
+ reply: 'Done, switched to const',
61
+ },
62
+ { comment_id: 'comment_2', action: 'changed', reply: 'Added try/catch' },
63
+ {
64
+ comment_id: 'comment_3',
65
+ action: 'skipped',
66
+ reply: 'Import is used in tests',
67
+ },
68
+ ];
69
+ const result = processResolveResult(agentResult, commentIdToThreadId);
70
+ assert.strictEqual(result.addressed.length, 2);
71
+ assert.strictEqual(result.skipped.length, 1);
72
+ assert.strictEqual(result.errors.length, 0);
73
+ // Verify thread ID mapping
74
+ assert.strictEqual(result.addressed[0].threadId, 'PRRT_aaa');
75
+ assert.strictEqual(result.addressed[1].threadId, 'PRRT_bbb');
76
+ assert.strictEqual(result.skipped[0].threadId, 'PRRT_ccc');
77
+ });
78
+ it('reports errors for unknown comment_ids', () => {
79
+ const threads = [makeThread('PRRT_aaa', 'Fix bug')];
80
+ const { commentIdToThreadId } = createResolveUserPrompt(threads);
81
+ const agentResult = [
82
+ { comment_id: 'comment_1', action: 'changed', reply: 'Fixed' },
83
+ { comment_id: 'comment_99', action: 'changed', reply: 'Unknown' },
84
+ ];
85
+ const result = processResolveResult(agentResult, commentIdToThreadId);
86
+ assert.strictEqual(result.addressed.length, 1);
87
+ assert.strictEqual(result.errors.length, 1);
88
+ assert.ok(result.errors[0].includes('comment_99'));
89
+ });
90
+ it('handles agent returning partial results', () => {
91
+ const threads = [
92
+ makeThread('PRRT_aaa', 'Fix A'),
93
+ makeThread('PRRT_bbb', 'Fix B'),
94
+ makeThread('PRRT_ccc', 'Fix C'),
95
+ ];
96
+ const { commentIdToThreadId } = createResolveUserPrompt(threads);
97
+ // Agent only addressed 2 of 3
98
+ const agentResult = [
99
+ { comment_id: 'comment_1', action: 'changed', reply: 'Done' },
100
+ { comment_id: 'comment_3', action: 'skipped', reply: 'No change needed' },
101
+ ];
102
+ const result = processResolveResult(agentResult, commentIdToThreadId);
103
+ assert.strictEqual(result.addressed.length, 1);
104
+ assert.strictEqual(result.skipped.length, 1);
105
+ assert.strictEqual(result.errors.length, 0);
106
+ // comment_2 was not mentioned - no error, just not processed
107
+ });
108
+ it('handles empty agent result', () => {
109
+ const threads = [makeThread('PRRT_aaa', 'Fix')];
110
+ const { commentIdToThreadId } = createResolveUserPrompt(threads);
111
+ const result = processResolveResult([], commentIdToThreadId);
112
+ assert.strictEqual(result.addressed.length, 0);
113
+ assert.strictEqual(result.skipped.length, 0);
114
+ assert.strictEqual(result.errors.length, 0);
115
+ });
116
+ it('preserves reply text in all cases', () => {
117
+ const threads = [
118
+ makeThread('PRRT_aaa', 'Fix this'),
119
+ makeThread('PRRT_bbb', 'Change that'),
120
+ ];
121
+ const { commentIdToThreadId } = createResolveUserPrompt(threads);
122
+ const agentResult = [
123
+ {
124
+ comment_id: 'comment_1',
125
+ action: 'changed',
126
+ reply: 'Switched to const as suggested. Good catch.',
127
+ },
128
+ {
129
+ comment_id: 'comment_2',
130
+ action: 'skipped',
131
+ reply: 'The current approach is better because it allows lazy initialization which is needed for the singleton pattern.',
132
+ },
133
+ ];
134
+ const result = processResolveResult(agentResult, commentIdToThreadId);
135
+ assert.ok(result.addressed[0].reply.includes('Good catch'));
136
+ assert.ok(result.skipped[0].reply.includes('singleton pattern'));
137
+ });
138
+ });
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { afterEach, beforeEach, describe, it } from 'node:test';
7
+ import { buildCredentialArgs, hasNewCommits, hasUncommittedChanges, } from '../workspace.js';
8
+ // Helper: create a temp git repo with an initial commit
9
+ function createTempRepo() {
10
+ const dir = mkdtempSync(join(tmpdir(), 'edsger-test-'));
11
+ execSync('git init', { cwd: dir, stdio: 'pipe' });
12
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
13
+ execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
14
+ writeFileSync(join(dir, 'README.md'), '# Test');
15
+ execSync('git add .', { cwd: dir, stdio: 'pipe' });
16
+ execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
17
+ return dir;
18
+ }
19
+ describe('buildCredentialArgs', () => {
20
+ it('returns 4 args with credential helper config', () => {
21
+ const args = buildCredentialArgs('my-token');
22
+ assert.strictEqual(args.length, 4);
23
+ assert.strictEqual(args[0], '-c');
24
+ assert.strictEqual(args[1], 'credential.helper=');
25
+ assert.strictEqual(args[2], '-c');
26
+ assert.ok(args[3].includes('my-token'));
27
+ assert.ok(args[3].includes('x-access-token'));
28
+ });
29
+ it('escapes token in credential helper', () => {
30
+ const args = buildCredentialArgs('token-with-special-chars!@#');
31
+ assert.ok(args[3].includes('token-with-special-chars!@#'));
32
+ });
33
+ });
34
+ describe('hasUncommittedChanges', () => {
35
+ let repoPath;
36
+ beforeEach(() => {
37
+ repoPath = createTempRepo();
38
+ });
39
+ afterEach(() => {
40
+ rmSync(repoPath, { recursive: true, force: true });
41
+ });
42
+ it('returns false for clean repo', () => {
43
+ assert.strictEqual(hasUncommittedChanges(repoPath), false);
44
+ });
45
+ it('returns true after modifying a file', () => {
46
+ writeFileSync(join(repoPath, 'README.md'), '# Modified');
47
+ assert.strictEqual(hasUncommittedChanges(repoPath), true);
48
+ });
49
+ it('returns true for new untracked file', () => {
50
+ writeFileSync(join(repoPath, 'new-file.txt'), 'content');
51
+ assert.strictEqual(hasUncommittedChanges(repoPath), true);
52
+ });
53
+ it('returns false after staging and committing', () => {
54
+ writeFileSync(join(repoPath, 'new.txt'), 'x');
55
+ execSync('git add . && git commit -m "add"', {
56
+ cwd: repoPath,
57
+ stdio: 'pipe',
58
+ });
59
+ assert.strictEqual(hasUncommittedChanges(repoPath), false);
60
+ });
61
+ it('returns false for non-existent path', () => {
62
+ assert.strictEqual(hasUncommittedChanges('/tmp/nonexistent-repo-xyz'), false);
63
+ });
64
+ });
65
+ describe('hasNewCommits', () => {
66
+ let repoPath;
67
+ let bareRemote;
68
+ beforeEach(() => {
69
+ // Create a bare remote and clone it
70
+ bareRemote = mkdtempSync(join(tmpdir(), 'edsger-remote-'));
71
+ execSync('git init --bare', { cwd: bareRemote, stdio: 'pipe' });
72
+ repoPath = mkdtempSync(join(tmpdir(), 'edsger-clone-'));
73
+ rmSync(repoPath, { recursive: true });
74
+ execSync(`git clone "${bareRemote}" "${repoPath}"`, { stdio: 'pipe' });
75
+ execSync('git config user.email "test@test.com"', {
76
+ cwd: repoPath,
77
+ stdio: 'pipe',
78
+ });
79
+ execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
80
+ // Create initial commit and push
81
+ writeFileSync(join(repoPath, 'README.md'), '# Test');
82
+ execSync('git add . && git commit -m "init"', {
83
+ cwd: repoPath,
84
+ stdio: 'pipe',
85
+ });
86
+ execSync('git push origin main', { cwd: repoPath, stdio: 'pipe' });
87
+ });
88
+ afterEach(() => {
89
+ rmSync(repoPath, { recursive: true, force: true });
90
+ rmSync(bareRemote, { recursive: true, force: true });
91
+ });
92
+ it('returns false when HEAD matches remote', () => {
93
+ assert.strictEqual(hasNewCommits(repoPath, 'main'), false);
94
+ });
95
+ it('returns true after a local commit not pushed', () => {
96
+ writeFileSync(join(repoPath, 'new.txt'), 'hello');
97
+ execSync('git add . && git commit -m "local"', {
98
+ cwd: repoPath,
99
+ stdio: 'pipe',
100
+ });
101
+ assert.strictEqual(hasNewCommits(repoPath, 'main'), true);
102
+ });
103
+ it('returns false after pushing local commit', () => {
104
+ writeFileSync(join(repoPath, 'new.txt'), 'hello');
105
+ execSync('git add . && git commit -m "local" && git push origin main', {
106
+ cwd: repoPath,
107
+ stdio: 'pipe',
108
+ });
109
+ assert.strictEqual(hasNewCommits(repoPath, 'main'), false);
110
+ });
111
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * GitHub API operations for PR resolve: reply to threads, resolve threads.
3
+ * Reuses GraphQL patterns from code-refine-verification.
4
+ */
5
+ import { type Octokit } from '@octokit/rest';
6
+ /**
7
+ * Reply to a review thread on GitHub using GraphQL.
8
+ */
9
+ export declare function replyToReviewThread(octokit: Octokit, threadId: string, body: string, verbose?: boolean): Promise<boolean>;
10
+ /**
11
+ * Resolve a review thread on GitHub using GraphQL.
12
+ */
13
+ export declare function resolveReviewThread(octokit: Octokit, threadId: string, verbose?: boolean): Promise<boolean>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * GitHub API operations for PR resolve: reply to threads, resolve threads.
3
+ * Reuses GraphQL patterns from code-refine-verification.
4
+ */
5
+ import { logError, logInfo } from '../../utils/logger.js';
6
+ /**
7
+ * Reply to a review thread on GitHub using GraphQL.
8
+ */
9
+ export async function replyToReviewThread(octokit, threadId, body, verbose) {
10
+ try {
11
+ const mutation = `
12
+ mutation($threadId: ID!, $body: String!) {
13
+ addPullRequestReviewThreadReply(input: {
14
+ pullRequestReviewThreadId: $threadId
15
+ body: $body
16
+ }) {
17
+ comment {
18
+ id
19
+ }
20
+ }
21
+ }
22
+ `;
23
+ await octokit.graphql(mutation, { threadId, body });
24
+ if (verbose) {
25
+ logInfo(`Replied to thread ${threadId}`);
26
+ }
27
+ return true;
28
+ }
29
+ catch (error) {
30
+ logError(`Failed to reply to thread ${threadId}: ${error}`);
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Resolve a review thread on GitHub using GraphQL.
36
+ */
37
+ export async function resolveReviewThread(octokit, threadId, verbose) {
38
+ try {
39
+ const mutation = `
40
+ mutation($threadId: ID!) {
41
+ resolveReviewThread(input: {threadId: $threadId}) {
42
+ thread {
43
+ id
44
+ isResolved
45
+ }
46
+ }
47
+ }
48
+ `;
49
+ await octokit.graphql(mutation, { threadId });
50
+ if (verbose) {
51
+ logInfo(`Resolved thread ${threadId}`);
52
+ }
53
+ return true;
54
+ }
55
+ catch (error) {
56
+ logError(`Failed to resolve thread ${threadId}: ${error}`);
57
+ return false;
58
+ }
59
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Standalone PR Resolve
3
+ * Evaluates unresolved review comments, fixes what's genuinely better,
4
+ * replies to every comment on GitHub explaining the decision.
5
+ */
6
+ export interface StandalonePRResolveOptions {
7
+ productId: string;
8
+ pullRequestUrl: string;
9
+ githubToken: string;
10
+ owner: string;
11
+ repo: string;
12
+ prId?: string;
13
+ verbose?: boolean;
14
+ }
15
+ export interface PRResolveResult {
16
+ status: 'success' | 'error';
17
+ message: string;
18
+ threadsAddressed?: number;
19
+ threadsSkipped?: number;
20
+ threadsErrored?: number;
21
+ filesModified?: string[];
22
+ summary?: string;
23
+ }
24
+ /**
25
+ * Resolve PR change requests: evaluate each comment, fix or explain.
26
+ */
27
+ export declare function resolveStandalonePR(options: StandalonePRResolveOptions): Promise<PRResolveResult>;