@diff-review-system/drs 3.3.1 → 4.0.0-rc.4

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 (203) hide show
  1. package/.pi/agents/task/agents-md-updater.md +24 -0
  2. package/.pi/agents/task/changelog-updater.md +29 -0
  3. package/.pi/agents/task/review-issue-fixer.md +25 -0
  4. package/.pi/workflows/github-pr-describe-post.yaml +24 -0
  5. package/.pi/workflows/github-pr-describe.yaml +23 -0
  6. package/.pi/workflows/github-pr-post-comment.yaml +19 -0
  7. package/.pi/workflows/github-pr-review-post.yaml +32 -0
  8. package/.pi/workflows/github-pr-review.yaml +23 -0
  9. package/.pi/workflows/github-pr-show-changes.yaml +25 -0
  10. package/.pi/workflows/gitlab-mr-describe-post.yaml +22 -0
  11. package/.pi/workflows/gitlab-mr-describe.yaml +21 -0
  12. package/.pi/workflows/gitlab-mr-post-comment.yaml +17 -0
  13. package/.pi/workflows/gitlab-mr-review-code-quality.yaml +31 -0
  14. package/.pi/workflows/gitlab-mr-review-post-code-quality.yaml +40 -0
  15. package/.pi/workflows/gitlab-mr-review-post.yaml +30 -0
  16. package/.pi/workflows/gitlab-mr-review.yaml +21 -0
  17. package/.pi/workflows/gitlab-mr-show-changes.yaml +23 -0
  18. package/.pi/workflows/local-changelog-update.yaml +23 -0
  19. package/.pi/workflows/local-fix-review-issues.yaml +42 -0
  20. package/.pi/workflows/local-review.yaml +17 -0
  21. package/.pi/workflows/local-staged-review.yaml +17 -0
  22. package/.pi/workflows/local-update-agents-md.yaml +24 -0
  23. package/.pi/workflows/tag-changelog-update.yaml +26 -0
  24. package/README.md +214 -101
  25. package/dist/ci/runner.d.ts.map +1 -1
  26. package/dist/ci/runner.js +7 -8
  27. package/dist/ci/runner.js.map +1 -1
  28. package/dist/cli/index.js +69 -341
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/init.d.ts.map +1 -1
  31. package/dist/cli/init.js +25 -23
  32. package/dist/cli/init.js.map +1 -1
  33. package/dist/cli/run-agent.d.ts +24 -0
  34. package/dist/cli/run-agent.d.ts.map +1 -0
  35. package/dist/cli/run-agent.js +139 -0
  36. package/dist/cli/run-agent.js.map +1 -0
  37. package/dist/cli/run-agent.test.d.ts +2 -0
  38. package/dist/cli/run-agent.test.d.ts.map +1 -0
  39. package/dist/cli/run-agent.test.js +204 -0
  40. package/dist/cli/run-agent.test.js.map +1 -0
  41. package/dist/cli/workflow.d.ts +51 -0
  42. package/dist/cli/workflow.d.ts.map +1 -0
  43. package/dist/cli/workflow.js +1229 -0
  44. package/dist/cli/workflow.js.map +1 -0
  45. package/dist/cli/workflow.test.d.ts +2 -0
  46. package/dist/cli/workflow.test.d.ts.map +1 -0
  47. package/dist/cli/workflow.test.js +1410 -0
  48. package/dist/cli/workflow.test.js.map +1 -0
  49. package/dist/lib/agent-id.d.ts +9 -0
  50. package/dist/lib/agent-id.d.ts.map +1 -0
  51. package/dist/lib/agent-id.js +32 -0
  52. package/dist/lib/agent-id.js.map +1 -0
  53. package/dist/lib/comment-formatter.d.ts +7 -1
  54. package/dist/lib/comment-formatter.d.ts.map +1 -1
  55. package/dist/lib/comment-formatter.js +42 -1
  56. package/dist/lib/comment-formatter.js.map +1 -1
  57. package/dist/lib/comment-formatter.test.js +33 -0
  58. package/dist/lib/comment-formatter.test.js.map +1 -1
  59. package/dist/lib/comment-manager.d.ts +4 -0
  60. package/dist/lib/comment-manager.d.ts.map +1 -1
  61. package/dist/lib/comment-manager.js +7 -1
  62. package/dist/lib/comment-manager.js.map +1 -1
  63. package/dist/lib/comment-poster.d.ts +2 -2
  64. package/dist/lib/comment-poster.d.ts.map +1 -1
  65. package/dist/lib/comment-poster.js +3 -3
  66. package/dist/lib/comment-poster.js.map +1 -1
  67. package/dist/lib/comment-poster.test.js +13 -3
  68. package/dist/lib/comment-poster.test.js.map +1 -1
  69. package/dist/lib/config-model-overrides.test.d.ts +0 -10
  70. package/dist/lib/config-model-overrides.test.d.ts.map +1 -1
  71. package/dist/lib/config-model-overrides.test.js +174 -210
  72. package/dist/lib/config-model-overrides.test.js.map +1 -1
  73. package/dist/lib/config.d.ts +111 -34
  74. package/dist/lib/config.d.ts.map +1 -1
  75. package/dist/lib/config.js +322 -83
  76. package/dist/lib/config.js.map +1 -1
  77. package/dist/lib/config.test.js +282 -2
  78. package/dist/lib/config.test.js.map +1 -1
  79. package/dist/lib/context-loader.d.ts +4 -4
  80. package/dist/lib/context-loader.d.ts.map +1 -1
  81. package/dist/lib/context-loader.js +10 -7
  82. package/dist/lib/context-loader.js.map +1 -1
  83. package/dist/lib/context-loader.test.js +31 -26
  84. package/dist/lib/context-loader.test.js.map +1 -1
  85. package/dist/lib/description-executor.js +1 -1
  86. package/dist/lib/description-executor.js.map +1 -1
  87. package/dist/lib/description-executor.test.js +10 -3
  88. package/dist/lib/description-executor.test.js.map +1 -1
  89. package/dist/lib/diff-lines.d.ts +9 -0
  90. package/dist/lib/diff-lines.d.ts.map +1 -0
  91. package/dist/lib/diff-lines.js +32 -0
  92. package/dist/lib/diff-lines.js.map +1 -0
  93. package/dist/lib/diff-lines.test.d.ts +2 -0
  94. package/dist/lib/diff-lines.test.d.ts.map +1 -0
  95. package/dist/lib/diff-lines.test.js +13 -0
  96. package/dist/lib/diff-lines.test.js.map +1 -0
  97. package/dist/lib/logger.d.ts +1 -1
  98. package/dist/lib/logger.d.ts.map +1 -1
  99. package/dist/lib/review-core.d.ts.map +1 -1
  100. package/dist/lib/review-core.js +22 -27
  101. package/dist/lib/review-core.js.map +1 -1
  102. package/dist/lib/review-core.test.js +37 -23
  103. package/dist/lib/review-core.test.js.map +1 -1
  104. package/dist/lib/review-orchestrator.d.ts.map +1 -1
  105. package/dist/lib/review-orchestrator.js +11 -8
  106. package/dist/lib/review-orchestrator.js.map +1 -1
  107. package/dist/lib/review-orchestrator.test.js +27 -6
  108. package/dist/lib/review-orchestrator.test.js.map +1 -1
  109. package/dist/lib/unified-review-executor.d.ts +0 -2
  110. package/dist/lib/unified-review-executor.d.ts.map +1 -1
  111. package/dist/lib/unified-review-executor.js +7 -13
  112. package/dist/lib/unified-review-executor.js.map +1 -1
  113. package/dist/lib/unified-review-executor.test.js +2 -2
  114. package/dist/lib/unified-review-executor.test.js.map +1 -1
  115. package/dist/pi/sdk.d.ts.map +1 -1
  116. package/dist/pi/sdk.js +36 -11
  117. package/dist/pi/sdk.js.map +1 -1
  118. package/dist/pi/sdk.test.js +48 -9
  119. package/dist/pi/sdk.test.js.map +1 -1
  120. package/dist/runtime/agent-loader.d.ts +10 -6
  121. package/dist/runtime/agent-loader.d.ts.map +1 -1
  122. package/dist/runtime/agent-loader.js +53 -27
  123. package/dist/runtime/agent-loader.js.map +1 -1
  124. package/dist/runtime/agent-loader.test.js +116 -119
  125. package/dist/runtime/agent-loader.test.js.map +1 -1
  126. package/dist/runtime/built-in-paths.d.ts +1 -0
  127. package/dist/runtime/built-in-paths.d.ts.map +1 -1
  128. package/dist/runtime/built-in-paths.js +7 -0
  129. package/dist/runtime/built-in-paths.js.map +1 -1
  130. package/dist/runtime/client.d.ts +12 -0
  131. package/dist/runtime/client.d.ts.map +1 -1
  132. package/dist/runtime/client.js +83 -58
  133. package/dist/runtime/client.js.map +1 -1
  134. package/dist/runtime/client.test.js +264 -15
  135. package/dist/runtime/client.test.js.map +1 -1
  136. package/dist/runtime/path-config.d.ts +2 -2
  137. package/dist/runtime/path-config.d.ts.map +1 -1
  138. package/dist/runtime/path-config.js +8 -8
  139. package/dist/runtime/path-config.js.map +1 -1
  140. package/dist/runtime/path-config.test.js +14 -14
  141. package/dist/runtime/path-config.test.js.map +1 -1
  142. package/package.json +3 -3
  143. package/.pi/agents/review/documentation.md +0 -56
  144. package/.pi/agents/review/performance.md +0 -53
  145. package/.pi/agents/review/quality.md +0 -59
  146. package/.pi/agents/review/security.md +0 -53
  147. package/.pi/agents/review/style.md +0 -132
  148. package/dist/cli/describe-mr.d.ts +0 -11
  149. package/dist/cli/describe-mr.d.ts.map +0 -1
  150. package/dist/cli/describe-mr.js +0 -134
  151. package/dist/cli/describe-mr.js.map +0 -1
  152. package/dist/cli/describe-pr.d.ts +0 -12
  153. package/dist/cli/describe-pr.d.ts.map +0 -1
  154. package/dist/cli/describe-pr.js +0 -135
  155. package/dist/cli/describe-pr.js.map +0 -1
  156. package/dist/cli/post-comments.d.ts +0 -20
  157. package/dist/cli/post-comments.d.ts.map +0 -1
  158. package/dist/cli/post-comments.js +0 -225
  159. package/dist/cli/post-comments.js.map +0 -1
  160. package/dist/cli/review-local.d.ts +0 -13
  161. package/dist/cli/review-local.d.ts.map +0 -1
  162. package/dist/cli/review-local.integration.test.d.ts +0 -2
  163. package/dist/cli/review-local.integration.test.d.ts.map +0 -1
  164. package/dist/cli/review-local.integration.test.js +0 -343
  165. package/dist/cli/review-local.integration.test.js.map +0 -1
  166. package/dist/cli/review-local.js +0 -90
  167. package/dist/cli/review-local.js.map +0 -1
  168. package/dist/cli/review-local.live.e2e.test.d.ts +0 -2
  169. package/dist/cli/review-local.live.e2e.test.d.ts.map +0 -1
  170. package/dist/cli/review-local.live.e2e.test.js +0 -153
  171. package/dist/cli/review-local.live.e2e.test.js.map +0 -1
  172. package/dist/cli/review-local.test.d.ts +0 -2
  173. package/dist/cli/review-local.test.d.ts.map +0 -1
  174. package/dist/cli/review-local.test.js +0 -164
  175. package/dist/cli/review-local.test.js.map +0 -1
  176. package/dist/cli/review-mr.d.ts +0 -22
  177. package/dist/cli/review-mr.d.ts.map +0 -1
  178. package/dist/cli/review-mr.js +0 -181
  179. package/dist/cli/review-mr.js.map +0 -1
  180. package/dist/cli/review-mr.test.d.ts +0 -2
  181. package/dist/cli/review-mr.test.d.ts.map +0 -1
  182. package/dist/cli/review-mr.test.js +0 -142
  183. package/dist/cli/review-mr.test.js.map +0 -1
  184. package/dist/cli/review-pr.d.ts +0 -22
  185. package/dist/cli/review-pr.d.ts.map +0 -1
  186. package/dist/cli/review-pr.js +0 -181
  187. package/dist/cli/review-pr.js.map +0 -1
  188. package/dist/cli/review-pr.test.d.ts +0 -2
  189. package/dist/cli/review-pr.test.d.ts.map +0 -1
  190. package/dist/cli/review-pr.test.js +0 -137
  191. package/dist/cli/review-pr.test.js.map +0 -1
  192. package/dist/cli/review-url.d.ts +0 -35
  193. package/dist/cli/review-url.d.ts.map +0 -1
  194. package/dist/cli/review-url.js +0 -110
  195. package/dist/cli/review-url.js.map +0 -1
  196. package/dist/cli/review-url.test.d.ts +0 -2
  197. package/dist/cli/review-url.test.d.ts.map +0 -1
  198. package/dist/cli/review-url.test.js +0 -132
  199. package/dist/cli/review-url.test.js.map +0 -1
  200. package/dist/cli/show-changes.d.ts +0 -15
  201. package/dist/cli/show-changes.d.ts.map +0 -1
  202. package/dist/cli/show-changes.js +0 -184
  203. package/dist/cli/show-changes.js.map +0 -1
@@ -0,0 +1,1410 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { loadConfig } from '../lib/config.js';
6
+ import { exitProcess } from '../lib/exit.js';
7
+ import { runWorkflow, listWorkflows } from './workflow.js';
8
+ const mocks = vi.hoisted(() => {
9
+ const githubAdapter = {
10
+ getPullRequest: vi.fn(),
11
+ getChangedFiles: vi.fn(),
12
+ getComments: vi.fn(),
13
+ getInlineComments: vi.fn(),
14
+ createComment: vi.fn(),
15
+ updateComment: vi.fn(),
16
+ deleteComment: vi.fn(),
17
+ createBulkInlineComments: vi.fn(),
18
+ addLabels: vi.fn(),
19
+ };
20
+ const gitlabAdapter = {
21
+ getPullRequest: vi.fn(),
22
+ getChangedFiles: vi.fn(),
23
+ getComments: vi.fn(),
24
+ getInlineComments: vi.fn(),
25
+ createComment: vi.fn(),
26
+ updateComment: vi.fn(),
27
+ deleteComment: vi.fn(),
28
+ createBulkInlineComments: vi.fn(),
29
+ addLabels: vi.fn(),
30
+ };
31
+ return {
32
+ git: {
33
+ checkIsRepo: vi.fn(async () => true),
34
+ diff: vi.fn(async () => 'diff --git a/src/app.ts b/src/app.ts'),
35
+ raw: vi.fn(async (_args) => ''),
36
+ add: vi.fn(async () => ''),
37
+ commit: vi.fn(async () => ({
38
+ commit: 'abc1234',
39
+ summary: {
40
+ changes: 1,
41
+ insertions: 2,
42
+ deletions: 0,
43
+ },
44
+ })),
45
+ },
46
+ simpleGit: vi.fn(),
47
+ createGitHubClient: vi.fn(() => ({ platform: 'github' })),
48
+ GitHubPlatformAdapter: vi.fn(() => githubAdapter),
49
+ githubAdapter,
50
+ createGitLabClient: vi.fn(() => ({ platform: 'gitlab' })),
51
+ GitLabPlatformAdapter: vi.fn(() => gitlabAdapter),
52
+ gitlabAdapter,
53
+ parseDiff: vi.fn(() => [{ filename: 'src/app.ts', patch: '@@ +1 @@\n+change' }]),
54
+ getChangedFiles: vi.fn(() => ['src/app.ts']),
55
+ getFilesWithDiffs: vi.fn(() => [{ filename: 'src/app.ts', patch: '@@ +1 @@\n+change' }]),
56
+ runtimeClient: {
57
+ shutdown: vi.fn(async () => undefined),
58
+ },
59
+ connectToRuntime: vi.fn(),
60
+ runDescribeIfEnabled: vi.fn(),
61
+ enforceRepoBranchMatch: vi.fn(async () => undefined),
62
+ executeReview: vi.fn(async (_config, source) => ({
63
+ issues: [],
64
+ summary: {
65
+ filesReviewed: source.files.length,
66
+ issuesFound: 0,
67
+ bySeverity: {},
68
+ byCategory: {},
69
+ },
70
+ filesReviewed: source.files.length,
71
+ })),
72
+ runAgent: vi.fn(async (_config, agent, options) => ({
73
+ timestamp: '2026-06-16T00:00:00.000Z',
74
+ agent,
75
+ response: `${agent}: ${options.prompt ?? 'configured prompt'}`,
76
+ usage: {
77
+ agent,
78
+ success: true,
79
+ inputTokens: 1,
80
+ outputTokens: 1,
81
+ cacheReadTokens: 0,
82
+ cacheWriteTokens: 0,
83
+ totalTokens: 2,
84
+ cost: 0,
85
+ messages: 1,
86
+ },
87
+ })),
88
+ };
89
+ });
90
+ mocks.simpleGit.mockReturnValue(mocks.git);
91
+ vi.mock('simple-git', () => ({
92
+ default: mocks.simpleGit,
93
+ }));
94
+ vi.mock('../github/client.js', () => ({
95
+ createGitHubClient: mocks.createGitHubClient,
96
+ }));
97
+ vi.mock('../github/platform-adapter.js', () => ({
98
+ GitHubPlatformAdapter: mocks.GitHubPlatformAdapter,
99
+ }));
100
+ vi.mock('../gitlab/client.js', () => ({
101
+ createGitLabClient: mocks.createGitLabClient,
102
+ }));
103
+ vi.mock('../gitlab/platform-adapter.js', () => ({
104
+ GitLabPlatformAdapter: mocks.GitLabPlatformAdapter,
105
+ }));
106
+ vi.mock('./run-agent.js', () => ({
107
+ runAgent: mocks.runAgent,
108
+ }));
109
+ vi.mock('../lib/diff-parser.js', () => ({
110
+ parseDiff: mocks.parseDiff,
111
+ getChangedFiles: mocks.getChangedFiles,
112
+ getFilesWithDiffs: mocks.getFilesWithDiffs,
113
+ }));
114
+ vi.mock('../lib/review-orchestrator.js', () => ({
115
+ connectToRuntime: mocks.connectToRuntime,
116
+ executeReview: mocks.executeReview,
117
+ filterIgnoredFiles: (files, config) => files.filter((file) => !(config.review.ignorePatterns ?? []).includes(file)),
118
+ }));
119
+ vi.mock('../lib/description-executor.js', () => ({
120
+ runDescribeIfEnabled: mocks.runDescribeIfEnabled,
121
+ }));
122
+ vi.mock('../lib/repository-validator.js', () => ({
123
+ enforceRepoBranchMatch: mocks.enforceRepoBranchMatch,
124
+ resolveBaseBranch: (_baseBranch, targetBranch) => ({
125
+ resolvedBaseBranch: targetBranch ? `origin/${targetBranch}` : undefined,
126
+ source: targetBranch ? 'pr:targetBranch' : undefined,
127
+ }),
128
+ getCanonicalDiffCommand: () => 'git diff origin/main origin/feature -- <file>',
129
+ }));
130
+ const baseConfig = {
131
+ pi: {},
132
+ agents: { default: { model: 'provider/default-model', skills: [] } },
133
+ gitlab: { url: '', token: '' },
134
+ github: { token: '' },
135
+ review: {
136
+ agents: ['review/security'],
137
+ ignorePatterns: [],
138
+ },
139
+ };
140
+ describe('workflow runner', () => {
141
+ const tempDirs = [];
142
+ function createTempDir(prefix) {
143
+ const dir = mkdtempSync(join(tmpdir(), prefix));
144
+ tempDirs.push(dir);
145
+ return dir;
146
+ }
147
+ function createMockAgentResult(agent, response) {
148
+ return {
149
+ timestamp: '2026-06-16T00:00:00.000Z',
150
+ agent,
151
+ response,
152
+ usage: {
153
+ agent,
154
+ success: true,
155
+ inputTokens: 1,
156
+ outputTokens: 1,
157
+ cacheReadTokens: 0,
158
+ cacheWriteTokens: 0,
159
+ totalTokens: 2,
160
+ cost: 0,
161
+ messages: 1,
162
+ },
163
+ };
164
+ }
165
+ function timeoutAfter(ms) {
166
+ return new Promise((_, reject) => {
167
+ setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
168
+ });
169
+ }
170
+ beforeEach(() => {
171
+ vi.clearAllMocks();
172
+ mocks.simpleGit.mockReturnValue(mocks.git);
173
+ mocks.git.checkIsRepo.mockResolvedValue(true);
174
+ mocks.git.diff.mockResolvedValue('diff --git a/src/app.ts b/src/app.ts');
175
+ mocks.git.raw.mockResolvedValue('');
176
+ mocks.git.add.mockResolvedValue('');
177
+ mocks.git.commit.mockResolvedValue({
178
+ commit: 'abc1234',
179
+ summary: {
180
+ changes: 1,
181
+ insertions: 2,
182
+ deletions: 0,
183
+ },
184
+ });
185
+ mocks.parseDiff.mockReturnValue([{ filename: 'src/app.ts', patch: '@@ +1 @@\n+change' }]);
186
+ mocks.getChangedFiles.mockReturnValue(['src/app.ts']);
187
+ mocks.getFilesWithDiffs.mockReturnValue([
188
+ { filename: 'src/app.ts', patch: '@@ +1 @@\n+change' },
189
+ ]);
190
+ mocks.runtimeClient.shutdown.mockResolvedValue(undefined);
191
+ mocks.connectToRuntime.mockResolvedValue(mocks.runtimeClient);
192
+ mocks.runDescribeIfEnabled.mockResolvedValue({
193
+ type: 'feature',
194
+ title: 'Generated description',
195
+ summary: ['Describe the change'],
196
+ });
197
+ mocks.enforceRepoBranchMatch.mockResolvedValue(undefined);
198
+ mocks.createGitHubClient.mockReturnValue({ platform: 'github' });
199
+ mocks.GitHubPlatformAdapter.mockReturnValue(mocks.githubAdapter);
200
+ mocks.githubAdapter.getPullRequest.mockResolvedValue({
201
+ number: 7,
202
+ title: 'GitHub PR',
203
+ author: 'octocat',
204
+ sourceBranch: 'feature',
205
+ targetBranch: 'main',
206
+ headSha: 'abc123',
207
+ });
208
+ mocks.githubAdapter.getChangedFiles.mockResolvedValue([
209
+ {
210
+ filename: 'src/github.ts',
211
+ status: 'modified',
212
+ additions: 2,
213
+ deletions: 1,
214
+ patch: '@@ +1 @@\n+github',
215
+ },
216
+ ]);
217
+ mocks.githubAdapter.getComments.mockResolvedValue([]);
218
+ mocks.githubAdapter.getInlineComments.mockResolvedValue([]);
219
+ mocks.githubAdapter.createComment.mockResolvedValue(undefined);
220
+ mocks.githubAdapter.updateComment.mockResolvedValue(undefined);
221
+ mocks.githubAdapter.deleteComment.mockResolvedValue(undefined);
222
+ mocks.githubAdapter.createBulkInlineComments.mockResolvedValue(undefined);
223
+ mocks.githubAdapter.addLabels.mockResolvedValue(undefined);
224
+ mocks.createGitLabClient.mockReturnValue({ platform: 'gitlab' });
225
+ mocks.GitLabPlatformAdapter.mockReturnValue(mocks.gitlabAdapter);
226
+ mocks.gitlabAdapter.getPullRequest.mockResolvedValue({
227
+ number: 8,
228
+ title: 'GitLab MR',
229
+ author: 'gitlab-user',
230
+ sourceBranch: 'feature',
231
+ targetBranch: 'main',
232
+ headSha: 'def456',
233
+ });
234
+ mocks.gitlabAdapter.getChangedFiles.mockResolvedValue([
235
+ {
236
+ filename: 'src/gitlab.ts',
237
+ status: 'modified',
238
+ additions: 3,
239
+ deletions: 1,
240
+ patch: '@@ +1 @@\n+gitlab',
241
+ },
242
+ ]);
243
+ mocks.gitlabAdapter.getComments.mockResolvedValue([]);
244
+ mocks.gitlabAdapter.getInlineComments.mockResolvedValue([]);
245
+ mocks.gitlabAdapter.createComment.mockResolvedValue(undefined);
246
+ mocks.gitlabAdapter.updateComment.mockResolvedValue(undefined);
247
+ mocks.gitlabAdapter.deleteComment.mockResolvedValue(undefined);
248
+ mocks.gitlabAdapter.createBulkInlineComments.mockResolvedValue(undefined);
249
+ mocks.gitlabAdapter.addLabels.mockResolvedValue(undefined);
250
+ mocks.executeReview.mockImplementation(async (_config, source) => ({
251
+ issues: [],
252
+ summary: {
253
+ filesReviewed: source.files.length,
254
+ issuesFound: 0,
255
+ bySeverity: {},
256
+ byCategory: {},
257
+ },
258
+ filesReviewed: source.files.length,
259
+ }));
260
+ vi.spyOn(console, 'log').mockImplementation(() => { });
261
+ });
262
+ afterEach(() => {
263
+ vi.restoreAllMocks();
264
+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
265
+ rmSync(dir, { recursive: true, force: true });
266
+ }
267
+ });
268
+ it('runs agent and write nodes in dependency order', async () => {
269
+ const projectRoot = createTempDir('drs-workflow-');
270
+ const config = {
271
+ ...baseConfig,
272
+ workflows: {
273
+ release: {
274
+ inputs: {
275
+ diff: 'Diff text',
276
+ },
277
+ nodes: {
278
+ summarize: {
279
+ agent: 'task/summarizer',
280
+ input: 'Summarize {{inputs.diff}}',
281
+ output: 'summary',
282
+ },
283
+ writeSummary: {
284
+ action: 'write',
285
+ needs: ['summarize'],
286
+ input: 'Summary:\n{{artifacts.summary}}',
287
+ writes: 'out/summary.md',
288
+ output: 'written',
289
+ },
290
+ },
291
+ },
292
+ },
293
+ };
294
+ const result = await runWorkflow(config, 'release', {
295
+ workingDir: projectRoot,
296
+ });
297
+ expect(mocks.runAgent).toHaveBeenCalledWith(config, 'task/summarizer', expect.objectContaining({
298
+ prompt: 'Summarize Diff text',
299
+ quiet: true,
300
+ allowImplicitStdin: false,
301
+ ignoreConfiguredOutput: true,
302
+ }));
303
+ expect(readFileSync(join(projectRoot, 'out/summary.md'), 'utf-8')).toBe('Summary:\ntask/summarizer: Summarize Diff text');
304
+ expect(result.output).toBe('Summary:\ntask/summarizer: Summarize Diff text');
305
+ });
306
+ it('rejects writes paths that render to empty strings', async () => {
307
+ const config = {
308
+ ...baseConfig,
309
+ workflows: {
310
+ emptyWrite: {
311
+ inputs: {
312
+ outputPath: '',
313
+ },
314
+ nodes: {
315
+ writeSummary: {
316
+ action: 'write',
317
+ input: 'content',
318
+ writes: '{{inputs.outputPath}}',
319
+ },
320
+ },
321
+ },
322
+ },
323
+ };
324
+ await expect(runWorkflow(config, 'emptyWrite')).rejects.toThrow('Workflow node "writeSummary" writes resolved to an empty path.');
325
+ });
326
+ it('uses strict boolean checks for node JSON writes', async () => {
327
+ const projectRoot = createTempDir('drs-workflow-json-');
328
+ const config = {
329
+ ...baseConfig,
330
+ workflows: {
331
+ jsonFlag: {
332
+ nodes: {
333
+ summarize: {
334
+ agent: 'task/summarizer',
335
+ input: 'Summarize',
336
+ writes: 'summary.txt',
337
+ json: 'false',
338
+ },
339
+ },
340
+ },
341
+ },
342
+ };
343
+ await runWorkflow(config, 'jsonFlag', { workingDir: projectRoot });
344
+ expect(readFileSync(join(projectRoot, 'summary.txt'), 'utf-8')).toBe('task/summarizer: Summarize');
345
+ });
346
+ it('lets CLI-style inputs override configured inputs', async () => {
347
+ const projectRoot = createTempDir('drs-workflow-inputs-');
348
+ writeFileSync(join(projectRoot, 'diff.md'), 'File diff');
349
+ const config = {
350
+ ...baseConfig,
351
+ workflows: {
352
+ describe: {
353
+ inputs: {
354
+ diff: 'Configured diff',
355
+ title: 'Configured title',
356
+ },
357
+ nodes: {
358
+ summarize: {
359
+ agent: 'task/summarizer',
360
+ input: '{{inputs.title}}\n{{inputs.diff}}',
361
+ },
362
+ },
363
+ },
364
+ },
365
+ };
366
+ await runWorkflow(config, 'describe', {
367
+ inputs: { title: 'CLI title' },
368
+ inputFiles: { diff: 'diff.md' },
369
+ workingDir: projectRoot,
370
+ });
371
+ expect(mocks.runAgent).toHaveBeenCalledWith(config, 'task/summarizer', expect.objectContaining({
372
+ prompt: 'CLI title\nFile diff',
373
+ }));
374
+ });
375
+ it('runs agentsFrom review.agents as a multi-agent node', async () => {
376
+ const config = {
377
+ ...baseConfig,
378
+ review: {
379
+ agents: ['review/security', { name: 'review/quality' }],
380
+ ignorePatterns: [],
381
+ },
382
+ workflows: {
383
+ review: {
384
+ inputs: {
385
+ diff: 'Diff text',
386
+ },
387
+ nodes: {
388
+ reviewers: {
389
+ agentsFrom: 'review.agents',
390
+ input: 'Review {{inputs.diff}}',
391
+ output: 'reviewResult',
392
+ },
393
+ },
394
+ },
395
+ },
396
+ };
397
+ const result = await runWorkflow(config, 'review', {
398
+ workingDir: process.cwd(),
399
+ });
400
+ expect(mocks.runAgent).toHaveBeenNthCalledWith(1, config, 'review/security', expect.objectContaining({ prompt: 'Review Diff text' }));
401
+ expect(mocks.runAgent).toHaveBeenNthCalledWith(2, config, 'review/quality', expect.objectContaining({ prompt: 'Review Diff text' }));
402
+ expect(result.artifacts.reviewResult).toContain('## review/security');
403
+ expect(result.artifacts.reviewResult).toContain('## review/quality');
404
+ });
405
+ it('runs agentsFrom agents concurrently', async () => {
406
+ let resolveSecurity = () => { };
407
+ let resolveQuality = () => { };
408
+ let resolveBothStarted = () => { };
409
+ const starts = [];
410
+ const securityDone = new Promise((resolve) => {
411
+ resolveSecurity = resolve;
412
+ });
413
+ const qualityDone = new Promise((resolve) => {
414
+ resolveQuality = resolve;
415
+ });
416
+ const bothStarted = new Promise((resolve) => {
417
+ resolveBothStarted = resolve;
418
+ });
419
+ mocks.runAgent.mockImplementation(async (_config, agent) => {
420
+ starts.push(agent);
421
+ if (starts.length === 2) {
422
+ resolveBothStarted();
423
+ }
424
+ await (agent === 'review/security' ? securityDone : qualityDone);
425
+ return createMockAgentResult(agent, `${agent} done`);
426
+ });
427
+ const config = {
428
+ ...baseConfig,
429
+ review: {
430
+ agents: ['review/security', 'review/quality'],
431
+ ignorePatterns: [],
432
+ },
433
+ workflows: {
434
+ review: {
435
+ inputs: {
436
+ diff: 'Diff text',
437
+ },
438
+ nodes: {
439
+ reviewers: {
440
+ agentsFrom: 'review.agents',
441
+ input: 'Review {{inputs.diff}}',
442
+ output: 'reviewResult',
443
+ },
444
+ },
445
+ },
446
+ },
447
+ };
448
+ const runPromise = runWorkflow(config, 'review', { workingDir: process.cwd() });
449
+ await Promise.race([bothStarted, timeoutAfter(250)]);
450
+ resolveSecurity();
451
+ resolveQuality();
452
+ const result = await runPromise;
453
+ expect(starts).toEqual(['review/security', 'review/quality']);
454
+ expect(result.artifacts.reviewResult).toContain('review/security done');
455
+ expect(result.artifacts.reviewResult).toContain('review/quality done');
456
+ });
457
+ it('runs independent workflow nodes concurrently', async () => {
458
+ let resolveOne = () => { };
459
+ let resolveTwo = () => { };
460
+ let resolveBothStarted = () => { };
461
+ const starts = [];
462
+ const oneDone = new Promise((resolve) => {
463
+ resolveOne = resolve;
464
+ });
465
+ const twoDone = new Promise((resolve) => {
466
+ resolveTwo = resolve;
467
+ });
468
+ const bothStarted = new Promise((resolve) => {
469
+ resolveBothStarted = resolve;
470
+ });
471
+ mocks.runAgent.mockImplementation(async (_config, agent) => {
472
+ starts.push(agent);
473
+ if (starts.length === 2) {
474
+ resolveBothStarted();
475
+ }
476
+ await (agent === 'task/one' ? oneDone : twoDone);
477
+ return createMockAgentResult(agent, `${agent} done`);
478
+ });
479
+ const config = {
480
+ ...baseConfig,
481
+ workflows: {
482
+ parallel: {
483
+ nodes: {
484
+ one: { agent: 'task/one', input: 'one', output: 'one' },
485
+ two: { agent: 'task/two', input: 'two', output: 'two' },
486
+ join: {
487
+ action: 'write',
488
+ needs: ['one', 'two'],
489
+ input: '{{artifacts.one}}\n{{artifacts.two}}',
490
+ writes: 'joined.txt',
491
+ },
492
+ },
493
+ },
494
+ },
495
+ };
496
+ const projectRoot = createTempDir('drs-workflow-parallel-');
497
+ const runPromise = runWorkflow(config, 'parallel', { workingDir: projectRoot });
498
+ await Promise.race([bothStarted, timeoutAfter(250)]);
499
+ resolveOne();
500
+ resolveTwo();
501
+ await runPromise;
502
+ expect(starts).toEqual(['task/one', 'task/two']);
503
+ expect(readFileSync(join(projectRoot, 'joined.txt'), 'utf-8')).toBe('task/one done\ntask/two done');
504
+ });
505
+ it('loads local git diff as an action artifact', async () => {
506
+ const projectRoot = createTempDir('drs-workflow-git-diff-');
507
+ const config = {
508
+ ...baseConfig,
509
+ workflows: {
510
+ localReview: {
511
+ nodes: {
512
+ diff: {
513
+ action: 'git-diff',
514
+ with: { staged: true },
515
+ output: 'localDiff',
516
+ },
517
+ summarize: {
518
+ agent: 'task/summarizer',
519
+ needs: ['diff'],
520
+ input: 'Summarize {{artifacts.localDiff}}',
521
+ },
522
+ },
523
+ },
524
+ },
525
+ };
526
+ await runWorkflow(config, 'localReview', {
527
+ workingDir: projectRoot,
528
+ });
529
+ expect(mocks.simpleGit).toHaveBeenCalledWith({ baseDir: projectRoot });
530
+ expect(mocks.git.diff).toHaveBeenCalledWith(['--cached']);
531
+ expect(mocks.runAgent).toHaveBeenCalledWith(config, 'task/summarizer', expect.objectContaining({
532
+ prompt: 'Summarize diff --git a/src/app.ts b/src/app.ts',
533
+ }));
534
+ });
535
+ it('stages paths with a git-add action', async () => {
536
+ const projectRoot = createTempDir('drs-workflow-git-add-');
537
+ const config = {
538
+ ...baseConfig,
539
+ workflows: {
540
+ stageChangelog: {
541
+ nodes: {
542
+ stage: {
543
+ action: 'git-add',
544
+ with: { paths: 'CHANGELOG.md, README.md' },
545
+ output: 'stagedPaths',
546
+ },
547
+ },
548
+ },
549
+ },
550
+ };
551
+ const result = await runWorkflow(config, 'stageChangelog', {
552
+ workingDir: projectRoot,
553
+ });
554
+ expect(mocks.git.add).toHaveBeenCalledWith(['CHANGELOG.md', 'README.md']);
555
+ expect(result.artifacts.stagedPaths).toEqual(['CHANGELOG.md', 'README.md']);
556
+ });
557
+ it('commits only configured paths with a git-commit action', async () => {
558
+ const projectRoot = createTempDir('drs-workflow-git-commit-');
559
+ const config = {
560
+ ...baseConfig,
561
+ workflows: {
562
+ commitChangelog: {
563
+ nodes: {
564
+ commit: {
565
+ action: 'git-commit',
566
+ with: {
567
+ paths: 'CHANGELOG.md',
568
+ message: 'docs: update changelog',
569
+ },
570
+ output: 'commitResult',
571
+ },
572
+ },
573
+ },
574
+ },
575
+ };
576
+ const result = await runWorkflow(config, 'commitChangelog', {
577
+ workingDir: projectRoot,
578
+ });
579
+ expect(mocks.git.add).toHaveBeenCalledWith(['CHANGELOG.md']);
580
+ expect(mocks.git.commit).toHaveBeenCalledWith('docs: update changelog', ['CHANGELOG.md']);
581
+ expect(result.artifacts.commitResult).toMatchObject({
582
+ commit: 'abc1234',
583
+ message: 'docs: update changelog',
584
+ paths: ['CHANGELOG.md'],
585
+ });
586
+ });
587
+ it('rejects git action paths outside the working directory', async () => {
588
+ const projectRoot = createTempDir('drs-workflow-git-path-');
589
+ const config = {
590
+ ...baseConfig,
591
+ workflows: {
592
+ unsafeStage: {
593
+ nodes: {
594
+ stage: {
595
+ action: 'git-add',
596
+ with: { path: '../outside.md' },
597
+ },
598
+ },
599
+ },
600
+ },
601
+ };
602
+ await expect(runWorkflow(config, 'unsafeStage', {
603
+ workingDir: projectRoot,
604
+ })).rejects.toThrow('Refusing to access outside working directory');
605
+ expect(mocks.git.add).not.toHaveBeenCalled();
606
+ });
607
+ it('loads a local change source and reviews it', async () => {
608
+ const projectRoot = createTempDir('drs-workflow-review-');
609
+ const config = {
610
+ ...baseConfig,
611
+ workflows: {
612
+ localReview: {
613
+ nodes: {
614
+ change: {
615
+ action: 'change-source',
616
+ with: { type: 'local', staged: true },
617
+ output: 'change',
618
+ },
619
+ review: {
620
+ action: 'review',
621
+ needs: ['change'],
622
+ with: { source: 'change' },
623
+ output: 'reviewResult',
624
+ writes: '.drs/review-result.json',
625
+ },
626
+ },
627
+ },
628
+ },
629
+ };
630
+ const result = await runWorkflow(config, 'localReview', {
631
+ workingDir: projectRoot,
632
+ debug: true,
633
+ thinkingLevel: 'high',
634
+ });
635
+ expect(mocks.git.diff).toHaveBeenCalledWith(['--cached']);
636
+ expect(mocks.parseDiff).toHaveBeenCalledWith('diff --git a/src/app.ts b/src/app.ts');
637
+ expect(mocks.executeReview).toHaveBeenCalledWith(config, expect.objectContaining({
638
+ name: 'Local staged diff',
639
+ files: ['src/app.ts'],
640
+ filesWithDiffs: [{ filename: 'src/app.ts', patch: '@@ +1 @@\n+change' }],
641
+ workingDir: projectRoot,
642
+ staged: true,
643
+ debug: true,
644
+ thinkingLevel: 'high',
645
+ }));
646
+ expect(result.artifacts.reviewResult).toMatchObject({ filesReviewed: 1 });
647
+ expect(JSON.parse(readFileSync(join(projectRoot, '.drs/review-result.json'), 'utf-8'))).toMatchObject({
648
+ filesReviewed: 1,
649
+ });
650
+ });
651
+ it('loads a git range change source from explicit refs', async () => {
652
+ const projectRoot = createTempDir('drs-workflow-git-range-');
653
+ const config = {
654
+ ...baseConfig,
655
+ workflows: {
656
+ releaseChanges: {
657
+ nodes: {
658
+ change: {
659
+ action: 'change-source',
660
+ with: { type: 'git-range', from: 'v3.3.1', to: 'v4.0.0-rc.1' },
661
+ output: 'change',
662
+ },
663
+ },
664
+ },
665
+ },
666
+ };
667
+ mocks.git.raw.mockResolvedValue('abc123\x1fAda Lovelace\x1f2026-06-16T00:00:00Z\x1fAdd workflow runtime\n');
668
+ const result = await runWorkflow(config, 'releaseChanges', {
669
+ workingDir: projectRoot,
670
+ });
671
+ expect(mocks.git.diff).toHaveBeenCalledWith(['v3.3.1..v4.0.0-rc.1']);
672
+ expect(mocks.git.raw).toHaveBeenCalledWith([
673
+ 'log',
674
+ '--format=%H%x1f%an%x1f%aI%x1f%s',
675
+ '--no-merges',
676
+ 'v3.3.1..v4.0.0-rc.1',
677
+ ]);
678
+ expect(result.artifacts.change).toMatchObject({
679
+ name: 'Git range v3.3.1..v4.0.0-rc.1',
680
+ files: ['src/app.ts'],
681
+ context: {
682
+ sourceType: 'git-range',
683
+ fromRef: 'v3.3.1',
684
+ toRef: 'v4.0.0-rc.1',
685
+ range: 'v3.3.1..v4.0.0-rc.1',
686
+ commits: [
687
+ {
688
+ sha: 'abc123',
689
+ author: 'Ada Lovelace',
690
+ date: '2026-06-16T00:00:00Z',
691
+ subject: 'Add workflow runtime',
692
+ },
693
+ ],
694
+ },
695
+ });
696
+ });
697
+ it('infers git range refs from a GitHub Actions tag event', async () => {
698
+ const previousRefType = process.env.GITHUB_REF_TYPE;
699
+ const previousRefName = process.env.GITHUB_REF_NAME;
700
+ process.env.GITHUB_REF_TYPE = 'tag';
701
+ process.env.GITHUB_REF_NAME = 'v4.0.0-rc.1';
702
+ const projectRoot = createTempDir('drs-workflow-github-tag-range-');
703
+ const config = {
704
+ ...baseConfig,
705
+ workflows: {
706
+ releaseChanges: {
707
+ nodes: {
708
+ change: {
709
+ action: 'change-source',
710
+ with: { type: 'git-range' },
711
+ output: 'change',
712
+ },
713
+ },
714
+ },
715
+ },
716
+ };
717
+ mocks.git.raw.mockImplementation(async (args) => {
718
+ if (args?.[0] === 'tag') {
719
+ return 'v4.0.0-rc.1\nv3.3.1\nv3.3.0\n';
720
+ }
721
+ return 'def456\x1fGrace Hopper\x1f2026-06-16T00:00:00Z\x1fPrepare 4.0\n';
722
+ });
723
+ try {
724
+ const result = await runWorkflow(config, 'releaseChanges', {
725
+ workingDir: projectRoot,
726
+ });
727
+ expect(mocks.git.raw).toHaveBeenCalledWith([
728
+ 'tag',
729
+ '--merged',
730
+ 'v4.0.0-rc.1',
731
+ '--sort=-v:refname',
732
+ ]);
733
+ expect(mocks.git.diff).toHaveBeenCalledWith(['v3.3.1..v4.0.0-rc.1']);
734
+ expect(result.artifacts.change).toMatchObject({
735
+ name: 'Git range v3.3.1..v4.0.0-rc.1',
736
+ context: {
737
+ fromRef: 'v3.3.1',
738
+ toRef: 'v4.0.0-rc.1',
739
+ },
740
+ });
741
+ }
742
+ finally {
743
+ if (previousRefType === undefined) {
744
+ delete process.env.GITHUB_REF_TYPE;
745
+ }
746
+ else {
747
+ process.env.GITHUB_REF_TYPE = previousRefType;
748
+ }
749
+ if (previousRefName === undefined) {
750
+ delete process.env.GITHUB_REF_NAME;
751
+ }
752
+ else {
753
+ process.env.GITHUB_REF_NAME = previousRefName;
754
+ }
755
+ }
756
+ });
757
+ it('suppresses review action logs when workflow JSON output is enabled', async () => {
758
+ const logSpy = vi.mocked(console.log);
759
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
760
+ mocks.executeReview.mockImplementation(async (_config, source) => {
761
+ console.log('review progress');
762
+ console.warn('review warning');
763
+ return {
764
+ issues: [],
765
+ summary: {
766
+ filesReviewed: source.files.length,
767
+ issuesFound: 0,
768
+ bySeverity: {},
769
+ byCategory: {},
770
+ },
771
+ filesReviewed: source.files.length,
772
+ };
773
+ });
774
+ const config = {
775
+ ...baseConfig,
776
+ workflows: {
777
+ localReview: {
778
+ nodes: {
779
+ change: {
780
+ action: 'change-source',
781
+ output: 'change',
782
+ },
783
+ review: {
784
+ action: 'review',
785
+ needs: ['change'],
786
+ with: { source: 'change' },
787
+ output: 'reviewResult',
788
+ },
789
+ },
790
+ },
791
+ },
792
+ };
793
+ await runWorkflow(config, 'localReview', {
794
+ jsonOutput: true,
795
+ workingDir: process.cwd(),
796
+ });
797
+ expect(warnSpy).not.toHaveBeenCalled();
798
+ expect(logSpy).toHaveBeenCalledTimes(1);
799
+ expect(() => JSON.parse(String(logSpy.mock.calls[0][0]))).not.toThrow();
800
+ });
801
+ it('keeps review action log suppression isolated for concurrent JSON nodes', async () => {
802
+ const logSpy = vi.mocked(console.log);
803
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
804
+ let resolveFirstStarted = () => { };
805
+ let resolveFirstCanReturn = () => { };
806
+ const firstStarted = new Promise((resolve) => {
807
+ resolveFirstStarted = resolve;
808
+ });
809
+ const firstCanReturn = new Promise((resolve) => {
810
+ resolveFirstCanReturn = resolve;
811
+ });
812
+ let reviewCalls = 0;
813
+ mocks.executeReview.mockImplementation(async (_config, source) => {
814
+ const callNumber = ++reviewCalls;
815
+ if (callNumber === 1) {
816
+ console.log('first review progress');
817
+ resolveFirstStarted();
818
+ await firstCanReturn;
819
+ }
820
+ else {
821
+ await new Promise((resolve) => {
822
+ setTimeout(resolve, 0);
823
+ });
824
+ console.log('second review progress');
825
+ console.warn('second review warning');
826
+ }
827
+ return {
828
+ issues: [],
829
+ summary: {
830
+ filesReviewed: source.files.length,
831
+ issuesFound: 0,
832
+ bySeverity: {},
833
+ byCategory: {},
834
+ },
835
+ filesReviewed: source.files.length,
836
+ };
837
+ });
838
+ const config = {
839
+ ...baseConfig,
840
+ workflows: {
841
+ concurrentReview: {
842
+ nodes: {
843
+ change: {
844
+ action: 'change-source',
845
+ output: 'change',
846
+ },
847
+ reviewOne: {
848
+ action: 'review',
849
+ needs: ['change'],
850
+ with: { source: 'change' },
851
+ output: 'reviewOneResult',
852
+ },
853
+ reviewTwo: {
854
+ action: 'review',
855
+ needs: ['change'],
856
+ with: { source: 'change' },
857
+ output: 'reviewTwoResult',
858
+ },
859
+ },
860
+ },
861
+ },
862
+ };
863
+ const runPromise = runWorkflow(config, 'concurrentReview', {
864
+ jsonOutput: true,
865
+ workingDir: process.cwd(),
866
+ });
867
+ await Promise.race([firstStarted, timeoutAfter(250)]);
868
+ resolveFirstCanReturn();
869
+ await runPromise;
870
+ expect(reviewCalls).toBe(2);
871
+ expect(warnSpy).not.toHaveBeenCalled();
872
+ expect(logSpy).toHaveBeenCalledTimes(1);
873
+ expect(() => JSON.parse(String(logSpy.mock.calls[0][0]))).not.toThrow();
874
+ expect(console.log).toBe(logSpy);
875
+ });
876
+ it('converts review action process exits into workflow errors', async () => {
877
+ mocks.executeReview.mockImplementation(async () => {
878
+ exitProcess(1);
879
+ });
880
+ const config = {
881
+ ...baseConfig,
882
+ workflows: {
883
+ localReview: {
884
+ nodes: {
885
+ change: {
886
+ action: 'change-source',
887
+ output: 'change',
888
+ },
889
+ review: {
890
+ action: 'review',
891
+ needs: ['change'],
892
+ with: { source: 'change' },
893
+ },
894
+ },
895
+ },
896
+ },
897
+ };
898
+ await expect(runWorkflow(config, 'localReview')).rejects.toThrow('Workflow review node "review" failed: all review agents failed.');
899
+ });
900
+ it('loads a GitHub PR change source and reviews it', async () => {
901
+ const config = {
902
+ ...baseConfig,
903
+ workflows: {
904
+ githubReview: {
905
+ inputs: {
906
+ owner: '',
907
+ repo: '',
908
+ pr: '',
909
+ },
910
+ nodes: {
911
+ change: {
912
+ action: 'change-source',
913
+ with: {
914
+ type: 'github-pr',
915
+ owner: '{{inputs.owner}}',
916
+ repo: '{{inputs.repo}}',
917
+ pr: '{{inputs.pr}}',
918
+ },
919
+ output: 'change',
920
+ },
921
+ review: {
922
+ action: 'review',
923
+ needs: ['change'],
924
+ with: { source: 'change' },
925
+ output: 'reviewResult',
926
+ },
927
+ },
928
+ },
929
+ },
930
+ };
931
+ await runWorkflow(config, 'githubReview', {
932
+ inputs: { owner: 'octocat', repo: 'hello-world', pr: '7' },
933
+ workingDir: process.cwd(),
934
+ });
935
+ expect(mocks.createGitHubClient).toHaveBeenCalled();
936
+ expect(mocks.GitHubPlatformAdapter).toHaveBeenCalled();
937
+ expect(mocks.githubAdapter.getPullRequest).toHaveBeenCalledWith('octocat/hello-world', 7);
938
+ expect(mocks.githubAdapter.getChangedFiles).toHaveBeenCalledWith('octocat/hello-world', 7);
939
+ expect(mocks.enforceRepoBranchMatch).toHaveBeenCalledWith(process.cwd(), 'octocat/hello-world', expect.objectContaining({ number: 7 }), {
940
+ skipRepoCheck: undefined,
941
+ skipBranchCheck: undefined,
942
+ });
943
+ expect(mocks.executeReview).toHaveBeenCalledWith(config, expect.objectContaining({
944
+ name: 'GitHub PR octocat/hello-world#7',
945
+ files: ['src/github.ts'],
946
+ filesWithDiffs: [{ filename: 'src/github.ts', patch: '@@ +1 @@\n+github' }],
947
+ context: expect.objectContaining({
948
+ platform: 'github',
949
+ projectId: 'octocat/hello-world',
950
+ }),
951
+ }));
952
+ });
953
+ it('shows GitHub PR review context with embedded diff content', async () => {
954
+ const config = {
955
+ ...baseConfig,
956
+ workflows: {
957
+ githubContext: {
958
+ nodes: {
959
+ change: {
960
+ action: 'change-source',
961
+ with: {
962
+ type: 'github-pr',
963
+ owner: 'octocat',
964
+ repo: 'hello-world',
965
+ pr: 7,
966
+ },
967
+ output: 'change',
968
+ },
969
+ context: {
970
+ action: 'review-context',
971
+ needs: ['change'],
972
+ with: { source: 'change' },
973
+ output: 'reviewContext',
974
+ },
975
+ },
976
+ },
977
+ },
978
+ };
979
+ const result = await runWorkflow(config, 'githubContext', { workingDir: process.cwd() });
980
+ expect(result.output).toEqual(expect.stringContaining('## Diff Content'));
981
+ expect(result.output).toEqual(expect.stringContaining('### src/github.ts'));
982
+ expect(result.output).toEqual(expect.stringContaining('+github'));
983
+ });
984
+ it('filters review context to a requested file', async () => {
985
+ mocks.gitlabAdapter.getChangedFiles.mockResolvedValue([
986
+ {
987
+ filename: 'src/one.ts',
988
+ status: 'modified',
989
+ additions: 1,
990
+ deletions: 0,
991
+ patch: '@@ +1 @@\n+one',
992
+ },
993
+ {
994
+ filename: 'src/two.ts',
995
+ status: 'modified',
996
+ additions: 1,
997
+ deletions: 0,
998
+ patch: '@@ +1 @@\n+two',
999
+ },
1000
+ ]);
1001
+ const config = {
1002
+ ...baseConfig,
1003
+ workflows: {
1004
+ gitlabContext: {
1005
+ nodes: {
1006
+ change: {
1007
+ action: 'change-source',
1008
+ with: {
1009
+ type: 'gitlab-mr',
1010
+ project: 'group/repo',
1011
+ mr: 8,
1012
+ },
1013
+ output: 'change',
1014
+ },
1015
+ context: {
1016
+ action: 'review-context',
1017
+ needs: ['change'],
1018
+ with: { source: 'change', file: 'src/two.ts' },
1019
+ output: 'reviewContext',
1020
+ },
1021
+ },
1022
+ },
1023
+ },
1024
+ };
1025
+ const result = await runWorkflow(config, 'gitlabContext', { workingDir: process.cwd() });
1026
+ expect(result.output).toEqual(expect.stringContaining('### src/two.ts'));
1027
+ expect(result.output).toEqual(expect.stringContaining('+two'));
1028
+ expect(result.output).not.toEqual(expect.stringContaining('### src/one.ts'));
1029
+ });
1030
+ it('generates and posts a GitHub PR description from workflow artifacts', async () => {
1031
+ const projectRoot = createTempDir('drs-workflow-describe-');
1032
+ const config = {
1033
+ ...baseConfig,
1034
+ workflows: {
1035
+ githubDescribe: {
1036
+ nodes: {
1037
+ change: {
1038
+ action: 'change-source',
1039
+ with: {
1040
+ type: 'github-pr',
1041
+ owner: 'octocat',
1042
+ repo: 'hello-world',
1043
+ pr: 7,
1044
+ },
1045
+ output: 'change',
1046
+ },
1047
+ describe: {
1048
+ action: 'describe',
1049
+ needs: ['change'],
1050
+ with: { source: 'change', post: true },
1051
+ output: 'description',
1052
+ writes: '.drs/description.json',
1053
+ },
1054
+ },
1055
+ },
1056
+ },
1057
+ };
1058
+ const result = await runWorkflow(config, 'githubDescribe', {
1059
+ workingDir: projectRoot,
1060
+ debug: true,
1061
+ thinkingLevel: 'high',
1062
+ });
1063
+ expect(mocks.connectToRuntime).toHaveBeenCalledWith(config, projectRoot, expect.objectContaining({
1064
+ debug: true,
1065
+ thinkingLevel: 'high',
1066
+ }));
1067
+ expect(mocks.runDescribeIfEnabled).toHaveBeenCalledWith(mocks.runtimeClient, config, mocks.githubAdapter, 'octocat/hello-world', expect.objectContaining({ number: 7 }), [{ filename: 'src/github.ts', patch: '@@ +1 @@\n+github' }], true, projectRoot, true);
1068
+ expect(mocks.runtimeClient.shutdown).toHaveBeenCalled();
1069
+ expect(result.artifacts.description).toMatchObject({ title: 'Generated description' });
1070
+ expect(JSON.parse(readFileSync(join(projectRoot, '.drs/description.json'), 'utf-8'))).toEqual(result.artifacts.description);
1071
+ });
1072
+ it('updates an existing marked platform comment', async () => {
1073
+ mocks.githubAdapter.getComments.mockResolvedValue([
1074
+ { id: 9, body: '<!-- drs-comment-id: release-notes -->\nold body' },
1075
+ ]);
1076
+ const config = {
1077
+ ...baseConfig,
1078
+ workflows: {
1079
+ postComment: {
1080
+ nodes: {
1081
+ comment: {
1082
+ action: 'post-comment',
1083
+ input: 'new body',
1084
+ with: {
1085
+ platform: 'github',
1086
+ owner: 'octocat',
1087
+ repo: 'hello-world',
1088
+ pr: 7,
1089
+ marker: 'release-notes',
1090
+ },
1091
+ output: 'commentResult',
1092
+ },
1093
+ },
1094
+ },
1095
+ },
1096
+ };
1097
+ const result = await runWorkflow(config, 'postComment', { workingDir: process.cwd() });
1098
+ expect(mocks.githubAdapter.updateComment).toHaveBeenCalledWith('octocat/hello-world', 7, 9, '<!-- drs-comment-id: release-notes -->\nnew body');
1099
+ expect(mocks.githubAdapter.createComment).not.toHaveBeenCalled();
1100
+ expect(result.artifacts.commentResult).toMatchObject({
1101
+ platform: 'github',
1102
+ projectId: 'octocat/hello-world',
1103
+ prNumber: 7,
1104
+ marker: 'release-notes',
1105
+ operation: 'updated',
1106
+ });
1107
+ });
1108
+ it('posts GitHub review comments from workflow review artifacts', async () => {
1109
+ mocks.githubAdapter.getChangedFiles.mockResolvedValue([
1110
+ {
1111
+ filename: 'src/github.ts',
1112
+ status: 'modified',
1113
+ additions: 1,
1114
+ deletions: 0,
1115
+ patch: '@@ -0,0 +1 @@\n+github',
1116
+ },
1117
+ ]);
1118
+ mocks.executeReview.mockImplementation(async () => ({
1119
+ issues: [
1120
+ {
1121
+ category: 'QUALITY',
1122
+ severity: 'HIGH',
1123
+ title: 'Validate input',
1124
+ file: 'src/github.ts',
1125
+ line: 1,
1126
+ problem: 'Input is not validated.',
1127
+ solution: 'Validate it before use.',
1128
+ agent: 'review/quality',
1129
+ },
1130
+ ],
1131
+ summary: {
1132
+ filesReviewed: 1,
1133
+ issuesFound: 1,
1134
+ bySeverity: { CRITICAL: 0, HIGH: 1, MEDIUM: 0, LOW: 0 },
1135
+ byCategory: { SECURITY: 0, QUALITY: 1, STYLE: 0, PERFORMANCE: 0, DOCUMENTATION: 0 },
1136
+ },
1137
+ filesReviewed: 1,
1138
+ }));
1139
+ const config = {
1140
+ ...baseConfig,
1141
+ workflows: {
1142
+ githubReview: {
1143
+ nodes: {
1144
+ change: {
1145
+ action: 'change-source',
1146
+ with: {
1147
+ type: 'github-pr',
1148
+ owner: 'octocat',
1149
+ repo: 'hello-world',
1150
+ pr: 7,
1151
+ },
1152
+ output: 'change',
1153
+ },
1154
+ review: {
1155
+ action: 'review',
1156
+ needs: ['change'],
1157
+ with: { source: 'change' },
1158
+ output: 'review',
1159
+ },
1160
+ post: {
1161
+ action: 'post-review-comments',
1162
+ needs: ['review'],
1163
+ with: {
1164
+ source: 'change',
1165
+ review: 'review',
1166
+ },
1167
+ output: 'postResult',
1168
+ },
1169
+ },
1170
+ },
1171
+ },
1172
+ };
1173
+ const result = await runWorkflow(config, 'githubReview', { workingDir: process.cwd() });
1174
+ expect(mocks.githubAdapter.deleteComment).not.toHaveBeenCalled();
1175
+ expect(mocks.githubAdapter.createComment).toHaveBeenCalledWith('octocat/hello-world', 7, expect.stringContaining('<!-- drs-comment-id: drs-review-summary -->'));
1176
+ expect(mocks.githubAdapter.createBulkInlineComments).toHaveBeenCalledWith('octocat/hello-world', 7, [
1177
+ expect.objectContaining({
1178
+ body: expect.stringContaining('<!-- issue-fp: src/github.ts:1:QUALITY:Validate input -->'),
1179
+ position: {
1180
+ path: 'src/github.ts',
1181
+ line: 1,
1182
+ commitSha: 'abc123',
1183
+ },
1184
+ }),
1185
+ ]);
1186
+ expect(mocks.githubAdapter.addLabels).toHaveBeenCalledWith('octocat/hello-world', 7, [
1187
+ 'ai-reviewed',
1188
+ ]);
1189
+ expect(result.artifacts.postResult).toMatchObject({
1190
+ platform: 'github',
1191
+ projectId: 'octocat/hello-world',
1192
+ prNumber: 7,
1193
+ issues: 1,
1194
+ });
1195
+ });
1196
+ it('writes a GitLab code quality report from workflow review artifacts', async () => {
1197
+ const projectRoot = createTempDir('drs-workflow-code-quality-');
1198
+ mocks.executeReview.mockImplementation(async () => ({
1199
+ issues: [
1200
+ {
1201
+ category: 'QUALITY',
1202
+ severity: 'HIGH',
1203
+ title: 'Validate input',
1204
+ file: 'src/gitlab.ts',
1205
+ line: 1,
1206
+ problem: 'Input is not validated.',
1207
+ solution: 'Validate it before use.',
1208
+ agent: 'review/quality',
1209
+ },
1210
+ ],
1211
+ summary: {
1212
+ filesReviewed: 1,
1213
+ issuesFound: 1,
1214
+ bySeverity: { CRITICAL: 0, HIGH: 1, MEDIUM: 0, LOW: 0 },
1215
+ byCategory: { SECURITY: 0, QUALITY: 1, STYLE: 0, PERFORMANCE: 0, DOCUMENTATION: 0 },
1216
+ },
1217
+ filesReviewed: 1,
1218
+ }));
1219
+ const config = {
1220
+ ...baseConfig,
1221
+ workflows: {
1222
+ codeQuality: {
1223
+ nodes: {
1224
+ change: {
1225
+ action: 'change-source',
1226
+ with: {
1227
+ type: 'gitlab-mr',
1228
+ project: 'group/repo',
1229
+ mr: 8,
1230
+ },
1231
+ output: 'change',
1232
+ },
1233
+ review: {
1234
+ action: 'review',
1235
+ needs: ['change'],
1236
+ with: { source: 'change' },
1237
+ output: 'review',
1238
+ },
1239
+ report: {
1240
+ action: 'code-quality-report',
1241
+ needs: ['review'],
1242
+ with: {
1243
+ review: 'review',
1244
+ path: 'gl-code-quality-report.json',
1245
+ },
1246
+ output: 'codeQualityReport',
1247
+ },
1248
+ },
1249
+ },
1250
+ },
1251
+ };
1252
+ const result = await runWorkflow(config, 'codeQuality', { workingDir: projectRoot });
1253
+ const report = JSON.parse(readFileSync(join(projectRoot, 'gl-code-quality-report.json'), 'utf-8'));
1254
+ expect(report).toEqual([
1255
+ expect.objectContaining({
1256
+ check_name: 'drs-quality',
1257
+ severity: 'critical',
1258
+ location: {
1259
+ path: 'src/gitlab.ts',
1260
+ lines: { begin: 1 },
1261
+ },
1262
+ }),
1263
+ ]);
1264
+ expect(result.artifacts.codeQualityReport).toMatchObject({
1265
+ path: 'gl-code-quality-report.json',
1266
+ issues: 1,
1267
+ });
1268
+ });
1269
+ it('loads a GitLab MR change source and reviews it', async () => {
1270
+ const config = {
1271
+ ...baseConfig,
1272
+ review: {
1273
+ ...baseConfig.review,
1274
+ skipRepoCheck: true,
1275
+ skipBranchCheck: true,
1276
+ },
1277
+ workflows: {
1278
+ gitlabReview: {
1279
+ inputs: {
1280
+ project: '',
1281
+ mr: '',
1282
+ },
1283
+ nodes: {
1284
+ change: {
1285
+ action: 'change-source',
1286
+ with: {
1287
+ type: 'gitlab-mr',
1288
+ project: '{{inputs.project}}',
1289
+ mr: '{{inputs.mr}}',
1290
+ },
1291
+ output: 'change',
1292
+ },
1293
+ review: {
1294
+ action: 'review',
1295
+ needs: ['change'],
1296
+ with: { source: 'change' },
1297
+ output: 'reviewResult',
1298
+ },
1299
+ },
1300
+ },
1301
+ },
1302
+ };
1303
+ await runWorkflow(config, 'gitlabReview', {
1304
+ inputs: { project: 'group/repo', mr: '8' },
1305
+ workingDir: process.cwd(),
1306
+ });
1307
+ expect(mocks.createGitLabClient).toHaveBeenCalled();
1308
+ expect(mocks.GitLabPlatformAdapter).toHaveBeenCalled();
1309
+ expect(mocks.gitlabAdapter.getPullRequest).toHaveBeenCalledWith('group/repo', 8);
1310
+ expect(mocks.gitlabAdapter.getChangedFiles).toHaveBeenCalledWith('group/repo', 8);
1311
+ expect(mocks.enforceRepoBranchMatch).toHaveBeenCalledWith(process.cwd(), 'group/repo', expect.objectContaining({ number: 8 }), {
1312
+ skipRepoCheck: true,
1313
+ skipBranchCheck: true,
1314
+ });
1315
+ expect(mocks.executeReview).toHaveBeenCalledWith(config, expect.objectContaining({
1316
+ name: 'GitLab MR group/repo!8',
1317
+ files: ['src/gitlab.ts'],
1318
+ filesWithDiffs: [{ filename: 'src/gitlab.ts', patch: '@@ +1 @@\n+gitlab' }],
1319
+ context: expect.objectContaining({
1320
+ platform: 'gitlab',
1321
+ projectId: 'group/repo',
1322
+ }),
1323
+ }));
1324
+ });
1325
+ it('rejects empty GitLab MR aliases without falling through to mrIid', async () => {
1326
+ const config = {
1327
+ ...baseConfig,
1328
+ workflows: {
1329
+ gitlabReview: {
1330
+ inputs: {
1331
+ project: 'group/repo',
1332
+ mr: '',
1333
+ },
1334
+ nodes: {
1335
+ change: {
1336
+ action: 'change-source',
1337
+ with: {
1338
+ type: 'gitlab-mr',
1339
+ project: '{{inputs.project}}',
1340
+ mr: '{{inputs.mr}}',
1341
+ },
1342
+ output: 'change',
1343
+ },
1344
+ },
1345
+ },
1346
+ },
1347
+ };
1348
+ await expect(runWorkflow(config, 'gitlabReview')).rejects.toThrow('Workflow node "change" must define with.mr.');
1349
+ expect(mocks.gitlabAdapter.getPullRequest).not.toHaveBeenCalled();
1350
+ });
1351
+ it('rejects dependency cycles', async () => {
1352
+ const config = {
1353
+ ...baseConfig,
1354
+ workflows: {
1355
+ cyclic: {
1356
+ nodes: {
1357
+ first: { agent: 'task/first', input: 'first', needs: ['second'] },
1358
+ second: { agent: 'task/second', input: 'second', needs: ['first'] },
1359
+ },
1360
+ },
1361
+ },
1362
+ };
1363
+ await expect(runWorkflow(config, 'cyclic')).rejects.toThrow('dependency cycle');
1364
+ expect(mocks.runAgent).not.toHaveBeenCalled();
1365
+ });
1366
+ it('rejects workflows with invalid nodes config', async () => {
1367
+ const config = {
1368
+ ...baseConfig,
1369
+ workflows: {
1370
+ invalid: {
1371
+ nodes: 'not an object',
1372
+ },
1373
+ },
1374
+ };
1375
+ await expect(runWorkflow(config, 'invalid')).rejects.toThrow('Workflow "invalid" must define at least one node.');
1376
+ });
1377
+ it('rejects unknown template references', async () => {
1378
+ const config = {
1379
+ ...baseConfig,
1380
+ workflows: {
1381
+ badTemplate: {
1382
+ nodes: {
1383
+ summarize: { agent: 'task/summarizer', input: '{{inputs.missing}}' },
1384
+ },
1385
+ },
1386
+ },
1387
+ };
1388
+ await expect(runWorkflow(config, 'badTemplate')).rejects.toThrow('Unknown workflow template value "{{inputs.missing}}"');
1389
+ expect(mocks.runAgent).not.toHaveBeenCalled();
1390
+ });
1391
+ it('lists workflows with packaged source by default', () => {
1392
+ const config = loadConfig(process.cwd());
1393
+ const entries = listWorkflows(config, { workingDir: process.cwd() });
1394
+ expect(entries.some((entry) => entry.source === 'packaged' && !entry.overridden)).toBe(true);
1395
+ });
1396
+ it('lists project workflows that override packaged ones', () => {
1397
+ const projectRoot = createTempDir('drs-workflow-list-');
1398
+ mkdirSync(join(projectRoot, '.drs', 'workflows'), { recursive: true });
1399
+ writeFileSync(join(projectRoot, '.drs', 'workflows', 'local-review.yaml'), 'description: Project override\nnodes:\n step:\n action: write\n input: hi\n writes: out.txt\n', 'utf-8');
1400
+ const config = loadConfig(projectRoot);
1401
+ const entries = listWorkflows(config, { workingDir: projectRoot });
1402
+ const localReview = entries.find((entry) => entry.name === 'local-review');
1403
+ expect(localReview).toMatchObject({
1404
+ source: 'project',
1405
+ overridden: true,
1406
+ description: 'Project override',
1407
+ });
1408
+ });
1409
+ });
1410
+ //# sourceMappingURL=workflow.test.js.map