@flydocs/cli 0.5.0-beta.0
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/README.md +96 -0
- package/dist/cli.js +2666 -0
- package/package.json +32 -0
- package/template/.claude/CLAUDE.md +90 -0
- package/template/.claude/agents/README.md +19 -0
- package/template/.claude/agents/implementation-agent.md +29 -0
- package/template/.claude/agents/pm-agent.md +29 -0
- package/template/.claude/agents/research-agent.md +25 -0
- package/template/.claude/agents/review-agent.md +29 -0
- package/template/.claude/commands/activate.md +10 -0
- package/template/.claude/commands/attach.md +9 -0
- package/template/.claude/commands/block.md +10 -0
- package/template/.claude/commands/capture.md +10 -0
- package/template/.claude/commands/close.md +10 -0
- package/template/.claude/commands/flydocs-setup.md +598 -0
- package/template/.claude/commands/flydocs-update.md +27 -0
- package/template/.claude/commands/implement.md +10 -0
- package/template/.claude/commands/new-project.md +11 -0
- package/template/.claude/commands/project-update.md +10 -0
- package/template/.claude/commands/refine.md +10 -0
- package/template/.claude/commands/review.md +10 -0
- package/template/.claude/commands/start-session.md +10 -0
- package/template/.claude/commands/status.md +10 -0
- package/template/.claude/commands/validate.md +10 -0
- package/template/.claude/commands/wrap-session.md +10 -0
- package/template/.claude/settings.json +49 -0
- package/template/.claude/skills/README.md +293 -0
- package/template/.claude/skills/flydocs-cloud/SKILL.md +96 -0
- package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +50 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +44 -0
- package/template/.claude/skills/flydocs-cloud/scripts/comment.py +39 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +100 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +46 -0
- package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +277 -0
- package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +77 -0
- package/template/.claude/skills/flydocs-cloud/scripts/link.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +35 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +105 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +40 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +45 -0
- package/template/.claude/skills/flydocs-cloud/scripts/priority.py +38 -0
- package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +59 -0
- package/template/.claude/skills/flydocs-cloud/scripts/transition.py +67 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +47 -0
- package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +111 -0
- package/template/.claude/skills/flydocs-context-graph/SKILL.md +87 -0
- package/template/.claude/skills/flydocs-context-graph/schema.md +78 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_build.py +299 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +338 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_query.py +191 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_session.py +161 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_update.py +194 -0
- package/template/.claude/skills/flydocs-context-graph/scripts/graph_utils.py +118 -0
- package/template/.claude/skills/flydocs-estimates/SKILL.md +384 -0
- package/template/.claude/skills/flydocs-estimates/references/provider-costs.md +152 -0
- package/template/.claude/skills/flydocs-figma/SKILL.md +377 -0
- package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +108 -0
- package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +112 -0
- package/template/.claude/skills/flydocs-local/SKILL.md +103 -0
- package/template/.claude/skills/flydocs-local/cursor-rule.mdc +43 -0
- package/template/.claude/skills/flydocs-local/scripts/assign.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/comment.py +27 -0
- package/template/.claude/skills/flydocs-local/scripts/create_issue.py +44 -0
- package/template/.claude/skills/flydocs-local/scripts/estimate.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +272 -0
- package/template/.claude/skills/flydocs-local/scripts/get_issue.py +20 -0
- package/template/.claude/skills/flydocs-local/scripts/link.py +41 -0
- package/template/.claude/skills/flydocs-local/scripts/list_issues.py +34 -0
- package/template/.claude/skills/flydocs-local/scripts/priority.py +37 -0
- package/template/.claude/skills/flydocs-local/scripts/project_update.py +67 -0
- package/template/.claude/skills/flydocs-local/scripts/status_summary.py +16 -0
- package/template/.claude/skills/flydocs-local/scripts/transition.py +24 -0
- package/template/.claude/skills/flydocs-local/scripts/update_description.py +35 -0
- package/template/.claude/skills/flydocs-local/scripts/update_issue.py +84 -0
- package/template/.claude/skills/flydocs-workflow/SKILL.md +85 -0
- package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +53 -0
- package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +131 -0
- package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +76 -0
- package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +28 -0
- package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +50 -0
- package/template/.claude/skills/flydocs-workflow/session.md +128 -0
- package/template/.claude/skills/flydocs-workflow/stages/activate.md +46 -0
- package/template/.claude/skills/flydocs-workflow/stages/capture.md +50 -0
- package/template/.claude/skills/flydocs-workflow/stages/close.md +32 -0
- package/template/.claude/skills/flydocs-workflow/stages/implement.md +124 -0
- package/template/.claude/skills/flydocs-workflow/stages/refine.md +51 -0
- package/template/.claude/skills/flydocs-workflow/stages/review.md +86 -0
- package/template/.claude/skills/flydocs-workflow/stages/validate.md +90 -0
- package/template/.claude/skills/flydocs-workflow/templates/bug.md +95 -0
- package/template/.claude/skills/flydocs-workflow/templates/chore.md +75 -0
- package/template/.claude/skills/flydocs-workflow/templates/feature.md +93 -0
- package/template/.claude/skills/flydocs-workflow/templates/idea.md +84 -0
- package/template/.cursor/agents/implementation-agent.md +28 -0
- package/template/.cursor/agents/pm-agent.md +27 -0
- package/template/.cursor/agents/research-agent.md +23 -0
- package/template/.cursor/agents/review-agent.md +27 -0
- package/template/.cursor/hooks.json +29 -0
- package/template/.cursor/mcp.json +16 -0
- package/template/.env.example +44 -0
- package/template/.flydocs/config.json +104 -0
- package/template/.flydocs/hooks/auto-approve.py +71 -0
- package/template/.flydocs/hooks/post-edit.py +72 -0
- package/template/.flydocs/hooks/prefer-scripts.py +89 -0
- package/template/.flydocs/hooks/prompt-submit.py +277 -0
- package/template/.flydocs/scripts/generate_manifest.py +287 -0
- package/template/.flydocs/scripts/skill_manager.py +541 -0
- package/template/.flydocs/templates/README.md +46 -0
- package/template/.flydocs/templates/bug.md +166 -0
- package/template/.flydocs/templates/chore.md +110 -0
- package/template/.flydocs/templates/design-system/README.md +27 -0
- package/template/.flydocs/templates/design-system/component-patterns.md +92 -0
- package/template/.flydocs/templates/design-system/token-mapping.md +168 -0
- package/template/.flydocs/templates/feature.md +173 -0
- package/template/.flydocs/templates/idea.md +122 -0
- package/template/.flydocs/templates/instructions.md +228 -0
- package/template/.flydocs/templates/quick-capture.md +35 -0
- package/template/.flydocs/templates/scripts/check-design-system.template.mjs +179 -0
- package/template/.flydocs/version +1 -0
- package/template/AGENTS.md +95 -0
- package/template/CHANGELOG.md +271 -0
- package/template/flydocs/README.md +186 -0
- package/template/flydocs/context/project.md +51 -0
- package/template/flydocs/design-system/README.md +126 -0
- package/template/flydocs/design-system/component-patterns.md +173 -0
- package/template/flydocs/design-system/token-mapping.md +114 -0
- package/template/flydocs/knowledge/INDEX.md +100 -0
- package/template/flydocs/knowledge/README.md +62 -0
- package/template/flydocs/knowledge/product/personas.md +79 -0
- package/template/flydocs/knowledge/product/user-flows.md +88 -0
- package/template/manifest.json +221 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a new issue in Linear."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from flydocs_api import get_client, output_json, fail
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
parser = argparse.ArgumentParser(description="Create issue")
|
|
14
|
+
parser.add_argument("--title", required=True)
|
|
15
|
+
parser.add_argument("--type", required=True, choices=["feature", "bug", "chore", "idea"], dest="issue_type")
|
|
16
|
+
parser.add_argument("--description", default="")
|
|
17
|
+
parser.add_argument("--description-file", default="", dest="description_file")
|
|
18
|
+
parser.add_argument("--priority", type=int, default=3, choices=range(5))
|
|
19
|
+
parser.add_argument("--estimate", type=int, default=0, choices=[0, 1, 2, 3, 5])
|
|
20
|
+
parser.add_argument("--assignee", default=None)
|
|
21
|
+
parser.add_argument("--project", default=None)
|
|
22
|
+
parser.add_argument("--triage", action="store_true")
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
# Resolve description: --description-file > stdin > --description
|
|
26
|
+
description = args.description
|
|
27
|
+
if args.description_file:
|
|
28
|
+
try:
|
|
29
|
+
description = Path(args.description_file).read_text()
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
fail(f"File not found: {args.description_file}")
|
|
32
|
+
elif not description and not sys.stdin.isatty():
|
|
33
|
+
description = sys.stdin.read().strip()
|
|
34
|
+
|
|
35
|
+
client = get_client()
|
|
36
|
+
|
|
37
|
+
issue_input = {
|
|
38
|
+
"teamId": client.team_id,
|
|
39
|
+
"title": args.title,
|
|
40
|
+
"description": description,
|
|
41
|
+
"priority": args.priority,
|
|
42
|
+
}
|
|
43
|
+
if args.estimate:
|
|
44
|
+
issue_input["estimate"] = args.estimate
|
|
45
|
+
|
|
46
|
+
# Labels
|
|
47
|
+
label_ids = []
|
|
48
|
+
cat_id = client.get_category_label_id(args.issue_type)
|
|
49
|
+
if cat_id:
|
|
50
|
+
label_ids.append(cat_id)
|
|
51
|
+
if args.triage:
|
|
52
|
+
triage_id = client.get_other_label_id("triage")
|
|
53
|
+
if triage_id:
|
|
54
|
+
label_ids.append(triage_id)
|
|
55
|
+
if label_ids:
|
|
56
|
+
issue_input["labelIds"] = label_ids
|
|
57
|
+
|
|
58
|
+
# Project
|
|
59
|
+
project_id = args.project
|
|
60
|
+
if not project_id:
|
|
61
|
+
active = client.workspace.get("activeProjects", [])
|
|
62
|
+
if active:
|
|
63
|
+
project_id = active[0]
|
|
64
|
+
if project_id:
|
|
65
|
+
issue_input["projectId"] = project_id
|
|
66
|
+
|
|
67
|
+
# Assignee
|
|
68
|
+
if args.assignee:
|
|
69
|
+
user_id, _ = client.resolve_user_id(args.assignee)
|
|
70
|
+
if user_id:
|
|
71
|
+
issue_input["assigneeId"] = user_id
|
|
72
|
+
|
|
73
|
+
mutation = """mutation($input: IssueCreateInput!) {
|
|
74
|
+
issueCreate(input: $input) {
|
|
75
|
+
success
|
|
76
|
+
issue { id identifier title url }
|
|
77
|
+
}
|
|
78
|
+
}"""
|
|
79
|
+
|
|
80
|
+
result = client.query(mutation, {"input": issue_input})
|
|
81
|
+
|
|
82
|
+
data = result.get("data", {}).get("issueCreate", {})
|
|
83
|
+
if not data.get("success"):
|
|
84
|
+
# Retry without labels if team mismatch (stale label IDs in config)
|
|
85
|
+
errors = result.get("errors", [])
|
|
86
|
+
label_error = any("label" in (e.get("message", "") or "").lower() for e in errors)
|
|
87
|
+
if label_error and "labelIds" in issue_input:
|
|
88
|
+
print("Label IDs rejected — retrying without labels...", file=sys.stderr)
|
|
89
|
+
del issue_input["labelIds"]
|
|
90
|
+
result = client.query(mutation, {"input": issue_input})
|
|
91
|
+
data = result.get("data", {}).get("issueCreate", {})
|
|
92
|
+
if not data.get("success"):
|
|
93
|
+
fail(f"Failed to create issue: {result}")
|
|
94
|
+
|
|
95
|
+
issue = data["issue"]
|
|
96
|
+
output_json({"id": issue["id"], "identifier": issue["identifier"], "title": issue["title"], "url": issue["url"]})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a milestone."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from flydocs_api import get_client, output_json, fail
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser(description="Create milestone")
|
|
12
|
+
parser.add_argument("--name", required=True)
|
|
13
|
+
parser.add_argument("--project", default="", help="Project ID (defaults to first activeProject)")
|
|
14
|
+
parser.add_argument("--target-date", default=None, help="YYYY-MM-DD")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
client = get_client()
|
|
18
|
+
|
|
19
|
+
project_id = args.project
|
|
20
|
+
if not project_id:
|
|
21
|
+
active = client.workspace.get("activeProjects", [])
|
|
22
|
+
if active:
|
|
23
|
+
project_id = active[0]
|
|
24
|
+
else:
|
|
25
|
+
fail("No --project specified and no activeProjects in config")
|
|
26
|
+
|
|
27
|
+
milestone_input = {"name": args.name, "projectId": project_id}
|
|
28
|
+
if args.target_date:
|
|
29
|
+
milestone_input["targetDate"] = args.target_date
|
|
30
|
+
|
|
31
|
+
result = client.query(
|
|
32
|
+
"""mutation($input: ProjectMilestoneCreateInput!) {
|
|
33
|
+
projectMilestoneCreate(input: $input) {
|
|
34
|
+
success
|
|
35
|
+
projectMilestone { id name }
|
|
36
|
+
}
|
|
37
|
+
}""",
|
|
38
|
+
{"input": milestone_input},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
data = result.get("data", {}).get("projectMilestoneCreate", {})
|
|
42
|
+
if not data.get("success"):
|
|
43
|
+
fail(f"Failed to create milestone: {result}")
|
|
44
|
+
|
|
45
|
+
ms = data["projectMilestone"]
|
|
46
|
+
output_json({"id": ms["id"], "name": ms["name"]})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a new project."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from flydocs_api import get_client, output_json, fail
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser(description="Create project")
|
|
12
|
+
parser.add_argument("--name", required=True)
|
|
13
|
+
parser.add_argument("--description", default="")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
client = get_client()
|
|
17
|
+
|
|
18
|
+
project_input = {
|
|
19
|
+
"name": args.name,
|
|
20
|
+
"teamIds": [client.team_id],
|
|
21
|
+
}
|
|
22
|
+
if args.description:
|
|
23
|
+
project_input["description"] = args.description
|
|
24
|
+
|
|
25
|
+
result = client.query(
|
|
26
|
+
"""mutation($input: ProjectCreateInput!) {
|
|
27
|
+
projectCreate(input: $input) {
|
|
28
|
+
success
|
|
29
|
+
project { id name url }
|
|
30
|
+
}
|
|
31
|
+
}""",
|
|
32
|
+
{"input": project_input},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
data = result.get("data", {}).get("projectCreate", {})
|
|
36
|
+
if not data.get("success"):
|
|
37
|
+
fail(f"Failed to create project: {result}")
|
|
38
|
+
|
|
39
|
+
project = data["project"]
|
|
40
|
+
output_json({"id": project["id"], "name": project["name"], "url": project["url"]})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Set estimate on an issue."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
8
|
+
from flydocs_api import get_client, output_json, fail
|
|
9
|
+
|
|
10
|
+
if len(sys.argv) < 3:
|
|
11
|
+
fail("Usage: estimate.py <ref> <1-5>")
|
|
12
|
+
|
|
13
|
+
ref = sys.argv[1]
|
|
14
|
+
try:
|
|
15
|
+
estimate = int(sys.argv[2])
|
|
16
|
+
except ValueError:
|
|
17
|
+
fail("Estimate must be a number (1-5)")
|
|
18
|
+
|
|
19
|
+
client = get_client()
|
|
20
|
+
issue_uuid = client.resolve_issue_id(ref)
|
|
21
|
+
if not issue_uuid:
|
|
22
|
+
fail(f"Issue not found: {ref}")
|
|
23
|
+
|
|
24
|
+
result = client.query(
|
|
25
|
+
"""mutation($id: String!, $estimate: Int!) {
|
|
26
|
+
issueUpdate(id: $id, input: { estimate: $estimate }) {
|
|
27
|
+
success
|
|
28
|
+
issue { identifier estimate }
|
|
29
|
+
}
|
|
30
|
+
}""",
|
|
31
|
+
{"id": issue_uuid, "estimate": estimate},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if not result.get("data", {}).get("issueUpdate", {}).get("success"):
|
|
35
|
+
fail(f"Failed to set estimate: {result}")
|
|
36
|
+
|
|
37
|
+
issue = result["data"]["issueUpdate"]["issue"]
|
|
38
|
+
output_json({"success": True, "issue": issue["identifier"], "estimate": issue["estimate"]})
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""FlyDocs Cloud API — Linear GraphQL client.
|
|
2
|
+
|
|
3
|
+
Central module for all Linear API operations.
|
|
4
|
+
Loads config from .flydocs/config.json and API key from .env files.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from flydocs_api import get_client
|
|
8
|
+
client = get_client()
|
|
9
|
+
result = client.query("{ viewer { id name } }")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
import urllib.error
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlyDocsClient:
|
|
24
|
+
"""Client for Linear GraphQL API with retry logic and config awareness."""
|
|
25
|
+
|
|
26
|
+
API_URL = "https://api.linear.app/graphql"
|
|
27
|
+
MAX_RETRIES = 5
|
|
28
|
+
RETRY_DELAY = 2
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.project_root = find_project_root()
|
|
32
|
+
self.config_path = self.project_root / ".flydocs" / "config.json"
|
|
33
|
+
self.log_path = self.project_root / ".flydocs" / "logs" / "linear-ops.jsonl"
|
|
34
|
+
|
|
35
|
+
self.config = self._load_config()
|
|
36
|
+
self.api_key = self._load_api_key()
|
|
37
|
+
if not self.api_key:
|
|
38
|
+
print("ERROR: LINEAR_API_KEY not found", file=sys.stderr)
|
|
39
|
+
print("Set in environment or .env/.env.local file", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
self.team_id = self.config.get("provider", {}).get("teamId")
|
|
43
|
+
self.status_mapping = self.config.get("statusMapping", {})
|
|
44
|
+
self.issue_labels = self.config.get("issueLabels", {})
|
|
45
|
+
self.workspace = self.config.get("workspace", {})
|
|
46
|
+
|
|
47
|
+
# In-process caches (live for one script invocation)
|
|
48
|
+
self._id_cache: dict[str, str] = {}
|
|
49
|
+
self._team_members: Optional[list] = None
|
|
50
|
+
self._active_cycle: Optional[dict] = None
|
|
51
|
+
|
|
52
|
+
def _load_config(self) -> dict:
|
|
53
|
+
if self.config_path.exists():
|
|
54
|
+
with open(self.config_path, "r") as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
def _load_api_key(self) -> Optional[str]:
|
|
59
|
+
if os.environ.get("LINEAR_API_KEY"):
|
|
60
|
+
return os.environ["LINEAR_API_KEY"]
|
|
61
|
+
for name in [".env.local", ".env"]:
|
|
62
|
+
env_file = self.project_root / name
|
|
63
|
+
if env_file.exists():
|
|
64
|
+
key = self._parse_env_file(env_file, "LINEAR_API_KEY")
|
|
65
|
+
if key:
|
|
66
|
+
return key
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _parse_env_file(self, path: Path, key: str) -> Optional[str]:
|
|
70
|
+
with open(path, "r") as f:
|
|
71
|
+
for line in f:
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if line.startswith("#") or "=" not in line:
|
|
74
|
+
continue
|
|
75
|
+
k, _, v = line.partition("=")
|
|
76
|
+
if k.strip() == key:
|
|
77
|
+
v = v.strip().strip("\"'")
|
|
78
|
+
return v if v else None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def query(self, query: str, variables: Optional[dict] = None) -> dict:
|
|
82
|
+
payload = {"query": query}
|
|
83
|
+
if variables:
|
|
84
|
+
payload["variables"] = variables
|
|
85
|
+
|
|
86
|
+
data = json.dumps(payload).encode("utf-8")
|
|
87
|
+
headers = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"Authorization": self.api_key,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for attempt in range(self.MAX_RETRIES):
|
|
93
|
+
try:
|
|
94
|
+
req = urllib.request.Request(self.API_URL, data=data, headers=headers)
|
|
95
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
96
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
97
|
+
# Normalize null data to empty dict for safe .get() chaining.
|
|
98
|
+
# Linear returns {"data": null, "errors": [...]} on errors,
|
|
99
|
+
# and .get("data", {}) returns None (key exists, value is null).
|
|
100
|
+
if result.get("data") is None:
|
|
101
|
+
result["data"] = {}
|
|
102
|
+
self._log_operation(query, variables, result)
|
|
103
|
+
return result
|
|
104
|
+
except urllib.error.HTTPError as e:
|
|
105
|
+
if e.code == 429 and attempt < self.MAX_RETRIES - 1:
|
|
106
|
+
delay = self.RETRY_DELAY * (2 ** attempt)
|
|
107
|
+
print(f"Rate limited, retrying in {delay}s...", file=sys.stderr)
|
|
108
|
+
time.sleep(delay)
|
|
109
|
+
continue
|
|
110
|
+
raise
|
|
111
|
+
except (urllib.error.URLError, TimeoutError):
|
|
112
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
113
|
+
delay = self.RETRY_DELAY * (2 ** attempt)
|
|
114
|
+
print(f"Network error, retrying in {delay}s...", file=sys.stderr)
|
|
115
|
+
time.sleep(delay)
|
|
116
|
+
continue
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
return {"errors": [{"message": "Max retries exceeded"}]}
|
|
120
|
+
|
|
121
|
+
def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict:
|
|
122
|
+
return self.query(mutation, variables)
|
|
123
|
+
|
|
124
|
+
def resolve_issue_id(self, identifier: str) -> Optional[str]:
|
|
125
|
+
"""Resolve issue identifier (e.g., ENG-123) to Linear UUID."""
|
|
126
|
+
if "-" in identifier and len(identifier) > 30:
|
|
127
|
+
return identifier
|
|
128
|
+
if identifier in self._id_cache:
|
|
129
|
+
return self._id_cache[identifier]
|
|
130
|
+
result = self.query(
|
|
131
|
+
"""query($term: String!) {
|
|
132
|
+
searchIssues(term: $term, first: 1) {
|
|
133
|
+
nodes { id identifier }
|
|
134
|
+
}
|
|
135
|
+
}""",
|
|
136
|
+
{"term": identifier},
|
|
137
|
+
)
|
|
138
|
+
nodes = (result.get("data") or {}).get("searchIssues", {}).get("nodes", [])
|
|
139
|
+
if nodes:
|
|
140
|
+
self._id_cache[identifier] = nodes[0]["id"]
|
|
141
|
+
return nodes[0]["id"]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def resolve_user_id(self, user_query: str) -> tuple[Optional[str], Optional[str]]:
|
|
145
|
+
"""Resolve user email/name to (user_id, user_name)."""
|
|
146
|
+
if user_query.lower() == "me":
|
|
147
|
+
result = self.query("query { viewer { id name } }")
|
|
148
|
+
viewer = result.get("data", {}).get("viewer")
|
|
149
|
+
return (viewer["id"], viewer["name"]) if viewer else (None, None)
|
|
150
|
+
|
|
151
|
+
if self._team_members is None:
|
|
152
|
+
result = self.query(
|
|
153
|
+
"""query($teamId: String!) {
|
|
154
|
+
team(id: $teamId) {
|
|
155
|
+
members { nodes { id name email } }
|
|
156
|
+
}
|
|
157
|
+
}""",
|
|
158
|
+
{"teamId": self.team_id},
|
|
159
|
+
)
|
|
160
|
+
self._team_members = result.get("data", {}).get("team", {}).get("members", {}).get("nodes", [])
|
|
161
|
+
|
|
162
|
+
for member in self._team_members:
|
|
163
|
+
if user_query.lower() in member.get("email", "").lower() or \
|
|
164
|
+
user_query.lower() in member.get("name", "").lower():
|
|
165
|
+
return member["id"], member["name"]
|
|
166
|
+
return None, None
|
|
167
|
+
|
|
168
|
+
def get_state_id(self, flydocs_status: str) -> Optional[str]:
|
|
169
|
+
linear_state_name = self.status_mapping.get(flydocs_status)
|
|
170
|
+
if not linear_state_name:
|
|
171
|
+
return None
|
|
172
|
+
result = self.query(
|
|
173
|
+
"""query($teamId: ID!) {
|
|
174
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
175
|
+
nodes { id name }
|
|
176
|
+
}
|
|
177
|
+
}""",
|
|
178
|
+
{"teamId": self.team_id},
|
|
179
|
+
)
|
|
180
|
+
states = result.get("data", {}).get("workflowStates", {}).get("nodes", [])
|
|
181
|
+
for state in states:
|
|
182
|
+
if state["name"].lower() == linear_state_name.lower():
|
|
183
|
+
return state["id"]
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def build_product_scope(self, filters: dict) -> dict:
|
|
187
|
+
"""Apply product scope to an existing filter dict.
|
|
188
|
+
|
|
189
|
+
Priority cascade:
|
|
190
|
+
1. activeProjects set → filter to those projects
|
|
191
|
+
2. product.labelIds set → filter to issues with ALL those labels
|
|
192
|
+
3. Neither set → no additional filter (team-wide)
|
|
193
|
+
"""
|
|
194
|
+
active = self.workspace.get("activeProjects", [])
|
|
195
|
+
if active:
|
|
196
|
+
if len(active) == 1:
|
|
197
|
+
filters["project"] = {"id": {"eq": active[0]}}
|
|
198
|
+
else:
|
|
199
|
+
filters["project"] = {"id": {"in": active}}
|
|
200
|
+
return filters
|
|
201
|
+
|
|
202
|
+
label_ids = self.workspace.get("product", {}).get("labelIds", [])
|
|
203
|
+
if label_ids:
|
|
204
|
+
if len(label_ids) == 1:
|
|
205
|
+
filters["labels"] = {"id": {"eq": label_ids[0]}}
|
|
206
|
+
else:
|
|
207
|
+
# Multiple labels: issue must have ALL — use AND conditions
|
|
208
|
+
label_conditions = [{"labels": {"id": {"eq": lid}}} for lid in label_ids]
|
|
209
|
+
return {"and": [filters, *label_conditions]}
|
|
210
|
+
return filters
|
|
211
|
+
|
|
212
|
+
return filters
|
|
213
|
+
|
|
214
|
+
def get_category_label_id(self, category: str) -> Optional[str]:
|
|
215
|
+
return self.issue_labels.get("category", {}).get(category)
|
|
216
|
+
|
|
217
|
+
def get_other_label_id(self, label: str) -> Optional[str]:
|
|
218
|
+
return self.issue_labels.get("other", {}).get(label)
|
|
219
|
+
|
|
220
|
+
def get_active_cycle(self) -> Optional[dict]:
|
|
221
|
+
if self._active_cycle is not None:
|
|
222
|
+
return self._active_cycle
|
|
223
|
+
result = self.query(
|
|
224
|
+
"""query($teamId: ID!) {
|
|
225
|
+
cycles(filter: {
|
|
226
|
+
team: { id: { eq: $teamId } },
|
|
227
|
+
isActive: { eq: true }
|
|
228
|
+
}, first: 1) {
|
|
229
|
+
nodes { id name number startsAt endsAt }
|
|
230
|
+
}
|
|
231
|
+
}""",
|
|
232
|
+
{"teamId": self.team_id},
|
|
233
|
+
)
|
|
234
|
+
cycles = result.get("data", {}).get("cycles", {}).get("nodes", [])
|
|
235
|
+
self._active_cycle = cycles[0] if cycles else None
|
|
236
|
+
return self._active_cycle
|
|
237
|
+
|
|
238
|
+
def _log_operation(self, query: str, variables: Optional[dict], result: dict):
|
|
239
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
entry = {
|
|
241
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
242
|
+
"query_hash": hash(query) % 10000,
|
|
243
|
+
"variables": variables,
|
|
244
|
+
"success": "errors" not in result,
|
|
245
|
+
}
|
|
246
|
+
with open(self.log_path, "a") as f:
|
|
247
|
+
f.write(json.dumps(entry) + "\n")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def find_project_root() -> Path:
|
|
251
|
+
current = Path.cwd()
|
|
252
|
+
while current != current.parent:
|
|
253
|
+
if (current / ".flydocs").is_dir():
|
|
254
|
+
return current
|
|
255
|
+
current = current.parent
|
|
256
|
+
return Path.cwd()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_client: Optional[FlyDocsClient] = None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_client() -> FlyDocsClient:
|
|
263
|
+
global _client
|
|
264
|
+
if _client is None:
|
|
265
|
+
_client = FlyDocsClient()
|
|
266
|
+
return _client
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def output_json(data: dict | list) -> None:
|
|
270
|
+
"""Print JSON to stdout — standard contract output."""
|
|
271
|
+
print(json.dumps(data))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def fail(message: str) -> None:
|
|
275
|
+
"""Print error to stderr and exit 1."""
|
|
276
|
+
print(message, file=sys.stderr)
|
|
277
|
+
sys.exit(1)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Get full details for an issue."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from flydocs_api import get_client, output_json, fail
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser(description="Get issue details")
|
|
12
|
+
parser.add_argument("ref", help="Issue reference (e.g., ENG-123)")
|
|
13
|
+
parser.add_argument("--fields", choices=["basic", "full"], default="full",
|
|
14
|
+
help="basic = no comments, full = with comments (default)")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
client = get_client()
|
|
18
|
+
|
|
19
|
+
issue_uuid = client.resolve_issue_id(args.ref)
|
|
20
|
+
if not issue_uuid:
|
|
21
|
+
fail(f"Issue not found: {args.ref}")
|
|
22
|
+
|
|
23
|
+
if args.fields == "basic":
|
|
24
|
+
query = """query($id: String!) {
|
|
25
|
+
issue(id: $id) {
|
|
26
|
+
id identifier title description
|
|
27
|
+
state { name }
|
|
28
|
+
assignee { name }
|
|
29
|
+
priority estimate dueDate
|
|
30
|
+
project { id name }
|
|
31
|
+
projectMilestone { id name }
|
|
32
|
+
}
|
|
33
|
+
}"""
|
|
34
|
+
else:
|
|
35
|
+
query = """query($id: String!) {
|
|
36
|
+
issue(id: $id) {
|
|
37
|
+
id identifier title description
|
|
38
|
+
state { name }
|
|
39
|
+
assignee { name }
|
|
40
|
+
priority estimate dueDate
|
|
41
|
+
project { id name }
|
|
42
|
+
projectMilestone { id name }
|
|
43
|
+
comments(first: 50) {
|
|
44
|
+
nodes { id body createdAt user { name } }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}"""
|
|
48
|
+
|
|
49
|
+
result = client.query(query, {"id": issue_uuid})
|
|
50
|
+
|
|
51
|
+
issue = (result.get("data") or {}).get("issue")
|
|
52
|
+
if not issue:
|
|
53
|
+
fail(f"Issue not found: {args.ref}")
|
|
54
|
+
|
|
55
|
+
output = {
|
|
56
|
+
"id": issue["id"],
|
|
57
|
+
"identifier": issue["identifier"],
|
|
58
|
+
"title": issue["title"],
|
|
59
|
+
"description": issue.get("description", ""),
|
|
60
|
+
"status": issue.get("state", {}).get("name", ""),
|
|
61
|
+
"assignee": (issue.get("assignee") or {}).get("name", ""),
|
|
62
|
+
"priority": issue.get("priority", 0),
|
|
63
|
+
"estimate": issue.get("estimate", 0),
|
|
64
|
+
"dueDate": issue.get("dueDate") or "",
|
|
65
|
+
"milestone": (issue.get("projectMilestone") or {}).get("name", ""),
|
|
66
|
+
"milestoneId": (issue.get("projectMilestone") or {}).get("id", ""),
|
|
67
|
+
"project": (issue.get("project") or {}).get("name", ""),
|
|
68
|
+
"projectId": (issue.get("project") or {}).get("id", ""),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if args.fields == "full":
|
|
72
|
+
output["comments"] = [
|
|
73
|
+
{"id": c["id"], "body": c["body"], "createdAt": c["createdAt"], "user": c.get("user", {}).get("name", "")}
|
|
74
|
+
for c in issue.get("comments", {}).get("nodes", [])
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
output_json(output)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a relationship between two issues."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
8
|
+
from flydocs_api import get_client, output_json, fail
|
|
9
|
+
|
|
10
|
+
LINK_TYPES = {"blocks": "blocks", "related": "relatedTo", "duplicate": "duplicate"}
|
|
11
|
+
|
|
12
|
+
if len(sys.argv) < 4:
|
|
13
|
+
fail(f"Usage: link.py <ref> <related_ref> <type>\nTypes: {', '.join(LINK_TYPES.keys())}")
|
|
14
|
+
|
|
15
|
+
ref, related_ref, link_type = sys.argv[1], sys.argv[2], sys.argv[3].lower()
|
|
16
|
+
if link_type not in LINK_TYPES:
|
|
17
|
+
fail(f"Invalid type: {link_type}. Valid: {', '.join(LINK_TYPES.keys())}")
|
|
18
|
+
|
|
19
|
+
client = get_client()
|
|
20
|
+
|
|
21
|
+
issue_uuid = client.resolve_issue_id(ref)
|
|
22
|
+
if not issue_uuid:
|
|
23
|
+
fail(f"Issue not found: {ref}")
|
|
24
|
+
|
|
25
|
+
related_uuid = client.resolve_issue_id(related_ref)
|
|
26
|
+
if not related_uuid:
|
|
27
|
+
fail(f"Related issue not found: {related_ref}")
|
|
28
|
+
|
|
29
|
+
# Linear enum values can't be passed as variables
|
|
30
|
+
api_type = LINK_TYPES[link_type]
|
|
31
|
+
result = client.query(
|
|
32
|
+
f"""mutation($issueId: String!, $relatedId: String!) {{
|
|
33
|
+
issueRelationCreate(input: {{
|
|
34
|
+
issueId: $issueId,
|
|
35
|
+
relatedIssueId: $relatedId,
|
|
36
|
+
type: {api_type}
|
|
37
|
+
}}) {{
|
|
38
|
+
success
|
|
39
|
+
}}
|
|
40
|
+
}}""",
|
|
41
|
+
{"issueId": issue_uuid, "relatedId": related_uuid},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not result.get("data", {}).get("issueRelationCreate", {}).get("success"):
|
|
45
|
+
fail(f"Failed to link: {result}")
|
|
46
|
+
|
|
47
|
+
output_json({"success": True, "type": link_type})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""List cycles (sprints)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from flydocs_api import get_client, output_json, fail
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser(description="List cycles")
|
|
12
|
+
parser.add_argument("--active", action="store_true", help="Only active cycle")
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
|
|
15
|
+
client = get_client()
|
|
16
|
+
|
|
17
|
+
filters = {"team": {"id": {"eq": client.team_id}}}
|
|
18
|
+
if args.active:
|
|
19
|
+
filters["isActive"] = {"eq": True}
|
|
20
|
+
|
|
21
|
+
result = client.query(
|
|
22
|
+
"""query($filter: CycleFilter!) {
|
|
23
|
+
cycles(filter: $filter, first: 10, orderBy: startsAt) {
|
|
24
|
+
nodes { id name number startsAt endsAt }
|
|
25
|
+
}
|
|
26
|
+
}""",
|
|
27
|
+
{"filter": filters},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
nodes = result.get("data", {}).get("cycles", {}).get("nodes", [])
|
|
31
|
+
cycles = [
|
|
32
|
+
{"id": n["id"], "name": n.get("name", ""), "number": n["number"], "startsAt": n["startsAt"], "endsAt": n["endsAt"]}
|
|
33
|
+
for n in nodes
|
|
34
|
+
]
|
|
35
|
+
output_json(cycles)
|