@mcoda/core 0.1.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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/api/AgentsApi.d.ts +36 -0
- package/dist/api/AgentsApi.d.ts.map +1 -0
- package/dist/api/AgentsApi.js +176 -0
- package/dist/api/QaTasksApi.d.ts +8 -0
- package/dist/api/QaTasksApi.d.ts.map +1 -0
- package/dist/api/QaTasksApi.js +36 -0
- package/dist/api/TasksApi.d.ts +7 -0
- package/dist/api/TasksApi.d.ts.map +1 -0
- package/dist/api/TasksApi.js +34 -0
- package/dist/config/ConfigService.d.ts +3 -0
- package/dist/config/ConfigService.d.ts.map +1 -0
- package/dist/config/ConfigService.js +2 -0
- package/dist/domain/dependencies/Dependency.d.ts +3 -0
- package/dist/domain/dependencies/Dependency.d.ts.map +1 -0
- package/dist/domain/dependencies/Dependency.js +2 -0
- package/dist/domain/epics/Epic.d.ts +3 -0
- package/dist/domain/epics/Epic.d.ts.map +1 -0
- package/dist/domain/epics/Epic.js +2 -0
- package/dist/domain/projects/Project.d.ts +3 -0
- package/dist/domain/projects/Project.d.ts.map +1 -0
- package/dist/domain/projects/Project.js +2 -0
- package/dist/domain/tasks/Task.d.ts +3 -0
- package/dist/domain/tasks/Task.d.ts.map +1 -0
- package/dist/domain/tasks/Task.js +2 -0
- package/dist/domain/userStories/UserStory.d.ts +3 -0
- package/dist/domain/userStories/UserStory.d.ts.map +1 -0
- package/dist/domain/userStories/UserStory.js +2 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/prompts/PdrPrompts.d.ts +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -0
- package/dist/prompts/PdrPrompts.js +21 -0
- package/dist/prompts/PromptLoader.d.ts +3 -0
- package/dist/prompts/PromptLoader.d.ts.map +1 -0
- package/dist/prompts/PromptLoader.js +2 -0
- package/dist/prompts/SdsPrompts.d.ts +5 -0
- package/dist/prompts/SdsPrompts.d.ts.map +1 -0
- package/dist/prompts/SdsPrompts.js +44 -0
- package/dist/services/agents/AgentManagementService.d.ts +3 -0
- package/dist/services/agents/AgentManagementService.d.ts.map +1 -0
- package/dist/services/agents/AgentManagementService.js +2 -0
- package/dist/services/agents/GatewayAgentService.d.ts +92 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -0
- package/dist/services/agents/GatewayAgentService.js +870 -0
- package/dist/services/agents/RoutingApiClient.d.ts +23 -0
- package/dist/services/agents/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/RoutingApiClient.js +62 -0
- package/dist/services/agents/RoutingService.d.ts +50 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -0
- package/dist/services/agents/RoutingService.js +386 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts +21 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/generated/RoutingApiClient.js +68 -0
- package/dist/services/backlog/BacklogService.d.ts +98 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -0
- package/dist/services/backlog/BacklogService.js +453 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +88 -0
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingService.js +675 -0
- package/dist/services/docs/DocsService.d.ts +82 -0
- package/dist/services/docs/DocsService.d.ts.map +1 -0
- package/dist/services/docs/DocsService.js +1631 -0
- package/dist/services/estimate/EstimateService.d.ts +12 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -0
- package/dist/services/estimate/EstimateService.js +103 -0
- package/dist/services/estimate/VelocityService.d.ts +19 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -0
- package/dist/services/estimate/VelocityService.js +237 -0
- package/dist/services/estimate/types.d.ts +30 -0
- package/dist/services/estimate/types.d.ts.map +1 -0
- package/dist/services/estimate/types.js +1 -0
- package/dist/services/execution/ExecutionService.d.ts +3 -0
- package/dist/services/execution/ExecutionService.d.ts.map +1 -0
- package/dist/services/execution/ExecutionService.js +2 -0
- package/dist/services/execution/QaFollowupService.d.ts +38 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -0
- package/dist/services/execution/QaFollowupService.js +236 -0
- package/dist/services/execution/QaProfileService.d.ts +22 -0
- package/dist/services/execution/QaProfileService.d.ts.map +1 -0
- package/dist/services/execution/QaProfileService.js +142 -0
- package/dist/services/execution/QaTasksService.d.ts +101 -0
- package/dist/services/execution/QaTasksService.d.ts.map +1 -0
- package/dist/services/execution/QaTasksService.js +1117 -0
- package/dist/services/execution/TaskSelectionService.d.ts +50 -0
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -0
- package/dist/services/execution/TaskSelectionService.js +281 -0
- package/dist/services/execution/TaskStateService.d.ts +19 -0
- package/dist/services/execution/TaskStateService.d.ts.map +1 -0
- package/dist/services/execution/TaskStateService.js +59 -0
- package/dist/services/execution/WorkOnTasksService.d.ts +80 -0
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -0
- package/dist/services/execution/WorkOnTasksService.js +1833 -0
- package/dist/services/jobs/JobInsightsService.d.ts +97 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -0
- package/dist/services/jobs/JobInsightsService.js +263 -0
- package/dist/services/jobs/JobResumeService.d.ts +16 -0
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -0
- package/dist/services/jobs/JobResumeService.js +113 -0
- package/dist/services/jobs/JobService.d.ts +149 -0
- package/dist/services/jobs/JobService.d.ts.map +1 -0
- package/dist/services/jobs/JobService.js +490 -0
- package/dist/services/jobs/JobsApiClient.d.ts +73 -0
- package/dist/services/jobs/JobsApiClient.d.ts.map +1 -0
- package/dist/services/jobs/JobsApiClient.js +67 -0
- package/dist/services/openapi/OpenApiService.d.ts +54 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -0
- package/dist/services/openapi/OpenApiService.js +503 -0
- package/dist/services/planning/CreateTasksService.d.ts +68 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -0
- package/dist/services/planning/CreateTasksService.js +989 -0
- package/dist/services/planning/KeyHelpers.d.ts +5 -0
- package/dist/services/planning/KeyHelpers.d.ts.map +1 -0
- package/dist/services/planning/KeyHelpers.js +62 -0
- package/dist/services/planning/PlanningService.d.ts +3 -0
- package/dist/services/planning/PlanningService.d.ts.map +1 -0
- package/dist/services/planning/PlanningService.js +2 -0
- package/dist/services/planning/RefineTasksService.d.ts +56 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -0
- package/dist/services/planning/RefineTasksService.js +1328 -0
- package/dist/services/review/CodeReviewService.d.ts +103 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -0
- package/dist/services/review/CodeReviewService.js +1187 -0
- package/dist/services/system/SystemUpdateService.d.ts +55 -0
- package/dist/services/system/SystemUpdateService.d.ts.map +1 -0
- package/dist/services/system/SystemUpdateService.js +136 -0
- package/dist/services/tasks/TaskApiResolver.d.ts +7 -0
- package/dist/services/tasks/TaskApiResolver.d.ts.map +1 -0
- package/dist/services/tasks/TaskApiResolver.js +41 -0
- package/dist/services/tasks/TaskDetailService.d.ts +106 -0
- package/dist/services/tasks/TaskDetailService.d.ts.map +1 -0
- package/dist/services/tasks/TaskDetailService.js +332 -0
- package/dist/services/telemetry/TelemetryService.d.ts +53 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -0
- package/dist/services/telemetry/TelemetryService.js +434 -0
- package/dist/workspace/WorkspaceManager.d.ts +35 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -0
- package/dist/workspace/WorkspaceManager.js +201 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { AgentService } from "@mcoda/agents";
|
|
4
|
+
import { DocdexClient, VcsClient } from "@mcoda/integrations";
|
|
5
|
+
import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
|
|
6
|
+
import { PathHelper } from "@mcoda/shared";
|
|
7
|
+
import { JobService } from "../jobs/JobService.js";
|
|
8
|
+
import { TaskSelectionService } from "../execution/TaskSelectionService.js";
|
|
9
|
+
import { TaskStateService } from "../execution/TaskStateService.js";
|
|
10
|
+
import { BacklogService } from "../backlog/BacklogService.js";
|
|
11
|
+
import yaml from "yaml";
|
|
12
|
+
import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
|
|
13
|
+
import { RoutingService } from "../agents/RoutingService.js";
|
|
14
|
+
const DEFAULT_BASE_BRANCH = "mcoda-dev";
|
|
15
|
+
const REVIEW_DIR = (workspaceRoot, jobId) => path.join(workspaceRoot, ".mcoda", "jobs", jobId, "review");
|
|
16
|
+
const STATE_PATH = (workspaceRoot, jobId) => path.join(REVIEW_DIR(workspaceRoot, jobId), "state.json");
|
|
17
|
+
const DEFAULT_CODE_REVIEW_PROMPT = [
|
|
18
|
+
"You are the code-review agent. Before reviewing, query docdex with the task key and feature keywords (MCP `docdex_search` limit 4–8 or CLI `docdexd query --repo <repo> --query \"<term>\" --limit 6 --snippets=false`). If results look stale, reindex (`docdex_index` or `docdexd index --repo <repo>`) then re-run. Fetch snippets via `docdex_open` or `/snippet/:doc_id?text_only=true` only for specific hits.",
|
|
19
|
+
"Use docdex snippets to verify contracts (data shapes, offline scope, accessibility/perf guardrails, acceptance criteria). Call out mismatches, missing tests, and undocumented changes.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
|
|
22
|
+
const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
|
|
23
|
+
const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
|
|
24
|
+
const parseJsonOutput = (raw) => {
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
const fenced = trimmed.replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
|
|
27
|
+
const candidates = [trimmed, fenced];
|
|
28
|
+
for (const candidate of candidates) {
|
|
29
|
+
const start = candidate.indexOf("{");
|
|
30
|
+
const end = candidate.lastIndexOf("}");
|
|
31
|
+
if (start === -1 || end === -1 || end <= start)
|
|
32
|
+
continue;
|
|
33
|
+
const slice = candidate.slice(start, end + 1);
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(slice);
|
|
36
|
+
return { ...parsed, raw: raw };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
};
|
|
44
|
+
const summarizeComments = (comments) => {
|
|
45
|
+
if (!comments.length)
|
|
46
|
+
return "No prior comments.";
|
|
47
|
+
return comments
|
|
48
|
+
.map((c) => {
|
|
49
|
+
const loc = c.file ? `${c.file}${c.line ? `:${c.line}` : ""}` : "";
|
|
50
|
+
return `- [${c.category ?? "general"}] ${loc ? `${loc} ` : ""}${c.body}`;
|
|
51
|
+
})
|
|
52
|
+
.join("\n");
|
|
53
|
+
};
|
|
54
|
+
const JSON_CONTRACT = `{
|
|
55
|
+
"decision": "approve | changes_requested | block | info_only",
|
|
56
|
+
"summary": "short textual summary",
|
|
57
|
+
"findings": [
|
|
58
|
+
{
|
|
59
|
+
"type": "bug | style | test | docs | contract | security | other",
|
|
60
|
+
"severity": "info | low | medium | high | critical",
|
|
61
|
+
"file": "relative/path/to/file.ext",
|
|
62
|
+
"line": 123,
|
|
63
|
+
"message": "Clear reviewer message",
|
|
64
|
+
"suggestedFix": "Optional suggested change"
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"testRecommendations": ["Optional test or QA recommendations per task"]
|
|
68
|
+
}`;
|
|
69
|
+
const normalizeSingleLine = (value, fallback) => {
|
|
70
|
+
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
|
71
|
+
return trimmed || fallback;
|
|
72
|
+
};
|
|
73
|
+
const buildStandardReviewComment = (params) => {
|
|
74
|
+
const decision = params.decision ?? (params.error ? "error" : "info_only");
|
|
75
|
+
const statusAfter = params.statusAfter ?? params.statusBefore;
|
|
76
|
+
const summary = normalizeSingleLine(params.summary, params.error ? "Review failed." : "No summary provided.");
|
|
77
|
+
const error = normalizeSingleLine(params.error, "none");
|
|
78
|
+
const followups = params.followupTaskKeys && params.followupTaskKeys.length ? params.followupTaskKeys.join(", ") : "none";
|
|
79
|
+
return [
|
|
80
|
+
"[code-review]",
|
|
81
|
+
`decision: ${decision}`,
|
|
82
|
+
`status_before: ${params.statusBefore}`,
|
|
83
|
+
`status_after: ${statusAfter}`,
|
|
84
|
+
`findings: ${params.findingsCount}`,
|
|
85
|
+
`summary: ${summary}`,
|
|
86
|
+
`followups: ${followups}`,
|
|
87
|
+
`error: ${error}`,
|
|
88
|
+
].join("\n");
|
|
89
|
+
};
|
|
90
|
+
export class CodeReviewService {
|
|
91
|
+
constructor(workspace, deps) {
|
|
92
|
+
this.workspace = workspace;
|
|
93
|
+
this.deps = deps;
|
|
94
|
+
this.taskLogSeq = new Map();
|
|
95
|
+
this.selectionService = deps.selectionService ?? new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
96
|
+
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
97
|
+
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
98
|
+
this.routingService = deps.routingService;
|
|
99
|
+
}
|
|
100
|
+
static async create(workspace) {
|
|
101
|
+
const repo = await GlobalRepository.create();
|
|
102
|
+
const agentService = new AgentService(repo);
|
|
103
|
+
const routingService = await RoutingService.create();
|
|
104
|
+
const docdex = new DocdexClient({
|
|
105
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
106
|
+
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
107
|
+
});
|
|
108
|
+
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
109
|
+
const jobService = new JobService(workspace, workspaceRepo);
|
|
110
|
+
const selectionService = new TaskSelectionService(workspace, workspaceRepo);
|
|
111
|
+
const stateService = new TaskStateService(workspaceRepo);
|
|
112
|
+
const vcsClient = new VcsClient();
|
|
113
|
+
return new CodeReviewService(workspace, {
|
|
114
|
+
agentService,
|
|
115
|
+
docdex,
|
|
116
|
+
jobService,
|
|
117
|
+
workspaceRepo,
|
|
118
|
+
selectionService,
|
|
119
|
+
stateService,
|
|
120
|
+
repo,
|
|
121
|
+
vcsClient,
|
|
122
|
+
routingService,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async close() {
|
|
126
|
+
const maybeClose = async (target) => {
|
|
127
|
+
try {
|
|
128
|
+
if (target?.close)
|
|
129
|
+
await target.close();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
await maybeClose(this.deps.selectionService);
|
|
136
|
+
await maybeClose(this.deps.stateService);
|
|
137
|
+
await maybeClose(this.deps.agentService);
|
|
138
|
+
await maybeClose(this.deps.jobService);
|
|
139
|
+
await maybeClose(this.deps.repo);
|
|
140
|
+
await maybeClose(this.deps.workspaceRepo);
|
|
141
|
+
await maybeClose(this.deps.routingService);
|
|
142
|
+
await maybeClose(this.deps.docdex);
|
|
143
|
+
}
|
|
144
|
+
async readPromptFiles(paths) {
|
|
145
|
+
const contents = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const promptPath of paths) {
|
|
148
|
+
try {
|
|
149
|
+
const content = await fs.readFile(promptPath, "utf8");
|
|
150
|
+
const trimmed = content.trim();
|
|
151
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
152
|
+
contents.push(trimmed);
|
|
153
|
+
seen.add(trimmed);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* optional prompt */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return contents;
|
|
161
|
+
}
|
|
162
|
+
async ensureMcoda() {
|
|
163
|
+
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
164
|
+
const gitignorePath = path.join(this.workspace.workspaceRoot, ".gitignore");
|
|
165
|
+
const entry = ".mcoda/\n";
|
|
166
|
+
try {
|
|
167
|
+
const content = await fs.readFile(gitignorePath, "utf8");
|
|
168
|
+
if (!content.includes(".mcoda/")) {
|
|
169
|
+
await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${entry}`, "utf8");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
await fs.writeFile(gitignorePath, entry, "utf8");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async loadPrompts(agentId) {
|
|
177
|
+
const mcodaPromptPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "code-reviewer.md");
|
|
178
|
+
const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "code-reviewer.md");
|
|
179
|
+
try {
|
|
180
|
+
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
181
|
+
await fs.access(mcodaPromptPath);
|
|
182
|
+
console.info(`[code-review] using existing code-reviewer prompt at ${mcodaPromptPath}`);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
try {
|
|
186
|
+
await fs.access(workspacePromptPath);
|
|
187
|
+
await fs.copyFile(workspacePromptPath, mcodaPromptPath);
|
|
188
|
+
console.info(`[code-review] copied code-reviewer prompt to ${mcodaPromptPath}`);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
console.info(`[code-review] no code-reviewer prompt found at ${workspacePromptPath}; writing default prompt to ${mcodaPromptPath}`);
|
|
192
|
+
await fs.writeFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const filePrompts = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
196
|
+
const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
|
|
197
|
+
const mergedCommandPrompt = (() => {
|
|
198
|
+
const parts = [...filePrompts];
|
|
199
|
+
if (agentPrompts?.commandPrompts?.["code-review"]) {
|
|
200
|
+
parts.push(agentPrompts.commandPrompts["code-review"]);
|
|
201
|
+
}
|
|
202
|
+
if (!parts.length)
|
|
203
|
+
parts.push(DEFAULT_CODE_REVIEW_PROMPT);
|
|
204
|
+
return parts.filter(Boolean).join("\n\n");
|
|
205
|
+
})();
|
|
206
|
+
return {
|
|
207
|
+
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
208
|
+
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
209
|
+
commandPrompt: mergedCommandPrompt || undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async loadRunbookAndChecklists() {
|
|
213
|
+
const extras = [];
|
|
214
|
+
const runbookPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "commands", "code-review.md");
|
|
215
|
+
try {
|
|
216
|
+
const content = await fs.readFile(runbookPath, "utf8");
|
|
217
|
+
extras.push(content);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* optional */
|
|
221
|
+
}
|
|
222
|
+
const checklistDir = path.join(this.workspace.workspaceRoot, ".mcoda", "checklists");
|
|
223
|
+
try {
|
|
224
|
+
const entries = await fs.readdir(checklistDir);
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (!entry.endsWith(".md"))
|
|
227
|
+
continue;
|
|
228
|
+
const content = await fs.readFile(path.join(checklistDir, entry), "utf8");
|
|
229
|
+
extras.push(content);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* optional */
|
|
234
|
+
}
|
|
235
|
+
return extras;
|
|
236
|
+
}
|
|
237
|
+
async resolveAgent(agentName) {
|
|
238
|
+
const resolved = await this.routingService.resolveAgentForCommand({
|
|
239
|
+
workspace: this.workspace,
|
|
240
|
+
commandName: "code-review",
|
|
241
|
+
overrideAgentSlug: agentName,
|
|
242
|
+
});
|
|
243
|
+
return resolved.agent;
|
|
244
|
+
}
|
|
245
|
+
async selectTasksViaApi(filters) {
|
|
246
|
+
// Prefer the backlog/task OpenAPI surface (via BacklogService) to mirror API filtering semantics.
|
|
247
|
+
const backlog = await BacklogService.create(this.workspace);
|
|
248
|
+
try {
|
|
249
|
+
const result = await backlog.getBacklog({
|
|
250
|
+
projectKey: filters.projectKey,
|
|
251
|
+
epicKey: filters.epicKey,
|
|
252
|
+
storyKey: filters.storyKey,
|
|
253
|
+
statuses: filters.statusFilter,
|
|
254
|
+
verbose: true,
|
|
255
|
+
});
|
|
256
|
+
let tasks = result.summary.tasks;
|
|
257
|
+
if (filters.taskKeys?.length) {
|
|
258
|
+
const allowed = new Set(filters.taskKeys);
|
|
259
|
+
tasks = tasks.filter((t) => allowed.has(t.task_key));
|
|
260
|
+
}
|
|
261
|
+
if (filters.limit && filters.limit > 0) {
|
|
262
|
+
tasks = tasks.slice(0, filters.limit);
|
|
263
|
+
}
|
|
264
|
+
const ids = tasks.map((t) => t.task_id);
|
|
265
|
+
const detailed = await this.deps.workspaceRepo.getTasksWithRelations(ids);
|
|
266
|
+
// Preserve ordering from backlog
|
|
267
|
+
const order = new Map(ids.map((id, idx) => [id, idx]));
|
|
268
|
+
return detailed.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
await backlog.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async persistState(jobId, state) {
|
|
275
|
+
const dir = REVIEW_DIR(this.workspace.workspaceRoot, jobId);
|
|
276
|
+
await fs.mkdir(dir, { recursive: true });
|
|
277
|
+
await fs.writeFile(STATE_PATH(this.workspace.workspaceRoot, jobId), JSON.stringify({ schema_version: 1, job_id: jobId, updated_at: new Date().toISOString(), ...state }, null, 2), "utf8");
|
|
278
|
+
}
|
|
279
|
+
async loadState(jobId) {
|
|
280
|
+
try {
|
|
281
|
+
const raw = await fs.readFile(STATE_PATH(this.workspace.workspaceRoot, jobId), "utf8");
|
|
282
|
+
return JSON.parse(raw);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async writeCheckpoint(jobId, stage, details) {
|
|
289
|
+
await this.deps.jobService.writeCheckpoint(jobId, { stage, timestamp: new Date().toISOString(), details });
|
|
290
|
+
}
|
|
291
|
+
componentHintsFromPaths(paths) {
|
|
292
|
+
const hints = new Set();
|
|
293
|
+
for (const p of paths) {
|
|
294
|
+
const segments = p.split("/").filter(Boolean);
|
|
295
|
+
if (segments.length) {
|
|
296
|
+
hints.add(segments[0]);
|
|
297
|
+
if (segments.length > 1)
|
|
298
|
+
hints.add(`${segments[0]}/${segments[1]}`);
|
|
299
|
+
}
|
|
300
|
+
const file = p.split("/").pop();
|
|
301
|
+
if (file) {
|
|
302
|
+
const base = file.split(".")[0];
|
|
303
|
+
hints.add(base);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return Array.from(hints).slice(0, 8);
|
|
307
|
+
}
|
|
308
|
+
async gatherDocContext(taskTitle, paths, acceptance) {
|
|
309
|
+
const snippets = [];
|
|
310
|
+
const warnings = [];
|
|
311
|
+
const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
|
|
312
|
+
for (const query of queries) {
|
|
313
|
+
try {
|
|
314
|
+
const docs = await this.deps.docdex.search({
|
|
315
|
+
query,
|
|
316
|
+
profile: "workspace-code",
|
|
317
|
+
});
|
|
318
|
+
snippets.push(...docs.slice(0, 2).map((doc) => {
|
|
319
|
+
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
320
|
+
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
321
|
+
return `- [${doc.docType ?? "doc"}] ${ref}: ${content}`;
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
warnings.push(`docdex search failed for ${query}: ${error.message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { snippets: Array.from(new Set(snippets)), warnings };
|
|
329
|
+
}
|
|
330
|
+
buildReviewPrompt(params) {
|
|
331
|
+
const parts = [];
|
|
332
|
+
if (params.systemPrompts.length) {
|
|
333
|
+
parts.push(params.systemPrompts.join("\n\n"));
|
|
334
|
+
}
|
|
335
|
+
const acceptance = params.task.acceptanceCriteria && params.task.acceptanceCriteria.length ? params.task.acceptanceCriteria.join(" | ") : "none provided";
|
|
336
|
+
parts.push([
|
|
337
|
+
`Task ${params.task.key}: ${params.task.title}`,
|
|
338
|
+
`Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
|
|
339
|
+
`Epic description: ${params.task.epicDescription ? params.task.epicDescription : "none"}`,
|
|
340
|
+
`Story: ${params.task.storyKey ?? ""} ${params.task.storyTitle ?? ""}`.trim(),
|
|
341
|
+
`Story description: ${params.task.storyDescription ? params.task.storyDescription : "none"}`,
|
|
342
|
+
`Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
|
|
343
|
+
`Task description: ${params.task.description ? params.task.description : "none"}`,
|
|
344
|
+
`History:\n${params.historySummary}`,
|
|
345
|
+
`Acceptance criteria: ${acceptance}`,
|
|
346
|
+
params.docContext.length ? `Doc context (docdex excerpts):\n${params.docContext.join("\n")}` : "Doc context: none",
|
|
347
|
+
params.openapiSnippet
|
|
348
|
+
? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${params.openapiSnippet}`
|
|
349
|
+
: "OpenAPI: not provided; avoid inventing endpoints.",
|
|
350
|
+
params.checklists && params.checklists.length ? `Review checklists/runbook:\n${params.checklists.join("\n\n")}` : "Checklists: none",
|
|
351
|
+
"Diff:\n" + (params.diff || "(no diff)"),
|
|
352
|
+
"Respond with STRICT JSON only, matching:\n" + JSON_CONTRACT,
|
|
353
|
+
"Rules: honor OpenAPI contracts; cite doc context where relevant; do not add prose outside JSON.",
|
|
354
|
+
].join("\n"));
|
|
355
|
+
return parts.join("\n\n");
|
|
356
|
+
}
|
|
357
|
+
async buildHistorySummary(taskId) {
|
|
358
|
+
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
359
|
+
sourceCommands: ["work-on-tasks", "code-review", "qa-tasks"],
|
|
360
|
+
limit: 10,
|
|
361
|
+
});
|
|
362
|
+
const lastReview = await this.deps.workspaceRepo.getLatestTaskReview(taskId);
|
|
363
|
+
const parts = [];
|
|
364
|
+
if (lastReview) {
|
|
365
|
+
parts.push(`Last review decision: ${lastReview.decision}${lastReview.summary ? ` — ${lastReview.summary}` : ""}`);
|
|
366
|
+
}
|
|
367
|
+
if (comments.length) {
|
|
368
|
+
parts.push("Recent comments:");
|
|
369
|
+
parts.push(summarizeComments(comments.map((c) => ({
|
|
370
|
+
category: c.category ?? undefined,
|
|
371
|
+
body: c.body,
|
|
372
|
+
file: c.file ?? undefined,
|
|
373
|
+
line: c.line ?? undefined,
|
|
374
|
+
}))));
|
|
375
|
+
const unresolved = comments.filter((c) => !c.resolvedAt);
|
|
376
|
+
if (unresolved.length) {
|
|
377
|
+
parts.push(`Unresolved items: ${unresolved.length}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!parts.length)
|
|
381
|
+
return "No prior review or QA history.";
|
|
382
|
+
return parts.join("\n");
|
|
383
|
+
}
|
|
384
|
+
extractPathsFromDiff(diff) {
|
|
385
|
+
const regex = /^(?:\+\+\+ b\/|\-\-\- a\/)([^\s]+)$/gm;
|
|
386
|
+
const paths = new Set();
|
|
387
|
+
let match;
|
|
388
|
+
while ((match = regex.exec(diff)) !== null) {
|
|
389
|
+
const raw = match[1]?.trim();
|
|
390
|
+
if (raw && raw !== "/dev/null")
|
|
391
|
+
paths.add(raw.replace(/^a\//, "").replace(/^b\//, ""));
|
|
392
|
+
}
|
|
393
|
+
return Array.from(paths);
|
|
394
|
+
}
|
|
395
|
+
async buildOpenApiSlice(changedPaths, acceptance) {
|
|
396
|
+
const openapiPath = path.join(this.workspace.workspaceRoot, "openapi", "mcoda.yaml");
|
|
397
|
+
try {
|
|
398
|
+
const content = await fs.readFile(openapiPath, "utf8");
|
|
399
|
+
const parsed = yaml.parse(content);
|
|
400
|
+
const pathHints = this.componentHintsFromPaths(changedPaths);
|
|
401
|
+
const criteriaHints = (acceptance ?? []).map((c) => c.toLowerCase()).slice(0, 5);
|
|
402
|
+
const matches = {};
|
|
403
|
+
if (parsed?.paths) {
|
|
404
|
+
for (const [apiPath, ops] of Object.entries(parsed.paths)) {
|
|
405
|
+
const lowerPath = apiPath.toLowerCase();
|
|
406
|
+
const hit = pathHints.some((h) => lowerPath.includes(h.toLowerCase())) ||
|
|
407
|
+
criteriaHints.some((h) => lowerPath.includes(h)) ||
|
|
408
|
+
(!pathHints.length && !criteriaHints.length);
|
|
409
|
+
if (hit) {
|
|
410
|
+
matches[apiPath] = ops;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const schemaMatches = {};
|
|
415
|
+
if (parsed?.components?.schemas) {
|
|
416
|
+
for (const [name, schema] of Object.entries(parsed.components.schemas)) {
|
|
417
|
+
const lower = name.toLowerCase();
|
|
418
|
+
if (pathHints.some((h) => lower.includes(h.toLowerCase()))) {
|
|
419
|
+
schemaMatches[name] = schema;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!Object.keys(matches).length && !Object.keys(schemaMatches).length) {
|
|
424
|
+
return content.slice(0, 4000);
|
|
425
|
+
}
|
|
426
|
+
const slice = {
|
|
427
|
+
openapi: parsed.openapi ?? "3.0.0",
|
|
428
|
+
info: parsed.info,
|
|
429
|
+
paths: matches,
|
|
430
|
+
components: Object.keys(schemaMatches).length ? { schemas: schemaMatches } : undefined,
|
|
431
|
+
};
|
|
432
|
+
const rendered = yaml.stringify(slice);
|
|
433
|
+
return rendered.slice(0, 8000);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async buildDiff(task, baseRef, fileScope) {
|
|
440
|
+
const branch = task.vcsBranch;
|
|
441
|
+
await this.vcs.ensureRepo(this.workspace.workspaceRoot);
|
|
442
|
+
const paths = fileScope.length ? fileScope : undefined;
|
|
443
|
+
const commitSha = task.vcsLastCommitSha;
|
|
444
|
+
if (commitSha) {
|
|
445
|
+
try {
|
|
446
|
+
const diff = await this.vcs.diff(this.workspace.workspaceRoot, `${commitSha}^`, commitSha, paths);
|
|
447
|
+
return { diff, source: "commit", commitSha };
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
if (!branch) {
|
|
451
|
+
throw new Error(`Task branch missing and commit diff failed: ${error.message}`);
|
|
452
|
+
}
|
|
453
|
+
const fallback = await this.vcs.diff(this.workspace.workspaceRoot, baseRef, branch, paths);
|
|
454
|
+
return {
|
|
455
|
+
diff: fallback,
|
|
456
|
+
source: "branch",
|
|
457
|
+
commitSha,
|
|
458
|
+
warning: `Failed to diff commit ${commitSha}; fell back to branch ${branch}.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!branch)
|
|
463
|
+
throw new Error("Task branch missing");
|
|
464
|
+
const diff = await this.vcs.diff(this.workspace.workspaceRoot, baseRef, branch, paths);
|
|
465
|
+
return { diff, source: "branch" };
|
|
466
|
+
}
|
|
467
|
+
async writeReviewSummaryComment(params) {
|
|
468
|
+
const body = buildStandardReviewComment({
|
|
469
|
+
decision: params.decision,
|
|
470
|
+
statusBefore: params.statusBefore,
|
|
471
|
+
statusAfter: params.statusAfter,
|
|
472
|
+
findingsCount: params.findingsCount,
|
|
473
|
+
summary: params.summary,
|
|
474
|
+
followupTaskKeys: params.followupTaskKeys,
|
|
475
|
+
error: params.error,
|
|
476
|
+
});
|
|
477
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
478
|
+
taskId: params.task.id,
|
|
479
|
+
taskRunId: params.taskRunId,
|
|
480
|
+
jobId: params.jobId,
|
|
481
|
+
sourceCommand: "code-review",
|
|
482
|
+
authorType: "agent",
|
|
483
|
+
authorAgentId: params.agentId,
|
|
484
|
+
category: "review_summary",
|
|
485
|
+
body,
|
|
486
|
+
createdAt: new Date().toISOString(),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
async persistContext(jobId, taskId, context) {
|
|
490
|
+
const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "context");
|
|
491
|
+
await fs.mkdir(dir, { recursive: true });
|
|
492
|
+
await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, created_at: new Date().toISOString(), ...context }, null, 2), "utf8");
|
|
493
|
+
}
|
|
494
|
+
async persistDiff(jobId, taskId, diff) {
|
|
495
|
+
const dir = path.join(REVIEW_DIR(this.workspace.workspaceRoot, jobId), "diffs");
|
|
496
|
+
await fs.mkdir(dir, { recursive: true });
|
|
497
|
+
await fs.writeFile(path.join(dir, `${taskId}.diff`), diff, "utf8");
|
|
498
|
+
// structured review diff snapshot
|
|
499
|
+
const files = [];
|
|
500
|
+
let current = null;
|
|
501
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
502
|
+
if (line.startsWith("diff --git")) {
|
|
503
|
+
if (current) {
|
|
504
|
+
files.push(current);
|
|
505
|
+
current = null;
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const fileHeader = line.match(/^(\+\+\+|---)\s+[ab]\/(.+)$/);
|
|
510
|
+
if (fileHeader) {
|
|
511
|
+
if (current)
|
|
512
|
+
files.push(current);
|
|
513
|
+
current = { path: fileHeader[2], hunks: [] };
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (line.startsWith("@@")) {
|
|
517
|
+
if (current)
|
|
518
|
+
current.hunks.push(line);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (current)
|
|
522
|
+
current.hunks.push(line);
|
|
523
|
+
}
|
|
524
|
+
if (current)
|
|
525
|
+
files.push(current);
|
|
526
|
+
await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, files }, null, 2), "utf8");
|
|
527
|
+
}
|
|
528
|
+
severityToPriority(severity) {
|
|
529
|
+
if (!severity)
|
|
530
|
+
return null;
|
|
531
|
+
const normalized = severity.toLowerCase();
|
|
532
|
+
const order = { critical: 1, high: 2, medium: 3, low: 4, info: 5 };
|
|
533
|
+
return order[normalized] ?? null;
|
|
534
|
+
}
|
|
535
|
+
shouldCreateFollowupTask(decision, finding) {
|
|
536
|
+
// SDS rule: create follow-ups for blocking/changes_requested decisions or critical/high issues,
|
|
537
|
+
// and for contract/security/bug types at medium+ severity. Do not create for approve+low/info.
|
|
538
|
+
const sev = (finding.severity ?? "").toLowerCase();
|
|
539
|
+
const type = (finding.type ?? "").toLowerCase();
|
|
540
|
+
const decisionRequestsChange = decision === "changes_requested" || decision === "block";
|
|
541
|
+
if (decisionRequestsChange && sev !== "info")
|
|
542
|
+
return true;
|
|
543
|
+
if (["critical", "high"].includes(sev))
|
|
544
|
+
return true;
|
|
545
|
+
if (["bug", "security", "contract"].includes(type) && !["info", "low"].includes(sev))
|
|
546
|
+
return true;
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
buildFollowupTitle(task, finding, generatedKey) {
|
|
550
|
+
const base = (finding.message ?? "Review follow-up").split("\n")[0]?.trim() ?? "Review follow-up";
|
|
551
|
+
const prefix = finding.file ? `${finding.file}: ` : "";
|
|
552
|
+
const raw = `${prefix}${base}`;
|
|
553
|
+
const truncated = raw.length > 140 ? `${raw.slice(0, 137)}...` : raw;
|
|
554
|
+
return truncated || `Follow-up ${generatedKey} for ${task.key}`;
|
|
555
|
+
}
|
|
556
|
+
buildFollowupDescription(task, finding, decision) {
|
|
557
|
+
const lines = [
|
|
558
|
+
`Auto-created from code review of ${task.key}. Decision: ${decision ?? "n/a"}.`,
|
|
559
|
+
finding.message ? `Finding: ${finding.message}` : undefined,
|
|
560
|
+
finding.file ? `Location: ${finding.file}${finding.line ? `:${finding.line}` : ""}` : undefined,
|
|
561
|
+
finding.severity ? `Severity: ${finding.severity}` : undefined,
|
|
562
|
+
finding.type ? `Category: ${finding.type}` : undefined,
|
|
563
|
+
finding.suggestedFix ? `Suggested fix: ${finding.suggestedFix}` : undefined,
|
|
564
|
+
`Story: ${task.storyKey ?? task.userStoryId}, Epic: ${task.epicKey ?? task.epicId}`,
|
|
565
|
+
].filter(Boolean);
|
|
566
|
+
return lines.join("\n");
|
|
567
|
+
}
|
|
568
|
+
async ensureGenericContainers(projectId) {
|
|
569
|
+
const epicCandidates = ["epic-bugs", "epic-issues"];
|
|
570
|
+
let epic;
|
|
571
|
+
for (const key of epicCandidates) {
|
|
572
|
+
epic = await this.deps.workspaceRepo.getEpicByKey(projectId, key);
|
|
573
|
+
if (epic)
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
if (!epic) {
|
|
577
|
+
const [createdEpic] = await this.deps.workspaceRepo.insertEpics([
|
|
578
|
+
{
|
|
579
|
+
projectId,
|
|
580
|
+
key: epicCandidates[0],
|
|
581
|
+
title: "Bug Backlog",
|
|
582
|
+
description: "Generic epic for code review follow-up issues",
|
|
583
|
+
metadata: { source: "code-review", autoGenerated: true },
|
|
584
|
+
},
|
|
585
|
+
], true);
|
|
586
|
+
epic = createdEpic;
|
|
587
|
+
}
|
|
588
|
+
const storyCandidates = ["us-bugs", "us-issues"];
|
|
589
|
+
let story;
|
|
590
|
+
for (const key of storyCandidates) {
|
|
591
|
+
story = await this.deps.workspaceRepo.getStoryByKey(epic.id, key);
|
|
592
|
+
if (story)
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
if (!story) {
|
|
596
|
+
const [createdStory] = await this.deps.workspaceRepo.insertStories([
|
|
597
|
+
{
|
|
598
|
+
projectId,
|
|
599
|
+
epicId: epic.id,
|
|
600
|
+
key: storyCandidates[0],
|
|
601
|
+
title: "Review issues",
|
|
602
|
+
description: "Auto-created story for code review findings",
|
|
603
|
+
acceptanceCriteria: "Track, fix, and verify issues found during reviews.",
|
|
604
|
+
metadata: { source: "code-review", autoGenerated: true },
|
|
605
|
+
},
|
|
606
|
+
], true);
|
|
607
|
+
story = createdStory;
|
|
608
|
+
}
|
|
609
|
+
return { epic, story };
|
|
610
|
+
}
|
|
611
|
+
async createFollowupTasksForFindings(params) {
|
|
612
|
+
const actionable = params.findings.filter((f) => this.shouldCreateFollowupTask(params.decision, f));
|
|
613
|
+
if (!actionable.length)
|
|
614
|
+
return [];
|
|
615
|
+
const useGeneric = actionable.some((f) => !f.file && !f.line);
|
|
616
|
+
const genericContainers = useGeneric ? await this.ensureGenericContainers(params.task.projectId) : undefined;
|
|
617
|
+
const inserts = [];
|
|
618
|
+
const generators = new Map();
|
|
619
|
+
const ensureKey = async (storyId, storyKey) => {
|
|
620
|
+
let entry = generators.get(storyId);
|
|
621
|
+
if (!entry) {
|
|
622
|
+
const existing = new Set(await this.deps.workspaceRepo.listTaskKeys(storyId));
|
|
623
|
+
entry = { gen: createTaskKeyGenerator(storyKey, existing), keys: existing, storyKey };
|
|
624
|
+
generators.set(storyId, entry);
|
|
625
|
+
}
|
|
626
|
+
const key = entry.gen();
|
|
627
|
+
entry.keys.add(key);
|
|
628
|
+
return key;
|
|
629
|
+
};
|
|
630
|
+
for (const finding of actionable) {
|
|
631
|
+
const genericTarget = !finding.file && !finding.line && genericContainers;
|
|
632
|
+
const storyId = genericTarget ? genericContainers.story.id : params.task.userStoryId;
|
|
633
|
+
const storyKey = genericTarget ? genericContainers.story.key : params.task.storyKey ?? genericContainers?.story.key ?? "US-AUTO";
|
|
634
|
+
const epicId = genericTarget ? genericContainers.epic.id : params.task.epicId;
|
|
635
|
+
const taskKey = await ensureKey(storyId, storyKey);
|
|
636
|
+
inserts.push({
|
|
637
|
+
projectId: params.task.projectId,
|
|
638
|
+
epicId,
|
|
639
|
+
userStoryId: storyId,
|
|
640
|
+
key: taskKey,
|
|
641
|
+
title: this.buildFollowupTitle(params.task, finding, taskKey),
|
|
642
|
+
description: this.buildFollowupDescription(params.task, finding, params.decision),
|
|
643
|
+
type: finding.type ?? (params.decision === "changes_requested" || params.decision === "block" ? "bug" : "issue"),
|
|
644
|
+
status: "not_started",
|
|
645
|
+
storyPoints: null,
|
|
646
|
+
priority: this.severityToPriority(finding.severity),
|
|
647
|
+
metadata: {
|
|
648
|
+
source: "code-review",
|
|
649
|
+
source_task_id: params.task.id,
|
|
650
|
+
source_task_key: params.task.key,
|
|
651
|
+
source_job_id: params.jobId,
|
|
652
|
+
source_command_run_id: params.commandRunId,
|
|
653
|
+
source_task_run_id: params.taskRunId,
|
|
654
|
+
severity: finding.severity,
|
|
655
|
+
type: finding.type,
|
|
656
|
+
file: finding.file,
|
|
657
|
+
line: finding.line,
|
|
658
|
+
suggestedFix: finding.suggestedFix,
|
|
659
|
+
generic: genericTarget ? true : false,
|
|
660
|
+
decision: params.decision,
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const created = await this.deps.workspaceRepo.insertTasks(inserts, true);
|
|
665
|
+
for (let i = 0; i < created.length; i += 1) {
|
|
666
|
+
const createdTask = created[i];
|
|
667
|
+
const sourceFinding = actionable[i];
|
|
668
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
669
|
+
taskRunId: params.taskRunId,
|
|
670
|
+
sequence: this.sequenceForTask(params.taskRunId),
|
|
671
|
+
timestamp: new Date().toISOString(),
|
|
672
|
+
source: "followup_task",
|
|
673
|
+
message: `Created follow-up task ${createdTask.key}`,
|
|
674
|
+
details: { targetTaskId: createdTask.id, sourceFinding },
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return created;
|
|
678
|
+
}
|
|
679
|
+
async reviewTasks(request) {
|
|
680
|
+
await this.ensureMcoda();
|
|
681
|
+
const agentStream = request.agentStream !== false;
|
|
682
|
+
const baseRef = request.baseRef ?? this.workspace.config?.branch ?? DEFAULT_BASE_BRANCH;
|
|
683
|
+
const statusFilter = request.statusFilter && request.statusFilter.length ? request.statusFilter : ["ready_to_review"];
|
|
684
|
+
let state;
|
|
685
|
+
const commandRun = await this.deps.jobService.startCommandRun("code-review", request.projectKey, {
|
|
686
|
+
taskIds: request.taskKeys,
|
|
687
|
+
gitBaseBranch: baseRef,
|
|
688
|
+
jobId: request.resumeJobId,
|
|
689
|
+
});
|
|
690
|
+
let jobId = request.resumeJobId;
|
|
691
|
+
let selectedTaskIds = [];
|
|
692
|
+
let warnings = [];
|
|
693
|
+
let selectedTasks = [];
|
|
694
|
+
if (request.resumeJobId) {
|
|
695
|
+
const job = await this.deps.jobService.getJob(request.resumeJobId);
|
|
696
|
+
if (!job)
|
|
697
|
+
throw new Error(`Job not found: ${request.resumeJobId}`);
|
|
698
|
+
if ((job.commandName ?? job.type) !== "code-review" && job.type !== "review") {
|
|
699
|
+
throw new Error(`Job ${request.resumeJobId} is not a code-review job`);
|
|
700
|
+
}
|
|
701
|
+
state = await this.loadState(request.resumeJobId);
|
|
702
|
+
selectedTaskIds = state?.selectedTaskIds ?? (Array.isArray(job.payload?.selection) ? job.payload.selection : []);
|
|
703
|
+
if (!selectedTaskIds.length) {
|
|
704
|
+
throw new Error("Resume requested but no task selection found in job payload");
|
|
705
|
+
}
|
|
706
|
+
await this.deps.jobService.updateJobStatus(job.id, "running", {
|
|
707
|
+
totalItems: job.totalItems ?? selectedTaskIds.length,
|
|
708
|
+
processedItems: state?.reviewed.length ?? 0,
|
|
709
|
+
});
|
|
710
|
+
selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
try {
|
|
714
|
+
selectedTasks = await this.selectTasksViaApi({
|
|
715
|
+
projectKey: request.projectKey,
|
|
716
|
+
epicKey: request.epicKey,
|
|
717
|
+
storyKey: request.storyKey,
|
|
718
|
+
taskKeys: request.taskKeys,
|
|
719
|
+
statusFilter,
|
|
720
|
+
limit: request.limit,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
const selection = await this.selectionService.selectTasks({
|
|
725
|
+
projectKey: request.projectKey,
|
|
726
|
+
epicKey: request.epicKey,
|
|
727
|
+
storyKey: request.storyKey,
|
|
728
|
+
taskKeys: request.taskKeys,
|
|
729
|
+
statusFilter,
|
|
730
|
+
limit: request.limit,
|
|
731
|
+
});
|
|
732
|
+
warnings = [...selection.warnings];
|
|
733
|
+
selectedTasks = selection.ordered.map((t) => t.task);
|
|
734
|
+
}
|
|
735
|
+
selectedTaskIds = selectedTasks.map((t) => t.id);
|
|
736
|
+
const job = await this.deps.jobService.startJob("review", commandRun.id, request.projectKey, {
|
|
737
|
+
commandName: "code-review",
|
|
738
|
+
payload: {
|
|
739
|
+
projectKey: request.projectKey,
|
|
740
|
+
epicKey: request.epicKey,
|
|
741
|
+
storyKey: request.storyKey,
|
|
742
|
+
tasks: request.taskKeys,
|
|
743
|
+
statusFilter,
|
|
744
|
+
baseRef,
|
|
745
|
+
selection: selectedTaskIds,
|
|
746
|
+
dryRun: request.dryRun ?? false,
|
|
747
|
+
agent: request.agentName,
|
|
748
|
+
agentStream,
|
|
749
|
+
},
|
|
750
|
+
totalItems: selectedTaskIds.length,
|
|
751
|
+
processedItems: 0,
|
|
752
|
+
});
|
|
753
|
+
jobId = job.id;
|
|
754
|
+
state = {
|
|
755
|
+
baseRef,
|
|
756
|
+
statusFilter,
|
|
757
|
+
selectedTaskIds,
|
|
758
|
+
contextBuilt: [],
|
|
759
|
+
reviewed: [],
|
|
760
|
+
};
|
|
761
|
+
await this.persistState(jobId, state);
|
|
762
|
+
await this.writeCheckpoint(jobId, "tasks_selected", { tasks: selectedTaskIds, baseRef, statusFilter });
|
|
763
|
+
}
|
|
764
|
+
if (!jobId) {
|
|
765
|
+
throw new Error("Failed to resolve job id for code-review");
|
|
766
|
+
}
|
|
767
|
+
if (!state) {
|
|
768
|
+
state = {
|
|
769
|
+
baseRef,
|
|
770
|
+
statusFilter,
|
|
771
|
+
selectedTaskIds,
|
|
772
|
+
contextBuilt: [],
|
|
773
|
+
reviewed: [],
|
|
774
|
+
};
|
|
775
|
+
await this.persistState(jobId, state);
|
|
776
|
+
}
|
|
777
|
+
if (selectedTaskIds.length === 0) {
|
|
778
|
+
await this.deps.jobService.updateJobStatus(jobId, "completed", { totalItems: 0, processedItems: 0 });
|
|
779
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "succeeded");
|
|
780
|
+
return { jobId, commandRunId: commandRun.id, tasks: [], warnings };
|
|
781
|
+
}
|
|
782
|
+
const tasks = selectedTasks.length && selectedTaskIds.length === selectedTasks.length
|
|
783
|
+
? selectedTasks
|
|
784
|
+
: await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
785
|
+
const agent = await this.resolveAgent(request.agentName);
|
|
786
|
+
const prompts = await this.loadPrompts(agent.id);
|
|
787
|
+
const extras = await this.loadRunbookAndChecklists();
|
|
788
|
+
const systemPrompts = [prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
|
|
789
|
+
const results = [];
|
|
790
|
+
for (const task of tasks) {
|
|
791
|
+
const statusBefore = task.status;
|
|
792
|
+
const taskRun = await this.deps.workspaceRepo.createTaskRun({
|
|
793
|
+
taskId: task.id,
|
|
794
|
+
command: "code-review",
|
|
795
|
+
jobId,
|
|
796
|
+
commandRunId: commandRun.id,
|
|
797
|
+
agentId: agent.id,
|
|
798
|
+
status: "running",
|
|
799
|
+
startedAt: new Date().toISOString(),
|
|
800
|
+
storyPointsAtRun: task.storyPoints ?? null,
|
|
801
|
+
gitBranch: task.vcsBranch ?? null,
|
|
802
|
+
gitBaseBranch: task.vcsBaseBranch ?? null,
|
|
803
|
+
gitCommitSha: task.vcsLastCommitSha ?? null,
|
|
804
|
+
});
|
|
805
|
+
const findings = [];
|
|
806
|
+
let decision;
|
|
807
|
+
let statusAfter;
|
|
808
|
+
const followupCreated = [];
|
|
809
|
+
// Debug visibility: show prompts/task details for this run
|
|
810
|
+
const systemPrompt = systemPrompts.join("\n\n");
|
|
811
|
+
try {
|
|
812
|
+
const metadata = task.metadata ?? {};
|
|
813
|
+
const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
|
|
814
|
+
const diffResult = await this.buildDiff(task, state?.baseRef ?? baseRef, allowedFiles);
|
|
815
|
+
const diff = diffResult.diff;
|
|
816
|
+
if (diffResult.warning)
|
|
817
|
+
warnings.push(diffResult.warning);
|
|
818
|
+
await this.persistDiff(jobId, task.id, diff);
|
|
819
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
820
|
+
taskRunId: taskRun.id,
|
|
821
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
822
|
+
timestamp: new Date().toISOString(),
|
|
823
|
+
source: "context_git_diff",
|
|
824
|
+
message: "Git diff computed",
|
|
825
|
+
details: {
|
|
826
|
+
baseRef: state?.baseRef ?? baseRef,
|
|
827
|
+
branch: task.vcsBranch,
|
|
828
|
+
commitSha: diffResult.commitSha,
|
|
829
|
+
diffSource: diffResult.source,
|
|
830
|
+
allowedFiles,
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
const historySummary = await this.buildHistorySummary(task.id);
|
|
834
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
835
|
+
taskRunId: taskRun.id,
|
|
836
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
837
|
+
timestamp: new Date().toISOString(),
|
|
838
|
+
source: "context_history",
|
|
839
|
+
message: "Loaded task history",
|
|
840
|
+
});
|
|
841
|
+
const changedPaths = this.extractPathsFromDiff(diff);
|
|
842
|
+
const docLinks = await this.gatherDocContext(task.title, changedPaths.length ? changedPaths : allowedFiles, task.acceptanceCriteria);
|
|
843
|
+
if (docLinks.warnings.length)
|
|
844
|
+
warnings.push(...docLinks.warnings);
|
|
845
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
846
|
+
taskRunId: taskRun.id,
|
|
847
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
848
|
+
timestamp: new Date().toISOString(),
|
|
849
|
+
source: "context_docdex",
|
|
850
|
+
message: "Docdex context gathered",
|
|
851
|
+
details: { snippets: docLinks.snippets },
|
|
852
|
+
});
|
|
853
|
+
const openapiSnippet = await this.buildOpenApiSlice(changedPaths, task.acceptanceCriteria);
|
|
854
|
+
if (!openapiSnippet) {
|
|
855
|
+
warnings.push("OpenAPI spec not found; proceeding without snippet");
|
|
856
|
+
}
|
|
857
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
858
|
+
taskRunId: taskRun.id,
|
|
859
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
860
|
+
timestamp: new Date().toISOString(),
|
|
861
|
+
source: "context_openapi",
|
|
862
|
+
message: "OpenAPI snippet loaded",
|
|
863
|
+
});
|
|
864
|
+
const prompt = this.buildReviewPrompt({
|
|
865
|
+
systemPrompts,
|
|
866
|
+
task,
|
|
867
|
+
diff,
|
|
868
|
+
docContext: docLinks.snippets,
|
|
869
|
+
openapiSnippet,
|
|
870
|
+
historySummary,
|
|
871
|
+
baseRef: state?.baseRef ?? baseRef,
|
|
872
|
+
branch: task.vcsBranch ?? undefined,
|
|
873
|
+
});
|
|
874
|
+
const separator = "============================================================";
|
|
875
|
+
const deps = Array.isArray(task.dependencyKeys) && task.dependencyKeys.length
|
|
876
|
+
? task.dependencyKeys
|
|
877
|
+
: Array.isArray(task.metadata?.depends_on)
|
|
878
|
+
? task.metadata.depends_on
|
|
879
|
+
: [];
|
|
880
|
+
console.info(separator);
|
|
881
|
+
console.info("[code-review] START OF TASK");
|
|
882
|
+
console.info(`[code-review] Task key: ${task.key}`);
|
|
883
|
+
console.info(`[code-review] Title: ${task.title ?? "(none)"}`);
|
|
884
|
+
console.info(`[code-review] Description: ${task.description ?? "(none)"}`);
|
|
885
|
+
console.info(`[code-review] Story points: ${typeof task.storyPoints === "number" ? task.storyPoints : "(none)"}`);
|
|
886
|
+
console.info(`[code-review] Dependencies: ${deps.length ? deps.join(", ") : "(none available)"}`);
|
|
887
|
+
if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length) {
|
|
888
|
+
console.info(`[code-review] Acceptance criteria:\n- ${task.acceptanceCriteria.join("\n- ")}`);
|
|
889
|
+
}
|
|
890
|
+
console.info(`[code-review] System prompt used:\n${systemPrompt || "(none)"}`);
|
|
891
|
+
console.info(`[code-review] Task prompt used:\n${prompt}`);
|
|
892
|
+
console.info(separator);
|
|
893
|
+
await this.persistContext(jobId, task.id, {
|
|
894
|
+
historySummary,
|
|
895
|
+
docdex: docLinks.snippets,
|
|
896
|
+
openapiSnippet,
|
|
897
|
+
changedPaths,
|
|
898
|
+
});
|
|
899
|
+
state?.contextBuilt.push(task.id);
|
|
900
|
+
await this.persistState(jobId, state);
|
|
901
|
+
await this.writeCheckpoint(jobId, "context_built", { contextBuilt: state?.contextBuilt ?? [], schema_version: 1 });
|
|
902
|
+
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
|
|
903
|
+
const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
|
|
904
|
+
const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
|
|
905
|
+
const tokensTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
|
|
906
|
+
await this.deps.jobService.recordTokenUsage({
|
|
907
|
+
workspaceId: this.workspace.workspaceId,
|
|
908
|
+
agentId: agent.id,
|
|
909
|
+
modelName: tokenMeta?.model ?? agent.defaultModel ?? undefined,
|
|
910
|
+
jobId,
|
|
911
|
+
commandRunId: commandRun.id,
|
|
912
|
+
taskRunId: taskRun.id,
|
|
913
|
+
taskId: task.id,
|
|
914
|
+
projectId: task.projectId,
|
|
915
|
+
tokensPrompt,
|
|
916
|
+
tokensCompletion,
|
|
917
|
+
tokensTotal,
|
|
918
|
+
durationSeconds,
|
|
919
|
+
timestamp: new Date().toISOString(),
|
|
920
|
+
metadata: { commandName: "code-review", phase, action: phase },
|
|
921
|
+
});
|
|
922
|
+
};
|
|
923
|
+
let agentOutput = "";
|
|
924
|
+
let durationSeconds = 0;
|
|
925
|
+
const started = Date.now();
|
|
926
|
+
let lastStreamMeta;
|
|
927
|
+
if (agentStream && this.deps.agentService.invokeStream) {
|
|
928
|
+
const stream = await this.deps.agentService.invokeStream(agent.id, { input: prompt, metadata: { taskKey: task.key } });
|
|
929
|
+
for await (const chunk of stream) {
|
|
930
|
+
agentOutput += chunk.output ?? "";
|
|
931
|
+
lastStreamMeta = chunk.metadata ?? lastStreamMeta;
|
|
932
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
933
|
+
taskRunId: taskRun.id,
|
|
934
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
935
|
+
timestamp: new Date().toISOString(),
|
|
936
|
+
source: "agent",
|
|
937
|
+
message: chunk.output ?? "",
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
const response = await this.deps.agentService.invoke(agent.id, { input: prompt, metadata: { taskKey: task.key } });
|
|
944
|
+
agentOutput = response.output ?? "";
|
|
945
|
+
durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
946
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
947
|
+
taskRunId: taskRun.id,
|
|
948
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
source: "agent",
|
|
951
|
+
message: agentOutput,
|
|
952
|
+
});
|
|
953
|
+
lastStreamMeta = response.metadata;
|
|
954
|
+
}
|
|
955
|
+
const tokenMetaMain = lastStreamMeta
|
|
956
|
+
? {
|
|
957
|
+
tokensPrompt: typeof lastStreamMeta.tokensPrompt === "number" ? lastStreamMeta.tokensPrompt : lastStreamMeta.tokens_prompt,
|
|
958
|
+
tokensCompletion: typeof lastStreamMeta.tokensCompletion === "number"
|
|
959
|
+
? lastStreamMeta.tokensCompletion
|
|
960
|
+
: lastStreamMeta.tokens_completion,
|
|
961
|
+
tokensTotal: typeof lastStreamMeta.tokensTotal === "number" ? lastStreamMeta.tokensTotal : lastStreamMeta.tokens_total,
|
|
962
|
+
model: (lastStreamMeta.model ?? lastStreamMeta.model_name ?? null),
|
|
963
|
+
}
|
|
964
|
+
: undefined;
|
|
965
|
+
await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
|
|
966
|
+
let parsed = parseJsonOutput(agentOutput);
|
|
967
|
+
if (!parsed) {
|
|
968
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
969
|
+
taskRunId: taskRun.id,
|
|
970
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
971
|
+
timestamp: new Date().toISOString(),
|
|
972
|
+
source: "agent",
|
|
973
|
+
message: "Invalid JSON from agent; retrying once with stricter instructions.",
|
|
974
|
+
});
|
|
975
|
+
const retryPrompt = `${prompt}\n\nRespond ONLY with valid JSON matching the schema above. Do not include prose or fences.`;
|
|
976
|
+
const retryStarted = Date.now();
|
|
977
|
+
const retryResp = await this.deps.agentService.invoke(agent.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } });
|
|
978
|
+
const retryOutput = retryResp.output ?? "";
|
|
979
|
+
const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
|
|
980
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
981
|
+
taskRunId: taskRun.id,
|
|
982
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
983
|
+
timestamp: new Date().toISOString(),
|
|
984
|
+
source: "agent_retry",
|
|
985
|
+
message: retryOutput,
|
|
986
|
+
});
|
|
987
|
+
const retryTokenMeta = retryResp.metadata
|
|
988
|
+
? {
|
|
989
|
+
tokensPrompt: typeof retryResp.metadata.tokensPrompt === "number"
|
|
990
|
+
? retryResp.metadata.tokensPrompt
|
|
991
|
+
: retryResp.metadata.tokens_prompt,
|
|
992
|
+
tokensCompletion: typeof retryResp.metadata.tokensCompletion === "number"
|
|
993
|
+
? retryResp.metadata.tokensCompletion
|
|
994
|
+
: retryResp.metadata.tokens_completion,
|
|
995
|
+
tokensTotal: typeof retryResp.metadata.tokensTotal === "number"
|
|
996
|
+
? retryResp.metadata.tokensTotal
|
|
997
|
+
: retryResp.metadata.tokens_total,
|
|
998
|
+
model: (retryResp.metadata.model ?? retryResp.metadata.model_name ?? null),
|
|
999
|
+
}
|
|
1000
|
+
: undefined;
|
|
1001
|
+
await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta);
|
|
1002
|
+
parsed = parseJsonOutput(retryOutput);
|
|
1003
|
+
agentOutput = retryOutput;
|
|
1004
|
+
}
|
|
1005
|
+
if (!parsed) {
|
|
1006
|
+
throw new Error("Agent output did not contain valid JSON review result after retry");
|
|
1007
|
+
}
|
|
1008
|
+
parsed.raw = agentOutput;
|
|
1009
|
+
decision = parsed.decision;
|
|
1010
|
+
findings.push(...(parsed.findings ?? []));
|
|
1011
|
+
const followups = await this.createFollowupTasksForFindings({
|
|
1012
|
+
task,
|
|
1013
|
+
findings: parsed.findings ?? [],
|
|
1014
|
+
decision: parsed.decision,
|
|
1015
|
+
jobId,
|
|
1016
|
+
commandRunId: commandRun.id,
|
|
1017
|
+
taskRunId: taskRun.id,
|
|
1018
|
+
});
|
|
1019
|
+
if (followups.length) {
|
|
1020
|
+
followupCreated.push(...followups.map((t) => ({
|
|
1021
|
+
taskId: t.id,
|
|
1022
|
+
taskKey: t.key,
|
|
1023
|
+
epicId: t.epicId,
|
|
1024
|
+
userStoryId: t.userStoryId,
|
|
1025
|
+
generic: t?.metadata?.generic ? true : undefined,
|
|
1026
|
+
})));
|
|
1027
|
+
warnings.push(`Created follow-up tasks for ${task.key}: ${followups.map((t) => t.key).join(", ")}`);
|
|
1028
|
+
}
|
|
1029
|
+
let taskStatusUpdate = statusBefore;
|
|
1030
|
+
if (!request.dryRun) {
|
|
1031
|
+
if (parsed.decision === "approve") {
|
|
1032
|
+
await this.stateService.markReadyToQa(task);
|
|
1033
|
+
taskStatusUpdate = "ready_to_qa";
|
|
1034
|
+
}
|
|
1035
|
+
else if (parsed.decision === "changes_requested") {
|
|
1036
|
+
await this.stateService.returnToInProgress(task);
|
|
1037
|
+
taskStatusUpdate = "in_progress";
|
|
1038
|
+
}
|
|
1039
|
+
else if (parsed.decision === "block") {
|
|
1040
|
+
await this.stateService.markBlocked(task, "review_blocked");
|
|
1041
|
+
taskStatusUpdate = "blocked";
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1046
|
+
taskRunId: taskRun.id,
|
|
1047
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1048
|
+
timestamp: new Date().toISOString(),
|
|
1049
|
+
source: "state",
|
|
1050
|
+
message: "Dry-run enabled; skipping status transition.",
|
|
1051
|
+
details: { requestedDecision: parsed.decision },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
statusAfter = taskStatusUpdate;
|
|
1055
|
+
for (const finding of parsed.findings ?? []) {
|
|
1056
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
1057
|
+
taskId: task.id,
|
|
1058
|
+
taskRunId: taskRun.id,
|
|
1059
|
+
jobId,
|
|
1060
|
+
sourceCommand: "code-review",
|
|
1061
|
+
authorType: "agent",
|
|
1062
|
+
authorAgentId: agent.id,
|
|
1063
|
+
category: finding.type ?? "other",
|
|
1064
|
+
file: finding.file,
|
|
1065
|
+
line: finding.line,
|
|
1066
|
+
pathHint: finding.file,
|
|
1067
|
+
body: finding.message + (finding.suggestedFix ? `\n\nSuggested fix: ${finding.suggestedFix}` : ""),
|
|
1068
|
+
metadata: {
|
|
1069
|
+
severity: finding.severity,
|
|
1070
|
+
suggestedFix: finding.suggestedFix,
|
|
1071
|
+
},
|
|
1072
|
+
createdAt: new Date().toISOString(),
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
await this.writeReviewSummaryComment({
|
|
1076
|
+
task,
|
|
1077
|
+
taskRunId: taskRun.id,
|
|
1078
|
+
jobId,
|
|
1079
|
+
agentId: agent.id,
|
|
1080
|
+
statusBefore,
|
|
1081
|
+
statusAfter: statusAfter ?? statusBefore,
|
|
1082
|
+
decision: parsed.decision,
|
|
1083
|
+
summary: parsed.summary,
|
|
1084
|
+
findingsCount: parsed.findings?.length ?? 0,
|
|
1085
|
+
followupTaskKeys: followupCreated.map((t) => t.taskKey),
|
|
1086
|
+
});
|
|
1087
|
+
await this.deps.workspaceRepo.createTaskReview({
|
|
1088
|
+
taskId: task.id,
|
|
1089
|
+
jobId,
|
|
1090
|
+
agentId: agent.id,
|
|
1091
|
+
modelName: agent.defaultModel ?? undefined,
|
|
1092
|
+
decision: parsed.decision,
|
|
1093
|
+
summary: parsed.summary ?? undefined,
|
|
1094
|
+
findingsJson: parsed.findings ?? [],
|
|
1095
|
+
testRecommendationsJson: parsed.testRecommendations ?? [],
|
|
1096
|
+
createdAt: new Date().toISOString(),
|
|
1097
|
+
});
|
|
1098
|
+
await this.stateService.recordReviewMetadata(task, {
|
|
1099
|
+
decision: parsed.decision,
|
|
1100
|
+
agentId: agent.id,
|
|
1101
|
+
modelName: agent.defaultModel ?? null,
|
|
1102
|
+
jobId,
|
|
1103
|
+
});
|
|
1104
|
+
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1105
|
+
status: "succeeded",
|
|
1106
|
+
finishedAt: new Date().toISOString(),
|
|
1107
|
+
runContext: { decision: parsed.decision },
|
|
1108
|
+
});
|
|
1109
|
+
state?.reviewed.push({ taskId: task.id, decision: parsed.decision });
|
|
1110
|
+
await this.persistState(jobId, state);
|
|
1111
|
+
await this.writeCheckpoint(jobId, "review_applied", { reviewed: state?.reviewed ?? [], schema_version: 1 });
|
|
1112
|
+
}
|
|
1113
|
+
catch (error) {
|
|
1114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1115
|
+
results.push({ taskId: task.id, taskKey: task.key, statusBefore, findings, error: message, followupTasks: followupCreated });
|
|
1116
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1117
|
+
taskRunId: taskRun.id,
|
|
1118
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1119
|
+
timestamp: new Date().toISOString(),
|
|
1120
|
+
source: "review_error",
|
|
1121
|
+
message,
|
|
1122
|
+
});
|
|
1123
|
+
try {
|
|
1124
|
+
await this.writeReviewSummaryComment({
|
|
1125
|
+
task,
|
|
1126
|
+
taskRunId: taskRun.id,
|
|
1127
|
+
jobId,
|
|
1128
|
+
agentId: agent.id,
|
|
1129
|
+
statusBefore,
|
|
1130
|
+
statusAfter: statusBefore,
|
|
1131
|
+
findingsCount: findings.length,
|
|
1132
|
+
error: message,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1137
|
+
taskRunId: taskRun.id,
|
|
1138
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1139
|
+
timestamp: new Date().toISOString(),
|
|
1140
|
+
source: "review_error",
|
|
1141
|
+
message: "Failed to write review summary comment.",
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1145
|
+
status: "failed",
|
|
1146
|
+
finishedAt: new Date().toISOString(),
|
|
1147
|
+
});
|
|
1148
|
+
state?.reviewed.push({ taskId: task.id, error: message });
|
|
1149
|
+
await this.persistState(jobId, state);
|
|
1150
|
+
await this.writeCheckpoint(jobId, "review_applied", { reviewed: state?.reviewed ?? [], schema_version: 1 });
|
|
1151
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1152
|
+
processedItems: state?.reviewed.length ?? 0,
|
|
1153
|
+
});
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
results.push({
|
|
1157
|
+
taskId: task.id,
|
|
1158
|
+
taskKey: task.key,
|
|
1159
|
+
statusBefore,
|
|
1160
|
+
statusAfter,
|
|
1161
|
+
decision,
|
|
1162
|
+
findings,
|
|
1163
|
+
followupTasks: followupCreated,
|
|
1164
|
+
});
|
|
1165
|
+
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1166
|
+
processedItems: state?.reviewed.length ?? 0,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
await this.deps.jobService.updateJobStatus(jobId, "completed", {
|
|
1170
|
+
processedItems: state?.reviewed.length ?? selectedTaskIds.length,
|
|
1171
|
+
totalItems: selectedTaskIds.length,
|
|
1172
|
+
});
|
|
1173
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "succeeded");
|
|
1174
|
+
return {
|
|
1175
|
+
jobId,
|
|
1176
|
+
commandRunId: commandRun.id,
|
|
1177
|
+
tasks: results,
|
|
1178
|
+
warnings,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
sequenceForTask(taskRunId) {
|
|
1182
|
+
const current = this.taskLogSeq.get(taskRunId) ?? 0;
|
|
1183
|
+
const next = current + 1;
|
|
1184
|
+
this.taskLogSeq.set(taskRunId, next);
|
|
1185
|
+
return next;
|
|
1186
|
+
}
|
|
1187
|
+
}
|