@flydocs/cli 0.6.0-alpha.20 → 0.6.0-alpha.22

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.
@@ -16,6 +16,24 @@ import re
16
16
  import sys
17
17
  from pathlib import Path
18
18
 
19
+ DEBUG_HOOK = os.environ.get('DEBUG_HOOK', '0') == '1'
20
+ SCRIPT_DIR = Path(__file__).parent.resolve()
21
+ DEBUG_LOG = SCRIPT_DIR.parent / 'logs' / 'hook-debug.log'
22
+
23
+
24
+ def debug_log(message: str) -> None:
25
+ """Write debug message to log file if DEBUG_HOOK is enabled."""
26
+ if not DEBUG_HOOK:
27
+ return
28
+ try:
29
+ DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
30
+ from datetime import datetime
31
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
32
+ with open(DEBUG_LOG, 'a') as f:
33
+ f.write(f'[{timestamp}] [stop-gate] {message}\n')
34
+ except (OSError, IOError):
35
+ pass
36
+
19
37
  ISSUE_ID_PATTERN = re.compile(r"[A-Z]+-[0-9]+")
20
38
  CHECKBOX_DONE = re.compile(r"- \[x\]", re.IGNORECASE)
21
39
  CHECKBOX_ALL = re.compile(r"- \[[ x]\]", re.IGNORECASE)
@@ -73,6 +91,29 @@ def main():
73
91
  sys.exit(0)
74
92
 
75
93
  status = status_text.strip().upper()
94
+ debug_log(f"Issue {issue_id} status={status}")
95
+
96
+ # -- READY: warn that transition to IMPLEMENTING was missed --
97
+ if status == "READY":
98
+ # Check if any code edits happened (heuristic: git has uncommitted changes)
99
+ try:
100
+ import subprocess
101
+ result = subprocess.run(
102
+ ["git", "diff", "--quiet"], capture_output=True, timeout=5
103
+ )
104
+ has_changes = result.returncode != 0
105
+ except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
106
+ has_changes = False
107
+
108
+ if has_changes:
109
+ msg = (
110
+ "Issue {} is still in READY but code changes were made. "
111
+ "Transition to IMPLEMENTING before continuing:\n"
112
+ " python3 .claude/skills/flydocs-workflow/scripts/issues.py "
113
+ "transition {} IMPLEMENTING \"Starting implementation\""
114
+ ).format(issue_id, issue_id)
115
+ sys.stderr.write(msg)
116
+ sys.exit(2)
76
117
 
77
118
  # -- IMPLEMENTING: block completion --
78
119
  if status == "IMPLEMENTING":
@@ -85,22 +126,34 @@ def main():
85
126
  sys.stderr.write(msg)
86
127
  sys.exit(2)
87
128
 
88
- # -- REVIEW: warn on incomplete acceptance criteria --
129
+ # -- REVIEW: block on incomplete acceptance criteria --
89
130
  if status == "REVIEW":
90
131
  ac_text = read_file_safe(session_dir / "acceptance-criteria.md")
91
132
  if ac_text:
92
133
  total = len(CHECKBOX_ALL.findall(ac_text))
93
134
  done = len(CHECKBOX_DONE.findall(ac_text))
94
135
  if total > 0 and done < total:
95
- result = {
96
- "hookSpecificOutput": {
97
- "additionalContext": (
98
- "Warning: {}/{} acceptance criteria checked "
99
- "for {}. Consider verifying remaining criteria."
100
- ).format(done, total, issue_id)
101
- }
102
- }
103
- print(json.dumps(result))
136
+ msg = (
137
+ "Issue {}: {}/{} acceptance criteria checked. "
138
+ "All AC must be verified before completion. "
139
+ "Update the issue description with checked criteria:\n"
140
+ " python3 .claude/skills/flydocs-workflow/scripts/issues.py "
141
+ "description {} --text \"<updated description with checked AC>\""
142
+ ).format(issue_id, done, total, issue_id)
143
+ sys.stderr.write(msg)
144
+ sys.exit(2)
145
+
146
+ # -- BLOCKED: inform that issue needs unblocking --
147
+ if status == "BLOCKED":
148
+ result = {
149
+ "hookSpecificOutput": {
150
+ "additionalContext": (
151
+ "Issue {} is BLOCKED. Resolve the blocker before continuing, "
152
+ "then transition back to IMPLEMENTING."
153
+ ).format(issue_id)
154
+ }
155
+ }
156
+ print(json.dumps(result))
104
157
 
105
158
  sys.exit(0)
106
159
 
@@ -306,6 +306,7 @@ class FlyDocsClient:
306
306
 
307
307
  def _cloud_create_issue(self, **kwargs: object) -> dict:
308
308
  relay = self.relay
309
+ auto_resolved: dict[str, str] = {}
309
310
  issue_input: dict = {
310
311
  "teamId": relay.team_id,
311
312
  "title": kwargs.get("title", ""),
@@ -316,17 +317,34 @@ class FlyDocsClient:
316
317
  if estimate:
317
318
  issue_input["estimate"] = estimate
318
319
 
319
- # Labels
320
+ # Labels — category + triage + repo (auto-resolved)
320
321
  label_ids: list[str] = []
321
322
  issue_type = kwargs.get("issue_type", "")
322
323
  if isinstance(issue_type, str):
323
324
  cat_id = relay.get_category_label_id(issue_type)
324
325
  if cat_id:
325
326
  label_ids.append(cat_id)
327
+ auto_resolved["categoryLabel"] = issue_type
326
328
  if kwargs.get("triage"):
327
329
  triage_id = relay.get_other_label_id("triage")
328
330
  if triage_id:
329
331
  label_ids.append(triage_id)
332
+
333
+ # Repo label for multi-repo workspaces
334
+ repo_labels = relay.config.get("issueLabels", {}).get("repo", {})
335
+ topology = relay.config.get("topology", {})
336
+ topo_type = topology.get("type", 1)
337
+ if topo_type in (3, 4) and repo_labels:
338
+ # Detect current repo name from slug
339
+ slug = relay.repo_slug or ""
340
+ repo_name = slug.split("/")[-1] if "/" in slug else slug
341
+ for name, label_id in repo_labels.items():
342
+ if label_id and name.lower() in repo_name.lower():
343
+ if label_id not in label_ids:
344
+ label_ids.append(label_id)
345
+ auto_resolved["repoLabel"] = name
346
+ break
347
+
330
348
  if label_ids:
331
349
  issue_input["labelIds"] = label_ids
332
350
 
@@ -336,9 +354,19 @@ class FlyDocsClient:
336
354
  active = relay.workspace.get("activeProjects", [])
337
355
  if active:
338
356
  project_id = active[0]
357
+ auto_resolved["project"] = "activeProjects"
339
358
  if project_id:
340
359
  issue_input["projectId"] = project_id
341
360
 
361
+ # Milestone
362
+ milestone_id = kwargs.get("milestone")
363
+ if not milestone_id:
364
+ milestone_id = relay.workspace.get("defaultMilestoneId")
365
+ if milestone_id:
366
+ auto_resolved["milestone"] = "defaultMilestoneId"
367
+ if milestone_id:
368
+ issue_input["projectMilestoneId"] = milestone_id
369
+
342
370
  # Assignee
343
371
  assignee = kwargs.get("assignee")
344
372
  if assignee and isinstance(assignee, str):
@@ -348,12 +376,15 @@ class FlyDocsClient:
348
376
 
349
377
  result = relay.post("/issues", issue_input)
350
378
  issue = result.get("issue", result)
351
- return {
379
+ response: dict = {
352
380
  "id": issue.get("id", ""),
353
381
  "identifier": issue.get("identifier", ""),
354
382
  "title": issue.get("title", kwargs.get("title", "")),
355
383
  "url": issue.get("url", ""),
356
384
  }
385
+ if auto_resolved:
386
+ response["autoResolved"] = auto_resolved
387
+ return response
357
388
 
358
389
  def transition(self, ref: str, status: str, comment: str) -> dict:
359
390
  if self.is_cloud:
@@ -17,12 +17,68 @@ from flydocs_api import get_client, output_json, fail, resolve_text_input
17
17
  # ---------------------------------------------------------------------------
18
18
 
19
19
  def cmd_create(args: argparse.Namespace) -> None:
20
- """Create a new issue."""
21
- # Description resolution: --description-file > stdin > --description
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
22
34
  description = resolve_text_input(file_arg=args.description_file)
23
35
  if description is None:
24
36
  description = args.description or ""
25
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
+
26
82
  client = get_client()
27
83
  result = client.create_issue(
28
84
  title=args.title,
@@ -30,8 +86,9 @@ def cmd_create(args: argparse.Namespace) -> None:
30
86
  description=description,
31
87
  priority=args.priority,
32
88
  estimate=args.estimate,
33
- assignee=args.assignee,
89
+ assignee=assignee,
34
90
  project=args.project,
91
+ milestone=args.milestone,
35
92
  triage=args.triage,
36
93
  )
37
94
  output_json(result)
@@ -60,10 +117,51 @@ def cmd_list(args: argparse.Namespace) -> None:
60
117
  output_json(result)
61
118
 
62
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
+
63
131
  def cmd_transition(args: argparse.Namespace) -> None:
64
- """Transition an issue to a new status with a comment."""
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
+
65
163
  client = get_client()
66
- result = client.transition(args.ref, args.status, args.comment)
164
+ result = client.transition(args.ref, target, args.comment)
67
165
  output_json(result)
68
166
 
69
167
 
@@ -357,6 +455,144 @@ def cmd_pr(args: argparse.Namespace) -> None:
357
455
  fail("PR creation timed out.")
358
456
 
359
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
+
360
596
  # ---------------------------------------------------------------------------
361
597
  # Argument parser
362
598
  # ---------------------------------------------------------------------------
@@ -373,9 +609,11 @@ def main() -> None:
373
609
  p.add_argument("--description-file", default=None)
374
610
  p.add_argument("--priority", type=int, default=3, choices=[0, 1, 2, 3, 4])
375
611
  p.add_argument("--estimate", type=int, default=None, choices=[0, 1, 2, 3, 5])
376
- p.add_argument("--assignee", default=None)
612
+ p.add_argument("--assignee", default=None, help="Assignee name/ID, or 'self'/'me' for current user")
377
613
  p.add_argument("--project", default=None)
378
- p.add_argument("--triage", action="store_true")
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")
379
617
 
380
618
  # -- get --
381
619
  p = sub.add_parser("get", help="Get a single issue")
@@ -464,6 +702,15 @@ def main() -> None:
464
702
  p.add_argument("--draft", action="store_true", help="Create as draft PR")
465
703
  p.add_argument("--dry-run", action="store_true", help="Show what would be created without creating")
466
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
+
467
714
  args = parser.parse_args()
468
715
 
469
716
  commands = {
@@ -481,6 +728,8 @@ def main() -> None:
481
728
  "assign-milestone": cmd_assign_milestone,
482
729
  "assign-cycle": cmd_assign_cycle,
483
730
  "pr": cmd_pr,
731
+ "audit": cmd_audit,
732
+ "fix": cmd_fix,
484
733
  }
485
734
  commands[args.command](args)
486
735