internaltool-mcp 1.5.7 → 1.6.1
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/index.js +766 -32
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -356,56 +356,433 @@ Set confirmed=false first to preview, then confirmed=true to actually save.`,
|
|
|
356
356
|
)
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ── Standup activity formatter ────────────────────────────────────────────────
|
|
360
|
+
// Converts raw activity {action, meta} into a human-readable sentence.
|
|
361
|
+
function formatActivityLine(a) {
|
|
362
|
+
const m = a.meta || {}
|
|
363
|
+
switch (a.action) {
|
|
364
|
+
case 'task.created': return 'Task created'
|
|
365
|
+
case 'task.updated': return m.fields?.length ? `Updated: ${m.fields.join(', ')}` : 'Task updated'
|
|
366
|
+
case 'task.moved': return `Moved from ${m.from} → ${m.to}`
|
|
367
|
+
case 'task.branch_linked': return `Branch linked: ${m.headBranch}`
|
|
368
|
+
case 'comment.created': return 'Comment posted'
|
|
369
|
+
case 'approval.submitted': return `Sent for approval → ${m.reviewerName || 'reviewer'}`
|
|
370
|
+
case 'approval.approved': return `Plan approved${m.note ? ': ' + m.note : ''}`
|
|
371
|
+
case 'approval.rejected': return `Plan rejected${m.note ? ': ' + m.note : ''}`
|
|
372
|
+
case 'task.merged_from_github': return `PR #${m.prNumber} merged → done`
|
|
373
|
+
case 'task.pr_opened_to_review': return `PR #${m.prNumber} opened — moved to in_review`
|
|
374
|
+
case 'task.pr_linked_in_review': return `PR #${m.prNumber} linked (already in review)`
|
|
375
|
+
case 'task.pr_changes_requested': return `PR #${m.prNumber} — changes requested by ${m.reviewerLogin || 'reviewer'}`
|
|
376
|
+
case 'task.pr_closed_without_merge': return `PR #${m.prNumber} closed without merging`
|
|
377
|
+
case 'issue.created': return `Issue logged: "${m.title || '?'}"`
|
|
378
|
+
case 'issue.updated': return `Issue updated`
|
|
379
|
+
default: return a.action
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
359
383
|
function registerProductivityTools(server) {
|
|
360
384
|
server.tool(
|
|
361
385
|
'generate_standup',
|
|
362
|
-
`Generate a daily standup
|
|
363
|
-
Fetches all assigned tasks and their recent activity, then formats:
|
|
364
|
-
- What was done yesterday (based on activity + park notes)
|
|
365
|
-
- What is planned today (in_progress / todo tasks)
|
|
366
|
-
- Any blockers
|
|
386
|
+
`Generate a full daily standup — covers both the current user's tasks AND the entire project board.
|
|
367
387
|
|
|
368
|
-
|
|
388
|
+
Returns:
|
|
389
|
+
- yesterday: readable activity lines for the last 24 h per task
|
|
390
|
+
- today: all active tasks grouped by assignee, sorted by priority (critical first)
|
|
391
|
+
- blockers: park notes + tasks without branches/PRs that are overdue
|
|
392
|
+
- risks: critical/high tasks with no branch, tasks with PR changes requested
|
|
393
|
+
- copy-paste: a ready-to-post standup snippet
|
|
394
|
+
|
|
395
|
+
Call this when the developer says "generate standup", "what did I do yesterday", or "morning standup".`,
|
|
369
396
|
{},
|
|
370
397
|
async () => {
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
398
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
399
|
+
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
400
|
+
|
|
401
|
+
// 1. Who am I?
|
|
402
|
+
const meRes = await api.get('/api/auth/me')
|
|
403
|
+
const me = meRes?.data?.user || {}
|
|
404
|
+
|
|
405
|
+
// 2. My tasks (all columns)
|
|
406
|
+
const myTasksRes = await api.get('/api/users/me/tasks')
|
|
407
|
+
const myTasks = myTasksRes?.data?.tasks || []
|
|
374
408
|
|
|
375
|
-
|
|
376
|
-
|
|
409
|
+
// 3. All my projects (to get project-wide board)
|
|
410
|
+
const projectsRes = await api.get('/api/projects')
|
|
411
|
+
const projects = projectsRes?.data?.projects || []
|
|
377
412
|
|
|
378
|
-
//
|
|
413
|
+
// 4. For each project, fetch full board to get all tasks (not just mine)
|
|
414
|
+
const projectBoards = await Promise.all(
|
|
415
|
+
projects.map(async (p) => {
|
|
416
|
+
try {
|
|
417
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
418
|
+
return r?.data?.project || null
|
|
419
|
+
} catch { return null }
|
|
420
|
+
})
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
// 5. Collect all unique tasks across all projects
|
|
424
|
+
const allTasksMap = new Map()
|
|
425
|
+
for (const board of projectBoards.filter(Boolean)) {
|
|
426
|
+
for (const t of (board.tasks || [])) {
|
|
427
|
+
allTasksMap.set(t._id, { ...t, projectName: board.name, projectId: board._id })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const allTasks = [...allTasksMap.values()]
|
|
431
|
+
|
|
432
|
+
// 6. Fetch recent activity for active tasks (parallel, capped at 20 tasks to avoid rate limits)
|
|
433
|
+
const activeTasks = allTasks.filter(t => ['in_progress', 'in_review', 'todo'].includes(t.column))
|
|
379
434
|
const withActivity = await Promise.all(
|
|
380
|
-
|
|
435
|
+
activeTasks.map(async (t) => {
|
|
381
436
|
try {
|
|
382
|
-
const
|
|
383
|
-
|
|
437
|
+
const r = await api.get(`/api/tasks/${t._id}/activity`)
|
|
438
|
+
const activity = r?.data?.activity || []
|
|
439
|
+
const recentLines = activity
|
|
440
|
+
.filter(a => new Date(a.createdAt) > yesterday)
|
|
441
|
+
.map(a => formatActivityLine(a))
|
|
442
|
+
return { ...t, recentActivityLines: recentLines, allActivity: activity }
|
|
384
443
|
} catch {
|
|
385
|
-
return { ...t,
|
|
444
|
+
return { ...t, recentActivityLines: [], allActivity: [] }
|
|
386
445
|
}
|
|
387
446
|
})
|
|
388
447
|
)
|
|
389
448
|
|
|
390
|
-
//
|
|
391
|
-
const
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
449
|
+
// 7. Group by assignee
|
|
450
|
+
const byAssignee = {}
|
|
451
|
+
for (const t of withActivity) {
|
|
452
|
+
const assigneeList = (t.assignees || [])
|
|
453
|
+
if (!assigneeList.length) {
|
|
454
|
+
const key = 'Unassigned'
|
|
455
|
+
if (!byAssignee[key]) byAssignee[key] = []
|
|
456
|
+
byAssignee[key].push(t)
|
|
457
|
+
}
|
|
458
|
+
for (const a of assigneeList) {
|
|
459
|
+
const name = a.name || a.email || String(a)
|
|
460
|
+
if (!byAssignee[name]) byAssignee[name] = []
|
|
461
|
+
byAssignee[name].push(t)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Sort each person's tasks by priority
|
|
465
|
+
for (const name of Object.keys(byAssignee)) {
|
|
466
|
+
byAssignee[name].sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 8. Build "yesterday" section — tasks with recent activity
|
|
470
|
+
const yesterdayItems = withActivity
|
|
471
|
+
.filter(t => t.recentActivityLines.length > 0)
|
|
472
|
+
.map(t => ({
|
|
473
|
+
key: t.key,
|
|
474
|
+
title: t.title,
|
|
475
|
+
project: t.projectName,
|
|
476
|
+
assignee: (t.assignees || []).map(a => a.name || a.email).join(', ') || 'Unassigned',
|
|
477
|
+
activity: t.recentActivityLines,
|
|
478
|
+
}))
|
|
479
|
+
|
|
480
|
+
// 9. Risks + blockers
|
|
481
|
+
const risks = []
|
|
482
|
+
for (const t of withActivity) {
|
|
483
|
+
if (t.priority === 'critical' && t.column === 'todo') {
|
|
484
|
+
risks.push(`⚠️ ${t.key} (${t.projectName}) — CRITICAL but still in todo — needs kickoff now`)
|
|
485
|
+
}
|
|
486
|
+
if (t.priority === 'critical' && t.column === 'in_progress' && !t.github?.headBranch) {
|
|
487
|
+
risks.push(`⚠️ ${t.key} — CRITICAL, in_progress but no branch linked`)
|
|
405
488
|
}
|
|
489
|
+
if (t.github?.changesRequestedAt) {
|
|
490
|
+
risks.push(`🔁 ${t.key} — PR has changes requested — developer needs to fix and re-push`)
|
|
491
|
+
}
|
|
492
|
+
if (t.column === 'in_review' && !t.github?.prUrl) {
|
|
493
|
+
risks.push(`❓ ${t.key} — in_review column but no PR URL recorded`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const blockers = withActivity
|
|
498
|
+
.filter(t => t.parkNote?.blockers && t.parkNote.blockers.trim())
|
|
499
|
+
.map(t => `${t.key}: ${t.parkNote.blockers}`)
|
|
500
|
+
|
|
501
|
+
// 10. My personal section (my tasks with readable status)
|
|
502
|
+
const mySection = myTasks
|
|
503
|
+
.filter(t => ['in_progress', 'in_review', 'todo'].includes(t.column))
|
|
504
|
+
.map(t => {
|
|
505
|
+
const status = t.github?.headBranch
|
|
506
|
+
? (t.github.prUrl ? `PR open → ${t.github.prUrl}` : `branch: ${t.github.headBranch}, no PR yet`)
|
|
507
|
+
: 'no branch yet'
|
|
508
|
+
return `${t.key} — ${t.title} [${t.column}, ${t.priority}] — ${status}`
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// 11. Copy-paste snippet
|
|
512
|
+
const todayFocus = Object.entries(byAssignee)
|
|
513
|
+
.map(([person, tasks]) => ` ${person}: ${tasks.map(t => `${t.key} (${t.priority})`).join(', ')}`)
|
|
514
|
+
.join('\n')
|
|
515
|
+
const copyPaste = [
|
|
516
|
+
`**Yesterday:** ${yesterdayItems.length
|
|
517
|
+
? yesterdayItems.map(i => `${i.key} — ${i.activity.slice(0,2).join('; ')}`).join('. ')
|
|
518
|
+
: 'No recorded activity in last 24 h.'}`,
|
|
519
|
+
`**Today:**\n${todayFocus || ' No active tasks.'}`,
|
|
520
|
+
blockers.length ? `**Blockers:** ${blockers.join(' | ')}` : '**Blockers:** None.',
|
|
521
|
+
risks.length ? `**Risks:** ${risks.join(' | ')}` : null,
|
|
522
|
+
].filter(Boolean).join('\n')
|
|
523
|
+
|
|
524
|
+
return text({
|
|
525
|
+
me: { name: me.name, email: me.email, role: me.role },
|
|
526
|
+
yesterday: yesterdayItems.length ? yesterdayItems : '(no activity logged in last 24 h)',
|
|
527
|
+
today: {
|
|
528
|
+
byAssignee: Object.fromEntries(
|
|
529
|
+
Object.entries(byAssignee).map(([person, tasks]) => [
|
|
530
|
+
person,
|
|
531
|
+
tasks.map(t => ({
|
|
532
|
+
key: t.key,
|
|
533
|
+
title: t.title,
|
|
534
|
+
column: t.column,
|
|
535
|
+
priority: t.priority,
|
|
536
|
+
project: t.projectName,
|
|
537
|
+
branch: t.github?.headBranch || null,
|
|
538
|
+
prUrl: t.github?.prUrl || null,
|
|
539
|
+
parkNote: t.parkNote?.summary || null,
|
|
540
|
+
needsFix: !!t.github?.changesRequestedAt,
|
|
541
|
+
}))
|
|
542
|
+
])
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
myTasks: mySection,
|
|
546
|
+
blockers: blockers.length ? blockers : ['None recorded'],
|
|
547
|
+
risks: risks.length ? risks : ['None'],
|
|
548
|
+
copyPaste,
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
// ── what_should_i_work_on ─────────────────────────────────────────────────────
|
|
554
|
+
server.tool(
|
|
555
|
+
'what_should_i_work_on',
|
|
556
|
+
`Recommend the single most important task to work on right now.
|
|
557
|
+
|
|
558
|
+
Scores all assigned tasks by: priority, urgency (PR needs fix), column state, branch readiness.
|
|
559
|
+
Returns the #1 pick with a clear reason and the exact next MCP call to make.
|
|
560
|
+
|
|
561
|
+
Use this when the developer says "what should I do next", "I'm free", or "what's my priority".`,
|
|
562
|
+
{},
|
|
563
|
+
async () => {
|
|
564
|
+
const res = await api.get('/api/users/me/tasks')
|
|
565
|
+
if (!res?.success) return errorText('Could not fetch tasks')
|
|
566
|
+
const tasks = (res.data.tasks || []).filter(t => t.column !== 'done')
|
|
567
|
+
|
|
568
|
+
if (!tasks.length) return text({ recommendation: 'No open tasks assigned to you. Ask your project lead for work.' })
|
|
569
|
+
|
|
570
|
+
function scoreTask(t) {
|
|
571
|
+
const PRIORITY = { critical: 100, high: 60, medium: 30, low: 10 }
|
|
572
|
+
let score = PRIORITY[t.priority] ?? 0
|
|
573
|
+
if (t.github?.changesRequestedAt) score += 80 // PR needs fix — most urgent
|
|
574
|
+
if (t.column === 'in_progress') score += 25 // already in flight, keep momentum
|
|
575
|
+
if (t.column === 'in_review') score += 10 // waiting on reviewer, low action needed
|
|
576
|
+
if (t.column === 'todo') score += 15 // ready to start
|
|
577
|
+
if (t.column === 'backlog') score -= 10 // not yet planned
|
|
578
|
+
if (t.parkNote?.parkedAt) score -= 20 // intentionally paused
|
|
579
|
+
if (t.github?.headBranch) score += 5 // branch already set up
|
|
580
|
+
return score
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const scored = tasks
|
|
584
|
+
.map(t => ({ ...t, _score: scoreTask(t) }))
|
|
585
|
+
.sort((a, b) => b._score - a._score)
|
|
586
|
+
|
|
587
|
+
const top = scored[0]
|
|
588
|
+
|
|
589
|
+
// Build the reason and next step
|
|
590
|
+
let reason, nextStep
|
|
591
|
+
if (top.github?.changesRequestedAt) {
|
|
592
|
+
reason = `PR has changes requested — this blocks the merge and the reviewer is waiting.`
|
|
593
|
+
nextStep = `Call fix_pr_feedback with taskAId="${top._id}" to get the full fix plan.`
|
|
594
|
+
} else if (top.column === 'in_progress' && top.github?.headBranch) {
|
|
595
|
+
reason = `Already in progress on branch ${top.github.headBranch} — keep the momentum going.`
|
|
596
|
+
nextStep = `Continue coding on ${top.github.headBranch}. When commits are pushed, call raise_pr.`
|
|
597
|
+
} else if (top.column === 'in_progress' && !top.github?.headBranch) {
|
|
598
|
+
reason = `In progress but no branch yet — needs a branch to start committing.`
|
|
599
|
+
nextStep = `Call create_branch with taskId="${top._id}" and projectId="${top.project?._id || top.project}".`
|
|
600
|
+
} else if (top.column === 'todo') {
|
|
601
|
+
reason = `Highest priority unstarted task — ready to kick off.`
|
|
602
|
+
nextStep = `Call kickoff_task with taskId="${top._id}" to read the plan and move to in_progress.`
|
|
603
|
+
} else {
|
|
604
|
+
reason = `Highest priority available task.`
|
|
605
|
+
nextStep = `Call get_task_context with taskId="${top._id}" for full details.`
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Show the queue (top 3)
|
|
609
|
+
const queue = scored.slice(0, 3).map((t, i) => ({
|
|
610
|
+
rank: i + 1,
|
|
611
|
+
key: t.key,
|
|
612
|
+
title: t.title,
|
|
613
|
+
priority: t.priority,
|
|
614
|
+
column: t.column,
|
|
615
|
+
score: t._score,
|
|
616
|
+
branch: t.github?.headBranch || null,
|
|
617
|
+
needsFix: !!t.github?.changesRequestedAt,
|
|
618
|
+
}))
|
|
619
|
+
|
|
620
|
+
return text({
|
|
621
|
+
recommendation: {
|
|
622
|
+
taskId: top._id,
|
|
623
|
+
key: top.key,
|
|
624
|
+
title: top.title,
|
|
625
|
+
priority: top.priority,
|
|
626
|
+
column: top.column,
|
|
627
|
+
project: top.project?.name || top.project,
|
|
628
|
+
reason,
|
|
629
|
+
nextStep,
|
|
630
|
+
},
|
|
631
|
+
queue,
|
|
406
632
|
})
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
// ── task_health_check ────────────────────────────────────────────────────────
|
|
637
|
+
server.tool(
|
|
638
|
+
'task_health_check',
|
|
639
|
+
`Scan the entire project board for health issues and surface them with severity levels.
|
|
640
|
+
|
|
641
|
+
Detects:
|
|
642
|
+
- Critical tasks sitting in todo without a branch (needs kickoff now)
|
|
643
|
+
- Tasks in review for 3+ days (reviewer may be stuck)
|
|
644
|
+
- Tasks in progress for 2+ days with no updates (possibly stale or blocked)
|
|
645
|
+
- In-progress tasks with no branch linked
|
|
646
|
+
- Active tasks with no README/implementation plan
|
|
647
|
+
- PRs with changes requested that haven't been fixed
|
|
648
|
+
|
|
649
|
+
Use this when the developer or team lead asks "how's the board?", "any blockers?", or for a weekly health review.`,
|
|
650
|
+
{},
|
|
651
|
+
async () => {
|
|
652
|
+
const projectsRes = await api.get('/api/projects')
|
|
653
|
+
const projects = projectsRes?.data?.projects || []
|
|
654
|
+
|
|
655
|
+
const boards = await Promise.all(
|
|
656
|
+
projects.map(async p => {
|
|
657
|
+
try {
|
|
658
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
659
|
+
return r?.data?.project || null
|
|
660
|
+
} catch { return null }
|
|
661
|
+
})
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
const now = Date.now()
|
|
665
|
+
const MS_PER_DAY = 1000 * 60 * 60 * 24
|
|
666
|
+
const flags = []
|
|
667
|
+
|
|
668
|
+
for (const board of boards.filter(Boolean)) {
|
|
669
|
+
for (const t of (board.tasks || [])) {
|
|
670
|
+
if (t.column === 'done') continue
|
|
671
|
+
const ageDays = (now - new Date(t.updatedAt).getTime()) / MS_PER_DAY
|
|
672
|
+
const assignees = (t.assignees || []).map(a => a.name || a.email).join(', ') || 'Unassigned'
|
|
673
|
+
|
|
674
|
+
// PR changes requested — top priority
|
|
675
|
+
if (t.github?.changesRequestedAt) {
|
|
676
|
+
const daysSince = (now - new Date(t.github.changesRequestedAt).getTime()) / MS_PER_DAY
|
|
677
|
+
flags.push({
|
|
678
|
+
severity: 'critical',
|
|
679
|
+
task: t.key,
|
|
680
|
+
title: t.title,
|
|
681
|
+
project: board.name,
|
|
682
|
+
assignee: assignees,
|
|
683
|
+
issue: `PR has had changes requested for ${Math.floor(daysSince)} day(s) — developer needs to fix and re-push`,
|
|
684
|
+
suggestion: `Use fix_pr_feedback with taskAId="${t._id}"`,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Critical task stuck in todo
|
|
689
|
+
if (t.priority === 'critical' && t.column === 'todo') {
|
|
690
|
+
flags.push({
|
|
691
|
+
severity: 'critical',
|
|
692
|
+
task: t.key,
|
|
693
|
+
title: t.title,
|
|
694
|
+
project: board.name,
|
|
695
|
+
assignee: assignees,
|
|
696
|
+
issue: `CRITICAL priority — stuck in todo for ${Math.floor(ageDays)} day(s)`,
|
|
697
|
+
suggestion: `Use kickoff_task with taskId="${t._id}" to start immediately`,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// High priority in todo for more than 1 day
|
|
702
|
+
if (t.priority === 'high' && t.column === 'todo' && ageDays > 1) {
|
|
703
|
+
flags.push({
|
|
704
|
+
severity: 'warning',
|
|
705
|
+
task: t.key,
|
|
706
|
+
title: t.title,
|
|
707
|
+
project: board.name,
|
|
708
|
+
assignee: assignees,
|
|
709
|
+
issue: `HIGH priority — in todo for ${Math.floor(ageDays)} day(s) without being started`,
|
|
710
|
+
suggestion: `Use kickoff_task to start`,
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// In review for 3+ days
|
|
715
|
+
if (t.column === 'in_review' && ageDays > 3) {
|
|
716
|
+
flags.push({
|
|
717
|
+
severity: 'warning',
|
|
718
|
+
task: t.key,
|
|
719
|
+
title: t.title,
|
|
720
|
+
project: board.name,
|
|
721
|
+
assignee: assignees,
|
|
722
|
+
issue: `In review for ${Math.floor(ageDays)} days — reviewer may be stuck or PR is forgotten`,
|
|
723
|
+
suggestion: `Check PR status on GitHub or ping the reviewer`,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// In progress for 2+ days with no updates
|
|
728
|
+
if (t.column === 'in_progress' && ageDays > 2) {
|
|
729
|
+
flags.push({
|
|
730
|
+
severity: 'warning',
|
|
731
|
+
task: t.key,
|
|
732
|
+
title: t.title,
|
|
733
|
+
project: board.name,
|
|
734
|
+
assignee: assignees,
|
|
735
|
+
issue: `In progress for ${Math.floor(ageDays)} day(s) with no board updates — possibly stale or silently blocked`,
|
|
736
|
+
suggestion: `Ask ${assignees} for a status update or check git log on ${t.github?.headBranch || 'their branch'}`,
|
|
737
|
+
})
|
|
738
|
+
}
|
|
407
739
|
|
|
408
|
-
|
|
740
|
+
// In progress but no branch linked
|
|
741
|
+
if (t.column === 'in_progress' && !t.github?.headBranch) {
|
|
742
|
+
flags.push({
|
|
743
|
+
severity: 'warning',
|
|
744
|
+
task: t.key,
|
|
745
|
+
title: t.title,
|
|
746
|
+
project: board.name,
|
|
747
|
+
assignee: assignees,
|
|
748
|
+
issue: 'In progress but no branch linked — commits may be going to the wrong branch',
|
|
749
|
+
suggestion: `Use create_branch with taskId="${t._id}" to create and link a branch`,
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Active task with no README
|
|
754
|
+
if (['in_progress', 'in_review'].includes(t.column) && !t.readmeMarkdown?.trim()) {
|
|
755
|
+
flags.push({
|
|
756
|
+
severity: 'info',
|
|
757
|
+
task: t.key,
|
|
758
|
+
title: t.title,
|
|
759
|
+
project: board.name,
|
|
760
|
+
assignee: assignees,
|
|
761
|
+
issue: 'No implementation plan (README) — harder to review and hard to resume if parked',
|
|
762
|
+
suggestion: `Use update_task to add a readmeMarkdown implementation plan`,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Sort: critical → warning → info
|
|
769
|
+
const SEV = { critical: 0, warning: 1, info: 2 }
|
|
770
|
+
flags.sort((a, b) => (SEV[a.severity] ?? 9) - (SEV[b.severity] ?? 9))
|
|
771
|
+
|
|
772
|
+
const counts = {
|
|
773
|
+
critical: flags.filter(f => f.severity === 'critical').length,
|
|
774
|
+
warning: flags.filter(f => f.severity === 'warning').length,
|
|
775
|
+
info: flags.filter(f => f.severity === 'info').length,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return text({
|
|
779
|
+
summary: flags.length === 0
|
|
780
|
+
? '✅ Board is healthy — no issues found.'
|
|
781
|
+
: `⚠️ ${flags.length} issue(s): ${counts.critical} critical, ${counts.warning} warnings, ${counts.info} info`,
|
|
782
|
+
counts,
|
|
783
|
+
flags,
|
|
784
|
+
healthy: flags.length === 0,
|
|
785
|
+
})
|
|
409
786
|
}
|
|
410
787
|
)
|
|
411
788
|
|
|
@@ -1470,6 +1847,363 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
|
|
|
1470
1847
|
}
|
|
1471
1848
|
}
|
|
1472
1849
|
)
|
|
1850
|
+
|
|
1851
|
+
// ── commit_helper ─────────────────────────────────────────────────────────────
|
|
1852
|
+
server.tool(
|
|
1853
|
+
'commit_helper',
|
|
1854
|
+
`Analyse local git changes and produce a ready-to-run conventional commit command.
|
|
1855
|
+
|
|
1856
|
+
Auto-detects local branch, changed files, and — when taskId is given — the task's linked branch.
|
|
1857
|
+
If the current branch does not match the task's branch, presents three options before doing anything:
|
|
1858
|
+
A) Switch to the task branch first, bring changes with you, then commit
|
|
1859
|
+
B) Stash changes now and commit on the task branch later
|
|
1860
|
+
C) Commit on the current branch (warns if that is main/master/dev)
|
|
1861
|
+
|
|
1862
|
+
Commit message format: <type>(<scope>): <description>
|
|
1863
|
+
type = feat | fix | refactor | chore | docs | test | style (auto-inferred)
|
|
1864
|
+
scope = task key (e.g. task-003) when taskId is provided
|
|
1865
|
+
|
|
1866
|
+
Also flags unsafe patterns before generating any command:
|
|
1867
|
+
- Untracked IDE/config dirs (.cursor/, .idea/, .vscode/, node_modules/)
|
|
1868
|
+
- Committing directly to main / master / dev without a task branch
|
|
1869
|
+
|
|
1870
|
+
TWO-STEP FLOW:
|
|
1871
|
+
confirmed=false → detect state, show branch mismatch options or commit preview
|
|
1872
|
+
confirmed=true + branchAction → execute the chosen path
|
|
1873
|
+
|
|
1874
|
+
branchAction values (only needed when current branch ≠ task branch):
|
|
1875
|
+
"switch_then_commit" — stash → checkout task branch → pop stash → commit + push
|
|
1876
|
+
"stash_for_later" — stash only; commit later when on the task branch
|
|
1877
|
+
"commit_here" — commit on current branch (use only when changes truly belong here)`,
|
|
1878
|
+
{
|
|
1879
|
+
taskId: z.string().optional().describe("Task's MongoDB ObjectId"),
|
|
1880
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo'),
|
|
1881
|
+
confirmed: z.boolean().optional().default(false),
|
|
1882
|
+
branchAction: z.enum(['switch_then_commit', 'stash_for_later', 'commit_here'])
|
|
1883
|
+
.optional()
|
|
1884
|
+
.describe('Required when confirmed=true and current branch ≠ task branch'),
|
|
1885
|
+
},
|
|
1886
|
+
async ({ taskId, repoPath, confirmed = false, branchAction }) => {
|
|
1887
|
+
const cwd = repoPath || process.cwd()
|
|
1888
|
+
|
|
1889
|
+
// ── Read local git state ──────────────────────────────────────────────────
|
|
1890
|
+
let porcelain = '', diffStat = '', currentBranch = ''
|
|
1891
|
+
try {
|
|
1892
|
+
porcelain = runGit('status --porcelain=v1', cwd)
|
|
1893
|
+
diffStat = runGit('diff --stat HEAD', cwd)
|
|
1894
|
+
currentBranch = runGit('branch --show-current', cwd)
|
|
1895
|
+
} catch (e) {
|
|
1896
|
+
return errorText(`Could not read git state: ${e.message.split('\n')[0]}`)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const { staged, unstaged, untracked, modified } = parseGitStatus(porcelain)
|
|
1900
|
+
|
|
1901
|
+
if (modified.length === 0 && untracked.length === 0) {
|
|
1902
|
+
return text({ message: 'Nothing to commit — working tree is clean.', currentBranch })
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// ── Fetch task ────────────────────────────────────────────────────────────
|
|
1906
|
+
let task = null
|
|
1907
|
+
if (taskId) {
|
|
1908
|
+
try {
|
|
1909
|
+
const r = await api.get(`/api/tasks/${taskId}`)
|
|
1910
|
+
if (r?.success) task = r.data.task
|
|
1911
|
+
} catch { /* non-fatal */ }
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const taskBranch = task?.github?.headBranch || null
|
|
1915
|
+
const PROTECTED = ['main', 'master', 'dev', 'develop', 'staging', 'production']
|
|
1916
|
+
const onProtected = PROTECTED.includes(currentBranch)
|
|
1917
|
+
const branchMismatch = taskBranch && currentBranch !== taskBranch
|
|
1918
|
+
|
|
1919
|
+
// ── Detect unsafe untracked paths ─────────────────────────────────────────
|
|
1920
|
+
const NOCOMMIT_PATTERNS = ['.cursor', '.idea', '.vscode', 'node_modules', '.env', 'dist', 'build', '.DS_Store']
|
|
1921
|
+
const unsafeUntracked = untracked.filter(f =>
|
|
1922
|
+
NOCOMMIT_PATTERNS.some(p => f === p || f.startsWith(p + '/'))
|
|
1923
|
+
)
|
|
1924
|
+
const safeUntracked = untracked.filter(f =>
|
|
1925
|
+
!NOCOMMIT_PATTERNS.some(p => f === p || f.startsWith(p + '/'))
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
// ── Build commit message ──────────────────────────────────────────────────
|
|
1929
|
+
// Use task branch name for type inference if available (more reliable than current branch)
|
|
1930
|
+
const branchForType = taskBranch || currentBranch
|
|
1931
|
+
const branchLower = branchForType.toLowerCase()
|
|
1932
|
+
const diffLower = diffStat.toLowerCase()
|
|
1933
|
+
let commitType = 'chore'
|
|
1934
|
+
if (branchLower.startsWith('feature/') || branchLower.startsWith('feat/')) commitType = 'feat'
|
|
1935
|
+
else if (branchLower.startsWith('fix/') || branchLower.startsWith('hotfix/')) commitType = 'fix'
|
|
1936
|
+
else if (branchLower.startsWith('refactor/')) commitType = 'refactor'
|
|
1937
|
+
else if (branchLower.startsWith('docs/')) commitType = 'docs'
|
|
1938
|
+
else if (branchLower.startsWith('test/')) commitType = 'test'
|
|
1939
|
+
else if (diffLower.includes('test') || diffLower.includes('spec')) commitType = 'test'
|
|
1940
|
+
else if (task) {
|
|
1941
|
+
const t = (task.title || '').toLowerCase()
|
|
1942
|
+
if (/\b(fix|bug|hotfix|patch)\b/.test(t)) commitType = 'fix'
|
|
1943
|
+
else if (/\b(refactor|cleanup)\b/.test(t)) commitType = 'refactor'
|
|
1944
|
+
else commitType = 'feat'
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
const scope = task?.key?.toLowerCase() || ''
|
|
1948
|
+
let description = task
|
|
1949
|
+
? task.title.toLowerCase().replace(/[^a-z0-9 ]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
1950
|
+
: modified.length === 1
|
|
1951
|
+
? `update ${modified[0].split('/').pop()}`
|
|
1952
|
+
: `update ${modified.length} file(s)`
|
|
1953
|
+
|
|
1954
|
+
const commitMsg = scope ? `${commitType}(${scope}): ${description}` : `${commitType}: ${description}`
|
|
1955
|
+
|
|
1956
|
+
// ── Build add command — exclude unsafe untracked ──────────────────────────
|
|
1957
|
+
const trackedFiles = [...modified, ...safeUntracked]
|
|
1958
|
+
const addCmd = unsafeUntracked.length > 0 && (unstaged.length > 0 || safeUntracked.length > 0)
|
|
1959
|
+
? trackedFiles.map(f => `git add "${f}"`).join('\n') // explicit adds — skip unsafe
|
|
1960
|
+
: unstaged.length > 0 || safeUntracked.length > 0
|
|
1961
|
+
? 'git add .'
|
|
1962
|
+
: null // everything already staged
|
|
1963
|
+
|
|
1964
|
+
const changedFilesList = [
|
|
1965
|
+
...staged.map(f => `staged: ${f.file}`),
|
|
1966
|
+
...unstaged.map(f => `unstaged: ${f.file}`),
|
|
1967
|
+
...safeUntracked.map(f => `untracked: ${f}`),
|
|
1968
|
+
...unsafeUntracked.map(f => `⚠️ SKIP: ${f} ← do not commit this`),
|
|
1969
|
+
]
|
|
1970
|
+
|
|
1971
|
+
// ── PREVIEW (confirmed=false) ─────────────────────────────────────────────
|
|
1972
|
+
if (!confirmed) {
|
|
1973
|
+
// Case 1: branch mismatch — must resolve before anything else
|
|
1974
|
+
if (branchMismatch) {
|
|
1975
|
+
const stashMsg = `wip: ${scope || currentBranch} — switching to ${taskBranch}`
|
|
1976
|
+
return text({
|
|
1977
|
+
situation: {
|
|
1978
|
+
currentBranch,
|
|
1979
|
+
taskBranch,
|
|
1980
|
+
message: `You are on "${currentBranch}" but TASK-${task?.key} is linked to "${taskBranch}". Choose how to handle your local changes before committing.`,
|
|
1981
|
+
},
|
|
1982
|
+
changedFiles: changedFilesList,
|
|
1983
|
+
unsafeUntrackedWarning: unsafeUntracked.length
|
|
1984
|
+
? `These paths should NOT be committed — add them to .gitignore: ${unsafeUntracked.join(', ')}`
|
|
1985
|
+
: null,
|
|
1986
|
+
options: {
|
|
1987
|
+
A: {
|
|
1988
|
+
branchAction: 'switch_then_commit',
|
|
1989
|
+
description: 'Switch to the task branch now, bring your changes, then commit (recommended)',
|
|
1990
|
+
commands: [
|
|
1991
|
+
`git stash push -m "${stashMsg}"`,
|
|
1992
|
+
`git fetch origin`,
|
|
1993
|
+
`git checkout ${taskBranch}`,
|
|
1994
|
+
`git stash pop`,
|
|
1995
|
+
addCmd,
|
|
1996
|
+
`git commit -m "${commitMsg}"`,
|
|
1997
|
+
`git push origin ${taskBranch}`,
|
|
1998
|
+
].filter(Boolean),
|
|
1999
|
+
},
|
|
2000
|
+
B: {
|
|
2001
|
+
branchAction: 'stash_for_later',
|
|
2002
|
+
description: 'Stash changes now and commit on the task branch later',
|
|
2003
|
+
commands: [
|
|
2004
|
+
`git stash push -m "${stashMsg}"`,
|
|
2005
|
+
`# Later: git checkout ${taskBranch} && git stash pop`,
|
|
2006
|
+
],
|
|
2007
|
+
},
|
|
2008
|
+
C: {
|
|
2009
|
+
branchAction: 'commit_here',
|
|
2010
|
+
description: `Commit on "${currentBranch}" as-is${onProtected ? ' ⚠️ THIS IS A PROTECTED BRANCH' : ''}`,
|
|
2011
|
+
warning: onProtected
|
|
2012
|
+
? `"${currentBranch}" is a protected branch. Committing here directly bypasses the PR review process. Only do this for base repo changes (e.g. .gitignore, root README) that don't belong to any feature branch.`
|
|
2013
|
+
: null,
|
|
2014
|
+
commands: [addCmd, `git commit -m "${commitMsg}"`, `git push origin ${currentBranch}`].filter(Boolean),
|
|
2015
|
+
},
|
|
2016
|
+
},
|
|
2017
|
+
requiresConfirmation: true,
|
|
2018
|
+
message: `Call commit_helper again with confirmed=true and branchAction set to "switch_then_commit", "stash_for_later", or "commit_here".`,
|
|
2019
|
+
})
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// Case 2: on correct branch — standard preview
|
|
2023
|
+
const pushCmd = `git push origin ${currentBranch}`
|
|
2024
|
+
return text({
|
|
2025
|
+
preview: {
|
|
2026
|
+
suggestedMessage: commitMsg,
|
|
2027
|
+
type: commitType,
|
|
2028
|
+
scope,
|
|
2029
|
+
currentBranch,
|
|
2030
|
+
taskBranch: taskBranch || '(none linked)',
|
|
2031
|
+
onCorrectBranch: !branchMismatch,
|
|
2032
|
+
changedFiles: changedFilesList,
|
|
2033
|
+
},
|
|
2034
|
+
unsafeUntrackedWarning: unsafeUntracked.length
|
|
2035
|
+
? `These paths should NOT be committed — add them to .gitignore first: ${unsafeUntracked.join(', ')}`
|
|
2036
|
+
: null,
|
|
2037
|
+
protectedBranchWarning: onProtected
|
|
2038
|
+
? `⚠️ You are on "${currentBranch}" — a protected branch. Consider committing on a feature branch instead.`
|
|
2039
|
+
: null,
|
|
2040
|
+
commands: [addCmd, `git commit -m "${commitMsg}"`, pushCmd].filter(Boolean),
|
|
2041
|
+
requiresConfirmation: true,
|
|
2042
|
+
message: `Suggested: "${commitMsg}". Call commit_helper again with confirmed=true to get the final commands.`,
|
|
2043
|
+
})
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// ── CONFIRMED (confirmed=true) ────────────────────────────────────────────
|
|
2047
|
+
if (branchMismatch && !branchAction) {
|
|
2048
|
+
return text({
|
|
2049
|
+
blocked: true,
|
|
2050
|
+
message: `Branch mismatch detected. You must set branchAction to one of: "switch_then_commit", "stash_for_later", "commit_here". Call commit_helper with confirmed=false first to see the options.`,
|
|
2051
|
+
})
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
const stashMsg = `wip: ${scope || currentBranch} — switching to ${taskBranch}`
|
|
2055
|
+
|
|
2056
|
+
if (branchAction === 'switch_then_commit') {
|
|
2057
|
+
return text({
|
|
2058
|
+
plan: 'switch_then_commit',
|
|
2059
|
+
commands: [
|
|
2060
|
+
`git stash push -m "${stashMsg}"`,
|
|
2061
|
+
`git fetch origin`,
|
|
2062
|
+
`git checkout ${taskBranch}`,
|
|
2063
|
+
`git stash pop`,
|
|
2064
|
+
addCmd,
|
|
2065
|
+
`git commit -m "${commitMsg}"`,
|
|
2066
|
+
`git push origin ${taskBranch}`,
|
|
2067
|
+
].filter(Boolean),
|
|
2068
|
+
commitMessage: commitMsg,
|
|
2069
|
+
message: `Run these commands in order. Your changes will land on "${taskBranch}" where they belong.`,
|
|
2070
|
+
nextStep: `After pushing, call raise_pr to open the pull request for ${task?.key}.`,
|
|
2071
|
+
unsafeUntrackedWarning: unsafeUntracked.length
|
|
2072
|
+
? `These will be in your working tree after stash pop — do NOT git add them: ${unsafeUntracked.join(', ')}`
|
|
2073
|
+
: null,
|
|
2074
|
+
})
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (branchAction === 'stash_for_later') {
|
|
2078
|
+
return text({
|
|
2079
|
+
plan: 'stash_for_later',
|
|
2080
|
+
commands: [
|
|
2081
|
+
`git stash push -m "${stashMsg}"`,
|
|
2082
|
+
],
|
|
2083
|
+
message: `Your changes are stashed. When you're ready to commit:`,
|
|
2084
|
+
resumeCommands: [
|
|
2085
|
+
`git checkout ${taskBranch}`,
|
|
2086
|
+
`git stash pop`,
|
|
2087
|
+
addCmd,
|
|
2088
|
+
`git commit -m "${commitMsg}"`,
|
|
2089
|
+
`git push origin ${taskBranch}`,
|
|
2090
|
+
].filter(Boolean),
|
|
2091
|
+
commitMessage: commitMsg,
|
|
2092
|
+
})
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// commit_here (or no mismatch)
|
|
2096
|
+
const targetBranch = currentBranch
|
|
2097
|
+
const pushCmd = `git push origin ${targetBranch}`
|
|
2098
|
+
return text({
|
|
2099
|
+
commitMessage: commitMsg,
|
|
2100
|
+
commands: [addCmd, `git commit -m "${commitMsg}"`, pushCmd].filter(Boolean),
|
|
2101
|
+
changedFiles: changedFilesList,
|
|
2102
|
+
protectedBranchWarning: onProtected
|
|
2103
|
+
? `⚠️ Committing directly to "${targetBranch}". Only appropriate for base repo changes that don't belong to a feature branch.`
|
|
2104
|
+
: null,
|
|
2105
|
+
unsafeUntrackedWarning: unsafeUntracked.length
|
|
2106
|
+
? `These were excluded from git add — add them to .gitignore: ${unsafeUntracked.join(', ')}`
|
|
2107
|
+
: null,
|
|
2108
|
+
message: `Copy-paste these commands in order.`,
|
|
2109
|
+
nextStep: taskBranch && !branchMismatch ? `After pushing, call raise_pr to open the pull request.` : null,
|
|
2110
|
+
})
|
|
2111
|
+
}
|
|
2112
|
+
)
|
|
2113
|
+
|
|
2114
|
+
// ── branch_cleanup ────────────────────────────────────────────────────────────
|
|
2115
|
+
server.tool(
|
|
2116
|
+
'branch_cleanup',
|
|
2117
|
+
`Find merged branches that are safe to delete — both on GitHub and locally.
|
|
2118
|
+
|
|
2119
|
+
Checks GitHub branches against tasks that have been merged (github.mergedAt set).
|
|
2120
|
+
Also runs "git branch --merged origin/main" locally to find stale local branches.
|
|
2121
|
+
|
|
2122
|
+
Set confirmed=false to preview the list, confirmed=true to get the exact delete commands.
|
|
2123
|
+
|
|
2124
|
+
Use this when the developer says "clean up branches", "delete merged branches", or after a sprint ends.`,
|
|
2125
|
+
{
|
|
2126
|
+
projectId: z.string().describe("Project's MongoDB ObjectId"),
|
|
2127
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo'),
|
|
2128
|
+
confirmed: z.boolean().optional().default(false).describe('Set true to get the delete commands'),
|
|
2129
|
+
},
|
|
2130
|
+
async ({ projectId, repoPath, confirmed = false }) => {
|
|
2131
|
+
if (scopedProjectId && projectId !== scopedProjectId) {
|
|
2132
|
+
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Fetch GitHub branches
|
|
2136
|
+
const branchesRes = await api.get(`/api/projects/${projectId}/github/branches`)
|
|
2137
|
+
if (!branchesRes?.success) return errorText('Could not fetch GitHub branches')
|
|
2138
|
+
const remoteBranches = (branchesRes.data.branches || []).map(b => b.name || b)
|
|
2139
|
+
|
|
2140
|
+
// Fetch all tasks to find which branches are merged
|
|
2141
|
+
const projectRes = await api.get(`/api/projects/${projectId}`)
|
|
2142
|
+
const tasks = projectRes?.data?.project?.tasks || []
|
|
2143
|
+
const defaultBranch = projectRes?.data?.project?.githubDefaultBranch || 'main'
|
|
2144
|
+
|
|
2145
|
+
// Branches linked to merged tasks
|
|
2146
|
+
const mergedTaskBranches = new Set(
|
|
2147
|
+
tasks
|
|
2148
|
+
.filter(t => t.github?.mergedAt && t.github?.headBranch)
|
|
2149
|
+
.map(t => t.github.headBranch)
|
|
2150
|
+
)
|
|
2151
|
+
|
|
2152
|
+
// Remote branches safe to delete (merged task branch, not the default branch)
|
|
2153
|
+
const remoteToDelete = remoteBranches.filter(b =>
|
|
2154
|
+
mergedTaskBranches.has(b) && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev'
|
|
2155
|
+
)
|
|
2156
|
+
|
|
2157
|
+
// Local merged branches
|
|
2158
|
+
const cwd = repoPath || process.cwd()
|
|
2159
|
+
let localMerged = []
|
|
2160
|
+
try {
|
|
2161
|
+
const localRaw = runGit(`branch --merged origin/${defaultBranch}`, cwd)
|
|
2162
|
+
localMerged = localRaw
|
|
2163
|
+
.split('\n')
|
|
2164
|
+
.map(b => b.replace(/^\*?\s+/, '').trim())
|
|
2165
|
+
.filter(b => b && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev')
|
|
2166
|
+
} catch { /* git may not be available or no upstream */ }
|
|
2167
|
+
|
|
2168
|
+
if (!confirmed) {
|
|
2169
|
+
return text({
|
|
2170
|
+
preview: {
|
|
2171
|
+
remoteBranchesToDelete: remoteToDelete,
|
|
2172
|
+
localBranchesToDelete: localMerged,
|
|
2173
|
+
defaultBranch,
|
|
2174
|
+
remoteCount: remoteToDelete.length,
|
|
2175
|
+
localCount: localMerged.length,
|
|
2176
|
+
},
|
|
2177
|
+
requiresConfirmation: true,
|
|
2178
|
+
message: remoteToDelete.length === 0 && localMerged.length === 0
|
|
2179
|
+
? 'No merged branches found to clean up. Repository is already clean.'
|
|
2180
|
+
: `Found ${remoteToDelete.length} remote and ${localMerged.length} local branch(es) to delete. Call branch_cleanup again with confirmed=true to get the delete commands.`,
|
|
2181
|
+
safetyNote: 'Only branches linked to tasks with a merged PR are included. Default branch, main, master, and dev are always excluded.',
|
|
2182
|
+
})
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const remoteDeleteCmds = remoteToDelete.map(b => `git push origin --delete ${b}`)
|
|
2186
|
+
const localDeleteCmds = localMerged.map(b => `git branch -d ${b}`)
|
|
2187
|
+
const allCmds = [
|
|
2188
|
+
'# Fetch latest remote state first',
|
|
2189
|
+
`git fetch origin --prune`,
|
|
2190
|
+
'',
|
|
2191
|
+
remoteDeleteCmds.length ? '# Delete merged remote branches' : null,
|
|
2192
|
+
...remoteDeleteCmds,
|
|
2193
|
+
'',
|
|
2194
|
+
localDeleteCmds.length ? '# Delete merged local branches' : null,
|
|
2195
|
+
...localDeleteCmds,
|
|
2196
|
+
].filter(v => v !== null)
|
|
2197
|
+
|
|
2198
|
+
return text({
|
|
2199
|
+
commands: allCmds,
|
|
2200
|
+
remoteBranchesDeleted: remoteToDelete,
|
|
2201
|
+
localBranchesDeleted: localMerged,
|
|
2202
|
+
message: `Run the commands above to clean up ${remoteToDelete.length} remote and ${localMerged.length} local branch(es).`,
|
|
2203
|
+
tip: 'git fetch --prune also removes stale remote-tracking refs automatically.',
|
|
2204
|
+
})
|
|
2205
|
+
}
|
|
2206
|
+
)
|
|
1473
2207
|
}
|
|
1474
2208
|
|
|
1475
2209
|
function registerAdminTools(server) {
|
package/package.json
CHANGED