@wascartech/devbot 0.0.0-stage → 2.1.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/LICENSE +202 -0
- package/README.md +638 -2
- package/dist/access-grant-store.js +99 -0
- package/dist/access-grant-store.js.map +1 -0
- package/dist/access-resolve.js +11 -0
- package/dist/access-resolve.js.map +1 -0
- package/dist/access-rule-store.js +99 -0
- package/dist/access-rule-store.js.map +1 -0
- package/dist/admin/access-routes.js +73 -0
- package/dist/admin/access-routes.js.map +1 -0
- package/dist/admin/access-rule-routes.js +107 -0
- package/dist/admin/access-rule-routes.js.map +1 -0
- package/dist/admin/custom-repos-routes.js +93 -0
- package/dist/admin/custom-repos-routes.js.map +1 -0
- package/dist/admin/env-routes.js +74 -0
- package/dist/admin/env-routes.js.map +1 -0
- package/dist/admin/global-settings-routes.js +254 -0
- package/dist/admin/global-settings-routes.js.map +1 -0
- package/dist/admin/mcp-cleanup.js +12 -0
- package/dist/admin/mcp-cleanup.js.map +1 -0
- package/dist/admin/mcp-routes.js +169 -0
- package/dist/admin/mcp-routes.js.map +1 -0
- package/dist/admin/query-params.js +80 -0
- package/dist/admin/query-params.js.map +1 -0
- package/dist/admin/routes.js +150 -0
- package/dist/admin/routes.js.map +1 -0
- package/dist/admin/run-history-routes.js +44 -0
- package/dist/admin/run-history-routes.js.map +1 -0
- package/dist/admin/run-routes.js +99 -0
- package/dist/admin/run-routes.js.map +1 -0
- package/dist/admin/settings-routes.js +119 -0
- package/dist/admin/settings-routes.js.map +1 -0
- package/dist/admin/stats-routes.js +51 -0
- package/dist/admin/stats-routes.js.map +1 -0
- package/dist/admin/task-routes.js +213 -0
- package/dist/admin/task-routes.js.map +1 -0
- package/dist/admin/test-routes.js +38 -0
- package/dist/admin/test-routes.js.map +1 -0
- package/dist/admin/volume-routes.js +82 -0
- package/dist/admin/volume-routes.js.map +1 -0
- package/dist/admin/webhook-routes.js +174 -0
- package/dist/admin/webhook-routes.js.map +1 -0
- package/dist/agents.js +48 -0
- package/dist/agents.js.map +1 -0
- package/dist/assignee-value.js +6 -0
- package/dist/assignee-value.js.map +1 -0
- package/dist/attachments.js +76 -0
- package/dist/attachments.js.map +1 -0
- package/dist/auto-commands.js +88 -0
- package/dist/auto-commands.js.map +1 -0
- package/dist/auto-features.js +32 -0
- package/dist/auto-features.js.map +1 -0
- package/dist/base-branch-store.js +40 -0
- package/dist/base-branch-store.js.map +1 -0
- package/dist/base-branches.js +9 -0
- package/dist/base-branches.js.map +1 -0
- package/dist/build-workflow.js +116 -0
- package/dist/build-workflow.js.map +1 -0
- package/dist/change-request-lifecycle.js +16 -0
- package/dist/change-request-lifecycle.js.map +1 -0
- package/dist/change-request-store.js +72 -0
- package/dist/change-request-store.js.map +1 -0
- package/dist/ci-failure.js +80 -0
- package/dist/ci-failure.js.map +1 -0
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +17 -0
- package/dist/config.js.map +1 -0
- package/dist/conflict-fix.js +178 -0
- package/dist/conflict-fix.js.map +1 -0
- package/dist/cookie.js +70 -0
- package/dist/cookie.js.map +1 -0
- package/dist/crypto-util.js +40 -0
- package/dist/crypto-util.js.map +1 -0
- package/dist/custom-repos-store.js +90 -0
- package/dist/custom-repos-store.js.map +1 -0
- package/dist/custom-repos-sync.js +118 -0
- package/dist/custom-repos-sync.js.map +1 -0
- package/dist/db.js +12 -0
- package/dist/db.js.map +1 -0
- package/dist/devbot-scripts.js +18 -0
- package/dist/devbot-scripts.js.map +1 -0
- package/dist/devcontainer.js +141 -0
- package/dist/devcontainer.js.map +1 -0
- package/dist/env-config-store.js +132 -0
- package/dist/env-config-store.js.map +1 -0
- package/dist/error-log-store.js +193 -0
- package/dist/error-log-store.js.map +1 -0
- package/dist/git.js +605 -0
- package/dist/git.js.map +1 -0
- package/dist/global-queue.js +127 -0
- package/dist/global-queue.js.map +1 -0
- package/dist/global-settings-store.js +307 -0
- package/dist/global-settings-store.js.map +1 -0
- package/dist/help.js +41 -0
- package/dist/help.js.map +1 -0
- package/dist/http-csp.js +15 -0
- package/dist/http-csp.js.map +1 -0
- package/dist/http-util.js +40 -0
- package/dist/http-util.js.map +1 -0
- package/dist/idempotency.js +50 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/lifecycle.js +234 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/logger.js +33 -0
- package/dist/logger.js.map +1 -0
- package/dist/lsp-servers.js +81 -0
- package/dist/lsp-servers.js.map +1 -0
- package/dist/mcp/auth.js +62 -0
- package/dist/mcp/auth.js.map +1 -0
- package/dist/mcp/effective-mcp.js +42 -0
- package/dist/mcp/effective-mcp.js.map +1 -0
- package/dist/mcp/oauth-refresh.js +138 -0
- package/dist/mcp/oauth-refresh.js.map +1 -0
- package/dist/mcp/peer-sync.js +109 -0
- package/dist/mcp/peer-sync.js.map +1 -0
- package/dist/mcp/registry.js +54 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/runner-bridge.js +93 -0
- package/dist/mcp/runner-bridge.js.map +1 -0
- package/dist/mcp/server.js +173 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/_helpers.js +88 -0
- package/dist/mcp/tools/_helpers.js.map +1 -0
- package/dist/mcp/tools/attachments.js +117 -0
- package/dist/mcp/tools/attachments.js.map +1 -0
- package/dist/mcp/tools/cicd.js +105 -0
- package/dist/mcp/tools/cicd.js.map +1 -0
- package/dist/mcp/tools/discovery.js +34 -0
- package/dist/mcp/tools/discovery.js.map +1 -0
- package/dist/mcp/tools/issues.js +56 -0
- package/dist/mcp/tools/issues.js.map +1 -0
- package/dist/mcp/tools/merge_requests.js +67 -0
- package/dist/mcp/tools/merge_requests.js.map +1 -0
- package/dist/mcp/tools/metadata.js +127 -0
- package/dist/mcp/tools/metadata.js.map +1 -0
- package/dist/mcp/tools/peer_sync.js +43 -0
- package/dist/mcp/tools/peer_sync.js.map +1 -0
- package/dist/mcp/tools/repository.js +178 -0
- package/dist/mcp/tools/repository.js.map +1 -0
- package/dist/mcp/tools/work_tracking.js +125 -0
- package/dist/mcp/tools/work_tracking.js.map +1 -0
- package/dist/mcp/tools/workitems.js +30 -0
- package/dist/mcp/tools/workitems.js.map +1 -0
- package/dist/mcp/volume-files.js +187 -0
- package/dist/mcp/volume-files.js.map +1 -0
- package/dist/mcp-auth-store.js +134 -0
- package/dist/mcp-auth-store.js.map +1 -0
- package/dist/mcp-config-store.js +83 -0
- package/dist/mcp-config-store.js.map +1 -0
- package/dist/mention.js +512 -0
- package/dist/mention.js.map +1 -0
- package/dist/migrate.js +73 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mr-assignee.js +71 -0
- package/dist/mr-assignee.js.map +1 -0
- package/dist/mr-content.js +90 -0
- package/dist/mr-content.js.map +1 -0
- package/dist/oauth-routes.js +54 -0
- package/dist/oauth-routes.js.map +1 -0
- package/dist/onboarding-allowlist.js +32 -0
- package/dist/onboarding-allowlist.js.map +1 -0
- package/dist/opencode.js +291 -0
- package/dist/opencode.js.map +1 -0
- package/dist/outbound-webhook-store.js +229 -0
- package/dist/outbound-webhook-store.js.map +1 -0
- package/dist/outbound-webhook.js +314 -0
- package/dist/outbound-webhook.js.map +1 -0
- package/dist/path-sandbox.js +200 -0
- package/dist/path-sandbox.js.map +1 -0
- package/dist/platform/git-auth.js +34 -0
- package/dist/platform/git-auth.js.map +1 -0
- package/dist/platform/gitealike/attachments.js +62 -0
- package/dist/platform/gitealike/attachments.js.map +1 -0
- package/dist/platform/gitealike/base.js +935 -0
- package/dist/platform/gitealike/base.js.map +1 -0
- package/dist/platform/gitealike/forgejo.js +16 -0
- package/dist/platform/gitealike/forgejo.js.map +1 -0
- package/dist/platform/gitealike/gitea.js +16 -0
- package/dist/platform/gitealike/gitea.js.map +1 -0
- package/dist/platform/gitealike/http.js +152 -0
- package/dist/platform/gitealike/http.js.map +1 -0
- package/dist/platform/gitealike/schemas.js +284 -0
- package/dist/platform/gitealike/schemas.js.map +1 -0
- package/dist/platform/github/attachments.js +52 -0
- package/dist/platform/github/attachments.js.map +1 -0
- package/dist/platform/github/http.js +153 -0
- package/dist/platform/github/http.js.map +1 -0
- package/dist/platform/github/index.js +936 -0
- package/dist/platform/github/index.js.map +1 -0
- package/dist/platform/github/schemas.js +308 -0
- package/dist/platform/github/schemas.js.map +1 -0
- package/dist/platform/github/zip.js +85 -0
- package/dist/platform/github/zip.js.map +1 -0
- package/dist/platform/gitlab/admin.js +105 -0
- package/dist/platform/gitlab/admin.js.map +1 -0
- package/dist/platform/gitlab/api.js +229 -0
- package/dist/platform/gitlab/api.js.map +1 -0
- package/dist/platform/gitlab/attachments.js +79 -0
- package/dist/platform/gitlab/attachments.js.map +1 -0
- package/dist/platform/gitlab/http.js +165 -0
- package/dist/platform/gitlab/http.js.map +1 -0
- package/dist/platform/gitlab/index.js +630 -0
- package/dist/platform/gitlab/index.js.map +1 -0
- package/dist/platform/gitlab/oauth.js +58 -0
- package/dist/platform/gitlab/oauth.js.map +1 -0
- package/dist/platform/gitlab/read.js +182 -0
- package/dist/platform/gitlab/read.js.map +1 -0
- package/dist/platform/gitlab/schemas-read.js +274 -0
- package/dist/platform/gitlab/schemas-read.js.map +1 -0
- package/dist/platform/gitlab/schemas.js +251 -0
- package/dist/platform/gitlab/schemas.js.map +1 -0
- package/dist/platform/gitlab/signature.js +52 -0
- package/dist/platform/gitlab/signature.js.map +1 -0
- package/dist/platform/index.js +38 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/port.js +26 -0
- package/dist/platform/port.js.map +1 -0
- package/dist/platform/types.js +80 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/podman.js +119 -0
- package/dist/podman.js.map +1 -0
- package/dist/project-sync.js +69 -0
- package/dist/project-sync.js.map +1 -0
- package/dist/prompt.js +47 -0
- package/dist/prompt.js.map +1 -0
- package/dist/queue-store.js +236 -0
- package/dist/queue-store.js.map +1 -0
- package/dist/queue.js +129 -0
- package/dist/queue.js.map +1 -0
- package/dist/redact.js +23 -0
- package/dist/redact.js.map +1 -0
- package/dist/run-files.js +297 -0
- package/dist/run-files.js.map +1 -0
- package/dist/run-filters.js +54 -0
- package/dist/run-filters.js.map +1 -0
- package/dist/run-history-store.js +229 -0
- package/dist/run-history-store.js.map +1 -0
- package/dist/run-log-sink.js +270 -0
- package/dist/run-log-sink.js.map +1 -0
- package/dist/run-log.js +97 -0
- package/dist/run-log.js.map +1 -0
- package/dist/run-usage.js +29 -0
- package/dist/run-usage.js.map +1 -0
- package/dist/run-view.js +112 -0
- package/dist/run-view.js.map +1 -0
- package/dist/runner.js +1094 -0
- package/dist/runner.js.map +1 -0
- package/dist/runtime-apply.js +26 -0
- package/dist/runtime-apply.js.map +1 -0
- package/dist/runtime-settings.js +71 -0
- package/dist/runtime-settings.js.map +1 -0
- package/dist/runtime-state.js +19 -0
- package/dist/runtime-state.js.map +1 -0
- package/dist/server.js +119 -0
- package/dist/server.js.map +1 -0
- package/dist/settings-store.js +320 -0
- package/dist/settings-store.js.map +1 -0
- package/dist/status.js +121 -0
- package/dist/status.js.map +1 -0
- package/dist/stop.js +56 -0
- package/dist/stop.js.map +1 -0
- package/dist/sweeps.js +87 -0
- package/dist/sweeps.js.map +1 -0
- package/dist/task-log.js +279 -0
- package/dist/task-log.js.map +1 -0
- package/dist/task-view.js +62 -0
- package/dist/task-view.js.map +1 -0
- package/dist/token-store.js +68 -0
- package/dist/token-store.js.map +1 -0
- package/dist/types/access.js +74 -0
- package/dist/types/access.js.map +1 -0
- package/dist/types/custom-repos.js +28 -0
- package/dist/types/custom-repos.js.map +1 -0
- package/dist/types/env-config.js +25 -0
- package/dist/types/env-config.js.map +1 -0
- package/dist/types/env.js +190 -0
- package/dist/types/env.js.map +1 -0
- package/dist/types/global-settings.js +178 -0
- package/dist/types/global-settings.js.map +1 -0
- package/dist/types/lsp-servers.js +145 -0
- package/dist/types/lsp-servers.js.map +1 -0
- package/dist/types/mcp-config.js +77 -0
- package/dist/types/mcp-config.js.map +1 -0
- package/dist/types/opencode.js +59 -0
- package/dist/types/opencode.js.map +1 -0
- package/dist/types/outbound-webhook.js +56 -0
- package/dist/types/outbound-webhook.js.map +1 -0
- package/dist/types/project-settings.js +114 -0
- package/dist/types/project-settings.js.map +1 -0
- package/dist/types/provider-registry.js +37 -0
- package/dist/types/provider-registry.js.map +1 -0
- package/dist/types/runtime-settings.js +78 -0
- package/dist/types/runtime-settings.js.map +1 -0
- package/dist/usage-stats.js +159 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/dist/volume-gc.js +193 -0
- package/dist/volume-gc.js.map +1 -0
- package/dist/volume-manage.js +191 -0
- package/dist/volume-manage.js.map +1 -0
- package/dist/volume-store.js +122 -0
- package/dist/volume-store.js.map +1 -0
- package/dist/webhook.js +1106 -0
- package/dist/webhook.js.map +1 -0
- package/dist/work-item-auto-store.js +75 -0
- package/dist/work-item-auto-store.js.map +1 -0
- package/dist/work-item-branch-store.js +37 -0
- package/dist/work-item-branch-store.js.map +1 -0
- package/migrations/0001_initial.sql +59 -0
- package/migrations/0002_queue_status.sql +15 -0
- package/migrations/0003_work_item_base_branch.sql +14 -0
- package/migrations/0004_project_settings.sql +15 -0
- package/migrations/0005_global_settings.sql +34 -0
- package/migrations/0006_model_variants.sql +8 -0
- package/migrations/0007_container_error_logs.sql +21 -0
- package/migrations/0008_project_access_grants.sql +16 -0
- package/migrations/0009_drop_imported_legacy.sql +4 -0
- package/migrations/0010_container_error_log_model.sql +6 -0
- package/migrations/0011_container_error_log_title.sql +5 -0
- package/migrations/0012_pending_task_cancel.sql +6 -0
- package/migrations/0013_builder_image.sql +6 -0
- package/migrations/0014_build_workflow.sql +7 -0
- package/migrations/0015_access_rules.sql +24 -0
- package/migrations/0016_trigger_policy.sql +17 -0
- package/migrations/0017_attachment_upload.sql +8 -0
- package/migrations/0018_work_item_branch.sql +11 -0
- package/migrations/0019_auto_push_mr.sql +18 -0
- package/migrations/0020_outbound_webhooks.sql +47 -0
- package/migrations/0021_runtime_settings.sql +12 -0
- package/migrations/0022_auto_resolve_conflicts.sql +17 -0
- package/migrations/0023_ci_fail_auto_report.sql +27 -0
- package/migrations/0024_work_item_auto_override.sql +22 -0
- package/migrations/0025_run_history.sql +37 -0
- package/migrations/0026_run_history_task_seq.sql +6 -0
- package/migrations/0027_custom_repos.sql +11 -0
- package/migrations/0028_run_usage.sql +14 -0
- package/migrations/0029_drop_ocr_langs.sql +6 -0
- package/migrations/0030_mr_assignee.sql +9 -0
- package/migrations/0031_lsp_servers.sql +14 -0
- package/opencode-config/managed/devbot-agent-policy.md +61 -0
- package/opencode-config/managed/opencode.json +77 -0
- package/opencode-config/opencode.json +211 -0
- package/opencode-config/plugins/devbot-path-guard.js +224 -0
- package/package.json +157 -4
- package/public/assets/DashboardSidebarCollapse-rIuzCmVm.js +1 -0
- package/public/assets/DashboardToolbar-DFCUqZ4Z.js +1 -0
- package/public/assets/ErrorLogModal.vue_vue_type_script_setup_true_lang-BCyNIf1E.js +1 -0
- package/public/assets/ErrorLogsPage-DyYK2Rop.js +1 -0
- package/public/assets/LogSlideover.vue_vue_type_script_setup_true_lang-33xie7TE.js +1 -0
- package/public/assets/OverviewPage-CFfa0uwg.js +1 -0
- package/public/assets/PageGrid-CBROIU87.js +1 -0
- package/public/assets/PeriodRangePicker.vue_vue_type_script_setup_true_lang-DiFUNem7.js +1 -0
- package/public/assets/ProjectDetailPage-CreyvUit.js +1 -0
- package/public/assets/ProjectsPage-CFx8-elJ.js +1 -0
- package/public/assets/RunDetailSlideover.vue_vue_type_script_setup_true_lang-DopYYmCT.js +1 -0
- package/public/assets/RunsPage-CRnnXU4P.js +1 -0
- package/public/assets/Select-Bvx32Diz.js +1 -0
- package/public/assets/SelectMenu-BjMxc4VB.js +1 -0
- package/public/assets/SettingsPage-Bb6LM3wO.js +60 -0
- package/public/assets/TimeCell.vue_vue_type_script_setup_true_lang-DW_UB0Fu.js +1 -0
- package/public/assets/UsagePage-BLMDEsv-.js +332 -0
- package/public/assets/UsagePage-DodEeAjB.css +1 -0
- package/public/assets/VolumesPage-CxcLc3L2.js +1 -0
- package/public/assets/WebhookConfigPanel.vue_vue_type_script_setup_true_lang-B2V2GF6b.js +1 -0
- package/public/assets/WorkItemCell.vue_vue_type_script_setup_true_lang-BQn6vTjd.js +1 -0
- package/public/assets/index-BMbMibyb.js +4 -0
- package/public/assets/index-C5RBBrPj.js +30 -0
- package/public/assets/index-CgX29UgC.css +1 -0
- package/public/assets/usage-format-jsb31_O8.js +1 -0
- package/public/assets/useManagedProjects-8rtjRqVJ.js +1 -0
- package/public/assets/useRouteTab-DfwrcMic.js +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/index.html +15 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import { config } from "./config.js";
|
|
2
|
+
import { runtime } from "./runtime-state.js";
|
|
3
|
+
import { log } from "./logger.js";
|
|
4
|
+
import { runPodmanCapture } from "./opencode.js";
|
|
5
|
+
import { getPlatform } from "./platform/index.js";
|
|
6
|
+
import { shq, redact } from "./redact.js";
|
|
7
|
+
import { prepareMcpRun, cleanupMcpRun, projectEnvVars, projectBuilderImage, projectBaseBranches, projectAutoResolveConflicts } from "./mcp/runner-bridge.js";
|
|
8
|
+
import { ensureGitExcludes, PEERS_DIR } from "./mcp/peer-sync.js";
|
|
9
|
+
import { syncCustomRepos, skillsPathsFor, SKILLS_DIR } from "./custom-repos-sync.js";
|
|
10
|
+
import { OUT_DIR, attachmentPromptSection } from "./mcp/volume-files.js";
|
|
11
|
+
import { podman } from "./podman.js";
|
|
12
|
+
import { volumeName, resolveWorkBranch, timeoutFor, volumeExists, ensureVolumeAndWorkspace, pushBranch, forcePushStandalone, pushStandalone, envArgs, runContainerName, logDriverArgs, podmanVolumeName, formatSubmoduleWarning, workItemContainerNames, xdgVolumeName, XDG_HOME_DIR, checkConflicts, conflictResolverPrompt, readRemoteBranchTip, parseResolverResult, volumeSelinuxArgs } from "./git.js";
|
|
13
|
+
import { buildPrompt, formatHistory } from "./prompt.js";
|
|
14
|
+
import { BUILTIN_SUBAGENTS, validateAgentNames } from "./mention.js";
|
|
15
|
+
import { renderBuildWorkflow } from "./build-workflow.js";
|
|
16
|
+
import { pathPolicyEnvArgs } from "./path-sandbox.js";
|
|
17
|
+
import { formatConflictFixNote, formatUnresolvedNote, formatFixOnlyUnresolvedNote, formatNoConflictsNote } from "./conflict-fix.js";
|
|
18
|
+
import { writeRunFilesToVolume, ensurePodmanStore } from "./run-files.js";
|
|
19
|
+
import { startStreamedPhase, endStreamedPhase, appendChunk } from "./run-log.js";
|
|
20
|
+
import { addUsage } from "./run-usage.js";
|
|
21
|
+
import { parseMrContent } from "./mr-content.js";
|
|
22
|
+
import { buildCiFailureSection } from "./ci-failure.js";
|
|
23
|
+
import { recordChangeRequest } from "./change-request-store.js";
|
|
24
|
+
import { createChangeRequestWithAssignee } from "./mr-assignee.js";
|
|
25
|
+
import { decideChangeRequestAction, openChangeRequest } from "./change-request-lifecycle.js";
|
|
26
|
+
import { recordVolume, touchVolume } from "./volume-store.js";
|
|
27
|
+
import { setBaseBranchIfUnset, getBaseBranch } from "./base-branch-store.js";
|
|
28
|
+
import { setWorkBranchIfUnset, getWorkBranch } from "./work-item-branch-store.js";
|
|
29
|
+
import { isWorkItemCancelRequested } from "./queue-store.js";
|
|
30
|
+
import { emitMrEvent } from "./outbound-webhook.js";
|
|
31
|
+
import { resolveStorageDriver, nestedPodmanArgs, devcontainerVolumeArgs, devcontainerEnvArgs, devcontainerPromptSection, devcontainerEnabled, hasFuseDevice, sandboxBaseArgs, } from "./devcontainer.js";
|
|
32
|
+
export const ERROR_REPLY = "⚠️ Something went wrong. Please ask the admin to check the logs.";
|
|
33
|
+
// Decide what a build run does with its branch after the agent succeeds (work
|
|
34
|
+
// item #53). Both steps are opt-in: the resolved auto-push/auto-MR settings
|
|
35
|
+
// enable them globally/per-project, and the /push /mr /force-push tokens force
|
|
36
|
+
// them on per run. `/mr` implies a push (an MR needs a published branch);
|
|
37
|
+
// auto-MR without a push is unreachable by design because the MR step is gated
|
|
38
|
+
// on the push actually being planned.
|
|
39
|
+
// Pure + exported so check-run-outcomes can pin the matrix without a container.
|
|
40
|
+
export function buildOutputPlan(input, autoPush, autoMr) {
|
|
41
|
+
const push = input.forcePush === true || input.pushRequested === true || input.mrRequested === true || autoPush;
|
|
42
|
+
const mr = push && (input.mrRequested === true || autoMr);
|
|
43
|
+
return { push, mr };
|
|
44
|
+
}
|
|
45
|
+
// Decide whether the post-build publish path runs the conflict auto-fix step.
|
|
46
|
+
// On when the feature resolves on for the work item, or when the mention
|
|
47
|
+
// explicitly forced it with /fix-conflicts (+ /build) — a deliberate per-run
|
|
48
|
+
// override, like /push and /mr. Pure + exported so check-run-outcomes can pin
|
|
49
|
+
// the matrix without a container.
|
|
50
|
+
export function shouldFixConflicts(input, autoResolveConflicts) {
|
|
51
|
+
return input.fixConflictsRequested === true || autoResolveConflicts;
|
|
52
|
+
}
|
|
53
|
+
// Cancellation checkpoint. /stop (or the WebUI Stop button) sets the durable
|
|
54
|
+
// cancel flag and kills the phase containers; the kill surfaces here as a
|
|
55
|
+
// non-zero podman exit. Returning {kind:"cancelled"} instead of an error keeps
|
|
56
|
+
// the intentional stop quiet — no error reply, no error-log row, no push/MR.
|
|
57
|
+
// `phase` is for logs only. Exported for scripts/check-run-outcomes.ts.
|
|
58
|
+
export function cancelledOutcome(workItemId, phase) {
|
|
59
|
+
if (!isWorkItemCancelRequested(workItemId))
|
|
60
|
+
return null;
|
|
61
|
+
log.info("run cancelled", { workItemId, phase });
|
|
62
|
+
return { kind: "cancelled", reason: "stopped by user" };
|
|
63
|
+
}
|
|
64
|
+
// Build the opencode CLI flags that select a model and (optionally) a variant.
|
|
65
|
+
// The variant flag is omitted entirely when unset so opencode uses the model's
|
|
66
|
+
// default variant. Pure + exported so runner-helpers can assert the arg shape.
|
|
67
|
+
export function modelArgs(model, variant = "") {
|
|
68
|
+
return variant ? ["--model", model, "--variant", variant] : ["--model", model];
|
|
69
|
+
}
|
|
70
|
+
// The XDG_* overrides passed to the opencode container as explicit `--env`
|
|
71
|
+
// pairs (never via the host `env` map — see the XDG_DATA_HOME note in
|
|
72
|
+
// runOpencodeInContainer: they would leak into the host podman process).
|
|
73
|
+
// Data + config live on the dedicated per-work-item xdg volume (mounted at
|
|
74
|
+
// XDG_HOME_DIR, OUTSIDE /workspace) so the session DB, auth, and per-run
|
|
75
|
+
// opencode.json persist across mentions yet can never be staged by the
|
|
76
|
+
// agent's `git add -A`. State + cache go to /tmp: when dev containers are
|
|
77
|
+
// enabled the nested-Podman store is mounted at
|
|
78
|
+
// /home/devbot/.local/share/containers under --userns=keep-id, which makes
|
|
79
|
+
// podman create the missing parents /home/devbot/.local{,/share} as root:root
|
|
80
|
+
// 1755 — so opencode's Bun default (~/.local/state) cannot be created
|
|
81
|
+
// (EACCES). /tmp is always writable and off every volume, so lock/state files
|
|
82
|
+
// can never enter the target repo's git diff. Pure + exported for tests.
|
|
83
|
+
export function xdgEnvArgs() {
|
|
84
|
+
return [
|
|
85
|
+
"--env", `XDG_DATA_HOME=${XDG_HOME_DIR}/data`,
|
|
86
|
+
"--env", `XDG_CONFIG_HOME=${XDG_HOME_DIR}/config`,
|
|
87
|
+
"--env", "XDG_STATE_HOME=/tmp/opencode-state",
|
|
88
|
+
"--env", "XDG_CACHE_HOME=/tmp/opencode-cache",
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
// Build a redacted failure record carried on a non-ok RunOutcome. The webhook
|
|
92
|
+
// handler persists it once the failure is final (terminal, or transient with
|
|
93
|
+
// retries exhausted) so retries don't create duplicate error-log rows.
|
|
94
|
+
// `model`/`variant` are the exact resolved values for the run (empty for
|
|
95
|
+
// pre-prepare failures, where no model was selected yet).
|
|
96
|
+
function failure(phase, kind, message, logs = "", exitCode = null, model = "", variant = "") {
|
|
97
|
+
return { phase, kind, exitCode, message, logs: redact(logs), model, variant };
|
|
98
|
+
}
|
|
99
|
+
// Post a non-fatal note when some submodules could not be fetched. The run
|
|
100
|
+
// proceeds without them (best-effort by design); this just surfaces which
|
|
101
|
+
// paths are missing so the user/admin can fix access. Never throws. Exported
|
|
102
|
+
// for scripts/check-run-outcomes.ts.
|
|
103
|
+
export async function postSubmoduleWarnings(projectId, workItemNumber, paths) {
|
|
104
|
+
const note = formatSubmoduleWarning(paths);
|
|
105
|
+
if (!note)
|
|
106
|
+
return;
|
|
107
|
+
await getPlatform().workItems.postComment(projectId, workItemNumber, note).catch((e) => log.warn("submodule-warning note failed", { projectId, workItemNumber, err: e }));
|
|
108
|
+
}
|
|
109
|
+
// Destroy the per-work-item volumes. Force-stops any in-flight container first
|
|
110
|
+
// (run/sync/push + the nested-podman init), then removes the workspace volume
|
|
111
|
+
// and, when present, the paired nested-Podman store volume
|
|
112
|
+
// (`devbot-wi-<id>-podman`). Idempotent: a missing volume (never created,
|
|
113
|
+
// already GC'd) is a no-op at debug level. DRY_RUN skips the actual rm and
|
|
114
|
+
// logs intent. Returns a status string for the caller to post back to the
|
|
115
|
+
// thread; never throws.
|
|
116
|
+
// Reset mid-run: the runner's runPodmanCapture sees its container die with a
|
|
117
|
+
// non-zero exit and posts ERROR_REPLY — intentional, no suppression flag.
|
|
118
|
+
export async function destroyWorkItemVolume(workItemId) {
|
|
119
|
+
const vol = volumeName(workItemId);
|
|
120
|
+
const podVol = podmanVolumeName(workItemId);
|
|
121
|
+
const xdgVol = xdgVolumeName(workItemId);
|
|
122
|
+
if (config.DRY_RUN) {
|
|
123
|
+
log.debug("DRY_RUN — would reset volume", { vol, podVol, xdgVol });
|
|
124
|
+
return { ok: true, message: `🧪 DRY_RUN — would reset volume ${vol}` };
|
|
125
|
+
}
|
|
126
|
+
// Force-stop any in-flight containers for this work item. Best-effort:
|
|
127
|
+
// missing containers produce a non-zero exit, logged at debug. One podman
|
|
128
|
+
// call with all names (one process, lazier than per-name killContainerByName).
|
|
129
|
+
const names = workItemContainerNames(workItemId);
|
|
130
|
+
const stop = await podman(["rm", "-f", ...names]);
|
|
131
|
+
if (stop.code !== 0) {
|
|
132
|
+
log.debug("container stop on reset (likely already gone)", { vol, code: stop.code });
|
|
133
|
+
}
|
|
134
|
+
// Remove the workspace, nested-Podman store, and xdg volumes independently:
|
|
135
|
+
// any may be absent (never created, already GC'd, or created before the
|
|
136
|
+
// feature landed), and a failure on one must not skip the others. The store
|
|
137
|
+
// volume is only present when DEVCONTAINER_ENABLED created it; the xdg
|
|
138
|
+
// volume only after the first writeRunFilesToVolume.
|
|
139
|
+
const results = await Promise.all([vol, podVol, xdgVol].map(async (v) => {
|
|
140
|
+
if (!(await volumeExists(v))) {
|
|
141
|
+
log.debug("volume absent on reset, nothing to remove", { vol: v });
|
|
142
|
+
return { vol: v, existed: false, removed: false };
|
|
143
|
+
}
|
|
144
|
+
const r = await podman(["volume", "rm", v]);
|
|
145
|
+
if (r.code === 0) {
|
|
146
|
+
log.info("reset: removed volume", { vol: v });
|
|
147
|
+
return { vol: v, existed: true, removed: true };
|
|
148
|
+
}
|
|
149
|
+
log.warn("reset: failed to remove volume", { vol: v, code: r.code, stderr: redact(r.stderr) });
|
|
150
|
+
return { vol: v, existed: true, removed: false };
|
|
151
|
+
}));
|
|
152
|
+
const workspace = results[0];
|
|
153
|
+
if (!workspace.existed) {
|
|
154
|
+
return { ok: true, message: `ℹ️ Nothing to reset (volume ${vol} was already gone)` };
|
|
155
|
+
}
|
|
156
|
+
if (!workspace.removed) {
|
|
157
|
+
return { ok: false, message: `⚠️ Reset failed (see logs)` };
|
|
158
|
+
}
|
|
159
|
+
// Workspace removed. The sibling volumes are best-effort: a failure there
|
|
160
|
+
// leaves it for the GC ledger, but the reset itself succeeded.
|
|
161
|
+
const stuck = results.slice(1).filter((r) => r.existed && !r.removed).map((r) => r.vol);
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
message: stuck.length === 0
|
|
165
|
+
? `✅ Reset: removed volume ${vol}`
|
|
166
|
+
: `✅ Reset: removed volume ${vol} (⚠️ sibling volume(s) ${stuck.join(", ")} could not be removed)`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// The agent run container must share the work item's SELinux level with the
|
|
170
|
+
// mid-run helper containers (attachment read/write, peer sync). Nested-Podman
|
|
171
|
+
// build runs already disable SELinux separation, where a level is moot.
|
|
172
|
+
export function runSelinuxArgs(workItemId, devcontainerArgs) {
|
|
173
|
+
return devcontainerArgs.includes("label=disable") ? [] : volumeSelinuxArgs(workItemId);
|
|
174
|
+
}
|
|
175
|
+
async function runOpencodeInContainer(input, mode, prompt, envVars = [], model, variant = "", devcontainerArgs = [], builderImage = config.BUILDER_IMAGE, opts = {}) {
|
|
176
|
+
const vol = volumeName(input.workItemId);
|
|
177
|
+
// ponytail: XDG_DATA_HOME=/home/devbot/.xdg/data redirects BOTH opencode's
|
|
178
|
+
// session DB AND its auth file onto the per-work-item xdg volume (mounted
|
|
179
|
+
// outside /workspace). The default ~/.local/share/opencode/ doesn't exist in
|
|
180
|
+
// the image and home is mode 700, so opencode can't create it; the volume is
|
|
181
|
+
// writable by devbot. opencode 1.18.5 ignores OPENCODE_AUTH_FILE and always
|
|
182
|
+
// reads $XDG_DATA_HOME/opencode/auth.json. The auth.json is written INTO the
|
|
183
|
+
// volume by writeRunFilesToVolume, NOT bind-mounted.
|
|
184
|
+
// ponytail: XDG_STATE_HOME/XDG_CACHE_HOME are redirected to /tmp (see
|
|
185
|
+
// xdgEnvArgs). Their defaults sit under ~/.local, which nested-Podman's
|
|
186
|
+
// keep-id store mount turns root-owned (EACCES) — and /tmp keeps lock/cache
|
|
187
|
+
// files off the volume so they can't reach the git diff.
|
|
188
|
+
// ponytail: XDG_DATA_HOME (and the other XDG_* overrides) are deliberately
|
|
189
|
+
// NOT in `env`. `env` is merged into the host podman process via
|
|
190
|
+
// runPodmanCapture -> spawn; XDG_DATA_HOME doubles as podman's own runtime
|
|
191
|
+
// storage dir, so a host-visible xdg path would make podman try to mkdir it
|
|
192
|
+
// (permission denied) and fail with exit 125 before the container starts.
|
|
193
|
+
// Pass them to the container via explicit --env NAME=VALUE below, never via
|
|
194
|
+
// host env.
|
|
195
|
+
const env = {};
|
|
196
|
+
const args = [
|
|
197
|
+
"run", "--rm", ...logDriverArgs(), "--name", opts.containerName ?? runContainerName(input.workItemId),
|
|
198
|
+
// --add-host lets the container reach the host's MCP server at
|
|
199
|
+
// host.containers.internal:<MCP_PORT> without --network host.
|
|
200
|
+
"--add-host", "host.containers.internal:host-gateway",
|
|
201
|
+
"-v", `${vol}:/workspace:Z`,
|
|
202
|
+
// Dedicated xdg volume, mounted OUTSIDE the work tree. opencode reads its
|
|
203
|
+
// config + writes its session DB / auth here via $XDG_CONFIG_HOME and
|
|
204
|
+
// $XDG_DATA_HOME (see xdgEnvArgs), so credentials never sit inside the
|
|
205
|
+
// cloned repo where `git add -A` could stage them.
|
|
206
|
+
"-v", `${xdgVolumeName(input.workItemId)}:${XDG_HOME_DIR}:Z`,
|
|
207
|
+
// ponytail: the opencode.json, path-guard plugin, custom agent files, and
|
|
208
|
+
// auth all live in the xdg volume (written per-run by run-files.ts and
|
|
209
|
+
// custom-repos-sync.ts) and are read via $XDG_CONFIG_HOME/$XDG_DATA_HOME
|
|
210
|
+
// below — no bind mount. The old read-only agents/plugins bind mounts to
|
|
211
|
+
// ~/.config/opencode were dead once XDG_CONFIG_HOME moved (work item #39)
|
|
212
|
+
// and were removed in #63.
|
|
213
|
+
"-w", "/workspace",
|
|
214
|
+
...xdgEnvArgs(),
|
|
215
|
+
// Path-scope policy for the in-container guard plugin (work item #46).
|
|
216
|
+
// Explicit --env for the same reason as the XDG_* overrides: never leak
|
|
217
|
+
// into the host podman process. See src/path-sandbox.ts.
|
|
218
|
+
...pathPolicyEnvArgs(),
|
|
219
|
+
...envArgs(envVars),
|
|
220
|
+
// CAP_SETUID/CAP_SETGID so docker/devbot-shell can drop commands to the
|
|
221
|
+
// unprivileged `agent` UID in every mode.
|
|
222
|
+
...sandboxBaseArgs(),
|
|
223
|
+
// One SELinux level for this work item so mid-run MCP helpers (which mount
|
|
224
|
+
// the same volume with :Z) can read/write it without revoking our access.
|
|
225
|
+
...runSelinuxArgs(input.workItemId, devcontainerArgs),
|
|
226
|
+
// Nested rootless Podman flags + the private store mount (build mode,
|
|
227
|
+
// DEVCONTAINER_ENABLED only). Empty otherwise.
|
|
228
|
+
...devcontainerArgs,
|
|
229
|
+
];
|
|
230
|
+
args.push(builderImage);
|
|
231
|
+
args.push(
|
|
232
|
+
// devbot-opencode sets umask 002 so files opencode writes into /workspace
|
|
233
|
+
// stay group-writable for the `agent` UID (bash tool / commits).
|
|
234
|
+
"/usr/local/bin/devbot-opencode", "run", "--format", "json",
|
|
235
|
+
// --continue resumes the last session for /workspace. opencode
|
|
236
|
+
// fingerprints projects by dir, so this reliably targets this work
|
|
237
|
+
// item's session. On a fresh volume (no prior session) opencode creates
|
|
238
|
+
// a new one rather than erroring. Replaces the old `--session issue-<id>`
|
|
239
|
+
// which always failed: opencode owns its `ses_...` IDs and won't adopt
|
|
240
|
+
// a caller-chosen one.
|
|
241
|
+
...(opts.resume === false ? [] : ["--continue"]), "--dir", "/workspace", "--auto", "--agent", mode, // "plan" or "build"
|
|
242
|
+
...modelArgs(model, variant), shq(prompt));
|
|
243
|
+
// Per-run log sink (work item #61): emit the phase delimiter, then stream
|
|
244
|
+
// chunks to disk as they arrive so a long run is not buffered in memory.
|
|
245
|
+
// opencode spawns podman directly (not via podman()), so this is the only
|
|
246
|
+
// capture point for the agent-run container.
|
|
247
|
+
const streamedName = opts.containerName ?? runContainerName(input.workItemId);
|
|
248
|
+
startStreamedPhase(streamedName);
|
|
249
|
+
const result = await runPodmanCapture({
|
|
250
|
+
args,
|
|
251
|
+
timeoutMs: timeoutFor(mode),
|
|
252
|
+
// Inactivity watchdog (#96): bound a silent wedge/loop well before the total
|
|
253
|
+
// phase timeout. RUN_STALL_TIMEOUT_MS=0 disables it.
|
|
254
|
+
stallMs: config.RUN_STALL_TIMEOUT_MS,
|
|
255
|
+
env,
|
|
256
|
+
context: { workItemId: input.workItemId, workItemNumber: input.workItemNumber, projectId: input.projectId, mode: input.mode },
|
|
257
|
+
onChunk: (_stream, chunk) => appendChunk(input.workItemId, chunk),
|
|
258
|
+
});
|
|
259
|
+
endStreamedPhase(streamedName, result.exitCode);
|
|
260
|
+
addUsage(input.workItemId, result.usage);
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
// Standalone `/force-push` (no /build): force-push the work item's existing
|
|
264
|
+
// local branch to origin with --force-with-lease. No opencode run, no clone,
|
|
265
|
+
// no commit — a recovery action that re-publishes a WIP branch whose normal
|
|
266
|
+
// push was refused (e.g. the branch history was rewritten). Queued through the
|
|
267
|
+
// normal per-work-item FIFO so it can never race an in-flight build's push.
|
|
268
|
+
// Uses the same error-log/retry machinery as run().
|
|
269
|
+
export async function runForcePushOnly(input) {
|
|
270
|
+
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
271
|
+
log.info("force-push (standalone) start", {
|
|
272
|
+
projectId: input.projectId,
|
|
273
|
+
workItemNumber: input.workItemNumber,
|
|
274
|
+
workItemId: input.workItemId,
|
|
275
|
+
});
|
|
276
|
+
if (config.DRY_RUN) {
|
|
277
|
+
log.info("DRY_RUN — would force-push branch", { branch });
|
|
278
|
+
await getPlatform().workItems
|
|
279
|
+
.postComment(input.projectId, input.workItemNumber, `🧪 DRY_RUN — would force-push \`${branch}\``)
|
|
280
|
+
.catch((e) => log.warn("dry-run force-push note failed", e));
|
|
281
|
+
return { kind: "ok" };
|
|
282
|
+
}
|
|
283
|
+
const pushed = await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
|
|
284
|
+
if (!pushed.ok) {
|
|
285
|
+
log.error("standalone force-push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
286
|
+
const reply = pushed.missingVolume
|
|
287
|
+
? `⚠️ Nothing to force-push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/force-push\`.`
|
|
288
|
+
: pushed.missingBranch
|
|
289
|
+
? `⚠️ Nothing to force-push — no local branch \`${branch}\` for this work item.`
|
|
290
|
+
: `⚠️ force-push failed: ${pushed.reason}`;
|
|
291
|
+
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
|
+
// a push failure may be a network blip or a lease rejection the retry's
|
|
294
|
+
// fresh fetch resolves — transient.
|
|
295
|
+
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
|
|
297
|
+
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
298
|
+
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
299
|
+
}
|
|
300
|
+
await getPlatform().workItems
|
|
301
|
+
.postComment(input.projectId, input.workItemNumber, `✅ Force-pushed \`${branch}\`.`)
|
|
302
|
+
.catch((e) => log.warn("force-push-success note failed", e));
|
|
303
|
+
return { kind: "ok" };
|
|
304
|
+
}
|
|
305
|
+
// Standalone `/push` (no /build): publish the work item's existing local branch
|
|
306
|
+
// to origin with a plain fast-forward push. No opencode run, no clone, no
|
|
307
|
+
// commit — a recovery action for a branch a prior run left unpublished (e.g.
|
|
308
|
+
// auto-push was off). Queued through the normal per-work-item FIFO so it can
|
|
309
|
+
// never race an in-flight build's push. Same error-log/retry machinery.
|
|
310
|
+
export async function runPushOnly(input) {
|
|
311
|
+
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
312
|
+
log.info("push (standalone) start", {
|
|
313
|
+
projectId: input.projectId,
|
|
314
|
+
workItemNumber: input.workItemNumber,
|
|
315
|
+
workItemId: input.workItemId,
|
|
316
|
+
});
|
|
317
|
+
if (config.DRY_RUN) {
|
|
318
|
+
log.info("DRY_RUN — would push branch", { branch });
|
|
319
|
+
await getPlatform().workItems
|
|
320
|
+
.postComment(input.projectId, input.workItemNumber, `🧪 DRY_RUN — would push \`${branch}\``)
|
|
321
|
+
.catch((e) => log.warn("dry-run push note failed", e));
|
|
322
|
+
return { kind: "ok" };
|
|
323
|
+
}
|
|
324
|
+
const pushed = await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
|
|
325
|
+
if (!pushed.ok) {
|
|
326
|
+
log.error("standalone push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
327
|
+
const reply = pushed.missingVolume
|
|
328
|
+
? `⚠️ Nothing to push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/push\`.`
|
|
329
|
+
: pushed.missingBranch
|
|
330
|
+
? `⚠️ Nothing to push — no local branch \`${branch}\` for this work item.`
|
|
331
|
+
: `⚠️ push failed: ${pushed.reason}`;
|
|
332
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("push-failure note failed", e));
|
|
333
|
+
// Missing workspace/branch are terminal (a re-run hits the same state); a
|
|
334
|
+
// push failure may be a network blip — transient.
|
|
335
|
+
const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "push-no-target" : "push-failed", pushed.reason, pushed.logs);
|
|
336
|
+
return pushed.missingVolume || pushed.missingBranch
|
|
337
|
+
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
338
|
+
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
339
|
+
}
|
|
340
|
+
await getPlatform().workItems
|
|
341
|
+
.postComment(input.projectId, input.workItemNumber, pushed.pushed ? `✅ Pushed \`${branch}\`.` : `ℹ️ Nothing to push — \`${branch}\` is already up to date.`)
|
|
342
|
+
.catch((e) => log.warn("push-success note failed", e));
|
|
343
|
+
return { kind: "ok" };
|
|
344
|
+
}
|
|
345
|
+
// Standalone `/mr` (no /build): publish the work item's existing local branch
|
|
346
|
+
// (plain push; --force-with-lease when /force-push was combined) then create
|
|
347
|
+
// or update its merge request against the resolved target branch. No opencode
|
|
348
|
+
// run and no agent-authored description, so the historical fallback content
|
|
349
|
+
// (`Resolve #<n>` / `Generated by devbot.`) is used. An already-published
|
|
350
|
+
// branch skips straight to the MR step.
|
|
351
|
+
export async function runMrOnly(input) {
|
|
352
|
+
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
353
|
+
log.info("mr (standalone) start", {
|
|
354
|
+
projectId: input.projectId,
|
|
355
|
+
workItemNumber: input.workItemNumber,
|
|
356
|
+
workItemId: input.workItemId,
|
|
357
|
+
forcePush: input.forcePush === true,
|
|
358
|
+
});
|
|
359
|
+
if (config.DRY_RUN) {
|
|
360
|
+
log.info("DRY_RUN — would push + open MR", { branch });
|
|
361
|
+
await getPlatform().workItems
|
|
362
|
+
.postComment(input.projectId, input.workItemNumber, `🧪 DRY_RUN — would push \`${branch}\` and open/update its MR`)
|
|
363
|
+
.catch((e) => log.warn("dry-run mr note failed", e));
|
|
364
|
+
return { kind: "ok" };
|
|
365
|
+
}
|
|
366
|
+
// Publish first. A missing workspace/branch is terminal and actionable.
|
|
367
|
+
const pushed = input.forcePush
|
|
368
|
+
? await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId))
|
|
369
|
+
: await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
|
|
370
|
+
if (!pushed.ok) {
|
|
371
|
+
log.error("standalone /mr push failed", { workItemId: input.workItemId, reason: pushed.reason });
|
|
372
|
+
const reply = pushed.missingVolume
|
|
373
|
+
? `⚠️ Nothing to publish — no workspace exists for this work item yet. Run a \`/build\` first, then \`/mr\`.`
|
|
374
|
+
: pushed.missingBranch
|
|
375
|
+
? `⚠️ Nothing to publish — no local branch \`${branch}\` for this work item.`
|
|
376
|
+
: `⚠️ push failed: ${pushed.reason}`;
|
|
377
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("mr-push-failure note failed", e));
|
|
378
|
+
const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "mr-no-target" : "mr-push-failed", pushed.reason, pushed.logs);
|
|
379
|
+
return pushed.missingVolume || pushed.missingBranch
|
|
380
|
+
? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
|
|
381
|
+
: { kind: "transient", reason: pushed.reason, failure: failureDetail };
|
|
382
|
+
}
|
|
383
|
+
const mrResolved = await resolveMrTarget(input.projectId, input.workItemId, projectBaseBranches(input.projectId), "", "");
|
|
384
|
+
if (!mrResolved.ok) {
|
|
385
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, mrResolved.note).catch((e) => log.warn("mrTarget note failed", e));
|
|
386
|
+
return mrResolved.outcome;
|
|
387
|
+
}
|
|
388
|
+
// Conflict auto-fix for the standalone /mr path (work item #56): same
|
|
389
|
+
// guarantee as the post-build path. Conflicts are checked here; only when
|
|
390
|
+
// the check actually runs do we mint an MCP context (a fresh bearer token +
|
|
391
|
+
// current providers) so the resolver build run has working forge tools.
|
|
392
|
+
if (projectAutoResolveConflicts(input.projectId, input.workItemId)) {
|
|
393
|
+
const envVars = projectEnvVars(input.projectId);
|
|
394
|
+
const builderImage = projectBuilderImage(input.projectId);
|
|
395
|
+
const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
|
|
396
|
+
if (cancelledPreCheck)
|
|
397
|
+
return cancelledPreCheck;
|
|
398
|
+
const checked = await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target: mrResolved.branch }, envVars, builderImage);
|
|
399
|
+
if (checked === null) {
|
|
400
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Could not check for merge conflicts with \`${mrResolved.branch}\`; skipping the merge request. Re-run when the forge is reachable.`)
|
|
401
|
+
.catch((e) => log.warn("conflict-check-fault note failed", e));
|
|
402
|
+
return { kind: "transient", reason: "conflict check failed", failure: failure("conflict", "check-failed", "checkConflicts returned null") };
|
|
403
|
+
}
|
|
404
|
+
if (!checked.conflictFree) {
|
|
405
|
+
let mcpCtx;
|
|
406
|
+
try {
|
|
407
|
+
mcpCtx = await prepareMcpRun(input.projectId, input.workItemId, input.workItemNumber, timeoutFor("build") + 5 * 60_000);
|
|
408
|
+
await writeRunFilesToVolume(input.workItemId, mcpCtx.mcpToken, mcpCtx.mcpServers, mcpCtx.mcpAuthJson, envVars, mcpCtx.plugins, mcpCtx.providers, builderImage, skillsPathsFor(mcpCtx.customRepos.skills), mcpCtx.lspServers);
|
|
409
|
+
const runModel = mcpCtx.models.build;
|
|
410
|
+
if (!runModel.model) {
|
|
411
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Conflict auto-fix is enabled but no build model is configured. Set a build model in the WebUI, then re-run \`/mr\`.`)
|
|
412
|
+
.catch((e) => log.warn("conflict-missing-model note failed", e));
|
|
413
|
+
return { kind: "terminal", reason: "conflict auto-fix requires a build model", failure: failure("conflict", "no-model", "no build model configured") };
|
|
414
|
+
}
|
|
415
|
+
const preFixTip = await readRemoteBranchTip({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage);
|
|
416
|
+
const cancelledPreResolve = cancelledOutcome(input.workItemId, "pre-conflict-resolve");
|
|
417
|
+
if (cancelledPreResolve)
|
|
418
|
+
return cancelledPreResolve;
|
|
419
|
+
const resolved = await runOpencodeInContainer(input, "build", conflictResolverPrompt(branch, mrResolved.branch), envVars, runModel.model, runModel.variant, [], builderImage, { resume: false, containerName: `devbot-wi-${input.workItemId}-resolve` });
|
|
420
|
+
const cancelledPostResolve = cancelledOutcome(input.workItemId, "post-conflict-resolve");
|
|
421
|
+
if (cancelledPostResolve)
|
|
422
|
+
return cancelledPostResolve;
|
|
423
|
+
const outcome = resolved.error ? { resolved: false, strategy: "", files: [] } : parseResolverResult(resolved.text);
|
|
424
|
+
const rechecked = outcome.resolved
|
|
425
|
+
? await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target: mrResolved.branch }, envVars, builderImage)
|
|
426
|
+
: null;
|
|
427
|
+
if (rechecked && rechecked.conflictFree) {
|
|
428
|
+
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
|
|
429
|
+
if (!repushed.ok) {
|
|
430
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrResolved.branch, checked.files))
|
|
431
|
+
.catch((e) => log.warn("conflict-repush-failure note failed", e));
|
|
432
|
+
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
|
+
}
|
|
434
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatConflictFixNote(mrResolved.branch, outcome.strategy))
|
|
435
|
+
.catch((e) => log.warn("conflict-fixed note failed", e));
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
const files = checked.files.length > 0 ? checked.files : outcome.files;
|
|
439
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrResolved.branch, files))
|
|
440
|
+
.catch((e) => log.warn("conflict-unresolved note failed", e));
|
|
441
|
+
return { kind: "terminal", reason: "merge conflicts could not be auto-resolved", failure: failure("conflict", "unresolved", files.join(", "), "", null, runModel.model, runModel.variant) };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
if (mcpCtx)
|
|
446
|
+
cleanupMcpRun(mcpCtx.mcpToken);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const mrContent = { title: `Resolve #${input.workItemNumber}`, description: "Generated by devbot." };
|
|
451
|
+
// No agent ran on the standalone /mr path, so no model/variant is recorded.
|
|
452
|
+
const mrInput = {
|
|
453
|
+
projectId: input.projectId,
|
|
454
|
+
workItemId: input.workItemId,
|
|
455
|
+
workItemNumber: input.workItemNumber,
|
|
456
|
+
mode: "build",
|
|
457
|
+
model: "",
|
|
458
|
+
variant: "",
|
|
459
|
+
};
|
|
460
|
+
try {
|
|
461
|
+
const mrs = await getPlatform().changeRequests.list(input.projectId, { sourceBranch: branch, state: "all" });
|
|
462
|
+
const action = decideChangeRequestAction(mrs);
|
|
463
|
+
if (action.kind === "update") {
|
|
464
|
+
const existing = await getPlatform().changeRequests.update(input.projectId, action.number, mrContent);
|
|
465
|
+
log.info("MR updated (standalone)", { url: existing.webUrl });
|
|
466
|
+
emitMrEvent(mrInput, { number: existing.number, webUrl: existing.webUrl }, false);
|
|
467
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `✅ Merge request updated: ${existing.webUrl}`).catch((e) => log.warn("mr-updated note failed", e));
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
if (action.priorState)
|
|
471
|
+
log.info("associated MR not open; creating a fresh one (standalone)", { priorState: action.priorState });
|
|
472
|
+
const { mr, note } = await createChangeRequestWithAssignee(input.projectId, {
|
|
473
|
+
sourceBranch: branch,
|
|
474
|
+
targetBranch: mrResolved.branch,
|
|
475
|
+
title: mrContent.title,
|
|
476
|
+
description: mrContent.description,
|
|
477
|
+
});
|
|
478
|
+
log.info("MR created (standalone)", { url: mr.webUrl });
|
|
479
|
+
emitMrEvent(mrInput, { number: mr.number, webUrl: mr.webUrl }, true);
|
|
480
|
+
recordChangeRequest({
|
|
481
|
+
projectId: input.projectId,
|
|
482
|
+
sourceBranch: branch,
|
|
483
|
+
crNumber: mr.number,
|
|
484
|
+
workItemId: input.workItemId,
|
|
485
|
+
workItemNumber: input.workItemNumber,
|
|
486
|
+
});
|
|
487
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, note).catch((e) => log.warn("mr-created note failed", e));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
log.error("standalone MR create/lookup failed", e);
|
|
492
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, ERROR_REPLY).catch((e) => log.warn("mr-failure note failed", e));
|
|
493
|
+
return { kind: "terminal", reason: "MR create/lookup failed", failure: failure("mr", "mr-failed", e instanceof Error ? e.message : String(e)) };
|
|
494
|
+
}
|
|
495
|
+
return { kind: "ok" };
|
|
496
|
+
}
|
|
497
|
+
// Standalone `/fix-conflicts` (no /build): fix-only. Re-run the #56 conflict
|
|
498
|
+
// resolver on the work item's existing branch against the target branch of
|
|
499
|
+
// its open merge request — falling back to the /base-resolved target when no
|
|
500
|
+
// open MR exists — then re-push the resolved branch with the rewrite-aware
|
|
501
|
+
// lease. No publish-first and no MR create/update: a successful re-push
|
|
502
|
+
// updates the existing MR on the forge. The manual trigger bypasses the
|
|
503
|
+
// autoResolveConflicts setting and a contextual /auto-resolve-conflicts off.
|
|
504
|
+
export async function runFixConflictsOnly(input) {
|
|
505
|
+
const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
506
|
+
log.info("fix-conflicts (standalone) start", {
|
|
507
|
+
projectId: input.projectId,
|
|
508
|
+
workItemNumber: input.workItemNumber,
|
|
509
|
+
workItemId: input.workItemId,
|
|
510
|
+
});
|
|
511
|
+
if (config.DRY_RUN) {
|
|
512
|
+
log.info("DRY_RUN — would fix merge conflicts", { branch });
|
|
513
|
+
await getPlatform().workItems
|
|
514
|
+
.postComment(input.projectId, input.workItemNumber, `🧪 DRY_RUN — would check and fix merge conflicts on \`${branch}\``)
|
|
515
|
+
.catch((e) => log.warn("dry-run fix-conflicts note failed", e));
|
|
516
|
+
return { kind: "ok" };
|
|
517
|
+
}
|
|
518
|
+
// A fix needs the work item's workspace (local branch). Fail with an
|
|
519
|
+
// actionable note instead of letting podman auto-create an empty volume.
|
|
520
|
+
if (!(await volumeExists(volumeName(input.workItemId)))) {
|
|
521
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Nothing to fix — no workspace exists for this work item yet. Run a \`/build\` first, then \`/fix-conflicts\`.`)
|
|
522
|
+
.catch((e) => log.warn("fix-conflicts-missing-workspace note failed", e));
|
|
523
|
+
return { kind: "terminal", reason: "no workspace for fix-conflicts", failure: failure("conflict", "no-workspace", "volume not found") };
|
|
524
|
+
}
|
|
525
|
+
// Target: the open MR's current target branch (this is what makes a
|
|
526
|
+
// retargeted MR work); fall back to the /base-resolved target when there is
|
|
527
|
+
// no open MR or the driver omits targetBranch.
|
|
528
|
+
let target;
|
|
529
|
+
try {
|
|
530
|
+
const mrs = await getPlatform().changeRequests.list(input.projectId, { sourceBranch: branch, state: "all" });
|
|
531
|
+
const open = openChangeRequest(mrs);
|
|
532
|
+
if (open?.targetBranch) {
|
|
533
|
+
target = open.targetBranch;
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
const resolvedTarget = await resolveMrTarget(input.projectId, input.workItemId, projectBaseBranches(input.projectId), "", "");
|
|
537
|
+
if (!resolvedTarget.ok) {
|
|
538
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, resolvedTarget.note).catch((e) => log.warn("fix-conflicts-target note failed", e));
|
|
539
|
+
return resolvedTarget.outcome;
|
|
540
|
+
}
|
|
541
|
+
target = resolvedTarget.branch;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
log.warn("fix-conflicts MR lookup failed", { workItemId: input.workItemId, err: e });
|
|
546
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Could not look up the merge request for \`${branch}\`; please retry.`)
|
|
547
|
+
.catch((err) => log.warn("fix-conflicts-lookup note failed", err));
|
|
548
|
+
return { kind: "transient", reason: "MR lookup failed", failure: failure("conflict", "mr-lookup-failed", e instanceof Error ? e.message : String(e)) };
|
|
549
|
+
}
|
|
550
|
+
const envVars = projectEnvVars(input.projectId);
|
|
551
|
+
const builderImage = projectBuilderImage(input.projectId);
|
|
552
|
+
const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
|
|
553
|
+
if (cancelledPreCheck)
|
|
554
|
+
return cancelledPreCheck;
|
|
555
|
+
const checked = await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target }, envVars, builderImage);
|
|
556
|
+
if (checked === null) {
|
|
557
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Could not check for merge conflicts with \`${target}\`. Re-run when the forge is reachable.`)
|
|
558
|
+
.catch((e) => log.warn("fix-conflicts-check-fault note failed", e));
|
|
559
|
+
return { kind: "transient", reason: "conflict check failed", failure: failure("conflict", "check-failed", "checkConflicts returned null") };
|
|
560
|
+
}
|
|
561
|
+
if (checked.conflictFree) {
|
|
562
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatNoConflictsNote(target))
|
|
563
|
+
.catch((e) => log.warn("fix-conflicts-clean note failed", e));
|
|
564
|
+
return { kind: "ok" };
|
|
565
|
+
}
|
|
566
|
+
let mcpCtx;
|
|
567
|
+
try {
|
|
568
|
+
mcpCtx = await prepareMcpRun(input.projectId, input.workItemId, input.workItemNumber, timeoutFor("build") + 5 * 60_000);
|
|
569
|
+
await writeRunFilesToVolume(input.workItemId, mcpCtx.mcpToken, mcpCtx.mcpServers, mcpCtx.mcpAuthJson, envVars, mcpCtx.plugins, mcpCtx.providers, builderImage, skillsPathsFor(mcpCtx.customRepos.skills), mcpCtx.lspServers);
|
|
570
|
+
const runModel = mcpCtx.models.build;
|
|
571
|
+
if (!runModel.model) {
|
|
572
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Fixing merge conflicts requires a build model. Set a build model in the WebUI, then re-run \`/fix-conflicts\`.`)
|
|
573
|
+
.catch((e) => log.warn("fix-conflicts-missing-model note failed", e));
|
|
574
|
+
return { kind: "terminal", reason: "conflict fix requires a build model", failure: failure("conflict", "no-model", "no build model configured") };
|
|
575
|
+
}
|
|
576
|
+
const preFixTip = await readRemoteBranchTip({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage);
|
|
577
|
+
const cancelledPreResolve = cancelledOutcome(input.workItemId, "pre-conflict-resolve");
|
|
578
|
+
if (cancelledPreResolve)
|
|
579
|
+
return cancelledPreResolve;
|
|
580
|
+
const resolved = await runOpencodeInContainer(input, "build", conflictResolverPrompt(branch, target), envVars, runModel.model, runModel.variant, [], builderImage, { resume: false, containerName: `devbot-wi-${input.workItemId}-resolve` });
|
|
581
|
+
const cancelledPostResolve = cancelledOutcome(input.workItemId, "post-conflict-resolve");
|
|
582
|
+
if (cancelledPostResolve)
|
|
583
|
+
return cancelledPostResolve;
|
|
584
|
+
const outcome = resolved.error ? { resolved: false, strategy: "", files: [] } : parseResolverResult(resolved.text);
|
|
585
|
+
// The re-check is mandatory: checkConflicts aborts a half-finished
|
|
586
|
+
// rebase/merge first, so a killed resolver cannot leave the branch wedged.
|
|
587
|
+
const rechecked = outcome.resolved
|
|
588
|
+
? await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target }, envVars, builderImage)
|
|
589
|
+
: null;
|
|
590
|
+
if (rechecked && rechecked.conflictFree) {
|
|
591
|
+
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
|
|
592
|
+
if (!repushed.ok) {
|
|
593
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatFixOnlyUnresolvedNote(target, checked.files))
|
|
594
|
+
.catch((e) => log.warn("fix-conflicts-repush-failure note failed", e));
|
|
595
|
+
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
|
+
}
|
|
597
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatConflictFixNote(target, outcome.strategy))
|
|
598
|
+
.catch((e) => log.warn("fix-conflicts-fixed note failed", e));
|
|
599
|
+
log.info("conflicts fixed (standalone /fix-conflicts)", { workItemId: input.workItemId, strategy: outcome.strategy });
|
|
600
|
+
return { kind: "ok" };
|
|
601
|
+
}
|
|
602
|
+
const files = checked.files.length > 0 ? checked.files : outcome.files;
|
|
603
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatFixOnlyUnresolvedNote(target, files))
|
|
604
|
+
.catch((e) => log.warn("fix-conflicts-unresolved note failed", e));
|
|
605
|
+
return { kind: "terminal", reason: "merge conflicts could not be resolved", failure: failure("conflict", "unresolved", files.join(", "), "", null, runModel.model, runModel.variant) };
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
if (mcpCtx)
|
|
609
|
+
cleanupMcpRun(mcpCtx.mcpToken);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Resolve the MR target branch for a work item. Chain (top wins):
|
|
613
|
+
// 1. Per-work-item /base store value (set on first run, first-write-wins)
|
|
614
|
+
// — a second mention without /base keeps the chosen target.
|
|
615
|
+
// 2. The configured base-branch list tried top-to-bottom (default "dev,master"
|
|
616
|
+
// → tries dev then master); first branch existing remotely wins.
|
|
617
|
+
// 3. Failure — none exist (or the forge lookup errored) → an actionable note
|
|
618
|
+
// and a RunOutcome. Shared by the build run (whose prompt needs mrTarget
|
|
619
|
+
// before the agent runs) and the standalone /mr task.
|
|
620
|
+
async function resolveMrTarget(projectId, workItemId, branches, model, variant) {
|
|
621
|
+
const stored = getBaseBranch(workItemId);
|
|
622
|
+
if (stored)
|
|
623
|
+
return { ok: true, branch: stored };
|
|
624
|
+
let hit = null;
|
|
625
|
+
try {
|
|
626
|
+
hit = await getPlatform().repository.firstExistingBranch(projectId, branches);
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
// Forge API blip — transient, the note asks the user to re-mention.
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
note: `⚠️ Could not verify base branches (\`${branches.join(", ")}\`); please retry.`,
|
|
633
|
+
outcome: { kind: "transient", reason: "base branch existence check failed", failure: failure("prepare", "base-lookup-failed", e instanceof Error ? e.message : String(e), "", null, model, variant) },
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (!hit) {
|
|
637
|
+
// Config problem — terminal until an admin fixes the base branches.
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
note: `⚠️ No base branch found for this project. Tried: \`${branches.join(", ")}\`. Set base branches in the WebUI global settings or create one of the configured branches, then re-mention.`,
|
|
641
|
+
outcome: { kind: "terminal", reason: "no base branch found", failure: failure("prepare", "no-base-branch", `tried: ${branches.join(", ")}`, "", null, model, variant) },
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return { ok: true, branch: hit };
|
|
645
|
+
}
|
|
646
|
+
export async function run(input) {
|
|
647
|
+
log.info("run start", {
|
|
648
|
+
projectId: input.projectId,
|
|
649
|
+
workItemNumber: input.workItemNumber,
|
|
650
|
+
workItemId: input.workItemId,
|
|
651
|
+
mode: input.mode,
|
|
652
|
+
prompt: input.prompt,
|
|
653
|
+
});
|
|
654
|
+
if (config.DRY_RUN) {
|
|
655
|
+
// In DRY_RUN we never create volumes, so existence check is meaningless.
|
|
656
|
+
// Always treat as first run to exercise the full-context prompt path.
|
|
657
|
+
const prompt = buildPrompt(input, true);
|
|
658
|
+
log.info("DRY_RUN — would run opencode", { mode: input.mode, prompt, isFirstRun: true });
|
|
659
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `🧪 DRY_RUN — mode=${input.mode}\n\n\`\`\`\n${prompt}\n\`\`\``)
|
|
660
|
+
.catch((e) => log.warn("dry-run note failed", e));
|
|
661
|
+
return { kind: "ok" };
|
|
662
|
+
}
|
|
663
|
+
// Cancellation checkpoint: a /stop that landed while the task waited in the
|
|
664
|
+
// queue should abort before any MCP prep or volume/container work.
|
|
665
|
+
const cancelledAtStart = cancelledOutcome(input.workItemId, "pre-prepare");
|
|
666
|
+
if (cancelledAtStart)
|
|
667
|
+
return cancelledAtStart;
|
|
668
|
+
// Prepare MCP context: mint a per-thread bearer token, load project server
|
|
669
|
+
// configs, and build the mcp-auth.json. All MCP store access is encapsulated
|
|
670
|
+
// in the bridge — runner.ts never imports a store directly.
|
|
671
|
+
let mcpCtx;
|
|
672
|
+
try {
|
|
673
|
+
mcpCtx = await prepareMcpRun(input.projectId, input.workItemId, input.workItemNumber, timeoutFor(input.mode) + 5 * 60_000);
|
|
674
|
+
// Exact model+variant resolved for this run. Captured once so an error-log
|
|
675
|
+
// row records the model the run actually used, even if an admin edits the
|
|
676
|
+
// settings while the run is in flight. Used by the post-prepare failures.
|
|
677
|
+
const runModel = mcpCtx.models[input.mode];
|
|
678
|
+
// Persist the /base choice BEFORE the clone so a crash mid-clone still
|
|
679
|
+
// leaves the value stored — the next run reads the correct mrTarget.
|
|
680
|
+
// First-write-wins: a subsequent /base mention does NOT overwrite (the
|
|
681
|
+
// ignored-note gate below surfaces that to the user). Cleared by /reset
|
|
682
|
+
// and work-item close so a post-reset re-mention sets a fresh value.
|
|
683
|
+
if (input.baseBranch) {
|
|
684
|
+
setBaseBranchIfUnset(input.workItemId, input.baseBranch);
|
|
685
|
+
}
|
|
686
|
+
// Persist the /branch choice on the FIRST naming only, measured by the
|
|
687
|
+
// store (not volume existence): the row survives /reset and close, so a
|
|
688
|
+
// post-reset re-run reuses the same remote branch instead of colliding
|
|
689
|
+
// with the branch we already pushed. A later, different /branch is ignored
|
|
690
|
+
// with a note; the stored value stays authoritative.
|
|
691
|
+
const storedWorkBranch = getWorkBranch(input.workItemId);
|
|
692
|
+
if (input.workBranch && !storedWorkBranch) {
|
|
693
|
+
setWorkBranchIfUnset(input.workItemId, input.workBranch);
|
|
694
|
+
}
|
|
695
|
+
else if (input.workBranch && storedWorkBranch && input.workBranch !== storedWorkBranch) {
|
|
696
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ /branch '${input.workBranch}' ignored — this work item already uses branch '${storedWorkBranch}'.`)
|
|
697
|
+
.catch((e) => log.warn("branch-ignored note failed", e));
|
|
698
|
+
}
|
|
699
|
+
// Re-read after the possible write so the first-naming value is picked up
|
|
700
|
+
// (storedWorkBranch is null on that run, so resolving it directly would
|
|
701
|
+
// wrongly fall back to wi-<number>).
|
|
702
|
+
const workBranch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
|
|
703
|
+
const prepared = await ensureVolumeAndWorkspace({ ...input, workBranch }, input.mode, mcpCtx.envVars, mcpCtx.baseBranches, mcpCtx.builderImage);
|
|
704
|
+
// Cancellation checkpoint: a stop during clone/sync kills that container;
|
|
705
|
+
// treat the resulting prepare failure as an intentional stop, not an error.
|
|
706
|
+
const cancelledAfterPrep = cancelledOutcome(input.workItemId, "post-prepare");
|
|
707
|
+
if (cancelledAfterPrep)
|
|
708
|
+
return cancelledAfterPrep;
|
|
709
|
+
if (!prepared.ok) {
|
|
710
|
+
log.error("prep failed", { workItemId: input.workItemId, reason: prepared.reason, isFirstRun: prepared.isFirstRun });
|
|
711
|
+
// ponytail: missing-branch and no-base-branch are developer-actionable
|
|
712
|
+
// (recreate the work item / set BASE_BRANCHES or create a branch); clone
|
|
713
|
+
// and workspace-sync failures are infra (token/perms/network/forge 5xx)
|
|
714
|
+
// — generic reply, retryable.
|
|
715
|
+
const actionable = prepared.reason.startsWith("work item branch")
|
|
716
|
+
|| prepared.reason.startsWith("no base branch found")
|
|
717
|
+
|| prepared.reason.startsWith("base branch existence check failed");
|
|
718
|
+
const reply = actionable ? `⚠️ ${prepared.reason}` : ERROR_REPLY;
|
|
719
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("prep-failure note failed", e));
|
|
720
|
+
// Clone/network/sync failures are transient (re-run may succeed);
|
|
721
|
+
// missing branch is terminal (re-run hits the same state).
|
|
722
|
+
const isTransient = prepared.reason.startsWith("clone failed")
|
|
723
|
+
|| prepared.reason.startsWith("volume create failed")
|
|
724
|
+
|| prepared.reason.startsWith("workspace sync failed");
|
|
725
|
+
const fail = failure("prepare", "prep-failed", prepared.reason, prepared.logs, null, runModel.model, runModel.variant);
|
|
726
|
+
return isTransient
|
|
727
|
+
? { kind: "transient", reason: prepared.reason, failure: fail }
|
|
728
|
+
: { kind: "terminal", reason: prepared.reason, failure: fail };
|
|
729
|
+
}
|
|
730
|
+
// Record the volume in the GC ledger so the TTL sweep can later purge it.
|
|
731
|
+
// recordVolume upserts: on first run it sets created_at + last_run_at;
|
|
732
|
+
// on subsequent runs it preserves created_at and refreshes last_run_at
|
|
733
|
+
// (overwriting a reconciled orphan's 0/0 with the real ids, so a future
|
|
734
|
+
// purge can post a note). Cheaper than a separate touch call here.
|
|
735
|
+
recordVolume(input.workItemId, input.projectId, input.workItemNumber);
|
|
736
|
+
// Submodules are best-effort: a fetch failure is reported but never blocks
|
|
737
|
+
// the run. Post the affected paths (if any) before the agent starts.
|
|
738
|
+
if (prepared.submoduleWarnings.length > 0) {
|
|
739
|
+
log.info("submodule fetch warnings", { workItemId: input.workItemId, paths: prepared.submoduleWarnings });
|
|
740
|
+
await postSubmoduleWarnings(input.projectId, input.workItemNumber, prepared.submoduleWarnings);
|
|
741
|
+
}
|
|
742
|
+
// /base on a subsequent run (volume exists) is inert for the clone — the
|
|
743
|
+
// base was already chosen on first run. Surface this to the user so a
|
|
744
|
+
// stale /base isn't silently dropped. The store value (first-write-wins)
|
|
745
|
+
// is unaffected; mrTarget below still reads the original choice.
|
|
746
|
+
if (!prepared.isFirstRun && input.baseBranch) {
|
|
747
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ /base '${input.baseBranch}' ignored — work item already initialized.`)
|
|
748
|
+
.catch((e) => log.warn("base-ignored note failed", e));
|
|
749
|
+
}
|
|
750
|
+
// Sync admin-registered skills/subagent repos (work item #63) after clone and
|
|
751
|
+
// before writing the run files, so skills.paths can reference the synced
|
|
752
|
+
// skills dirs. Best-effort per repo: a failure logs and the run proceeds.
|
|
753
|
+
const skillsSync = await syncCustomRepos({
|
|
754
|
+
workItemId: input.workItemId,
|
|
755
|
+
kind: "skills",
|
|
756
|
+
repos: mcpCtx.customRepos.skills,
|
|
757
|
+
envVars: mcpCtx.envVars,
|
|
758
|
+
builderImage: mcpCtx.builderImage,
|
|
759
|
+
}).catch((e) => {
|
|
760
|
+
log.warn("skills sync failed", { workItemId: input.workItemId, err: e });
|
|
761
|
+
return { synced: [], failed: [], agents: [] };
|
|
762
|
+
});
|
|
763
|
+
const agentsSync = await syncCustomRepos({
|
|
764
|
+
workItemId: input.workItemId,
|
|
765
|
+
kind: "agents",
|
|
766
|
+
repos: mcpCtx.customRepos.agents,
|
|
767
|
+
envVars: mcpCtx.envVars,
|
|
768
|
+
builderImage: mcpCtx.builderImage,
|
|
769
|
+
}).catch((e) => {
|
|
770
|
+
log.warn("agents sync failed", { workItemId: input.workItemId, err: e });
|
|
771
|
+
return { synced: [], failed: [], agents: [] };
|
|
772
|
+
});
|
|
773
|
+
// Unknown-agent check moved here from the webhook (work item #63): the
|
|
774
|
+
// installed agent set is only known after syncing configured repos. Names
|
|
775
|
+
// requested by the mention are validated against built-ins + synced agents;
|
|
776
|
+
// a miss posts the same actionable note the webhook used to.
|
|
777
|
+
if (input.agentNames && input.agentNames.length > 0) {
|
|
778
|
+
const availableAgents = [...BUILTIN_SUBAGENTS, ...agentsSync.agents];
|
|
779
|
+
const agentError = validateAgentNames(input.agentNames, availableAgents);
|
|
780
|
+
if (agentError) {
|
|
781
|
+
await getPlatform().workItems
|
|
782
|
+
.postErrorReply(input.projectId, input.workItemNumber, input.triggeringNoteId, agentError)
|
|
783
|
+
.catch((e) => log.warn("unknown-agent reply failed", e));
|
|
784
|
+
return { kind: "terminal", reason: agentError };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const skillsPaths = skillsPathsFor(skillsSync.synced.map((name) => ({ name })));
|
|
788
|
+
// Write auth.json + per-run opencode.json (carrying the MCP bearer token)
|
|
789
|
+
// + mcp-auth.json (pasted OAuth tokens) into the volume (after clone,
|
|
790
|
+
// before opencode). See writeRunFilesToVolume for why we don't bind-mount.
|
|
791
|
+
await writeRunFilesToVolume(input.workItemId, mcpCtx.mcpToken, mcpCtx.mcpServers, mcpCtx.mcpAuthJson, mcpCtx.envVars, mcpCtx.plugins, mcpCtx.providers, mcpCtx.builderImage, skillsPaths, mcpCtx.lspServers);
|
|
792
|
+
const cancelledAfterFiles = cancelledOutcome(input.workItemId, "post-run-files");
|
|
793
|
+
if (cancelledAfterFiles)
|
|
794
|
+
return cancelledAfterFiles;
|
|
795
|
+
// Pre-write the .git/info/exclude entries so agent scratch output is
|
|
796
|
+
// git-invisible from the start: `.devbot-out/` (outbound attachment staging,
|
|
797
|
+
// always) and `.devbot-peers/` (only when this run has granted targets).
|
|
798
|
+
// Best-effort; the upload + sync_project tools re-assert the exclude before
|
|
799
|
+
// writing anything anyway.
|
|
800
|
+
const excludeDirs = [`${OUT_DIR}/`, `${SKILLS_DIR}/`, ...(mcpCtx.peerProjects.length > 0 ? [`${PEERS_DIR}/`] : [])];
|
|
801
|
+
await ensureGitExcludes(input.workItemId, excludeDirs, mcpCtx.envVars, undefined, mcpCtx.builderImage).catch((e) => log.warn("git exclude init failed", { workItemId: input.workItemId, err: e }));
|
|
802
|
+
// Nested rootless Podman for build-mode test workloads (work item #17).
|
|
803
|
+
// Resolve the storage driver (auto probes /dev/fuse), create the private
|
|
804
|
+
// per-work-item store volume + inner storage.conf, and collect the extra
|
|
805
|
+
// outer-run args. Empty when disabled or plan mode, so the launch stays
|
|
806
|
+
// byte-identical to the default unprivileged one. A missing /dev/fuse is
|
|
807
|
+
// not an error — it selects the portable vfs driver.
|
|
808
|
+
let devcontainerArgs = [];
|
|
809
|
+
if (devcontainerEnabled(runtime().devcontainerEnabled, input.mode)) {
|
|
810
|
+
const driver = resolveStorageDriver(runtime().devcontainerStorageDriver, hasFuseDevice());
|
|
811
|
+
await ensurePodmanStore(input.workItemId, driver, mcpCtx.builderImage);
|
|
812
|
+
devcontainerArgs = [
|
|
813
|
+
...nestedPodmanArgs(driver),
|
|
814
|
+
...devcontainerVolumeArgs(podmanVolumeName(input.workItemId)),
|
|
815
|
+
...devcontainerEnvArgs(),
|
|
816
|
+
];
|
|
817
|
+
log.info("dev containers enabled", { workItemId: input.workItemId, driver });
|
|
818
|
+
}
|
|
819
|
+
// First run (or volume-lost recovery — destroyWorkItemVolume on close
|
|
820
|
+
// makes this the common path across close/reopen cycles): reseed the
|
|
821
|
+
// session from the forge's note history minus the bot's own prior replies
|
|
822
|
+
// and the triggering mention (already the final `---` segment). One
|
|
823
|
+
// cheap API call; subsequent runs carry context via opencode --continue.
|
|
824
|
+
let history = "";
|
|
825
|
+
if (prepared.isFirstRun) {
|
|
826
|
+
try {
|
|
827
|
+
const notes = await getPlatform().workItems.listComments(input.projectId, input.workItemNumber);
|
|
828
|
+
// Attachments are no longer inlined host-side: note bodies reach the
|
|
829
|
+
// prompt with their markdown links intact, and the agent downloads the
|
|
830
|
+
// ones it needs with `download_work_item_attachment`.
|
|
831
|
+
history = formatHistory(notes, config.BOT_USER_ID, input.triggeringNoteId);
|
|
832
|
+
}
|
|
833
|
+
catch (e) {
|
|
834
|
+
// ponytail: history fetch is best-effort — a forge blip shouldn't
|
|
835
|
+
// abort the run. Log + fall back to the no-history prompt shape.
|
|
836
|
+
log.warn("history fetch failed", { workItemId: input.workItemId, err: e instanceof Error ? e.message : String(e) });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Build mode: append a commit instruction. The agent authors the real
|
|
840
|
+
// commit (Conventional Commits, #<iid> in the subject); devbot only
|
|
841
|
+
// pushes after, with a fallback commit if the agent forgot (see
|
|
842
|
+
// pushBranch). Plan mode stays byte-identical to buildPrompt output.
|
|
843
|
+
// mrTarget: the MR target branch. Resolution order (top wins):
|
|
844
|
+
// 1. Per-work-item /base store value (set on first run, first-write-wins)
|
|
845
|
+
// — a second mention without /base keeps the chosen target.
|
|
846
|
+
// 2. BASE_BRANCHES list tried top-to-bottom (default "dev,master" →
|
|
847
|
+
// tries dev then master); first branch existing remotely wins.
|
|
848
|
+
// 3. Error — none of the configured branches exist → actionable note, no
|
|
849
|
+
// MR. (The clone already resolved targetBranch via the same walk in
|
|
850
|
+
// git.ts and would have errored there on first run; this path covers
|
|
851
|
+
// the rare case where the store is empty on a subsequent run after a
|
|
852
|
+
// base branch was deleted upstream between runs.)
|
|
853
|
+
const mrResolved = await resolveMrTarget(input.projectId, input.workItemId, mcpCtx.baseBranches, runModel.model, runModel.variant);
|
|
854
|
+
if (!mrResolved.ok) {
|
|
855
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, mrResolved.note).catch((e) => log.warn("mrTarget note failed", e));
|
|
856
|
+
return mrResolved.outcome;
|
|
857
|
+
}
|
|
858
|
+
const mrTarget = mrResolved.branch;
|
|
859
|
+
const basePrompt = buildPrompt(input, prepared.isFirstRun, history);
|
|
860
|
+
// Attachment guidance is always appended: the upload tool is available in
|
|
861
|
+
// both modes, and the staging dir is git-excluded for every run. Placed
|
|
862
|
+
// before the git workflow block so commit/MR instructions stay last.
|
|
863
|
+
const attachmentSection = `\n\n---\n\n${attachmentPromptSection()}`;
|
|
864
|
+
// CI-failure auto report (work item #57): when this run was started by a
|
|
865
|
+
// failed pipeline, fetch the failing job logs (tails) and inject them as
|
|
866
|
+
// read-only context. A forge failure degrades to a note in the section,
|
|
867
|
+
// never aborting the run.
|
|
868
|
+
let ciSection = "";
|
|
869
|
+
if (input.ciFailure) {
|
|
870
|
+
const ci = await buildCiFailureSection(input.projectId, input.ciFailure).catch((e) => {
|
|
871
|
+
log.warn("ci-failure section failed", { workItemId: input.workItemId, err: e });
|
|
872
|
+
return null;
|
|
873
|
+
});
|
|
874
|
+
if (ci)
|
|
875
|
+
ciSection = `\n\n---\n\n${ci.section}`;
|
|
876
|
+
}
|
|
877
|
+
// Dev-container guidance is appended only when the runtime is actually
|
|
878
|
+
// available (build mode + enabled), placed before the git workflow block so
|
|
879
|
+
// the commit/MR instructions stay last.
|
|
880
|
+
const devSection = devcontainerEnabled(runtime().devcontainerEnabled, input.mode) ? `\n\n---\n\n${devcontainerPromptSection()}` : "";
|
|
881
|
+
const prompt = input.mode === "build"
|
|
882
|
+
? `${basePrompt}${attachmentSection}${devSection}\n\n---\n\n` +
|
|
883
|
+
renderBuildWorkflow({
|
|
884
|
+
workItemNumber: input.workItemNumber,
|
|
885
|
+
branch: workBranch,
|
|
886
|
+
mrTarget,
|
|
887
|
+
workflow: mcpCtx.buildWorkflow,
|
|
888
|
+
mrDescription: mcpCtx.mrDescription,
|
|
889
|
+
})
|
|
890
|
+
: `${basePrompt}${attachmentSection}`;
|
|
891
|
+
const promptWithCi = `${prompt}${ciSection}`;
|
|
892
|
+
const result = await runOpencodeInContainer(input, input.mode, promptWithCi, mcpCtx.envVars, runModel.model, runModel.variant, devcontainerArgs, mcpCtx.builderImage);
|
|
893
|
+
// Cancellation checkpoint: a stop killed the run container. Skip the
|
|
894
|
+
// reply, the error branch, push, and MR — unwind as an intentional stop.
|
|
895
|
+
const cancelledAfterRun = cancelledOutcome(input.workItemId, "post-run");
|
|
896
|
+
if (cancelledAfterRun)
|
|
897
|
+
return cancelledAfterRun;
|
|
898
|
+
// Build mode: parse the agent's MR content block (```mr ... ```) from the
|
|
899
|
+
// reply, strip it from the note text. Plan mode: no parsing, post raw.
|
|
900
|
+
const parsed = input.mode === "build" && !result.error
|
|
901
|
+
? parseMrContent(result.text, input.workItemNumber)
|
|
902
|
+
: null;
|
|
903
|
+
const noteText = parsed ? parsed.reply : result.text;
|
|
904
|
+
// ponytail: timeout is user-actionable (split task / simplify / re-run);
|
|
905
|
+
// spawn + non-zero exit are infra/internal — generic reply, detail in logs.
|
|
906
|
+
if (result.error && result.kind !== "timeout") {
|
|
907
|
+
log.warn("opencode error", { workItemId: input.workItemId, kind: result.kind, text: result.text });
|
|
908
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, ERROR_REPLY).catch((e) => {
|
|
909
|
+
log.error("postNote failed", e);
|
|
910
|
+
});
|
|
911
|
+
// spawn failure (podman couldn't start) is transient — re-run may succeed.
|
|
912
|
+
// exit (non-zero) is terminal — the agent/opencode had a real error.
|
|
913
|
+
if (result.kind === "spawn") {
|
|
914
|
+
return { kind: "transient", reason: `podman spawn failed: ${result.text.slice(0, 200)}`, failure: failure("run", "spawn", result.text, `${result.rawOutput}\n${result.stderrTail}`, result.exitCode, runModel.model, runModel.variant) };
|
|
915
|
+
}
|
|
916
|
+
return { kind: "terminal", reason: `opencode exited (${result.exitCode})`, failure: failure("run", "exit", result.text, `${result.rawOutput}\n${result.stderrTail}`, result.exitCode, runModel.model, runModel.variant) };
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, noteText).catch((e) => {
|
|
920
|
+
log.error("postNote failed", e);
|
|
921
|
+
});
|
|
922
|
+
// Timed-out runs are terminal for the queue (the container was killed);
|
|
923
|
+
// record them as an error so they surface in the WebUI error log.
|
|
924
|
+
if (result.error && result.kind === "timeout") {
|
|
925
|
+
return { kind: "terminal", reason: "opencode timed out", failure: failure("run", "timeout", result.text, `${result.rawOutput}\n${result.stderrTail}`, result.exitCode, runModel.model, runModel.variant) };
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Build mode only: push branch + create MR. Plan mode: no git side-effects.
|
|
929
|
+
// Both steps are opt-in (work item #53): a run pushes only when the
|
|
930
|
+
// resolved auto-push setting is on or the user asked per-run with /push
|
|
931
|
+
// (or /force-push); it opens/updates an MR only when auto-MR is on or /mr
|
|
932
|
+
// was given — and only if the branch was actually pushed. auto-MR without a
|
|
933
|
+
// push is unreachable by design (an MR needs a published branch).
|
|
934
|
+
if (input.mode === "build" && !result.error) {
|
|
935
|
+
const { push: shouldPush, mr: shouldMr } = buildOutputPlan(input, mcpCtx.autoPush, mcpCtx.autoMr);
|
|
936
|
+
if (!shouldPush) {
|
|
937
|
+
log.info("auto-push disabled, skipping push/MR", { workItemId: input.workItemId });
|
|
938
|
+
return { kind: "ok" };
|
|
939
|
+
}
|
|
940
|
+
const cancelledBeforePush = cancelledOutcome(input.workItemId, "pre-push");
|
|
941
|
+
if (cancelledBeforePush)
|
|
942
|
+
return cancelledBeforePush;
|
|
943
|
+
const pushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, ...(input.forcePush ? { forcePush: true } : {}), ...(prepared.branchTip ? { preRunTip: prepared.branchTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
944
|
+
if (pushed.ok && pushed.fallbackCommitted) {
|
|
945
|
+
log.warn("agent forgot to commit; devbot authored fallback commit", {
|
|
946
|
+
workItemId: input.workItemId,
|
|
947
|
+
workItemNumber: input.workItemNumber,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
if (!pushed.ok) {
|
|
951
|
+
log.error("push failed", { workItemId: input.workItemId, stderr: pushed.reason });
|
|
952
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ push failed: ${pushed.reason}`)
|
|
953
|
+
.catch((e) => log.warn("push-failure note failed", e));
|
|
954
|
+
return { kind: "terminal", reason: `push failed: ${pushed.reason}`, failure: failure("push", "push-failed", pushed.reason, pushed.logs, null, runModel.model, runModel.variant) };
|
|
955
|
+
}
|
|
956
|
+
if (!pushed.pushed) {
|
|
957
|
+
log.info("no new commits, skipping MR", { workItemId: input.workItemId });
|
|
958
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, "ℹ️ No changes to merge.")
|
|
959
|
+
.catch((e) => log.warn("no-changes note failed", e));
|
|
960
|
+
return { kind: "ok" };
|
|
961
|
+
}
|
|
962
|
+
if (!shouldMr) {
|
|
963
|
+
log.info("auto-MR disabled, branch pushed", { workItemId: input.workItemId, branch: workBranch });
|
|
964
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `✅ Pushed \`${workBranch}\`.`)
|
|
965
|
+
.catch((e) => log.warn("pushed note failed", e));
|
|
966
|
+
return { kind: "ok" };
|
|
967
|
+
}
|
|
968
|
+
const branch = workBranch;
|
|
969
|
+
// Conflict auto-fix (work item #56): before creating/updating the MR,
|
|
970
|
+
// check whether the just-pushed branch merges cleanly into its target.
|
|
971
|
+
// When off (default) this block is skipped entirely. When conflicts are
|
|
972
|
+
// found, a direct build run resolves them, then the branch is re-pushed
|
|
973
|
+
// (rewrite-aware lease) before the MR. On failure the branch stays
|
|
974
|
+
// published, the MR is skipped, and an actionable note is posted.
|
|
975
|
+
if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts)) {
|
|
976
|
+
const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
|
|
977
|
+
if (cancelledPreCheck)
|
|
978
|
+
return cancelledPreCheck;
|
|
979
|
+
const checked = await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, target: mrTarget }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
980
|
+
if (checked === null) {
|
|
981
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ Could not check for merge conflicts with \`${mrTarget}\`; skipping the merge request. Re-run when the forge is reachable.`)
|
|
982
|
+
.catch((e) => log.warn("conflict-check-fault note failed", e));
|
|
983
|
+
return { kind: "transient", reason: "conflict check failed", failure: failure("conflict", "check-failed", "checkConflicts returned null", "", null, runModel.model, runModel.variant) };
|
|
984
|
+
}
|
|
985
|
+
if (!checked.conflictFree) {
|
|
986
|
+
// Pin the lease to the tip the resolver is about to rewrite.
|
|
987
|
+
const preFixTip = await readRemoteBranchTip({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
988
|
+
const cancelledPreResolve = cancelledOutcome(input.workItemId, "pre-conflict-resolve");
|
|
989
|
+
if (cancelledPreResolve)
|
|
990
|
+
return cancelledPreResolve;
|
|
991
|
+
const resolved = await runOpencodeInContainer(input, "build", conflictResolverPrompt(branch, mrTarget), mcpCtx.envVars, runModel.model, runModel.variant, [], mcpCtx.builderImage, { resume: false, containerName: `devbot-wi-${input.workItemId}-resolve` });
|
|
992
|
+
const cancelledPostResolve = cancelledOutcome(input.workItemId, "post-conflict-resolve");
|
|
993
|
+
if (cancelledPostResolve)
|
|
994
|
+
return cancelledPostResolve;
|
|
995
|
+
const outcome = resolved.error ? { resolved: false, strategy: "", files: [] } : parseResolverResult(resolved.text);
|
|
996
|
+
// Re-check is mandatory: `checkConflicts` aborts a half-finished
|
|
997
|
+
// rebase/merge first, so a killed resolver cannot leave the branch
|
|
998
|
+
// wedged or commit conflict markers.
|
|
999
|
+
const rechecked = outcome.resolved
|
|
1000
|
+
? await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, target: mrTarget }, mcpCtx.envVars, mcpCtx.builderImage)
|
|
1001
|
+
: null;
|
|
1002
|
+
if (rechecked && rechecked.conflictFree) {
|
|
1003
|
+
const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
|
|
1004
|
+
if (!repushed.ok) {
|
|
1005
|
+
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))
|
|
1007
|
+
.catch((e) => log.warn("conflict-repush-failure note failed", e));
|
|
1008
|
+
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
|
+
}
|
|
1010
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatConflictFixNote(mrTarget, outcome.strategy))
|
|
1011
|
+
.catch((e) => log.warn("conflict-fixed note failed", e));
|
|
1012
|
+
log.info("conflicts auto-resolved", { workItemId: input.workItemId, strategy: outcome.strategy });
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
// Unresolved (or the re-check failed): the branch is already
|
|
1016
|
+
// published from the pre-fix push, so skip the MR and hand the
|
|
1017
|
+
// conflicts back to the user.
|
|
1018
|
+
const files = checked.files.length > 0 ? checked.files : outcome.files;
|
|
1019
|
+
log.warn("conflicts not auto-resolved", { workItemId: input.workItemId, files });
|
|
1020
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrTarget, files))
|
|
1021
|
+
.catch((e) => log.warn("conflict-unresolved note failed", e));
|
|
1022
|
+
return { kind: "terminal", reason: "merge conflicts could not be auto-resolved", failure: failure("conflict", "unresolved", files.join(", "), "", null, runModel.model, runModel.variant) };
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const mrContent = parsed?.mr ?? { title: `Resolve #${input.workItemNumber}`, description: "Generated by devbot." };
|
|
1027
|
+
// Cancellation checkpoint: the branch was just pushed, so skip creating or
|
|
1028
|
+
// mutating the MR — a stopped run must not open a merge request.
|
|
1029
|
+
const cancelledBeforeMr = cancelledOutcome(input.workItemId, "pre-mr");
|
|
1030
|
+
if (cancelledBeforeMr)
|
|
1031
|
+
return cancelledBeforeMr;
|
|
1032
|
+
try {
|
|
1033
|
+
const mrs = await getPlatform().changeRequests.list(input.projectId, { sourceBranch: branch, state: "all" });
|
|
1034
|
+
const action = decideChangeRequestAction(mrs);
|
|
1035
|
+
const mrInput = {
|
|
1036
|
+
projectId: input.projectId,
|
|
1037
|
+
workItemId: input.workItemId,
|
|
1038
|
+
workItemNumber: input.workItemNumber,
|
|
1039
|
+
mode: input.mode,
|
|
1040
|
+
model: runModel.model,
|
|
1041
|
+
variant: runModel.variant,
|
|
1042
|
+
};
|
|
1043
|
+
if (action.kind === "update") {
|
|
1044
|
+
const existing = await getPlatform().changeRequests.update(input.projectId, action.number, mrContent);
|
|
1045
|
+
log.info("MR updated", { url: existing.webUrl });
|
|
1046
|
+
emitMrEvent(mrInput, { number: existing.number, webUrl: existing.webUrl }, false);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
if (action.priorState)
|
|
1050
|
+
log.info("associated MR not open; creating a fresh one", { priorState: action.priorState });
|
|
1051
|
+
const { mr, note, warning } = await createChangeRequestWithAssignee(input.projectId, {
|
|
1052
|
+
sourceBranch: branch,
|
|
1053
|
+
targetBranch: mrTarget,
|
|
1054
|
+
title: mrContent.title,
|
|
1055
|
+
description: mrContent.description,
|
|
1056
|
+
});
|
|
1057
|
+
log.info("MR created", { url: mr.webUrl });
|
|
1058
|
+
emitMrEvent(mrInput, { number: mr.number, webUrl: mr.webUrl }, true);
|
|
1059
|
+
recordChangeRequest({
|
|
1060
|
+
projectId: input.projectId,
|
|
1061
|
+
sourceBranch: branch,
|
|
1062
|
+
crNumber: mr.number,
|
|
1063
|
+
workItemId: input.workItemId,
|
|
1064
|
+
workItemNumber: input.workItemNumber,
|
|
1065
|
+
});
|
|
1066
|
+
// The auto-MR path historically posts no created note. Only surface one
|
|
1067
|
+
// when it carries the D5 assignee warning.
|
|
1068
|
+
if (warning) {
|
|
1069
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, note).catch((e) => log.warn("mr-created note failed", e));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
log.error("MR create/lookup failed", e);
|
|
1075
|
+
await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, ERROR_REPLY).catch((e) => log.warn("mr-failure note failed", e));
|
|
1076
|
+
// The run itself succeeded (opencode produced output); only the MR step
|
|
1077
|
+
// failed. Terminal so we don't retry the whole run just for MR plumbing.
|
|
1078
|
+
return { kind: "terminal", reason: "MR create/lookup failed", failure: failure("mr", "mr-failed", e instanceof Error ? e.message : String(e), "", null, runModel.model, runModel.variant) };
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Default: run completed successfully (plan mode, or build mode with push+MR ok).
|
|
1082
|
+
return { kind: "ok" };
|
|
1083
|
+
}
|
|
1084
|
+
finally {
|
|
1085
|
+
if (mcpCtx)
|
|
1086
|
+
cleanupMcpRun(mcpCtx.mcpToken);
|
|
1087
|
+
// Refresh last_run_at so the GC sweep's inactivity clock resets on every
|
|
1088
|
+
// run termination (success or failure). The finally fires on every exit
|
|
1089
|
+
// path including early returns.
|
|
1090
|
+
touchVolume(input.workItemId);
|
|
1091
|
+
log.info("run end", { workItemId: input.workItemId, mode: input.mode });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
//# sourceMappingURL=runner.js.map
|