@evref-bl/dev-nexus 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +677 -0
- package/dist/browserOpener.d.ts +9 -0
- package/dist/browserOpener.js +47 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +2374 -0
- package/dist/gitWorktreeService.d.ts +57 -0
- package/dist/gitWorktreeService.js +157 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +47 -0
- package/dist/nexusAgentMcpConfig.d.ts +30 -0
- package/dist/nexusAgentMcpConfig.js +228 -0
- package/dist/nexusAutomation.d.ts +103 -0
- package/dist/nexusAutomation.js +390 -0
- package/dist/nexusAutomationAgentLaunch.d.ts +148 -0
- package/dist/nexusAutomationAgentLaunch.js +855 -0
- package/dist/nexusAutomationAgentProfile.d.ts +39 -0
- package/dist/nexusAutomationAgentProfile.js +103 -0
- package/dist/nexusAutomationAgentSurface.d.ts +62 -0
- package/dist/nexusAutomationAgentSurface.js +90 -0
- package/dist/nexusAutomationCommandExecutor.d.ts +29 -0
- package/dist/nexusAutomationCommandExecutor.js +251 -0
- package/dist/nexusAutomationConfig.d.ts +114 -0
- package/dist/nexusAutomationConfig.js +547 -0
- package/dist/nexusAutomationEnqueue.d.ts +37 -0
- package/dist/nexusAutomationEnqueue.js +128 -0
- package/dist/nexusAutomationRunOnce.d.ts +91 -0
- package/dist/nexusAutomationRunOnce.js +586 -0
- package/dist/nexusAutomationScheduler.d.ts +50 -0
- package/dist/nexusAutomationScheduler.js +196 -0
- package/dist/nexusAutomationStatus.d.ts +55 -0
- package/dist/nexusAutomationStatus.js +462 -0
- package/dist/nexusAutomationTarget.d.ts +19 -0
- package/dist/nexusAutomationTarget.js +33 -0
- package/dist/nexusAutomationTargetCycle.d.ts +90 -0
- package/dist/nexusAutomationTargetCycle.js +282 -0
- package/dist/nexusAutomationTargetReport.d.ts +136 -0
- package/dist/nexusAutomationTargetReport.js +504 -0
- package/dist/nexusAutomationWorktreeSetup.d.ts +89 -0
- package/dist/nexusAutomationWorktreeSetup.js +661 -0
- package/dist/nexusCoordination.d.ts +198 -0
- package/dist/nexusCoordination.js +1018 -0
- package/dist/nexusExtension.d.ts +31 -0
- package/dist/nexusExtension.js +1 -0
- package/dist/nexusHomeConfig.d.ts +38 -0
- package/dist/nexusHomeConfig.js +133 -0
- package/dist/nexusMcpServer.d.ts +31 -0
- package/dist/nexusMcpServer.js +1036 -0
- package/dist/nexusPluginCapabilities.d.ts +197 -0
- package/dist/nexusPluginCapabilities.js +201 -0
- package/dist/nexusProjectConfig.d.ts +95 -0
- package/dist/nexusProjectConfig.js +880 -0
- package/dist/nexusProjectHomeService.d.ts +121 -0
- package/dist/nexusProjectHomeService.js +171 -0
- package/dist/nexusProjectLifecycle.d.ts +62 -0
- package/dist/nexusProjectLifecycle.js +205 -0
- package/dist/nexusProjectOperations.d.ts +101 -0
- package/dist/nexusProjectOperations.js +296 -0
- package/dist/nexusProjectRegistry.d.ts +42 -0
- package/dist/nexusProjectRegistry.js +91 -0
- package/dist/nexusProjectScaffold.d.ts +25 -0
- package/dist/nexusProjectScaffold.js +61 -0
- package/dist/nexusProjectTemplate.d.ts +34 -0
- package/dist/nexusProjectTemplate.js +354 -0
- package/dist/nexusSkills.d.ts +134 -0
- package/dist/nexusSkills.js +647 -0
- package/dist/nexusWorkerContextBundle.d.ts +142 -0
- package/dist/nexusWorkerContextBundle.js +375 -0
- package/dist/processSupervisor.d.ts +89 -0
- package/dist/processSupervisor.js +440 -0
- package/dist/vibeKanbanApi.d.ts +11 -0
- package/dist/vibeKanbanApi.js +14 -0
- package/dist/vibeKanbanAuth.d.ts +25 -0
- package/dist/vibeKanbanAuth.js +101 -0
- package/dist/vibeKanbanBoardAdapter.d.ts +36 -0
- package/dist/vibeKanbanBoardAdapter.js +196 -0
- package/dist/vibeKanbanMcpConfig.d.ts +36 -0
- package/dist/vibeKanbanMcpConfig.js +191 -0
- package/dist/vibeKanbanProjectAdapter.d.ts +39 -0
- package/dist/vibeKanbanProjectAdapter.js +113 -0
- package/dist/vibeKanbanWorkspaceSetup.d.ts +1 -0
- package/dist/vibeKanbanWorkspaceSetup.js +96 -0
- package/dist/workItemService.d.ts +60 -0
- package/dist/workItemService.js +163 -0
- package/dist/workTrackingGitHubProvider.d.ts +71 -0
- package/dist/workTrackingGitHubProvider.js +663 -0
- package/dist/workTrackingGitLabProvider.d.ts +62 -0
- package/dist/workTrackingGitLabProvider.js +523 -0
- package/dist/workTrackingJiraProvider.d.ts +67 -0
- package/dist/workTrackingJiraProvider.js +652 -0
- package/dist/workTrackingLocalProvider.d.ts +49 -0
- package/dist/workTrackingLocalProvider.js +463 -0
- package/dist/workTrackingProviderService.d.ts +21 -0
- package/dist/workTrackingProviderService.js +117 -0
- package/dist/workTrackingTypes.d.ts +202 -0
- package/dist/workTrackingTypes.js +1 -0
- package/dist/workTrackingVibeProvider.d.ts +35 -0
- package/dist/workTrackingVibeProvider.js +119 -0
- package/dist/worktreeExecutionMetadata.d.ts +76 -0
- package/dist/worktreeExecutionMetadata.js +239 -0
- package/package.json +37 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultGitRunner, } from "./gitWorktreeService.js";
|
|
4
|
+
import { loadProjectConfig, } from "./nexusProjectConfig.js";
|
|
5
|
+
import { resolvePrimaryProjectComponent, resolveProjectComponents, } from "./nexusProjectLifecycle.js";
|
|
6
|
+
import { createWorkItemService, } from "./workItemService.js";
|
|
7
|
+
import { loadLocalWorkTrackingStore, resolveLocalWorkTrackingStorePath, } from "./workTrackingLocalProvider.js";
|
|
8
|
+
export const coordinationHandoffCommentMarker = "DevNexus coordination handoff";
|
|
9
|
+
export const coordinationHandoffKind = "dev-nexus.coordination.handoff";
|
|
10
|
+
export const defaultCoordinationHandoffStaleAfterMs = 24 * 60 * 60 * 1000;
|
|
11
|
+
const handoffStatuses = new Set([
|
|
12
|
+
"working",
|
|
13
|
+
"ready",
|
|
14
|
+
"blocked",
|
|
15
|
+
"merged",
|
|
16
|
+
]);
|
|
17
|
+
export async function getNexusCoordinationStatus(options) {
|
|
18
|
+
const context = resolveCoordinationContext(options);
|
|
19
|
+
const git = getCoordinationGitStatus(context, options.gitRunner);
|
|
20
|
+
const workItem = options.workItemId
|
|
21
|
+
? await workItemServiceForContext(context, options.now).getWorkItem({
|
|
22
|
+
projectRoot: context.projectRoot,
|
|
23
|
+
componentId: context.component.id,
|
|
24
|
+
id: options.workItemId,
|
|
25
|
+
})
|
|
26
|
+
: null;
|
|
27
|
+
const now = currentTimestamp(options.now);
|
|
28
|
+
const handoffs = readCoordinationHandoffs({
|
|
29
|
+
context,
|
|
30
|
+
workItemId: options.workItemId,
|
|
31
|
+
now,
|
|
32
|
+
maxHandoffAgeMs: options.maxHandoffAgeMs ?? defaultCoordinationHandoffStaleAfterMs,
|
|
33
|
+
});
|
|
34
|
+
const warnings = [...git.warnings, ...handoffs.warnings];
|
|
35
|
+
return {
|
|
36
|
+
project: projectSummary(context),
|
|
37
|
+
component: componentSummary(context.component),
|
|
38
|
+
workItem,
|
|
39
|
+
git,
|
|
40
|
+
handoffs,
|
|
41
|
+
nextAction: coordinationNextAction(git),
|
|
42
|
+
blocking: false,
|
|
43
|
+
warnings,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function createNexusCoordinationHandoff(options) {
|
|
47
|
+
const context = resolveCoordinationContext(options);
|
|
48
|
+
const status = parseNexusCoordinationHandoffStatus(options.status, "status");
|
|
49
|
+
const git = getCoordinationGitStatus(context, options.gitRunner);
|
|
50
|
+
const timestamp = currentTimestamp(options.now);
|
|
51
|
+
const record = {
|
|
52
|
+
kind: coordinationHandoffKind,
|
|
53
|
+
version: 1,
|
|
54
|
+
createdAt: timestamp,
|
|
55
|
+
projectId: context.projectConfig.id,
|
|
56
|
+
projectRoot: context.projectRoot,
|
|
57
|
+
componentId: context.component.id,
|
|
58
|
+
componentName: context.component.name,
|
|
59
|
+
workItemId: requiredNonEmptyString(options.workItemId, "workItemId"),
|
|
60
|
+
hostId: optionalTrimmedString(options.hostId) ?? os.hostname(),
|
|
61
|
+
agentId: optionalTrimmedString(options.agentId) ?? null,
|
|
62
|
+
status,
|
|
63
|
+
repositoryPath: git.repositoryPath,
|
|
64
|
+
branch: git.branch,
|
|
65
|
+
upstream: git.upstream,
|
|
66
|
+
baseRef: git.baseRef,
|
|
67
|
+
headCommit: git.headCommit,
|
|
68
|
+
dirty: git.dirty,
|
|
69
|
+
ahead: git.ahead,
|
|
70
|
+
behind: git.behind,
|
|
71
|
+
pushed: git.pushed,
|
|
72
|
+
changedAreas: normalizedStringArray(options.changedAreas, "changedAreas"),
|
|
73
|
+
decisions: normalizedStringArray(options.decisions, "decisions"),
|
|
74
|
+
verificationSummary: optionalNullableTrimmedString(options.verificationSummary) ?? null,
|
|
75
|
+
integrationPreference: optionalNullableTrimmedString(options.integrationPreference) ?? null,
|
|
76
|
+
note: optionalNullableTrimmedString(options.note) ?? null,
|
|
77
|
+
};
|
|
78
|
+
const comment = await workItemServiceForContext(context, options.now).addComment({
|
|
79
|
+
projectRoot: context.projectRoot,
|
|
80
|
+
componentId: context.component.id,
|
|
81
|
+
ref: { id: record.workItemId },
|
|
82
|
+
body: formatCoordinationHandoffComment(record),
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
project: projectSummary(context),
|
|
86
|
+
component: componentSummary(context.component),
|
|
87
|
+
record,
|
|
88
|
+
comment,
|
|
89
|
+
git,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export async function getNexusCoordinationIntegrationPlan(options) {
|
|
93
|
+
const context = resolveCoordinationContext(options);
|
|
94
|
+
const runner = options.gitRunner ?? defaultGitRunner;
|
|
95
|
+
const now = currentTimestamp(options.now);
|
|
96
|
+
const maxHandoffAgeMs = options.maxHandoffAgeMs ?? defaultCoordinationHandoffStaleAfterMs;
|
|
97
|
+
const git = getCoordinationGitStatus(context, runner);
|
|
98
|
+
const targetBranch = integrationTargetBranch(context, options, git);
|
|
99
|
+
const fetch = maybeFetchIntegrationTarget({
|
|
100
|
+
context,
|
|
101
|
+
repositoryPath: git.repositoryPath,
|
|
102
|
+
runner,
|
|
103
|
+
requested: options.fetch === true,
|
|
104
|
+
targetBranch,
|
|
105
|
+
});
|
|
106
|
+
const targetRef = integrationTargetRef({ fetch, git, targetBranch });
|
|
107
|
+
const handoffCollection = readCoordinationHandoffs({
|
|
108
|
+
context,
|
|
109
|
+
workItemId: options.workItemId,
|
|
110
|
+
now,
|
|
111
|
+
maxHandoffAgeMs,
|
|
112
|
+
});
|
|
113
|
+
const scope = options.workItemId
|
|
114
|
+
? "work_item"
|
|
115
|
+
: options.targetBranch
|
|
116
|
+
? "target_branch"
|
|
117
|
+
: "component";
|
|
118
|
+
const relatedRecords = relatedIntegrationHandoffs({
|
|
119
|
+
records: handoffCollection.records,
|
|
120
|
+
targetBranch,
|
|
121
|
+
filterByTargetBranch: scope === "target_branch",
|
|
122
|
+
});
|
|
123
|
+
const uniqueRecords = latestHandoffPerBranch(relatedRecords);
|
|
124
|
+
const repositoryPath = git.repositoryPath;
|
|
125
|
+
const targetCommit = repositoryPath
|
|
126
|
+
? gitStdout(runOptionalGit(runner, ["rev-parse", "--verify", targetRef], repositoryPath))
|
|
127
|
+
: null;
|
|
128
|
+
const branches = uniqueRecords.map((record) => integrationBranchPlan({
|
|
129
|
+
record,
|
|
130
|
+
runner,
|
|
131
|
+
repositoryPath,
|
|
132
|
+
targetRef,
|
|
133
|
+
targetCommit,
|
|
134
|
+
}));
|
|
135
|
+
const decisionConflicts = findDecisionConflicts(branches);
|
|
136
|
+
const suggestedOrder = suggestMergeOrder({
|
|
137
|
+
branches,
|
|
138
|
+
targetRef,
|
|
139
|
+
decisionConflicts,
|
|
140
|
+
});
|
|
141
|
+
const warnings = [
|
|
142
|
+
...git.warnings,
|
|
143
|
+
...handoffCollection.warnings,
|
|
144
|
+
...(fetch.warning ? [fetch.warning] : []),
|
|
145
|
+
...branches.flatMap((branch) => branch.merge.status === "unknown" ? [branch.merge.summary] : []),
|
|
146
|
+
];
|
|
147
|
+
return {
|
|
148
|
+
project: projectSummary(context),
|
|
149
|
+
component: componentSummary(context.component),
|
|
150
|
+
scope,
|
|
151
|
+
target: {
|
|
152
|
+
branch: targetBranch,
|
|
153
|
+
ref: targetRef,
|
|
154
|
+
commit: targetCommit,
|
|
155
|
+
},
|
|
156
|
+
fetch,
|
|
157
|
+
handoffs: {
|
|
158
|
+
available: handoffCollection.available,
|
|
159
|
+
provider: handoffCollection.provider,
|
|
160
|
+
totalCount: relatedRecords.length,
|
|
161
|
+
activeCount: branches.filter((branch) => !branch.stale && branch.status !== "merged").length,
|
|
162
|
+
staleCount: relatedRecords.filter((record) => record.stale).length,
|
|
163
|
+
records: relatedRecords,
|
|
164
|
+
warnings: handoffCollection.warnings,
|
|
165
|
+
},
|
|
166
|
+
branches,
|
|
167
|
+
decisionConflicts,
|
|
168
|
+
suggestedOrder,
|
|
169
|
+
nextAction: integrationNextAction(branches, decisionConflicts),
|
|
170
|
+
warnings,
|
|
171
|
+
mutatesSource: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function parseNexusCoordinationHandoffStatus(value, pathName) {
|
|
175
|
+
if (handoffStatuses.has(value)) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`${pathName} must be working, ready, blocked, or merged`);
|
|
179
|
+
}
|
|
180
|
+
export function formatCoordinationHandoffComment(record) {
|
|
181
|
+
const lines = [
|
|
182
|
+
coordinationHandoffCommentMarker,
|
|
183
|
+
"",
|
|
184
|
+
`Status: ${record.status}`,
|
|
185
|
+
`Host: ${record.hostId}`,
|
|
186
|
+
`Branch: ${record.branch ?? "unknown"}`,
|
|
187
|
+
`Head: ${record.headCommit ?? "unknown"}`,
|
|
188
|
+
"",
|
|
189
|
+
"```json",
|
|
190
|
+
JSON.stringify(record, null, 2),
|
|
191
|
+
"```",
|
|
192
|
+
];
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
function resolveCoordinationContext(options) {
|
|
196
|
+
const projectRoot = path.resolve(requiredNonEmptyString(options.projectRoot, "projectRoot"));
|
|
197
|
+
const projectConfig = loadProjectConfig(projectRoot);
|
|
198
|
+
const currentPath = path.resolve(options.currentPath ?? process.cwd());
|
|
199
|
+
const component = resolveCoordinationComponent(projectRoot, projectConfig, options.componentId, currentPath);
|
|
200
|
+
if (!component.workTracking) {
|
|
201
|
+
throw new Error(`Component ${component.id} work tracking is not configured`);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
projectRoot,
|
|
205
|
+
projectConfig,
|
|
206
|
+
component,
|
|
207
|
+
currentPath,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function resolveCoordinationComponent(projectRoot, projectConfig, componentId, currentPath) {
|
|
211
|
+
const components = resolveProjectComponents(projectRoot, projectConfig);
|
|
212
|
+
if (componentId) {
|
|
213
|
+
const component = components.find((candidate) => candidate.id === componentId);
|
|
214
|
+
if (!component) {
|
|
215
|
+
throw new Error(`Project component is not configured: ${componentId}`);
|
|
216
|
+
}
|
|
217
|
+
return component;
|
|
218
|
+
}
|
|
219
|
+
const inferred = components
|
|
220
|
+
.flatMap((component) => [
|
|
221
|
+
{ component, root: component.sourceRoot },
|
|
222
|
+
{ component, root: component.worktreesRoot },
|
|
223
|
+
])
|
|
224
|
+
.filter((candidate) => samePathOrDescendant(currentPath, candidate.root))
|
|
225
|
+
.sort((a, b) => b.root.length - a.root.length)[0];
|
|
226
|
+
return inferred?.component ?? resolvePrimaryProjectComponent(projectRoot, projectConfig);
|
|
227
|
+
}
|
|
228
|
+
function workItemServiceForContext(context, now) {
|
|
229
|
+
return createWorkItemService({
|
|
230
|
+
resolveProject: () => workItemProjectContext(context),
|
|
231
|
+
now,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function workItemProjectContext(context) {
|
|
235
|
+
const workTracking = context.component.workTracking;
|
|
236
|
+
if (!workTracking) {
|
|
237
|
+
throw new Error(`Component ${context.component.id} work tracking is not configured`);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
homePath: context.projectConfig.home ?? "",
|
|
241
|
+
projectRoot: context.projectRoot,
|
|
242
|
+
projectId: context.projectConfig.id,
|
|
243
|
+
projectName: context.projectConfig.name,
|
|
244
|
+
componentId: context.component.id,
|
|
245
|
+
componentName: context.component.name,
|
|
246
|
+
sourceRoot: context.component.sourceRoot,
|
|
247
|
+
workTracking,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function getCoordinationGitStatus(context, gitRunner) {
|
|
251
|
+
const runner = gitRunner ?? defaultGitRunner;
|
|
252
|
+
const repositoryPath = findGitRepositoryPath(runner, [
|
|
253
|
+
context.currentPath,
|
|
254
|
+
context.component.sourceRoot,
|
|
255
|
+
]);
|
|
256
|
+
const baseRefFallback = context.component.defaultBranch;
|
|
257
|
+
if (!repositoryPath) {
|
|
258
|
+
return {
|
|
259
|
+
repositoryPath: null,
|
|
260
|
+
branch: null,
|
|
261
|
+
upstream: null,
|
|
262
|
+
baseRef: baseRefFallback,
|
|
263
|
+
headCommit: null,
|
|
264
|
+
dirty: null,
|
|
265
|
+
stagedCount: 0,
|
|
266
|
+
unstagedCount: 0,
|
|
267
|
+
untrackedCount: 0,
|
|
268
|
+
ahead: null,
|
|
269
|
+
behind: null,
|
|
270
|
+
pushed: null,
|
|
271
|
+
warnings: ["No git repository could be resolved for the coordination path."],
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const branch = gitStdout(runOptionalGit(runner, ["symbolic-ref", "--short", "HEAD"], repositoryPath));
|
|
275
|
+
const headCommit = gitStdout(runOptionalGit(runner, ["rev-parse", "HEAD"], repositoryPath));
|
|
276
|
+
const upstream = gitStdout(runOptionalGit(runner, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repositoryPath));
|
|
277
|
+
const parsedStatus = parsePorcelainStatus(gitStdout(runOptionalGit(runner, ["status", "--porcelain=v1"], repositoryPath)) ??
|
|
278
|
+
"");
|
|
279
|
+
const aheadBehind = upstream
|
|
280
|
+
? parseAheadBehind(gitStdout(runOptionalGit(runner, ["rev-list", "--left-right", "--count", "HEAD...@{u}"], repositoryPath)))
|
|
281
|
+
: { ahead: null, behind: null };
|
|
282
|
+
const warnings = [];
|
|
283
|
+
if (!upstream) {
|
|
284
|
+
warnings.push("Current branch has no upstream configured.");
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
repositoryPath,
|
|
288
|
+
branch,
|
|
289
|
+
upstream,
|
|
290
|
+
baseRef: upstream ?? baseRefFallback,
|
|
291
|
+
headCommit,
|
|
292
|
+
dirty: parsedStatus.dirty,
|
|
293
|
+
stagedCount: parsedStatus.stagedCount,
|
|
294
|
+
unstagedCount: parsedStatus.unstagedCount,
|
|
295
|
+
untrackedCount: parsedStatus.untrackedCount,
|
|
296
|
+
ahead: aheadBehind.ahead,
|
|
297
|
+
behind: aheadBehind.behind,
|
|
298
|
+
pushed: upstream && aheadBehind.ahead !== null ? aheadBehind.ahead === 0 : null,
|
|
299
|
+
warnings,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function integrationTargetBranch(context, options, git) {
|
|
303
|
+
return (optionalTrimmedString(options.targetBranch) ??
|
|
304
|
+
context.component.publication?.targetBranch ??
|
|
305
|
+
context.projectConfig.automation?.publication.targetBranch ??
|
|
306
|
+
context.component.defaultBranch ??
|
|
307
|
+
git.baseRef);
|
|
308
|
+
}
|
|
309
|
+
function integrationTargetRef(options) {
|
|
310
|
+
if (options.fetch.ran &&
|
|
311
|
+
options.fetch.exitCode === 0 &&
|
|
312
|
+
options.fetch.remote &&
|
|
313
|
+
options.targetBranch) {
|
|
314
|
+
return `${options.fetch.remote}/${options.targetBranch}`;
|
|
315
|
+
}
|
|
316
|
+
return options.targetBranch ?? options.git.baseRef ?? "HEAD";
|
|
317
|
+
}
|
|
318
|
+
function integrationRemote(context) {
|
|
319
|
+
return (context.component.publication?.remote ??
|
|
320
|
+
context.projectConfig.automation?.publication.remote ??
|
|
321
|
+
null);
|
|
322
|
+
}
|
|
323
|
+
function maybeFetchIntegrationTarget(options) {
|
|
324
|
+
const remote = integrationRemote(options.context);
|
|
325
|
+
const allowed = options.context.projectConfig.automation?.safety.allowHostMutation === true;
|
|
326
|
+
const base = {
|
|
327
|
+
requested: options.requested,
|
|
328
|
+
allowed,
|
|
329
|
+
remote,
|
|
330
|
+
targetBranch: options.targetBranch,
|
|
331
|
+
ran: false,
|
|
332
|
+
exitCode: null,
|
|
333
|
+
warning: null,
|
|
334
|
+
};
|
|
335
|
+
if (!options.requested) {
|
|
336
|
+
return base;
|
|
337
|
+
}
|
|
338
|
+
if (!options.repositoryPath) {
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
341
|
+
warning: "Fetch requested but no git repository was resolved.",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (!remote) {
|
|
345
|
+
return {
|
|
346
|
+
...base,
|
|
347
|
+
warning: "Fetch requested but no integration remote is configured.",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (!allowed) {
|
|
351
|
+
return {
|
|
352
|
+
...base,
|
|
353
|
+
warning: "Fetch requested but automation safety does not allow host mutation.",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const result = runGitNoThrow(options.runner, [
|
|
357
|
+
"fetch",
|
|
358
|
+
"--prune",
|
|
359
|
+
remote,
|
|
360
|
+
...(options.targetBranch ? [options.targetBranch] : []),
|
|
361
|
+
], options.repositoryPath);
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
364
|
+
ran: true,
|
|
365
|
+
exitCode: result?.exitCode ?? null,
|
|
366
|
+
warning: result && result.exitCode !== 0
|
|
367
|
+
? conciseGitFailure("Fetch failed", result)
|
|
368
|
+
: null,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function relatedIntegrationHandoffs(options) {
|
|
372
|
+
if (!options.filterByTargetBranch || !options.targetBranch) {
|
|
373
|
+
return options.records;
|
|
374
|
+
}
|
|
375
|
+
return options.records.filter((record) => handoffTargetsBranch(record, options.targetBranch));
|
|
376
|
+
}
|
|
377
|
+
function handoffTargetsBranch(record, targetBranch) {
|
|
378
|
+
const normalizedTarget = normalizedBranchTail(targetBranch);
|
|
379
|
+
const candidates = [
|
|
380
|
+
record.baseRef,
|
|
381
|
+
record.upstream,
|
|
382
|
+
record.integrationPreference,
|
|
383
|
+
].filter((value) => Boolean(value));
|
|
384
|
+
if (candidates.length === 0) {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
return candidates.some((candidate) => normalizedBranchTail(candidate).endsWith(normalizedTarget));
|
|
388
|
+
}
|
|
389
|
+
function normalizedBranchTail(value) {
|
|
390
|
+
return value
|
|
391
|
+
.trim()
|
|
392
|
+
.replace(/^refs\/heads\//u, "")
|
|
393
|
+
.replace(/^refs\/remotes\//u, "")
|
|
394
|
+
.replace(/^origin\//u, "");
|
|
395
|
+
}
|
|
396
|
+
function latestHandoffPerBranch(records) {
|
|
397
|
+
const byBranch = new Map();
|
|
398
|
+
for (const record of records) {
|
|
399
|
+
const branch = optionalTrimmedString(record.branch ?? undefined);
|
|
400
|
+
if (!branch || byBranch.has(branch)) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
byBranch.set(branch, record);
|
|
404
|
+
}
|
|
405
|
+
return [...byBranch.values()];
|
|
406
|
+
}
|
|
407
|
+
function integrationBranchPlan(options) {
|
|
408
|
+
const branch = options.record.branch;
|
|
409
|
+
const skipReason = skippedMergeReason(options.record, options.repositoryPath);
|
|
410
|
+
const merge = skipReason
|
|
411
|
+
? skippedMerge(skipReason)
|
|
412
|
+
: analyzeBranchMerge({
|
|
413
|
+
runner: options.runner,
|
|
414
|
+
repositoryPath: options.repositoryPath,
|
|
415
|
+
targetRef: options.targetRef,
|
|
416
|
+
targetCommit: options.targetCommit,
|
|
417
|
+
branch,
|
|
418
|
+
});
|
|
419
|
+
return {
|
|
420
|
+
workItemId: options.record.workItemId,
|
|
421
|
+
branch,
|
|
422
|
+
status: options.record.status,
|
|
423
|
+
stale: options.record.stale,
|
|
424
|
+
headCommit: options.record.headCommit,
|
|
425
|
+
handoff: {
|
|
426
|
+
hostId: options.record.hostId,
|
|
427
|
+
agentId: options.record.agentId,
|
|
428
|
+
status: options.record.status,
|
|
429
|
+
createdAt: options.record.createdAt,
|
|
430
|
+
stale: options.record.stale,
|
|
431
|
+
changedAreas: options.record.changedAreas,
|
|
432
|
+
decisions: options.record.decisions,
|
|
433
|
+
verificationSummary: options.record.verificationSummary,
|
|
434
|
+
integrationPreference: options.record.integrationPreference,
|
|
435
|
+
note: options.record.note,
|
|
436
|
+
},
|
|
437
|
+
merge,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function skippedMergeReason(record, repositoryPath) {
|
|
441
|
+
if (!repositoryPath) {
|
|
442
|
+
return "No git repository was resolved for integration planning.";
|
|
443
|
+
}
|
|
444
|
+
if (record.stale) {
|
|
445
|
+
return "Skipped stale handoff; refresh the handoff before integration.";
|
|
446
|
+
}
|
|
447
|
+
if (record.status === "merged") {
|
|
448
|
+
return "Skipped merged handoff.";
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
function skippedMerge(summary) {
|
|
453
|
+
return {
|
|
454
|
+
status: "skipped",
|
|
455
|
+
mergeBase: null,
|
|
456
|
+
targetCommit: null,
|
|
457
|
+
branchCommit: null,
|
|
458
|
+
changedFiles: [],
|
|
459
|
+
conflictFiles: [],
|
|
460
|
+
messages: [summary],
|
|
461
|
+
summary,
|
|
462
|
+
rangeDiff: [],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function analyzeBranchMerge(options) {
|
|
466
|
+
const branchCommit = gitStdout(runOptionalGit(options.runner, ["rev-parse", "--verify", options.branch], options.repositoryPath));
|
|
467
|
+
const mergeBase = gitStdout(runOptionalGit(options.runner, ["merge-base", options.targetRef, options.branch], options.repositoryPath));
|
|
468
|
+
const changedFiles = uniqueSortedStrings(linesFromGitResult(runOptionalGit(options.runner, ["diff", "--name-only", `${options.targetRef}...${options.branch}`], options.repositoryPath)));
|
|
469
|
+
const quietMerge = runGitNoThrow(options.runner, ["merge-tree", "--write-tree", "--quiet", options.targetRef, options.branch], options.repositoryPath);
|
|
470
|
+
const detailMerge = runGitNoThrow(options.runner, [
|
|
471
|
+
"merge-tree",
|
|
472
|
+
"--write-tree",
|
|
473
|
+
"--name-only",
|
|
474
|
+
"--messages",
|
|
475
|
+
options.targetRef,
|
|
476
|
+
options.branch,
|
|
477
|
+
], options.repositoryPath);
|
|
478
|
+
const mergeStatus = mergeStatusFromResult(quietMerge);
|
|
479
|
+
const messages = conciseMergeMessages(detailMerge);
|
|
480
|
+
const conflictFiles = mergeStatus === "conflict"
|
|
481
|
+
? uniqueSortedStrings([
|
|
482
|
+
...mergeTreeConflictFiles(detailMerge),
|
|
483
|
+
...conflictFilesFromMessages(messages),
|
|
484
|
+
])
|
|
485
|
+
: [];
|
|
486
|
+
const rangeDiff = mergeBase
|
|
487
|
+
? conciseOutputLines(runGitNoThrow(options.runner, [
|
|
488
|
+
"range-diff",
|
|
489
|
+
`${mergeBase}..${options.targetRef}`,
|
|
490
|
+
`${mergeBase}..${options.branch}`,
|
|
491
|
+
], options.repositoryPath))
|
|
492
|
+
: [];
|
|
493
|
+
return {
|
|
494
|
+
status: mergeStatus,
|
|
495
|
+
mergeBase,
|
|
496
|
+
targetCommit: options.targetCommit,
|
|
497
|
+
branchCommit,
|
|
498
|
+
changedFiles,
|
|
499
|
+
conflictFiles,
|
|
500
|
+
messages,
|
|
501
|
+
summary: mergeSummary({
|
|
502
|
+
status: mergeStatus,
|
|
503
|
+
branch: options.branch,
|
|
504
|
+
targetRef: options.targetRef,
|
|
505
|
+
changedFiles,
|
|
506
|
+
conflictFiles,
|
|
507
|
+
}),
|
|
508
|
+
rangeDiff,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function mergeStatusFromResult(result) {
|
|
512
|
+
if (!result) {
|
|
513
|
+
return "unknown";
|
|
514
|
+
}
|
|
515
|
+
if (result.exitCode === 0) {
|
|
516
|
+
return "clean";
|
|
517
|
+
}
|
|
518
|
+
if (result.exitCode === 1) {
|
|
519
|
+
return "conflict";
|
|
520
|
+
}
|
|
521
|
+
return "unknown";
|
|
522
|
+
}
|
|
523
|
+
function mergeSummary(options) {
|
|
524
|
+
if (options.status === "clean") {
|
|
525
|
+
return `${options.branch} merges cleanly into ${options.targetRef} with ${options.changedFiles.length} changed file(s).`;
|
|
526
|
+
}
|
|
527
|
+
if (options.status === "conflict") {
|
|
528
|
+
const files = options.conflictFiles.length > 0
|
|
529
|
+
? options.conflictFiles.join(", ")
|
|
530
|
+
: "unknown files";
|
|
531
|
+
return `${options.branch} has textual conflicts against ${options.targetRef}: ${files}.`;
|
|
532
|
+
}
|
|
533
|
+
if (options.status === "skipped") {
|
|
534
|
+
return "Merge analysis skipped.";
|
|
535
|
+
}
|
|
536
|
+
return `Merge analysis for ${options.branch} against ${options.targetRef} is unknown.`;
|
|
537
|
+
}
|
|
538
|
+
function runGitNoThrow(gitRunner, args, cwd) {
|
|
539
|
+
try {
|
|
540
|
+
return gitRunner(args, cwd);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function linesFromGitResult(result) {
|
|
547
|
+
return result?.stdout ? nonEmptyLines(result.stdout) : [];
|
|
548
|
+
}
|
|
549
|
+
function conciseMergeMessages(result) {
|
|
550
|
+
const lines = [
|
|
551
|
+
...nonEmptyLines(result?.stdout ?? ""),
|
|
552
|
+
...nonEmptyLines(result?.stderr ?? ""),
|
|
553
|
+
].filter((line) => !/^[0-9a-f]{40}$/iu.test(line));
|
|
554
|
+
return conciseLines(lines.filter((line) => !looksLikeMergeTreePathLine(line)));
|
|
555
|
+
}
|
|
556
|
+
function conciseOutputLines(result) {
|
|
557
|
+
if (!result || (result.exitCode !== 0 && !result.stdout.trim())) {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
return conciseLines(nonEmptyLines(result.stdout));
|
|
561
|
+
}
|
|
562
|
+
function conciseLines(lines) {
|
|
563
|
+
return lines.slice(0, 8).map((line) => line.length > 140 ? `${line.slice(0, 137)}...` : line);
|
|
564
|
+
}
|
|
565
|
+
function mergeTreeConflictFiles(result) {
|
|
566
|
+
if (!result?.stdout) {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
const files = [];
|
|
570
|
+
const lines = nonEmptyLines(result.stdout);
|
|
571
|
+
for (const line of lines) {
|
|
572
|
+
if (/^[0-9a-f]{40}$/iu.test(line)) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (line.startsWith("Auto-merging ") || line.startsWith("CONFLICT ")) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (looksLikeMergeTreePathLine(line)) {
|
|
579
|
+
files.push(line);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return files;
|
|
583
|
+
}
|
|
584
|
+
function conflictFilesFromMessages(messages) {
|
|
585
|
+
const files = [];
|
|
586
|
+
for (const message of messages) {
|
|
587
|
+
const match = /\bin\s+(.+)$/u.exec(message);
|
|
588
|
+
if (message.startsWith("CONFLICT ") && match?.[1]) {
|
|
589
|
+
files.push(match[1].trim());
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return files;
|
|
593
|
+
}
|
|
594
|
+
function looksLikeMergeTreePathLine(line) {
|
|
595
|
+
return (!line.includes(": ") &&
|
|
596
|
+
!line.includes(" ") &&
|
|
597
|
+
!line.startsWith("CONFLICT") &&
|
|
598
|
+
!line.startsWith("Auto-merging") &&
|
|
599
|
+
!/^[0-9a-f]{40}$/iu.test(line));
|
|
600
|
+
}
|
|
601
|
+
function nonEmptyLines(output) {
|
|
602
|
+
return output
|
|
603
|
+
.split(/\r?\n/u)
|
|
604
|
+
.map((line) => line.trim())
|
|
605
|
+
.filter(Boolean);
|
|
606
|
+
}
|
|
607
|
+
function uniqueSortedStrings(values) {
|
|
608
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
609
|
+
}
|
|
610
|
+
function conciseGitFailure(prefix, result) {
|
|
611
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
612
|
+
return detail ? `${prefix}: ${detail}` : `${prefix}.`;
|
|
613
|
+
}
|
|
614
|
+
function findDecisionConflicts(branches) {
|
|
615
|
+
const activeBranches = branches.filter((branch) => !branch.stale && branch.status !== "merged");
|
|
616
|
+
const conflicts = [];
|
|
617
|
+
const conflictsByArea = new Map();
|
|
618
|
+
for (const branch of activeBranches) {
|
|
619
|
+
const areas = branch.handoff.changedAreas.length > 0
|
|
620
|
+
? branch.handoff.changedAreas
|
|
621
|
+
: branch.merge.changedFiles;
|
|
622
|
+
for (const area of areas) {
|
|
623
|
+
const existing = conflictsByArea.get(area) ?? [];
|
|
624
|
+
existing.push(branch);
|
|
625
|
+
conflictsByArea.set(area, existing);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const [area, areaBranches] of conflictsByArea) {
|
|
629
|
+
const decisionSets = uniqueSortedStrings(areaBranches.flatMap((branch) => branch.handoff.decisions));
|
|
630
|
+
if (areaBranches.length > 1 && decisionSets.length > 1) {
|
|
631
|
+
conflicts.push({
|
|
632
|
+
kind: "changed_area",
|
|
633
|
+
changedArea: area,
|
|
634
|
+
branches: uniqueSortedStrings(areaBranches.map((branch) => branch.branch)),
|
|
635
|
+
decisions: decisionSets,
|
|
636
|
+
summary: `Multiple handoff branches record different decisions for ${area}.`,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const preferences = new Map();
|
|
641
|
+
for (const branch of activeBranches) {
|
|
642
|
+
const preference = branch.handoff.integrationPreference;
|
|
643
|
+
if (!preference) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const group = preferences.get(preference) ?? [];
|
|
647
|
+
group.push(branch);
|
|
648
|
+
preferences.set(preference, group);
|
|
649
|
+
}
|
|
650
|
+
if (preferences.size > 1) {
|
|
651
|
+
conflicts.push({
|
|
652
|
+
kind: "integration_preference",
|
|
653
|
+
changedArea: null,
|
|
654
|
+
branches: uniqueSortedStrings(activeBranches.map((branch) => branch.branch)),
|
|
655
|
+
decisions: uniqueSortedStrings([...preferences.keys()]),
|
|
656
|
+
summary: "Handoff branches record different integration preferences.",
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return conflicts;
|
|
660
|
+
}
|
|
661
|
+
function suggestMergeOrder(options) {
|
|
662
|
+
if (options.decisionConflicts.length > 0) {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
return options.branches
|
|
666
|
+
.filter((branch) => !branch.stale &&
|
|
667
|
+
branch.status === "ready" &&
|
|
668
|
+
branch.merge.status === "clean")
|
|
669
|
+
.sort((a, b) => a.handoff.createdAt.localeCompare(b.handoff.createdAt))
|
|
670
|
+
.map((branch) => ({
|
|
671
|
+
branch: branch.branch,
|
|
672
|
+
workItemId: branch.workItemId,
|
|
673
|
+
direction: `${branch.branch} -> ${options.targetRef}`,
|
|
674
|
+
reason: branch.handoff.integrationPreference
|
|
675
|
+
? `Handoff preference: ${branch.handoff.integrationPreference}`
|
|
676
|
+
: "Ready handoff merges cleanly.",
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
function integrationNextAction(branches, decisionConflicts) {
|
|
680
|
+
if (decisionConflicts.length > 0) {
|
|
681
|
+
return "Resolve competing handoff decisions before choosing merge order.";
|
|
682
|
+
}
|
|
683
|
+
if (branches.some((branch) => branch.merge.status === "conflict")) {
|
|
684
|
+
return "Resolve textual conflicts before merging affected branches.";
|
|
685
|
+
}
|
|
686
|
+
if (branches.some((branch) => !branch.stale &&
|
|
687
|
+
branch.status === "ready" &&
|
|
688
|
+
branch.merge.status === "clean")) {
|
|
689
|
+
return "Merge clean ready handoff branches in the suggested order.";
|
|
690
|
+
}
|
|
691
|
+
if (branches.some((branch) => branch.stale)) {
|
|
692
|
+
return "Refresh stale handoffs before integration.";
|
|
693
|
+
}
|
|
694
|
+
return "No active handoff branches are ready for integration.";
|
|
695
|
+
}
|
|
696
|
+
function findGitRepositoryPath(gitRunner, candidates) {
|
|
697
|
+
const seen = new Set();
|
|
698
|
+
for (const candidate of candidates) {
|
|
699
|
+
const resolved = path.resolve(candidate);
|
|
700
|
+
if (seen.has(resolved)) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
seen.add(resolved);
|
|
704
|
+
const result = runOptionalGit(gitRunner, ["rev-parse", "--show-toplevel"], resolved);
|
|
705
|
+
const repositoryPath = gitStdout(result);
|
|
706
|
+
if (repositoryPath) {
|
|
707
|
+
return path.resolve(repositoryPath);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
function runOptionalGit(gitRunner, args, cwd) {
|
|
713
|
+
try {
|
|
714
|
+
const result = gitRunner(args, cwd);
|
|
715
|
+
return result.exitCode === 0 ? result : null;
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function gitStdout(result) {
|
|
722
|
+
const value = result?.stdout.trim();
|
|
723
|
+
return value ? value : null;
|
|
724
|
+
}
|
|
725
|
+
function parsePorcelainStatus(output) {
|
|
726
|
+
let stagedCount = 0;
|
|
727
|
+
let unstagedCount = 0;
|
|
728
|
+
let untrackedCount = 0;
|
|
729
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
730
|
+
if (!line) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (line.startsWith("??")) {
|
|
734
|
+
untrackedCount += 1;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const staged = line[0];
|
|
738
|
+
const unstaged = line[1];
|
|
739
|
+
if (staged && staged !== " ") {
|
|
740
|
+
stagedCount += 1;
|
|
741
|
+
}
|
|
742
|
+
if (unstaged && unstaged !== " ") {
|
|
743
|
+
unstagedCount += 1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
dirty: stagedCount + unstagedCount + untrackedCount > 0,
|
|
748
|
+
stagedCount,
|
|
749
|
+
unstagedCount,
|
|
750
|
+
untrackedCount,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function parseAheadBehind(output) {
|
|
754
|
+
if (!output) {
|
|
755
|
+
return { ahead: null, behind: null };
|
|
756
|
+
}
|
|
757
|
+
const [aheadValue, behindValue] = output.split(/\s+/u);
|
|
758
|
+
const ahead = Number(aheadValue);
|
|
759
|
+
const behind = Number(behindValue);
|
|
760
|
+
if (!Number.isInteger(ahead) || !Number.isInteger(behind)) {
|
|
761
|
+
return { ahead: null, behind: null };
|
|
762
|
+
}
|
|
763
|
+
return { ahead, behind };
|
|
764
|
+
}
|
|
765
|
+
function readCoordinationHandoffs(options) {
|
|
766
|
+
const provider = options.context.component.workTracking?.provider ?? null;
|
|
767
|
+
if (provider !== "local") {
|
|
768
|
+
return {
|
|
769
|
+
available: false,
|
|
770
|
+
provider,
|
|
771
|
+
records: [],
|
|
772
|
+
warnings: [
|
|
773
|
+
"Related handoffs cannot be read because the configured provider does not expose comments through DevNexus core.",
|
|
774
|
+
],
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const workTracking = options.context.component
|
|
778
|
+
.workTracking;
|
|
779
|
+
const storePath = resolveLocalWorkTrackingStorePath(options.context.projectRoot, workTracking);
|
|
780
|
+
const store = loadLocalWorkTrackingStore(storePath);
|
|
781
|
+
const comments = options.workItemId
|
|
782
|
+
? (store.comments[options.workItemId] ?? []).map((comment) => ({
|
|
783
|
+
workItemId: options.workItemId,
|
|
784
|
+
comment,
|
|
785
|
+
}))
|
|
786
|
+
: Object.entries(store.comments).flatMap(([workItemId, itemComments]) => itemComments.map((comment) => ({ workItemId, comment })));
|
|
787
|
+
const nowMs = Date.parse(options.now);
|
|
788
|
+
const warnings = [];
|
|
789
|
+
const records = comments
|
|
790
|
+
.map(({ workItemId, comment }) => handoffSummaryFromComment({
|
|
791
|
+
comment,
|
|
792
|
+
fallbackWorkItemId: workItemId,
|
|
793
|
+
projectId: options.context.projectConfig.id,
|
|
794
|
+
componentId: options.context.component.id,
|
|
795
|
+
nowMs,
|
|
796
|
+
maxHandoffAgeMs: options.maxHandoffAgeMs,
|
|
797
|
+
}))
|
|
798
|
+
.filter((record) => Boolean(record))
|
|
799
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
800
|
+
for (const record of records) {
|
|
801
|
+
if (record.stale) {
|
|
802
|
+
warnings.push(`Handoff for ${record.workItemId} from ${record.createdAt} is stale.`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
available: true,
|
|
807
|
+
provider,
|
|
808
|
+
records,
|
|
809
|
+
warnings,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function handoffSummaryFromComment(options) {
|
|
813
|
+
const record = parseCoordinationHandoffComment(options.comment.body);
|
|
814
|
+
if (!record) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
if (record.projectId !== options.projectId ||
|
|
818
|
+
record.componentId !== options.componentId) {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
const createdMs = Date.parse(record.createdAt);
|
|
822
|
+
const ageMs = Number.isFinite(createdMs) && Number.isFinite(options.nowMs)
|
|
823
|
+
? Math.max(0, options.nowMs - createdMs)
|
|
824
|
+
: null;
|
|
825
|
+
return {
|
|
826
|
+
...record,
|
|
827
|
+
workItemId: record.workItemId || options.fallbackWorkItemId,
|
|
828
|
+
commentId: options.comment.id ?? null,
|
|
829
|
+
commentCreatedAt: options.comment.createdAt ?? null,
|
|
830
|
+
stale: ageMs !== null && options.maxHandoffAgeMs >= 0
|
|
831
|
+
? ageMs > options.maxHandoffAgeMs
|
|
832
|
+
: false,
|
|
833
|
+
ageMs,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function parseCoordinationHandoffComment(body) {
|
|
837
|
+
if (!body.includes(coordinationHandoffCommentMarker)) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
const match = /```json\s*([\s\S]*?)```/u.exec(body);
|
|
841
|
+
if (!match) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
return handoffRecordFromUnknown(JSON.parse(match[1]));
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
function handoffRecordFromUnknown(value) {
|
|
852
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const record = value;
|
|
856
|
+
if (record.kind !== coordinationHandoffKind || record.version !== 1) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
kind: coordinationHandoffKind,
|
|
861
|
+
version: 1,
|
|
862
|
+
createdAt: requiredRecordString(record, "createdAt"),
|
|
863
|
+
projectId: requiredRecordString(record, "projectId"),
|
|
864
|
+
projectRoot: requiredRecordString(record, "projectRoot"),
|
|
865
|
+
componentId: requiredRecordString(record, "componentId"),
|
|
866
|
+
componentName: requiredRecordString(record, "componentName"),
|
|
867
|
+
workItemId: requiredRecordString(record, "workItemId"),
|
|
868
|
+
hostId: requiredRecordString(record, "hostId"),
|
|
869
|
+
agentId: nullableRecordString(record, "agentId"),
|
|
870
|
+
status: parseNexusCoordinationHandoffStatus(requiredRecordString(record, "status"), "handoff.status"),
|
|
871
|
+
repositoryPath: nullableRecordString(record, "repositoryPath"),
|
|
872
|
+
branch: nullableRecordString(record, "branch"),
|
|
873
|
+
upstream: nullableRecordString(record, "upstream"),
|
|
874
|
+
baseRef: nullableRecordString(record, "baseRef"),
|
|
875
|
+
headCommit: nullableRecordString(record, "headCommit"),
|
|
876
|
+
dirty: nullableRecordBoolean(record, "dirty"),
|
|
877
|
+
ahead: nullableRecordInteger(record, "ahead"),
|
|
878
|
+
behind: nullableRecordInteger(record, "behind"),
|
|
879
|
+
pushed: nullableRecordBoolean(record, "pushed"),
|
|
880
|
+
changedAreas: recordStringArray(record, "changedAreas"),
|
|
881
|
+
decisions: recordStringArray(record, "decisions"),
|
|
882
|
+
verificationSummary: nullableRecordString(record, "verificationSummary"),
|
|
883
|
+
integrationPreference: nullableRecordString(record, "integrationPreference"),
|
|
884
|
+
note: nullableRecordString(record, "note"),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function coordinationNextAction(git) {
|
|
888
|
+
if (!git.repositoryPath) {
|
|
889
|
+
return "Open the component source or a git worktree before integration.";
|
|
890
|
+
}
|
|
891
|
+
if (git.dirty) {
|
|
892
|
+
return "Review, commit, or explicitly hand off local changes before integration.";
|
|
893
|
+
}
|
|
894
|
+
if (!git.upstream) {
|
|
895
|
+
return "Push the branch and set upstream, or tell the integration host where to fetch it.";
|
|
896
|
+
}
|
|
897
|
+
if (git.behind !== null && git.behind > 0) {
|
|
898
|
+
return "Rebase or merge upstream before integration.";
|
|
899
|
+
}
|
|
900
|
+
if (git.ahead !== null && git.ahead > 0) {
|
|
901
|
+
return "Push the branch or ask the integration host to fetch it.";
|
|
902
|
+
}
|
|
903
|
+
return "Ready for review or integration.";
|
|
904
|
+
}
|
|
905
|
+
function projectSummary(context) {
|
|
906
|
+
return {
|
|
907
|
+
id: context.projectConfig.id,
|
|
908
|
+
name: context.projectConfig.name,
|
|
909
|
+
projectRoot: context.projectRoot,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function componentSummary(component) {
|
|
913
|
+
return {
|
|
914
|
+
id: component.id,
|
|
915
|
+
name: component.name,
|
|
916
|
+
role: component.role,
|
|
917
|
+
sourceRoot: component.sourceRoot,
|
|
918
|
+
worktreesRoot: component.worktreesRoot,
|
|
919
|
+
workTrackingProvider: component.workTracking?.provider ?? null,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function samePathOrDescendant(candidatePath, rootPath) {
|
|
923
|
+
const relative = path.relative(path.resolve(rootPath), path.resolve(candidatePath));
|
|
924
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
925
|
+
}
|
|
926
|
+
function currentTimestamp(now) {
|
|
927
|
+
const value = now?.() ?? new Date();
|
|
928
|
+
return typeof value === "string" ? value : value.toISOString();
|
|
929
|
+
}
|
|
930
|
+
function normalizedStringArray(values, pathName) {
|
|
931
|
+
if (!values) {
|
|
932
|
+
return [];
|
|
933
|
+
}
|
|
934
|
+
const result = [];
|
|
935
|
+
const seen = new Set();
|
|
936
|
+
for (const value of values) {
|
|
937
|
+
const normalized = requiredNonEmptyString(value, pathName);
|
|
938
|
+
if (!seen.has(normalized)) {
|
|
939
|
+
seen.add(normalized);
|
|
940
|
+
result.push(normalized);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
function optionalTrimmedString(value) {
|
|
946
|
+
if (value === undefined || value === null) {
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
const trimmed = value.trim();
|
|
950
|
+
return trimmed ? trimmed : undefined;
|
|
951
|
+
}
|
|
952
|
+
function optionalNullableTrimmedString(value) {
|
|
953
|
+
if (value === undefined) {
|
|
954
|
+
return undefined;
|
|
955
|
+
}
|
|
956
|
+
if (value === null) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
return optionalTrimmedString(value) ?? null;
|
|
960
|
+
}
|
|
961
|
+
function requiredNonEmptyString(value, pathName) {
|
|
962
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
963
|
+
throw new Error(`${pathName} must be a non-empty string`);
|
|
964
|
+
}
|
|
965
|
+
return value.trim();
|
|
966
|
+
}
|
|
967
|
+
function requiredRecordString(record, key) {
|
|
968
|
+
const value = record[key];
|
|
969
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
970
|
+
throw new Error(`handoff.${key} must be a non-empty string`);
|
|
971
|
+
}
|
|
972
|
+
return value;
|
|
973
|
+
}
|
|
974
|
+
function nullableRecordString(record, key) {
|
|
975
|
+
const value = record[key];
|
|
976
|
+
if (value === undefined || value === null) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
if (typeof value !== "string") {
|
|
980
|
+
throw new Error(`handoff.${key} must be a string or null`);
|
|
981
|
+
}
|
|
982
|
+
return value;
|
|
983
|
+
}
|
|
984
|
+
function nullableRecordBoolean(record, key) {
|
|
985
|
+
const value = record[key];
|
|
986
|
+
if (value === undefined || value === null) {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
if (typeof value !== "boolean") {
|
|
990
|
+
throw new Error(`handoff.${key} must be a boolean or null`);
|
|
991
|
+
}
|
|
992
|
+
return value;
|
|
993
|
+
}
|
|
994
|
+
function nullableRecordInteger(record, key) {
|
|
995
|
+
const value = record[key];
|
|
996
|
+
if (value === undefined || value === null) {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
1000
|
+
throw new Error(`handoff.${key} must be an integer or null`);
|
|
1001
|
+
}
|
|
1002
|
+
return value;
|
|
1003
|
+
}
|
|
1004
|
+
function recordStringArray(record, key) {
|
|
1005
|
+
const value = record[key];
|
|
1006
|
+
if (value === undefined) {
|
|
1007
|
+
return [];
|
|
1008
|
+
}
|
|
1009
|
+
if (!Array.isArray(value)) {
|
|
1010
|
+
throw new Error(`handoff.${key} must be an array`);
|
|
1011
|
+
}
|
|
1012
|
+
return value.map((entry, index) => {
|
|
1013
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
1014
|
+
throw new Error(`handoff.${key}[${index}] must be a non-empty string`);
|
|
1015
|
+
}
|
|
1016
|
+
return entry;
|
|
1017
|
+
});
|
|
1018
|
+
}
|