@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.
- package/dist/cli.js +28 -2
- package/package.json +1 -1
- package/template/.claude/CLAUDE.md +55 -59
- package/template/.claude/hooks/auto-approve.py +82 -2
- package/template/.claude/hooks/post-transition-check.py +190 -3
- package/template/.claude/hooks/prompt-submit.py +53 -12
- package/template/.claude/hooks/stop-gate.py +63 -10
- package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +33 -2
- package/template/.claude/skills/flydocs-workflow/scripts/issues.py +256 -7
- package/template/.claude/skills/flydocs-workflow/scripts/test_enforcement.py +225 -0
- package/template/.claude/skills/flydocs-workflow/session.md +37 -17
- package/template/.flydocs/config.json +1 -1
- package/template/.flydocs/templates/bug.md +30 -0
- package/template/.flydocs/templates/chore.md +22 -0
- package/template/.flydocs/templates/feature.md +27 -0
- package/template/.flydocs/templates/idea.md +22 -0
- package/template/.flydocs/version +1 -1
- package/template/manifest.json +1 -1
|
@@ -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:
|
|
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
|
-
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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,
|
|
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("--
|
|
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
|
|