@wascartech/devbot 2.2.0 → 2.3.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 +17 -12
- package/dist/ci-lint-guard.js +88 -0
- package/dist/ci-lint-guard.js.map +1 -0
- package/dist/conflict-fix.js +69 -10
- package/dist/conflict-fix.js.map +1 -1
- package/dist/devcontainer.js +14 -2
- package/dist/devcontainer.js.map +1 -1
- package/dist/git.js +142 -45
- package/dist/git.js.map +1 -1
- package/dist/help.js +1 -1
- package/dist/help.js.map +1 -1
- package/dist/opencode.js +22 -4
- package/dist/opencode.js.map +1 -1
- package/dist/platform/gitealike/base.js +21 -0
- package/dist/platform/gitealike/base.js.map +1 -1
- package/dist/platform/github/index.js +21 -0
- package/dist/platform/github/index.js.map +1 -1
- package/dist/platform/gitlab/index.js +37 -8
- package/dist/platform/gitlab/index.js.map +1 -1
- package/dist/platform/gitlab/read.js +33 -2
- package/dist/platform/gitlab/read.js.map +1 -1
- package/dist/platform/gitlab/schemas-read.js +7 -0
- package/dist/platform/gitlab/schemas-read.js.map +1 -1
- package/dist/platform/port.js +2 -2
- package/dist/platform/port.js.map +1 -1
- package/dist/prompt.js.map +1 -1
- package/dist/run-files.js +9 -8
- package/dist/run-files.js.map +1 -1
- package/dist/runner.js +166 -27
- package/dist/runner.js.map +1 -1
- package/dist/webhook.js +51 -25
- package/dist/webhook.js.map +1 -1
- package/dist/work-item-branch-store.js +14 -5
- package/dist/work-item-branch-store.js.map +1 -1
- package/migrations/0033_work_item_branch_adopted.sql +6 -0
- package/package.json +3 -1
package/dist/runner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { buildPrompt, formatHistory } from "./prompt.js";
|
|
|
14
14
|
import { BUILTIN_SUBAGENTS, validateAgentNames } from "./mention.js";
|
|
15
15
|
import { renderBuildWorkflow } from "./build-workflow.js";
|
|
16
16
|
import { pathPolicyEnvArgs } from "./path-sandbox.js";
|
|
17
|
-
import { formatConflictFixNote, formatUnresolvedNote, formatFixOnlyUnresolvedNote, formatNoConflictsNote } from "./conflict-fix.js";
|
|
17
|
+
import { formatConflictFixNote, formatUnresolvedNote, formatFixOnlyUnresolvedNote, formatNoConflictsNote, formatAdoptedPushRefusedNote, formatAdoptedForceRefusedNote, formatAdoptedConflictRefusedNote } from "./conflict-fix.js";
|
|
18
18
|
import { writeRunFilesToVolume, ensurePodmanStore } from "./run-files.js";
|
|
19
19
|
import { startStreamedPhase, endStreamedPhase, appendChunk } from "./run-log.js";
|
|
20
20
|
import { addUsage } from "./run-usage.js";
|
|
@@ -22,10 +22,11 @@ import { parseMrContent } from "./mr-content.js";
|
|
|
22
22
|
import { buildCiFailureSection } from "./ci-failure.js";
|
|
23
23
|
import { recordChangeRequest } from "./change-request-store.js";
|
|
24
24
|
import { createChangeRequestWithAssignee } from "./mr-assignee.js";
|
|
25
|
+
import { runCiLintGuard } from "./ci-lint-guard.js";
|
|
25
26
|
import { decideChangeRequestAction, openChangeRequest } from "./change-request-lifecycle.js";
|
|
26
27
|
import { recordVolume, touchVolume } from "./volume-store.js";
|
|
27
28
|
import { setBaseBranchIfUnset, getBaseBranch } from "./base-branch-store.js";
|
|
28
|
-
import { setWorkBranchIfUnset, getWorkBranch } from "./work-item-branch-store.js";
|
|
29
|
+
import { setWorkBranchIfUnset, getWorkBranch, isWorkBranchAdopted } from "./work-item-branch-store.js";
|
|
29
30
|
import { isWorkItemCancelRequested } from "./queue-store.js";
|
|
30
31
|
import { emitMrEvent } from "./outbound-webhook.js";
|
|
31
32
|
import { resolveStorageDriver, nestedPodmanArgs, devcontainerVolumeArgs, devcontainerEnvArgs, devcontainerPromptSection, devcontainerEnabled, hasFuseDevice, sandboxBaseArgs, } from "./devcontainer.js";
|
|
@@ -75,7 +76,7 @@ export function modelArgs(model, variant = "") {
|
|
|
75
76
|
// opencode.json persist across mentions yet can never be staged by the
|
|
76
77
|
// agent's `git add -A`. State + cache go to /tmp: when dev containers are
|
|
77
78
|
// enabled the nested-Podman store is mounted at
|
|
78
|
-
// /home/devbot/.local/share/containers under
|
|
79
|
+
// /home/devbot/.local/share/containers under the pinned keep-id mapping, which makes
|
|
79
80
|
// podman create the missing parents /home/devbot/.local{,/share} as root:root
|
|
80
81
|
// 1755 — so opencode's Bun default (~/.local/state) cannot be created
|
|
81
82
|
// (EACCES). /tmp is always writable and off every volume, so lock/state files
|
|
@@ -96,6 +97,22 @@ export function xdgEnvArgs() {
|
|
|
96
97
|
function failure(phase, kind, message, logs = "", exitCode = null, model = "", variant = "") {
|
|
97
98
|
return { phase, kind, exitCode, message, logs: redact(logs), model, variant };
|
|
98
99
|
}
|
|
100
|
+
// Note for a push that failed because a repository object database refused the
|
|
101
|
+
// write. A lost push is an error, never a warning. The location distinguishes
|
|
102
|
+
// the two object databases involved: `local` is the workspace volume the push
|
|
103
|
+
// container fetches into, `server` is the forge repository receiving the push.
|
|
104
|
+
// A server-side failure is a forge storage problem (object ownership,
|
|
105
|
+
// permissions or disk), not something DevBot may paper over.
|
|
106
|
+
export function lostPushNote(location, reason) {
|
|
107
|
+
const cause = location === "local"
|
|
108
|
+
? "DevBot's workspace repository (`/workspace/.git/objects`) refused the object write (local permissions or disk)."
|
|
109
|
+
: location === "server"
|
|
110
|
+
? "The git server's repository object database refused the write — a server-side storage problem (object ownership/permissions or disk), not a DevBot bug."
|
|
111
|
+
: "A repository object database refused the object write.";
|
|
112
|
+
return (`❌ push failed — the branch was NOT pushed.\n\n${cause}\n\n` +
|
|
113
|
+
"Fix the repository storage before re-running; the commit is preserved in the workspace volume.\n\n" +
|
|
114
|
+
"```\n" + reason + "\n```");
|
|
115
|
+
}
|
|
99
116
|
// Post a non-fatal note when some submodules could not be fetched. The run
|
|
100
117
|
// proceeds without them (best-effort by design); this just surfaces which
|
|
101
118
|
// paths are missing so the user/admin can fix access. Never throws. Exported
|
|
@@ -273,6 +290,11 @@ export async function runForcePushOnly(input) {
|
|
|
273
290
|
workItemNumber: input.workItemNumber,
|
|
274
291
|
workItemId: input.workItemId,
|
|
275
292
|
});
|
|
293
|
+
if (isWorkBranchAdopted(input.workItemId)) {
|
|
294
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedForceRefusedNote(branch))
|
|
295
|
+
.catch((e) => log.warn("adopted-force-refused note failed", e));
|
|
296
|
+
return { kind: "terminal", reason: "force-push refused on adopted branch" };
|
|
297
|
+
}
|
|
276
298
|
if (config.DRY_RUN) {
|
|
277
299
|
log.info("DRY_RUN — would force-push branch", { branch });
|
|
278
300
|
await getPlatform().workItems
|
|
@@ -280,20 +302,28 @@ export async function runForcePushOnly(input) {
|
|
|
280
302
|
.catch((e) => log.warn("dry-run force-push note failed", e));
|
|
281
303
|
return { kind: "ok" };
|
|
282
304
|
}
|
|
283
|
-
const
|
|
305
|
+
const envVars = projectEnvVars(input.projectId);
|
|
306
|
+
const builderImage = projectBuilderImage(input.projectId);
|
|
307
|
+
const pushed = await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage, ciLintGate(input, envVars, builderImage));
|
|
284
308
|
if (!pushed.ok) {
|
|
309
|
+
if (pushed.gateBlocked) {
|
|
310
|
+
return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
|
|
311
|
+
}
|
|
285
312
|
log.error("standalone force-push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
286
313
|
const reply = pushed.missingVolume
|
|
287
314
|
? `⚠️ Nothing to force-push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/force-push\`.`
|
|
288
315
|
: pushed.missingBranch
|
|
289
316
|
? `⚠️ Nothing to force-push — no local branch \`${branch}\` for this work item.`
|
|
290
|
-
:
|
|
317
|
+
: pushed.objectDbFailure
|
|
318
|
+
? lostPushNote(pushed.objectDbLocation, pushed.reason)
|
|
319
|
+
: `⚠️ force-push failed: ${pushed.reason}`;
|
|
291
320
|
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("force-push-failure note failed", e));
|
|
292
|
-
// Missing workspace/branch are terminal (a re-run hits the same state);
|
|
293
|
-
//
|
|
294
|
-
//
|
|
321
|
+
// Missing workspace/branch are terminal (a re-run hits the same state); a
|
|
322
|
+
// repository object-database write failure is also terminal (retrying the
|
|
323
|
+
// same unwritable store cannot help and a lost push must be an error). Any
|
|
324
|
+
// other push failure may be a network blip — transient.
|
|
295
325
|
const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "force-push-no-target" : "force-push-failed", pushed.reason, pushed.logs);
|
|
296
|
-
return pushed.missingVolume || pushed.missingBranch
|
|
326
|
+
return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
|
|
297
327
|
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
298
328
|
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
299
329
|
}
|
|
@@ -309,6 +339,7 @@ export async function runForcePushOnly(input) {
|
|
|
309
339
|
// never race an in-flight build's push. Same error-log/retry machinery.
|
|
310
340
|
export async function runPushOnly(input) {
|
|
311
341
|
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
342
|
+
const adopted = isWorkBranchAdopted(input.workItemId);
|
|
312
343
|
log.info("push (standalone) start", {
|
|
313
344
|
projectId: input.projectId,
|
|
314
345
|
workItemNumber: input.workItemNumber,
|
|
@@ -321,19 +352,30 @@ export async function runPushOnly(input) {
|
|
|
321
352
|
.catch((e) => log.warn("dry-run push note failed", e));
|
|
322
353
|
return { kind: "ok" };
|
|
323
354
|
}
|
|
324
|
-
const
|
|
355
|
+
const envVars = projectEnvVars(input.projectId);
|
|
356
|
+
const builderImage = projectBuilderImage(input.projectId);
|
|
357
|
+
const pushed = await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, workBranchAdopted: adopted }, envVars, builderImage, ciLintGate(input, envVars, builderImage));
|
|
325
358
|
if (!pushed.ok) {
|
|
359
|
+
if (pushed.gateBlocked) {
|
|
360
|
+
return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
|
|
361
|
+
}
|
|
326
362
|
log.error("standalone push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
327
363
|
const reply = pushed.missingVolume
|
|
328
364
|
? `⚠️ Nothing to push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/push\`.`
|
|
329
365
|
: pushed.missingBranch
|
|
330
366
|
? `⚠️ Nothing to push — no local branch \`${branch}\` for this work item.`
|
|
331
|
-
:
|
|
367
|
+
: pushed.objectDbFailure
|
|
368
|
+
? lostPushNote(pushed.objectDbLocation, pushed.reason)
|
|
369
|
+
: pushed.adoptedDiverged
|
|
370
|
+
? formatAdoptedPushRefusedNote(branch)
|
|
371
|
+
: `⚠️ push failed: ${pushed.reason}`;
|
|
332
372
|
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("push-failure note failed", e));
|
|
333
373
|
// Missing workspace/branch are terminal (a re-run hits the same state); a
|
|
334
|
-
//
|
|
374
|
+
// repository object-database write failure is also terminal (retrying the
|
|
375
|
+
// same unwritable store cannot help and a lost push must be an error). Any
|
|
376
|
+
// other push failure may be a network blip — transient.
|
|
335
377
|
const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "push-no-target" : "push-failed", pushed.reason, pushed.logs);
|
|
336
|
-
return pushed.missingVolume || pushed.missingBranch
|
|
378
|
+
return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
|
|
337
379
|
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
338
380
|
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
339
381
|
}
|
|
@@ -350,12 +392,18 @@ export async function runPushOnly(input) {
|
|
|
350
392
|
// branch skips straight to the MR step.
|
|
351
393
|
export async function runMrOnly(input) {
|
|
352
394
|
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
395
|
+
const adopted = isWorkBranchAdopted(input.workItemId);
|
|
353
396
|
log.info("mr (standalone) start", {
|
|
354
397
|
projectId: input.projectId,
|
|
355
398
|
workItemNumber: input.workItemNumber,
|
|
356
399
|
workItemId: input.workItemId,
|
|
357
400
|
forcePush: input.forcePush === true,
|
|
358
401
|
});
|
|
402
|
+
if (adopted && input.forcePush) {
|
|
403
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedForceRefusedNote(branch))
|
|
404
|
+
.catch((e) => log.warn("adopted-force-refused note failed", e));
|
|
405
|
+
return { kind: "terminal", reason: "force-push refused on adopted branch" };
|
|
406
|
+
}
|
|
359
407
|
if (config.DRY_RUN) {
|
|
360
408
|
log.info("DRY_RUN — would push + open MR", { branch });
|
|
361
409
|
await getPlatform().workItems
|
|
@@ -363,20 +411,30 @@ export async function runMrOnly(input) {
|
|
|
363
411
|
.catch((e) => log.warn("dry-run mr note failed", e));
|
|
364
412
|
return { kind: "ok" };
|
|
365
413
|
}
|
|
414
|
+
const envVars = projectEnvVars(input.projectId);
|
|
415
|
+
const builderImage = projectBuilderImage(input.projectId);
|
|
416
|
+
const gate = ciLintGate(input, envVars, builderImage);
|
|
366
417
|
// Publish first. A missing workspace/branch is terminal and actionable.
|
|
367
418
|
const pushed = input.forcePush
|
|
368
|
-
? await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch },
|
|
369
|
-
: await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch },
|
|
419
|
+
? await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage, gate)
|
|
420
|
+
: await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, workBranchAdopted: adopted }, envVars, builderImage, gate);
|
|
370
421
|
if (!pushed.ok) {
|
|
422
|
+
if (pushed.gateBlocked) {
|
|
423
|
+
return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
|
|
424
|
+
}
|
|
371
425
|
log.error("standalone /mr push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
372
426
|
const reply = pushed.missingVolume
|
|
373
427
|
? `⚠️ Nothing to publish — no workspace exists for this work item yet. Run a \`/build\` first, then \`/mr\`.`
|
|
374
428
|
: pushed.missingBranch
|
|
375
429
|
? `⚠️ Nothing to publish — no local branch \`${branch}\` for this work item.`
|
|
376
|
-
:
|
|
430
|
+
: pushed.objectDbFailure
|
|
431
|
+
? lostPushNote(pushed.objectDbLocation, pushed.reason)
|
|
432
|
+
: "adoptedDiverged" in pushed && pushed.adoptedDiverged
|
|
433
|
+
? formatAdoptedPushRefusedNote(branch)
|
|
434
|
+
: `⚠️ push failed: ${pushed.reason}`;
|
|
377
435
|
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("mr-push-failure note failed", e));
|
|
378
436
|
const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "mr-no-target" : "mr-push-failed", pushed.reason, pushed.logs);
|
|
379
|
-
return pushed.missingVolume || pushed.missingBranch
|
|
437
|
+
return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
|
|
380
438
|
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
381
439
|
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
382
440
|
}
|
|
@@ -389,7 +447,11 @@ export async function runMrOnly(input) {
|
|
|
389
447
|
// guarantee as the post-build path. Conflicts are checked here; only when
|
|
390
448
|
// the check actually runs do we mint an MCP context (a fresh bearer token +
|
|
391
449
|
// current providers) so the resolver build run has working forge tools.
|
|
392
|
-
if (projectAutoResolveConflicts(input.projectId, input.workItemId)) {
|
|
450
|
+
if (projectAutoResolveConflicts(input.projectId, input.workItemId) && adopted) {
|
|
451
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
|
|
452
|
+
.catch((e) => log.warn("adopted-conflict-refused note failed", e));
|
|
453
|
+
}
|
|
454
|
+
else if (projectAutoResolveConflicts(input.projectId, input.workItemId)) {
|
|
393
455
|
const envVars = projectEnvVars(input.projectId);
|
|
394
456
|
const builderImage = projectBuilderImage(input.projectId);
|
|
395
457
|
const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
|
|
@@ -427,7 +489,7 @@ export async function runMrOnly(input) {
|
|
|
427
489
|
if (rechecked && rechecked.conflictFree) {
|
|
428
490
|
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
|
|
429
491
|
if (!repushed.ok) {
|
|
430
|
-
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrResolved.branch, checked.files))
|
|
492
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatUnresolvedNote(mrResolved.branch, checked.files))
|
|
431
493
|
.catch((e) => log.warn("conflict-repush-failure note failed", e));
|
|
432
494
|
return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
|
|
433
495
|
}
|
|
@@ -508,6 +570,11 @@ export async function runFixConflictsOnly(input) {
|
|
|
508
570
|
workItemNumber: input.workItemNumber,
|
|
509
571
|
workItemId: input.workItemId,
|
|
510
572
|
});
|
|
573
|
+
if (isWorkBranchAdopted(input.workItemId)) {
|
|
574
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
|
|
575
|
+
.catch((e) => log.warn("adopted-conflict-refused note failed", e));
|
|
576
|
+
return { kind: "terminal", reason: "conflict resolution refused on adopted branch" };
|
|
577
|
+
}
|
|
511
578
|
if (config.DRY_RUN) {
|
|
512
579
|
log.info("DRY_RUN — would fix merge conflicts", { branch });
|
|
513
580
|
await getPlatform().workItems
|
|
@@ -588,9 +655,20 @@ export async function runFixConflictsOnly(input) {
|
|
|
588
655
|
? await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target }, envVars, builderImage)
|
|
589
656
|
: null;
|
|
590
657
|
if (rechecked && rechecked.conflictFree) {
|
|
658
|
+
const ciLint = await runCiLintGuard({
|
|
659
|
+
projectId: input.projectId,
|
|
660
|
+
workItemId: input.workItemId,
|
|
661
|
+
workItemNumber: input.workItemNumber,
|
|
662
|
+
baseBranch: target,
|
|
663
|
+
envVars,
|
|
664
|
+
builderImage,
|
|
665
|
+
});
|
|
666
|
+
if (ciLint.status === "invalid") {
|
|
667
|
+
return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("conflict", "ci-lint-invalid", ciLint.errors.join("\n") || "invalid CI configuration", "", null, runModel.model, runModel.variant) };
|
|
668
|
+
}
|
|
591
669
|
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
|
|
592
670
|
if (!repushed.ok) {
|
|
593
|
-
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatFixOnlyUnresolvedNote(target, checked.files))
|
|
671
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatFixOnlyUnresolvedNote(target, checked.files))
|
|
594
672
|
.catch((e) => log.warn("fix-conflicts-repush-failure note failed", e));
|
|
595
673
|
return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
|
|
596
674
|
}
|
|
@@ -643,6 +721,39 @@ async function resolveMrTarget(projectId, workItemId, branches, model, variant)
|
|
|
643
721
|
}
|
|
644
722
|
return { ok: true, branch: hit };
|
|
645
723
|
}
|
|
724
|
+
// The base branch used to resolve lint includes. The stored /base value wins;
|
|
725
|
+
// otherwise the configured base-branch list is probed. A lookup failure leaves
|
|
726
|
+
// the guard skipped rather than blocking a publish the run was about to make.
|
|
727
|
+
async function resolveCiLintBaseBranch(projectId, workItemId) {
|
|
728
|
+
const stored = getBaseBranch(workItemId);
|
|
729
|
+
if (stored)
|
|
730
|
+
return stored;
|
|
731
|
+
try {
|
|
732
|
+
return await getPlatform().repository.firstExistingBranch(projectId, projectBaseBranches(projectId));
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
log.warn("ci/lint base branch lookup failed", { workItemId, err: e });
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// The publish gate handed to pushStandalone/forcePushStandalone: it runs the
|
|
740
|
+
// lint after the workspace existence check (so a missing workspace still stops
|
|
741
|
+
// on that inspect) and blocks only on an invalid verdict.
|
|
742
|
+
function ciLintGate(input, envVars, builderImage) {
|
|
743
|
+
return async () => {
|
|
744
|
+
const result = await runCiLintGuard({
|
|
745
|
+
projectId: input.projectId,
|
|
746
|
+
workItemId: input.workItemId,
|
|
747
|
+
workItemNumber: input.workItemNumber,
|
|
748
|
+
baseBranch: await resolveCiLintBaseBranch(input.projectId, input.workItemId),
|
|
749
|
+
envVars,
|
|
750
|
+
builderImage,
|
|
751
|
+
});
|
|
752
|
+
return result.status === "invalid"
|
|
753
|
+
? { ok: false, reason: result.errors.join("\n") || "invalid CI configuration" }
|
|
754
|
+
: { ok: true };
|
|
755
|
+
};
|
|
756
|
+
}
|
|
646
757
|
export async function run(input) {
|
|
647
758
|
log.info("run start", {
|
|
648
759
|
projectId: input.projectId,
|
|
@@ -690,7 +801,7 @@ export async function run(input) {
|
|
|
690
801
|
// with a note; the stored value stays authoritative.
|
|
691
802
|
const storedWorkBranch = getWorkBranch(input.workItemId);
|
|
692
803
|
if (input.workBranch && !storedWorkBranch) {
|
|
693
|
-
setWorkBranchIfUnset(input.workItemId, input.workBranch);
|
|
804
|
+
setWorkBranchIfUnset(input.workItemId, input.workBranch, input.workBranchAdopted === true);
|
|
694
805
|
}
|
|
695
806
|
else if (input.workBranch && storedWorkBranch && input.workBranch !== storedWorkBranch) {
|
|
696
807
|
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ /branch '${input.workBranch}' ignored — this work item already uses branch '${storedWorkBranch}'.`)
|
|
@@ -700,7 +811,8 @@ export async function run(input) {
|
|
|
700
811
|
// (storedWorkBranch is null on that run, so resolving it directly would
|
|
701
812
|
// wrongly fall back to wi-<number>).
|
|
702
813
|
const workBranch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
703
|
-
const
|
|
814
|
+
const workBranchAdopted = isWorkBranchAdopted(input.workItemId);
|
|
815
|
+
const prepared = await ensureVolumeAndWorkspace({ ...input, workBranch, workBranchAdopted }, input.mode, mcpCtx.envVars, mcpCtx.baseBranches, mcpCtx.builderImage);
|
|
704
816
|
// Cancellation checkpoint: a stop during clone/sync kills that container;
|
|
705
817
|
// treat the resulting prepare failure as an intentional stop, not an error.
|
|
706
818
|
const cancelledAfterPrep = cancelledOutcome(input.workItemId, "post-prepare");
|
|
@@ -940,7 +1052,25 @@ export async function run(input) {
|
|
|
940
1052
|
const cancelledBeforePush = cancelledOutcome(input.workItemId, "pre-push");
|
|
941
1053
|
if (cancelledBeforePush)
|
|
942
1054
|
return cancelledBeforePush;
|
|
943
|
-
|
|
1055
|
+
// CI guard (#116): the last commit is in the working tree now, so lint the
|
|
1056
|
+
// entry file once before the branch becomes visible. Invalid config never
|
|
1057
|
+
// reaches the forge; an unavailable linter only adds a report line.
|
|
1058
|
+
const ciLint = await runCiLintGuard({
|
|
1059
|
+
projectId: input.projectId,
|
|
1060
|
+
workItemId: input.workItemId,
|
|
1061
|
+
workItemNumber: input.workItemNumber,
|
|
1062
|
+
baseBranch: mrTarget,
|
|
1063
|
+
envVars: mcpCtx.envVars,
|
|
1064
|
+
builderImage: mcpCtx.builderImage,
|
|
1065
|
+
});
|
|
1066
|
+
if (ciLint.status === "invalid") {
|
|
1067
|
+
return {
|
|
1068
|
+
kind: "terminal",
|
|
1069
|
+
reason: "ci/lint rejected the CI configuration",
|
|
1070
|
+
failure: failure("push", "ci-lint-invalid", ciLint.errors.join("\n") || "invalid CI configuration", "", null, runModel.model, runModel.variant),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const pushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, workBranchAdopted, ...(input.forcePush ? { forcePush: true } : {}), ...(prepared.branchTip ? { preRunTip: prepared.branchTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
944
1074
|
if (pushed.ok && pushed.fallbackCommitted) {
|
|
945
1075
|
log.warn("agent forgot to commit; devbot authored fallback commit", {
|
|
946
1076
|
workItemId: input.workItemId,
|
|
@@ -948,8 +1078,13 @@ export async function run(input) {
|
|
|
948
1078
|
});
|
|
949
1079
|
}
|
|
950
1080
|
if (!pushed.ok) {
|
|
951
|
-
log.error("push failed", { workItemId: input.workItemId, stderr: pushed.reason });
|
|
952
|
-
|
|
1081
|
+
log.error("push failed", { workItemId: input.workItemId, stderr: pushed.reason, objectDbFailure: pushed.objectDbFailure, objectDbLocation: pushed.objectDbLocation });
|
|
1082
|
+
const pushNote = pushed.objectDbFailure
|
|
1083
|
+
? lostPushNote(pushed.objectDbLocation, pushed.reason)
|
|
1084
|
+
: pushed.adoptedDiverged
|
|
1085
|
+
? formatAdoptedPushRefusedNote(workBranch)
|
|
1086
|
+
: `❌ push failed — the branch was NOT pushed: ${pushed.reason}`;
|
|
1087
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, pushNote)
|
|
953
1088
|
.catch((e) => log.warn("push-failure note failed", e));
|
|
954
1089
|
return { kind: "terminal", reason: `push failed: ${pushed.reason}`, failure: failure("push", "push-failed", pushed.reason, pushed.logs, null, runModel.model, runModel.variant) };
|
|
955
1090
|
}
|
|
@@ -972,7 +1107,11 @@ export async function run(input) {
|
|
|
972
1107
|
// found, a direct build run resolves them, then the branch is re-pushed
|
|
973
1108
|
// (rewrite-aware lease) before the MR. On failure the branch stays
|
|
974
1109
|
// published, the MR is skipped, and an actionable note is posted.
|
|
975
|
-
if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts)) {
|
|
1110
|
+
if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts) && workBranchAdopted) {
|
|
1111
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
|
|
1112
|
+
.catch((e) => log.warn("adopted-conflict-refused note failed", e));
|
|
1113
|
+
}
|
|
1114
|
+
else if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts)) {
|
|
976
1115
|
const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
|
|
977
1116
|
if (cancelledPreCheck)
|
|
978
1117
|
return cancelledPreCheck;
|
|
@@ -1003,7 +1142,7 @@ export async function run(input) {
|
|
|
1003
1142
|
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
1004
1143
|
if (!repushed.ok) {
|
|
1005
1144
|
log.error("conflict-fix re-push failed", { workItemId: input.workItemId, reason: repushed.reason });
|
|
1006
|
-
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrTarget, checked.files))
|
|
1145
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatUnresolvedNote(mrTarget, checked.files))
|
|
1007
1146
|
.catch((e) => log.warn("conflict-repush-failure note failed", e));
|
|
1008
1147
|
return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
|
|
1009
1148
|
}
|