@mcoda/core 0.1.8 → 0.1.11
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 +3 -0
- package/README.md +2 -2
- package/dist/api/AgentsApi.d.ts +9 -1
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +201 -6
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +6 -0
- package/dist/api/TasksApi.d.ts.map +1 -1
- package/dist/api/TasksApi.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +9 -1
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +9 -0
- package/dist/services/agents/AgentRatingFormula.d.ts +27 -0
- package/dist/services/agents/AgentRatingFormula.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingFormula.js +45 -0
- package/dist/services/agents/AgentRatingService.d.ts +60 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -0
- package/dist/services/agents/AgentRatingService.js +363 -0
- package/dist/services/agents/GatewayAgentService.d.ts +11 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +525 -84
- package/dist/services/agents/GatewayHandoff.d.ts +11 -0
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -0
- package/dist/services/agents/GatewayHandoff.js +141 -0
- package/dist/services/agents/RoutingService.d.ts +1 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -1
- package/dist/services/agents/RoutingService.js +4 -4
- package/dist/services/backlog/BacklogService.d.ts +23 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -1
- package/dist/services/backlog/BacklogService.js +62 -7
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts +12 -0
- package/dist/services/backlog/TaskOrderingHeuristics.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingHeuristics.js +56 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +17 -4
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +538 -79
- package/dist/services/docs/DocInventory.d.ts +11 -0
- package/dist/services/docs/DocInventory.d.ts.map +1 -0
- package/dist/services/docs/DocInventory.js +230 -0
- package/dist/services/docs/DocgenRunContext.d.ts +59 -0
- package/dist/services/docs/DocgenRunContext.d.ts.map +1 -0
- package/dist/services/docs/DocgenRunContext.js +4 -0
- package/dist/services/docs/DocsService.d.ts +70 -3
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +1930 -89
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts +23 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentGraph.js +78 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts +19 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.d.ts.map +1 -0
- package/dist/services/docs/alignment/DocAlignmentPatcher.js +222 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts +57 -0
- package/dist/services/docs/patch/DocPatchEngine.d.ts.map +1 -0
- package/dist/services/docs/patch/DocPatchEngine.js +331 -0
- package/dist/services/docs/review/Glossary.d.ts +16 -0
- package/dist/services/docs/review/Glossary.d.ts.map +1 -0
- package/dist/services/docs/review/Glossary.js +47 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts +3 -0
- package/dist/services/docs/review/ReviewReportRenderer.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportRenderer.js +133 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts +39 -0
- package/dist/services/docs/review/ReviewReportSchema.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewReportSchema.js +47 -0
- package/dist/services/docs/review/ReviewTypes.d.ts +76 -0
- package/dist/services/docs/review/ReviewTypes.d.ts.map +1 -0
- package/dist/services/docs/review/ReviewTypes.js +94 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts +7 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/AdminOpenApiSpecGate.js +93 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts +7 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/ApiPathConsistencyGate.js +308 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts +8 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/BuildReadyCompletenessGate.js +278 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts +8 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/DeploymentBlueprintGate.js +487 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts +8 -0
- package/dist/services/docs/review/gates/NoMaybesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/NoMaybesGate.js +145 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiCoverageGate.js +266 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenApiSchemaSanityGate.js +59 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/OpenQuestionsGate.js +200 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrInterfacesGate.js +159 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts +8 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOpenQuestionsGate.js +129 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts +7 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PdrOwnershipGate.js +169 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts +10 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/PlaceholderArtifactGate.js +261 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts +6 -0
- package/dist/services/docs/review/gates/RfpConsentGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpConsentGate.js +127 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts +7 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/RfpDefinitionGate.js +173 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsAdaptersGate.js +196 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsDecisionsGate.js +89 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsOpsGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsOpsGate.js +162 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SdsPolicyTelemetryGate.js +166 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlRequiredTablesGate.js +273 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts +7 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/SqlSyntaxGate.js +203 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts +9 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.d.ts.map +1 -0
- package/dist/services/docs/review/gates/TerminologyNormalizationGate.js +217 -0
- package/dist/services/docs/review/glossary.json +47 -0
- package/dist/services/estimate/EstimateService.d.ts +2 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -1
- package/dist/services/estimate/EstimateService.js +66 -18
- package/dist/services/estimate/VelocityService.d.ts +4 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -1
- package/dist/services/estimate/VelocityService.js +179 -36
- package/dist/services/estimate/types.d.ts +1 -0
- package/dist/services/estimate/types.d.ts.map +1 -1
- package/dist/services/execution/GatewayTrioService.d.ts +200 -0
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -0
- package/dist/services/execution/GatewayTrioService.js +2492 -0
- package/dist/services/execution/QaApiRunner.d.ts +30 -0
- package/dist/services/execution/QaApiRunner.d.ts.map +1 -0
- package/dist/services/execution/QaApiRunner.js +881 -0
- package/dist/services/execution/QaFollowupService.d.ts +2 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +9 -2
- package/dist/services/execution/QaPlanValidator.d.ts +10 -0
- package/dist/services/execution/QaPlanValidator.d.ts.map +1 -0
- package/dist/services/execution/QaPlanValidator.js +128 -0
- package/dist/services/execution/QaProfileService.d.ts +27 -1
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +354 -7
- package/dist/services/execution/QaTasksService.d.ts +59 -1
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +3347 -318
- package/dist/services/execution/QaTestCommandBuilder.d.ts +51 -0
- package/dist/services/execution/QaTestCommandBuilder.d.ts.map +1 -0
- package/dist/services/execution/QaTestCommandBuilder.js +495 -0
- package/dist/services/execution/TaskSelectionService.d.ts +4 -2
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -1
- package/dist/services/execution/TaskSelectionService.js +144 -28
- package/dist/services/execution/TaskStateService.d.ts +19 -6
- package/dist/services/execution/TaskStateService.d.ts.map +1 -1
- package/dist/services/execution/TaskStateService.js +128 -13
- package/dist/services/execution/WorkOnTasksService.d.ts +32 -1
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +4667 -722
- package/dist/services/jobs/JobInsightsService.d.ts +4 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -1
- package/dist/services/jobs/JobInsightsService.js +51 -5
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -1
- package/dist/services/jobs/JobResumeService.js +23 -10
- package/dist/services/jobs/JobService.d.ts +56 -4
- package/dist/services/jobs/JobService.d.ts.map +1 -1
- package/dist/services/jobs/JobService.js +232 -1
- package/dist/services/openapi/OpenApiService.d.ts +51 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +953 -106
- package/dist/services/planning/CreateTasksService.d.ts +21 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +569 -31
- package/dist/services/planning/RefineTasksService.d.ts +9 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +409 -59
- package/dist/services/review/CodeReviewService.d.ts +18 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +1309 -167
- package/dist/services/review/ReviewNormalizer.d.ts +9 -0
- package/dist/services/review/ReviewNormalizer.d.ts.map +1 -0
- package/dist/services/review/ReviewNormalizer.js +147 -0
- package/dist/services/shared/AuthErrors.d.ts +3 -0
- package/dist/services/shared/AuthErrors.d.ts.map +1 -0
- package/dist/services/shared/AuthErrors.js +17 -0
- package/dist/services/shared/DocdexGuidance.d.ts +7 -0
- package/dist/services/shared/DocdexGuidance.d.ts.map +1 -0
- package/dist/services/shared/DocdexGuidance.js +12 -0
- package/dist/services/shared/ProjectGuidance.d.ts +17 -0
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -0
- package/dist/services/shared/ProjectGuidance.js +78 -0
- package/dist/services/system/ToolDenylist.d.ts +13 -0
- package/dist/services/system/ToolDenylist.d.ts.map +1 -0
- package/dist/services/system/ToolDenylist.js +85 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts +20 -0
- package/dist/services/tasks/TaskCommentFormatter.d.ts.map +1 -0
- package/dist/services/tasks/TaskCommentFormatter.js +54 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/dist/services/telemetry/TelemetryService.js +39 -7
- package/dist/workspace/WorkspaceManager.d.ts +26 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +206 -32
- package/package.json +6 -5
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { AgentService } from "@mcoda/agents";
|
|
4
5
|
import { DocdexClient, VcsClient } from "@mcoda/integrations";
|
|
5
|
-
import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
|
|
6
|
-
import { PathHelper } from "@mcoda/shared";
|
|
6
|
+
import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
|
|
7
|
+
import { PathHelper, READY_TO_CODE_REVIEW, REVIEW_ALLOWED_STATUSES, filterTaskStatuses, normalizeReviewStatuses, } from "@mcoda/shared";
|
|
7
8
|
import { JobService } from "../jobs/JobService.js";
|
|
8
9
|
import { TaskSelectionService } from "../execution/TaskSelectionService.js";
|
|
9
10
|
import { TaskStateService } from "../execution/TaskStateService.js";
|
|
@@ -11,36 +12,88 @@ import { BacklogService } from "../backlog/BacklogService.js";
|
|
|
11
12
|
import yaml from "yaml";
|
|
12
13
|
import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
|
|
13
14
|
import { RoutingService } from "../agents/RoutingService.js";
|
|
15
|
+
import { AgentRatingService } from "../agents/AgentRatingService.js";
|
|
16
|
+
import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from "../shared/ProjectGuidance.js";
|
|
17
|
+
import { buildDocdexUsageGuidance } from "../shared/DocdexGuidance.js";
|
|
18
|
+
import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
|
|
19
|
+
import { AUTH_ERROR_REASON, isAuthErrorMessage } from "../shared/AuthErrors.js";
|
|
20
|
+
import { normalizeReviewOutput } from "./ReviewNormalizer.js";
|
|
14
21
|
const DEFAULT_BASE_BRANCH = "mcoda-dev";
|
|
15
|
-
const REVIEW_DIR = (
|
|
16
|
-
const STATE_PATH = (
|
|
22
|
+
const REVIEW_DIR = (mcodaDir, jobId) => path.join(mcodaDir, "jobs", jobId, "review");
|
|
23
|
+
const STATE_PATH = (mcodaDir, jobId) => path.join(REVIEW_DIR(mcodaDir, jobId), "state.json");
|
|
24
|
+
const REVIEW_PROMPT_LIMITS = {
|
|
25
|
+
diff: 12000,
|
|
26
|
+
history: 3000,
|
|
27
|
+
docContext: 4000,
|
|
28
|
+
openapi: 8000,
|
|
29
|
+
checklist: 3000,
|
|
30
|
+
};
|
|
31
|
+
const DOCDEX_TIMEOUT_MS = 8000;
|
|
17
32
|
const DEFAULT_CODE_REVIEW_PROMPT = [
|
|
18
|
-
"You are the code-review agent.
|
|
33
|
+
"You are the code-review agent.",
|
|
34
|
+
buildDocdexUsageGuidance({ contextLabel: "the review", includeHeading: false, includeFallback: true }),
|
|
19
35
|
"Use docdex snippets to verify contracts (data shapes, offline scope, accessibility/perf guardrails, acceptance criteria). Call out mismatches, missing tests, and undocumented changes.",
|
|
36
|
+
"When recommending tests, prefer the repo's existing runner (tests/all.js or package manager scripts). Avoid suggesting new Jest configs unless the repo explicitly documents them.",
|
|
37
|
+
"Do not require docs/qa/<task>.md reports unless the task explicitly asks for one. QA artifacts typically live in mcoda workspace outputs.",
|
|
38
|
+
"Do not hardcode ports; if a port matters, call out that it must be discovered or configured dynamically.",
|
|
20
39
|
].join("\n");
|
|
40
|
+
const REPO_PROMPTS_DIR = fileURLToPath(new URL("../../../../../prompts/", import.meta.url));
|
|
41
|
+
const resolveRepoPromptPath = (filename) => path.join(REPO_PROMPTS_DIR, filename);
|
|
21
42
|
const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
|
|
22
43
|
const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
const GATEWAY_PROMPT_MARKERS = [
|
|
45
|
+
"you are the gateway agent",
|
|
46
|
+
"return json only",
|
|
47
|
+
"output json only",
|
|
48
|
+
"docdexnotes",
|
|
49
|
+
"fileslikelytouched",
|
|
50
|
+
"filestocreate",
|
|
51
|
+
"do not include fields outside the schema",
|
|
52
|
+
];
|
|
53
|
+
const sanitizeNonGatewayPrompt = (value) => {
|
|
54
|
+
if (!value)
|
|
55
|
+
return undefined;
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
if (!trimmed)
|
|
58
|
+
return undefined;
|
|
59
|
+
const lower = trimmed.toLowerCase();
|
|
60
|
+
if (GATEWAY_PROMPT_MARKERS.some((marker) => lower.includes(marker)))
|
|
61
|
+
return undefined;
|
|
62
|
+
return trimmed;
|
|
63
|
+
};
|
|
64
|
+
const readPromptFile = async (promptPath, fallback) => {
|
|
65
|
+
try {
|
|
66
|
+
const content = await fs.readFile(promptPath, "utf8");
|
|
67
|
+
const trimmed = content.trim();
|
|
68
|
+
if (trimmed)
|
|
69
|
+
return trimmed;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// fall through to fallback
|
|
73
|
+
}
|
|
74
|
+
return fallback;
|
|
75
|
+
};
|
|
76
|
+
const filterOpenApiContext = (entries, hasOpenApiSnippet) => {
|
|
77
|
+
let openApiIncluded = false;
|
|
78
|
+
const filtered = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const isOpenApi = /\[linked:openapi\]|\[openapi\]/i.test(entry);
|
|
81
|
+
if (!isOpenApi) {
|
|
82
|
+
filtered.push(entry);
|
|
32
83
|
continue;
|
|
33
|
-
const slice = candidate.slice(start, end + 1);
|
|
34
|
-
try {
|
|
35
|
-
const parsed = JSON.parse(slice);
|
|
36
|
-
return { ...parsed, raw: raw };
|
|
37
84
|
}
|
|
38
|
-
|
|
39
|
-
|
|
85
|
+
if (hasOpenApiSnippet) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (openApiIncluded) {
|
|
89
|
+
continue;
|
|
40
90
|
}
|
|
91
|
+
openApiIncluded = true;
|
|
92
|
+
filtered.push(entry);
|
|
41
93
|
}
|
|
42
|
-
return
|
|
94
|
+
return filtered;
|
|
43
95
|
};
|
|
96
|
+
const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
|
|
44
97
|
const summarizeComments = (comments) => {
|
|
45
98
|
if (!comments.length)
|
|
46
99
|
return "No prior comments.";
|
|
@@ -51,6 +104,41 @@ const summarizeComments = (comments) => {
|
|
|
51
104
|
})
|
|
52
105
|
.join("\n");
|
|
53
106
|
};
|
|
107
|
+
const truncateSection = (label, text, limit) => {
|
|
108
|
+
if (!text)
|
|
109
|
+
return text;
|
|
110
|
+
if (text.length <= limit)
|
|
111
|
+
return text;
|
|
112
|
+
const trimmed = text.slice(0, limit);
|
|
113
|
+
const remaining = text.length - limit;
|
|
114
|
+
return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
|
|
115
|
+
};
|
|
116
|
+
const isNonBlockingFinding = (finding) => {
|
|
117
|
+
const severity = (finding.severity ?? "").toLowerCase();
|
|
118
|
+
if (["info", "low"].includes(severity))
|
|
119
|
+
return true;
|
|
120
|
+
return false;
|
|
121
|
+
};
|
|
122
|
+
const isNonBlockingOnly = (findings = []) => {
|
|
123
|
+
if (!findings.length)
|
|
124
|
+
return false;
|
|
125
|
+
return findings.every((finding) => isNonBlockingFinding(finding));
|
|
126
|
+
};
|
|
127
|
+
const withTimeout = async (promise, ms, label) => {
|
|
128
|
+
let timeoutId;
|
|
129
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
130
|
+
timeoutId = setTimeout(() => {
|
|
131
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
132
|
+
}, ms);
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (timeoutId)
|
|
139
|
+
clearTimeout(timeoutId);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
54
142
|
const JSON_CONTRACT = `{
|
|
55
143
|
"decision": "approve | changes_requested | block | info_only",
|
|
56
144
|
"summary": "short textual summary",
|
|
@@ -64,28 +152,179 @@ const JSON_CONTRACT = `{
|
|
|
64
152
|
"suggestedFix": "Optional suggested change"
|
|
65
153
|
}
|
|
66
154
|
],
|
|
67
|
-
"testRecommendations": ["Optional test or QA recommendations per task"]
|
|
155
|
+
"testRecommendations": ["Optional test or QA recommendations per task"],
|
|
156
|
+
"resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
|
|
157
|
+
"unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
|
|
68
158
|
}`;
|
|
159
|
+
const JSON_RETRY_RULES = [
|
|
160
|
+
"Return ONLY valid JSON. No markdown, no prose, no code fences.",
|
|
161
|
+
"The response must start with '{' and end with '}'.",
|
|
162
|
+
"Match the schema exactly; use empty arrays when no items apply.",
|
|
163
|
+
].join("\n");
|
|
164
|
+
const isRetryableAgentError = (message) => /unexpected eof|econnreset|etimedout|socket hang up|fetch failed|connection closed/i.test(message.toLowerCase());
|
|
69
165
|
const normalizeSingleLine = (value, fallback) => {
|
|
70
166
|
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
|
71
167
|
return trimmed || fallback;
|
|
72
168
|
};
|
|
169
|
+
const normalizeSlugList = (input) => {
|
|
170
|
+
if (!Array.isArray(input))
|
|
171
|
+
return [];
|
|
172
|
+
const cleaned = new Set();
|
|
173
|
+
for (const slug of input) {
|
|
174
|
+
if (typeof slug !== "string")
|
|
175
|
+
continue;
|
|
176
|
+
const trimmed = slug.trim();
|
|
177
|
+
if (trimmed)
|
|
178
|
+
cleaned.add(trimmed);
|
|
179
|
+
}
|
|
180
|
+
return Array.from(cleaned);
|
|
181
|
+
};
|
|
182
|
+
const normalizePath = (value) => value
|
|
183
|
+
.replace(/\\/g, "/")
|
|
184
|
+
.replace(/^\.\//, "")
|
|
185
|
+
.replace(/^\/+/, "");
|
|
186
|
+
const normalizeLineNumber = (value) => {
|
|
187
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
188
|
+
return Math.max(1, Math.round(value));
|
|
189
|
+
}
|
|
190
|
+
if (typeof value === "string") {
|
|
191
|
+
const parsed = Number.parseInt(value, 10);
|
|
192
|
+
if (Number.isFinite(parsed))
|
|
193
|
+
return Math.max(1, parsed);
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
};
|
|
197
|
+
const summaryIndicatesNoChanges = (summary) => {
|
|
198
|
+
const normalized = (summary ?? "").toLowerCase();
|
|
199
|
+
if (!normalized)
|
|
200
|
+
return false;
|
|
201
|
+
const patterns = [
|
|
202
|
+
"no changes required",
|
|
203
|
+
"no changes needed",
|
|
204
|
+
"no change required",
|
|
205
|
+
"no change needed",
|
|
206
|
+
"no code changes",
|
|
207
|
+
"already complete",
|
|
208
|
+
"already completed",
|
|
209
|
+
"already satisfied",
|
|
210
|
+
];
|
|
211
|
+
return patterns.some((pattern) => normalized.includes(pattern));
|
|
212
|
+
};
|
|
213
|
+
const validateReviewOutput = (result, options = {}) => {
|
|
214
|
+
if (!result.decision || !["approve", "changes_requested", "block", "info_only"].includes(result.decision)) {
|
|
215
|
+
return "Review decision is required.";
|
|
216
|
+
}
|
|
217
|
+
if (!result.summary || !result.summary.trim()) {
|
|
218
|
+
return "Review summary is required.";
|
|
219
|
+
}
|
|
220
|
+
if (options.requireCommentSlugs && result.resolvedSlugs === undefined && result.unresolvedSlugs === undefined) {
|
|
221
|
+
return "resolvedSlugs/unresolvedSlugs required when comment backlog exists.";
|
|
222
|
+
}
|
|
223
|
+
for (const finding of result.findings ?? []) {
|
|
224
|
+
const message = (finding.message ?? "").trim();
|
|
225
|
+
const file = typeof finding.file === "string" ? finding.file.trim() : "";
|
|
226
|
+
const line = normalizeLineNumber(finding.line);
|
|
227
|
+
if (!message || !file || !line) {
|
|
228
|
+
return "Each review finding must include file, line, and message.";
|
|
229
|
+
}
|
|
230
|
+
finding.file = normalizePath(file);
|
|
231
|
+
finding.line = line;
|
|
232
|
+
finding.message = message;
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
};
|
|
236
|
+
const parseCommentBody = (body) => {
|
|
237
|
+
const trimmed = (body ?? "").trim();
|
|
238
|
+
if (!trimmed)
|
|
239
|
+
return { message: "(no details provided)" };
|
|
240
|
+
const lines = trimmed.split(/\r?\n/);
|
|
241
|
+
const normalize = (value) => value.trim().toLowerCase();
|
|
242
|
+
const messageIndex = lines.findIndex((line) => normalize(line) === "message:");
|
|
243
|
+
const suggestedIndex = lines.findIndex((line) => {
|
|
244
|
+
const normalized = normalize(line);
|
|
245
|
+
return normalized === "suggested_fix:" || normalized === "suggested fix:";
|
|
246
|
+
});
|
|
247
|
+
if (messageIndex >= 0) {
|
|
248
|
+
const messageLines = lines.slice(messageIndex + 1, suggestedIndex >= 0 ? suggestedIndex : undefined);
|
|
249
|
+
const message = messageLines.join("\n").trim();
|
|
250
|
+
const suggestedLines = suggestedIndex >= 0 ? lines.slice(suggestedIndex + 1) : [];
|
|
251
|
+
const suggestedFix = suggestedLines.join("\n").trim();
|
|
252
|
+
return { message: message || trimmed, suggestedFix: suggestedFix || undefined };
|
|
253
|
+
}
|
|
254
|
+
if (suggestedIndex >= 0) {
|
|
255
|
+
const message = lines.slice(0, suggestedIndex).join("\n").trim() || trimmed;
|
|
256
|
+
const inlineFix = lines[suggestedIndex]?.split(/suggested fix:/i)[1]?.trim();
|
|
257
|
+
const suggestedTail = lines.slice(suggestedIndex + 1).join("\n").trim();
|
|
258
|
+
const suggestedFix = inlineFix || suggestedTail || undefined;
|
|
259
|
+
return { message, suggestedFix };
|
|
260
|
+
}
|
|
261
|
+
return { message: trimmed };
|
|
262
|
+
};
|
|
263
|
+
const buildCommentBacklog = (comments) => {
|
|
264
|
+
if (!comments.length)
|
|
265
|
+
return "";
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
const lines = [];
|
|
268
|
+
const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
269
|
+
for (const comment of comments) {
|
|
270
|
+
const details = parseCommentBody(comment.body);
|
|
271
|
+
const slug = comment.slug?.trim() ||
|
|
272
|
+
createTaskCommentSlug({
|
|
273
|
+
source: comment.sourceCommand ?? "comment",
|
|
274
|
+
message: details.message || comment.body,
|
|
275
|
+
file: comment.file,
|
|
276
|
+
line: comment.line,
|
|
277
|
+
category: comment.category ?? null,
|
|
278
|
+
});
|
|
279
|
+
const key = slug ??
|
|
280
|
+
`${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
|
|
281
|
+
if (seen.has(key))
|
|
282
|
+
continue;
|
|
283
|
+
seen.add(key);
|
|
284
|
+
const location = comment.file
|
|
285
|
+
? `${comment.file}${typeof comment.line === "number" ? `:${comment.line}` : ""}`
|
|
286
|
+
: "(location not specified)";
|
|
287
|
+
const message = toSingleLine(details.message || comment.body || "(no details provided)");
|
|
288
|
+
lines.push(`- [${slug ?? "untracked"}] ${location} ${message}`);
|
|
289
|
+
const suggestedFix = comment.metadata?.suggestedFix ?? details.suggestedFix ?? undefined;
|
|
290
|
+
if (suggestedFix) {
|
|
291
|
+
lines.push(` Suggested fix: ${toSingleLine(suggestedFix)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return lines.join("\n");
|
|
295
|
+
};
|
|
296
|
+
const formatSlugList = (slugs, limit = 12) => {
|
|
297
|
+
if (!slugs.length)
|
|
298
|
+
return "none";
|
|
299
|
+
if (slugs.length <= limit)
|
|
300
|
+
return slugs.join(", ");
|
|
301
|
+
return `${slugs.slice(0, limit).join(", ")} (+${slugs.length - limit} more)`;
|
|
302
|
+
};
|
|
73
303
|
const buildStandardReviewComment = (params) => {
|
|
74
304
|
const decision = params.decision ?? (params.error ? "error" : "info_only");
|
|
75
305
|
const statusAfter = params.statusAfter ?? params.statusBefore;
|
|
76
306
|
const summary = normalizeSingleLine(params.summary, params.error ? "Review failed." : "No summary provided.");
|
|
77
307
|
const error = normalizeSingleLine(params.error, "none");
|
|
78
308
|
const followups = params.followupTaskKeys && params.followupTaskKeys.length ? params.followupTaskKeys.join(", ") : "none";
|
|
79
|
-
|
|
309
|
+
const lines = [
|
|
80
310
|
"[code-review]",
|
|
81
311
|
`decision: ${decision}`,
|
|
82
312
|
`status_before: ${params.statusBefore}`,
|
|
83
313
|
`status_after: ${statusAfter}`,
|
|
84
314
|
`findings: ${params.findingsCount}`,
|
|
85
315
|
`summary: ${summary}`,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
316
|
+
];
|
|
317
|
+
if (typeof params.resolvedCount === "number") {
|
|
318
|
+
lines.push(`resolved_slugs: ${params.resolvedCount}`);
|
|
319
|
+
}
|
|
320
|
+
if (typeof params.reopenedCount === "number") {
|
|
321
|
+
lines.push(`reopened_slugs: ${params.reopenedCount}`);
|
|
322
|
+
}
|
|
323
|
+
if (typeof params.openCount === "number") {
|
|
324
|
+
lines.push(`open_slugs: ${params.openCount}`);
|
|
325
|
+
}
|
|
326
|
+
lines.push(`followups: ${followups}`, `error: ${error}`);
|
|
327
|
+
return lines.join("\n");
|
|
89
328
|
};
|
|
90
329
|
export class CodeReviewService {
|
|
91
330
|
constructor(workspace, deps) {
|
|
@@ -96,14 +335,17 @@ export class CodeReviewService {
|
|
|
96
335
|
this.stateService = deps.stateService ?? new TaskStateService(deps.workspaceRepo);
|
|
97
336
|
this.vcs = deps.vcsClient ?? new VcsClient();
|
|
98
337
|
this.routingService = deps.routingService;
|
|
338
|
+
this.ratingService = deps.ratingService;
|
|
99
339
|
}
|
|
100
340
|
static async create(workspace) {
|
|
101
341
|
const repo = await GlobalRepository.create();
|
|
102
342
|
const agentService = new AgentService(repo);
|
|
103
343
|
const routingService = await RoutingService.create();
|
|
344
|
+
const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
|
|
104
345
|
const docdex = new DocdexClient({
|
|
105
346
|
workspaceRoot: workspace.workspaceRoot,
|
|
106
347
|
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
348
|
+
repoId: docdexRepoId,
|
|
107
349
|
});
|
|
108
350
|
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
109
351
|
const jobService = new JobService(workspace, workspaceRepo);
|
|
@@ -141,6 +383,14 @@ export class CodeReviewService {
|
|
|
141
383
|
await maybeClose(this.deps.routingService);
|
|
142
384
|
await maybeClose(this.deps.docdex);
|
|
143
385
|
}
|
|
386
|
+
setDocdexAvailability(available, reason) {
|
|
387
|
+
if (available)
|
|
388
|
+
return;
|
|
389
|
+
const docdex = this.deps.docdex;
|
|
390
|
+
if (docdex && typeof docdex.disable === "function") {
|
|
391
|
+
docdex.disable(reason);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
144
394
|
async readPromptFiles(paths) {
|
|
145
395
|
const contents = [];
|
|
146
396
|
const seen = new Set();
|
|
@@ -161,21 +411,11 @@ export class CodeReviewService {
|
|
|
161
411
|
}
|
|
162
412
|
async ensureMcoda() {
|
|
163
413
|
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
414
|
}
|
|
176
415
|
async loadPrompts(agentId) {
|
|
177
|
-
const mcodaPromptPath = path.join(this.workspace.
|
|
416
|
+
const mcodaPromptPath = path.join(this.workspace.mcodaDir, "prompts", "code-reviewer.md");
|
|
178
417
|
const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "code-reviewer.md");
|
|
418
|
+
const repoPromptPath = resolveRepoPromptPath("code-reviewer.md");
|
|
179
419
|
try {
|
|
180
420
|
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
181
421
|
await fs.access(mcodaPromptPath);
|
|
@@ -188,30 +428,29 @@ export class CodeReviewService {
|
|
|
188
428
|
console.info(`[code-review] copied code-reviewer prompt to ${mcodaPromptPath}`);
|
|
189
429
|
}
|
|
190
430
|
catch {
|
|
191
|
-
|
|
192
|
-
|
|
431
|
+
try {
|
|
432
|
+
await fs.access(repoPromptPath);
|
|
433
|
+
await fs.copyFile(repoPromptPath, mcodaPromptPath);
|
|
434
|
+
console.info(`[code-review] copied repo code-reviewer prompt to ${mcodaPromptPath}`);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
console.info(`[code-review] no code-reviewer prompt found at ${workspacePromptPath} or repo prompts; writing default prompt to ${mcodaPromptPath}`);
|
|
438
|
+
await fs.writeFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT, "utf8");
|
|
439
|
+
}
|
|
193
440
|
}
|
|
194
441
|
}
|
|
195
|
-
const filePrompts = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
196
442
|
const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
|
|
197
|
-
const
|
|
198
|
-
|
|
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
|
-
})();
|
|
443
|
+
const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT);
|
|
444
|
+
const commandPrompt = agentPrompts?.commandPrompts?.["code-review"]?.trim() || filePrompt;
|
|
206
445
|
return {
|
|
207
|
-
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
208
|
-
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
209
|
-
commandPrompt:
|
|
446
|
+
jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
|
|
447
|
+
characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
|
|
448
|
+
commandPrompt: commandPrompt || undefined,
|
|
210
449
|
};
|
|
211
450
|
}
|
|
212
451
|
async loadRunbookAndChecklists() {
|
|
213
452
|
const extras = [];
|
|
214
|
-
const runbookPath = path.join(this.workspace.
|
|
453
|
+
const runbookPath = path.join(this.workspace.mcodaDir, "prompts", "commands", "code-review.md");
|
|
215
454
|
try {
|
|
216
455
|
const content = await fs.readFile(runbookPath, "utf8");
|
|
217
456
|
extras.push(content);
|
|
@@ -219,7 +458,7 @@ export class CodeReviewService {
|
|
|
219
458
|
catch {
|
|
220
459
|
/* optional */
|
|
221
460
|
}
|
|
222
|
-
const checklistDir = path.join(this.workspace.
|
|
461
|
+
const checklistDir = path.join(this.workspace.mcodaDir, "checklists");
|
|
223
462
|
try {
|
|
224
463
|
const entries = await fs.readdir(checklistDir);
|
|
225
464
|
for (const entry of entries) {
|
|
@@ -240,7 +479,33 @@ export class CodeReviewService {
|
|
|
240
479
|
commandName: "code-review",
|
|
241
480
|
overrideAgentSlug: agentName,
|
|
242
481
|
});
|
|
243
|
-
|
|
482
|
+
if (agentName) {
|
|
483
|
+
const matches = agentName === resolved.agent.id || agentName === resolved.agent.slug;
|
|
484
|
+
if (!matches) {
|
|
485
|
+
throw new Error(`Review agent override "${agentName}" resolved to "${resolved.agent.slug}" (source: ${resolved.source}).`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return resolved;
|
|
489
|
+
}
|
|
490
|
+
ensureRatingService() {
|
|
491
|
+
if (!this.ratingService) {
|
|
492
|
+
this.ratingService = new AgentRatingService(this.workspace, {
|
|
493
|
+
workspaceRepo: this.deps.workspaceRepo,
|
|
494
|
+
globalRepo: this.deps.repo,
|
|
495
|
+
agentService: this.deps.agentService,
|
|
496
|
+
routingService: this.routingService,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return this.ratingService;
|
|
500
|
+
}
|
|
501
|
+
resolveTaskComplexity(task) {
|
|
502
|
+
const metadata = task.metadata ?? {};
|
|
503
|
+
const metaComplexity = typeof metadata.complexity === "number" && Number.isFinite(metadata.complexity) ? metadata.complexity : undefined;
|
|
504
|
+
const storyPoints = typeof task.storyPoints === "number" && Number.isFinite(task.storyPoints) ? task.storyPoints : undefined;
|
|
505
|
+
const candidate = metaComplexity ?? storyPoints;
|
|
506
|
+
if (!Number.isFinite(candidate ?? NaN))
|
|
507
|
+
return undefined;
|
|
508
|
+
return Math.min(10, Math.max(1, Math.round(candidate)));
|
|
244
509
|
}
|
|
245
510
|
async selectTasksViaApi(filters) {
|
|
246
511
|
// Prefer the backlog/task OpenAPI surface (via BacklogService) to mirror API filtering semantics.
|
|
@@ -250,7 +515,7 @@ export class CodeReviewService {
|
|
|
250
515
|
projectKey: filters.projectKey,
|
|
251
516
|
epicKey: filters.epicKey,
|
|
252
517
|
storyKey: filters.storyKey,
|
|
253
|
-
statuses: filters.statusFilter,
|
|
518
|
+
statuses: filters.statusFilter && filters.statusFilter.length ? filters.statusFilter : undefined,
|
|
254
519
|
verbose: true,
|
|
255
520
|
});
|
|
256
521
|
let tasks = result.summary.tasks;
|
|
@@ -272,13 +537,13 @@ export class CodeReviewService {
|
|
|
272
537
|
}
|
|
273
538
|
}
|
|
274
539
|
async persistState(jobId, state) {
|
|
275
|
-
const dir = REVIEW_DIR(this.workspace.
|
|
540
|
+
const dir = REVIEW_DIR(this.workspace.mcodaDir, jobId);
|
|
276
541
|
await fs.mkdir(dir, { recursive: true });
|
|
277
|
-
await fs.writeFile(STATE_PATH(this.workspace.
|
|
542
|
+
await fs.writeFile(STATE_PATH(this.workspace.mcodaDir, jobId), JSON.stringify({ schema_version: 1, job_id: jobId, updated_at: new Date().toISOString(), ...state }, null, 2), "utf8");
|
|
278
543
|
}
|
|
279
544
|
async loadState(jobId) {
|
|
280
545
|
try {
|
|
281
|
-
const raw = await fs.readFile(STATE_PATH(this.workspace.
|
|
546
|
+
const raw = await fs.readFile(STATE_PATH(this.workspace.mcodaDir, jobId), "utf8");
|
|
282
547
|
return JSON.parse(raw);
|
|
283
548
|
}
|
|
284
549
|
catch {
|
|
@@ -305,26 +570,109 @@ export class CodeReviewService {
|
|
|
305
570
|
}
|
|
306
571
|
return Array.from(hints).slice(0, 8);
|
|
307
572
|
}
|
|
308
|
-
async gatherDocContext(taskTitle, paths, acceptance) {
|
|
573
|
+
async gatherDocContext(taskTitle, paths, acceptance, docLinks = []) {
|
|
309
574
|
const snippets = [];
|
|
310
575
|
const warnings = [];
|
|
576
|
+
if (typeof this.deps.docdex?.ensureRepoScope === "function") {
|
|
577
|
+
try {
|
|
578
|
+
await this.deps.docdex.ensureRepoScope();
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
warnings.push(`docdex scope missing: ${error.message}`);
|
|
582
|
+
return { snippets, warnings };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
311
585
|
const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
|
|
586
|
+
let reindexed = false;
|
|
587
|
+
const resolveDocType = (doc, pathOverride) => {
|
|
588
|
+
const content = doc.segments?.[0]?.content ?? doc.content ?? "";
|
|
589
|
+
const normalized = normalizeDocType({
|
|
590
|
+
docType: doc.docType,
|
|
591
|
+
path: doc.path ?? pathOverride,
|
|
592
|
+
title: doc.title,
|
|
593
|
+
content,
|
|
594
|
+
});
|
|
595
|
+
if (normalized.downgraded) {
|
|
596
|
+
warnings.push(`Docdex docType downgraded from SDS to DOC for ${doc.path ?? doc.title ?? doc.docType ?? "unknown"}: ${normalized.reason ?? "not_sds"}`);
|
|
597
|
+
}
|
|
598
|
+
return normalized.docType;
|
|
599
|
+
};
|
|
312
600
|
for (const query of queries) {
|
|
313
601
|
try {
|
|
314
|
-
const docs = await this.deps.docdex.search({
|
|
602
|
+
const docs = await withTimeout(this.deps.docdex.search({
|
|
315
603
|
query,
|
|
316
604
|
profile: "workspace-code",
|
|
317
|
-
});
|
|
318
|
-
|
|
605
|
+
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
|
|
606
|
+
const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
|
|
607
|
+
snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
|
|
319
608
|
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
320
609
|
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
321
|
-
return `- [${doc
|
|
610
|
+
return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
|
|
322
611
|
}));
|
|
323
612
|
}
|
|
324
613
|
catch (error) {
|
|
614
|
+
if (!reindexed && typeof this.deps.docdex.reindex === "function") {
|
|
615
|
+
reindexed = true;
|
|
616
|
+
try {
|
|
617
|
+
await this.deps.docdex.reindex();
|
|
618
|
+
const docs = await withTimeout(this.deps.docdex.search({
|
|
619
|
+
query,
|
|
620
|
+
profile: "workspace-code",
|
|
621
|
+
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
|
|
622
|
+
const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
|
|
623
|
+
snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
|
|
624
|
+
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
625
|
+
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
626
|
+
return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
|
|
627
|
+
}));
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
catch (retryError) {
|
|
631
|
+
warnings.push(`docdex search failed after reindex for ${query}: ${retryError.message}`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
325
635
|
warnings.push(`docdex search failed for ${query}: ${error.message}`);
|
|
326
636
|
}
|
|
327
637
|
}
|
|
638
|
+
const normalizeDocLink = (value) => {
|
|
639
|
+
const trimmed = value.trim();
|
|
640
|
+
const stripped = trimmed.replace(/^docdex:/i, "").replace(/^doc:/i, "");
|
|
641
|
+
const candidate = stripped || trimmed;
|
|
642
|
+
const looksLikePath = candidate.includes("/") ||
|
|
643
|
+
candidate.includes("\\") ||
|
|
644
|
+
/\.(md|markdown|txt|rst|yaml|yml|json)$/i.test(candidate);
|
|
645
|
+
return { type: looksLikePath ? "path" : "id", ref: candidate };
|
|
646
|
+
};
|
|
647
|
+
for (const link of docLinks) {
|
|
648
|
+
try {
|
|
649
|
+
const { type, ref } = normalizeDocLink(link);
|
|
650
|
+
if (type === "path" && isDocContextExcluded(ref, false)) {
|
|
651
|
+
snippets.push(`- [linked:filtered] ${link} — excluded from non-QA context`);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
let doc = undefined;
|
|
655
|
+
if (type === "path" && "findDocumentByPath" in this.deps.docdex) {
|
|
656
|
+
doc = await this.deps.docdex.findDocumentByPath(ref);
|
|
657
|
+
}
|
|
658
|
+
if (!doc) {
|
|
659
|
+
doc = await this.deps.docdex.fetchDocumentById(ref);
|
|
660
|
+
}
|
|
661
|
+
if (!doc) {
|
|
662
|
+
warnings.push(`docdex fetch returned no document for ${link}`);
|
|
663
|
+
snippets.push(`- [linked:missing] ${link} — no docdex entry found`);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
667
|
+
const refLabel = doc.path ?? doc.id ?? doc.title ?? link;
|
|
668
|
+
snippets.push(`- [linked:${resolveDocType(doc, type === "path" ? ref : undefined)}] ${refLabel}: ${content}`);
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
const message = error.message;
|
|
672
|
+
warnings.push(`docdex fetch failed for ${link}: ${message}`);
|
|
673
|
+
snippets.push(`- [linked:missing] ${link} — ${message}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
328
676
|
return { snippets: Array.from(new Set(snippets)), warnings };
|
|
329
677
|
}
|
|
330
678
|
buildReviewPrompt(params) {
|
|
@@ -333,7 +681,28 @@ export class CodeReviewService {
|
|
|
333
681
|
parts.push(params.systemPrompts.join("\n\n"));
|
|
334
682
|
}
|
|
335
683
|
const acceptance = params.task.acceptanceCriteria && params.task.acceptanceCriteria.length ? params.task.acceptanceCriteria.join(" | ") : "none provided";
|
|
684
|
+
const historySummary = truncateSection("history", params.historySummary, REVIEW_PROMPT_LIMITS.history);
|
|
685
|
+
const commentBacklog = params.commentBacklog
|
|
686
|
+
? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
|
|
687
|
+
: "";
|
|
688
|
+
const filteredDocContext = filterOpenApiContext(params.docContext, Boolean(params.openapiSnippet));
|
|
689
|
+
const docContextText = filteredDocContext.length
|
|
690
|
+
? truncateSection("doc context", filteredDocContext.join("\n"), REVIEW_PROMPT_LIMITS.docContext)
|
|
691
|
+
: "";
|
|
692
|
+
const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
|
|
693
|
+
const checklistsText = params.checklists?.length
|
|
694
|
+
? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
|
|
695
|
+
: "";
|
|
696
|
+
const diffText = truncateSection("diff", params.diff || "(no diff)", REVIEW_PROMPT_LIMITS.diff);
|
|
697
|
+
const reviewFocus = [
|
|
698
|
+
"Review focus:",
|
|
699
|
+
"- First validate the task requirements and the work-on-tasks actions (history/comments) rather than the diff.",
|
|
700
|
+
"- If the diff is empty, decide whether no code changes are required to satisfy the task.",
|
|
701
|
+
"- If no changes are required, use decision=approve or info_only and explicitly say no code changes are needed.",
|
|
702
|
+
"- If changes are required, use decision=changes_requested and explain exactly what is missing and why.",
|
|
703
|
+
].join("\n");
|
|
336
704
|
parts.push([
|
|
705
|
+
reviewFocus,
|
|
337
706
|
`Task ${params.task.key}: ${params.task.title}`,
|
|
338
707
|
`Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
|
|
339
708
|
`Epic description: ${params.task.epicDescription ? params.task.epicDescription : "none"}`,
|
|
@@ -341,22 +710,25 @@ export class CodeReviewService {
|
|
|
341
710
|
`Story description: ${params.task.storyDescription ? params.task.storyDescription : "none"}`,
|
|
342
711
|
`Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
|
|
343
712
|
`Task description: ${params.task.description ? params.task.description : "none"}`,
|
|
344
|
-
`History:\n${
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
713
|
+
`History:\n${historySummary}`,
|
|
714
|
+
commentBacklog
|
|
715
|
+
? `Code-review comment backlog (unresolved slugs):\n${commentBacklog}`
|
|
716
|
+
: "Code-review comment backlog: none",
|
|
717
|
+
`Task DoD / acceptance criteria: ${acceptance}`,
|
|
718
|
+
docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
|
|
719
|
+
openapiSnippet
|
|
720
|
+
? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
|
|
349
721
|
: "OpenAPI: not provided; avoid inventing endpoints.",
|
|
350
|
-
|
|
351
|
-
"Diff
|
|
722
|
+
checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
|
|
723
|
+
params.diffEmpty ? "Diff: (empty — no changes between base and branch)" : "Diff:\n" + diffText,
|
|
352
724
|
"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.",
|
|
725
|
+
"Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for code-review comment backlog items only; do not require docs/qa/* reports; avoid hardcoded ports; do not add prose or markdown fences outside JSON.",
|
|
354
726
|
].join("\n"));
|
|
355
727
|
return parts.join("\n\n");
|
|
356
728
|
}
|
|
357
729
|
async buildHistorySummary(taskId) {
|
|
358
730
|
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
359
|
-
sourceCommands: ["work-on-tasks", "code-review"
|
|
731
|
+
sourceCommands: ["work-on-tasks", "code-review"],
|
|
360
732
|
limit: 10,
|
|
361
733
|
});
|
|
362
734
|
const lastReview = await this.deps.workspaceRepo.getLatestTaskReview(taskId);
|
|
@@ -378,9 +750,194 @@ export class CodeReviewService {
|
|
|
378
750
|
}
|
|
379
751
|
}
|
|
380
752
|
if (!parts.length)
|
|
381
|
-
return "No prior review
|
|
753
|
+
return "No prior review history.";
|
|
382
754
|
return parts.join("\n");
|
|
383
755
|
}
|
|
756
|
+
async loadCommentContext(taskId) {
|
|
757
|
+
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
758
|
+
sourceCommands: ["code-review"],
|
|
759
|
+
limit: 50,
|
|
760
|
+
});
|
|
761
|
+
const unresolved = comments.filter((comment) => !comment.resolvedAt);
|
|
762
|
+
return { comments, unresolved };
|
|
763
|
+
}
|
|
764
|
+
commentSlugKey(file, line, category) {
|
|
765
|
+
if (!file)
|
|
766
|
+
return undefined;
|
|
767
|
+
const normalizedFile = normalizePath(file);
|
|
768
|
+
const linePart = typeof line === "number" ? String(line) : "";
|
|
769
|
+
const categoryPart = category?.toLowerCase() ?? "";
|
|
770
|
+
return `${normalizedFile}|${linePart}|${categoryPart}`;
|
|
771
|
+
}
|
|
772
|
+
buildCommentSlugIndex(comments) {
|
|
773
|
+
const index = new Map();
|
|
774
|
+
for (const comment of comments) {
|
|
775
|
+
if (!comment.slug)
|
|
776
|
+
continue;
|
|
777
|
+
const key = this.commentSlugKey(comment.file, comment.line, comment.category);
|
|
778
|
+
if (!key)
|
|
779
|
+
continue;
|
|
780
|
+
if (!index.has(key))
|
|
781
|
+
index.set(key, comment.slug);
|
|
782
|
+
}
|
|
783
|
+
return index;
|
|
784
|
+
}
|
|
785
|
+
resolveFindingSlug(finding, slugIndex) {
|
|
786
|
+
const key = this.commentSlugKey(finding.file, finding.line, finding.type ?? null);
|
|
787
|
+
const existing = key ? slugIndex.get(key) : undefined;
|
|
788
|
+
if (existing)
|
|
789
|
+
return existing;
|
|
790
|
+
const message = (finding.message ?? "").trim() || "Review finding.";
|
|
791
|
+
return createTaskCommentSlug({
|
|
792
|
+
source: "code-review",
|
|
793
|
+
message,
|
|
794
|
+
file: finding.file,
|
|
795
|
+
line: finding.line,
|
|
796
|
+
category: finding.type ?? null,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async applyCommentResolutions(params) {
|
|
800
|
+
const existingBySlug = new Map();
|
|
801
|
+
const openBySlug = new Set();
|
|
802
|
+
const resolvedBySlug = new Set();
|
|
803
|
+
for (const comment of params.existingComments) {
|
|
804
|
+
if (!comment.slug)
|
|
805
|
+
continue;
|
|
806
|
+
if (!existingBySlug.has(comment.slug)) {
|
|
807
|
+
existingBySlug.set(comment.slug, comment);
|
|
808
|
+
}
|
|
809
|
+
if (comment.resolvedAt) {
|
|
810
|
+
resolvedBySlug.add(comment.slug);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
openBySlug.add(comment.slug);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
|
|
817
|
+
const allowedSlugs = new Set(existingBySlug.keys());
|
|
818
|
+
const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug));
|
|
819
|
+
const resolvedSet = new Set(resolvedSlugs);
|
|
820
|
+
const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug)));
|
|
821
|
+
const findingSlugs = [];
|
|
822
|
+
for (const finding of params.findings ?? []) {
|
|
823
|
+
const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
|
|
824
|
+
findingSlugs.push(slug);
|
|
825
|
+
const severity = (finding.severity ?? "").toLowerCase();
|
|
826
|
+
const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
|
|
827
|
+
["info", "low"].includes(severity);
|
|
828
|
+
if (!resolvedSet.has(slug) && !autoResolve) {
|
|
829
|
+
unresolvedSet.add(slug);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
for (const slug of resolvedSet) {
|
|
833
|
+
unresolvedSet.delete(slug);
|
|
834
|
+
}
|
|
835
|
+
const toResolve = resolvedSlugs.filter((slug) => openBySlug.has(slug));
|
|
836
|
+
const toReopen = Array.from(unresolvedSet).filter((slug) => resolvedBySlug.has(slug));
|
|
837
|
+
for (const slug of toResolve) {
|
|
838
|
+
await this.deps.workspaceRepo.resolveTaskComment({
|
|
839
|
+
taskId: params.task.id,
|
|
840
|
+
slug,
|
|
841
|
+
resolvedAt: new Date().toISOString(),
|
|
842
|
+
resolvedBy: params.agentId,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
for (const slug of toReopen) {
|
|
846
|
+
await this.deps.workspaceRepo.reopenTaskComment({ taskId: params.task.id, slug });
|
|
847
|
+
}
|
|
848
|
+
const createdSlugs = new Set();
|
|
849
|
+
for (const finding of params.findings ?? []) {
|
|
850
|
+
const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
|
|
851
|
+
if (existingBySlug.has(slug) || createdSlugs.has(slug))
|
|
852
|
+
continue;
|
|
853
|
+
const severity = (finding.severity ?? "").toLowerCase();
|
|
854
|
+
const autoResolve = (params.decision === "approve" || params.decision === "info_only") &&
|
|
855
|
+
["info", "low"].includes(severity);
|
|
856
|
+
const message = (finding.message ?? "").trim() || "(no details provided)";
|
|
857
|
+
const body = formatTaskCommentBody({
|
|
858
|
+
slug,
|
|
859
|
+
source: "code-review",
|
|
860
|
+
message,
|
|
861
|
+
status: autoResolve ? "resolved" : "open",
|
|
862
|
+
category: finding.type ?? "other",
|
|
863
|
+
file: finding.file ?? null,
|
|
864
|
+
line: finding.line ?? null,
|
|
865
|
+
suggestedFix: finding.suggestedFix ?? null,
|
|
866
|
+
});
|
|
867
|
+
const resolvedAt = autoResolve ? new Date().toISOString() : undefined;
|
|
868
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
869
|
+
taskId: params.task.id,
|
|
870
|
+
taskRunId: params.taskRunId,
|
|
871
|
+
jobId: params.jobId,
|
|
872
|
+
sourceCommand: "code-review",
|
|
873
|
+
authorType: "agent",
|
|
874
|
+
authorAgentId: params.agentId,
|
|
875
|
+
category: finding.type ?? "other",
|
|
876
|
+
slug,
|
|
877
|
+
status: autoResolve ? "resolved" : "open",
|
|
878
|
+
file: finding.file ?? null,
|
|
879
|
+
line: finding.line ?? null,
|
|
880
|
+
pathHint: finding.file ?? null,
|
|
881
|
+
body,
|
|
882
|
+
resolvedAt,
|
|
883
|
+
resolvedBy: autoResolve ? params.agentId : undefined,
|
|
884
|
+
metadata: {
|
|
885
|
+
severity: finding.severity,
|
|
886
|
+
suggestedFix: finding.suggestedFix,
|
|
887
|
+
},
|
|
888
|
+
createdAt: new Date().toISOString(),
|
|
889
|
+
});
|
|
890
|
+
createdSlugs.add(slug);
|
|
891
|
+
}
|
|
892
|
+
const openSet = new Set(openBySlug);
|
|
893
|
+
for (const slug of unresolvedSet) {
|
|
894
|
+
openSet.add(slug);
|
|
895
|
+
}
|
|
896
|
+
for (const slug of resolvedSet) {
|
|
897
|
+
openSet.delete(slug);
|
|
898
|
+
}
|
|
899
|
+
if (resolvedSlugs.length || toReopen.length || unresolvedSet.size) {
|
|
900
|
+
const resolutionMessage = [
|
|
901
|
+
`Resolved slugs: ${formatSlugList(toResolve)}`,
|
|
902
|
+
`Reopened slugs: ${formatSlugList(toReopen)}`,
|
|
903
|
+
`Open slugs: ${formatSlugList(Array.from(openSet))}`,
|
|
904
|
+
].join("\n");
|
|
905
|
+
const resolutionSlug = createTaskCommentSlug({
|
|
906
|
+
source: "code-review",
|
|
907
|
+
message: resolutionMessage,
|
|
908
|
+
category: "comment_resolution",
|
|
909
|
+
});
|
|
910
|
+
const resolutionBody = formatTaskCommentBody({
|
|
911
|
+
slug: resolutionSlug,
|
|
912
|
+
source: "code-review",
|
|
913
|
+
message: resolutionMessage,
|
|
914
|
+
status: "resolved",
|
|
915
|
+
category: "comment_resolution",
|
|
916
|
+
});
|
|
917
|
+
const createdAt = new Date().toISOString();
|
|
918
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
919
|
+
taskId: params.task.id,
|
|
920
|
+
taskRunId: params.taskRunId,
|
|
921
|
+
jobId: params.jobId,
|
|
922
|
+
sourceCommand: "code-review",
|
|
923
|
+
authorType: "agent",
|
|
924
|
+
authorAgentId: params.agentId,
|
|
925
|
+
category: "comment_resolution",
|
|
926
|
+
slug: resolutionSlug,
|
|
927
|
+
status: "resolved",
|
|
928
|
+
body: resolutionBody,
|
|
929
|
+
createdAt,
|
|
930
|
+
resolvedAt: createdAt,
|
|
931
|
+
resolvedBy: params.agentId,
|
|
932
|
+
metadata: {
|
|
933
|
+
resolvedSlugs: toResolve,
|
|
934
|
+
reopenedSlugs: toReopen,
|
|
935
|
+
openSlugs: Array.from(openSet),
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
return { resolved: toResolve, reopened: toReopen, open: Array.from(openSet) };
|
|
940
|
+
}
|
|
384
941
|
extractPathsFromDiff(diff) {
|
|
385
942
|
const regex = /^(?:\+\+\+ b\/|\-\-\- a\/)([^\s]+)$/gm;
|
|
386
943
|
const paths = new Set();
|
|
@@ -473,6 +1030,9 @@ export class CodeReviewService {
|
|
|
473
1030
|
summary: params.summary,
|
|
474
1031
|
followupTaskKeys: params.followupTaskKeys,
|
|
475
1032
|
error: params.error,
|
|
1033
|
+
resolvedCount: params.resolvedCount,
|
|
1034
|
+
reopenedCount: params.reopenedCount,
|
|
1035
|
+
openCount: params.openCount,
|
|
476
1036
|
});
|
|
477
1037
|
await this.deps.workspaceRepo.createTaskComment({
|
|
478
1038
|
taskId: params.task.id,
|
|
@@ -487,12 +1047,12 @@ export class CodeReviewService {
|
|
|
487
1047
|
});
|
|
488
1048
|
}
|
|
489
1049
|
async persistContext(jobId, taskId, context) {
|
|
490
|
-
const dir = path.join(REVIEW_DIR(this.workspace.
|
|
1050
|
+
const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "context");
|
|
491
1051
|
await fs.mkdir(dir, { recursive: true });
|
|
492
1052
|
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
1053
|
}
|
|
494
1054
|
async persistDiff(jobId, taskId, diff) {
|
|
495
|
-
const dir = path.join(REVIEW_DIR(this.workspace.
|
|
1055
|
+
const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "diffs");
|
|
496
1056
|
await fs.mkdir(dir, { recursive: true });
|
|
497
1057
|
await fs.writeFile(path.join(dir, `${taskId}.diff`), diff, "utf8");
|
|
498
1058
|
// structured review diff snapshot
|
|
@@ -525,6 +1085,13 @@ export class CodeReviewService {
|
|
|
525
1085
|
files.push(current);
|
|
526
1086
|
await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, files }, null, 2), "utf8");
|
|
527
1087
|
}
|
|
1088
|
+
async persistReviewOutput(jobId, taskId, payload) {
|
|
1089
|
+
const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "outputs");
|
|
1090
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1091
|
+
const target = path.join(dir, `${taskId}.json`);
|
|
1092
|
+
await fs.writeFile(target, JSON.stringify(payload, null, 2), "utf8");
|
|
1093
|
+
return path.relative(this.workspace.mcodaDir, target);
|
|
1094
|
+
}
|
|
528
1095
|
severityToPriority(severity) {
|
|
529
1096
|
if (!severity)
|
|
530
1097
|
return null;
|
|
@@ -532,6 +1099,13 @@ export class CodeReviewService {
|
|
|
532
1099
|
const order = { critical: 1, high: 2, medium: 3, low: 4, info: 5 };
|
|
533
1100
|
return order[normalized] ?? null;
|
|
534
1101
|
}
|
|
1102
|
+
severityToStoryPoints(severity) {
|
|
1103
|
+
if (!severity)
|
|
1104
|
+
return null;
|
|
1105
|
+
const normalized = severity.toLowerCase();
|
|
1106
|
+
const points = { critical: 8, high: 5, medium: 3, low: 2, info: 1 };
|
|
1107
|
+
return points[normalized] ?? null;
|
|
1108
|
+
}
|
|
535
1109
|
shouldCreateFollowupTask(decision, finding) {
|
|
536
1110
|
// SDS rule: create follow-ups for blocking/changes_requested decisions or critical/high issues,
|
|
537
1111
|
// and for contract/security/bug types at medium+ severity. Do not create for approve+low/info.
|
|
@@ -632,6 +1206,9 @@ export class CodeReviewService {
|
|
|
632
1206
|
const storyId = genericTarget ? genericContainers.story.id : params.task.userStoryId;
|
|
633
1207
|
const storyKey = genericTarget ? genericContainers.story.key : params.task.storyKey ?? genericContainers?.story.key ?? "US-AUTO";
|
|
634
1208
|
const epicId = genericTarget ? genericContainers.epic.id : params.task.epicId;
|
|
1209
|
+
const fallbackPoints = this.resolveTaskComplexity(params.task) ?? params.task.storyPoints ?? 1;
|
|
1210
|
+
const storyPoints = this.severityToStoryPoints(finding.severity) ?? fallbackPoints;
|
|
1211
|
+
const boundedPoints = Number.isFinite(storyPoints) ? Math.min(10, Math.max(1, Math.round(storyPoints))) : 1;
|
|
635
1212
|
const taskKey = await ensureKey(storyId, storyKey);
|
|
636
1213
|
inserts.push({
|
|
637
1214
|
projectId: params.task.projectId,
|
|
@@ -642,7 +1219,7 @@ export class CodeReviewService {
|
|
|
642
1219
|
description: this.buildFollowupDescription(params.task, finding, params.decision),
|
|
643
1220
|
type: finding.type ?? (params.decision === "changes_requested" || params.decision === "block" ? "bug" : "issue"),
|
|
644
1221
|
status: "not_started",
|
|
645
|
-
storyPoints:
|
|
1222
|
+
storyPoints: boundedPoints,
|
|
646
1223
|
priority: this.severityToPriority(finding.severity),
|
|
647
1224
|
metadata: {
|
|
648
1225
|
source: "code-review",
|
|
@@ -658,6 +1235,7 @@ export class CodeReviewService {
|
|
|
658
1235
|
suggestedFix: finding.suggestedFix,
|
|
659
1236
|
generic: genericTarget ? true : false,
|
|
660
1237
|
decision: params.decision,
|
|
1238
|
+
complexity: boundedPoints,
|
|
661
1239
|
},
|
|
662
1240
|
});
|
|
663
1241
|
}
|
|
@@ -680,7 +1258,14 @@ export class CodeReviewService {
|
|
|
680
1258
|
await this.ensureMcoda();
|
|
681
1259
|
const agentStream = request.agentStream !== false;
|
|
682
1260
|
const baseRef = request.baseRef ?? this.workspace.config?.branch ?? DEFAULT_BASE_BRANCH;
|
|
683
|
-
const
|
|
1261
|
+
const ignoreStatusFilter = Boolean(request.taskKeys?.length) || request.ignoreStatusFilter === true;
|
|
1262
|
+
const rawStatusFilter = ignoreStatusFilter
|
|
1263
|
+
? []
|
|
1264
|
+
: request.statusFilter && request.statusFilter.length
|
|
1265
|
+
? request.statusFilter
|
|
1266
|
+
: [READY_TO_CODE_REVIEW];
|
|
1267
|
+
const { filtered: allowedStatusFilter, rejected } = filterTaskStatuses(rawStatusFilter, REVIEW_ALLOWED_STATUSES, REVIEW_ALLOWED_STATUSES);
|
|
1268
|
+
const statusFilter = normalizeReviewStatuses(allowedStatusFilter);
|
|
684
1269
|
let state;
|
|
685
1270
|
const commandRun = await this.deps.jobService.startCommandRun("code-review", request.projectKey, {
|
|
686
1271
|
taskIds: request.taskKeys,
|
|
@@ -690,6 +1275,10 @@ export class CodeReviewService {
|
|
|
690
1275
|
let jobId = request.resumeJobId;
|
|
691
1276
|
let selectedTaskIds = [];
|
|
692
1277
|
let warnings = [];
|
|
1278
|
+
if (rejected.length > 0 && !ignoreStatusFilter) {
|
|
1279
|
+
warnings.push(`code-review ignores unsupported statuses: ${rejected.join(", ")}. Allowed: ${REVIEW_ALLOWED_STATUSES.join(", ")}.`);
|
|
1280
|
+
}
|
|
1281
|
+
let allowFollowups = request.createFollowupTasks === true;
|
|
693
1282
|
let selectedTasks = [];
|
|
694
1283
|
if (request.resumeJobId) {
|
|
695
1284
|
const job = await this.deps.jobService.getJob(request.resumeJobId);
|
|
@@ -698,16 +1287,37 @@ export class CodeReviewService {
|
|
|
698
1287
|
if ((job.commandName ?? job.type) !== "code-review" && job.type !== "review") {
|
|
699
1288
|
throw new Error(`Job ${request.resumeJobId} is not a code-review job`);
|
|
700
1289
|
}
|
|
1290
|
+
if (request.createFollowupTasks === undefined) {
|
|
1291
|
+
allowFollowups = Boolean(job.payload?.createFollowupTasks);
|
|
1292
|
+
}
|
|
701
1293
|
state = await this.loadState(request.resumeJobId);
|
|
702
1294
|
selectedTaskIds = state?.selectedTaskIds ?? (Array.isArray(job.payload?.selection) ? job.payload.selection : []);
|
|
703
1295
|
if (!selectedTaskIds.length) {
|
|
704
1296
|
throw new Error("Resume requested but no task selection found in job payload");
|
|
705
1297
|
}
|
|
1298
|
+
selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
1299
|
+
const terminalStatuses = new Set(["completed", "cancelled"]);
|
|
1300
|
+
const terminalTasks = selectedTasks.filter((task) => terminalStatuses.has((task.status ?? "").toLowerCase()));
|
|
1301
|
+
if (terminalTasks.length) {
|
|
1302
|
+
const terminalIds = new Set(terminalTasks.map((task) => task.id));
|
|
1303
|
+
const terminalKeys = terminalTasks.map((task) => task.key);
|
|
1304
|
+
warnings.push(`Skipping terminal tasks on resume: ${terminalKeys.join(", ")}`);
|
|
1305
|
+
selectedTasks = selectedTasks.filter((task) => !terminalIds.has(task.id));
|
|
1306
|
+
selectedTaskIds = selectedTaskIds.filter((id) => !terminalIds.has(id));
|
|
1307
|
+
if (state) {
|
|
1308
|
+
state.selectedTaskIds = selectedTaskIds;
|
|
1309
|
+
await this.persistState(job.id, state);
|
|
1310
|
+
}
|
|
1311
|
+
await this.writeCheckpoint(job.id, "resume_filtered", {
|
|
1312
|
+
skippedTaskKeys: terminalKeys,
|
|
1313
|
+
selectedTaskIds,
|
|
1314
|
+
schema_version: 1,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
706
1317
|
await this.deps.jobService.updateJobStatus(job.id, "running", {
|
|
707
|
-
totalItems:
|
|
1318
|
+
totalItems: selectedTaskIds.length,
|
|
708
1319
|
processedItems: state?.reviewed.length ?? 0,
|
|
709
1320
|
});
|
|
710
|
-
selectedTasks = await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
711
1321
|
}
|
|
712
1322
|
else {
|
|
713
1323
|
try {
|
|
@@ -716,7 +1326,7 @@ export class CodeReviewService {
|
|
|
716
1326
|
epicKey: request.epicKey,
|
|
717
1327
|
storyKey: request.storyKey,
|
|
718
1328
|
taskKeys: request.taskKeys,
|
|
719
|
-
statusFilter,
|
|
1329
|
+
statusFilter: ignoreStatusFilter ? undefined : statusFilter,
|
|
720
1330
|
limit: request.limit,
|
|
721
1331
|
});
|
|
722
1332
|
}
|
|
@@ -726,10 +1336,11 @@ export class CodeReviewService {
|
|
|
726
1336
|
epicKey: request.epicKey,
|
|
727
1337
|
storyKey: request.storyKey,
|
|
728
1338
|
taskKeys: request.taskKeys,
|
|
729
|
-
statusFilter,
|
|
1339
|
+
statusFilter: ignoreStatusFilter ? undefined : statusFilter,
|
|
730
1340
|
limit: request.limit,
|
|
1341
|
+
ignoreStatusFilter,
|
|
731
1342
|
});
|
|
732
|
-
warnings = [...selection.warnings];
|
|
1343
|
+
warnings = [...warnings, ...selection.warnings];
|
|
733
1344
|
selectedTasks = selection.ordered.map((t) => t.task);
|
|
734
1345
|
}
|
|
735
1346
|
selectedTaskIds = selectedTasks.map((t) => t.id);
|
|
@@ -740,12 +1351,13 @@ export class CodeReviewService {
|
|
|
740
1351
|
epicKey: request.epicKey,
|
|
741
1352
|
storyKey: request.storyKey,
|
|
742
1353
|
tasks: request.taskKeys,
|
|
743
|
-
statusFilter,
|
|
1354
|
+
statusFilter: ignoreStatusFilter ? [] : statusFilter,
|
|
744
1355
|
baseRef,
|
|
745
1356
|
selection: selectedTaskIds,
|
|
746
1357
|
dryRun: request.dryRun ?? false,
|
|
747
1358
|
agent: request.agentName,
|
|
748
1359
|
agentStream,
|
|
1360
|
+
createFollowupTasks: allowFollowups,
|
|
749
1361
|
},
|
|
750
1362
|
totalItems: selectedTaskIds.length,
|
|
751
1363
|
processedItems: 0,
|
|
@@ -782,12 +1394,208 @@ export class CodeReviewService {
|
|
|
782
1394
|
const tasks = selectedTasks.length && selectedTaskIds.length === selectedTasks.length
|
|
783
1395
|
? selectedTasks
|
|
784
1396
|
: await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
785
|
-
|
|
1397
|
+
let resolvedAgent;
|
|
1398
|
+
try {
|
|
1399
|
+
resolvedAgent = await this.resolveAgent(request.agentName);
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1403
|
+
if (request.agentName) {
|
|
1404
|
+
const warning = `Review agent override (${request.agentName}) failed: ${message}`;
|
|
1405
|
+
warnings.push(warning);
|
|
1406
|
+
console.warn(`[code-review] ${warning}`);
|
|
1407
|
+
}
|
|
1408
|
+
throw error;
|
|
1409
|
+
}
|
|
1410
|
+
const agent = resolvedAgent.agent;
|
|
1411
|
+
const reviewJsonAgentOverride = this.workspace.config?.reviewJsonAgent ??
|
|
1412
|
+
process.env.MCODA_REVIEW_JSON_AGENT ??
|
|
1413
|
+
process.env.MCODA_REVIEW_JSON_AGENT_NAME;
|
|
1414
|
+
let reviewJsonAgent;
|
|
1415
|
+
if (reviewJsonAgentOverride &&
|
|
1416
|
+
reviewJsonAgentOverride !== agent.id &&
|
|
1417
|
+
reviewJsonAgentOverride !== agent.slug) {
|
|
1418
|
+
try {
|
|
1419
|
+
reviewJsonAgent = (await this.resolveAgent(reviewJsonAgentOverride)).agent;
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1423
|
+
warnings.push(`Review JSON agent override (${reviewJsonAgentOverride}) failed: ${message}`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
786
1426
|
const prompts = await this.loadPrompts(agent.id);
|
|
787
1427
|
const extras = await this.loadRunbookAndChecklists();
|
|
788
|
-
const
|
|
1428
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
1429
|
+
if (projectGuidance) {
|
|
1430
|
+
console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
|
|
1431
|
+
}
|
|
1432
|
+
const guidanceBlock = projectGuidance?.content ? `Project Guidance (read first):\n${projectGuidance.content}` : undefined;
|
|
1433
|
+
const systemPrompts = [guidanceBlock, prompts.jobPrompt, prompts.characterPrompt, prompts.commandPrompt, ...extras].filter(Boolean);
|
|
1434
|
+
const abortSignal = request.abortSignal;
|
|
1435
|
+
const resolveAbortReason = () => {
|
|
1436
|
+
const reason = abortSignal?.reason;
|
|
1437
|
+
if (typeof reason === "string" && reason.trim().length > 0)
|
|
1438
|
+
return reason;
|
|
1439
|
+
if (reason instanceof Error && reason.message)
|
|
1440
|
+
return reason.message;
|
|
1441
|
+
return "code_review_aborted";
|
|
1442
|
+
};
|
|
1443
|
+
const abortIfSignaled = () => {
|
|
1444
|
+
if (abortSignal?.aborted) {
|
|
1445
|
+
throw new Error(resolveAbortReason());
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
const withAbort = async (promise) => {
|
|
1449
|
+
if (!abortSignal)
|
|
1450
|
+
return promise;
|
|
1451
|
+
if (abortSignal.aborted) {
|
|
1452
|
+
throw new Error(resolveAbortReason());
|
|
1453
|
+
}
|
|
1454
|
+
return await new Promise((resolve, reject) => {
|
|
1455
|
+
const onAbort = () => reject(new Error(resolveAbortReason()));
|
|
1456
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
1457
|
+
promise.then(resolve, reject).finally(() => {
|
|
1458
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
};
|
|
789
1462
|
const results = [];
|
|
1463
|
+
const formatSessionId = (iso) => {
|
|
1464
|
+
const date = new Date(iso);
|
|
1465
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
1466
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
1467
|
+
};
|
|
1468
|
+
const formatDuration = (ms) => {
|
|
1469
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
1470
|
+
const seconds = totalSeconds % 60;
|
|
1471
|
+
const minutesTotal = Math.floor(totalSeconds / 60);
|
|
1472
|
+
const minutes = minutesTotal % 60;
|
|
1473
|
+
const hours = Math.floor(minutesTotal / 60);
|
|
1474
|
+
if (hours > 0)
|
|
1475
|
+
return `${hours}H ${minutes}M ${seconds}S`;
|
|
1476
|
+
return `${minutes}M ${seconds}S`;
|
|
1477
|
+
};
|
|
1478
|
+
const resolveProvider = (adapter) => {
|
|
1479
|
+
if (!adapter)
|
|
1480
|
+
return "n/a";
|
|
1481
|
+
const trimmed = adapter.trim();
|
|
1482
|
+
if (!trimmed)
|
|
1483
|
+
return "n/a";
|
|
1484
|
+
if (trimmed.includes("-"))
|
|
1485
|
+
return trimmed.split("-")[0];
|
|
1486
|
+
return trimmed;
|
|
1487
|
+
};
|
|
1488
|
+
const resolveReasoning = (config) => {
|
|
1489
|
+
if (!config)
|
|
1490
|
+
return "n/a";
|
|
1491
|
+
const raw = config.reasoning ?? config.thinking;
|
|
1492
|
+
if (typeof raw === "string")
|
|
1493
|
+
return raw;
|
|
1494
|
+
if (typeof raw === "boolean")
|
|
1495
|
+
return raw ? "enabled" : "disabled";
|
|
1496
|
+
return "n/a";
|
|
1497
|
+
};
|
|
1498
|
+
const emitLine = (line) => {
|
|
1499
|
+
console.info(line);
|
|
1500
|
+
};
|
|
1501
|
+
const emitBlank = () => emitLine("");
|
|
1502
|
+
const emitReviewStart = (details) => {
|
|
1503
|
+
emitLine("╭──────────────────────────────────────────────────────────╮");
|
|
1504
|
+
emitLine("│ START OF CODE REVIEW TASK │");
|
|
1505
|
+
emitLine("╰──────────────────────────────────────────────────────────╯");
|
|
1506
|
+
emitLine(` [🪪] Code Review Task ID: ${details.taskKey}`);
|
|
1507
|
+
emitLine(` [👹] Alias: ${details.alias}`);
|
|
1508
|
+
emitLine(` [ℹ️] Summary: ${details.summary}`);
|
|
1509
|
+
emitLine(` [🤖] Model: ${details.model}`);
|
|
1510
|
+
emitLine(` [🕹️] Provider: ${details.provider}`);
|
|
1511
|
+
emitLine(` [🧩] Step: ${details.step}`);
|
|
1512
|
+
emitLine(` [🧠] Reasoning: ${details.reasoning}`);
|
|
1513
|
+
emitLine(` [📁] Workdir: ${details.workdir}`);
|
|
1514
|
+
emitLine(` [🔑] Session: ${details.sessionId}`);
|
|
1515
|
+
emitLine(` [🕒] Started: ${details.startedAt}`);
|
|
1516
|
+
emitBlank();
|
|
1517
|
+
emitLine(" ░░░░░ START OF CODE REVIEW TASK ░░░░░");
|
|
1518
|
+
emitBlank();
|
|
1519
|
+
emitLine(` [STEP ${details.step}] [MODEL ${details.model}]`);
|
|
1520
|
+
emitBlank();
|
|
1521
|
+
emitBlank();
|
|
1522
|
+
};
|
|
1523
|
+
const emitReviewEnd = (details) => {
|
|
1524
|
+
emitLine("╭──────────────────────────────────────────────────────────╮");
|
|
1525
|
+
emitLine("│ END OF CODE REVIEW TASK │");
|
|
1526
|
+
emitLine("╰──────────────────────────────────────────────────────────╯");
|
|
1527
|
+
emitLine(` 👀 CODE REVIEW TASK ${details.taskKey} | 📜 STATUS ${details.statusLabel} | ✅ DECISION ${details.decision ?? "n/a"} | 🔎 FINDINGS ${details.findingsCount} | ⌛ TIME ${formatDuration(details.elapsedMs)}`);
|
|
1528
|
+
emitLine(` [🕒] Started: ${details.startedAt}`);
|
|
1529
|
+
emitLine(` [🕒] Ended: ${details.endedAt}`);
|
|
1530
|
+
emitLine(` Tokens used: ${details.tokensTotal.toLocaleString("en-US")}`);
|
|
1531
|
+
emitBlank();
|
|
1532
|
+
emitLine(" ░░░░░ END OF CODE REVIEW TASK ░░░░░");
|
|
1533
|
+
emitBlank();
|
|
1534
|
+
};
|
|
1535
|
+
const maybeRateTask = async (task, taskRunId, tokensTotal) => {
|
|
1536
|
+
if (!request.rateAgents || tokensTotal <= 0)
|
|
1537
|
+
return;
|
|
1538
|
+
try {
|
|
1539
|
+
const ratingService = this.ensureRatingService();
|
|
1540
|
+
await ratingService.rate({
|
|
1541
|
+
workspace: this.workspace,
|
|
1542
|
+
agentId: agent.id,
|
|
1543
|
+
commandName: "code-review",
|
|
1544
|
+
jobId,
|
|
1545
|
+
commandRunId: commandRun.id,
|
|
1546
|
+
taskId: task.id,
|
|
1547
|
+
taskKey: task.key,
|
|
1548
|
+
discipline: task.type ?? "review",
|
|
1549
|
+
complexity: this.resolveTaskComplexity(task),
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
catch (error) {
|
|
1553
|
+
const message = `Agent rating failed for ${task.key}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1554
|
+
warnings.push(message);
|
|
1555
|
+
try {
|
|
1556
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1557
|
+
taskRunId,
|
|
1558
|
+
sequence: this.sequenceForTask(taskRunId),
|
|
1559
|
+
timestamp: new Date().toISOString(),
|
|
1560
|
+
source: "rating",
|
|
1561
|
+
message,
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
/* ignore rating log failures */
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
let abortRemainingReason = null;
|
|
790
1570
|
for (const task of tasks) {
|
|
1571
|
+
if (abortRemainingReason)
|
|
1572
|
+
break;
|
|
1573
|
+
abortIfSignaled();
|
|
1574
|
+
const startedAt = new Date().toISOString();
|
|
1575
|
+
const taskStartMs = Date.now();
|
|
1576
|
+
const sessionId = formatSessionId(startedAt);
|
|
1577
|
+
const taskAlias = `Reviewing task ${task.key}`;
|
|
1578
|
+
const taskSummary = task.title ?? task.description ?? "(none)";
|
|
1579
|
+
const modelLabel = agent.defaultModel ?? "(default)";
|
|
1580
|
+
const providerLabel = resolveProvider(agent.adapter);
|
|
1581
|
+
const reasoningLabel = resolveReasoning(agent.config);
|
|
1582
|
+
const stepLabel = "review";
|
|
1583
|
+
let endEmitted = false;
|
|
1584
|
+
const emitReviewEndOnce = (details) => {
|
|
1585
|
+
if (endEmitted)
|
|
1586
|
+
return;
|
|
1587
|
+
endEmitted = true;
|
|
1588
|
+
emitReviewEnd({
|
|
1589
|
+
taskKey: task.key,
|
|
1590
|
+
statusLabel: details.statusLabel,
|
|
1591
|
+
decision: details.decision,
|
|
1592
|
+
findingsCount: details.findingsCount,
|
|
1593
|
+
elapsedMs: Date.now() - taskStartMs,
|
|
1594
|
+
tokensTotal: details.tokensTotal,
|
|
1595
|
+
startedAt,
|
|
1596
|
+
endedAt: new Date().toISOString(),
|
|
1597
|
+
});
|
|
1598
|
+
};
|
|
791
1599
|
const statusBefore = task.status;
|
|
792
1600
|
const taskRun = await this.deps.workspaceRepo.createTaskRun({
|
|
793
1601
|
taskId: task.id,
|
|
@@ -796,19 +1604,42 @@ export class CodeReviewService {
|
|
|
796
1604
|
commandRunId: commandRun.id,
|
|
797
1605
|
agentId: agent.id,
|
|
798
1606
|
status: "running",
|
|
799
|
-
startedAt
|
|
1607
|
+
startedAt,
|
|
800
1608
|
storyPointsAtRun: task.storyPoints ?? null,
|
|
801
1609
|
gitBranch: task.vcsBranch ?? null,
|
|
802
1610
|
gitBaseBranch: task.vcsBaseBranch ?? null,
|
|
803
1611
|
gitCommitSha: task.vcsLastCommitSha ?? null,
|
|
804
1612
|
});
|
|
1613
|
+
const statusContext = {
|
|
1614
|
+
commandName: "code-review",
|
|
1615
|
+
jobId,
|
|
1616
|
+
taskRunId: taskRun.id,
|
|
1617
|
+
agentId: agent.id,
|
|
1618
|
+
metadata: { lane: "review" },
|
|
1619
|
+
};
|
|
805
1620
|
const findings = [];
|
|
806
1621
|
let decision;
|
|
807
1622
|
let statusAfter;
|
|
1623
|
+
let reviewErrorCode;
|
|
808
1624
|
const followupCreated = [];
|
|
1625
|
+
let commentResolution;
|
|
809
1626
|
// Debug visibility: show prompts/task details for this run
|
|
810
1627
|
const systemPrompt = systemPrompts.join("\n\n");
|
|
1628
|
+
let tokensTotal = 0;
|
|
1629
|
+
let agentOutput = "";
|
|
811
1630
|
try {
|
|
1631
|
+
emitReviewStart({
|
|
1632
|
+
taskKey: task.key,
|
|
1633
|
+
alias: taskAlias,
|
|
1634
|
+
summary: taskSummary,
|
|
1635
|
+
model: modelLabel,
|
|
1636
|
+
provider: providerLabel,
|
|
1637
|
+
step: stepLabel,
|
|
1638
|
+
reasoning: reasoningLabel,
|
|
1639
|
+
workdir: this.workspace.workspaceRoot,
|
|
1640
|
+
sessionId,
|
|
1641
|
+
startedAt,
|
|
1642
|
+
});
|
|
812
1643
|
const metadata = task.metadata ?? {};
|
|
813
1644
|
const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
|
|
814
1645
|
const diffResult = await this.buildDiff(task, state?.baseRef ?? baseRef, allowedFiles);
|
|
@@ -830,7 +1661,21 @@ export class CodeReviewService {
|
|
|
830
1661
|
allowedFiles,
|
|
831
1662
|
},
|
|
832
1663
|
});
|
|
1664
|
+
const diffEmpty = !diff.trim();
|
|
1665
|
+
if (diffEmpty) {
|
|
1666
|
+
const message = `Empty diff for ${task.key}; reviewing task requirements to confirm whether no changes are acceptable.`;
|
|
1667
|
+
warnings.push(message);
|
|
1668
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1669
|
+
taskRunId: taskRun.id,
|
|
1670
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1671
|
+
timestamp: new Date().toISOString(),
|
|
1672
|
+
source: "review_warning",
|
|
1673
|
+
message,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
833
1676
|
const historySummary = await this.buildHistorySummary(task.id);
|
|
1677
|
+
const commentContext = await this.loadCommentContext(task.id);
|
|
1678
|
+
const commentBacklog = buildCommentBacklog(commentContext.unresolved);
|
|
834
1679
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
835
1680
|
taskRunId: taskRun.id,
|
|
836
1681
|
sequence: this.sequenceForTask(taskRun.id),
|
|
@@ -839,7 +1684,8 @@ export class CodeReviewService {
|
|
|
839
1684
|
message: "Loaded task history",
|
|
840
1685
|
});
|
|
841
1686
|
const changedPaths = this.extractPathsFromDiff(diff);
|
|
842
|
-
const
|
|
1687
|
+
const diffMeta = { diffEmpty, changedPaths };
|
|
1688
|
+
const docLinks = await this.gatherDocContext(task.title, changedPaths.length ? changedPaths : allowedFiles, task.acceptanceCriteria, Array.isArray(task.metadata?.doc_links) ? task.metadata.doc_links : []);
|
|
843
1689
|
if (docLinks.warnings.length)
|
|
844
1690
|
warnings.push(...docLinks.warnings);
|
|
845
1691
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -865,12 +1711,15 @@ export class CodeReviewService {
|
|
|
865
1711
|
systemPrompts,
|
|
866
1712
|
task,
|
|
867
1713
|
diff,
|
|
1714
|
+
diffEmpty,
|
|
868
1715
|
docContext: docLinks.snippets,
|
|
869
1716
|
openapiSnippet,
|
|
870
1717
|
historySummary,
|
|
1718
|
+
commentBacklog,
|
|
871
1719
|
baseRef: state?.baseRef ?? baseRef,
|
|
872
1720
|
branch: task.vcsBranch ?? undefined,
|
|
873
1721
|
});
|
|
1722
|
+
const requireCommentSlugs = Boolean(commentBacklog.trim());
|
|
874
1723
|
const separator = "============================================================";
|
|
875
1724
|
const deps = Array.isArray(task.dependencyKeys) && task.dependencyKeys.length
|
|
876
1725
|
? task.dependencyKeys
|
|
@@ -878,7 +1727,6 @@ export class CodeReviewService {
|
|
|
878
1727
|
? task.metadata.depends_on
|
|
879
1728
|
: [];
|
|
880
1729
|
console.info(separator);
|
|
881
|
-
console.info("[code-review] START OF TASK");
|
|
882
1730
|
console.info(`[code-review] Task key: ${task.key}`);
|
|
883
1731
|
console.info(`[code-review] Title: ${task.title ?? "(none)"}`);
|
|
884
1732
|
console.info(`[code-review] Description: ${task.description ?? "(none)"}`);
|
|
@@ -892,21 +1740,24 @@ export class CodeReviewService {
|
|
|
892
1740
|
console.info(separator);
|
|
893
1741
|
await this.persistContext(jobId, task.id, {
|
|
894
1742
|
historySummary,
|
|
1743
|
+
commentBacklog,
|
|
895
1744
|
docdex: docLinks.snippets,
|
|
896
1745
|
openapiSnippet,
|
|
897
1746
|
changedPaths,
|
|
1747
|
+
diffEmpty,
|
|
898
1748
|
});
|
|
899
1749
|
state?.contextBuilt.push(task.id);
|
|
900
1750
|
await this.persistState(jobId, state);
|
|
901
1751
|
await this.writeCheckpoint(jobId, "context_built", { contextBuilt: state?.contextBuilt ?? [], schema_version: 1 });
|
|
902
|
-
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
|
|
1752
|
+
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta, agentUsed = agent, attempt = 1) => {
|
|
903
1753
|
const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
|
|
904
1754
|
const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
|
|
905
|
-
const
|
|
1755
|
+
const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
|
|
1756
|
+
tokensTotal += entryTotal;
|
|
906
1757
|
await this.deps.jobService.recordTokenUsage({
|
|
907
1758
|
workspaceId: this.workspace.workspaceId,
|
|
908
|
-
agentId:
|
|
909
|
-
modelName: tokenMeta?.model ??
|
|
1759
|
+
agentId: agentUsed.id,
|
|
1760
|
+
modelName: tokenMeta?.model ?? agentUsed.defaultModel ?? undefined,
|
|
910
1761
|
jobId,
|
|
911
1762
|
commandRunId: commandRun.id,
|
|
912
1763
|
taskRunId: taskRun.id,
|
|
@@ -914,43 +1765,85 @@ export class CodeReviewService {
|
|
|
914
1765
|
projectId: task.projectId,
|
|
915
1766
|
tokensPrompt,
|
|
916
1767
|
tokensCompletion,
|
|
917
|
-
tokensTotal,
|
|
1768
|
+
tokensTotal: entryTotal,
|
|
918
1769
|
durationSeconds,
|
|
919
1770
|
timestamp: new Date().toISOString(),
|
|
920
|
-
metadata: { commandName: "code-review", phase, action: phase },
|
|
1771
|
+
metadata: { commandName: "code-review", phase, action: phase, attempt },
|
|
921
1772
|
});
|
|
922
1773
|
};
|
|
923
|
-
|
|
1774
|
+
agentOutput = "";
|
|
924
1775
|
let durationSeconds = 0;
|
|
925
|
-
const started = Date.now();
|
|
926
1776
|
let lastStreamMeta;
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1777
|
+
let agentUsedForOutput = agent;
|
|
1778
|
+
let outputAttempt = 1;
|
|
1779
|
+
const invokeReviewAgent = async (agentToUse, useStream, logSource) => {
|
|
1780
|
+
let output = "";
|
|
1781
|
+
let metadata;
|
|
1782
|
+
const started = Date.now();
|
|
1783
|
+
if (useStream && this.deps.agentService.invokeStream) {
|
|
1784
|
+
const stream = await withAbort(this.deps.agentService.invokeStream(agentToUse.id, {
|
|
1785
|
+
input: prompt,
|
|
1786
|
+
metadata: { taskKey: task.key, retry: logSource === "agent_retry" },
|
|
1787
|
+
}));
|
|
1788
|
+
while (true) {
|
|
1789
|
+
abortIfSignaled();
|
|
1790
|
+
const { value, done } = await withAbort(stream.next());
|
|
1791
|
+
if (done)
|
|
1792
|
+
break;
|
|
1793
|
+
const chunk = value;
|
|
1794
|
+
output += chunk.output ?? "";
|
|
1795
|
+
metadata = chunk.metadata ?? metadata;
|
|
1796
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1797
|
+
taskRunId: taskRun.id,
|
|
1798
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1799
|
+
timestamp: new Date().toISOString(),
|
|
1800
|
+
source: logSource,
|
|
1801
|
+
message: chunk.output ?? "",
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
const response = await withAbort(this.deps.agentService.invoke(agentToUse.id, {
|
|
1807
|
+
input: prompt,
|
|
1808
|
+
metadata: { taskKey: task.key, retry: logSource === "agent_retry" },
|
|
1809
|
+
}));
|
|
1810
|
+
output = response.output ?? "";
|
|
1811
|
+
metadata = response.metadata;
|
|
932
1812
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
933
1813
|
taskRunId: taskRun.id,
|
|
934
1814
|
sequence: this.sequenceForTask(taskRun.id),
|
|
935
1815
|
timestamp: new Date().toISOString(),
|
|
936
|
-
source:
|
|
937
|
-
message:
|
|
1816
|
+
source: logSource,
|
|
1817
|
+
message: output,
|
|
938
1818
|
});
|
|
939
1819
|
}
|
|
940
|
-
durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
1820
|
+
const durationSeconds = Math.round(((Date.now() - started) / 1000) * 1000) / 1000;
|
|
1821
|
+
return { output, durationSeconds, metadata };
|
|
1822
|
+
};
|
|
1823
|
+
try {
|
|
1824
|
+
const invocation = await invokeReviewAgent(agent, Boolean(agentStream && this.deps.agentService.invokeStream), "agent");
|
|
1825
|
+
agentOutput = invocation.output;
|
|
1826
|
+
durationSeconds = invocation.durationSeconds;
|
|
1827
|
+
lastStreamMeta = invocation.metadata;
|
|
941
1828
|
}
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
1829
|
+
catch (error) {
|
|
1830
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1831
|
+
if (!isRetryableAgentError(message)) {
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
outputAttempt = 2;
|
|
1835
|
+
agentUsedForOutput = reviewJsonAgent ?? agent;
|
|
946
1836
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
947
1837
|
taskRunId: taskRun.id,
|
|
948
1838
|
sequence: this.sequenceForTask(taskRun.id),
|
|
949
1839
|
timestamp: new Date().toISOString(),
|
|
950
|
-
source: "
|
|
951
|
-
message:
|
|
1840
|
+
source: "agent_retry",
|
|
1841
|
+
message: `Transient agent error (${message}); retrying once with ${agentUsedForOutput.slug ?? agentUsedForOutput.id}.`,
|
|
952
1842
|
});
|
|
953
|
-
|
|
1843
|
+
const invocation = await invokeReviewAgent(agentUsedForOutput, false, "agent_retry");
|
|
1844
|
+
agentOutput = invocation.output;
|
|
1845
|
+
durationSeconds = invocation.durationSeconds;
|
|
1846
|
+
lastStreamMeta = invocation.metadata;
|
|
954
1847
|
}
|
|
955
1848
|
const tokenMetaMain = lastStreamMeta
|
|
956
1849
|
? {
|
|
@@ -962,20 +1855,60 @@ export class CodeReviewService {
|
|
|
962
1855
|
model: (lastStreamMeta.model ?? lastStreamMeta.model_name ?? null),
|
|
963
1856
|
}
|
|
964
1857
|
: undefined;
|
|
965
|
-
await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
|
|
966
|
-
|
|
967
|
-
|
|
1858
|
+
await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain, agentUsedForOutput, outputAttempt);
|
|
1859
|
+
const primaryOutput = agentOutput;
|
|
1860
|
+
let retryOutput;
|
|
1861
|
+
let retryAgentUsed;
|
|
1862
|
+
let normalization = normalizeReviewOutput(agentOutput);
|
|
1863
|
+
let parsed = normalization.result;
|
|
1864
|
+
let validationError = validateReviewOutput(parsed, { requireCommentSlugs });
|
|
1865
|
+
if (validationError === "resolvedSlugs/unresolvedSlugs required when comment backlog exists.") {
|
|
1866
|
+
const warning = `Review output missing comment slugs for ${task.key}; assuming no backlog items resolved.`;
|
|
1867
|
+
warnings.push(warning);
|
|
1868
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1869
|
+
taskRunId: taskRun.id,
|
|
1870
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1871
|
+
timestamp: new Date().toISOString(),
|
|
1872
|
+
source: "review_warning",
|
|
1873
|
+
message: warning,
|
|
1874
|
+
});
|
|
1875
|
+
validationError = undefined;
|
|
1876
|
+
}
|
|
1877
|
+
const needsRetry = Boolean(validationError) || normalization.usedFallback;
|
|
1878
|
+
if (needsRetry) {
|
|
1879
|
+
const retryReason = validationError
|
|
1880
|
+
? `Invalid review schema (${validationError}); retrying once with stricter instructions.`
|
|
1881
|
+
: "Unstructured review output; retrying once with stricter instructions.";
|
|
968
1882
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
969
1883
|
taskRunId: taskRun.id,
|
|
970
1884
|
sequence: this.sequenceForTask(taskRun.id),
|
|
971
1885
|
timestamp: new Date().toISOString(),
|
|
972
1886
|
source: "agent",
|
|
973
|
-
message:
|
|
1887
|
+
message: retryReason,
|
|
974
1888
|
});
|
|
975
|
-
const
|
|
1889
|
+
const buildRetryPrompt = (raw) => [
|
|
1890
|
+
"Your previous response was invalid JSON. Reformat it to match the schema below.",
|
|
1891
|
+
JSON_RETRY_RULES,
|
|
1892
|
+
JSON_CONTRACT,
|
|
1893
|
+
"RESPONSE_TO_CONVERT:",
|
|
1894
|
+
raw,
|
|
1895
|
+
].join("\n");
|
|
1896
|
+
const retryPrompt = agentOutput.trim()
|
|
1897
|
+
? buildRetryPrompt(agentOutput)
|
|
1898
|
+
: `${prompt}\n\n${JSON_RETRY_RULES}\n${JSON_CONTRACT}`;
|
|
976
1899
|
const retryStarted = Date.now();
|
|
977
|
-
|
|
978
|
-
|
|
1900
|
+
retryAgentUsed = reviewJsonAgent ?? agent;
|
|
1901
|
+
if (retryAgentUsed.id !== agent.id) {
|
|
1902
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1903
|
+
taskRunId: taskRun.id,
|
|
1904
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1905
|
+
timestamp: new Date().toISOString(),
|
|
1906
|
+
source: "agent_retry",
|
|
1907
|
+
message: `Retrying with JSON-only agent override: ${retryAgentUsed.slug ?? retryAgentUsed.id}`,
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
const retryResp = await withAbort(this.deps.agentService.invoke(retryAgentUsed.id, { input: retryPrompt, metadata: { taskKey: task.key, retry: true } }));
|
|
1911
|
+
retryOutput = retryResp.output ?? "";
|
|
979
1912
|
const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
|
|
980
1913
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
981
1914
|
taskRunId: taskRun.id,
|
|
@@ -998,47 +1931,231 @@ export class CodeReviewService {
|
|
|
998
1931
|
model: (retryResp.metadata.model ?? retryResp.metadata.model_name ?? null),
|
|
999
1932
|
}
|
|
1000
1933
|
: undefined;
|
|
1001
|
-
await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta);
|
|
1002
|
-
|
|
1934
|
+
await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta, retryAgentUsed, 2);
|
|
1935
|
+
normalization = normalizeReviewOutput(retryOutput);
|
|
1936
|
+
parsed = normalization.result;
|
|
1937
|
+
validationError = validateReviewOutput(parsed, { requireCommentSlugs });
|
|
1938
|
+
if (validationError === "resolvedSlugs/unresolvedSlugs required when comment backlog exists.") {
|
|
1939
|
+
const warning = `Review output missing comment slugs for ${task.key} after retry; assuming no backlog items resolved.`;
|
|
1940
|
+
warnings.push(warning);
|
|
1941
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1942
|
+
taskRunId: taskRun.id,
|
|
1943
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1944
|
+
timestamp: new Date().toISOString(),
|
|
1945
|
+
source: "review_warning",
|
|
1946
|
+
message: warning,
|
|
1947
|
+
});
|
|
1948
|
+
validationError = undefined;
|
|
1949
|
+
}
|
|
1003
1950
|
agentOutput = retryOutput;
|
|
1004
1951
|
}
|
|
1005
|
-
if (
|
|
1006
|
-
|
|
1952
|
+
if (validationError) {
|
|
1953
|
+
const fallbackSummary = `Review output missing required fields (${validationError}); treated as informational.`;
|
|
1954
|
+
warnings.push(`Review output missing required fields for ${task.key}; proceeding with info_only.`);
|
|
1955
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1956
|
+
taskRunId: taskRun.id,
|
|
1957
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1958
|
+
timestamp: new Date().toISOString(),
|
|
1959
|
+
source: "review_warning",
|
|
1960
|
+
message: fallbackSummary,
|
|
1961
|
+
});
|
|
1962
|
+
parsed = {
|
|
1963
|
+
decision: "info_only",
|
|
1964
|
+
summary: fallbackSummary,
|
|
1965
|
+
findings: [],
|
|
1966
|
+
testRecommendations: [],
|
|
1967
|
+
raw: retryOutput ?? agentOutput,
|
|
1968
|
+
};
|
|
1969
|
+
normalization = { parsedFromJson: false, usedFallback: true, issues: ["validation_error"], result: parsed };
|
|
1970
|
+
}
|
|
1971
|
+
if (normalization.usedFallback) {
|
|
1972
|
+
const fallbackMessage = `Review output was not valid JSON for ${task.key}; treated as informational.`;
|
|
1973
|
+
warnings.push(fallbackMessage);
|
|
1974
|
+
await this.deps.workspaceRepo.insertTaskLog({
|
|
1975
|
+
taskRunId: taskRun.id,
|
|
1976
|
+
sequence: this.sequenceForTask(taskRun.id),
|
|
1977
|
+
timestamp: new Date().toISOString(),
|
|
1978
|
+
source: "review_warning",
|
|
1979
|
+
message: fallbackMessage,
|
|
1980
|
+
});
|
|
1981
|
+
try {
|
|
1982
|
+
const artifactPath = await this.persistReviewOutput(jobId, task.id, {
|
|
1983
|
+
schema_version: 1,
|
|
1984
|
+
task_key: task.key,
|
|
1985
|
+
created_at: new Date().toISOString(),
|
|
1986
|
+
agent_id: agent.id,
|
|
1987
|
+
retry_agent_id: retryAgentUsed?.id ?? agent.id,
|
|
1988
|
+
primary_output: primaryOutput,
|
|
1989
|
+
retry_output: retryOutput ?? agentOutput,
|
|
1990
|
+
validation_error: validationError ?? null,
|
|
1991
|
+
});
|
|
1992
|
+
warnings.push(`Review output saved to ${artifactPath} for ${task.key}.`);
|
|
1993
|
+
}
|
|
1994
|
+
catch (persistError) {
|
|
1995
|
+
warnings.push(`Failed to persist review output for ${task.key}: ${persistError instanceof Error ? persistError.message : String(persistError)}`);
|
|
1996
|
+
}
|
|
1007
1997
|
}
|
|
1008
|
-
parsed.raw = agentOutput;
|
|
1998
|
+
parsed.raw = parsed.raw ?? agentOutput;
|
|
1999
|
+
const originalDecision = parsed.decision;
|
|
1009
2000
|
decision = parsed.decision;
|
|
1010
2001
|
findings.push(...(parsed.findings ?? []));
|
|
1011
|
-
const
|
|
2002
|
+
const historySupportsNoChanges = task.metadata?.completed_reason === "no_changes" ||
|
|
2003
|
+
historySummary.toLowerCase().includes("no_changes") ||
|
|
2004
|
+
historySummary.toLowerCase().includes("no changes");
|
|
2005
|
+
const summarySupportsNoChanges = summaryIndicatesNoChanges(parsed.summary);
|
|
2006
|
+
const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
|
|
2007
|
+
let finalDecision = parsed.decision;
|
|
2008
|
+
let emptyDiffOverride = false;
|
|
2009
|
+
if (diffEmpty && approveDecision && !(summarySupportsNoChanges && historySupportsNoChanges)) {
|
|
2010
|
+
finalDecision = "changes_requested";
|
|
2011
|
+
emptyDiffOverride = true;
|
|
2012
|
+
}
|
|
2013
|
+
if (finalDecision === "changes_requested" && isNonBlockingOnly(parsed.findings ?? []) && !emptyDiffOverride) {
|
|
2014
|
+
finalDecision = "info_only";
|
|
2015
|
+
warnings.push(`Review for ${task.key} requested changes but only low/info findings were reported; downgrading to info_only.`);
|
|
2016
|
+
}
|
|
2017
|
+
commentResolution = await this.applyCommentResolutions({
|
|
1012
2018
|
task,
|
|
1013
|
-
findings: parsed.findings ?? [],
|
|
1014
|
-
decision: parsed.decision,
|
|
1015
|
-
jobId,
|
|
1016
|
-
commandRunId: commandRun.id,
|
|
1017
2019
|
taskRunId: taskRun.id,
|
|
2020
|
+
jobId,
|
|
2021
|
+
agentId: agent.id,
|
|
2022
|
+
findings: parsed.findings ?? [],
|
|
2023
|
+
resolvedSlugs: parsed.resolvedSlugs ?? undefined,
|
|
2024
|
+
unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
|
|
2025
|
+
decision: finalDecision,
|
|
2026
|
+
existingComments: commentContext.comments,
|
|
1018
2027
|
});
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
2028
|
+
if (commentResolution?.open?.length &&
|
|
2029
|
+
(finalDecision === "approve" || finalDecision === "info_only")) {
|
|
2030
|
+
const openSlugs = commentResolution.open;
|
|
2031
|
+
finalDecision = "changes_requested";
|
|
2032
|
+
const message = `Unresolved comment slugs remain: ${formatSlugList(openSlugs)}. Review approval requires resolving these items.`;
|
|
2033
|
+
const backlogSlug = createTaskCommentSlug({
|
|
2034
|
+
source: "code-review",
|
|
2035
|
+
message,
|
|
2036
|
+
category: "comment_backlog",
|
|
2037
|
+
});
|
|
2038
|
+
const backlogBody = formatTaskCommentBody({
|
|
2039
|
+
slug: backlogSlug,
|
|
2040
|
+
source: "code-review",
|
|
2041
|
+
message,
|
|
2042
|
+
status: "open",
|
|
2043
|
+
category: "comment_backlog",
|
|
2044
|
+
});
|
|
2045
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2046
|
+
taskId: task.id,
|
|
2047
|
+
taskRunId: taskRun.id,
|
|
2048
|
+
jobId,
|
|
2049
|
+
sourceCommand: "code-review",
|
|
2050
|
+
authorType: "agent",
|
|
2051
|
+
authorAgentId: agent.id,
|
|
2052
|
+
category: "comment_backlog",
|
|
2053
|
+
slug: backlogSlug,
|
|
2054
|
+
status: "open",
|
|
2055
|
+
body: backlogBody,
|
|
2056
|
+
metadata: { openSlugs },
|
|
2057
|
+
createdAt: new Date().toISOString(),
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
if (emptyDiffOverride) {
|
|
2061
|
+
const message = [
|
|
2062
|
+
"Empty diff detected; approval requires an explicit no-changes justification",
|
|
2063
|
+
"and task history indicating no changes were needed.",
|
|
2064
|
+
].join(" ");
|
|
2065
|
+
const slug = createTaskCommentSlug({
|
|
2066
|
+
source: "code-review",
|
|
2067
|
+
message,
|
|
2068
|
+
category: "review_empty_diff",
|
|
2069
|
+
});
|
|
2070
|
+
const body = formatTaskCommentBody({
|
|
2071
|
+
slug,
|
|
2072
|
+
source: "code-review",
|
|
2073
|
+
message,
|
|
2074
|
+
status: "open",
|
|
2075
|
+
category: "review_empty_diff",
|
|
2076
|
+
});
|
|
2077
|
+
await this.deps.workspaceRepo.createTaskComment({
|
|
2078
|
+
taskId: task.id,
|
|
2079
|
+
taskRunId: taskRun.id,
|
|
2080
|
+
jobId,
|
|
2081
|
+
sourceCommand: "code-review",
|
|
2082
|
+
authorType: "agent",
|
|
2083
|
+
authorAgentId: agent.id,
|
|
2084
|
+
category: "review_empty_diff",
|
|
2085
|
+
slug,
|
|
2086
|
+
status: "open",
|
|
2087
|
+
body,
|
|
2088
|
+
createdAt: new Date().toISOString(),
|
|
2089
|
+
});
|
|
2090
|
+
warnings.push(`Empty diff approval rejected for ${task.key}; requesting explicit no-changes justification.`);
|
|
2091
|
+
}
|
|
2092
|
+
const appendSyntheticFinding = (message, suggestedFix) => {
|
|
2093
|
+
const finding = {
|
|
2094
|
+
type: "process",
|
|
2095
|
+
severity: "info",
|
|
2096
|
+
message,
|
|
2097
|
+
suggestedFix,
|
|
2098
|
+
};
|
|
2099
|
+
if (!parsed.findings) {
|
|
2100
|
+
parsed.findings = [];
|
|
2101
|
+
}
|
|
2102
|
+
parsed.findings.push(finding);
|
|
2103
|
+
findings.push(finding);
|
|
2104
|
+
};
|
|
2105
|
+
if (finalDecision === "changes_requested" && (parsed.findings?.length ?? 0) === 0) {
|
|
2106
|
+
if (emptyDiffOverride) {
|
|
2107
|
+
appendSyntheticFinding("Empty diff lacks explicit no-changes justification; changes requested to confirm no code updates were required.", "Update the review summary to state no changes were required and confirm task history reflects no_changes.");
|
|
2108
|
+
}
|
|
2109
|
+
else if (commentResolution?.open?.length) {
|
|
2110
|
+
appendSyntheticFinding(`Unresolved comment backlog remains (${formatSlugList(commentResolution.open)}); approval requires resolving these items.`, "Resolve or explicitly reopen the listed comment slugs before approving.");
|
|
2111
|
+
}
|
|
2112
|
+
else {
|
|
2113
|
+
finalDecision = "info_only";
|
|
2114
|
+
warnings.push(`Review requested changes for ${task.key} but provided no findings; downgrading to info_only.`);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
parsed.decision = finalDecision;
|
|
2118
|
+
decision = finalDecision;
|
|
2119
|
+
if (allowFollowups) {
|
|
2120
|
+
const followups = await this.createFollowupTasksForFindings({
|
|
2121
|
+
task,
|
|
2122
|
+
findings: parsed.findings ?? [],
|
|
2123
|
+
decision: originalDecision,
|
|
2124
|
+
jobId,
|
|
2125
|
+
commandRunId: commandRun.id,
|
|
2126
|
+
taskRunId: taskRun.id,
|
|
2127
|
+
});
|
|
2128
|
+
if (followups.length) {
|
|
2129
|
+
followupCreated.push(...followups.map((t) => ({
|
|
2130
|
+
taskId: t.id,
|
|
2131
|
+
taskKey: t.key,
|
|
2132
|
+
epicId: t.epicId,
|
|
2133
|
+
userStoryId: t.userStoryId,
|
|
2134
|
+
generic: t?.metadata?.generic ? true : undefined,
|
|
2135
|
+
})));
|
|
2136
|
+
warnings.push(`Created follow-up tasks for ${task.key}: ${followups.map((t) => t.key).join(", ")}`);
|
|
2137
|
+
}
|
|
1028
2138
|
}
|
|
1029
2139
|
let taskStatusUpdate = statusBefore;
|
|
1030
2140
|
if (!request.dryRun) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
2141
|
+
const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
|
|
2142
|
+
if (approveDecision) {
|
|
2143
|
+
if (diffEmpty) {
|
|
2144
|
+
await this.stateService.markCompleted(task, { review_no_changes: true }, statusContext);
|
|
2145
|
+
taskStatusUpdate = "completed";
|
|
2146
|
+
}
|
|
2147
|
+
else {
|
|
2148
|
+
await this.stateService.markReadyToQa(task, undefined, statusContext);
|
|
2149
|
+
taskStatusUpdate = "ready_to_qa";
|
|
2150
|
+
}
|
|
1034
2151
|
}
|
|
1035
2152
|
else if (parsed.decision === "changes_requested") {
|
|
1036
|
-
await this.stateService.
|
|
1037
|
-
taskStatusUpdate = "
|
|
2153
|
+
await this.stateService.markChangesRequested(task, undefined, statusContext);
|
|
2154
|
+
taskStatusUpdate = "changes_requested";
|
|
1038
2155
|
}
|
|
1039
2156
|
else if (parsed.decision === "block") {
|
|
1040
|
-
await this.stateService.
|
|
1041
|
-
taskStatusUpdate = "
|
|
2157
|
+
await this.stateService.markFailed(task, "review_blocked", statusContext);
|
|
2158
|
+
taskStatusUpdate = "failed";
|
|
1042
2159
|
}
|
|
1043
2160
|
}
|
|
1044
2161
|
else {
|
|
@@ -1052,26 +2169,6 @@ export class CodeReviewService {
|
|
|
1052
2169
|
});
|
|
1053
2170
|
}
|
|
1054
2171
|
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
2172
|
await this.writeReviewSummaryComment({
|
|
1076
2173
|
task,
|
|
1077
2174
|
taskRunId: taskRun.id,
|
|
@@ -1083,8 +2180,11 @@ export class CodeReviewService {
|
|
|
1083
2180
|
summary: parsed.summary,
|
|
1084
2181
|
findingsCount: parsed.findings?.length ?? 0,
|
|
1085
2182
|
followupTaskKeys: followupCreated.map((t) => t.taskKey),
|
|
2183
|
+
resolvedCount: commentResolution?.resolved.length,
|
|
2184
|
+
reopenedCount: commentResolution?.reopened.length,
|
|
2185
|
+
openCount: commentResolution?.open.length,
|
|
1086
2186
|
});
|
|
1087
|
-
await this.deps.workspaceRepo.createTaskReview({
|
|
2187
|
+
const review = await this.deps.workspaceRepo.createTaskReview({
|
|
1088
2188
|
taskId: task.id,
|
|
1089
2189
|
jobId,
|
|
1090
2190
|
agentId: agent.id,
|
|
@@ -1093,6 +2193,7 @@ export class CodeReviewService {
|
|
|
1093
2193
|
summary: parsed.summary ?? undefined,
|
|
1094
2194
|
findingsJson: parsed.findings ?? [],
|
|
1095
2195
|
testRecommendationsJson: parsed.testRecommendations ?? [],
|
|
2196
|
+
metadata: diffMeta,
|
|
1096
2197
|
createdAt: new Date().toISOString(),
|
|
1097
2198
|
});
|
|
1098
2199
|
await this.stateService.recordReviewMetadata(task, {
|
|
@@ -1100,6 +2201,9 @@ export class CodeReviewService {
|
|
|
1100
2201
|
agentId: agent.id,
|
|
1101
2202
|
modelName: agent.defaultModel ?? null,
|
|
1102
2203
|
jobId,
|
|
2204
|
+
reviewId: review.id,
|
|
2205
|
+
diffEmpty,
|
|
2206
|
+
changedPaths,
|
|
1103
2207
|
});
|
|
1104
2208
|
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1105
2209
|
status: "succeeded",
|
|
@@ -1151,6 +2255,18 @@ export class CodeReviewService {
|
|
|
1151
2255
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1152
2256
|
processedItems: state?.reviewed.length ?? 0,
|
|
1153
2257
|
});
|
|
2258
|
+
emitReviewEndOnce({
|
|
2259
|
+
statusLabel: "FAILED",
|
|
2260
|
+
decision: "error",
|
|
2261
|
+
findingsCount: findings.length,
|
|
2262
|
+
tokensTotal,
|
|
2263
|
+
});
|
|
2264
|
+
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
2265
|
+
if (isAuthErrorMessage(message)) {
|
|
2266
|
+
abortRemainingReason = message;
|
|
2267
|
+
warnings.push(`Auth/rate limit error detected; stopping after ${task.key}. ${message}`);
|
|
2268
|
+
break;
|
|
2269
|
+
}
|
|
1154
2270
|
continue;
|
|
1155
2271
|
}
|
|
1156
2272
|
results.push({
|
|
@@ -1160,11 +2276,37 @@ export class CodeReviewService {
|
|
|
1160
2276
|
statusAfter,
|
|
1161
2277
|
decision,
|
|
1162
2278
|
findings,
|
|
2279
|
+
error: reviewErrorCode,
|
|
1163
2280
|
followupTasks: followupCreated,
|
|
1164
2281
|
});
|
|
2282
|
+
const statusLabel = reviewErrorCode
|
|
2283
|
+
? "FAILED"
|
|
2284
|
+
: decision === "approve" || decision === "info_only"
|
|
2285
|
+
? "APPROVED"
|
|
2286
|
+
: decision === "block"
|
|
2287
|
+
? "FAILED"
|
|
2288
|
+
: decision === "changes_requested"
|
|
2289
|
+
? "CHANGES_REQUESTED"
|
|
2290
|
+
: "FAILED";
|
|
2291
|
+
emitReviewEndOnce({
|
|
2292
|
+
statusLabel,
|
|
2293
|
+
decision,
|
|
2294
|
+
findingsCount: findings.length,
|
|
2295
|
+
tokensTotal,
|
|
2296
|
+
});
|
|
1165
2297
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1166
2298
|
processedItems: state?.reviewed.length ?? 0,
|
|
1167
2299
|
});
|
|
2300
|
+
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
2301
|
+
}
|
|
2302
|
+
if (abortRemainingReason) {
|
|
2303
|
+
await this.deps.jobService.updateJobStatus(jobId, "failed", {
|
|
2304
|
+
processedItems: state?.reviewed.length ?? 0,
|
|
2305
|
+
totalItems: selectedTaskIds.length,
|
|
2306
|
+
errorSummary: AUTH_ERROR_REASON,
|
|
2307
|
+
});
|
|
2308
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "failed", abortRemainingReason);
|
|
2309
|
+
return { jobId, commandRunId: commandRun.id, tasks: results, warnings };
|
|
1168
2310
|
}
|
|
1169
2311
|
await this.deps.jobService.updateJobStatus(jobId, "completed", {
|
|
1170
2312
|
processedItems: state?.reviewed.length ?? selectedTaskIds.length,
|