bosun 0.35.2 → 0.35.3

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.
@@ -299,12 +299,17 @@ export const PR_CONFLICT_RESOLVER_TEMPLATE = {
299
299
  id: "template-pr-conflict-resolver",
300
300
  name: "PR Conflict Resolver",
301
301
  description:
302
- "Detects PRs with merge conflicts or failing CI and automatically " +
303
- "resolves them rebases, fixes conflicts, re-runs CI, and auto-merges " +
304
- "when green.",
302
+ "⚠️ SUPERSEDED for bosun-managed repos use the Bosun PR Watchdog " +
303
+ "(template-bosun-pr-watchdog) instead. The Watchdog consolidates conflict " +
304
+ "resolution, CI-failure repair, diff-safety review, and merge into one " +
305
+ "cycle with a single gh API call and a mandatory review gate before any merge. " +
306
+ "This template is kept for repos that do not use the bosun-attached label " +
307
+ "convention. It ONLY touches PRs labelled bosun-attached and never " +
308
+ "auto-merges directly — it resolves conflicts and then defers to the " +
309
+ "Watchdog's review gate for the actual merge decision.",
305
310
  category: "github",
306
- enabled: true,
307
- recommended: true,
311
+ enabled: false,
312
+ recommended: false,
308
313
  trigger: "trigger.schedule",
309
314
  variables: {
310
315
  checkIntervalMs: 1800000,
@@ -317,113 +322,138 @@ export const PR_CONFLICT_RESOLVER_TEMPLATE = {
317
322
  cron: "*/30 * * * *",
318
323
  }, { x: 400, y: 50 }),
319
324
 
320
- node("list-prs", "action.run_command", "List Open PRs", {
321
- command: "gh pr list --json number,title,headRefName,mergeable,statusCheckRollup --limit 20",
325
+ // Only fetch bosun-attached PRs never touch external-contributor PRs.
326
+ // Includes labels so we can skip PRs already tagged bosun-needs-fix (watchdog owns those).
327
+ node("list-prs", "action.run_command", "List Bosun-Attached Conflicting PRs", {
328
+ command:
329
+ "gh pr list --label bosun-attached --state open " +
330
+ "--json number,title,headRefName,baseRefName,mergeable,labels --limit 20",
322
331
  }, { x: 400, y: 180 }),
323
332
 
324
333
  node("target-pr", "action.set_variable", "Pick Conflict PR", {
325
334
  key: "targetPrNumber",
326
335
  value:
327
- "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const conflict = prs.find((pr) => ['CONFLICTING', 'BEHIND'].includes(String(pr?.mergeable || '').toUpperCase())); return conflict?.number ? String(conflict.number) : ''; })()",
336
+ "(() => {" +
337
+ " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" +
338
+ " let prs = [];" +
339
+ " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; }" +
340
+ " if (!Array.isArray(prs)) return '';" +
341
+ " const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']);" +
342
+ " // Skip PRs already owned by the watchdog fix agent" +
343
+ " const pr = prs.find((p) =>" +
344
+ " CONFLICT.has(String(p?.mergeable || '').toUpperCase()) &&" +
345
+ " !(p.labels || []).some((l) => l.name === 'bosun-needs-fix')" +
346
+ " );" +
347
+ " return pr?.number ? String(pr.number) : '';" +
348
+ "})()",
328
349
  isExpression: true,
329
350
  }, { x: 400, y: 260 }),
330
351
 
331
352
  node("target-branch", "action.set_variable", "Capture Conflict Branch", {
332
353
  key: "targetPrBranch",
333
354
  value:
334
- "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const conflict = prs.find((pr) => String(pr?.number || '') === String($data?.targetPrNumber || '')); return conflict?.headRefName || ''; })()",
355
+ "(() => {" +
356
+ " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" +
357
+ " let prs = [];" +
358
+ " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; }" +
359
+ " if (!Array.isArray(prs)) return '';" +
360
+ " const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || ''));" +
361
+ " return pr?.headRefName || '';" +
362
+ "})()",
335
363
  isExpression: true,
336
364
  }, { x: 400, y: 340 }),
337
365
 
366
+ node("target-base", "action.set_variable", "Capture Base Branch", {
367
+ key: "targetPrBase",
368
+ value:
369
+ "(() => {" +
370
+ " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" +
371
+ " let prs = [];" +
372
+ " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; }" +
373
+ " if (!Array.isArray(prs)) return 'main';" +
374
+ " const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || ''));" +
375
+ " return pr?.baseRefName || 'main';" +
376
+ "})()",
377
+ isExpression: true,
378
+ }, { x: 400, y: 420 }),
379
+
338
380
  node("has-conflicts", "condition.expression", "Any Conflicts?", {
339
381
  expression: "Boolean($data?.targetPrNumber)",
340
- }, { x: 400, y: 430 }),
382
+ }, { x: 400, y: 510 }),
383
+
384
+ // Label the PR so the watchdog knows it is being worked on
385
+ node("label-fixing", "action.run_command", "Label bosun-needs-fix", {
386
+ command: "gh pr edit {{targetPrNumber}} --add-label bosun-needs-fix",
387
+ continueOnError: true,
388
+ }, { x: 200, y: 650 }),
341
389
 
342
390
  node("resolve-conflicts", "action.run_agent", "Resolve Conflicts", {
343
- prompt: `You are a merge conflict resolution agent for PR #{{targetPrNumber}} on branch {{targetPrBranch}}.
344
- 1. Check out the PR branch
345
- 2. Rebase onto main (or the configured base branch)
346
- 3. Resolve merge conflicts while preserving intended behavior
347
- 4. Run the repo's build/tests to validate the resolution
348
- 5. Push the resolved branch and leave CI re-running
349
-
350
- Only perform minimal conflict-resolution changes. Do NOT add unrelated refactors.`,
391
+ prompt:
392
+ "You are a merge conflict resolution agent for PR #{{targetPrNumber}} " +
393
+ "on branch {{targetPrBranch}} (base: {{targetPrBase}}).\n\n" +
394
+ "Steps:\n" +
395
+ "1. git fetch origin\n" +
396
+ "2. git checkout {{targetPrBranch}}\n" +
397
+ "3. git rebase origin/{{targetPrBase}} (fall back to merge if rebase is too complex)\n" +
398
+ "4. Resolve all merge conflicts, preserving the intent of both sides.\n" +
399
+ "5. Run the repo's build and test suite to confirm nothing is broken.\n" +
400
+ "6. git push --force-with-lease origin {{targetPrBranch}}\n" +
401
+ "7. Remove the bosun-needs-fix label: gh pr edit {{targetPrNumber}} --remove-label bosun-needs-fix\n\n" +
402
+ "Rules:\n" +
403
+ "- Only make minimal conflict-resolution changes. No unrelated refactors.\n" +
404
+ "- Do NOT merge, close, or approve the PR — the Bosun PR Watchdog handles merging.\n" +
405
+ "- Do NOT touch PRs that do not have the bosun-attached label.",
351
406
  sdk: "auto",
352
407
  timeoutMs: 1800000,
353
408
  failOnError: true,
354
409
  maxRetries: "{{maxRetries}}",
355
410
  retryDelayMs: 30000,
356
411
  continueOnError: true,
357
- }, { x: 200, y: 590 }),
358
-
359
- node("verify-ci", "action.run_command", "Verify CI Green", {
360
- command: "gh pr checks {{targetPrNumber}} --json name,state",
361
- failOnError: true,
362
- maxRetries: "{{maxRetries}}",
363
- retryDelayMs: 30000,
364
- continueOnError: true,
365
- }, { x: 200, y: 750 }),
366
-
367
- node("ci-passed", "condition.expression", "CI Passed?", {
368
- expression:
369
- "(() => { const out = $ctx.getNodeOutput('verify-ci'); if (!out || out.success !== true) return false; let checks = []; try { checks = JSON.parse(out.output || '[]'); } catch { return false; } if (!Array.isArray(checks) || checks.length === 0) return false; const ok = new Set(['SUCCESS', 'PASSED', 'PASS', 'COMPLETED', 'NEUTRAL', 'SKIPPED']); return checks.every((c) => ok.has(String(c?.state || '').toUpperCase())); })()",
370
- }, { x: 200, y: 910 }),
371
-
372
- node("do-merge", "action.run_command", "Auto-Merge", {
373
- command: "gh pr merge {{targetPrNumber}} --auto --squash",
374
- failOnError: true,
375
- maxRetries: "{{maxRetries}}",
376
- retryDelayMs: 20000,
377
- continueOnError: true,
378
- }, { x: 100, y: 1040 }),
379
-
380
- node("merge-succeeded", "condition.expression", "Merge Succeeded?", {
381
- expression: "$ctx.getNodeOutput('do-merge')?.success === true",
382
- }, { x: 100, y: 1140 }),
412
+ }, { x: 200, y: 800 }),
383
413
 
384
- node("notify-fixed", "notify.telegram", "Notify Fixed", {
385
- message: "🔧 PR #{{targetPrNumber}} conflicts auto-resolved and merged",
414
+ node("notify-fixed", "notify.telegram", "Notify Resolved", {
415
+ message: "🔧 PR #{{targetPrNumber}} conflict resolved — awaiting CI and Watchdog review before merge",
386
416
  silent: true,
387
- }, { x: 100, y: 1260 }),
417
+ }, { x: 200, y: 960 }),
388
418
 
389
- node("notify-failed", "notify.log", "Log CI Failed", {
390
- message: "PR #{{targetPrNumber}} conflict auto-resolution could not complete cleanly — manual review required",
419
+ node("notify-failed", "notify.log", "Log Resolution Failed", {
420
+ message: "PR #{{targetPrNumber}} conflict could not be resolved cleanly — manual review required",
391
421
  level: "warn",
392
- }, { x: 420, y: 1040 }),
422
+ }, { x: 450, y: 800 }),
393
423
 
394
424
  node("skip", "notify.log", "No Conflicts", {
395
- message: "All PRs are clean no conflicts found",
425
+ message: "PR Conflict Resolver: no unhandled bosun-attached conflicts found",
396
426
  level: "info",
397
- }, { x: 620, y: 430 }),
427
+ }, { x: 620, y: 510 }),
398
428
  ],
399
429
  edges: [
400
- edge("trigger", "list-prs"),
401
- edge("list-prs", "target-pr"),
402
- edge("target-pr", "target-branch"),
403
- edge("target-branch", "has-conflicts"),
404
- edge("has-conflicts", "resolve-conflicts", { condition: "$output?.result === true" }),
405
- edge("has-conflicts", "skip", { condition: "$output?.result !== true" }),
406
- edge("resolve-conflicts", "verify-ci"),
407
- edge("verify-ci", "ci-passed"),
408
- edge("ci-passed", "do-merge", { condition: "$output?.result === true" }),
409
- edge("ci-passed", "notify-failed", { condition: "$output?.result !== true" }),
410
- edge("do-merge", "merge-succeeded"),
411
- edge("merge-succeeded", "notify-fixed", { condition: "$output?.result === true" }),
412
- edge("merge-succeeded", "notify-failed", { condition: "$output?.result !== true" }),
430
+ edge("trigger", "list-prs"),
431
+ edge("list-prs", "target-pr"),
432
+ edge("target-pr", "target-branch"),
433
+ edge("target-branch", "target-base"),
434
+ edge("target-base", "has-conflicts"),
435
+ edge("has-conflicts", "label-fixing", { condition: "$output?.result === true" }),
436
+ edge("has-conflicts", "skip", { condition: "$output?.result !== true" }),
437
+ edge("label-fixing", "resolve-conflicts"),
438
+ edge("resolve-conflicts", "notify-fixed", { condition: "$ctx.getNodeOutput('resolve-conflicts')?.success === true" }),
439
+ edge("resolve-conflicts", "notify-failed", { condition: "$ctx.getNodeOutput('resolve-conflicts')?.success !== true" }),
413
440
  ],
414
441
  metadata: {
415
442
  author: "bosun",
416
- version: 1,
443
+ version: 2,
417
444
  createdAt: "2025-02-25T00:00:00Z",
418
- templateVersion: "1.0.0",
419
- tags: ["github", "pr", "conflict", "rebase", "automation"],
445
+ templateVersion: "2.0.0",
446
+ tags: ["github", "pr", "conflict", "rebase", "automation", "bosun-attached"],
420
447
  replaces: {
421
448
  module: "pr-cleanup-daemon.mjs",
422
449
  functions: ["PRCleanupDaemon.run", "processCleanup", "resolveConflicts"],
423
450
  calledFrom: ["monitor.mjs:startProcess"],
424
- description: "Replaces the pr-cleanup-daemon class with a visual workflow. " +
425
- "Conflict detection, rebase, CI verification, and auto-merge become " +
426
- "explicit workflow steps with configurable intervals.",
451
+ description:
452
+ "v2: Restricted to bosun-attached PRs only — never touches external-contributor PRs. " +
453
+ "Removed direct auto-merge: this template now only resolves the conflict and pushes; " +
454
+ "the Bosun PR Watchdog (template-bosun-pr-watchdog) owns the merge decision with its " +
455
+ "diff-safety review gate. Skips PRs already tagged bosun-needs-fix (watchdog owns those). " +
456
+ "Labels PR with bosun-needs-fix during resolution so watchdog knows it is in-flight.",
427
457
  },
428
458
  },
429
459
  };
@@ -611,3 +641,291 @@ Omit empty sections. Include contributor attribution. Be concise.`,
611
641
  tags: ["github", "release", "notes", "changelog", "draft"],
612
642
  },
613
643
  };
644
+
645
+ // ═══════════════════════════════════════════════════════════════════════════
646
+ // Bosun PR Watchdog
647
+ // ═══════════════════════════════════════════════════════════════════════════
648
+
649
+ resetLayout();
650
+
651
+ /**
652
+ * Bosun PR Watchdog — opt-in, scheduled CI poller for bosun-owned PRs.
653
+ *
654
+ * Only acts on PRs labelled `bosun-attached` (applied by the
655
+ * .github/workflows/bosun-pr-attach.yml GitHub Action when Bosun opens a PR).
656
+ * External-contributor and human PRs that lack that label are never touched.
657
+ *
658
+ * Per cycle:
659
+ * 1. List all open bosun-attached PRs.
660
+ * 2. Merge any PR whose CI checks are all passing (not draft, not pending).
661
+ * 3. Label any PR whose CI checks have failures with `bosun-needs-fix` and
662
+ * dispatch a repair agent to fix the branch.
663
+ *
664
+ * Disable: set `enabled: false` in your bosun config, or delete the workflow.
665
+ * Interval: default 5 min — change `intervalMs` / `cron` variables.
666
+ */
667
+ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
668
+ id: "template-bosun-pr-watchdog",
669
+ name: "Bosun PR Watchdog",
670
+ description:
671
+ "Scans open bosun-attached PRs on a schedule. Makes ONE gh API call to " +
672
+ "fetch and classify all PRs, then: labels conflicting or failing-CI PRs " +
673
+ "with bosun-needs-fix and dispatches a repair agent; sends merge candidates " +
674
+ "through a MANDATORY agent review gate that checks diff stats before any " +
675
+ "merge — preventing destructive PRs (e.g. -183k lines) from being silently " +
676
+ "auto-merged. External-contributor PRs without bosun-attached are never touched.",
677
+ category: "github",
678
+ enabled: false,
679
+ trigger: "trigger.schedule",
680
+ variables: {
681
+ mergeMethod: "squash", // squash | merge | rebase
682
+ labelNeedsFix: "bosun-needs-fix", // applied to CI failures and conflicts
683
+ labelNeedsReview: "bosun-needs-human-review", // applied when review agent flags a suspicious diff
684
+ maxPrs: 25,
685
+ intervalMs: 300_000, // 5 minutes
686
+ // Merge-safety thresholds checked by the review agent:
687
+ // If net deletions > additions × ratio AND deletions > minDestructiveDeletions → HOLD
688
+ suspiciousDeletionRatio: 3, // e.g. deletes 3× more lines than it adds
689
+ minDestructiveDeletions: 500, // absolute floor — small PRs are fine even if net negative
690
+ },
691
+ nodes: [
692
+ node("trigger", "trigger.schedule", "Poll Every 5 min", {
693
+ intervalMs: "{{intervalMs}}",
694
+ cron: "*/5 * * * *",
695
+ }, { x: 400, y: 50 }),
696
+
697
+ // ─────────────────────────────────────────────────────────────────────────
698
+ // STEP 1: ONE gh API call — fetch all fields we need in a single request.
699
+ // classify-and-label does all subsequent classification + labeling inline
700
+ // from this one response, so no repeated gh pr list calls later.
701
+ // ─────────────────────────────────────────────────────────────────────────
702
+ node("fetch-and-classify", "action.run_command", "Fetch, Classify & Label PRs", {
703
+ // Fetches all open bosun-attached PRs with every field needed for
704
+ // classification. Pipes into an inline Node script that:
705
+ // • Classifies each PR into: ready | conflict | ci_failure | pending | draft
706
+ // • Labels conflict/ci_failure PRs with bosun-needs-fix (skips if already present)
707
+ // • Outputs a JSON summary used by all downstream nodes/agents
708
+ // Total gh API calls this node makes: 1 list + N edits (only for newly-broken PRs)
709
+ command: [
710
+ "gh pr list --label bosun-attached --state open",
711
+ "--json number,title,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup,labels,url",
712
+ "--limit {{maxPrs}}",
713
+ "| node -e \"",
714
+ "const LABEL_FIX='{{labelNeedsFix}}';",
715
+ "const {execSync}=require('child_process');",
716
+ "let raw='';",
717
+ "process.stdin.on('data',c=>raw+=c);",
718
+ "process.stdin.on('end',()=>{",
719
+ " let prs=[];",
720
+ " try{prs=JSON.parse(raw);}catch(e){console.log(JSON.stringify({error:e.message,total:0}));return;}",
721
+ " const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']);",
722
+ " const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']);",
723
+ " const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']);",
724
+ " const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[];",
725
+ " let newlyLabeled=0;",
726
+ " for(const pr of prs){",
727
+ " const labels=(pr.labels||[]).map(l=>l.name);",
728
+ " const hasFixLabel=labels.includes(LABEL_FIX);",
729
+ " const checks=pr.statusCheckRollup||[];",
730
+ " const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||''));",
731
+ " const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||''));",
732
+ " const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase());",
733
+ " const isDraft=pr.isDraft===true;",
734
+ " if(isDraft){drafted.push(pr.number);continue;}",
735
+ " if(isConflict){",
736
+ " conflicts.push({n:pr.number,branch:pr.headRefName,base:pr.baseRefName,url:pr.url});",
737
+ " if(!hasFixLabel){",
738
+ " try{execSync('gh pr edit '+pr.number+' --add-label '+LABEL_FIX,{stdio:'pipe'});newlyLabeled++;}",
739
+ " catch(e){process.stderr.write('label err #'+pr.number+': '+e.message+'\\n');}",
740
+ " }",
741
+ " } else if(hasFail){",
742
+ " ciFailures.push({n:pr.number,branch:pr.headRefName,url:pr.url});",
743
+ " if(!hasFixLabel){",
744
+ " try{execSync('gh pr edit '+pr.number+' --add-label '+LABEL_FIX,{stdio:'pipe'});newlyLabeled++;}",
745
+ " catch(e){process.stderr.write('label err #'+pr.number+': '+e.message+'\\n');}",
746
+ " }",
747
+ " } else if(hasPend){",
748
+ " pending.push(pr.number);",
749
+ " } else if(checks.length>0&&!hasFixLabel){",
750
+ " // CI all-passing, no conflicts, not draft — a review candidate",
751
+ " readyCandidates.push({n:pr.number,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title});",
752
+ " }",
753
+ " }",
754
+ " console.log(JSON.stringify({",
755
+ " total:prs.length,",
756
+ " readyCandidates,",
757
+ " conflicts,",
758
+ " ciFailures,",
759
+ " pending:pending.length,",
760
+ " drafted:drafted.length,",
761
+ " newlyLabeled,",
762
+ " fixNeeded:conflicts.length+ciFailures.length",
763
+ " }));",
764
+ "});",
765
+ "\"",
766
+ ].join(" "),
767
+ continueOnError: false,
768
+ }, { x: 400, y: 200 }),
769
+
770
+ node("has-prs", "condition.expression", "Any Bosun PRs?", {
771
+ expression:
772
+ "(()=>{try{" +
773
+ "const o=$ctx.getNodeOutput('fetch-and-classify')?.output;" +
774
+ "return (JSON.parse(o||'{}').total||0)>0;" +
775
+ "}catch(e){return false;}})()",
776
+ }, { x: 400, y: 370 }),
777
+
778
+ // ─────────────────────────────────────────────────────────────────────────
779
+ // STEP 2a: Fix path — dispatch ONE agent for all conflicts + CI failures.
780
+ // The agent handles rebase/conflict-resolution AND CI lint/test fixes.
781
+ // ─────────────────────────────────────────────────────────────────────────
782
+ node("fix-needed", "condition.expression", "Fix Needed?", {
783
+ expression:
784
+ "(()=>{try{" +
785
+ "const o=$ctx.getNodeOutput('fetch-and-classify')?.output;" +
786
+ "return (JSON.parse(o||'{}').fixNeeded||0)>0;" +
787
+ "}catch(e){return false;}})()",
788
+ }, { x: 200, y: 530 }),
789
+
790
+ node("dispatch-fix", "action.run_agent", "Dispatch Fix Agent", {
791
+ prompt:
792
+ "You are a Bosun PR repair agent. A watchdog workflow has identified " +
793
+ "bosun-attached PRs that need fixing.\n\n" +
794
+ "Run this single command to get the current list of PRs needing work:\n" +
795
+ " gh pr list --label bosun-needs-fix --label bosun-attached --state open \\\n" +
796
+ " --json number,title,headRefName,baseRefName,mergeable,statusCheckRollup,labels,url \\\n" +
797
+ " --limit 10\n\n" +
798
+ "For each PR returned, follow the appropriate path:\n\n" +
799
+ "CONFLICT (mergeable is CONFLICTING, BEHIND, or DIRTY):\n" +
800
+ "1. git fetch origin\n" +
801
+ "2. git checkout <headRefName>\n" +
802
+ "3. git rebase origin/<baseRefName> (or git merge origin/<baseRefName> if rebase is too complex)\n" +
803
+ "4. Resolve merge conflicts, preserving the intent of both sides.\n" +
804
+ "5. Run the repo's build + test suite to confirm nothing is broken.\n" +
805
+ "6. git push --force-with-lease origin <headRefName>\n\n" +
806
+ "CI FAILURE (statusCheckRollup has FAILURE/ERROR/TIMED_OUT entries):\n" +
807
+ "1. git checkout <headRefName>\n" +
808
+ "2. Inspect CI logs: gh run list --branch <headRefName> --limit 3\n" +
809
+ " Then: gh run view <run-id> --log-failed\n" +
810
+ "3. Fix the root cause (lint, type error, failing test, build error).\n" +
811
+ "4. Commit: fix(<scope>): <description> (conventional commit format)\n" +
812
+ "5. Push the branch — CI will re-trigger automatically.\n\n" +
813
+ "AFTER fixing either type:\n" +
814
+ "- Remove the bosun-needs-fix label: gh pr edit <number> --remove-label bosun-needs-fix\n\n" +
815
+ "STRICT RULES:\n" +
816
+ "- Fix only what breaks CI or causes the conflict. No scope creep.\n" +
817
+ "- Do NOT merge, close, or approve any PR.\n" +
818
+ "- Do NOT touch PRs that do not have the bosun-attached label.\n" +
819
+ "- If a conflict cannot be resolved cleanly, leave it labeled and add a comment explaining why.",
820
+ sdk: "auto",
821
+ timeoutMs: 1_800_000,
822
+ maxRetries: 2,
823
+ retryDelayMs: 30_000,
824
+ continueOnError: true,
825
+ }, { x: 200, y: 700 }),
826
+
827
+ // ─────────────────────────────────────────────────────────────────────────
828
+ // STEP 2b: Review gate — MANDATORY before any merge.
829
+ // The review agent checks diff stats per candidate and is the ONLY thing
830
+ // that can call `gh pr merge`. It blocks suspicious/destructive diffs.
831
+ // ─────────────────────────────────────────────────────────────────────────
832
+ node("review-needed", "condition.expression", "Review Candidates?", {
833
+ expression:
834
+ "(()=>{try{" +
835
+ "const o=$ctx.getNodeOutput('fetch-and-classify')?.output;" +
836
+ "return (JSON.parse(o||'{}').readyCandidates||[]).length>0;" +
837
+ "}catch(e){return false;}})()",
838
+ }, { x: 600, y: 530 }),
839
+
840
+ node("dispatch-review", "action.run_agent", "Review Gate: Inspect & Merge", {
841
+ prompt:
842
+ "You are the Bosun PR merge review agent — the LAST LINE OF DEFENCE before " +
843
+ "any PR is merged. Your job is to inspect each merge candidate and decide " +
844
+ "whether it is safe to merge.\n\n" +
845
+ "MERGE CANDIDATES (CI passing, no conflicts, bosun-attached):\n" +
846
+ " Run: gh pr list --label bosun-attached --state open \\\n" +
847
+ " --json number,title,headRefName,isDraft,statusCheckRollup,labels,url \\\n" +
848
+ " --limit {{maxPrs}}\n" +
849
+ " Filter to PRs where: not isDraft, CI all-passing, no bosun-needs-fix label.\n\n" +
850
+ "FOR EACH CANDIDATE — before merging, run:\n" +
851
+ " gh pr view <number> --json number,title,additions,deletions,changedFiles,body,baseRefName\n\n" +
852
+ "SAFETY CHECKS (ALL must pass before merging):\n\n" +
853
+ "1. DESTRUCTIVE DIFF CHECK:\n" +
854
+ " If (deletions > additions × {{suspiciousDeletionRatio}}) AND (deletions > {{minDestructiveDeletions}}):\n" +
855
+ " → This PR deletes far more than it adds — HOLD IT.\n" +
856
+ " → Run: gh pr edit <number> --add-label {{labelNeedsReview}}\n" +
857
+ " → Run: gh pr comment <number> --body '⚠️ **Bosun Review Agent: merge held** — " +
858
+ "This PR deletes significantly more lines than it adds (deletions: <X>, additions: <Y>). " +
859
+ "A human should verify this is intentional before merging.'\n" +
860
+ " → Do NOT merge this PR. Move to next candidate.\n\n" +
861
+ "2. DIFF SANITY CHECK (for PRs that pass the ratio check):\n" +
862
+ " Run: gh pr diff <number> | head -200\n" +
863
+ " Look for: mass file deletions, removal of entire modules/directories, " +
864
+ " files changed that are unrelated to the PR description.\n" +
865
+ " If something looks wrong → HOLD with bosun-needs-human-review label + comment.\n\n" +
866
+ "3. CI STATUS RECONFIRM:\n" +
867
+ " Run: gh pr checks <number> --json name,state,conclusion\n" +
868
+ " Ensure ALL checks have conclusion SUCCESS/SKIPPED/NEUTRAL. " +
869
+ " If any are pending or failing → do NOT merge (CI may still be running).\n\n" +
870
+ "MERGE (only if ALL checks pass):\n" +
871
+ " gh pr merge <number> --{{mergeMethod}} --delete-branch\n" +
872
+ " Log: ✅ Merged PR #<number> — <title>\n\n" +
873
+ "STRICT RULES:\n" +
874
+ "- NEVER merge if ANY safety check fails. When in doubt, HOLD.\n" +
875
+ "- NEVER merge PRs without the bosun-attached label.\n" +
876
+ "- NEVER merge draft PRs.\n" +
877
+ "- The bosun-needs-human-review label means a human must look at it before bosun touches it again.\n" +
878
+ "- After processing all candidates, output a summary: { merged: N, held: N, skipped: N }",
879
+ sdk: "auto",
880
+ timeoutMs: 1_200_000,
881
+ maxRetries: 1,
882
+ retryDelayMs: 30_000,
883
+ continueOnError: true,
884
+ }, { x: 600, y: 700 }),
885
+
886
+ node("notify", "notify.telegram", "Watchdog Report", {
887
+ message:
888
+ "🐕 Bosun PR Watchdog cycle complete — " +
889
+ "fix-dispatched: {{fixNeeded}} | candidates-reviewed: {{readyCandidates}}",
890
+ silent: true,
891
+ }, { x: 400, y: 900 }),
892
+
893
+ node("no-prs", "notify.log", "No Bosun PRs Open", {
894
+ message: "Bosun PR Watchdog: no open bosun-attached PRs found — idle",
895
+ level: "info",
896
+ }, { x: 700, y: 370 }),
897
+ ],
898
+ edges: [
899
+ edge("trigger", "fetch-and-classify"),
900
+ edge("fetch-and-classify","has-prs"),
901
+ edge("has-prs", "fix-needed", { condition: "$output?.result === true" }),
902
+ edge("has-prs", "no-prs", { condition: "$output?.result !== true" }),
903
+ // Fix path (conflicts + CI failures)
904
+ edge("fix-needed", "dispatch-fix", { condition: "$output?.result === true" }),
905
+ edge("fix-needed", "review-needed", { condition: "$output?.result !== true" }),
906
+ edge("dispatch-fix", "review-needed"),
907
+ // Review gate (merge candidates)
908
+ edge("review-needed", "dispatch-review", { condition: "$output?.result === true" }),
909
+ edge("review-needed", "notify", { condition: "$output?.result !== true" }),
910
+ edge("dispatch-review", "notify"),
911
+ ],
912
+ metadata: {
913
+ author: "bosun",
914
+ version: 2,
915
+ createdAt: "2025-07-01T00:00:00Z",
916
+ templateVersion: "2.0.0",
917
+ tags: ["github", "pr", "ci", "merge", "watchdog", "bosun-attached", "safety"],
918
+ replaces: {
919
+ module: "agent-hooks.mjs",
920
+ functions: ["registerBuiltinHooks (PostPR block)"],
921
+ calledFrom: [],
922
+ description:
923
+ "v2: Consolidates all gh API calls into ONE gh pr list fetch per cycle. " +
924
+ "Adds mandatory review gate agent that checks diff stats (additions/deletions " +
925
+ "ratio) and diff content before any merge — preventing destructive PRs from " +
926
+ "being auto-merged. Adds conflict detection via the 'mergeable' field. " +
927
+ "Single fix agent handles both conflict resolution and CI failures. " +
928
+ "All external PRs (no bosun-attached label) are never touched.",
929
+ },
930
+ },
931
+ };
@@ -53,20 +53,29 @@ export const TASK_PLANNER_TEMPLATE = {
53
53
  dedup: true,
54
54
  }, { x: 400, y: 440 }),
55
55
 
56
- node("check-result", "condition.expression", "Planner Succeeded?", {
57
- expression: "$output?.run_planner?.success === true || $ctx.getNodeOutput('run-planner')?.success === true",
56
+ node("materialize-tasks", "action.materialize_planner_tasks", "Create Tasks", {
57
+ plannerNodeId: "run-planner",
58
+ maxTasks: 5,
59
+ status: "todo",
60
+ dedup: true,
61
+ failOnZero: true,
62
+ minCreated: 1,
58
63
  }, { x: 400, y: 570 }),
59
64
 
65
+ node("check-result", "condition.expression", "Planner Succeeded?", {
66
+ expression: "$ctx.getNodeOutput('materialize-tasks')?.success === true && ($ctx.getNodeOutput('materialize-tasks')?.createdCount || 0) > 0",
67
+ }, { x: 400, y: 700 }),
68
+
60
69
  node("set-timestamp", "action.set_variable", "Update Last Run", {
61
70
  key: "_lastPlannerRun",
62
71
  value: "Date.now()",
63
72
  isExpression: true,
64
- }, { x: 200, y: 700 }),
73
+ }, { x: 200, y: 830 }),
65
74
 
66
75
  node("notify-done", "notify.telegram", "Notify Tasks Created", {
67
- message: "🗂️ Task planner generated new backlog tasks. Todo count was {{todoCount}}.",
76
+ message: "🗂️ Task planner created {{materialize-tasks.createdCount}} backlog tasks (skipped {{materialize-tasks.skippedCount}} duplicates).",
68
77
  silent: true,
69
- }, { x: 200, y: 830 }),
78
+ }, { x: 200, y: 960 }),
70
79
 
71
80
  node("notify-skip", "notify.log", "Log Dedup Skip", {
72
81
  message: "Task planner skipped: within dedup window",
@@ -74,16 +83,17 @@ export const TASK_PLANNER_TEMPLATE = {
74
83
  }, { x: 650, y: 180 }),
75
84
 
76
85
  node("notify-fail", "notify.log", "Log Planner Failure", {
77
- message: "Task planner failed to generate tasks",
86
+ message: "Task planner failed to materialize tasks from planner output",
78
87
  level: "warn",
79
- }, { x: 600, y: 700 }),
88
+ }, { x: 600, y: 830 }),
80
89
  ],
81
90
  edges: [
82
91
  edge("trigger", "check-dedup"),
83
92
  edge("check-dedup", "log-start", { condition: "$output?.result === true" }),
84
93
  edge("check-dedup", "notify-skip", { condition: "$output?.result !== true" }),
85
94
  edge("log-start", "run-planner"),
86
- edge("run-planner", "check-result"),
95
+ edge("run-planner", "materialize-tasks"),
96
+ edge("materialize-tasks", "check-result"),
87
97
  edge("check-result", "set-timestamp", { condition: "$output?.result === true" }),
88
98
  edge("check-result", "notify-fail", { condition: "$output?.result !== true" }),
89
99
  edge("set-timestamp", "notify-done"),
@@ -143,10 +153,19 @@ export const TASK_REPLENISH_TEMPLATE = {
143
153
  context: "Scheduled replenishment run. Prioritize implementation tasks that build on recent PRs.",
144
154
  }, { x: 400, y: 440 }),
145
155
 
156
+ node("materialize-tasks", "action.materialize_planner_tasks", "Create Tasks", {
157
+ plannerNodeId: "run-planner",
158
+ maxTasks: 8,
159
+ status: "todo",
160
+ dedup: true,
161
+ failOnZero: true,
162
+ minCreated: 1,
163
+ }, { x: 400, y: 570 }),
164
+
146
165
  node("notify", "notify.telegram", "Notify", {
147
- message: "🔄 Scheduled task replenishment complete.",
166
+ message: "🔄 Scheduled replenishment created {{materialize-tasks.createdCount}} tasks (skipped {{materialize-tasks.skippedCount}}).",
148
167
  silent: true,
149
- }, { x: 400, y: 570 }),
168
+ }, { x: 400, y: 700 }),
150
169
 
151
170
  node("skip-log", "notify.log", "No Replenish Needed", {
152
171
  message: "Scheduled replenishment check: backlog sufficient, skipping",
@@ -157,7 +176,8 @@ export const TASK_REPLENISH_TEMPLATE = {
157
176
  edge("check-backlog", "needs-tasks"),
158
177
  edge("needs-tasks", "run-planner", { condition: "$output?.result === true" }),
159
178
  edge("needs-tasks", "skip-log", { condition: "$output?.result !== true" }),
160
- edge("run-planner", "notify"),
179
+ edge("run-planner", "materialize-tasks"),
180
+ edge("materialize-tasks", "notify"),
161
181
  ],
162
182
  metadata: {
163
183
  author: "bosun",
@@ -43,6 +43,7 @@ import {
43
43
  PR_CONFLICT_RESOLVER_TEMPLATE,
44
44
  STALE_PR_REAPER_TEMPLATE,
45
45
  RELEASE_DRAFTER_TEMPLATE,
46
+ BOSUN_PR_WATCHDOG_TEMPLATE,
46
47
  } from "./workflow-templates/github.mjs";
47
48
 
48
49
  // Agents
@@ -95,6 +96,7 @@ export {
95
96
  PR_CONFLICT_RESOLVER_TEMPLATE,
96
97
  STALE_PR_REAPER_TEMPLATE,
97
98
  RELEASE_DRAFTER_TEMPLATE,
99
+ BOSUN_PR_WATCHDOG_TEMPLATE,
98
100
  FRONTEND_AGENT_TEMPLATE,
99
101
  REVIEW_AGENT_TEMPLATE,
100
102
  CUSTOM_AGENT_TEMPLATE,
@@ -141,6 +143,7 @@ export const WORKFLOW_TEMPLATES = Object.freeze([
141
143
  PR_CONFLICT_RESOLVER_TEMPLATE,
142
144
  STALE_PR_REAPER_TEMPLATE,
143
145
  RELEASE_DRAFTER_TEMPLATE,
146
+ BOSUN_PR_WATCHDOG_TEMPLATE,
144
147
  // ── Agents ──
145
148
  REVIEW_AGENT_TEMPLATE,
146
149
  FRONTEND_AGENT_TEMPLATE,