forge-workflow 0.0.5 → 0.0.7

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.
Files changed (164) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. package/scripts/test.js +18 -1
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env bash
2
+ # dashboard.sh — Team health dashboard for forge-team.
3
+ #
4
+ # Provides cmd_dashboard [--format=json] which aggregates team issue data
5
+ # from Beads and displays per-developer stats, stale assignments, and
6
+ # blocked issues.
7
+
8
+ # ── Dependencies ─────────────────────────────────────────────────────────
9
+ # BD_CMD — path to bd binary (default: bd)
10
+ BD_CMD="${BD_CMD:-bd}"
11
+
12
+ # FORGE_NOW — override current epoch for testing (default: real time)
13
+ _now() {
14
+ if [[ -n "${FORGE_NOW:-}" ]]; then
15
+ echo "$FORGE_NOW"
16
+ else
17
+ date +%s
18
+ fi
19
+ }
20
+
21
+ # Convert ISO8601 timestamp to epoch seconds
22
+ _iso_to_epoch() {
23
+ local ts="$1"
24
+ # Remove trailing Z and replace T with space for date parsing
25
+ local clean="${ts%Z}"
26
+ clean="${clean//T/ }"
27
+ # Use date -d for GNU date (Linux/Git Bash)
28
+ if date -d "$clean UTC" +%s 2>/dev/null; then
29
+ return
30
+ fi
31
+ # Fallback: manual parse YYYY-MM-DD HH:MM:SS
32
+ # This handles the common case in Git Bash on Windows
33
+ local year month day hour min sec
34
+ IFS='-: ' read -r year month day hour min sec <<< "$clean"
35
+ # Use python if available
36
+ if command -v python3 &>/dev/null; then
37
+ python3 -c "import calendar,datetime; print(int(calendar.timegm(datetime.datetime($year,$month,$day,$hour,$min,$sec).timetuple())))"
38
+ elif command -v python &>/dev/null; then
39
+ python -c "import calendar,datetime; print(int(calendar.timegm(datetime.datetime($year,$month,$day,$hour,$min,$sec).timetuple())))"
40
+ else
41
+ # Last resort: approximate with GNU date
42
+ echo 0
43
+ fi
44
+ }
45
+
46
+ # ── Data Collection ──────────────────────────────────────────────────────
47
+
48
+ # Parse bd list output into issue IDs and titles.
49
+ # Each line: "◐ forge-aaa · Feature A" or "○ forge-bbb · Feature B"
50
+ _parse_issue_list() {
51
+ local line
52
+ while IFS= read -r line; do
53
+ [[ -z "$line" ]] && continue
54
+ # Extract issue ID: between status icon and " · "
55
+ local id title
56
+ id="$(echo "$line" | sed -E 's/^[^ ]+ +([^ ]+) +·.*/\1/')"
57
+ title="$(echo "$line" | sed -E 's/^[^ ]+ +[^ ]+ +· +(.*)/\1/')"
58
+ echo "$id|$title"
59
+ done
60
+ }
61
+
62
+ # Get issue details: status, owner, updated timestamp, blocked-by list
63
+ # Output: id|title|status|owner|updated_epoch|blocked_by
64
+ _get_issue_details() {
65
+ local issue_id="$1"
66
+ local show_output
67
+ show_output="$($BD_CMD show "$issue_id" 2>/dev/null)" || return 1
68
+
69
+ local status="OPEN" owner="" updated="" blocked_by="" title=""
70
+
71
+ local line
72
+ local in_depends=0
73
+ while IFS= read -r line; do
74
+ # First line has status: "◐ forge-aaa · Feature A [● P2 · IN_PROGRESS]"
75
+ if [[ "$line" =~ \[.*·[[:space:]]*(OPEN|IN_PROGRESS|BLOCKED)\] ]]; then
76
+ status="${BASH_REMATCH[1]}"
77
+ # Extract title from first line
78
+ title="$(echo "$line" | sed -E 's/^[^ ]+ +[^ ]+ +· +(.+) +\[.*/\1/')"
79
+ fi
80
+
81
+ # Owner line
82
+ if [[ "$line" =~ ^Owner:[[:space:]]*(.+) ]]; then
83
+ owner="${BASH_REMATCH[1]}"
84
+ fi
85
+
86
+ # Updated line
87
+ if [[ "$line" =~ ^Updated:[[:space:]]*(.+) ]]; then
88
+ updated="${BASH_REMATCH[1]}"
89
+ fi
90
+
91
+ # DEPENDS ON section
92
+ if [[ "$line" == "DEPENDS ON" ]]; then
93
+ in_depends=1
94
+ continue
95
+ fi
96
+
97
+ if [[ $in_depends -eq 1 && "$line" =~ →.*([a-z]+-[a-z]+):.* ]]; then
98
+ # Extract dependency issue ID
99
+ local dep_id
100
+ dep_id="$(echo "$line" | sed -E 's/.*→ +[^ ]+ +([a-z]+-[a-z0-9]+):.*/\1/')"
101
+ if [[ -n "$blocked_by" ]]; then
102
+ blocked_by="$blocked_by,$dep_id"
103
+ else
104
+ blocked_by="$dep_id"
105
+ fi
106
+ fi
107
+ done <<< "$show_output"
108
+
109
+ local updated_epoch=0
110
+ if [[ -n "$updated" ]]; then
111
+ updated_epoch="$(_iso_to_epoch "$updated")"
112
+ fi
113
+
114
+ echo "$issue_id|$title|$status|$owner|$updated_epoch|$blocked_by"
115
+ }
116
+
117
+ # ── Dashboard Command ────────────────────────────────────────────────────
118
+
119
+ cmd_dashboard() {
120
+ local format="text"
121
+
122
+ # Parse args
123
+ local arg
124
+ for arg in "$@"; do
125
+ case "$arg" in
126
+ --format=json) format="json" ;;
127
+ --format=*) echo "Error: unsupported format '${arg#--format=}'" >&2; return 1 ;;
128
+ esac
129
+ done
130
+
131
+ # 1. Get all open/in_progress issues
132
+ local issue_list
133
+ issue_list="$($BD_CMD list --status=open,in_progress 2>/dev/null)" || issue_list=""
134
+
135
+ # Check for empty
136
+ if [[ -z "$issue_list" ]]; then
137
+ if [[ "$format" == "json" ]]; then
138
+ echo '{"developers":{},"total":0,"stale":[],"blocked":[],"message":"No active issues — team is clear"}'
139
+ else
140
+ echo "No active issues — team is clear"
141
+ fi
142
+ return 0
143
+ fi
144
+
145
+ # 2. Parse issue IDs
146
+ local issues=()
147
+ while IFS= read -r entry; do
148
+ [[ -z "$entry" ]] && continue
149
+ issues+=("$entry")
150
+ done <<< "$(echo "$issue_list" | _parse_issue_list)"
151
+
152
+ # 3. Collect details for each issue
153
+ local now_epoch
154
+ now_epoch="$(_now)"
155
+ local stale_threshold=172800 # 48h in seconds
156
+
157
+ # Associative arrays for per-developer aggregation
158
+ declare -A dev_open dev_inprogress dev_blocked dev_stale
159
+
160
+ # Lists for stale and blocked sections
161
+ local stale_entries=()
162
+ local blocked_entries=()
163
+ local total_open=0 total_inprogress=0 total_blocked=0 total_stale=0
164
+
165
+ for entry in "${issues[@]}"; do
166
+ local issue_id="${entry%%|*}"
167
+
168
+ local details
169
+ details="$(_get_issue_details "$issue_id")" || continue
170
+
171
+ IFS='|' read -r _id _title _status _owner _updated_epoch _blocked_by <<< "$details"
172
+
173
+ # Default owner if empty
174
+ [[ -z "$_owner" ]] && _owner="unassigned"
175
+
176
+ # Initialize developer counters
177
+ [[ -z "${dev_open[$_owner]:-}" ]] && dev_open[$_owner]=0
178
+ [[ -z "${dev_inprogress[$_owner]:-}" ]] && dev_inprogress[$_owner]=0
179
+ [[ -z "${dev_blocked[$_owner]:-}" ]] && dev_blocked[$_owner]=0
180
+ [[ -z "${dev_stale[$_owner]:-}" ]] && dev_stale[$_owner]=0
181
+
182
+ # Count by status
183
+ case "$_status" in
184
+ OPEN)
185
+ dev_open[$_owner]=$(( ${dev_open[$_owner]} + 1 ))
186
+ total_open=$((total_open + 1))
187
+ ;;
188
+ IN_PROGRESS)
189
+ dev_inprogress[$_owner]=$(( ${dev_inprogress[$_owner]} + 1 ))
190
+ total_inprogress=$((total_inprogress + 1))
191
+ ;;
192
+ BLOCKED)
193
+ dev_blocked[$_owner]=$(( ${dev_blocked[$_owner]} + 1 ))
194
+ total_blocked=$((total_blocked + 1))
195
+ ;;
196
+ esac
197
+
198
+ # Check for blocked (has DEPENDS ON with open deps)
199
+ if [[ -n "$_blocked_by" ]]; then
200
+ dev_blocked[$_owner]=$(( ${dev_blocked[$_owner]} + 1 ))
201
+ total_blocked=$((total_blocked + 1))
202
+ blocked_entries+=("$_id|$_owner|$_title|$_blocked_by")
203
+ fi
204
+
205
+ # Check for stale (>48h since last update)
206
+ if [[ "$_updated_epoch" -gt 0 ]]; then
207
+ local age=$(( now_epoch - _updated_epoch ))
208
+ if [[ $age -gt $stale_threshold ]]; then
209
+ local hours=$(( age / 3600 ))
210
+ dev_stale[$_owner]=$(( ${dev_stale[$_owner]} + 1 ))
211
+ total_stale=$((total_stale + 1))
212
+ stale_entries+=("$_id|$_owner|$_title|${hours}h since last update")
213
+ fi
214
+ fi
215
+ done
216
+
217
+ local total_issues=${#issues[@]}
218
+
219
+ # 4. Output
220
+ if [[ "$format" == "json" ]]; then
221
+ _dashboard_json "$total_issues"
222
+ else
223
+ _dashboard_text "$total_issues"
224
+ fi
225
+ }
226
+
227
+ # ── Text Output ──────────────────────────────────────────────────────────
228
+
229
+ _dashboard_text() {
230
+ local total_issues="$1"
231
+
232
+ echo "Team Dashboard"
233
+ echo "══════════════"
234
+ echo ""
235
+
236
+ # Header
237
+ printf "%-16s %4s %11s %7s %5s\n" "Developer" "Open" "In Progress" "Blocked" "Stale"
238
+
239
+ # Get sorted list of developers
240
+ local devs=()
241
+ local dev
242
+ for dev in "${!dev_open[@]}"; do
243
+ devs+=("$dev")
244
+ done
245
+ # Sort developers
246
+ IFS=$'\n' devs=($(sort <<< "${devs[*]}")); unset IFS
247
+
248
+ for dev in "${devs[@]}"; do
249
+ printf "%-16s %4d %11d %7d %5d\n" \
250
+ "$dev" \
251
+ "${dev_open[$dev]:-0}" \
252
+ "${dev_inprogress[$dev]:-0}" \
253
+ "${dev_blocked[$dev]:-0}" \
254
+ "${dev_stale[$dev]:-0}"
255
+ done
256
+
257
+ echo ""
258
+
259
+ # Build total summary parts
260
+ local parts=()
261
+ [[ $total_inprogress -gt 0 ]] && parts+=("$total_inprogress in progress")
262
+ [[ $total_blocked -gt 0 ]] && parts+=("$total_blocked blocked")
263
+ [[ $total_stale -gt 0 ]] && parts+=("$total_stale stale")
264
+ [[ $total_open -gt 0 ]] && parts+=("$total_open open")
265
+
266
+ local summary=""
267
+ if [[ ${#parts[@]} -gt 0 ]]; then
268
+ summary=" ($(IFS=', '; echo "${parts[*]}"))"
269
+ fi
270
+ echo "Total: $total_issues issues${summary}"
271
+
272
+ # Stale section
273
+ if [[ ${#stale_entries[@]} -gt 0 ]]; then
274
+ echo ""
275
+ echo "Stale assignments (>48h):"
276
+ local entry
277
+ for entry in "${stale_entries[@]}"; do
278
+ IFS='|' read -r _id _owner _title _age <<< "$entry"
279
+ echo " ⚠ $_id [$_owner] — $_title ($_age)"
280
+ done
281
+ fi
282
+
283
+ # Blocked section
284
+ if [[ ${#blocked_entries[@]} -gt 0 ]]; then
285
+ echo ""
286
+ echo "Blocked issues:"
287
+ local entry
288
+ for entry in "${blocked_entries[@]}"; do
289
+ IFS='|' read -r _id _owner _title _deps <<< "$entry"
290
+ echo " ⚠ $_id [$_owner] — $_title (blocked by $_deps)"
291
+ done
292
+ fi
293
+ }
294
+
295
+ # ── JSON Output ──────────────────────────────────────────────────────────
296
+
297
+ _dashboard_json() {
298
+ local total_issues="$1"
299
+
300
+ # Build developers JSON
301
+ local dev_json="{"
302
+ local first=1
303
+ local devs=()
304
+ local dev
305
+ for dev in "${!dev_open[@]}"; do
306
+ devs+=("$dev")
307
+ done
308
+ IFS=$'\n' devs=($(sort <<< "${devs[*]}")); unset IFS
309
+
310
+ for dev in "${devs[@]}"; do
311
+ [[ $first -eq 0 ]] && dev_json+=","
312
+ first=0
313
+ dev_json+="\"$dev\":{\"open\":${dev_open[$dev]:-0},\"in_progress\":${dev_inprogress[$dev]:-0},\"blocked\":${dev_blocked[$dev]:-0},\"stale\":${dev_stale[$dev]:-0}}"
314
+ done
315
+ dev_json+="}"
316
+
317
+ # Build stale JSON array
318
+ local stale_json="["
319
+ first=1
320
+ local entry
321
+ for entry in "${stale_entries[@]}"; do
322
+ [[ $first -eq 0 ]] && stale_json+=","
323
+ first=0
324
+ IFS='|' read -r _id _owner _title _age <<< "$entry"
325
+ stale_json+="$(jq -n -c --arg id "$_id" --arg owner "$_owner" --arg title "$_title" --arg age "$_age" '{id:$id,owner:$owner,title:$title,age:$age}')"
326
+ done
327
+ stale_json+="]"
328
+
329
+ # Build blocked JSON array
330
+ local blocked_json="["
331
+ first=1
332
+ for entry in "${blocked_entries[@]}"; do
333
+ [[ $first -eq 0 ]] && blocked_json+=","
334
+ first=0
335
+ IFS='|' read -r _id _owner _title _deps <<< "$entry"
336
+ blocked_json+="$(jq -n -c --arg id "$_id" --arg owner "$_owner" --arg title "$_title" --arg deps "$_deps" '{id:$id,owner:$owner,title:$title,blocked_by:$deps}')"
337
+ done
338
+ blocked_json+="]"
339
+
340
+ echo "{\"developers\":$dev_json,\"total\":$total_issues,\"in_progress\":$total_inprogress,\"blocked\":$total_blocked,\"stale\":$total_stale,\"stale_entries\":$stale_json,\"blocked_entries\":$blocked_json}"
341
+ }
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/forge-team/lib/epic.sh — Epic rollup view
3
+ #
4
+ # Shows epic progress with per-developer breakdown and blocked issue tracking.
5
+ #
6
+ # Functions:
7
+ # cmd_epic — Top-level dispatcher entry point
8
+ #
9
+ # Env overrides (for testing):
10
+ # BD_CMD — Path to bd binary
11
+ #
12
+ # This file does NOT set errexit/pipefail — callers manage their own shell options.
13
+
14
+ # Guard against double-sourcing
15
+ if [[ -n "${_EPIC_LIB_LOADED:-}" ]]; then
16
+ return 0 2>/dev/null || true
17
+ fi
18
+ _EPIC_LIB_LOADED=1
19
+
20
+ # ── Internal helpers ─────────────────────────────────────────────────────
21
+
22
+ # _epic_get_bd — Returns the bd command path
23
+ _epic_get_bd() {
24
+ printf '%s' "${BD_CMD:-bd}"
25
+ }
26
+
27
+ # _epic_parse_blocks <bd-show-output>
28
+ # Extracts child issue IDs from the BLOCKS section.
29
+ # Each line in BLOCKS looks like: ← ✓ forge-child1: Child 1 (closed)
30
+ # Returns one ID per line.
31
+ _epic_parse_blocks() {
32
+ local bd_output="$1"
33
+ local in_blocks=0
34
+
35
+ while IFS= read -r line; do
36
+ if [[ "$line" == "BLOCKS" ]]; then
37
+ in_blocks=1
38
+ continue
39
+ fi
40
+ # Stop at next section header (non-indented, non-empty line after BLOCKS)
41
+ if [[ $in_blocks -eq 1 ]]; then
42
+ if [[ -z "$line" ]]; then
43
+ continue
44
+ fi
45
+ if [[ "$line" =~ ^[A-Z] ]]; then
46
+ break
47
+ fi
48
+ # Extract issue ID: matches forge-XXXX pattern after the arrow+status marker
49
+ local child_id
50
+ child_id="$(printf '%s' "$line" | sed -n 's/.*← [^ ]* \([a-z0-9-]*\):.*/\1/p')"
51
+ if [[ -n "$child_id" ]]; then
52
+ printf '%s\n' "$child_id"
53
+ fi
54
+ fi
55
+ done <<< "$bd_output"
56
+ }
57
+
58
+ # _epic_get_child_info <child-id>
59
+ # Returns: id|status|owner|title|blocked_by
60
+ _epic_get_child_info() {
61
+ local child_id="$1"
62
+ local bd_cmd
63
+ bd_cmd="$(_epic_get_bd)"
64
+
65
+ local child_output
66
+ child_output="$("$bd_cmd" show "$child_id" 2>/dev/null)" || return 1
67
+
68
+ local first_line
69
+ first_line="$(printf '%s' "$child_output" | head -1)"
70
+
71
+ # Parse status from the first character/marker
72
+ local status="open"
73
+ if [[ "$first_line" == *"CLOSED"* ]] || [[ "$first_line" == *"✓"* ]]; then
74
+ status="closed"
75
+ elif [[ "$first_line" == *"IN_PROGRESS"* ]] || [[ "$first_line" == *"◐"* ]]; then
76
+ status="in_progress"
77
+ fi
78
+
79
+ # Parse title — between · and [
80
+ local title
81
+ title="$(printf '%s' "$first_line" | sed -n 's/^[^ ]* [^ ]* · \(.*\) \[.*/\1/p')"
82
+ if [[ -z "$title" ]]; then
83
+ title="$child_id"
84
+ fi
85
+
86
+ # Parse owner from "Owner: xxx" line
87
+ local owner=""
88
+ owner="$(printf '%s' "$child_output" | sed -n 's/^Owner: *//p' | head -1)"
89
+
90
+ # Check for BLOCKED_BY section
91
+ local blocked_by=""
92
+ blocked_by="$(printf '%s' "$child_output" | sed -n '/^BLOCKED_BY$/,/^[A-Z]/{ /^ /{ s/.*→ \([a-z0-9-]*\):.*/\1/p; }; }')"
93
+
94
+ printf '%s|%s|%s|%s|%s' "$child_id" "$status" "$owner" "$title" "$blocked_by"
95
+ }
96
+
97
+ # _epic_status_icon <status>
98
+ _epic_status_icon() {
99
+ case "$1" in
100
+ closed) printf '%s' "✓" ;;
101
+ in_progress) printf '%s' "◐" ;;
102
+ *) printf '%s' "○" ;;
103
+ esac
104
+ }
105
+
106
+ # ── Public API ───────────────────────────────────────────────────────────
107
+
108
+ # cmd_epic <issue-id> [--format=json]
109
+ cmd_epic() {
110
+ local issue_id="${1:-}"
111
+ local format_flag="${2:-}"
112
+
113
+ if [[ -z "$issue_id" ]]; then
114
+ echo "ERROR: Usage: forge team epic <issue-id> [--format=json]" >&2
115
+ return 1
116
+ fi
117
+
118
+ local bd_cmd
119
+ bd_cmd="$(_epic_get_bd)"
120
+
121
+ # 1. Get epic details
122
+ local epic_output
123
+ epic_output="$("$bd_cmd" show "$issue_id" 2>/dev/null)" || {
124
+ echo "ERROR: Could not fetch epic $issue_id" >&2
125
+ return 1
126
+ }
127
+
128
+ # Parse epic title from first line
129
+ local epic_first_line
130
+ epic_first_line="$(printf '%s' "$epic_output" | head -1)"
131
+ local epic_title
132
+ epic_title="$(printf '%s' "$epic_first_line" | sed -n 's/^[^ ]* [^ ]* · \(.*\) \[.*/\1/p')"
133
+ if [[ -z "$epic_title" ]]; then
134
+ epic_title="$issue_id"
135
+ fi
136
+
137
+ # 2. Parse BLOCKS section to find child issues
138
+ local children_ids
139
+ children_ids="$(_epic_parse_blocks "$epic_output")"
140
+
141
+ # Handle empty epic
142
+ if [[ -z "$children_ids" ]]; then
143
+ if [[ "$format_flag" == "--format=json" ]]; then
144
+ cat <<ENDJSON
145
+ {"epic_id":"$issue_id","title":"$epic_title","total":0,"done":0,"in_progress":0,"open_count":0,"percentage":0,"children":[],"by_developer":{},"blocked":[]}
146
+ ENDJSON
147
+ return 0
148
+ fi
149
+ echo "Epic: $issue_id — $epic_title"
150
+ echo "No child issues"
151
+ return 0
152
+ fi
153
+
154
+ # 3. For each child: get status, assignee
155
+ local total=0
156
+ local done_count=0
157
+ local in_progress_count=0
158
+ local open_count=0
159
+ local blocked_list=""
160
+
161
+ # Arrays for child data (using indexed approach for bash 3 compat)
162
+ local child_lines=""
163
+ # Developer tracking: accumulate "dev:status" pairs
164
+ local dev_entries=""
165
+
166
+ while IFS= read -r child_id; do
167
+ [[ -z "$child_id" ]] && continue
168
+
169
+ local info
170
+ info="$(_epic_get_child_info "$child_id")" || continue
171
+
172
+ local c_id c_status c_owner c_title c_blocked
173
+ IFS='|' read -r c_id c_status c_owner c_title c_blocked <<< "$info"
174
+
175
+ total=$((total + 1))
176
+
177
+ case "$c_status" in
178
+ closed) done_count=$((done_count + 1)) ;;
179
+ in_progress) in_progress_count=$((in_progress_count + 1)) ;;
180
+ *) open_count=$((open_count + 1)) ;;
181
+ esac
182
+
183
+ # Track blocked children
184
+ if [[ -n "$c_blocked" ]]; then
185
+ if [[ -n "$blocked_list" ]]; then
186
+ blocked_list="$blocked_list"$'\n'"$c_id blocked by $c_blocked"
187
+ else
188
+ blocked_list="$c_id blocked by $c_blocked"
189
+ fi
190
+ fi
191
+
192
+ # Build child line for display
193
+ local icon
194
+ icon="$(_epic_status_icon "$c_status")"
195
+ local display_owner="${c_owner:-unassigned}"
196
+ child_lines="${child_lines} $icon $c_id [$display_owner] $c_title ($c_status)"$'\n'
197
+
198
+ # Track developer entries
199
+ if [[ -n "$c_owner" ]]; then
200
+ dev_entries="${dev_entries}${c_owner}:${c_status}"$'\n'
201
+ fi
202
+ done <<< "$children_ids"
203
+
204
+ # 4. Calculate completion percentage
205
+ local percentage=0
206
+ if [[ $total -gt 0 ]]; then
207
+ percentage=$(( (done_count * 100) / total ))
208
+ fi
209
+
210
+ # 5. Build per-developer breakdown
211
+ # Collect unique developers
212
+ local devs=""
213
+ while IFS= read -r entry; do
214
+ [[ -z "$entry" ]] && continue
215
+ local dev="${entry%%:*}"
216
+ if [[ -z "$devs" ]] || ! printf '%s' "$devs" | grep -qxF "$dev"; then
217
+ if [[ -n "$devs" ]]; then
218
+ devs="$devs"$'\n'"$dev"
219
+ else
220
+ devs="$dev"
221
+ fi
222
+ fi
223
+ done <<< "$dev_entries"
224
+
225
+ # JSON output
226
+ if [[ "$format_flag" == "--format=json" ]]; then
227
+ local children_json="["
228
+ local first_child=1
229
+ while IFS= read -r child_id; do
230
+ [[ -z "$child_id" ]] && continue
231
+ local info
232
+ info="$(_epic_get_child_info "$child_id")" || continue
233
+ local c_id c_status c_owner c_title c_blocked
234
+ IFS='|' read -r c_id c_status c_owner c_title c_blocked <<< "$info"
235
+
236
+ if [[ $first_child -eq 0 ]]; then
237
+ children_json="$children_json,"
238
+ fi
239
+ first_child=0
240
+ children_json="$children_json{\"id\":\"$c_id\",\"status\":\"$c_status\",\"owner\":\"$c_owner\",\"title\":\"$c_title\",\"blocked_by\":\"$c_blocked\"}"
241
+ done <<< "$children_ids"
242
+ children_json="$children_json]"
243
+
244
+ local dev_json="{"
245
+ local first_dev=1
246
+ while IFS= read -r dev; do
247
+ [[ -z "$dev" ]] && continue
248
+ local d_done=0 d_in_progress=0 d_open=0 d_total=0
249
+ while IFS= read -r entry; do
250
+ [[ -z "$entry" ]] && continue
251
+ local e_dev="${entry%%:*}"
252
+ local e_status="${entry#*:}"
253
+ if [[ "$e_dev" == "$dev" ]]; then
254
+ d_total=$((d_total + 1))
255
+ case "$e_status" in
256
+ closed) d_done=$((d_done + 1)) ;;
257
+ in_progress) d_in_progress=$((d_in_progress + 1)) ;;
258
+ *) d_open=$((d_open + 1)) ;;
259
+ esac
260
+ fi
261
+ done <<< "$dev_entries"
262
+
263
+ if [[ $first_dev -eq 0 ]]; then
264
+ dev_json="$dev_json,"
265
+ fi
266
+ first_dev=0
267
+ dev_json="$dev_json\"$dev\":{\"total\":$d_total,\"done\":$d_done,\"in_progress\":$d_in_progress,\"open\":$d_open}"
268
+ done <<< "$devs"
269
+ dev_json="$dev_json}"
270
+
271
+ local blocked_json="["
272
+ if [[ -n "$blocked_list" ]]; then
273
+ local first_blocked=1
274
+ while IFS= read -r bline; do
275
+ [[ -z "$bline" ]] && continue
276
+ if [[ $first_blocked -eq 0 ]]; then
277
+ blocked_json="$blocked_json,"
278
+ fi
279
+ first_blocked=0
280
+ blocked_json="$blocked_json\"$bline\""
281
+ done <<< "$blocked_list"
282
+ fi
283
+ blocked_json="$blocked_json]"
284
+
285
+ printf '{"epic_id":"%s","title":"%s","total":%d,"done":%d,"in_progress":%d,"open_count":%d,"percentage":%d,"children":%s,"by_developer":%s,"blocked":%s}\n' \
286
+ "$issue_id" "$epic_title" "$total" "$done_count" "$in_progress_count" "$open_count" "$percentage" \
287
+ "$children_json" "$dev_json" "$blocked_json"
288
+ return 0
289
+ fi
290
+
291
+ # Text output
292
+ echo "Epic: $issue_id — $epic_title"
293
+ echo "Progress: $done_count/$total ($percentage%)"
294
+ printf '%s' "$child_lines"
295
+
296
+ echo ""
297
+ echo "By developer:"
298
+ while IFS= read -r dev; do
299
+ [[ -z "$dev" ]] && continue
300
+ local d_done=0 d_in_progress=0 d_open=0 d_total=0
301
+ while IFS= read -r entry; do
302
+ [[ -z "$entry" ]] && continue
303
+ local e_dev="${entry%%:*}"
304
+ local e_status="${entry#*:}"
305
+ if [[ "$e_dev" == "$dev" ]]; then
306
+ d_total=$((d_total + 1))
307
+ case "$e_status" in
308
+ closed) d_done=$((d_done + 1)) ;;
309
+ in_progress) d_in_progress=$((d_in_progress + 1)) ;;
310
+ *) d_open=$((d_open + 1)) ;;
311
+ esac
312
+ fi
313
+ done <<< "$dev_entries"
314
+
315
+ local dev_parts=""
316
+ dev_parts="$d_done/$d_total done"
317
+ [[ $d_in_progress -gt 0 ]] && dev_parts="$dev_parts, $d_in_progress in progress"
318
+ [[ $d_open -gt 0 ]] && dev_parts="$dev_parts, $d_open open"
319
+ echo " $dev: $dev_parts"
320
+ done <<< "$devs"
321
+
322
+ echo ""
323
+ if [[ -n "$blocked_list" ]]; then
324
+ echo "Blocked:"
325
+ while IFS= read -r bline; do
326
+ [[ -z "$bline" ]] && continue
327
+ echo " $bline"
328
+ done <<< "$blocked_list"
329
+ else
330
+ echo "Blocked: none"
331
+ fi
332
+ }