@aion0/forge 0.6.1 → 0.8.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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
package/lib/pipeline.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
@@ -35,6 +35,9 @@ export interface WorkflowNode {
|
|
|
35
35
|
agent?: string; // agent ID (default: from settings)
|
|
36
36
|
branch?: string; // auto checkout this branch before running (supports templates)
|
|
37
37
|
worktree?: boolean; // default: true. Set false to skip worktree isolation (run in project dir directly)
|
|
38
|
+
workdir?: string; // explicit cwd override (template). Wins over auto-worktree. Use when one node
|
|
39
|
+
// produces a worktree path that a later node needs to execute inside —
|
|
40
|
+
// e.g. worktree-setup → fix-code via `workdir: "{{nodes.worktree-setup.outputs.wt}}"`.
|
|
38
41
|
// Plugin mode fields
|
|
39
42
|
plugin?: string; // plugin ID (e.g., 'jenkins', 'docker')
|
|
40
43
|
pluginAction?: string; // action name (e.g., 'trigger', 'build'), defaults to plugin's defaultAction
|
|
@@ -242,6 +245,597 @@ nodes:
|
|
|
242
245
|
outputs:
|
|
243
246
|
- name: result
|
|
244
247
|
extract: stdout
|
|
248
|
+
`,
|
|
249
|
+
'mantis-bug-fix-and-mr': `
|
|
250
|
+
name: mantis-bug-fix-and-mr
|
|
251
|
+
description: "Fetch Mantis bug context → worktree → fix code via headless Claude → push branch → open GitLab MR via glab CLI → notify assignee + reporter on Teams."
|
|
252
|
+
input:
|
|
253
|
+
bug_id: "Mantis bug id (number)"
|
|
254
|
+
project: "Forge project name"
|
|
255
|
+
base_branch: "Target branch the MR will be opened against (e.g. release/25.4). Required — set in the Job's input_template from the bug's product_version or target_branch field."
|
|
256
|
+
summary: "Bug summary (one line)"
|
|
257
|
+
description: "Full bug description (free text)"
|
|
258
|
+
priority: "Bug priority (optional)"
|
|
259
|
+
category: "Bug category (optional)"
|
|
260
|
+
reporter: "Mantis reporter username — used to notify them on Teams"
|
|
261
|
+
assignee: "Mantis assignee username — used to notify them on Teams"
|
|
262
|
+
extra_context: "Extra hints for Claude (optional)"
|
|
263
|
+
mr_title_template: "MR title template. Vars: {bug_id} {summary}. Default: 'Fix Mantis #{bug_id}: {summary}'"
|
|
264
|
+
mr_body_template: "MR body. Vars: {bug_id} {summary} {description} {claude_summary}. Default closes-reference + Claude summary."
|
|
265
|
+
teams_message_template: "Teams DM template. Vars: {bug_id} {summary} {mr_url} {role}. Default: '🤖 Mantis #{bug_id} fixed — review MR: {mr_url}'"
|
|
266
|
+
nodes:
|
|
267
|
+
resolve:
|
|
268
|
+
mode: shell
|
|
269
|
+
project: "{{input.project}}"
|
|
270
|
+
worktree: false
|
|
271
|
+
prompt: |
|
|
272
|
+
set -e
|
|
273
|
+
cd "$(git rev-parse --show-toplevel)"
|
|
274
|
+
command -v glab >/dev/null || { echo "ERROR: glab CLI not installed. brew install glab && glab auth login" >&2; exit 1; }
|
|
275
|
+
BUG_ID="{{input.bug_id}}"
|
|
276
|
+
BASE="{{input.base_branch}}"
|
|
277
|
+
[ -z "$BUG_ID" ] && { echo "ERROR: bug_id is required" >&2; exit 1; }
|
|
278
|
+
[ -z "$BASE" ] && { echo "ERROR: base_branch is required (set via Job input_template)" >&2; exit 1; }
|
|
279
|
+
REMOTE=$(git remote get-url origin)
|
|
280
|
+
RAW=$(echo "$REMOTE" | sed -E 's#^(https?://[^/]+/|git@[^:]+:)##; s#\\.git$##')
|
|
281
|
+
HOST=$(echo "$REMOTE" | sed -E 's#^https?://##; s#git@##; s#[:/].*##')
|
|
282
|
+
PROJECT_PATH="$RAW"
|
|
283
|
+
echo "HOST=$HOST"
|
|
284
|
+
echo "PROJECT_PATH=$PROJECT_PATH"
|
|
285
|
+
echo "BUG_ID=$BUG_ID"
|
|
286
|
+
echo "BASE=$BASE"
|
|
287
|
+
git fetch origin "$BASE" --quiet 2>/dev/null || true
|
|
288
|
+
outputs:
|
|
289
|
+
- name: info
|
|
290
|
+
extract: stdout
|
|
291
|
+
worktree-setup:
|
|
292
|
+
mode: shell
|
|
293
|
+
project: "{{input.project}}"
|
|
294
|
+
worktree: false
|
|
295
|
+
depends_on: [resolve]
|
|
296
|
+
prompt: |
|
|
297
|
+
set -e
|
|
298
|
+
INFO=$'{{nodes.resolve.outputs.info}}'
|
|
299
|
+
eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
|
|
300
|
+
# worktree: false above keeps us in the project root, so this is fine.
|
|
301
|
+
# Falls back to FORGE_PROJECT_ROOT just in case auto-worktree gets
|
|
302
|
+
# re-enabled in the future.
|
|
303
|
+
ROOT="\${FORGE_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
|
|
304
|
+
cd "$ROOT"
|
|
305
|
+
WORKTREE_DIR="$ROOT/.forge/worktrees/mantis-$BUG_ID"
|
|
306
|
+
BRANCH="fix/mantis-\${BUG_ID}"
|
|
307
|
+
|
|
308
|
+
# ALL the noisy git operations route to stderr — downstream nodes
|
|
309
|
+
# consume \`wt\` via \`eval "$(echo … | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"\` inside
|
|
310
|
+
# \`$'…'\` quoting. ANY apostrophe / paren / space-ID-paren in the
|
|
311
|
+
# captured stdout (e.g. "Deleted branch fix/X (was abc1234).",
|
|
312
|
+
# "Preparing worktree (resetting branch 'X')") breaks bash with
|
|
313
|
+
# "syntax error near unexpected token '('".
|
|
314
|
+
{
|
|
315
|
+
# 1. Remove the on-disk worktree for THIS bug (idempotent).
|
|
316
|
+
if [ -d "$WORKTREE_DIR" ]; then
|
|
317
|
+
git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || rm -rf "$WORKTREE_DIR"
|
|
318
|
+
fi
|
|
319
|
+
# 2. Drop stale worktree registrations + any other worktree pinning
|
|
320
|
+
# our branch. Without (3), "fatal: <branch> already used by
|
|
321
|
+
# worktree at <path>" can fire on the next add.
|
|
322
|
+
git worktree prune
|
|
323
|
+
git worktree list --porcelain | awk -v b="refs/heads/$BRANCH" '
|
|
324
|
+
/^worktree /{w=$2}
|
|
325
|
+
$0=="branch "b{print w}
|
|
326
|
+
' | while read w; do
|
|
327
|
+
[ -n "$w" ] && git worktree remove --force "$w" 2>/dev/null || true
|
|
328
|
+
done
|
|
329
|
+
git branch -D "$BRANCH" 2>/dev/null || true
|
|
330
|
+
mkdir -p "$ROOT/.forge/worktrees"
|
|
331
|
+
# -B (capital) = create-or-reset; --force lets us reuse an existing dir.
|
|
332
|
+
git worktree add --force -B "$BRANCH" "$WORKTREE_DIR" "origin/$BASE"
|
|
333
|
+
} 1>&2
|
|
334
|
+
|
|
335
|
+
# IMPORTANT: emit ONLY the worktree path on stdout — nothing else.
|
|
336
|
+
# fix-code uses \`workdir: {{nodes.worktree-setup.outputs.wt}}\` and the
|
|
337
|
+
# task runner needs a clean path. If we echo'd "WORKTREE=…\\nBRANCH=…"
|
|
338
|
+
# the multi-line string became an invalid workdir → Claude silently
|
|
339
|
+
# ran in the pipeline's parent worktree, made its changes there, and
|
|
340
|
+
# nothing showed up in the inner branch when push-and-mr cd'd in.
|
|
341
|
+
# Downstream nodes get the branch by asking git from inside the
|
|
342
|
+
# worktree (git symbolic-ref --short HEAD) — no out-of-band passing.
|
|
343
|
+
echo "$WORKTREE_DIR"
|
|
344
|
+
outputs:
|
|
345
|
+
- name: wt
|
|
346
|
+
extract: stdout
|
|
347
|
+
fetch-bug-details:
|
|
348
|
+
mode: shell
|
|
349
|
+
project: "{{input.project}}"
|
|
350
|
+
worktree: false
|
|
351
|
+
depends_on: [resolve]
|
|
352
|
+
prompt: |
|
|
353
|
+
# Pull the FULL mantis bug — summary, description, additional_information,
|
|
354
|
+
# reproducibility, all notes with author/date, history — via Forge's
|
|
355
|
+
# loopback connector-tool endpoint. search_bugs (when used as the Job
|
|
356
|
+
# source) only returns list-page columns; this node fills the gap so
|
|
357
|
+
# fix-code has every field it could possibly want.
|
|
358
|
+
set -e
|
|
359
|
+
INFO=$'{{nodes.resolve.outputs.info}}'
|
|
360
|
+
eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
|
|
361
|
+
PAYLOAD="{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"get_bug\\",\\"input\\":{\\"id\\":$BUG_ID}}"
|
|
362
|
+
RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
|
|
363
|
+
-H 'content-type: application/json' --data "$PAYLOAD")
|
|
364
|
+
IS_ERR=$(echo "$RESP" | jq -r '.is_error // false')
|
|
365
|
+
if [ "$IS_ERR" = "true" ]; then
|
|
366
|
+
echo "WARN: mantis.get_bug failed: $(echo "$RESP" | jq -r '.content // .error // "(unknown)"' | head -c 300)" >&2
|
|
367
|
+
echo "BUG_ID=$BUG_ID"
|
|
368
|
+
echo "BUG_JSON_B64="
|
|
369
|
+
echo "SUMMARY="
|
|
370
|
+
echo "CATEGORY="
|
|
371
|
+
echo "DESCRIPTION_B64="
|
|
372
|
+
echo "ADDITIONAL_INFO_B64="
|
|
373
|
+
echo "NOTES_B64="
|
|
374
|
+
exit 0
|
|
375
|
+
fi
|
|
376
|
+
# .content is itself a JSON string (the bug object); decode once.
|
|
377
|
+
BUG=$(echo "$RESP" | jq -r '.content' | jq -c '.')
|
|
378
|
+
|
|
379
|
+
# Whole bug as base64 — gives fix-code an escape hatch to read any
|
|
380
|
+
# field we didn't bother to enumerate (history, _fields_raw, etc).
|
|
381
|
+
BUG_JSON_B64=$(echo -n "$BUG" | base64 | tr -d '\\n')
|
|
382
|
+
|
|
383
|
+
# Convenience extracts. Multi-line fields (description, notes,
|
|
384
|
+
# additional_information) → base64'd so newlines / quotes / shell
|
|
385
|
+
# metachars survive transport. Single-line stays plain for the
|
|
386
|
+
# KEY=value eval pattern downstream nodes already use.
|
|
387
|
+
SUMMARY=$(echo "$BUG" | jq -r '.summary // ""' | head -c 500 | tr -d '\\n')
|
|
388
|
+
CAT=$(echo "$BUG" | jq -r '.category // ""' | tr -d '\\n')
|
|
389
|
+
REPRO=$(echo "$BUG" | jq -r '.reproducibility // ""' | tr -d '\\n')
|
|
390
|
+
DESC_B64=$(echo "$BUG" | jq -r '.description // ""' | base64 | tr -d '\\n')
|
|
391
|
+
ADDL_B64=$(echo "$BUG" | jq -r '.additional_information // ""' | base64 | tr -d '\\n')
|
|
392
|
+
# Notes with author + date + body, separated by ─── for readability.
|
|
393
|
+
NOTES_B64=$(echo "$BUG" | jq -r '[.notes[]? | "[\\(.author // "?") @ \\(.date // "?")]\\n\\(.body // "")"] | join("\\n\\n─────────────\\n\\n")' 2>/dev/null | base64 | tr -d '\\n')
|
|
394
|
+
|
|
395
|
+
echo "BUG_ID=$BUG_ID"
|
|
396
|
+
echo "SUMMARY=$SUMMARY"
|
|
397
|
+
echo "CATEGORY=$CAT"
|
|
398
|
+
echo "REPRODUCIBILITY=$REPRO"
|
|
399
|
+
echo "DESCRIPTION_B64=$DESC_B64"
|
|
400
|
+
echo "ADDITIONAL_INFO_B64=$ADDL_B64"
|
|
401
|
+
echo "NOTES_B64=$NOTES_B64"
|
|
402
|
+
echo "BUG_JSON_B64=$BUG_JSON_B64"
|
|
403
|
+
|
|
404
|
+
# ── Human-readable preview to stderr ──
|
|
405
|
+
# Goes into the task log so the user can see what we actually got
|
|
406
|
+
# from Mantis without having to manually base64 -d. Truncated at
|
|
407
|
+
# 800 chars per field to keep the log tractable. Stdout above keeps
|
|
408
|
+
# the clean KEY=value pairs downstream nodes parse.
|
|
409
|
+
DESC_PLAIN=$(echo "$BUG" | jq -r '.description // ""')
|
|
410
|
+
ADDL_PLAIN=$(echo "$BUG" | jq -r '.additional_information // ""')
|
|
411
|
+
NOTES_COUNT=$(echo "$BUG" | jq -r '.notes | length // 0')
|
|
412
|
+
NOTES_FIRST=$(echo "$BUG" | jq -r '.notes[0]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
|
|
413
|
+
NOTES_LAST=$(echo "$BUG" | jq -r '.notes[-1]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
|
|
414
|
+
DESC_LEN=$(printf %s "$DESC_PLAIN" | wc -c | tr -d ' ')
|
|
415
|
+
ADDL_LEN=$(printf %s "$ADDL_PLAIN" | wc -c | tr -d ' ')
|
|
416
|
+
{
|
|
417
|
+
echo ""
|
|
418
|
+
echo "─── fetch-bug-details — what we got from Mantis #$BUG_ID ───"
|
|
419
|
+
echo "Summary: $SUMMARY"
|
|
420
|
+
echo "Category: $CAT"
|
|
421
|
+
echo "Reproducibility:$REPRO"
|
|
422
|
+
echo ""
|
|
423
|
+
echo "Description (\${DESC_LEN} chars, showing first 800):"
|
|
424
|
+
printf %s "$DESC_PLAIN" | head -c 800
|
|
425
|
+
[ "$DESC_LEN" -gt 800 ] && echo "… [truncated]"
|
|
426
|
+
echo ""
|
|
427
|
+
echo ""
|
|
428
|
+
echo "Additional Information (\${ADDL_LEN} chars, showing first 800):"
|
|
429
|
+
printf %s "$ADDL_PLAIN" | head -c 800
|
|
430
|
+
[ "$ADDL_LEN" -gt 800 ] && echo "… [truncated]"
|
|
431
|
+
echo ""
|
|
432
|
+
echo ""
|
|
433
|
+
echo "Notes: $NOTES_COUNT total"
|
|
434
|
+
if [ "$NOTES_COUNT" -gt 0 ]; then
|
|
435
|
+
echo " First: $NOTES_FIRST"
|
|
436
|
+
[ "$NOTES_COUNT" -gt 1 ] && echo " Last: $NOTES_LAST"
|
|
437
|
+
fi
|
|
438
|
+
echo "──────────────────────────────────────────────────────"
|
|
439
|
+
} >&2
|
|
440
|
+
outputs:
|
|
441
|
+
- name: details
|
|
442
|
+
extract: stdout
|
|
443
|
+
download-attachments:
|
|
444
|
+
mode: shell
|
|
445
|
+
project: "{{input.project}}"
|
|
446
|
+
worktree: false
|
|
447
|
+
depends_on: [resolve, worktree-setup]
|
|
448
|
+
prompt: |
|
|
449
|
+
# Download all Attached Files from the Mantis bug into the worktree's
|
|
450
|
+
# .attachments/ directory so fix-code (Claude with vision) can read
|
|
451
|
+
# screenshots, logs, and any other supporting material. Skips files
|
|
452
|
+
# larger than 5 MB (see download_attachment's max_size_bytes default).
|
|
453
|
+
set -e
|
|
454
|
+
INFO=$'{{nodes.resolve.outputs.info}}'
|
|
455
|
+
eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
|
|
456
|
+
WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
|
|
457
|
+
WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
|
|
458
|
+
ATTACH_DIR="$WORKTREE_DIR/.attachments"
|
|
459
|
+
mkdir -p "$ATTACH_DIR"
|
|
460
|
+
|
|
461
|
+
# 1. List attachments.
|
|
462
|
+
LIST=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
|
|
463
|
+
-H 'content-type: application/json' \\
|
|
464
|
+
--data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"list_attachments\\",\\"input\\":{\\"id\\":$BUG_ID,\\"bug_id\\":$BUG_ID}}" )
|
|
465
|
+
IS_ERR=$(echo "$LIST" | jq -r '.is_error // false')
|
|
466
|
+
if [ "$IS_ERR" = "true" ]; then
|
|
467
|
+
echo "ATTACH_COUNT=0"
|
|
468
|
+
echo "ATTACH_DIR=$ATTACH_DIR"
|
|
469
|
+
echo "WARN: list_attachments failed: $(echo "$LIST" | jq -r '.content' | head -c 200)" >&2
|
|
470
|
+
exit 0
|
|
471
|
+
fi
|
|
472
|
+
ATTACHMENTS=$(echo "$LIST" | jq -r '.content' | jq -c '.attachments // []')
|
|
473
|
+
TOTAL=$(echo "$ATTACHMENTS" | jq 'length')
|
|
474
|
+
echo "Found $TOTAL attachment(s) on bug $BUG_ID"
|
|
475
|
+
|
|
476
|
+
COUNT=0
|
|
477
|
+
SKIPPED=0
|
|
478
|
+
# 2. Iterate + download each. jq stream the ids/filenames.
|
|
479
|
+
echo "$ATTACHMENTS" | jq -c '.[]' | while read -r ATT; do
|
|
480
|
+
FID=$(echo "$ATT" | jq -r '.file_id')
|
|
481
|
+
FN=$(echo "$ATT" | jq -r '.filename')
|
|
482
|
+
SZ=$(echo "$ATT" | jq -r '.size')
|
|
483
|
+
# Sanitize filename to avoid path traversal.
|
|
484
|
+
SAFE=$(echo "$FN" | tr -d '\\\\/:*?"<>|' | head -c 200)
|
|
485
|
+
[ -z "$SAFE" ] && SAFE="file-$FID"
|
|
486
|
+
echo " → $SAFE ($SZ bytes)"
|
|
487
|
+
RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
|
|
488
|
+
-H 'content-type: application/json' \\
|
|
489
|
+
--data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"download_attachment\\",\\"input\\":{\\"file_id\\":$FID}}")
|
|
490
|
+
if [ "$(echo "$RESP" | jq -r '.is_error // false')" = "true" ]; then
|
|
491
|
+
echo " WARN: download failed for $SAFE" >&2
|
|
492
|
+
continue
|
|
493
|
+
fi
|
|
494
|
+
BODY=$(echo "$RESP" | jq -r '.content')
|
|
495
|
+
SKIP=$(echo "$BODY" | jq -r '.skipped // false')
|
|
496
|
+
if [ "$SKIP" = "true" ]; then
|
|
497
|
+
REASON=$(echo "$BODY" | jq -r '.reason // ""')
|
|
498
|
+
echo " SKIPPED $SAFE — $REASON" >&2
|
|
499
|
+
SKIPPED=$((SKIPPED+1))
|
|
500
|
+
continue
|
|
501
|
+
fi
|
|
502
|
+
B64=$(echo "$BODY" | jq -r '.content_b64')
|
|
503
|
+
[ -z "$B64" ] && { echo " WARN: empty content_b64 for $SAFE" >&2; continue; }
|
|
504
|
+
echo "$B64" | base64 -d > "$ATTACH_DIR/$SAFE"
|
|
505
|
+
COUNT=$((COUNT+1))
|
|
506
|
+
done
|
|
507
|
+
|
|
508
|
+
echo "ATTACH_COUNT=$COUNT"
|
|
509
|
+
echo "ATTACH_SKIPPED=$SKIPPED"
|
|
510
|
+
echo "ATTACH_DIR=$ATTACH_DIR"
|
|
511
|
+
|
|
512
|
+
# Human-readable directory listing into stderr so the log shows
|
|
513
|
+
# what's actually on disk for the next node.
|
|
514
|
+
{
|
|
515
|
+
echo ""
|
|
516
|
+
echo "─── download-attachments — files in $ATTACH_DIR ───"
|
|
517
|
+
if [ -d "$ATTACH_DIR" ] && [ -n "$(ls -A "$ATTACH_DIR" 2>/dev/null)" ]; then
|
|
518
|
+
ls -lhS "$ATTACH_DIR" 2>/dev/null | tail -n +2
|
|
519
|
+
else
|
|
520
|
+
echo "(no files)"
|
|
521
|
+
fi
|
|
522
|
+
echo "──────────────────────────────────────────────────────"
|
|
523
|
+
} >&2
|
|
524
|
+
outputs:
|
|
525
|
+
- name: attach
|
|
526
|
+
extract: stdout
|
|
527
|
+
fix-code:
|
|
528
|
+
project: "{{input.project}}"
|
|
529
|
+
worktree: false
|
|
530
|
+
depends_on: [worktree-setup, fetch-bug-details, download-attachments]
|
|
531
|
+
workdir: "{{nodes.worktree-setup.outputs.wt}}"
|
|
532
|
+
prompt: |
|
|
533
|
+
A Mantis bug needs to be fixed in this worktree (already checked out
|
|
534
|
+
from the target base branch). You are running headless — make the fix
|
|
535
|
+
yourself, stage + commit.
|
|
536
|
+
|
|
537
|
+
## Bug
|
|
538
|
+
ID: {{input.bug_id}}
|
|
539
|
+
Priority: {{input.priority}}
|
|
540
|
+
Category (from get_bug, full): see DETAILS below
|
|
541
|
+
Assignee: {{input.assignee}}
|
|
542
|
+
Reporter: {{input.reporter}}
|
|
543
|
+
|
|
544
|
+
## Summary
|
|
545
|
+
{{input.summary}}
|
|
546
|
+
|
|
547
|
+
## Full bug details (fetched via mantis.get_bug)
|
|
548
|
+
The fetch-bug-details node ran \`mantis.get_bug\` and exported these
|
|
549
|
+
key=value lines. _B64 fields are base64-encoded multi-line strings:
|
|
550
|
+
|
|
551
|
+
{{nodes.fetch-bug-details.outputs.details}}
|
|
552
|
+
|
|
553
|
+
Decode any _B64 field with:
|
|
554
|
+
\`echo "<value>" | base64 -d\`
|
|
555
|
+
|
|
556
|
+
Available fields (READ ALL of them before deciding on a fix):
|
|
557
|
+
SUMMARY one-line title (plain)
|
|
558
|
+
CATEGORY Mantis category (plain)
|
|
559
|
+
REPRODUCIBILITY always / sometimes / random / N/A (plain)
|
|
560
|
+
DESCRIPTION_B64 full bug description (base64; often the most
|
|
561
|
+
important field — repro steps live here)
|
|
562
|
+
ADDITIONAL_INFO_B64 "Additional Information" custom field —
|
|
563
|
+
FortiNAC bug reports usually put stack traces,
|
|
564
|
+
env details, log snippets here. Decode + read.
|
|
565
|
+
NOTES_B64 all comments concatenated, with author + date
|
|
566
|
+
headers ([author @ date]). Use to see what
|
|
567
|
+
QA + reporter already discussed.
|
|
568
|
+
BUG_JSON_B64 escape hatch — the whole bug object as JSON
|
|
569
|
+
(decoded, this has every field including
|
|
570
|
+
history[] and _fields_raw if you need it).
|
|
571
|
+
|
|
572
|
+
## Description (search_bugs-supplied, may be empty)
|
|
573
|
+
{{input.description}}
|
|
574
|
+
|
|
575
|
+
## Attached Files
|
|
576
|
+
{{nodes.download-attachments.outputs.attach}}
|
|
577
|
+
|
|
578
|
+
Files (if any) were saved under \`.attachments/\` in this worktree.
|
|
579
|
+
List them with \`ls -la .attachments/\`. For images, USE THE READ
|
|
580
|
+
TOOL on the file — your vision can analyze screenshots / error
|
|
581
|
+
dialogs / network diagrams directly. For text-like attachments
|
|
582
|
+
(log snippets, .txt, .json, small .pcap text exports) just open
|
|
583
|
+
them. Files larger than 5 MB were skipped — if a critical one is
|
|
584
|
+
missing, mention it in your fix notes; don't try to re-fetch.
|
|
585
|
+
|
|
586
|
+
## Extra context
|
|
587
|
+
{{input.extra_context}}
|
|
588
|
+
|
|
589
|
+
## HARD RULES — read before tool use
|
|
590
|
+
- **Never call mcp__* tools.** Pipeline tasks do not have interactive
|
|
591
|
+
auth; any MCP call that needs SSO/TOTP (mantis_auth, gitlab, pmdb,
|
|
592
|
+
…) will hang and fail the pipeline. The bug context above is the
|
|
593
|
+
only Mantis data you get.
|
|
594
|
+
- If a field above is empty or truncated, **work with what is given**.
|
|
595
|
+
Do NOT try to fetch more from Mantis. The pipeline owns Mantis access
|
|
596
|
+
upstream — re-running mantis lookup here is wasted effort and breaks
|
|
597
|
+
the design contract (Job → Pipeline data flow).
|
|
598
|
+
- If you ABSOLUTELY need supplemental data from another Forge connector
|
|
599
|
+
(rare), use the Forge HTTP API instead of MCP. POST
|
|
600
|
+
http://127.0.0.1:8403/api/connector-tool with JSON body
|
|
601
|
+
{"plugin_id":"<plugin>","tool":"<tool>","input":{...}}
|
|
602
|
+
|
|
603
|
+
## Steps
|
|
604
|
+
1. Read the bug carefully — identify the affected component.
|
|
605
|
+
2. Find the relevant file(s) via git grep + reading the code.
|
|
606
|
+
3. Implement a minimal fix.
|
|
607
|
+
4. Add tests if the codebase has a test suite.
|
|
608
|
+
5. Stage + commit with message: 'Fix Mantis #{{input.bug_id}}: <one-line>'.
|
|
609
|
+
**DO NOT add a "Co-Authored-By: Claude …" or "Generated with Claude
|
|
610
|
+
Code" trailer.** Plain commit message only — the MR is going into a
|
|
611
|
+
corporate repo and the AI co-authorship attribution is not wanted.
|
|
612
|
+
|
|
613
|
+
Do NOT push — the next pipeline node handles push + MR.
|
|
614
|
+
outputs:
|
|
615
|
+
- name: summary
|
|
616
|
+
extract: result
|
|
617
|
+
- name: diff
|
|
618
|
+
extract: git_diff
|
|
619
|
+
push-and-mr:
|
|
620
|
+
mode: shell
|
|
621
|
+
project: "{{input.project}}"
|
|
622
|
+
worktree: false
|
|
623
|
+
workdir: "{{nodes.worktree-setup.outputs.wt}}"
|
|
624
|
+
depends_on: [fix-code]
|
|
625
|
+
prompt: |
|
|
626
|
+
set -e
|
|
627
|
+
INFO=$'{{nodes.resolve.outputs.info}}'
|
|
628
|
+
eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
|
|
629
|
+
# workdir already cd'd us into the mantis worktree. Derive BRANCH.
|
|
630
|
+
BRANCH=$(git symbolic-ref --short HEAD)
|
|
631
|
+
# Bail if Claude didn't actually commit anything
|
|
632
|
+
AHEAD=$(git rev-list --count "origin/$BASE..HEAD" 2>/dev/null || echo 0)
|
|
633
|
+
if [ "$AHEAD" -lt 1 ]; then
|
|
634
|
+
echo "NO_CHANGES — Claude did not commit; aborting MR creation"
|
|
635
|
+
echo "MR_URL="
|
|
636
|
+
exit 0
|
|
637
|
+
fi
|
|
638
|
+
git push -u origin "$BRANCH" --force-with-lease 2>&1
|
|
639
|
+
# Heredoc keeps Claude's summary intact (apostrophes, parens, $, …).
|
|
640
|
+
SUMMARY=$(cat <<'FORGE_SUMMARY_EOF'
|
|
641
|
+
{{nodes.fix-code.outputs.summary}}
|
|
642
|
+
FORGE_SUMMARY_EOF
|
|
643
|
+
)
|
|
644
|
+
# ALL pipeline-templated values must enter via \`$'…'\` ANSI-C quoting,
|
|
645
|
+
# NOT plain "…". The pipeline engine ANSI-C-escapes substituted values
|
|
646
|
+
# (\\n / \\t / \\' etc.); inside "…" bash treats those as literal text
|
|
647
|
+
# and the MR description ends up showing "\\n" instead of real
|
|
648
|
+
# newlines, breaking the markdown.
|
|
649
|
+
TITLE_TPL=$'{{input.mr_title_template}}'
|
|
650
|
+
[ -z "$TITLE_TPL" ] && TITLE_TPL="Fix Mantis #{bug_id}: {summary}"
|
|
651
|
+
BODY_TPL=$'{{input.mr_body_template}}'
|
|
652
|
+
[ -z "$BODY_TPL" ] && BODY_TPL="Auto-fix for Mantis bug #{bug_id}.
|
|
653
|
+
|
|
654
|
+
## Summary
|
|
655
|
+
{summary}
|
|
656
|
+
|
|
657
|
+
## Original description
|
|
658
|
+
{description}
|
|
659
|
+
|
|
660
|
+
## Claude's fix notes
|
|
661
|
+
{claude_summary}
|
|
662
|
+
|
|
663
|
+
## Files changed
|
|
664
|
+
{diff_stat}
|
|
665
|
+
|
|
666
|
+
_Opened by Forge mantis-bug-fix-and-mr pipeline._"
|
|
667
|
+
BUG_ID_VAR=$'{{input.bug_id}}'
|
|
668
|
+
SUMMARY_VAR=$'{{input.summary}}'
|
|
669
|
+
DESCRIPTION_VAR=$'{{input.description}}'
|
|
670
|
+
# Compact list of files touched. --stat gives "N files changed, X+/Y-"
|
|
671
|
+
# which is small but tells the reviewer what's in the MR at a glance.
|
|
672
|
+
DIFF_STAT=$(git diff --stat "origin/$BASE..HEAD" 2>/dev/null || echo "")
|
|
673
|
+
MR_TITLE=$(python3 - "$TITLE_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" <<'PY'
|
|
674
|
+
import sys
|
|
675
|
+
tpl, bug_id, summary = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
676
|
+
print(tpl.replace('{bug_id}', bug_id).replace('{summary}', summary))
|
|
677
|
+
PY
|
|
678
|
+
)
|
|
679
|
+
MR_BODY=$(python3 - "$BODY_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" "$DESCRIPTION_VAR" "$SUMMARY" "$DIFF_STAT" <<'PY'
|
|
680
|
+
import sys
|
|
681
|
+
tpl, bug_id, summary, description, claude, diff_stat = sys.argv[1:7]
|
|
682
|
+
out = (tpl.replace('{bug_id}', bug_id)
|
|
683
|
+
.replace('{summary}', summary)
|
|
684
|
+
.replace('{description}', description)
|
|
685
|
+
.replace('{claude_summary}', claude)
|
|
686
|
+
.replace('{diff_stat}', diff_stat))
|
|
687
|
+
print(out)
|
|
688
|
+
PY
|
|
689
|
+
)
|
|
690
|
+
# Bug assignee → MR reviewer. Mantis assignee comes as "Jane Doe (jdoe)";
|
|
691
|
+
# extract the (username) and pass to glab. If parens absent or empty,
|
|
692
|
+
# skip the flag — glab errors on --reviewer "" .
|
|
693
|
+
ASSIGNEE_RAW=$'{{input.assignee}}'
|
|
694
|
+
REVIEWER_USERNAME=$(echo "$ASSIGNEE_RAW" | grep -oE '\\(([a-zA-Z0-9._-]+)\\)' | tail -1 | tr -d '()')
|
|
695
|
+
REVIEWER_FLAGS=()
|
|
696
|
+
if [ -n "$REVIEWER_USERNAME" ]; then
|
|
697
|
+
REVIEWER_FLAGS+=(--reviewer "$REVIEWER_USERNAME")
|
|
698
|
+
echo "Setting MR reviewer: $REVIEWER_USERNAME (from assignee '$ASSIGNEE_RAW')"
|
|
699
|
+
else
|
|
700
|
+
echo "No (username) found in assignee '$ASSIGNEE_RAW' — skipping --reviewer"
|
|
701
|
+
fi
|
|
702
|
+
# Capture glab output AND exit code so we can debug failures. Old
|
|
703
|
+
# pipeline that just grep'd for the URL ate everything if the
|
|
704
|
+
# regex didn't match — leaving MR_URL empty with no clue why
|
|
705
|
+
# (auth refresh needed, reviewer username wrong, MR already exists
|
|
706
|
+
# at a non-canonical URL, etc).
|
|
707
|
+
set +e
|
|
708
|
+
GLAB_OUT=$(glab mr create -R "$PROJECT_PATH" --target-branch "$BASE" --source-branch "$BRANCH" \\
|
|
709
|
+
--title "$MR_TITLE" --description "$MR_BODY" \\
|
|
710
|
+
"\${REVIEWER_FLAGS[@]}" \\
|
|
711
|
+
--yes 2>&1)
|
|
712
|
+
GLAB_RC=$?
|
|
713
|
+
set -e
|
|
714
|
+
# Surface glab's output to the task log (truncated) so a failed
|
|
715
|
+
# retry doesn't disappear into the void.
|
|
716
|
+
echo "--- glab mr create (rc=$GLAB_RC) ---"
|
|
717
|
+
echo "$GLAB_OUT" | head -50
|
|
718
|
+
echo "--- end glab output ---"
|
|
719
|
+
|
|
720
|
+
# Common, very actionable failure: corp GitLab revoked the glab
|
|
721
|
+
# token. Detect + give the exact command to fix instead of making
|
|
722
|
+
# the user dig through stack-trace-like output.
|
|
723
|
+
if echo "$GLAB_OUT" | grep -qE '401|invalid_token|Token was revoked|unauthorized'; then
|
|
724
|
+
HOST=$(echo "$PROJECT_PATH" | sed -E 's#^.+@?##; s#:.*##' || true)
|
|
725
|
+
REMOTE_HOST=$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
|
|
726
|
+
echo "ERROR: glab token revoked / expired for \${REMOTE_HOST:-this GitLab server}." >&2
|
|
727
|
+
echo "Fix: glab auth login --hostname \${REMOTE_HOST:-<gitlab-host>}" >&2
|
|
728
|
+
echo "Then retry this node from the pipeline UI." >&2
|
|
729
|
+
fi
|
|
730
|
+
|
|
731
|
+
MR_URL=$(echo "$GLAB_OUT" | grep -oE 'https://[^[:space:]]+/-/merge_requests/[0-9]+' | head -1)
|
|
732
|
+
|
|
733
|
+
# If glab succeeded but we didn't parse a URL (output format
|
|
734
|
+
# variant), OR if the MR already existed (glab errors on conflict),
|
|
735
|
+
# fall back to looking up the MR by source branch.
|
|
736
|
+
if [ -z "$MR_URL" ]; then
|
|
737
|
+
echo "Fallback: looking up existing MR for branch $BRANCH"
|
|
738
|
+
MR_URL=$(glab mr view "$BRANCH" -R "$PROJECT_PATH" --output json 2>/dev/null | jq -r '.web_url // empty')
|
|
739
|
+
if [ -z "$MR_URL" ]; then
|
|
740
|
+
# Last resort: glab mr list with our source branch.
|
|
741
|
+
MR_URL=$(glab mr list -R "$PROJECT_PATH" --source-branch "$BRANCH" --output json 2>/dev/null \\
|
|
742
|
+
| jq -r '.[0].web_url // empty')
|
|
743
|
+
fi
|
|
744
|
+
fi
|
|
745
|
+
|
|
746
|
+
if [ -z "$MR_URL" ]; then
|
|
747
|
+
echo "ERROR: could not create OR find MR for $BRANCH. See glab output above." >&2
|
|
748
|
+
# Don't fail the node hard — push succeeded, the user can create
|
|
749
|
+
# the MR manually via the GitLab "create MR" hint in the push
|
|
750
|
+
# output. notify-teams will see empty MR_URL and skip.
|
|
751
|
+
fi
|
|
752
|
+
|
|
753
|
+
echo "MR_URL=$MR_URL"
|
|
754
|
+
echo "REVIEWER=$REVIEWER_USERNAME"
|
|
755
|
+
outputs:
|
|
756
|
+
- name: mr
|
|
757
|
+
extract: stdout
|
|
758
|
+
notify-teams:
|
|
759
|
+
mode: shell
|
|
760
|
+
project: "{{input.project}}"
|
|
761
|
+
worktree: false
|
|
762
|
+
depends_on: [push-and-mr]
|
|
763
|
+
prompt: |
|
|
764
|
+
set -e
|
|
765
|
+
MR_OUT=$'{{nodes.push-and-mr.outputs.mr}}'
|
|
766
|
+
eval "$(echo "$MR_OUT" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
|
|
767
|
+
if [ -z "$MR_URL" ]; then
|
|
768
|
+
echo "SKIP — no MR URL (Claude likely made no changes)"
|
|
769
|
+
exit 0
|
|
770
|
+
fi
|
|
771
|
+
MSG_TPL="{{input.teams_message_template}}"
|
|
772
|
+
[ -z "$MSG_TPL" ] && MSG_TPL="🤖 Mantis #{bug_id} fixed — please review MR: {mr_url}"$'\\n'"Bug: {summary}"
|
|
773
|
+
# Forge exposes /api/connector-tool on loopback (no auth) — pipelines
|
|
774
|
+
# call connector tools through it. teams.send_message takes name (fuzzy
|
|
775
|
+
# match against any chat the user is in) + text.
|
|
776
|
+
send_to() {
|
|
777
|
+
local who="$1"
|
|
778
|
+
local role="$2"
|
|
779
|
+
if [ -z "$who" ]; then echo "SKIP — no $role name"; return 0; fi
|
|
780
|
+
# Render template + build JSON payload in one Python invocation so we
|
|
781
|
+
# never have to worry about shell quoting of summary / description /
|
|
782
|
+
# MR URL when they contain |, $, backticks, etc.
|
|
783
|
+
local payload
|
|
784
|
+
payload=$(python3 - "$MSG_TPL" "{{input.bug_id}}" "{{input.summary}}" "$role" "$MR_URL" "$who" <<'PY'
|
|
785
|
+
import json, sys
|
|
786
|
+
tpl, bug_id, summary, role, mr_url, who = sys.argv[1:7]
|
|
787
|
+
text = (tpl.replace('{bug_id}', bug_id)
|
|
788
|
+
.replace('{summary}', summary)
|
|
789
|
+
.replace('{role}', role)
|
|
790
|
+
.replace('{mr_url}', mr_url))
|
|
791
|
+
print(json.dumps({'plugin_id': 'teams', 'tool': 'send_message',
|
|
792
|
+
'input': {'name': who, 'text': text}}))
|
|
793
|
+
PY
|
|
794
|
+
)
|
|
795
|
+
echo "Sending to Teams chat '$who' ($role)…"
|
|
796
|
+
curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
|
|
797
|
+
-H 'content-type: application/json' \\
|
|
798
|
+
--data-binary "$payload" \\
|
|
799
|
+
| jq .
|
|
800
|
+
}
|
|
801
|
+
send_to "{{input.assignee}}" "assignee"
|
|
802
|
+
send_to "{{input.reporter}}" "reporter"
|
|
803
|
+
echo "DONE — MR: $MR_URL"
|
|
804
|
+
outputs:
|
|
805
|
+
- name: result
|
|
806
|
+
extract: stdout
|
|
807
|
+
cleanup:
|
|
808
|
+
mode: shell
|
|
809
|
+
project: "{{input.project}}"
|
|
810
|
+
worktree: false
|
|
811
|
+
depends_on: [notify-teams]
|
|
812
|
+
prompt: |
|
|
813
|
+
# Best-effort cleanup so the project working tree doesn't pile up
|
|
814
|
+
# one full checkout per fixed bug. Runs only after notify-teams
|
|
815
|
+
# succeeded — if anything upstream failed, the worktree is kept
|
|
816
|
+
# for inspection. The remote branch + MR still live on GitLab.
|
|
817
|
+
set +e
|
|
818
|
+
WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
|
|
819
|
+
WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
|
|
820
|
+
# Derive BRANCH from inside the worktree before we delete it.
|
|
821
|
+
BRANCH=$(git -C "$WORKTREE_DIR" symbolic-ref --short HEAD 2>/dev/null)
|
|
822
|
+
cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
823
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
824
|
+
[ -z "$ROOT" ] && { echo "SKIP — not in a git repo"; exit 0; }
|
|
825
|
+
# Paranoia — only clean under our .forge/worktrees/.
|
|
826
|
+
case "$WORKTREE_DIR" in
|
|
827
|
+
"$ROOT/.forge/worktrees/"*) : ;;
|
|
828
|
+
*) echo "SKIP — WORKTREE_DIR=$WORKTREE_DIR not under .forge/worktrees/"; exit 0 ;;
|
|
829
|
+
esac
|
|
830
|
+
echo "removing worktree $WORKTREE_DIR …"
|
|
831
|
+
git worktree remove --force "$WORKTREE_DIR" 2>&1 || rm -rf "$WORKTREE_DIR"
|
|
832
|
+
git worktree prune
|
|
833
|
+
# Drop the local branch too — remote copy is on GitLab + the MR.
|
|
834
|
+
[ -n "$BRANCH" ] && git branch -D "$BRANCH" 2>/dev/null && echo "deleted local branch $BRANCH"
|
|
835
|
+
echo "cleanup done"
|
|
836
|
+
outputs:
|
|
837
|
+
- name: result
|
|
838
|
+
extract: stdout
|
|
245
839
|
`,
|
|
246
840
|
'multi-agent-collaboration': `
|
|
247
841
|
name: multi-agent-collaboration
|
|
@@ -439,6 +1033,7 @@ function parseWorkflow(raw: string): Workflow {
|
|
|
439
1033
|
agent: n.agent || undefined,
|
|
440
1034
|
branch: n.branch || undefined,
|
|
441
1035
|
worktree: n.worktree !== undefined ? n.worktree : undefined,
|
|
1036
|
+
workdir: n.workdir || undefined,
|
|
442
1037
|
plugin: n.plugin || undefined,
|
|
443
1038
|
pluginAction: n.plugin_action || n.pluginAction || undefined,
|
|
444
1039
|
pluginParams: n.plugin_params || n.pluginParams || n.params || undefined,
|
|
@@ -511,24 +1106,102 @@ export function deletePipeline(id: string): boolean {
|
|
|
511
1106
|
if (existsSync(filePath)) {
|
|
512
1107
|
const { unlinkSync } = require('node:fs');
|
|
513
1108
|
unlinkSync(filePath);
|
|
1109
|
+
__pipelineCache.delete(filePath);
|
|
514
1110
|
return true;
|
|
515
1111
|
}
|
|
516
1112
|
} catch {}
|
|
517
1113
|
return false;
|
|
518
1114
|
}
|
|
519
1115
|
|
|
1116
|
+
// Parsed-pipeline cache keyed by absolute path → { mtime, pipeline }.
|
|
1117
|
+
// Pipeline files only change when a pipeline run advances (savePipeline) —
|
|
1118
|
+
// re-reading + JSON.parse on every list call was burning 1-3s with 200
|
|
1119
|
+
// runs accumulated (largest file 136KB, conversation history + node
|
|
1120
|
+
// outputs blow up). With this cache, subsequent listPipelines() calls
|
|
1121
|
+
// only re-parse files whose mtime changed since the last read.
|
|
1122
|
+
const __pipelineCache = new Map<string, { mtimeMs: number; pipeline: Pipeline }>();
|
|
1123
|
+
|
|
520
1124
|
export function listPipelines(): Pipeline[] {
|
|
521
1125
|
ensureDir();
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
1126
|
+
const files = readdirSync(PIPELINES_DIR).filter(f => f.endsWith('.json'));
|
|
1127
|
+
const out: Pipeline[] = [];
|
|
1128
|
+
for (const f of files) {
|
|
1129
|
+
const path = join(PIPELINES_DIR, f);
|
|
1130
|
+
try {
|
|
1131
|
+
const stat = statSync(path);
|
|
1132
|
+
const cached = __pipelineCache.get(path);
|
|
1133
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
1134
|
+
out.push(cached.pipeline);
|
|
1135
|
+
continue;
|
|
529
1136
|
}
|
|
530
|
-
|
|
531
|
-
|
|
1137
|
+
const pipeline = JSON.parse(readFileSync(path, 'utf-8')) as Pipeline;
|
|
1138
|
+
__pipelineCache.set(path, { mtimeMs: stat.mtimeMs, pipeline });
|
|
1139
|
+
out.push(pipeline);
|
|
1140
|
+
} catch { /* skip unreadable */ }
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Light summary for the pipeline LIST UI — strips the heavy fields
|
|
1147
|
+
* (node outputs, conversation message bodies, errors). The full pipeline
|
|
1148
|
+
* is fetched via GET /api/pipelines/:id when the user opens a specific
|
|
1149
|
+
* run. Cuts /api/pipelines payload from ~1.6MB to <50KB with 200 runs.
|
|
1150
|
+
*/
|
|
1151
|
+
export interface PipelineSummary {
|
|
1152
|
+
id: string;
|
|
1153
|
+
workflowName: string;
|
|
1154
|
+
status: Pipeline['status'];
|
|
1155
|
+
type?: Pipeline['type'];
|
|
1156
|
+
createdAt: string;
|
|
1157
|
+
completedAt?: string;
|
|
1158
|
+
nodeOrder: string[];
|
|
1159
|
+
nodes: Record<string, { status: PipelineNodeState['status']; iterations?: number }>;
|
|
1160
|
+
conversation?: { currentRound?: number; config?: { maxRounds?: number } };
|
|
1161
|
+
input?: Record<string, string>;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
export interface ListPipelinesOptions {
|
|
1165
|
+
/** Newest-first limit. Defaults to 100. Hard ceiling 500 (the heavy
|
|
1166
|
+
* pipeline detail view fetches one run at a time anyway, so a
|
|
1167
|
+
* five-hundred-deep list covers every practical workflow grouping). */
|
|
1168
|
+
limit?: number;
|
|
1169
|
+
/** Filter to a single workflow. Combine with a higher limit when the
|
|
1170
|
+
* user expands one workflow and wants its full history. */
|
|
1171
|
+
workflow?: string;
|
|
1172
|
+
/** Cursor for "Load more" — return runs with createdAt < before. */
|
|
1173
|
+
before?: string;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export function listPipelinesSummary(opts: ListPipelinesOptions = {}): PipelineSummary[] {
|
|
1177
|
+
const limit = Math.min(Math.max(1, opts.limit ?? 100), 500);
|
|
1178
|
+
// listPipelines already builds parsed objects (cached). Sort by
|
|
1179
|
+
// createdAt desc, optionally filter, then slice. Avoids serializing the
|
|
1180
|
+
// hundreds of older runs the UI never shows.
|
|
1181
|
+
let all = listPipelines();
|
|
1182
|
+
if (opts.workflow) {
|
|
1183
|
+
const target = opts.workflow;
|
|
1184
|
+
all = all.filter(p => p.workflowName === target);
|
|
1185
|
+
}
|
|
1186
|
+
all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1187
|
+
if (opts.before) all = all.filter(p => p.createdAt < opts.before!);
|
|
1188
|
+
const slice = all.slice(0, limit);
|
|
1189
|
+
return slice.map(p => ({
|
|
1190
|
+
id: p.id,
|
|
1191
|
+
workflowName: p.workflowName,
|
|
1192
|
+
status: p.status,
|
|
1193
|
+
type: p.type,
|
|
1194
|
+
createdAt: p.createdAt,
|
|
1195
|
+
completedAt: p.completedAt,
|
|
1196
|
+
nodeOrder: p.nodeOrder,
|
|
1197
|
+
nodes: Object.fromEntries(
|
|
1198
|
+
Object.entries(p.nodes).map(([k, v]) => [k, { status: v.status, iterations: v.iterations }]),
|
|
1199
|
+
),
|
|
1200
|
+
conversation: p.conversation
|
|
1201
|
+
? { currentRound: p.conversation.currentRound, config: { maxRounds: p.conversation.config?.maxRounds } }
|
|
1202
|
+
: undefined,
|
|
1203
|
+
input: p.input,
|
|
1204
|
+
}));
|
|
532
1205
|
}
|
|
533
1206
|
|
|
534
1207
|
// ─── Template Resolution ──────────────────────────────────
|
|
@@ -1135,6 +1808,80 @@ setInterval(recoverStuckPipelines, 30_000);
|
|
|
1135
1808
|
// Also run once on load
|
|
1136
1809
|
setTimeout(recoverStuckPipelines, 5000);
|
|
1137
1810
|
|
|
1811
|
+
/**
|
|
1812
|
+
* Retry a single failed node. Cascades-reset to any downstream nodes that
|
|
1813
|
+
* got marked 'skipped' (because this one failed) so they re-run too. The
|
|
1814
|
+
* pipeline flips back to 'running' if it had completed as failed.
|
|
1815
|
+
*
|
|
1816
|
+
* Use case: pipeline failed at push-and-mr because of a shell quoting bug;
|
|
1817
|
+
* fix-code already produced a valid commit in the worktree. Retry push-and-mr
|
|
1818
|
+
* without re-running the 15-minute fix-code.
|
|
1819
|
+
*
|
|
1820
|
+
* Returns { ok, error? }. Validation:
|
|
1821
|
+
* - node must exist + be in failed status
|
|
1822
|
+
* - all of the node's *dependencies* must be 'done' (we don't retry into
|
|
1823
|
+
* a broken upstream — fix the upstream first)
|
|
1824
|
+
*/
|
|
1825
|
+
export async function retryNode(pipelineId: string, nodeId: string): Promise<{ ok: boolean; error?: string }> {
|
|
1826
|
+
const pipeline = getPipeline(pipelineId);
|
|
1827
|
+
if (!pipeline) return { ok: false, error: 'pipeline not found' };
|
|
1828
|
+
|
|
1829
|
+
const nodeState = pipeline.nodes[nodeId];
|
|
1830
|
+
if (!nodeState) return { ok: false, error: `node '${nodeId}' is not in this pipeline` };
|
|
1831
|
+
if (nodeState.status !== 'failed') {
|
|
1832
|
+
return { ok: false, error: `node is in status '${nodeState.status}' — only failed nodes can be retried` };
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const workflow = getWorkflow(pipeline.workflowName);
|
|
1836
|
+
if (!workflow) return { ok: false, error: `workflow '${pipeline.workflowName}' not found` };
|
|
1837
|
+
|
|
1838
|
+
const nodeDef = workflow.nodes[nodeId];
|
|
1839
|
+
if (!nodeDef) return { ok: false, error: `node '${nodeId}' is not in workflow definition` };
|
|
1840
|
+
|
|
1841
|
+
for (const dep of nodeDef.dependsOn) {
|
|
1842
|
+
const depState = pipeline.nodes[dep];
|
|
1843
|
+
if (!depState || depState.status !== 'done') {
|
|
1844
|
+
return { ok: false, error: `dependency '${dep}' is in status '${depState?.status || 'missing'}' — retry upstream first` };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// BFS downstream — any node whose chain of dependencies reaches `nodeId`
|
|
1849
|
+
// needs to be reset too (it likely got marked 'skipped' when this failed).
|
|
1850
|
+
const toReset = new Set<string>([nodeId]);
|
|
1851
|
+
let added = true;
|
|
1852
|
+
while (added) {
|
|
1853
|
+
added = false;
|
|
1854
|
+
for (const [otherId, otherDef] of Object.entries(workflow.nodes)) {
|
|
1855
|
+
if (toReset.has(otherId)) continue;
|
|
1856
|
+
if (otherDef.dependsOn.some(d => toReset.has(d))) {
|
|
1857
|
+
toReset.add(otherId);
|
|
1858
|
+
added = true;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
for (const rid of toReset) {
|
|
1864
|
+
const s = pipeline.nodes[rid];
|
|
1865
|
+
if (!s) continue;
|
|
1866
|
+
s.status = 'pending';
|
|
1867
|
+
s.error = undefined;
|
|
1868
|
+
s.taskId = undefined;
|
|
1869
|
+
s.startedAt = undefined;
|
|
1870
|
+
s.completedAt = undefined;
|
|
1871
|
+
s.outputs = {};
|
|
1872
|
+
// Don't reset s.iterations — keep monotonic so retries show up in history.
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if (pipeline.status === 'failed' || pipeline.status === 'done') {
|
|
1876
|
+
pipeline.status = 'running';
|
|
1877
|
+
pipeline.completedAt = undefined;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
savePipeline(pipeline);
|
|
1881
|
+
await scheduleReadyNodes(pipeline, workflow);
|
|
1882
|
+
return { ok: true };
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1138
1885
|
export function cancelPipeline(id: string): boolean {
|
|
1139
1886
|
const pipeline = getPipeline(id);
|
|
1140
1887
|
if (!pipeline || pipeline.status !== 'running') return false;
|
|
@@ -1243,6 +1990,32 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
1243
1990
|
console.warn(`[pipeline] Worktree creation failed, falling back to project dir: ${e.message}`);
|
|
1244
1991
|
}
|
|
1245
1992
|
|
|
1993
|
+
// Explicit workdir override — wins over auto-worktree. Use case:
|
|
1994
|
+
// worktree-setup emits a path on stdout; downstream nodes (fix-code,
|
|
1995
|
+
// push-and-mr) declare workdir: "{{nodes.worktree-setup.outputs.wt}}"
|
|
1996
|
+
// to actually execute inside that worktree instead of the auto one.
|
|
1997
|
+
// Before this support existed, the YAML's workdir was silently ignored
|
|
1998
|
+
// and Claude wrote its changes into the wrong tree.
|
|
1999
|
+
if (nodeDef.workdir) {
|
|
2000
|
+
const resolved = resolveTemplate(nodeDef.workdir, ctx).trim();
|
|
2001
|
+
// Reject empties + multi-line strings — those are signs of malformed
|
|
2002
|
+
// upstream output (e.g. accidentally including KEY=value lines).
|
|
2003
|
+
if (resolved && !resolved.includes('\n')) {
|
|
2004
|
+
try {
|
|
2005
|
+
const { statSync } = require('node:fs');
|
|
2006
|
+
if (statSync(resolved).isDirectory()) {
|
|
2007
|
+
effectivePath = resolved;
|
|
2008
|
+
} else {
|
|
2009
|
+
console.warn(`[pipeline] workdir resolved to non-directory '${resolved}', keeping ${effectivePath}`);
|
|
2010
|
+
}
|
|
2011
|
+
} catch (e: any) {
|
|
2012
|
+
console.warn(`[pipeline] workdir '${resolved}' not accessible (${e.code || e.message}), keeping ${effectivePath}`);
|
|
2013
|
+
}
|
|
2014
|
+
} else if (resolved) {
|
|
2015
|
+
console.warn(`[pipeline] workdir resolved to a multi-line value — ignoring (upstream likely emitted KEY=value pairs instead of a clean path)`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1246
2019
|
// ── Plugin mode: execute plugin action directly ──
|
|
1247
2020
|
if (nodeDef.mode === 'plugin' && nodeDef.plugin) {
|
|
1248
2021
|
nodeState.status = 'running';
|
|
@@ -1306,6 +2079,13 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
1306
2079
|
prompt: effectivePrompt,
|
|
1307
2080
|
mode: taskMode as any,
|
|
1308
2081
|
agent: nodeDef.agent || undefined,
|
|
2082
|
+
// Pipeline nodes always start a fresh Claude session. Without this,
|
|
2083
|
+
// createTask falls back to getProjectConversationId() which returns
|
|
2084
|
+
// the project's last interactive session id — but pipeline nodes
|
|
2085
|
+
// often run in a per-node worktree where that session file doesn't
|
|
2086
|
+
// exist, producing 'No conversation found with session ID ...' on
|
|
2087
|
+
// first execution.
|
|
2088
|
+
conversationId: '',
|
|
1309
2089
|
});
|
|
1310
2090
|
pipelineTaskIds.add(task.id);
|
|
1311
2091
|
// Pipeline tasks use the same model selection as normal tasks:
|