@flydocs/cli 0.6.0-alpha.3 → 0.6.0-alpha.31

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 (151) hide show
  1. package/dist/cli.js +2053 -469
  2. package/package.json +1 -1
  3. package/template/.claude/CLAUDE.md +43 -48
  4. package/template/.claude/agents/implementation-agent.md +1 -1
  5. package/template/.claude/agents/pm-agent.md +1 -1
  6. package/template/.claude/commands/activate.md +1 -1
  7. package/template/.claude/commands/attach.md +1 -1
  8. package/template/.claude/commands/block.md +2 -2
  9. package/template/.claude/commands/capture.md +1 -1
  10. package/template/.claude/commands/close.md +1 -1
  11. package/template/.claude/commands/flydocs-setup.md +359 -72
  12. package/template/.claude/commands/flydocs-upgrade.md +26 -27
  13. package/template/.claude/commands/implement.md +1 -1
  14. package/template/.claude/commands/knowledge.md +61 -0
  15. package/template/.claude/commands/new-project.md +1 -1
  16. package/template/.claude/commands/onboard.md +275 -0
  17. package/template/.claude/commands/project-update.md +1 -1
  18. package/template/.claude/commands/refine.md +1 -1
  19. package/template/.claude/commands/review.md +1 -1
  20. package/template/.claude/commands/start-session.md +1 -1
  21. package/template/.claude/commands/status.md +1 -1
  22. package/template/.claude/commands/validate.md +1 -1
  23. package/template/.claude/commands/wrap-session.md +1 -1
  24. package/template/.claude/hooks/auto-approve.py +212 -0
  25. package/template/.claude/hooks/post-pr-check.py +108 -0
  26. package/template/.claude/hooks/post-transition-check.py +281 -0
  27. package/template/.claude/hooks/prompt-submit.py +554 -0
  28. package/template/.claude/hooks/session-start.py +262 -0
  29. package/template/.claude/hooks/stop-gate.py +162 -0
  30. package/template/.claude/settings.json +41 -4
  31. package/template/.claude/skills/README.md +23 -25
  32. package/template/.claude/skills/flydocs-workflow/SKILL.md +134 -42
  33. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +9 -8
  34. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +1 -0
  35. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +28 -17
  36. package/template/.claude/skills/flydocs-workflow/reference/graph-schema.md +116 -0
  37. package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +120 -0
  38. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +37 -15
  39. package/template/.claude/skills/flydocs-workflow/reference/service-descriptor-schema.md +260 -0
  40. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +26 -26
  41. package/template/.claude/skills/flydocs-workflow/scripts/_local/__init__.py +0 -0
  42. package/template/.claude/skills/{flydocs-local/scripts/flydocs_api.py → flydocs-workflow/scripts/_local/file_store.py} +137 -47
  43. package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +724 -0
  44. package/template/{.flydocs → .claude/skills/flydocs-workflow}/scripts/generate_manifest.py +4 -4
  45. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_build.py +132 -1
  46. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_query.py +18 -5
  47. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_session.py +1 -10
  48. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_update.py +4 -4
  49. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_utils.py +2 -1
  50. package/template/.claude/skills/flydocs-workflow/scripts/issues.py +738 -0
  51. package/template/.claude/skills/flydocs-workflow/scripts/projects.py +144 -0
  52. package/template/.claude/skills/flydocs-workflow/scripts/pull_services.py +128 -0
  53. package/template/.claude/skills/flydocs-workflow/scripts/push_service.py +132 -0
  54. package/template/.claude/skills/flydocs-workflow/scripts/session.py +54 -0
  55. package/template/.claude/skills/flydocs-workflow/scripts/test_enforcement.py +225 -0
  56. package/template/.claude/skills/flydocs-workflow/scripts/workspace.py +902 -0
  57. package/template/.claude/skills/flydocs-workflow/session.md +87 -29
  58. package/template/.claude/skills/flydocs-workflow/stages/activate.md +18 -7
  59. package/template/.claude/skills/flydocs-workflow/stages/capture.md +10 -5
  60. package/template/.claude/skills/flydocs-workflow/stages/close.md +4 -3
  61. package/template/.claude/skills/flydocs-workflow/stages/implement.md +33 -9
  62. package/template/.claude/skills/flydocs-workflow/stages/refine.md +22 -6
  63. package/template/.claude/skills/flydocs-workflow/stages/review.md +16 -4
  64. package/template/.claude/skills/flydocs-workflow/stages/validate.md +3 -1
  65. package/template/.claude/skills/flydocs-workflow/templates/pr/default.md +33 -0
  66. package/template/.cursor/agents/implementation-agent.md +1 -1
  67. package/template/.cursor/agents/pm-agent.md +2 -2
  68. package/template/.cursor/hooks.json +10 -3
  69. package/template/.env.example +6 -6
  70. package/template/.flydocs/config.json +5 -18
  71. package/template/.flydocs/templates/README.md +13 -14
  72. package/template/.flydocs/templates/bug.md +17 -153
  73. package/template/.flydocs/templates/chore.md +10 -98
  74. package/template/.flydocs/templates/feature.md +12 -158
  75. package/template/.flydocs/templates/idea.md +11 -111
  76. package/template/.flydocs/templates/quick-capture.md +4 -8
  77. package/template/.flydocs/version +1 -1
  78. package/template/AGENTS.md +44 -32
  79. package/template/CHANGELOG.md +37 -0
  80. package/template/flydocs/README.md +1 -3
  81. package/template/flydocs/context/project.md +6 -3
  82. package/template/flydocs/design-system/README.md +3 -3
  83. package/template/flydocs/knowledge/INDEX.md +38 -53
  84. package/template/flydocs/knowledge/README.md +60 -9
  85. package/template/flydocs/knowledge/templates/decision.md +47 -0
  86. package/template/flydocs/knowledge/templates/feature.md +35 -0
  87. package/template/flydocs/knowledge/templates/note.md +25 -0
  88. package/template/manifest.json +24 -20
  89. package/template/.claude/skills/flydocs-cloud/SKILL.md +0 -113
  90. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +0 -50
  91. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +0 -22
  92. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +0 -28
  93. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +0 -22
  94. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +0 -29
  95. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +0 -66
  96. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +0 -35
  97. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +0 -33
  98. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +0 -39
  99. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +0 -29
  100. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +0 -210
  101. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +0 -24
  102. package/template/.claude/skills/flydocs-cloud/scripts/link.py +0 -28
  103. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +0 -28
  104. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +0 -44
  105. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +0 -19
  106. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +0 -28
  107. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +0 -31
  108. package/template/.claude/skills/flydocs-cloud/scripts/list_providers.py +0 -19
  109. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +0 -19
  110. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +0 -29
  111. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +0 -45
  112. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +0 -68
  113. package/template/.claude/skills/flydocs-cloud/scripts/set_provider.py +0 -46
  114. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +0 -41
  115. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +0 -26
  116. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +0 -36
  117. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +0 -82
  118. package/template/.claude/skills/flydocs-context-graph/SKILL.md +0 -87
  119. package/template/.claude/skills/flydocs-context-graph/schema.md +0 -78
  120. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +0 -338
  121. package/template/.claude/skills/flydocs-context7/SKILL.md +0 -105
  122. package/template/.claude/skills/flydocs-context7/cursor-rule.mdc +0 -49
  123. package/template/.claude/skills/flydocs-context7/scripts/context7.py +0 -293
  124. package/template/.claude/skills/flydocs-estimates/SKILL.md +0 -384
  125. package/template/.claude/skills/flydocs-figma/SKILL.md +0 -377
  126. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +0 -108
  127. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +0 -112
  128. package/template/.claude/skills/flydocs-local/SKILL.md +0 -103
  129. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +0 -43
  130. package/template/.claude/skills/flydocs-local/scripts/assign.py +0 -20
  131. package/template/.claude/skills/flydocs-local/scripts/comment.py +0 -27
  132. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +0 -44
  133. package/template/.claude/skills/flydocs-local/scripts/estimate.py +0 -37
  134. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +0 -20
  135. package/template/.claude/skills/flydocs-local/scripts/link.py +0 -41
  136. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +0 -34
  137. package/template/.claude/skills/flydocs-local/scripts/priority.py +0 -37
  138. package/template/.claude/skills/flydocs-local/scripts/project_update.py +0 -67
  139. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +0 -16
  140. package/template/.claude/skills/flydocs-local/scripts/transition.py +0 -24
  141. package/template/.claude/skills/flydocs-local/scripts/update_description.py +0 -35
  142. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +0 -84
  143. package/template/.flydocs/hooks/auto-approve.py +0 -71
  144. package/template/.flydocs/hooks/prompt-submit.py +0 -277
  145. package/template/.flydocs/scripts/skill_manager.py +0 -541
  146. /package/template/{.flydocs → .claude}/hooks/post-edit.py +0 -0
  147. /package/template/.claude/skills/{flydocs-estimates/references → flydocs-workflow/reference}/provider-costs.md +0 -0
  148. /package/template/.claude/skills/flydocs-workflow/templates/{bug.md → issues/bug.md} +0 -0
  149. /package/template/.claude/skills/flydocs-workflow/templates/{chore.md → issues/chore.md} +0 -0
  150. /package/template/.claude/skills/flydocs-workflow/templates/{feature.md → issues/feature.md} +0 -0
  151. /package/template/.claude/skills/flydocs-workflow/templates/{idea.md → issues/idea.md} +0 -0
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env python3
2
+ """Issue operations dispatcher — create, get, list, transition, assign, update, and more."""
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).parent))
12
+ from flydocs_api import get_client, output_json, fail, resolve_text_input
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Subcommand handlers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def cmd_create(args: argparse.Namespace) -> None:
20
+ """Create a new issue.
21
+
22
+ Enforces required fields to prevent incomplete issues:
23
+ - Description must be non-empty unless --triage is set
24
+ - Title is already required by argparse
25
+ - Type is already required by argparse
26
+
27
+ Auto-resolves from config when not explicitly passed:
28
+ - Category labels from issueLabels.category
29
+ - Repo labels from issueLabels.repo (multi-repo workspaces)
30
+ - Project from workspace.activeProjects
31
+ - Milestone from workspace.defaultMilestoneId
32
+ """
33
+ # Description resolution: --description-file > stdin > --description > --template
34
+ description = resolve_text_input(file_arg=args.description_file)
35
+ if description is None:
36
+ description = args.description or ""
37
+
38
+ # Template fallback — read type template if no description provided
39
+ if not description.strip() and args.template:
40
+ template_path = Path(f".flydocs/templates/{args.type}.md")
41
+ if template_path.exists():
42
+ try:
43
+ raw = template_path.read_text()
44
+ # Strip agent instruction comments
45
+ import re as _re
46
+ description = _re.sub(r'<!--\s*AGENT:.*?-->\s*\n?', '', raw).strip()
47
+ except OSError:
48
+ pass
49
+
50
+ # Enforce non-empty description — triage bypasses with warning
51
+ if not description.strip():
52
+ if args.triage:
53
+ description = f"[Quick capture — needs refinement via /refine]\n\n{args.title}"
54
+ import sys as _sys
55
+ print(
56
+ "Note: Triage issue created with minimal description. "
57
+ "Run /refine to add full description and AC.",
58
+ file=_sys.stderr,
59
+ )
60
+ else:
61
+ template_path = f".flydocs/templates/{args.type}.md"
62
+ fail(
63
+ "Description is required when creating an issue. "
64
+ "Use --description, --description-file, or pipe to stdin.\n"
65
+ f"Read the template at {template_path} for the expected format, "
66
+ "then populate all sections before creating the issue."
67
+ )
68
+
69
+ # Auto-resolve assignee from me.json
70
+ assignee = args.assignee
71
+ if assignee in ("self", "me"):
72
+ me_file = Path(".flydocs/me.json")
73
+ if me_file.exists():
74
+ try:
75
+ me = json.loads(me_file.read_text())
76
+ assignee = me.get("providerId") or me.get("displayName")
77
+ except (json.JSONDecodeError, OSError):
78
+ fail("Could not read .flydocs/me.json — run: workspace.py get-me")
79
+ else:
80
+ fail("No .flydocs/me.json found — run: workspace.py get-me")
81
+
82
+ client = get_client()
83
+ result = client.create_issue(
84
+ title=args.title,
85
+ issue_type=args.type,
86
+ description=description,
87
+ priority=args.priority,
88
+ estimate=args.estimate,
89
+ assignee=assignee,
90
+ project=args.project,
91
+ milestone=args.milestone,
92
+ triage=args.triage,
93
+ )
94
+ output_json(result)
95
+
96
+
97
+ def cmd_get(args: argparse.Namespace) -> None:
98
+ """Get a single issue by reference."""
99
+ client = get_client()
100
+ result = client.get_issue(args.ref, fields=args.fields)
101
+ output_json(result)
102
+
103
+
104
+ def cmd_list(args: argparse.Namespace) -> None:
105
+ """List issues with optional filters."""
106
+ client = get_client()
107
+ result = client.list_issues(
108
+ status=args.status,
109
+ active=args.active,
110
+ project=args.project,
111
+ assignee=args.assignee,
112
+ milestone=args.milestone,
113
+ mine=args.mine,
114
+ show_all=args.show_all,
115
+ limit=args.limit,
116
+ )
117
+ output_json(result)
118
+
119
+
120
+ VALID_TRANSITIONS: dict[str, set[str]] = {
121
+ "BACKLOG": {"READY", "IMPLEMENTING", "CANCELED"},
122
+ "TRIAGE": {"BACKLOG", "READY", "IMPLEMENTING", "CANCELED"},
123
+ "READY": {"IMPLEMENTING", "CANCELED"},
124
+ "IMPLEMENTING": {"REVIEW", "BLOCKED", "CANCELED"},
125
+ "BLOCKED": {"IMPLEMENTING", "CANCELED"},
126
+ "REVIEW": {"COMPLETE", "TESTING", "IMPLEMENTING", "CANCELED"},
127
+ "TESTING": {"COMPLETE", "IMPLEMENTING", "CANCELED"},
128
+ }
129
+
130
+
131
+ def cmd_transition(args: argparse.Namespace) -> None:
132
+ """Transition an issue to a new status with a comment.
133
+
134
+ Validates:
135
+ - Comment is non-empty (not just whitespace)
136
+ - Transition is valid (from->to) based on current status
137
+ """
138
+ # Enforce non-empty comment
139
+ if not args.comment.strip():
140
+ fail(
141
+ "Transition comment cannot be empty. Every status transition "
142
+ "requires a meaningful comment describing what changed."
143
+ )
144
+
145
+ target = args.status.upper()
146
+
147
+ # Validate transition if we know the current status
148
+ status_file = Path(".flydocs/session/status")
149
+ if status_file.exists():
150
+ try:
151
+ current = status_file.read_text().strip().upper()
152
+ if current in VALID_TRANSITIONS:
153
+ allowed = VALID_TRANSITIONS[current]
154
+ if target not in allowed:
155
+ allowed_str = ", ".join(sorted(allowed))
156
+ fail(
157
+ f"Invalid transition: {current} -> {target}. "
158
+ f"Allowed targets from {current}: {allowed_str}"
159
+ )
160
+ except OSError:
161
+ pass
162
+
163
+ client = get_client()
164
+ result = client.transition(args.ref, target, args.comment)
165
+ output_json(result)
166
+
167
+
168
+ def cmd_assign(args: argparse.Namespace) -> None:
169
+ """Assign or unassign an issue."""
170
+ assignee = None if args.unassign else args.assignee
171
+ client = get_client()
172
+ result = client.assign(args.ref, assignee)
173
+ output_json(result)
174
+
175
+
176
+ def cmd_update(args: argparse.Namespace) -> None:
177
+ """Update one or more fields on an issue."""
178
+ # Description resolution: --description-file > --description
179
+ description = args.description
180
+ if args.description_file:
181
+ path = Path(args.description_file)
182
+ if not path.exists():
183
+ fail(f"File not found: {args.description_file}")
184
+ description = path.read_text()
185
+
186
+ fields: dict = {}
187
+ if args.title is not None:
188
+ fields["title"] = args.title
189
+ if args.priority is not None:
190
+ fields["priority"] = args.priority
191
+ if args.estimate is not None:
192
+ fields["estimate"] = args.estimate
193
+ if args.assignee is not None:
194
+ fields["assignee"] = args.assignee
195
+ if args.state is not None:
196
+ fields["state"] = args.state
197
+ if description is not None:
198
+ fields["description"] = description
199
+ if args.labels is not None:
200
+ fields["labels"] = args.labels
201
+ if args.milestone is not None:
202
+ fields["milestone"] = args.milestone
203
+ if args.comment is not None:
204
+ fields["comment"] = args.comment
205
+
206
+ if not fields:
207
+ fail("No fields to update")
208
+
209
+ client = get_client()
210
+ result = client.update_issue(args.ref, **fields)
211
+ output_json(result)
212
+
213
+
214
+ def cmd_description(args: argparse.Namespace) -> None:
215
+ """Update an issue's description."""
216
+ # Text resolution: --file > stdin > --text
217
+ text = resolve_text_input(text_arg=args.text, file_arg=args.file)
218
+ if text is None:
219
+ fail("No description text provided (use --text, --file, or pipe to stdin)")
220
+
221
+ client = get_client()
222
+ result = client.update_description(args.ref, text)
223
+ output_json(result)
224
+
225
+
226
+ def cmd_comment(args: argparse.Namespace) -> None:
227
+ """Add a comment to an issue."""
228
+ body = args.body
229
+ if body is None and not sys.stdin.isatty():
230
+ body = sys.stdin.read().strip()
231
+ if not body:
232
+ fail("No comment body provided (pass as argument or pipe to stdin)")
233
+
234
+ client = get_client()
235
+ result = client.comment(args.ref, body)
236
+ output_json(result)
237
+
238
+
239
+ def cmd_estimate(args: argparse.Namespace) -> None:
240
+ """Set estimate points on an issue."""
241
+ if args.points < 0:
242
+ fail("Estimate points must be non-negative")
243
+
244
+ client = get_client()
245
+ result = client.estimate(args.ref, args.points)
246
+ output_json(result)
247
+
248
+
249
+ def cmd_priority(args: argparse.Namespace) -> None:
250
+ """Set priority level on an issue."""
251
+ if args.level < 0 or args.level > 4:
252
+ fail("Priority level must be 0-4")
253
+
254
+ client = get_client()
255
+ result = client.priority(args.ref, args.level)
256
+ output_json(result)
257
+
258
+
259
+ def cmd_link(args: argparse.Namespace) -> None:
260
+ """Link two issues together."""
261
+ client = get_client()
262
+ result = client.link(args.ref, args.related_ref, args.type)
263
+ output_json(result)
264
+
265
+
266
+ def cmd_assign_milestone(args: argparse.Namespace) -> None:
267
+ """Assign an issue to a milestone."""
268
+ client = get_client()
269
+ result = client.assign_milestone(args.ref, args.milestone_id)
270
+ output_json(result)
271
+
272
+
273
+ def cmd_assign_cycle(args: argparse.Namespace) -> None:
274
+ """Assign an issue to a cycle (or remove from current cycle)."""
275
+ client = get_client()
276
+ result = client.assign_cycle(args.ref, args.cycle_id)
277
+ output_json(result)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # PR creation
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _detect_platform() -> str:
285
+ """Detect git hosting platform from remote URL. Returns github|gitlab|bitbucket|unknown."""
286
+ try:
287
+ result = subprocess.run(
288
+ ["git", "remote", "get-url", "origin"],
289
+ capture_output=True, text=True, timeout=5,
290
+ )
291
+ url = result.stdout.strip().lower()
292
+ if "github" in url:
293
+ return "github"
294
+ if "gitlab" in url:
295
+ return "gitlab"
296
+ if "bitbucket" in url:
297
+ return "bitbucket"
298
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
299
+ pass
300
+ return "unknown"
301
+
302
+
303
+ def _get_current_branch() -> str:
304
+ """Get current git branch name."""
305
+ try:
306
+ result = subprocess.run(
307
+ ["git", "branch", "--show-current"],
308
+ capture_output=True, text=True, timeout=5,
309
+ )
310
+ return result.stdout.strip()
311
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
312
+ return ""
313
+
314
+
315
+ def _get_default_branch() -> str:
316
+ """Detect the default branch (main or master)."""
317
+ try:
318
+ result = subprocess.run(
319
+ ["git", "rev-parse", "--verify", "refs/heads/main"],
320
+ capture_output=True, timeout=5,
321
+ )
322
+ if result.returncode == 0:
323
+ return "main"
324
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
325
+ pass
326
+ return "master"
327
+
328
+
329
+ def _build_pr_body(issue_ref: str | None, summary: str | None) -> str:
330
+ """Build PR body from template, optionally populating from issue data."""
331
+ # Read the PR template
332
+ script_dir = Path(__file__).parent
333
+ template_path = script_dir.parent / "templates" / "pr" / "default.md"
334
+
335
+ if template_path.exists():
336
+ body = template_path.read_text()
337
+ else:
338
+ body = "## Summary\n\n{ISSUE_SUMMARY}\n\n## Changes\n\n- \n\n## Test Plan\n\n- [ ] \n\n## Notes\n\n"
339
+
340
+ # Populate issue context if available
341
+ if issue_ref:
342
+ body = body.replace("{ISSUE_REF}", issue_ref)
343
+ try:
344
+ client = get_client()
345
+ issue = client.get_issue(issue_ref, fields="full")
346
+ if isinstance(issue, dict):
347
+ title = issue.get("title", "")
348
+ description = issue.get("description", "")
349
+
350
+ # Extract AC checkboxes from description
351
+ ac_lines = []
352
+ if description:
353
+ for line in description.splitlines():
354
+ if re.match(r"^\s*-\s*\[[ x]\]", line, re.IGNORECASE):
355
+ ac_lines.append(line)
356
+
357
+ body = body.replace("{ISSUE_SUMMARY}", title)
358
+ body = body.replace(
359
+ "{ACCEPTANCE_CRITERIA}",
360
+ "\n".join(ac_lines) if ac_lines else "See issue for acceptance criteria.",
361
+ )
362
+ except Exception:
363
+ body = body.replace("{ISSUE_SUMMARY}", summary or "")
364
+ body = body.replace("{ACCEPTANCE_CRITERIA}", "See issue for acceptance criteria.")
365
+ else:
366
+ body = body.replace("{ISSUE_REF}", "")
367
+ body = body.replace("{ISSUE_SUMMARY}", summary or "")
368
+ body = body.replace("{ACCEPTANCE_CRITERIA}", "")
369
+
370
+ # Clean up remaining placeholders
371
+ body = body.replace("{CHANGE_1}", "")
372
+ body = body.replace("{CHANGE_2}", "")
373
+ body = body.replace("{TEST_1}", "")
374
+ body = body.replace("{TEST_2}", "")
375
+ body = body.replace("{NOTES}", "")
376
+
377
+ return body
378
+
379
+
380
+ def cmd_pr(args: argparse.Namespace) -> None:
381
+ """Create a pull/merge request with standard template."""
382
+ platform = _detect_platform()
383
+ branch = _get_current_branch()
384
+ base = args.base or _get_default_branch()
385
+
386
+ if not branch or branch == base:
387
+ fail(f"Cannot create PR from branch '{branch}' — switch to a feature branch first.")
388
+
389
+ # Build title
390
+ title = args.title
391
+ if not title and args.issue:
392
+ try:
393
+ client = get_client()
394
+ issue = client.get_issue(args.issue, fields="basic")
395
+ if isinstance(issue, dict):
396
+ title = issue.get("title", args.issue)
397
+ except Exception:
398
+ title = args.issue
399
+ if not title:
400
+ title = branch
401
+
402
+ # Build body from template
403
+ body = _build_pr_body(issue_ref=args.issue, summary=title)
404
+
405
+ if args.dry_run:
406
+ output_json({
407
+ "platform": platform,
408
+ "branch": branch,
409
+ "base": base,
410
+ "title": title,
411
+ "body": body,
412
+ })
413
+ return
414
+
415
+ # Create PR via platform CLI
416
+ if platform == "github":
417
+ cmd = ["gh", "pr", "create", "--title", title, "--body", body, "--base", base]
418
+ if args.draft:
419
+ cmd.append("--draft")
420
+ elif platform == "gitlab":
421
+ cmd = ["glab", "mr", "create", "--title", title, "--description", body, "--target-branch", base]
422
+ if args.draft:
423
+ cmd.append("--draft")
424
+ elif platform == "bitbucket":
425
+ fail("Bitbucket PR creation via CLI is not yet supported. Use the web UI or Bitbucket API.")
426
+ else:
427
+ fail(f"Could not detect git platform from remote URL. Detected: {platform}")
428
+
429
+ try:
430
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
431
+ if result.returncode != 0:
432
+ fail(f"PR creation failed: {result.stderr.strip()}")
433
+
434
+ pr_url = result.stdout.strip()
435
+
436
+ # Post PR link as comment on the issue
437
+ if args.issue:
438
+ try:
439
+ client = get_client()
440
+ client.comment(args.issue, f"PR created: {pr_url}")
441
+ except Exception:
442
+ pass # Non-blocking — PR was created successfully
443
+
444
+ output_json({
445
+ "success": True,
446
+ "platform": platform,
447
+ "url": pr_url,
448
+ "branch": branch,
449
+ "base": base,
450
+ })
451
+ except FileNotFoundError:
452
+ cli = "gh" if platform == "github" else "glab"
453
+ fail(f"{cli} CLI not found. Install it to create PRs from the command line.")
454
+ except subprocess.TimeoutExpired:
455
+ fail("PR creation timed out.")
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # Audit
460
+ # ---------------------------------------------------------------------------
461
+
462
+ CHECKBOX_PATTERN = re.compile(r"^\s*-\s*\[[ x]\]", re.MULTILINE | re.IGNORECASE)
463
+
464
+
465
+ def cmd_audit(args: argparse.Namespace) -> None:
466
+ """Audit issues for workflow compliance.
467
+
468
+ Checks for: missing descriptions, missing labels, missing AC checkboxes,
469
+ unassigned in-progress issues, and issues in wrong states.
470
+ """
471
+ client = get_client()
472
+ issues_data = client.list_issues(
473
+ status=args.status,
474
+ show_all=True,
475
+ limit=args.limit,
476
+ )
477
+
478
+ # Handle both list and dict response shapes
479
+ issues = issues_data if isinstance(issues_data, list) else issues_data.get("issues", [])
480
+
481
+ findings: list[dict] = []
482
+
483
+ for issue in issues:
484
+ ref = issue.get("identifier", "?")
485
+ title = issue.get("title", "")
486
+ status = issue.get("status", "").upper()
487
+ description = issue.get("description", "") or ""
488
+ assignee = issue.get("assignee")
489
+ labels = issue.get("labels", [])
490
+ priority = issue.get("priority")
491
+ issue_findings: list[str] = []
492
+
493
+ # Missing description
494
+ if not description.strip():
495
+ issue_findings.append("missing_description")
496
+
497
+ # Missing AC checkboxes (for non-idea types in active states)
498
+ if status in ("IMPLEMENTING", "REVIEW", "TESTING", "COMPLETE"):
499
+ if description and not CHECKBOX_PATTERN.search(description):
500
+ issue_findings.append("no_acceptance_criteria")
501
+
502
+ # Unassigned but in progress
503
+ if status in ("IMPLEMENTING", "REVIEW") and not assignee:
504
+ issue_findings.append("unassigned_active")
505
+
506
+ # Missing labels
507
+ if not labels:
508
+ issue_findings.append("no_labels")
509
+
510
+ # Default priority (might be intentional, but flag it)
511
+ if priority == 0 and status not in ("CANCELED",):
512
+ issue_findings.append("no_priority")
513
+
514
+ if issue_findings:
515
+ findings.append({
516
+ "ref": ref,
517
+ "title": title,
518
+ "status": status,
519
+ "findings": issue_findings,
520
+ })
521
+
522
+ output_json({
523
+ "total_checked": len(issues),
524
+ "issues_with_findings": len(findings),
525
+ "findings": findings,
526
+ "checks": [
527
+ "missing_description",
528
+ "no_acceptance_criteria",
529
+ "unassigned_active",
530
+ "no_labels",
531
+ "no_priority",
532
+ ],
533
+ })
534
+
535
+
536
+ def cmd_fix(args: argparse.Namespace) -> None:
537
+ """Fix missing fields on an issue using config defaults.
538
+
539
+ Auto-applies: category labels from type, project from activeProjects,
540
+ milestone from defaultMilestoneId.
541
+ """
542
+ client = get_client()
543
+ issue = client.get_issue(args.ref, fields="full")
544
+ if not isinstance(issue, dict):
545
+ fail(f"Could not fetch issue {args.ref}")
546
+
547
+ fixes: dict = {}
548
+ applied: list[str] = []
549
+
550
+ # Fix missing labels (if we can detect type from title/description)
551
+ labels = issue.get("labels", [])
552
+ if not labels and client.is_cloud:
553
+ # Try to detect type from title keywords
554
+ title_lower = issue.get("title", "").lower()
555
+ detected_type = None
556
+ if any(w in title_lower for w in ["bug", "fix", "broken", "error"]):
557
+ detected_type = "bug"
558
+ elif any(w in title_lower for w in ["add", "implement", "create", "new"]):
559
+ detected_type = "feature"
560
+ elif any(w in title_lower for w in ["refactor", "clean", "update", "upgrade", "remove"]):
561
+ detected_type = "chore"
562
+ if detected_type:
563
+ cat_id = client.relay.get_category_label_id(detected_type)
564
+ if cat_id:
565
+ fixes["labels"] = cat_id
566
+ applied.append(f"label:{detected_type}")
567
+
568
+ # Fix missing project
569
+ project = issue.get("project")
570
+ if not project and client.is_cloud:
571
+ active = client.relay.workspace.get("activeProjects", [])
572
+ if active:
573
+ fixes["projectId"] = active[0]
574
+ applied.append("project:activeProjects")
575
+
576
+ # Fix missing milestone
577
+ milestone = issue.get("milestone")
578
+ if not milestone and client.is_cloud:
579
+ default_ms = client.relay.workspace.get("defaultMilestoneId")
580
+ if default_ms:
581
+ fixes["milestone"] = default_ms
582
+ applied.append("milestone:default")
583
+
584
+ if not fixes:
585
+ output_json({"ref": args.ref, "fixed": [], "message": "No fixes needed"})
586
+ return
587
+
588
+ result = client.update_issue(args.ref, **fixes)
589
+ output_json({
590
+ "ref": args.ref,
591
+ "fixed": applied,
592
+ "result": result,
593
+ })
594
+
595
+
596
+ # ---------------------------------------------------------------------------
597
+ # Argument parser
598
+ # ---------------------------------------------------------------------------
599
+
600
+ def main() -> None:
601
+ parser = argparse.ArgumentParser(description="FlyDocs issue operations")
602
+ sub = parser.add_subparsers(dest="command", required=True)
603
+
604
+ # -- create --
605
+ p = sub.add_parser("create", help="Create a new issue")
606
+ p.add_argument("--title", required=True)
607
+ p.add_argument("--type", required=True, choices=["feature", "bug", "chore", "idea"])
608
+ p.add_argument("--description", default=None)
609
+ p.add_argument("--description-file", default=None)
610
+ p.add_argument("--priority", type=int, default=3, choices=[0, 1, 2, 3, 4])
611
+ p.add_argument("--estimate", type=int, default=None, choices=[0, 1, 2, 3, 5])
612
+ p.add_argument("--assignee", default=None, help="Assignee name/ID, or 'self'/'me' for current user")
613
+ p.add_argument("--project", default=None)
614
+ p.add_argument("--milestone", default=None, help="Milestone ID (defaults to workspace.defaultMilestoneId)")
615
+ p.add_argument("--template", action="store_true", help="Read type template as description skeleton")
616
+ p.add_argument("--triage", action="store_true", help="Quick capture — bypasses description enforcement")
617
+
618
+ # -- get --
619
+ p = sub.add_parser("get", help="Get a single issue")
620
+ p.add_argument("ref")
621
+ p.add_argument("--fields", default="full", choices=["basic", "full"])
622
+
623
+ # -- list --
624
+ p = sub.add_parser("list", help="List issues")
625
+ p.add_argument("--status", default=None)
626
+ p.add_argument("--active", action="store_true")
627
+ p.add_argument("--project", default=None)
628
+ p.add_argument("--assignee", default=None)
629
+ p.add_argument("--milestone", default=None)
630
+ p.add_argument("--mine", action="store_true")
631
+ p.add_argument("--all", action="store_true", dest="show_all", help="Bypass product scope cascade")
632
+ p.add_argument("--limit", type=int, default=50)
633
+
634
+ # -- transition --
635
+ p = sub.add_parser("transition", help="Transition issue status")
636
+ p.add_argument("ref")
637
+ p.add_argument("status")
638
+ p.add_argument("comment")
639
+
640
+ # -- assign --
641
+ p = sub.add_parser("assign", help="Assign or unassign an issue")
642
+ p.add_argument("ref")
643
+ p.add_argument("assignee", nargs="?", default=None)
644
+ p.add_argument("--unassign", action="store_true")
645
+
646
+ # -- update --
647
+ p = sub.add_parser("update", help="Update issue fields")
648
+ p.add_argument("ref")
649
+ p.add_argument("--title", default=None)
650
+ p.add_argument("--priority", type=int, default=None)
651
+ p.add_argument("--estimate", type=int, default=None)
652
+ p.add_argument("--assignee", default=None)
653
+ p.add_argument("--state", default=None)
654
+ p.add_argument("--description", default=None)
655
+ p.add_argument("--description-file", default=None)
656
+ p.add_argument("--labels", default=None)
657
+ p.add_argument("--milestone", default=None)
658
+ p.add_argument("--comment", default=None)
659
+
660
+ # -- description --
661
+ p = sub.add_parser("description", help="Update issue description")
662
+ p.add_argument("ref")
663
+ p.add_argument("--text", default=None)
664
+ p.add_argument("--file", default=None)
665
+
666
+ # -- comment --
667
+ p = sub.add_parser("comment", help="Add a comment to an issue")
668
+ p.add_argument("ref")
669
+ p.add_argument("body", nargs="?", default=None)
670
+
671
+ # -- estimate --
672
+ p = sub.add_parser("estimate", help="Set estimate points")
673
+ p.add_argument("ref")
674
+ p.add_argument("points", type=int)
675
+
676
+ # -- priority --
677
+ p = sub.add_parser("priority", help="Set priority level")
678
+ p.add_argument("ref")
679
+ p.add_argument("level", type=int)
680
+
681
+ # -- link --
682
+ p = sub.add_parser("link", help="Link two issues")
683
+ p.add_argument("ref")
684
+ p.add_argument("related_ref")
685
+ p.add_argument("type", choices=["blocks", "related", "duplicate"])
686
+
687
+ # -- assign-milestone --
688
+ p = sub.add_parser("assign-milestone", help="Assign issue to milestone")
689
+ p.add_argument("ref")
690
+ p.add_argument("milestone_id")
691
+
692
+ # -- assign-cycle --
693
+ p = sub.add_parser("assign-cycle", help="Assign issue to cycle")
694
+ p.add_argument("ref")
695
+ p.add_argument("cycle_id", nargs="?", default=None)
696
+
697
+ # -- pr --
698
+ p = sub.add_parser("pr", help="Create pull/merge request with standard template")
699
+ p.add_argument("--issue", default=None, help="Issue ref to link (e.g. FLY-123)")
700
+ p.add_argument("--title", default=None, help="PR title (defaults to issue title or branch name)")
701
+ p.add_argument("--base", default=None, help="Base branch (defaults to main/master)")
702
+ p.add_argument("--draft", action="store_true", help="Create as draft PR")
703
+ p.add_argument("--dry-run", action="store_true", help="Show what would be created without creating")
704
+
705
+ # -- audit --
706
+ p = sub.add_parser("audit", help="Audit issues for workflow compliance")
707
+ p.add_argument("--status", default=None, help="Filter by status (default: all)")
708
+ p.add_argument("--limit", type=int, default=50, help="Max issues to check")
709
+
710
+ # -- fix --
711
+ p = sub.add_parser("fix", help="Fix missing fields on an issue using config defaults")
712
+ p.add_argument("ref")
713
+
714
+ args = parser.parse_args()
715
+
716
+ commands = {
717
+ "create": cmd_create,
718
+ "get": cmd_get,
719
+ "list": cmd_list,
720
+ "transition": cmd_transition,
721
+ "assign": cmd_assign,
722
+ "update": cmd_update,
723
+ "description": cmd_description,
724
+ "comment": cmd_comment,
725
+ "estimate": cmd_estimate,
726
+ "priority": cmd_priority,
727
+ "link": cmd_link,
728
+ "assign-milestone": cmd_assign_milestone,
729
+ "assign-cycle": cmd_assign_cycle,
730
+ "pr": cmd_pr,
731
+ "audit": cmd_audit,
732
+ "fix": cmd_fix,
733
+ }
734
+ commands[args.command](args)
735
+
736
+
737
+ if __name__ == "__main__":
738
+ main()