@mcoda/core 0.1.9 → 0.1.12
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/README.md +2 -2
- package/dist/api/AgentsApi.d.ts +1 -0
- package/dist/api/AgentsApi.d.ts.map +1 -1
- package/dist/api/AgentsApi.js +136 -11
- package/dist/api/QaTasksApi.d.ts.map +1 -1
- package/dist/api/QaTasksApi.js +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -1
- package/dist/prompts/PdrPrompts.js +6 -0
- package/dist/prompts/SdsPrompts.d.ts.map +1 -1
- package/dist/prompts/SdsPrompts.js +7 -0
- package/dist/services/agents/AgentRatingService.d.ts +19 -0
- package/dist/services/agents/AgentRatingService.d.ts.map +1 -1
- package/dist/services/agents/AgentRatingService.js +66 -2
- package/dist/services/agents/GatewayAgentService.d.ts +8 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -1
- package/dist/services/agents/GatewayAgentService.js +462 -65
- package/dist/services/agents/GatewayHandoff.d.ts +5 -1
- package/dist/services/agents/GatewayHandoff.d.ts.map +1 -1
- package/dist/services/agents/GatewayHandoff.js +65 -32
- 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 +16 -4
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -1
- package/dist/services/backlog/TaskOrderingService.js +529 -73
- 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 +59 -2
- package/dist/services/docs/DocsService.d.ts.map +1 -1
- package/dist/services/docs/DocsService.js +1701 -48
- 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 +71 -4
- package/dist/services/execution/GatewayTrioService.d.ts.map +1 -1
- package/dist/services/execution/GatewayTrioService.js +1695 -328
- 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 +1 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -1
- package/dist/services/execution/QaFollowupService.js +8 -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 +21 -1
- package/dist/services/execution/QaProfileService.d.ts.map +1 -1
- package/dist/services/execution/QaProfileService.js +214 -29
- package/dist/services/execution/QaTasksService.d.ts +41 -1
- package/dist/services/execution/QaTasksService.d.ts.map +1 -1
- package/dist/services/execution/QaTasksService.js +2851 -500
- 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 +19 -2
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -1
- package/dist/services/execution/WorkOnTasksService.js +3913 -1225
- 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 +41 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -1
- package/dist/services/openapi/OpenApiService.js +889 -98
- package/dist/services/planning/CreateTasksService.d.ts +15 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -1
- package/dist/services/planning/CreateTasksService.js +311 -6
- package/dist/services/planning/RefineTasksService.d.ts +4 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -1
- package/dist/services/planning/RefineTasksService.js +225 -24
- package/dist/services/review/CodeReviewService.d.ts +4 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -1
- package/dist/services/review/CodeReviewService.js +778 -232
- 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 +12 -1
- package/dist/services/shared/ProjectGuidance.d.ts.map +1 -1
- package/dist/services/shared/ProjectGuidance.js +64 -7
- 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/telemetry/TelemetryService.d.ts.map +1 -1
- package/dist/services/telemetry/TelemetryService.js +39 -7
- package/dist/workspace/WorkspaceManager.d.ts +22 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -1
- package/dist/workspace/WorkspaceManager.js +203 -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
6
|
import { GlobalRepository, WorkspaceRepository, } from "@mcoda/db";
|
|
6
|
-
import { PathHelper } from "@mcoda/shared";
|
|
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";
|
|
@@ -12,11 +13,14 @@ import yaml from "yaml";
|
|
|
12
13
|
import { createTaskKeyGenerator } from "../planning/KeyHelpers.js";
|
|
13
14
|
import { RoutingService } from "../agents/RoutingService.js";
|
|
14
15
|
import { AgentRatingService } from "../agents/AgentRatingService.js";
|
|
15
|
-
import { loadProjectGuidance } from "../shared/ProjectGuidance.js";
|
|
16
|
+
import { isDocContextExcluded, loadProjectGuidance, normalizeDocType } from "../shared/ProjectGuidance.js";
|
|
17
|
+
import { buildDocdexUsageGuidance } from "../shared/DocdexGuidance.js";
|
|
16
18
|
import { createTaskCommentSlug, formatTaskCommentBody } from "../tasks/TaskCommentFormatter.js";
|
|
19
|
+
import { AUTH_ERROR_REASON, isAuthErrorMessage } from "../shared/AuthErrors.js";
|
|
20
|
+
import { normalizeReviewOutput } from "./ReviewNormalizer.js";
|
|
17
21
|
const DEFAULT_BASE_BRANCH = "mcoda-dev";
|
|
18
|
-
const REVIEW_DIR = (
|
|
19
|
-
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");
|
|
20
24
|
const REVIEW_PROMPT_LIMITS = {
|
|
21
25
|
diff: 12000,
|
|
22
26
|
history: 3000,
|
|
@@ -26,63 +30,70 @@ const REVIEW_PROMPT_LIMITS = {
|
|
|
26
30
|
};
|
|
27
31
|
const DOCDEX_TIMEOUT_MS = 8000;
|
|
28
32
|
const DEFAULT_CODE_REVIEW_PROMPT = [
|
|
29
|
-
"You are the code-review agent.
|
|
33
|
+
"You are the code-review agent.",
|
|
34
|
+
buildDocdexUsageGuidance({ contextLabel: "the review", includeHeading: false, includeFallback: true }),
|
|
30
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.",
|
|
31
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);
|
|
32
42
|
const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
|
|
33
43
|
const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)))
|
|
39
61
|
return undefined;
|
|
40
|
-
return
|
|
62
|
+
return trimmed;
|
|
41
63
|
};
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
trimmed.startsWith("\"")) {
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
})
|
|
58
|
-
.join("\n");
|
|
59
|
-
return cleanedLines.replace(/,\s*([}\]])/g, "$1");
|
|
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;
|
|
60
75
|
};
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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);
|
|
68
83
|
continue;
|
|
69
|
-
try {
|
|
70
|
-
const parsed = JSON.parse(slice);
|
|
71
|
-
return { ...parsed, raw: raw };
|
|
72
84
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const parsed = JSON.parse(sanitized);
|
|
77
|
-
return { ...parsed, raw: raw };
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
/* ignore */
|
|
81
|
-
}
|
|
85
|
+
if (hasOpenApiSnippet) {
|
|
86
|
+
continue;
|
|
82
87
|
}
|
|
88
|
+
if (openApiIncluded) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
openApiIncluded = true;
|
|
92
|
+
filtered.push(entry);
|
|
83
93
|
}
|
|
84
|
-
return
|
|
94
|
+
return filtered;
|
|
85
95
|
};
|
|
96
|
+
const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
|
|
86
97
|
const summarizeComments = (comments) => {
|
|
87
98
|
if (!comments.length)
|
|
88
99
|
return "No prior comments.";
|
|
@@ -102,6 +113,17 @@ const truncateSection = (label, text, limit) => {
|
|
|
102
113
|
const remaining = text.length - limit;
|
|
103
114
|
return `${trimmed}\n...[truncated ${remaining} chars from ${label}]`;
|
|
104
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
|
+
};
|
|
105
127
|
const withTimeout = async (promise, ms, label) => {
|
|
106
128
|
let timeoutId;
|
|
107
129
|
const timeoutPromise = new Promise((_, reject) => {
|
|
@@ -134,6 +156,12 @@ const JSON_CONTRACT = `{
|
|
|
134
156
|
"resolvedSlugs": ["Optional list of comment slugs that are confirmed fixed"],
|
|
135
157
|
"unresolvedSlugs": ["Optional list of comment slugs still open or reintroduced"]
|
|
136
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());
|
|
137
165
|
const normalizeSingleLine = (value, fallback) => {
|
|
138
166
|
const trimmed = (value ?? "").replace(/\s+/g, " ").trim();
|
|
139
167
|
return trimmed || fallback;
|
|
@@ -155,6 +183,56 @@ const normalizePath = (value) => value
|
|
|
155
183
|
.replace(/\\/g, "/")
|
|
156
184
|
.replace(/^\.\//, "")
|
|
157
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
|
+
};
|
|
158
236
|
const parseCommentBody = (body) => {
|
|
159
237
|
const trimmed = (body ?? "").trim();
|
|
160
238
|
if (!trimmed)
|
|
@@ -189,8 +267,15 @@ const buildCommentBacklog = (comments) => {
|
|
|
189
267
|
const lines = [];
|
|
190
268
|
const toSingleLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
191
269
|
for (const comment of comments) {
|
|
192
|
-
const slug = comment.slug?.trim() || undefined;
|
|
193
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
|
+
});
|
|
194
279
|
const key = slug ??
|
|
195
280
|
`${comment.sourceCommand}:${comment.file ?? ""}:${comment.line ?? ""}:${details.message || comment.body}`;
|
|
196
281
|
if (seen.has(key))
|
|
@@ -256,9 +341,11 @@ export class CodeReviewService {
|
|
|
256
341
|
const repo = await GlobalRepository.create();
|
|
257
342
|
const agentService = new AgentService(repo);
|
|
258
343
|
const routingService = await RoutingService.create();
|
|
344
|
+
const docdexRepoId = workspace.config?.docdexRepoId ?? process.env.MCODA_DOCDEX_REPO_ID ?? process.env.DOCDEX_REPO_ID;
|
|
259
345
|
const docdex = new DocdexClient({
|
|
260
346
|
workspaceRoot: workspace.workspaceRoot,
|
|
261
347
|
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
348
|
+
repoId: docdexRepoId,
|
|
262
349
|
});
|
|
263
350
|
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
264
351
|
const jobService = new JobService(workspace, workspaceRepo);
|
|
@@ -296,6 +383,14 @@ export class CodeReviewService {
|
|
|
296
383
|
await maybeClose(this.deps.routingService);
|
|
297
384
|
await maybeClose(this.deps.docdex);
|
|
298
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
|
+
}
|
|
299
394
|
async readPromptFiles(paths) {
|
|
300
395
|
const contents = [];
|
|
301
396
|
const seen = new Set();
|
|
@@ -316,21 +411,11 @@ export class CodeReviewService {
|
|
|
316
411
|
}
|
|
317
412
|
async ensureMcoda() {
|
|
318
413
|
await PathHelper.ensureDir(this.workspace.mcodaDir);
|
|
319
|
-
const gitignorePath = path.join(this.workspace.workspaceRoot, ".gitignore");
|
|
320
|
-
const entry = ".mcoda/\n";
|
|
321
|
-
try {
|
|
322
|
-
const content = await fs.readFile(gitignorePath, "utf8");
|
|
323
|
-
if (!content.includes(".mcoda/")) {
|
|
324
|
-
await fs.writeFile(gitignorePath, `${content.trimEnd()}\n${entry}`, "utf8");
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
await fs.writeFile(gitignorePath, entry, "utf8");
|
|
329
|
-
}
|
|
330
414
|
}
|
|
331
415
|
async loadPrompts(agentId) {
|
|
332
|
-
const mcodaPromptPath = path.join(this.workspace.
|
|
416
|
+
const mcodaPromptPath = path.join(this.workspace.mcodaDir, "prompts", "code-reviewer.md");
|
|
333
417
|
const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "code-reviewer.md");
|
|
418
|
+
const repoPromptPath = resolveRepoPromptPath("code-reviewer.md");
|
|
334
419
|
try {
|
|
335
420
|
await fs.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
336
421
|
await fs.access(mcodaPromptPath);
|
|
@@ -343,30 +428,29 @@ export class CodeReviewService {
|
|
|
343
428
|
console.info(`[code-review] copied code-reviewer prompt to ${mcodaPromptPath}`);
|
|
344
429
|
}
|
|
345
430
|
catch {
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
}
|
|
348
440
|
}
|
|
349
441
|
}
|
|
350
|
-
const filePrompts = await this.readPromptFiles([mcodaPromptPath, workspacePromptPath]);
|
|
351
442
|
const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
if (agentPrompts?.commandPrompts?.["code-review"]) {
|
|
355
|
-
parts.push(agentPrompts.commandPrompts["code-review"]);
|
|
356
|
-
}
|
|
357
|
-
if (!parts.length)
|
|
358
|
-
parts.push(DEFAULT_CODE_REVIEW_PROMPT);
|
|
359
|
-
return parts.filter(Boolean).join("\n\n");
|
|
360
|
-
})();
|
|
443
|
+
const filePrompt = await readPromptFile(mcodaPromptPath, DEFAULT_CODE_REVIEW_PROMPT);
|
|
444
|
+
const commandPrompt = agentPrompts?.commandPrompts?.["code-review"]?.trim() || filePrompt;
|
|
361
445
|
return {
|
|
362
|
-
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
363
|
-
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
364
|
-
commandPrompt:
|
|
446
|
+
jobPrompt: sanitizeNonGatewayPrompt(agentPrompts?.jobPrompt) ?? DEFAULT_JOB_PROMPT,
|
|
447
|
+
characterPrompt: sanitizeNonGatewayPrompt(agentPrompts?.characterPrompt) ?? DEFAULT_CHARACTER_PROMPT,
|
|
448
|
+
commandPrompt: commandPrompt || undefined,
|
|
365
449
|
};
|
|
366
450
|
}
|
|
367
451
|
async loadRunbookAndChecklists() {
|
|
368
452
|
const extras = [];
|
|
369
|
-
const runbookPath = path.join(this.workspace.
|
|
453
|
+
const runbookPath = path.join(this.workspace.mcodaDir, "prompts", "commands", "code-review.md");
|
|
370
454
|
try {
|
|
371
455
|
const content = await fs.readFile(runbookPath, "utf8");
|
|
372
456
|
extras.push(content);
|
|
@@ -374,7 +458,7 @@ export class CodeReviewService {
|
|
|
374
458
|
catch {
|
|
375
459
|
/* optional */
|
|
376
460
|
}
|
|
377
|
-
const checklistDir = path.join(this.workspace.
|
|
461
|
+
const checklistDir = path.join(this.workspace.mcodaDir, "checklists");
|
|
378
462
|
try {
|
|
379
463
|
const entries = await fs.readdir(checklistDir);
|
|
380
464
|
for (const entry of entries) {
|
|
@@ -395,7 +479,13 @@ export class CodeReviewService {
|
|
|
395
479
|
commandName: "code-review",
|
|
396
480
|
overrideAgentSlug: agentName,
|
|
397
481
|
});
|
|
398
|
-
|
|
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;
|
|
399
489
|
}
|
|
400
490
|
ensureRatingService() {
|
|
401
491
|
if (!this.ratingService) {
|
|
@@ -425,7 +515,7 @@ export class CodeReviewService {
|
|
|
425
515
|
projectKey: filters.projectKey,
|
|
426
516
|
epicKey: filters.epicKey,
|
|
427
517
|
storyKey: filters.storyKey,
|
|
428
|
-
statuses: filters.statusFilter,
|
|
518
|
+
statuses: filters.statusFilter && filters.statusFilter.length ? filters.statusFilter : undefined,
|
|
429
519
|
verbose: true,
|
|
430
520
|
});
|
|
431
521
|
let tasks = result.summary.tasks;
|
|
@@ -447,13 +537,13 @@ export class CodeReviewService {
|
|
|
447
537
|
}
|
|
448
538
|
}
|
|
449
539
|
async persistState(jobId, state) {
|
|
450
|
-
const dir = REVIEW_DIR(this.workspace.
|
|
540
|
+
const dir = REVIEW_DIR(this.workspace.mcodaDir, jobId);
|
|
451
541
|
await fs.mkdir(dir, { recursive: true });
|
|
452
|
-
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");
|
|
453
543
|
}
|
|
454
544
|
async loadState(jobId) {
|
|
455
545
|
try {
|
|
456
|
-
const raw = await fs.readFile(STATE_PATH(this.workspace.
|
|
546
|
+
const raw = await fs.readFile(STATE_PATH(this.workspace.mcodaDir, jobId), "utf8");
|
|
457
547
|
return JSON.parse(raw);
|
|
458
548
|
}
|
|
459
549
|
catch {
|
|
@@ -480,21 +570,44 @@ export class CodeReviewService {
|
|
|
480
570
|
}
|
|
481
571
|
return Array.from(hints).slice(0, 8);
|
|
482
572
|
}
|
|
483
|
-
async gatherDocContext(taskTitle, paths, acceptance) {
|
|
573
|
+
async gatherDocContext(taskTitle, paths, acceptance, docLinks = []) {
|
|
484
574
|
const snippets = [];
|
|
485
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
|
+
}
|
|
486
585
|
const queries = [...new Set([...(paths.length ? this.componentHintsFromPaths(paths) : []), taskTitle, ...(acceptance ?? [])])].slice(0, 8);
|
|
487
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
|
+
};
|
|
488
600
|
for (const query of queries) {
|
|
489
601
|
try {
|
|
490
602
|
const docs = await withTimeout(this.deps.docdex.search({
|
|
491
603
|
query,
|
|
492
604
|
profile: "workspace-code",
|
|
493
605
|
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}"`);
|
|
494
|
-
|
|
606
|
+
const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
|
|
607
|
+
snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
|
|
495
608
|
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
496
609
|
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
497
|
-
return `- [${doc
|
|
610
|
+
return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
|
|
498
611
|
}));
|
|
499
612
|
}
|
|
500
613
|
catch (error) {
|
|
@@ -506,10 +619,11 @@ export class CodeReviewService {
|
|
|
506
619
|
query,
|
|
507
620
|
profile: "workspace-code",
|
|
508
621
|
}), DOCDEX_TIMEOUT_MS, `docdex search for "${query}" after reindex`);
|
|
509
|
-
|
|
622
|
+
const filteredDocs = docs.filter((doc) => !isDocContextExcluded(doc.path ?? doc.title ?? doc.id, false));
|
|
623
|
+
snippets.push(...filteredDocs.slice(0, 2).map((doc) => {
|
|
510
624
|
const content = (doc.segments?.[0]?.content ?? doc.content ?? "").slice(0, 400);
|
|
511
625
|
const ref = doc.path ?? doc.id ?? doc.title ?? query;
|
|
512
|
-
return `- [${doc
|
|
626
|
+
return `- [${resolveDocType(doc)}] ${ref}: ${content}`;
|
|
513
627
|
}));
|
|
514
628
|
continue;
|
|
515
629
|
}
|
|
@@ -521,6 +635,44 @@ export class CodeReviewService {
|
|
|
521
635
|
warnings.push(`docdex search failed for ${query}: ${error.message}`);
|
|
522
636
|
}
|
|
523
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
|
+
}
|
|
524
676
|
return { snippets: Array.from(new Set(snippets)), warnings };
|
|
525
677
|
}
|
|
526
678
|
buildReviewPrompt(params) {
|
|
@@ -533,13 +685,24 @@ export class CodeReviewService {
|
|
|
533
685
|
const commentBacklog = params.commentBacklog
|
|
534
686
|
? truncateSection("comment backlog", params.commentBacklog, REVIEW_PROMPT_LIMITS.history)
|
|
535
687
|
: "";
|
|
536
|
-
const
|
|
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
|
+
: "";
|
|
537
692
|
const openapiSnippet = params.openapiSnippet ? truncateSection("openapi", params.openapiSnippet, REVIEW_PROMPT_LIMITS.openapi) : undefined;
|
|
538
693
|
const checklistsText = params.checklists?.length
|
|
539
694
|
? truncateSection("checklists", params.checklists.join("\n\n"), REVIEW_PROMPT_LIMITS.checklist)
|
|
540
695
|
: "";
|
|
541
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");
|
|
542
704
|
parts.push([
|
|
705
|
+
reviewFocus,
|
|
543
706
|
`Task ${params.task.key}: ${params.task.title}`,
|
|
544
707
|
`Epic: ${params.task.epicKey ?? ""} ${params.task.epicTitle ?? ""}`.trim(),
|
|
545
708
|
`Epic description: ${params.task.epicDescription ? params.task.epicDescription : "none"}`,
|
|
@@ -548,22 +711,24 @@ export class CodeReviewService {
|
|
|
548
711
|
`Status: ${params.task.status}, Branch: ${params.branch ?? params.task.vcsBranch ?? "n/a"} (base ${params.baseRef})`,
|
|
549
712
|
`Task description: ${params.task.description ? params.task.description : "none"}`,
|
|
550
713
|
`History:\n${historySummary}`,
|
|
551
|
-
commentBacklog
|
|
552
|
-
|
|
714
|
+
commentBacklog
|
|
715
|
+
? `Code-review comment backlog (unresolved slugs):\n${commentBacklog}`
|
|
716
|
+
: "Code-review comment backlog: none",
|
|
717
|
+
`Task DoD / acceptance criteria: ${acceptance}`,
|
|
553
718
|
docContextText ? `Doc context (docdex excerpts):\n${docContextText}` : "Doc context: none",
|
|
554
719
|
openapiSnippet
|
|
555
720
|
? `OpenAPI (authoritative contract; do not invent endpoints outside this):\n${openapiSnippet}`
|
|
556
721
|
: "OpenAPI: not provided; avoid inventing endpoints.",
|
|
557
722
|
checklistsText ? `Review checklists/runbook:\n${checklistsText}` : "Checklists: none",
|
|
558
|
-
"Diff:\n" + diffText,
|
|
723
|
+
params.diffEmpty ? "Diff: (empty — no changes between base and branch)" : "Diff:\n" + diffText,
|
|
559
724
|
"Respond with STRICT JSON only, matching:\n" + JSON_CONTRACT,
|
|
560
|
-
"Rules: honor OpenAPI contracts; cite doc context where relevant; include resolvedSlugs/unresolvedSlugs for comment backlog items; 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.",
|
|
561
726
|
].join("\n"));
|
|
562
727
|
return parts.join("\n\n");
|
|
563
728
|
}
|
|
564
729
|
async buildHistorySummary(taskId) {
|
|
565
730
|
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
566
|
-
sourceCommands: ["work-on-tasks", "code-review"
|
|
731
|
+
sourceCommands: ["work-on-tasks", "code-review"],
|
|
567
732
|
limit: 10,
|
|
568
733
|
});
|
|
569
734
|
const lastReview = await this.deps.workspaceRepo.getLatestTaskReview(taskId);
|
|
@@ -585,12 +750,12 @@ export class CodeReviewService {
|
|
|
585
750
|
}
|
|
586
751
|
}
|
|
587
752
|
if (!parts.length)
|
|
588
|
-
return "No prior review
|
|
753
|
+
return "No prior review history.";
|
|
589
754
|
return parts.join("\n");
|
|
590
755
|
}
|
|
591
756
|
async loadCommentContext(taskId) {
|
|
592
757
|
const comments = await this.deps.workspaceRepo.listTaskComments(taskId, {
|
|
593
|
-
sourceCommands: ["code-review"
|
|
758
|
+
sourceCommands: ["code-review"],
|
|
594
759
|
limit: 50,
|
|
595
760
|
});
|
|
596
761
|
const unresolved = comments.filter((comment) => !comment.resolvedAt);
|
|
@@ -649,9 +814,10 @@ export class CodeReviewService {
|
|
|
649
814
|
}
|
|
650
815
|
}
|
|
651
816
|
const reviewSlugIndex = this.buildCommentSlugIndex(params.existingComments.filter((comment) => comment.sourceCommand === "code-review"));
|
|
652
|
-
const
|
|
817
|
+
const allowedSlugs = new Set(existingBySlug.keys());
|
|
818
|
+
const resolvedSlugs = normalizeSlugList(params.resolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug));
|
|
653
819
|
const resolvedSet = new Set(resolvedSlugs);
|
|
654
|
-
const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined));
|
|
820
|
+
const unresolvedSet = new Set(normalizeSlugList(params.unresolvedSlugs ?? undefined).filter((slug) => allowedSlugs.has(slug)));
|
|
655
821
|
const findingSlugs = [];
|
|
656
822
|
for (const finding of params.findings ?? []) {
|
|
657
823
|
const slug = this.resolveFindingSlug(finding, reviewSlugIndex);
|
|
@@ -881,12 +1047,12 @@ export class CodeReviewService {
|
|
|
881
1047
|
});
|
|
882
1048
|
}
|
|
883
1049
|
async persistContext(jobId, taskId, context) {
|
|
884
|
-
const dir = path.join(REVIEW_DIR(this.workspace.
|
|
1050
|
+
const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "context");
|
|
885
1051
|
await fs.mkdir(dir, { recursive: true });
|
|
886
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");
|
|
887
1053
|
}
|
|
888
1054
|
async persistDiff(jobId, taskId, diff) {
|
|
889
|
-
const dir = path.join(REVIEW_DIR(this.workspace.
|
|
1055
|
+
const dir = path.join(REVIEW_DIR(this.workspace.mcodaDir, jobId), "diffs");
|
|
890
1056
|
await fs.mkdir(dir, { recursive: true });
|
|
891
1057
|
await fs.writeFile(path.join(dir, `${taskId}.diff`), diff, "utf8");
|
|
892
1058
|
// structured review diff snapshot
|
|
@@ -919,6 +1085,13 @@ export class CodeReviewService {
|
|
|
919
1085
|
files.push(current);
|
|
920
1086
|
await fs.writeFile(path.join(dir, `${taskId}.json`), JSON.stringify({ schema_version: 1, task_id: taskId, files }, null, 2), "utf8");
|
|
921
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
|
+
}
|
|
922
1095
|
severityToPriority(severity) {
|
|
923
1096
|
if (!severity)
|
|
924
1097
|
return null;
|
|
@@ -926,6 +1099,13 @@ export class CodeReviewService {
|
|
|
926
1099
|
const order = { critical: 1, high: 2, medium: 3, low: 4, info: 5 };
|
|
927
1100
|
return order[normalized] ?? null;
|
|
928
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
|
+
}
|
|
929
1109
|
shouldCreateFollowupTask(decision, finding) {
|
|
930
1110
|
// SDS rule: create follow-ups for blocking/changes_requested decisions or critical/high issues,
|
|
931
1111
|
// and for contract/security/bug types at medium+ severity. Do not create for approve+low/info.
|
|
@@ -1026,6 +1206,9 @@ export class CodeReviewService {
|
|
|
1026
1206
|
const storyId = genericTarget ? genericContainers.story.id : params.task.userStoryId;
|
|
1027
1207
|
const storyKey = genericTarget ? genericContainers.story.key : params.task.storyKey ?? genericContainers?.story.key ?? "US-AUTO";
|
|
1028
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;
|
|
1029
1212
|
const taskKey = await ensureKey(storyId, storyKey);
|
|
1030
1213
|
inserts.push({
|
|
1031
1214
|
projectId: params.task.projectId,
|
|
@@ -1036,7 +1219,7 @@ export class CodeReviewService {
|
|
|
1036
1219
|
description: this.buildFollowupDescription(params.task, finding, params.decision),
|
|
1037
1220
|
type: finding.type ?? (params.decision === "changes_requested" || params.decision === "block" ? "bug" : "issue"),
|
|
1038
1221
|
status: "not_started",
|
|
1039
|
-
storyPoints:
|
|
1222
|
+
storyPoints: boundedPoints,
|
|
1040
1223
|
priority: this.severityToPriority(finding.severity),
|
|
1041
1224
|
metadata: {
|
|
1042
1225
|
source: "code-review",
|
|
@@ -1052,6 +1235,7 @@ export class CodeReviewService {
|
|
|
1052
1235
|
suggestedFix: finding.suggestedFix,
|
|
1053
1236
|
generic: genericTarget ? true : false,
|
|
1054
1237
|
decision: params.decision,
|
|
1238
|
+
complexity: boundedPoints,
|
|
1055
1239
|
},
|
|
1056
1240
|
});
|
|
1057
1241
|
}
|
|
@@ -1074,7 +1258,14 @@ export class CodeReviewService {
|
|
|
1074
1258
|
await this.ensureMcoda();
|
|
1075
1259
|
const agentStream = request.agentStream !== false;
|
|
1076
1260
|
const baseRef = request.baseRef ?? this.workspace.config?.branch ?? DEFAULT_BASE_BRANCH;
|
|
1077
|
-
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);
|
|
1078
1269
|
let state;
|
|
1079
1270
|
const commandRun = await this.deps.jobService.startCommandRun("code-review", request.projectKey, {
|
|
1080
1271
|
taskIds: request.taskKeys,
|
|
@@ -1084,6 +1275,10 @@ export class CodeReviewService {
|
|
|
1084
1275
|
let jobId = request.resumeJobId;
|
|
1085
1276
|
let selectedTaskIds = [];
|
|
1086
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;
|
|
1087
1282
|
let selectedTasks = [];
|
|
1088
1283
|
if (request.resumeJobId) {
|
|
1089
1284
|
const job = await this.deps.jobService.getJob(request.resumeJobId);
|
|
@@ -1092,6 +1287,9 @@ export class CodeReviewService {
|
|
|
1092
1287
|
if ((job.commandName ?? job.type) !== "code-review" && job.type !== "review") {
|
|
1093
1288
|
throw new Error(`Job ${request.resumeJobId} is not a code-review job`);
|
|
1094
1289
|
}
|
|
1290
|
+
if (request.createFollowupTasks === undefined) {
|
|
1291
|
+
allowFollowups = Boolean(job.payload?.createFollowupTasks);
|
|
1292
|
+
}
|
|
1095
1293
|
state = await this.loadState(request.resumeJobId);
|
|
1096
1294
|
selectedTaskIds = state?.selectedTaskIds ?? (Array.isArray(job.payload?.selection) ? job.payload.selection : []);
|
|
1097
1295
|
if (!selectedTaskIds.length) {
|
|
@@ -1128,7 +1326,7 @@ export class CodeReviewService {
|
|
|
1128
1326
|
epicKey: request.epicKey,
|
|
1129
1327
|
storyKey: request.storyKey,
|
|
1130
1328
|
taskKeys: request.taskKeys,
|
|
1131
|
-
statusFilter,
|
|
1329
|
+
statusFilter: ignoreStatusFilter ? undefined : statusFilter,
|
|
1132
1330
|
limit: request.limit,
|
|
1133
1331
|
});
|
|
1134
1332
|
}
|
|
@@ -1138,10 +1336,11 @@ export class CodeReviewService {
|
|
|
1138
1336
|
epicKey: request.epicKey,
|
|
1139
1337
|
storyKey: request.storyKey,
|
|
1140
1338
|
taskKeys: request.taskKeys,
|
|
1141
|
-
statusFilter,
|
|
1339
|
+
statusFilter: ignoreStatusFilter ? undefined : statusFilter,
|
|
1142
1340
|
limit: request.limit,
|
|
1341
|
+
ignoreStatusFilter,
|
|
1143
1342
|
});
|
|
1144
|
-
warnings = [...selection.warnings];
|
|
1343
|
+
warnings = [...warnings, ...selection.warnings];
|
|
1145
1344
|
selectedTasks = selection.ordered.map((t) => t.task);
|
|
1146
1345
|
}
|
|
1147
1346
|
selectedTaskIds = selectedTasks.map((t) => t.id);
|
|
@@ -1152,12 +1351,13 @@ export class CodeReviewService {
|
|
|
1152
1351
|
epicKey: request.epicKey,
|
|
1153
1352
|
storyKey: request.storyKey,
|
|
1154
1353
|
tasks: request.taskKeys,
|
|
1155
|
-
statusFilter,
|
|
1354
|
+
statusFilter: ignoreStatusFilter ? [] : statusFilter,
|
|
1156
1355
|
baseRef,
|
|
1157
1356
|
selection: selectedTaskIds,
|
|
1158
1357
|
dryRun: request.dryRun ?? false,
|
|
1159
1358
|
agent: request.agentName,
|
|
1160
1359
|
agentStream,
|
|
1360
|
+
createFollowupTasks: allowFollowups,
|
|
1161
1361
|
},
|
|
1162
1362
|
totalItems: selectedTaskIds.length,
|
|
1163
1363
|
processedItems: 0,
|
|
@@ -1194,10 +1394,38 @@ export class CodeReviewService {
|
|
|
1194
1394
|
const tasks = selectedTasks.length && selectedTaskIds.length === selectedTasks.length
|
|
1195
1395
|
? selectedTasks
|
|
1196
1396
|
: await this.deps.workspaceRepo.getTasksWithRelations(selectedTaskIds);
|
|
1197
|
-
|
|
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
|
+
}
|
|
1198
1426
|
const prompts = await this.loadPrompts(agent.id);
|
|
1199
1427
|
const extras = await this.loadRunbookAndChecklists();
|
|
1200
|
-
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot);
|
|
1428
|
+
const projectGuidance = await loadProjectGuidance(this.workspace.workspaceRoot, this.workspace.mcodaDir);
|
|
1201
1429
|
if (projectGuidance) {
|
|
1202
1430
|
console.info(`[code-review] loaded project guidance from ${projectGuidance.source}`);
|
|
1203
1431
|
}
|
|
@@ -1232,6 +1460,78 @@ export class CodeReviewService {
|
|
|
1232
1460
|
});
|
|
1233
1461
|
};
|
|
1234
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
|
+
};
|
|
1235
1535
|
const maybeRateTask = async (task, taskRunId, tokensTotal) => {
|
|
1236
1536
|
if (!request.rateAgents || tokensTotal <= 0)
|
|
1237
1537
|
return;
|
|
@@ -1245,7 +1545,7 @@ export class CodeReviewService {
|
|
|
1245
1545
|
commandRunId: commandRun.id,
|
|
1246
1546
|
taskId: task.id,
|
|
1247
1547
|
taskKey: task.key,
|
|
1248
|
-
discipline: task.type ??
|
|
1548
|
+
discipline: task.type ?? "review",
|
|
1249
1549
|
complexity: this.resolveTaskComplexity(task),
|
|
1250
1550
|
});
|
|
1251
1551
|
}
|
|
@@ -1266,8 +1566,36 @@ export class CodeReviewService {
|
|
|
1266
1566
|
}
|
|
1267
1567
|
}
|
|
1268
1568
|
};
|
|
1569
|
+
let abortRemainingReason = null;
|
|
1269
1570
|
for (const task of tasks) {
|
|
1571
|
+
if (abortRemainingReason)
|
|
1572
|
+
break;
|
|
1270
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
|
+
};
|
|
1271
1599
|
const statusBefore = task.status;
|
|
1272
1600
|
const taskRun = await this.deps.workspaceRepo.createTaskRun({
|
|
1273
1601
|
taskId: task.id,
|
|
@@ -1276,21 +1604,42 @@ export class CodeReviewService {
|
|
|
1276
1604
|
commandRunId: commandRun.id,
|
|
1277
1605
|
agentId: agent.id,
|
|
1278
1606
|
status: "running",
|
|
1279
|
-
startedAt
|
|
1607
|
+
startedAt,
|
|
1280
1608
|
storyPointsAtRun: task.storyPoints ?? null,
|
|
1281
1609
|
gitBranch: task.vcsBranch ?? null,
|
|
1282
1610
|
gitBaseBranch: task.vcsBaseBranch ?? null,
|
|
1283
1611
|
gitCommitSha: task.vcsLastCommitSha ?? null,
|
|
1284
1612
|
});
|
|
1613
|
+
const statusContext = {
|
|
1614
|
+
commandName: "code-review",
|
|
1615
|
+
jobId,
|
|
1616
|
+
taskRunId: taskRun.id,
|
|
1617
|
+
agentId: agent.id,
|
|
1618
|
+
metadata: { lane: "review" },
|
|
1619
|
+
};
|
|
1285
1620
|
const findings = [];
|
|
1286
1621
|
let decision;
|
|
1287
1622
|
let statusAfter;
|
|
1623
|
+
let reviewErrorCode;
|
|
1288
1624
|
const followupCreated = [];
|
|
1289
1625
|
let commentResolution;
|
|
1290
1626
|
// Debug visibility: show prompts/task details for this run
|
|
1291
1627
|
const systemPrompt = systemPrompts.join("\n\n");
|
|
1292
1628
|
let tokensTotal = 0;
|
|
1629
|
+
let agentOutput = "";
|
|
1293
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
|
+
});
|
|
1294
1643
|
const metadata = task.metadata ?? {};
|
|
1295
1644
|
const allowedFiles = Array.isArray(metadata.files) ? metadata.files : [];
|
|
1296
1645
|
const diffResult = await this.buildDiff(task, state?.baseRef ?? baseRef, allowedFiles);
|
|
@@ -1312,9 +1661,10 @@ export class CodeReviewService {
|
|
|
1312
1661
|
allowedFiles,
|
|
1313
1662
|
},
|
|
1314
1663
|
});
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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);
|
|
1318
1668
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1319
1669
|
taskRunId: taskRun.id,
|
|
1320
1670
|
sequence: this.sequenceForTask(taskRun.id),
|
|
@@ -1322,43 +1672,6 @@ export class CodeReviewService {
|
|
|
1322
1672
|
source: "review_warning",
|
|
1323
1673
|
message,
|
|
1324
1674
|
});
|
|
1325
|
-
if (!request.dryRun) {
|
|
1326
|
-
await this.stateService.markBlocked(task, "review_empty_diff");
|
|
1327
|
-
statusAfter = "blocked";
|
|
1328
|
-
}
|
|
1329
|
-
await this.writeReviewSummaryComment({
|
|
1330
|
-
task,
|
|
1331
|
-
taskRunId: taskRun.id,
|
|
1332
|
-
jobId,
|
|
1333
|
-
agentId: agent.id,
|
|
1334
|
-
statusBefore,
|
|
1335
|
-
statusAfter: statusAfter ?? statusBefore,
|
|
1336
|
-
decision: "block",
|
|
1337
|
-
summary: message,
|
|
1338
|
-
findingsCount: 0,
|
|
1339
|
-
});
|
|
1340
|
-
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1341
|
-
status: "failed",
|
|
1342
|
-
finishedAt: new Date().toISOString(),
|
|
1343
|
-
runContext: { decision: "block", reason: "empty_diff" },
|
|
1344
|
-
});
|
|
1345
|
-
state?.reviewed.push({ taskId: task.id, decision: "block" });
|
|
1346
|
-
await this.persistState(jobId, state);
|
|
1347
|
-
await this.writeCheckpoint(jobId, "review_applied", { reviewed: state?.reviewed ?? [], schema_version: 1 });
|
|
1348
|
-
results.push({
|
|
1349
|
-
taskId: task.id,
|
|
1350
|
-
taskKey: task.key,
|
|
1351
|
-
statusBefore,
|
|
1352
|
-
statusAfter: statusAfter ?? statusBefore,
|
|
1353
|
-
decision: "block",
|
|
1354
|
-
findings,
|
|
1355
|
-
followupTasks: followupCreated,
|
|
1356
|
-
});
|
|
1357
|
-
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1358
|
-
processedItems: state?.reviewed.length ?? 0,
|
|
1359
|
-
});
|
|
1360
|
-
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
1361
|
-
continue;
|
|
1362
1675
|
}
|
|
1363
1676
|
const historySummary = await this.buildHistorySummary(task.id);
|
|
1364
1677
|
const commentContext = await this.loadCommentContext(task.id);
|
|
@@ -1371,7 +1684,8 @@ export class CodeReviewService {
|
|
|
1371
1684
|
message: "Loaded task history",
|
|
1372
1685
|
});
|
|
1373
1686
|
const changedPaths = this.extractPathsFromDiff(diff);
|
|
1374
|
-
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 : []);
|
|
1375
1689
|
if (docLinks.warnings.length)
|
|
1376
1690
|
warnings.push(...docLinks.warnings);
|
|
1377
1691
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -1397,6 +1711,7 @@ export class CodeReviewService {
|
|
|
1397
1711
|
systemPrompts,
|
|
1398
1712
|
task,
|
|
1399
1713
|
diff,
|
|
1714
|
+
diffEmpty,
|
|
1400
1715
|
docContext: docLinks.snippets,
|
|
1401
1716
|
openapiSnippet,
|
|
1402
1717
|
historySummary,
|
|
@@ -1404,6 +1719,7 @@ export class CodeReviewService {
|
|
|
1404
1719
|
baseRef: state?.baseRef ?? baseRef,
|
|
1405
1720
|
branch: task.vcsBranch ?? undefined,
|
|
1406
1721
|
});
|
|
1722
|
+
const requireCommentSlugs = Boolean(commentBacklog.trim());
|
|
1407
1723
|
const separator = "============================================================";
|
|
1408
1724
|
const deps = Array.isArray(task.dependencyKeys) && task.dependencyKeys.length
|
|
1409
1725
|
? task.dependencyKeys
|
|
@@ -1411,7 +1727,6 @@ export class CodeReviewService {
|
|
|
1411
1727
|
? task.metadata.depends_on
|
|
1412
1728
|
: [];
|
|
1413
1729
|
console.info(separator);
|
|
1414
|
-
console.info("[code-review] START OF TASK");
|
|
1415
1730
|
console.info(`[code-review] Task key: ${task.key}`);
|
|
1416
1731
|
console.info(`[code-review] Title: ${task.title ?? "(none)"}`);
|
|
1417
1732
|
console.info(`[code-review] Description: ${task.description ?? "(none)"}`);
|
|
@@ -1429,19 +1744,20 @@ export class CodeReviewService {
|
|
|
1429
1744
|
docdex: docLinks.snippets,
|
|
1430
1745
|
openapiSnippet,
|
|
1431
1746
|
changedPaths,
|
|
1747
|
+
diffEmpty,
|
|
1432
1748
|
});
|
|
1433
1749
|
state?.contextBuilt.push(task.id);
|
|
1434
1750
|
await this.persistState(jobId, state);
|
|
1435
1751
|
await this.writeCheckpoint(jobId, "context_built", { contextBuilt: state?.contextBuilt ?? [], schema_version: 1 });
|
|
1436
|
-
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta) => {
|
|
1752
|
+
const recordUsage = async (phase, promptText, outputText, durationSeconds, tokenMeta, agentUsed = agent, attempt = 1) => {
|
|
1437
1753
|
const tokensPrompt = tokenMeta?.tokensPrompt ?? estimateTokens(promptText);
|
|
1438
1754
|
const tokensCompletion = tokenMeta?.tokensCompletion ?? estimateTokens(outputText);
|
|
1439
1755
|
const entryTotal = tokenMeta?.tokensTotal ?? tokensPrompt + tokensCompletion;
|
|
1440
1756
|
tokensTotal += entryTotal;
|
|
1441
1757
|
await this.deps.jobService.recordTokenUsage({
|
|
1442
1758
|
workspaceId: this.workspace.workspaceId,
|
|
1443
|
-
agentId:
|
|
1444
|
-
modelName: tokenMeta?.model ??
|
|
1759
|
+
agentId: agentUsed.id,
|
|
1760
|
+
modelName: tokenMeta?.model ?? agentUsed.defaultModel ?? undefined,
|
|
1445
1761
|
jobId,
|
|
1446
1762
|
commandRunId: commandRun.id,
|
|
1447
1763
|
taskRunId: taskRun.id,
|
|
@@ -1452,45 +1768,82 @@ export class CodeReviewService {
|
|
|
1452
1768
|
tokensTotal: entryTotal,
|
|
1453
1769
|
durationSeconds,
|
|
1454
1770
|
timestamp: new Date().toISOString(),
|
|
1455
|
-
metadata: { commandName: "code-review", phase, action: phase },
|
|
1771
|
+
metadata: { commandName: "code-review", phase, action: phase, attempt },
|
|
1456
1772
|
});
|
|
1457
1773
|
};
|
|
1458
|
-
|
|
1774
|
+
agentOutput = "";
|
|
1459
1775
|
let durationSeconds = 0;
|
|
1460
|
-
const started = Date.now();
|
|
1461
1776
|
let lastStreamMeta;
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
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;
|
|
1472
1812
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1473
1813
|
taskRunId: taskRun.id,
|
|
1474
1814
|
sequence: this.sequenceForTask(taskRun.id),
|
|
1475
1815
|
timestamp: new Date().toISOString(),
|
|
1476
|
-
source:
|
|
1477
|
-
message:
|
|
1816
|
+
source: logSource,
|
|
1817
|
+
message: output,
|
|
1478
1818
|
});
|
|
1479
1819
|
}
|
|
1480
|
-
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;
|
|
1481
1828
|
}
|
|
1482
|
-
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
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;
|
|
1486
1836
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1487
1837
|
taskRunId: taskRun.id,
|
|
1488
1838
|
sequence: this.sequenceForTask(taskRun.id),
|
|
1489
1839
|
timestamp: new Date().toISOString(),
|
|
1490
|
-
source: "
|
|
1491
|
-
message:
|
|
1840
|
+
source: "agent_retry",
|
|
1841
|
+
message: `Transient agent error (${message}); retrying once with ${agentUsedForOutput.slug ?? agentUsedForOutput.id}.`,
|
|
1492
1842
|
});
|
|
1493
|
-
|
|
1843
|
+
const invocation = await invokeReviewAgent(agentUsedForOutput, false, "agent_retry");
|
|
1844
|
+
agentOutput = invocation.output;
|
|
1845
|
+
durationSeconds = invocation.durationSeconds;
|
|
1846
|
+
lastStreamMeta = invocation.metadata;
|
|
1494
1847
|
}
|
|
1495
1848
|
const tokenMetaMain = lastStreamMeta
|
|
1496
1849
|
? {
|
|
@@ -1502,21 +1855,60 @@ export class CodeReviewService {
|
|
|
1502
1855
|
model: (lastStreamMeta.model ?? lastStreamMeta.model_name ?? null),
|
|
1503
1856
|
}
|
|
1504
1857
|
: undefined;
|
|
1505
|
-
await recordUsage("review_main", prompt, agentOutput, durationSeconds, tokenMetaMain);
|
|
1506
|
-
|
|
1507
|
-
let
|
|
1508
|
-
|
|
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.";
|
|
1509
1882
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1510
1883
|
taskRunId: taskRun.id,
|
|
1511
1884
|
sequence: this.sequenceForTask(taskRun.id),
|
|
1512
1885
|
timestamp: new Date().toISOString(),
|
|
1513
1886
|
source: "agent",
|
|
1514
|
-
message:
|
|
1887
|
+
message: retryReason,
|
|
1515
1888
|
});
|
|
1516
|
-
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}`;
|
|
1517
1899
|
const retryStarted = Date.now();
|
|
1518
|
-
|
|
1519
|
-
|
|
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 ?? "";
|
|
1520
1912
|
const retryDuration = Math.round(((Date.now() - retryStarted) / 1000) * 1000) / 1000;
|
|
1521
1913
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1522
1914
|
taskRunId: taskRun.id,
|
|
@@ -1539,14 +1931,27 @@ export class CodeReviewService {
|
|
|
1539
1931
|
model: (retryResp.metadata.model ?? retryResp.metadata.model_name ?? null),
|
|
1540
1932
|
}
|
|
1541
1933
|
: undefined;
|
|
1542
|
-
await recordUsage("review_retry", retryPrompt, retryOutput, retryDuration, retryTokenMeta);
|
|
1543
|
-
|
|
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
|
+
}
|
|
1544
1950
|
agentOutput = retryOutput;
|
|
1545
1951
|
}
|
|
1546
|
-
if (
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
warnings.push(`Review agent returned non-JSON output for ${task.key}; blocking review.`);
|
|
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.`);
|
|
1550
1955
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
1551
1956
|
taskRunId: taskRun.id,
|
|
1552
1957
|
sequence: this.sequenceForTask(taskRun.id),
|
|
@@ -1555,17 +1960,60 @@ export class CodeReviewService {
|
|
|
1555
1960
|
message: fallbackSummary,
|
|
1556
1961
|
});
|
|
1557
1962
|
parsed = {
|
|
1558
|
-
decision: "
|
|
1963
|
+
decision: "info_only",
|
|
1559
1964
|
summary: fallbackSummary,
|
|
1560
1965
|
findings: [],
|
|
1561
1966
|
testRecommendations: [],
|
|
1562
|
-
raw: agentOutput,
|
|
1967
|
+
raw: retryOutput ?? agentOutput,
|
|
1563
1968
|
};
|
|
1969
|
+
normalization = { parsedFromJson: false, usedFallback: true, issues: ["validation_error"], result: parsed };
|
|
1564
1970
|
}
|
|
1565
|
-
|
|
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
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
parsed.raw = parsed.raw ?? agentOutput;
|
|
1566
1999
|
const originalDecision = parsed.decision;
|
|
1567
2000
|
decision = parsed.decision;
|
|
1568
2001
|
findings.push(...(parsed.findings ?? []));
|
|
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
|
+
}
|
|
1569
2017
|
commentResolution = await this.applyCommentResolutions({
|
|
1570
2018
|
task,
|
|
1571
2019
|
taskRunId: taskRun.id,
|
|
@@ -1574,10 +2022,9 @@ export class CodeReviewService {
|
|
|
1574
2022
|
findings: parsed.findings ?? [],
|
|
1575
2023
|
resolvedSlugs: parsed.resolvedSlugs ?? undefined,
|
|
1576
2024
|
unresolvedSlugs: parsed.unresolvedSlugs ?? undefined,
|
|
1577
|
-
decision:
|
|
2025
|
+
decision: finalDecision,
|
|
1578
2026
|
existingComments: commentContext.comments,
|
|
1579
2027
|
});
|
|
1580
|
-
let finalDecision = parsed.decision;
|
|
1581
2028
|
if (commentResolution?.open?.length &&
|
|
1582
2029
|
(finalDecision === "approve" || finalDecision === "info_only")) {
|
|
1583
2030
|
const openSlugs = commentResolution.open;
|
|
@@ -1610,47 +2057,106 @@ export class CodeReviewService {
|
|
|
1610
2057
|
createdAt: new Date().toISOString(),
|
|
1611
2058
|
});
|
|
1612
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
|
+
}
|
|
1613
2117
|
parsed.decision = finalDecision;
|
|
1614
2118
|
decision = finalDecision;
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
+
}
|
|
1632
2138
|
}
|
|
1633
2139
|
let taskStatusUpdate = statusBefore;
|
|
1634
2140
|
if (!request.dryRun) {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const approveDecision = parsed.decision === "approve" || parsed.decision === "info_only";
|
|
1641
|
-
if (approveDecision) {
|
|
1642
|
-
await this.stateService.markReadyToQa(task);
|
|
1643
|
-
taskStatusUpdate = "ready_to_qa";
|
|
1644
|
-
}
|
|
1645
|
-
else if (parsed.decision === "changes_requested") {
|
|
1646
|
-
await this.stateService.returnToInProgress(task);
|
|
1647
|
-
taskStatusUpdate = "in_progress";
|
|
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";
|
|
1648
2146
|
}
|
|
1649
|
-
else
|
|
1650
|
-
await this.stateService.
|
|
1651
|
-
taskStatusUpdate = "
|
|
2147
|
+
else {
|
|
2148
|
+
await this.stateService.markReadyToQa(task, undefined, statusContext);
|
|
2149
|
+
taskStatusUpdate = "ready_to_qa";
|
|
1652
2150
|
}
|
|
1653
2151
|
}
|
|
2152
|
+
else if (parsed.decision === "changes_requested") {
|
|
2153
|
+
await this.stateService.markChangesRequested(task, undefined, statusContext);
|
|
2154
|
+
taskStatusUpdate = "changes_requested";
|
|
2155
|
+
}
|
|
2156
|
+
else if (parsed.decision === "block") {
|
|
2157
|
+
await this.stateService.markFailed(task, "review_blocked", statusContext);
|
|
2158
|
+
taskStatusUpdate = "failed";
|
|
2159
|
+
}
|
|
1654
2160
|
}
|
|
1655
2161
|
else {
|
|
1656
2162
|
await this.deps.workspaceRepo.insertTaskLog({
|
|
@@ -1678,7 +2184,7 @@ export class CodeReviewService {
|
|
|
1678
2184
|
reopenedCount: commentResolution?.reopened.length,
|
|
1679
2185
|
openCount: commentResolution?.open.length,
|
|
1680
2186
|
});
|
|
1681
|
-
await this.deps.workspaceRepo.createTaskReview({
|
|
2187
|
+
const review = await this.deps.workspaceRepo.createTaskReview({
|
|
1682
2188
|
taskId: task.id,
|
|
1683
2189
|
jobId,
|
|
1684
2190
|
agentId: agent.id,
|
|
@@ -1687,6 +2193,7 @@ export class CodeReviewService {
|
|
|
1687
2193
|
summary: parsed.summary ?? undefined,
|
|
1688
2194
|
findingsJson: parsed.findings ?? [],
|
|
1689
2195
|
testRecommendationsJson: parsed.testRecommendations ?? [],
|
|
2196
|
+
metadata: diffMeta,
|
|
1690
2197
|
createdAt: new Date().toISOString(),
|
|
1691
2198
|
});
|
|
1692
2199
|
await this.stateService.recordReviewMetadata(task, {
|
|
@@ -1694,6 +2201,9 @@ export class CodeReviewService {
|
|
|
1694
2201
|
agentId: agent.id,
|
|
1695
2202
|
modelName: agent.defaultModel ?? null,
|
|
1696
2203
|
jobId,
|
|
2204
|
+
reviewId: review.id,
|
|
2205
|
+
diffEmpty,
|
|
2206
|
+
changedPaths,
|
|
1697
2207
|
});
|
|
1698
2208
|
await this.deps.workspaceRepo.updateTaskRun(taskRun.id, {
|
|
1699
2209
|
status: "succeeded",
|
|
@@ -1745,7 +2255,18 @@ export class CodeReviewService {
|
|
|
1745
2255
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1746
2256
|
processedItems: state?.reviewed.length ?? 0,
|
|
1747
2257
|
});
|
|
2258
|
+
emitReviewEndOnce({
|
|
2259
|
+
statusLabel: "FAILED",
|
|
2260
|
+
decision: "error",
|
|
2261
|
+
findingsCount: findings.length,
|
|
2262
|
+
tokensTotal,
|
|
2263
|
+
});
|
|
1748
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
|
+
}
|
|
1749
2270
|
continue;
|
|
1750
2271
|
}
|
|
1751
2272
|
results.push({
|
|
@@ -1755,13 +2276,38 @@ export class CodeReviewService {
|
|
|
1755
2276
|
statusAfter,
|
|
1756
2277
|
decision,
|
|
1757
2278
|
findings,
|
|
2279
|
+
error: reviewErrorCode,
|
|
1758
2280
|
followupTasks: followupCreated,
|
|
1759
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
|
+
});
|
|
1760
2297
|
await this.deps.jobService.updateJobStatus(jobId, "running", {
|
|
1761
2298
|
processedItems: state?.reviewed.length ?? 0,
|
|
1762
2299
|
});
|
|
1763
2300
|
await maybeRateTask(task, taskRun.id, tokensTotal);
|
|
1764
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 };
|
|
2310
|
+
}
|
|
1765
2311
|
await this.deps.jobService.updateJobStatus(jobId, "completed", {
|
|
1766
2312
|
processedItems: state?.reviewed.length ?? selectedTaskIds.length,
|
|
1767
2313
|
totalItems: selectedTaskIds.length,
|