@aion0/forge 0.8.4 → 0.8.6

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/lib/pipeline.ts CHANGED
@@ -252,615 +252,6 @@ nodes:
252
252
  outputs:
253
253
  - name: result
254
254
  extract: stdout
255
- `,
256
- 'mantis-bug-fix-and-mr': `
257
- name: mantis-bug-fix-and-mr
258
- description: "Fetch Mantis bug context → worktree → fix code via headless Claude → push branch → open GitLab MR via glab CLI → notify assignee + reporter on Teams."
259
- input:
260
- bug_id: "Mantis bug id (number)"
261
- project: "Forge project name"
262
- 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."
263
- summary: "Bug summary (one line)"
264
- description: "Full bug description (free text)"
265
- priority: "Bug priority (optional)"
266
- category: "Bug category (optional)"
267
- reporter: "Mantis reporter username — used to notify them on Teams"
268
- assignee: "Mantis assignee username — used to notify them on Teams"
269
- extra_context: "Extra hints for Claude (optional)"
270
- mr_title_template: "MR title template. Vars: {bug_id} {summary}. Default: 'Fix Mantis #{bug_id}: {summary}'"
271
- mr_body_template: "MR body. Vars: {bug_id} {summary} {description} {claude_summary}. Default closes-reference + Claude summary."
272
- teams_message_template: "Teams DM template. Vars: {bug_id} {summary} {mr_url} {role}. Default: '🤖 Mantis #{bug_id} fixed — review MR: {mr_url}'"
273
- nodes:
274
- resolve:
275
- mode: shell
276
- project: "{{input.project}}"
277
- worktree: false
278
- prompt: |
279
- set -e
280
- cd "$(git rev-parse --show-toplevel)"
281
- command -v glab >/dev/null || { echo "ERROR: glab CLI not installed. brew install glab && glab auth login" >&2; exit 1; }
282
- BUG_ID="{{input.bug_id}}"
283
- BASE="{{input.base_branch}}"
284
- [ -z "$BUG_ID" ] && { echo "ERROR: bug_id is required" >&2; exit 1; }
285
- [ -z "$BASE" ] && { echo "ERROR: base_branch is required (set via Job input_template)" >&2; exit 1; }
286
- REMOTE=$(git remote get-url origin)
287
- RAW=$(echo "$REMOTE" | sed -E 's#^(https?://[^/]+/|git@[^:]+:)##; s#\\.git$##')
288
- HOST=$(echo "$REMOTE" | sed -E 's#^https?://##; s#git@##; s#[:/].*##')
289
- PROJECT_PATH="$RAW"
290
- echo "HOST=$HOST"
291
- echo "PROJECT_PATH=$PROJECT_PATH"
292
- echo "BUG_ID=$BUG_ID"
293
- echo "BASE=$BASE"
294
- git fetch origin "$BASE" --quiet 2>/dev/null || true
295
- outputs:
296
- - name: info
297
- extract: stdout
298
- worktree-setup:
299
- mode: shell
300
- project: "{{input.project}}"
301
- worktree: false
302
- depends_on: [resolve]
303
- prompt: |
304
- set -e
305
- INFO=$'{{nodes.resolve.outputs.info}}'
306
- eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
307
- # worktree: false above keeps us in the project root, so this is fine.
308
- # Falls back to FORGE_PROJECT_ROOT just in case auto-worktree gets
309
- # re-enabled in the future.
310
- ROOT="\${FORGE_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
311
- cd "$ROOT"
312
- WORKTREE_DIR="$ROOT/.forge/worktrees/mantis-$BUG_ID"
313
- BRANCH="fix/mantis-\${BUG_ID}"
314
-
315
- # ALL the noisy git operations route to stderr — downstream nodes
316
- # consume \`wt\` via \`eval "$(echo … | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"\` inside
317
- # \`$'…'\` quoting. ANY apostrophe / paren / space-ID-paren in the
318
- # captured stdout (e.g. "Deleted branch fix/X (was abc1234).",
319
- # "Preparing worktree (resetting branch 'X')") breaks bash with
320
- # "syntax error near unexpected token '('".
321
- {
322
- # 1. Remove the on-disk worktree for THIS bug (idempotent).
323
- if [ -d "$WORKTREE_DIR" ]; then
324
- git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || rm -rf "$WORKTREE_DIR"
325
- fi
326
- # 2. Drop stale worktree registrations + any other worktree pinning
327
- # our branch. Without (3), "fatal: <branch> already used by
328
- # worktree at <path>" can fire on the next add.
329
- git worktree prune
330
- git worktree list --porcelain | awk -v b="refs/heads/$BRANCH" '
331
- /^worktree /{w=$2}
332
- $0=="branch "b{print w}
333
- ' | while read w; do
334
- [ -n "$w" ] && git worktree remove --force "$w" 2>/dev/null || true
335
- done
336
- git branch -D "$BRANCH" 2>/dev/null || true
337
- mkdir -p "$ROOT/.forge/worktrees"
338
- # -B (capital) = create-or-reset; --force lets us reuse an existing dir.
339
- git worktree add --force -B "$BRANCH" "$WORKTREE_DIR" "origin/$BASE"
340
- } 1>&2
341
-
342
- # IMPORTANT: emit ONLY the worktree path on stdout — nothing else.
343
- # fix-code uses \`workdir: {{nodes.worktree-setup.outputs.wt}}\` and the
344
- # task runner needs a clean path. If we echo'd "WORKTREE=…\\nBRANCH=…"
345
- # the multi-line string became an invalid workdir → Claude silently
346
- # ran in the pipeline's parent worktree, made its changes there, and
347
- # nothing showed up in the inner branch when push-and-mr cd'd in.
348
- # Downstream nodes get the branch by asking git from inside the
349
- # worktree (git symbolic-ref --short HEAD) — no out-of-band passing.
350
- echo "$WORKTREE_DIR"
351
- outputs:
352
- - name: wt
353
- extract: stdout
354
- fetch-bug-details:
355
- mode: shell
356
- project: "{{input.project}}"
357
- worktree: false
358
- depends_on: [resolve]
359
- prompt: |
360
- # Pull the FULL mantis bug — summary, description, additional_information,
361
- # reproducibility, all notes with author/date, history — via Forge's
362
- # loopback connector-tool endpoint. search_bugs (when used as the Job
363
- # source) only returns list-page columns; this node fills the gap so
364
- # fix-code has every field it could possibly want.
365
- set -e
366
- INFO=$'{{nodes.resolve.outputs.info}}'
367
- eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
368
- PAYLOAD="{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"get_bug\\",\\"input\\":{\\"id\\":$BUG_ID}}"
369
- RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
370
- -H 'content-type: application/json' --data "$PAYLOAD")
371
- IS_ERR=$(echo "$RESP" | jq -r '.is_error // false')
372
- if [ "$IS_ERR" = "true" ]; then
373
- echo "WARN: mantis.get_bug failed: $(echo "$RESP" | jq -r '.content // .error // "(unknown)"' | head -c 300)" >&2
374
- echo "BUG_ID=$BUG_ID"
375
- echo "BUG_JSON_B64="
376
- echo "SUMMARY="
377
- echo "CATEGORY="
378
- echo "DESCRIPTION_B64="
379
- echo "ADDITIONAL_INFO_B64="
380
- echo "NOTES_B64="
381
- exit 0
382
- fi
383
- # .content is itself a JSON string (the bug object); decode once.
384
- BUG=$(echo "$RESP" | jq -r '.content' | jq -c '.')
385
-
386
- # Whole bug as base64 — gives fix-code an escape hatch to read any
387
- # field we didn't bother to enumerate (history, _fields_raw, etc).
388
- BUG_JSON_B64=$(echo -n "$BUG" | base64 | tr -d '\\n')
389
-
390
- # Convenience extracts. Multi-line fields (description, notes,
391
- # additional_information) → base64'd so newlines / quotes / shell
392
- # metachars survive transport. Single-line stays plain for the
393
- # KEY=value eval pattern downstream nodes already use.
394
- SUMMARY=$(echo "$BUG" | jq -r '.summary // ""' | head -c 500 | tr -d '\\n')
395
- CAT=$(echo "$BUG" | jq -r '.category // ""' | tr -d '\\n')
396
- REPRO=$(echo "$BUG" | jq -r '.reproducibility // ""' | tr -d '\\n')
397
- DESC_B64=$(echo "$BUG" | jq -r '.description // ""' | base64 | tr -d '\\n')
398
- ADDL_B64=$(echo "$BUG" | jq -r '.additional_information // ""' | base64 | tr -d '\\n')
399
- # Notes with author + date + body, separated by ─── for readability.
400
- NOTES_B64=$(echo "$BUG" | jq -r '[.notes[]? | "[\\(.author // "?") @ \\(.date // "?")]\\n\\(.body // "")"] | join("\\n\\n─────────────\\n\\n")' 2>/dev/null | base64 | tr -d '\\n')
401
-
402
- echo "BUG_ID=$BUG_ID"
403
- echo "SUMMARY=$SUMMARY"
404
- echo "CATEGORY=$CAT"
405
- echo "REPRODUCIBILITY=$REPRO"
406
- echo "DESCRIPTION_B64=$DESC_B64"
407
- echo "ADDITIONAL_INFO_B64=$ADDL_B64"
408
- echo "NOTES_B64=$NOTES_B64"
409
- echo "BUG_JSON_B64=$BUG_JSON_B64"
410
-
411
- # ── Human-readable preview to stderr ──
412
- # Goes into the task log so the user can see what we actually got
413
- # from Mantis without having to manually base64 -d. Truncated at
414
- # 800 chars per field to keep the log tractable. Stdout above keeps
415
- # the clean KEY=value pairs downstream nodes parse.
416
- DESC_PLAIN=$(echo "$BUG" | jq -r '.description // ""')
417
- ADDL_PLAIN=$(echo "$BUG" | jq -r '.additional_information // ""')
418
- NOTES_COUNT=$(echo "$BUG" | jq -r '.notes | length // 0')
419
- NOTES_FIRST=$(echo "$BUG" | jq -r '.notes[0]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
420
- NOTES_LAST=$(echo "$BUG" | jq -r '.notes[-1]? | "[\\(.author // "?") @ \\(.date // "?")] \\(.body // "")"' 2>/dev/null | head -c 400)
421
- DESC_LEN=$(printf %s "$DESC_PLAIN" | wc -c | tr -d ' ')
422
- ADDL_LEN=$(printf %s "$ADDL_PLAIN" | wc -c | tr -d ' ')
423
- {
424
- echo ""
425
- echo "─── fetch-bug-details — what we got from Mantis #$BUG_ID ───"
426
- echo "Summary: $SUMMARY"
427
- echo "Category: $CAT"
428
- echo "Reproducibility:$REPRO"
429
- echo ""
430
- echo "Description (\${DESC_LEN} chars, showing first 800):"
431
- printf %s "$DESC_PLAIN" | head -c 800
432
- [ "$DESC_LEN" -gt 800 ] && echo "… [truncated]"
433
- echo ""
434
- echo ""
435
- echo "Additional Information (\${ADDL_LEN} chars, showing first 800):"
436
- printf %s "$ADDL_PLAIN" | head -c 800
437
- [ "$ADDL_LEN" -gt 800 ] && echo "… [truncated]"
438
- echo ""
439
- echo ""
440
- echo "Notes: $NOTES_COUNT total"
441
- if [ "$NOTES_COUNT" -gt 0 ]; then
442
- echo " First: $NOTES_FIRST"
443
- [ "$NOTES_COUNT" -gt 1 ] && echo " Last: $NOTES_LAST"
444
- fi
445
- echo "──────────────────────────────────────────────────────"
446
- } >&2
447
- outputs:
448
- - name: details
449
- extract: stdout
450
- download-attachments:
451
- mode: shell
452
- project: "{{input.project}}"
453
- worktree: false
454
- depends_on: [resolve, worktree-setup]
455
- prompt: |
456
- # Download all Attached Files from the Mantis bug into the worktree's
457
- # .attachments/ directory so fix-code (Claude with vision) can read
458
- # screenshots, logs, and any other supporting material. Skips files
459
- # larger than 5 MB (see download_attachment's max_size_bytes default).
460
- set -e
461
- INFO=$'{{nodes.resolve.outputs.info}}'
462
- eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
463
- WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
464
- WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
465
- ATTACH_DIR="$WORKTREE_DIR/.attachments"
466
- mkdir -p "$ATTACH_DIR"
467
-
468
- # 1. List attachments.
469
- LIST=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
470
- -H 'content-type: application/json' \\
471
- --data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"list_attachments\\",\\"input\\":{\\"id\\":$BUG_ID,\\"bug_id\\":$BUG_ID}}" )
472
- IS_ERR=$(echo "$LIST" | jq -r '.is_error // false')
473
- if [ "$IS_ERR" = "true" ]; then
474
- echo "ATTACH_COUNT=0"
475
- echo "ATTACH_DIR=$ATTACH_DIR"
476
- echo "WARN: list_attachments failed: $(echo "$LIST" | jq -r '.content' | head -c 200)" >&2
477
- exit 0
478
- fi
479
- ATTACHMENTS=$(echo "$LIST" | jq -r '.content' | jq -c '.attachments // []')
480
- TOTAL=$(echo "$ATTACHMENTS" | jq 'length')
481
- echo "Found $TOTAL attachment(s) on bug $BUG_ID"
482
-
483
- COUNT=0
484
- SKIPPED=0
485
- # 2. Iterate + download each. jq stream the ids/filenames.
486
- echo "$ATTACHMENTS" | jq -c '.[]' | while read -r ATT; do
487
- FID=$(echo "$ATT" | jq -r '.file_id')
488
- FN=$(echo "$ATT" | jq -r '.filename')
489
- SZ=$(echo "$ATT" | jq -r '.size')
490
- # Sanitize filename to avoid path traversal.
491
- SAFE=$(echo "$FN" | tr -d '\\\\/:*?"<>|' | head -c 200)
492
- [ -z "$SAFE" ] && SAFE="file-$FID"
493
- echo " → $SAFE ($SZ bytes)"
494
- RESP=$(curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
495
- -H 'content-type: application/json' \\
496
- --data "{\\"plugin_id\\":\\"mantis\\",\\"tool\\":\\"download_attachment\\",\\"input\\":{\\"file_id\\":$FID}}")
497
- if [ "$(echo "$RESP" | jq -r '.is_error // false')" = "true" ]; then
498
- echo " WARN: download failed for $SAFE" >&2
499
- continue
500
- fi
501
- BODY=$(echo "$RESP" | jq -r '.content')
502
- SKIP=$(echo "$BODY" | jq -r '.skipped // false')
503
- if [ "$SKIP" = "true" ]; then
504
- REASON=$(echo "$BODY" | jq -r '.reason // ""')
505
- echo " SKIPPED $SAFE — $REASON" >&2
506
- SKIPPED=$((SKIPPED+1))
507
- continue
508
- fi
509
- B64=$(echo "$BODY" | jq -r '.content_b64')
510
- [ -z "$B64" ] && { echo " WARN: empty content_b64 for $SAFE" >&2; continue; }
511
- echo "$B64" | base64 -d > "$ATTACH_DIR/$SAFE"
512
- COUNT=$((COUNT+1))
513
- done
514
-
515
- echo "ATTACH_COUNT=$COUNT"
516
- echo "ATTACH_SKIPPED=$SKIPPED"
517
- echo "ATTACH_DIR=$ATTACH_DIR"
518
-
519
- # Human-readable directory listing into stderr so the log shows
520
- # what's actually on disk for the next node.
521
- {
522
- echo ""
523
- echo "─── download-attachments — files in $ATTACH_DIR ───"
524
- if [ -d "$ATTACH_DIR" ] && [ -n "$(ls -A "$ATTACH_DIR" 2>/dev/null)" ]; then
525
- ls -lhS "$ATTACH_DIR" 2>/dev/null | tail -n +2
526
- else
527
- echo "(no files)"
528
- fi
529
- echo "──────────────────────────────────────────────────────"
530
- } >&2
531
- outputs:
532
- - name: attach
533
- extract: stdout
534
- fix-code:
535
- project: "{{input.project}}"
536
- worktree: false
537
- depends_on: [worktree-setup, fetch-bug-details, download-attachments]
538
- workdir: "{{nodes.worktree-setup.outputs.wt}}"
539
- prompt: |
540
- A Mantis bug needs to be fixed in this worktree (already checked out
541
- from the target base branch). You are running headless — make the fix
542
- yourself, stage + commit.
543
-
544
- ## Bug
545
- ID: {{input.bug_id}}
546
- Priority: {{input.priority}}
547
- Category (from get_bug, full): see DETAILS below
548
- Assignee: {{input.assignee}}
549
- Reporter: {{input.reporter}}
550
-
551
- ## Summary
552
- {{input.summary}}
553
-
554
- ## Full bug details (fetched via mantis.get_bug)
555
- The fetch-bug-details node ran \`mantis.get_bug\` and exported these
556
- key=value lines. _B64 fields are base64-encoded multi-line strings:
557
-
558
- {{nodes.fetch-bug-details.outputs.details}}
559
-
560
- Decode any _B64 field with:
561
- \`echo "<value>" | base64 -d\`
562
-
563
- Available fields (READ ALL of them before deciding on a fix):
564
- SUMMARY one-line title (plain)
565
- CATEGORY Mantis category (plain)
566
- REPRODUCIBILITY always / sometimes / random / N/A (plain)
567
- DESCRIPTION_B64 full bug description (base64; often the most
568
- important field — repro steps live here)
569
- ADDITIONAL_INFO_B64 "Additional Information" custom field —
570
- often holds stack traces, env details, log
571
- snippets. Decode + read.
572
- NOTES_B64 all comments concatenated, with author + date
573
- headers ([author @ date]). Use to see what
574
- QA + reporter already discussed.
575
- BUG_JSON_B64 escape hatch — the whole bug object as JSON
576
- (decoded, this has every field including
577
- history[] and _fields_raw if you need it).
578
-
579
- ## Description (search_bugs-supplied, may be empty)
580
- {{input.description}}
581
-
582
- ## Attached Files
583
- {{nodes.download-attachments.outputs.attach}}
584
-
585
- Files (if any) were saved under \`.attachments/\` in this worktree.
586
- List them with \`ls -la .attachments/\`. For images, USE THE READ
587
- TOOL on the file — your vision can analyze screenshots / error
588
- dialogs / network diagrams directly. For text-like attachments
589
- (log snippets, .txt, .json, small .pcap text exports) just open
590
- them. Files larger than 5 MB were skipped — if a critical one is
591
- missing, mention it in your fix notes; don't try to re-fetch.
592
-
593
- ## Extra context
594
- {{input.extra_context}}
595
-
596
- ## HARD RULES — read before tool use
597
- - **Never call mcp__* tools.** Pipeline tasks do not have interactive
598
- auth; any MCP call that needs SSO/TOTP (mantis_auth, gitlab, pmdb,
599
- …) will hang and fail the pipeline. The bug context above is the
600
- only Mantis data you get.
601
- - If a field above is empty or truncated, **work with what is given**.
602
- Do NOT try to fetch more from Mantis. The pipeline owns Mantis access
603
- upstream — re-running mantis lookup here is wasted effort and breaks
604
- the design contract (Job → Pipeline data flow).
605
- - If you ABSOLUTELY need supplemental data from another Forge connector
606
- (rare), use the Forge HTTP API instead of MCP. POST
607
- http://127.0.0.1:8403/api/connector-tool with JSON body
608
- {"plugin_id":"<plugin>","tool":"<tool>","input":{...}}
609
-
610
- ## Steps
611
- 1. Read the bug carefully — identify the affected component.
612
- 2. Find the relevant file(s) via git grep + reading the code.
613
- 3. Implement a minimal fix.
614
- 4. Add tests if the codebase has a test suite.
615
- 5. Stage + commit with message: 'Fix Mantis #{{input.bug_id}}: <one-line>'.
616
- **DO NOT add a "Co-Authored-By: Claude …" or "Generated with Claude
617
- Code" trailer.** Plain commit message only — the MR is going into a
618
- corporate repo and the AI co-authorship attribution is not wanted.
619
-
620
- Do NOT push — the next pipeline node handles push + MR.
621
- outputs:
622
- - name: summary
623
- extract: result
624
- - name: diff
625
- extract: git_diff
626
- push-and-mr:
627
- mode: shell
628
- project: "{{input.project}}"
629
- worktree: false
630
- workdir: "{{nodes.worktree-setup.outputs.wt}}"
631
- depends_on: [fix-code]
632
- prompt: |
633
- set -e
634
- INFO=$'{{nodes.resolve.outputs.info}}'
635
- eval "$(echo "$INFO" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
636
- # workdir already cd'd us into the mantis worktree. Derive BRANCH.
637
- BRANCH=$(git symbolic-ref --short HEAD)
638
- # Bail if Claude didn't actually commit anything
639
- AHEAD=$(git rev-list --count "origin/$BASE..HEAD" 2>/dev/null || echo 0)
640
- if [ "$AHEAD" -lt 1 ]; then
641
- echo "NO_CHANGES — Claude did not commit; aborting MR creation"
642
- echo "MR_URL="
643
- exit 0
644
- fi
645
- git push -u origin "$BRANCH" --force-with-lease 2>&1
646
- # Heredoc keeps Claude's summary intact (apostrophes, parens, $, …).
647
- SUMMARY=$(cat <<'FORGE_SUMMARY_EOF'
648
- {{nodes.fix-code.outputs.summary}}
649
- FORGE_SUMMARY_EOF
650
- )
651
- # ALL pipeline-templated values must enter via \`$'…'\` ANSI-C quoting,
652
- # NOT plain "…". The pipeline engine ANSI-C-escapes substituted values
653
- # (\\n / \\t / \\' etc.); inside "…" bash treats those as literal text
654
- # and the MR description ends up showing "\\n" instead of real
655
- # newlines, breaking the markdown.
656
- TITLE_TPL=$'{{input.mr_title_template}}'
657
- [ -z "$TITLE_TPL" ] && TITLE_TPL="Fix Mantis #{bug_id}: {summary}"
658
- BODY_TPL=$'{{input.mr_body_template}}'
659
- [ -z "$BODY_TPL" ] && BODY_TPL="Auto-fix for Mantis bug #{bug_id}.
660
-
661
- ## Summary
662
- {summary}
663
-
664
- ## Original description
665
- {description}
666
-
667
- ## Claude's fix notes
668
- {claude_summary}
669
-
670
- ## Files changed
671
- {diff_stat}
672
-
673
- _Opened by Forge mantis-bug-fix-and-mr pipeline._"
674
- BUG_ID_VAR=$'{{input.bug_id}}'
675
- SUMMARY_VAR=$'{{input.summary}}'
676
- DESCRIPTION_VAR=$'{{input.description}}'
677
- # Compact list of files touched. --stat gives "N files changed, X+/Y-"
678
- # which is small but tells the reviewer what's in the MR at a glance.
679
- DIFF_STAT=$(git diff --stat "origin/$BASE..HEAD" 2>/dev/null || echo "")
680
- MR_TITLE=$(python3 - "$TITLE_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" <<'PY'
681
- import sys
682
- tpl, bug_id, summary = sys.argv[1], sys.argv[2], sys.argv[3]
683
- print(tpl.replace('{bug_id}', bug_id).replace('{summary}', summary))
684
- PY
685
- )
686
- MR_BODY=$(python3 - "$BODY_TPL" "$BUG_ID_VAR" "$SUMMARY_VAR" "$DESCRIPTION_VAR" "$SUMMARY" "$DIFF_STAT" <<'PY'
687
- import sys
688
- tpl, bug_id, summary, description, claude, diff_stat = sys.argv[1:7]
689
- out = (tpl.replace('{bug_id}', bug_id)
690
- .replace('{summary}', summary)
691
- .replace('{description}', description)
692
- .replace('{claude_summary}', claude)
693
- .replace('{diff_stat}', diff_stat))
694
- print(out)
695
- PY
696
- )
697
- # Force glab to use the Forge-managed GitLab PAT. Forge injects
698
- # GITLAB_TOKEN from the gitlab connector's config; without
699
- # rewriting it here, glab can stick to its stale per-host token
700
- # in ~/.config/glab-cli/config.yml and 401 with "Token was
701
- # revoked" — even when the env is set. glab auth login --token
702
- # is idempotent and writes the right host config in 100 ms.
703
- if [ -n "$GITLAB_TOKEN" ]; then
704
- REMOTE_HOST_FOR_AUTH=$(git -C "$PROJECT_PATH" config --get remote.origin.url 2>/dev/null \\
705
- | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
706
- if [ -n "$REMOTE_HOST_FOR_AUTH" ]; then
707
- echo "Refreshing glab auth for $REMOTE_HOST_FOR_AUTH from Forge connector token"
708
- echo "$GITLAB_TOKEN" | glab auth login --hostname "$REMOTE_HOST_FOR_AUTH" --token-stdin >/dev/null 2>&1 || \\
709
- echo "(glab auth login refresh failed — falling through; will likely 401)" >&2
710
- fi
711
- fi
712
-
713
- # Bug assignee → MR reviewer. Mantis assignee comes as "Jane Doe (jdoe)";
714
- # extract the (username) and pass to glab. If parens absent or empty,
715
- # skip the flag — glab errors on --reviewer "" .
716
- ASSIGNEE_RAW=$'{{input.assignee}}'
717
- REVIEWER_USERNAME=$(echo "$ASSIGNEE_RAW" | grep -oE '\\(([a-zA-Z0-9._-]+)\\)' | tail -1 | tr -d '()')
718
- REVIEWER_FLAGS=()
719
- if [ -n "$REVIEWER_USERNAME" ]; then
720
- REVIEWER_FLAGS+=(--reviewer "$REVIEWER_USERNAME")
721
- echo "Setting MR reviewer: $REVIEWER_USERNAME (from assignee '$ASSIGNEE_RAW')"
722
- else
723
- echo "No (username) found in assignee '$ASSIGNEE_RAW' — skipping --reviewer"
724
- fi
725
- # Capture glab output AND exit code so we can debug failures. Old
726
- # pipeline that just grep'd for the URL ate everything if the
727
- # regex didn't match — leaving MR_URL empty with no clue why
728
- # (auth refresh needed, reviewer username wrong, MR already exists
729
- # at a non-canonical URL, etc).
730
- set +e
731
- GLAB_OUT=$(glab mr create -R "$PROJECT_PATH" --target-branch "$BASE" --source-branch "$BRANCH" \\
732
- --title "$MR_TITLE" --description "$MR_BODY" \\
733
- "\${REVIEWER_FLAGS[@]}" \\
734
- --yes 2>&1)
735
- GLAB_RC=$?
736
- set -e
737
- # Surface glab's output to the task log (truncated) so a failed
738
- # retry doesn't disappear into the void.
739
- echo "--- glab mr create (rc=$GLAB_RC) ---"
740
- echo "$GLAB_OUT" | head -50
741
- echo "--- end glab output ---"
742
-
743
- # Common, very actionable failure: corp GitLab revoked the
744
- # token. Forge already pushes the connector PAT here via
745
- # GITLAB_TOKEN + a glab auth login --token-stdin refresh
746
- # earlier in this step, so the only way to land here is if the
747
- # connector PAT itself is wrong/revoked. Point the user at
748
- # Settings, not at glab auth login.
749
- if echo "$GLAB_OUT" | grep -qE '401|invalid_token|Token was revoked|unauthorized'; then
750
- REMOTE_HOST=$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
751
- echo "ERROR: GitLab rejected the token for \${REMOTE_HOST:-this GitLab server}." >&2
752
- echo "Fix: open Forge → Settings → Skills → Connectors tab → GitLab, paste a fresh Personal Access Token, click Save + Test." >&2
753
- echo "Then retry this node from the pipeline UI — Forge will push the new token to glab automatically." >&2
754
- fi
755
-
756
- MR_URL=$(echo "$GLAB_OUT" | grep -oE 'https://[^[:space:]]+/-/merge_requests/[0-9]+' | head -1)
757
-
758
- # If glab succeeded but we didn't parse a URL (output format
759
- # variant), OR if the MR already existed (glab errors on conflict),
760
- # fall back to looking up the MR by source branch.
761
- if [ -z "$MR_URL" ]; then
762
- echo "Fallback: looking up existing MR for branch $BRANCH"
763
- MR_URL=$(glab mr view "$BRANCH" -R "$PROJECT_PATH" --output json 2>/dev/null | jq -r '.web_url // empty')
764
- if [ -z "$MR_URL" ]; then
765
- # Last resort: glab mr list with our source branch.
766
- MR_URL=$(glab mr list -R "$PROJECT_PATH" --source-branch "$BRANCH" --output json 2>/dev/null \\
767
- | jq -r '.[0].web_url // empty')
768
- fi
769
- fi
770
-
771
- if [ -z "$MR_URL" ]; then
772
- echo "ERROR: could not create OR find MR for $BRANCH. See glab output above." >&2
773
- # Don't fail the node hard — push succeeded, the user can create
774
- # the MR manually via the GitLab "create MR" hint in the push
775
- # output. notify-teams will see empty MR_URL and skip.
776
- fi
777
-
778
- echo "MR_URL=$MR_URL"
779
- echo "REVIEWER=$REVIEWER_USERNAME"
780
- outputs:
781
- - name: mr
782
- extract: stdout
783
- notify-teams:
784
- mode: shell
785
- project: "{{input.project}}"
786
- worktree: false
787
- depends_on: [push-and-mr]
788
- prompt: |
789
- set -e
790
- MR_OUT=$'{{nodes.push-and-mr.outputs.mr}}'
791
- eval "$(echo "$MR_OUT" | grep -E '^[A-Za-z_][A-Za-z0-9_]*=' | sed 's/^/export /')"
792
- if [ -z "$MR_URL" ]; then
793
- echo "SKIP — no MR URL (Claude likely made no changes)"
794
- exit 0
795
- fi
796
- MSG_TPL="{{input.teams_message_template}}"
797
- [ -z "$MSG_TPL" ] && MSG_TPL="🤖 Mantis #{bug_id} fixed — please review MR: {mr_url}"$'\\n'"Bug: {summary}"
798
- # Forge exposes /api/connector-tool on loopback (no auth) — pipelines
799
- # call connector tools through it. teams.send_message takes name (fuzzy
800
- # match against any chat the user is in) + text.
801
- send_to() {
802
- local who="$1"
803
- local role="$2"
804
- if [ -z "$who" ]; then echo "SKIP — no $role name"; return 0; fi
805
- # Render template + build JSON payload in one Python invocation so we
806
- # never have to worry about shell quoting of summary / description /
807
- # MR URL when they contain |, $, backticks, etc.
808
- local payload
809
- payload=$(python3 - "$MSG_TPL" "{{input.bug_id}}" "{{input.summary}}" "$role" "$MR_URL" "$who" <<'PY'
810
- import json, sys
811
- tpl, bug_id, summary, role, mr_url, who = sys.argv[1:7]
812
- text = (tpl.replace('{bug_id}', bug_id)
813
- .replace('{summary}', summary)
814
- .replace('{role}', role)
815
- .replace('{mr_url}', mr_url))
816
- print(json.dumps({'plugin_id': 'teams', 'tool': 'send_message',
817
- 'input': {'name': who, 'text': text}}))
818
- PY
819
- )
820
- echo "Sending to Teams chat '$who' ($role)…"
821
- curl -sS -X POST http://127.0.0.1:8403/api/connector-tool \\
822
- -H 'content-type: application/json' \\
823
- --data-binary "$payload" \\
824
- | jq .
825
- }
826
- send_to "{{input.assignee}}" "assignee"
827
- send_to "{{input.reporter}}" "reporter"
828
- echo "DONE — MR: $MR_URL"
829
- outputs:
830
- - name: result
831
- extract: stdout
832
- cleanup:
833
- mode: shell
834
- project: "{{input.project}}"
835
- worktree: false
836
- depends_on: [notify-teams]
837
- prompt: |
838
- # Best-effort cleanup so the project working tree doesn't pile up
839
- # one full checkout per fixed bug. Runs only after notify-teams
840
- # succeeded — if anything upstream failed, the worktree is kept
841
- # for inspection. The remote branch + MR still live on GitLab.
842
- set +e
843
- WORKTREE_DIR=$'{{nodes.worktree-setup.outputs.wt}}'
844
- WORKTREE_DIR=$(echo "$WORKTREE_DIR" | head -1 | tr -d '[:space:]')
845
- # Derive BRANCH from inside the worktree before we delete it.
846
- BRANCH=$(git -C "$WORKTREE_DIR" symbolic-ref --short HEAD 2>/dev/null)
847
- cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
848
- ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
849
- [ -z "$ROOT" ] && { echo "SKIP — not in a git repo"; exit 0; }
850
- # Paranoia — only clean under our .forge/worktrees/.
851
- case "$WORKTREE_DIR" in
852
- "$ROOT/.forge/worktrees/"*) : ;;
853
- *) echo "SKIP — WORKTREE_DIR=$WORKTREE_DIR not under .forge/worktrees/"; exit 0 ;;
854
- esac
855
- echo "removing worktree $WORKTREE_DIR …"
856
- git worktree remove --force "$WORKTREE_DIR" 2>&1 || rm -rf "$WORKTREE_DIR"
857
- git worktree prune
858
- # Drop the local branch too — remote copy is on GitLab + the MR.
859
- [ -n "$BRANCH" ] && git branch -D "$BRANCH" 2>/dev/null && echo "deleted local branch $BRANCH"
860
- echo "cleanup done"
861
- outputs:
862
- - name: result
863
- extract: stdout
864
255
  `,
865
256
  'multi-agent-collaboration': `
866
257
  name: multi-agent-collaboration
@@ -2070,7 +1461,11 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
2070
1461
  // Shell steps receive env vars: FORGE_WORKTREE, FORGE_WORKTREE_BRANCH, FORGE_PROJECT_ROOT.
2071
1462
  // Set worktree: false on a node to skip (e.g. read-only gh commands that don't need isolation).
2072
1463
  let effectivePath = projectInfo.path;
2073
- const useWorktree = nodeDef.worktree !== false;
1464
+ // `workdir:` always wins (sets effectivePath at the bottom). If it's
1465
+ // declared, skip the auto-worktree creation entirely — otherwise we
1466
+ // produce dead `.forge/worktrees/pipeline-<id>` directories that
1467
+ // nothing ever uses but pile up on disk per pipeline run.
1468
+ const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir;
2074
1469
  const branchName = nodeDef.branch ? resolveTemplate(nodeDef.branch, ctx) : `pipeline/${pipeline.id.slice(0, 8)}`;
2075
1470
  if (useWorktree) try {
2076
1471
  const { execSync } = require('node:child_process');
package/lib/settings.ts CHANGED
@@ -71,6 +71,11 @@ export interface Settings {
71
71
  * (offline only). Default: `aiwatching/forge-connectors`.
72
72
  */
73
73
  connectorsRepoUrl: string;
74
+ /**
75
+ * Remote registry for workflow templates (recipes + pipelines). Same
76
+ * shape as connectorsRepoUrl. Default: `aiwatching/forge-workflow`.
77
+ */
78
+ workflowRepoUrl: string;
74
79
  displayName: string;
75
80
  displayEmail: string;
76
81
  favoriteProjects: string[];
@@ -126,6 +131,7 @@ const defaults: Settings = {
126
131
  notificationRetentionDays: 30,
127
132
  skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
128
133
  connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
134
+ workflowRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-workflow/main',
129
135
  displayName: 'Forge',
130
136
  displayEmail: '',
131
137
  favoriteProjects: [],