@flydocs/cli 0.5.0-beta.9 → 0.6.0-alpha.2

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 (41) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +1259 -370
  3. package/package.json +1 -1
  4. package/template/.claude/agents/implementation-agent.md +0 -1
  5. package/template/.claude/agents/pm-agent.md +0 -1
  6. package/template/.claude/agents/research-agent.md +0 -1
  7. package/template/.claude/agents/review-agent.md +0 -1
  8. package/template/.claude/commands/flydocs-setup.md +109 -26
  9. package/template/.claude/commands/flydocs-upgrade.md +330 -0
  10. package/template/.claude/skills/flydocs-cloud/SKILL.md +53 -38
  11. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +5 -5
  12. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +8 -24
  13. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +14 -30
  14. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +10 -32
  15. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +15 -25
  16. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +21 -58
  17. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +26 -37
  18. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +24 -31
  19. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +39 -0
  20. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +10 -19
  21. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +103 -170
  22. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +6 -59
  23. package/template/.claude/skills/flydocs-cloud/scripts/link.py +16 -35
  24. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +21 -28
  25. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +16 -77
  26. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +19 -0
  27. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +21 -33
  28. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +24 -38
  29. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +19 -0
  30. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +10 -19
  31. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +36 -50
  32. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +68 -0
  33. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +41 -0
  34. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +11 -52
  35. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +16 -27
  36. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +23 -52
  37. package/template/.env.example +16 -7
  38. package/template/.flydocs/config.json +1 -1
  39. package/template/.flydocs/version +1 -1
  40. package/template/CHANGELOG.md +144 -0
  41. package/template/manifest.json +5 -3
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """Create a team (or sub-team) via the FlyDocs Relay API."""
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
10
+
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(description="Create team")
14
+ parser.add_argument("--name", required=True)
15
+ parser.add_argument("--key", default=None, help="Team key (e.g., PROD). Auto-generated if omitted.")
16
+ parser.add_argument("--description", default=None)
17
+ parser.add_argument("--parent", default=None, dest="parent_id", help="Parent team ID to create a sub-team")
18
+ args = parser.parse_args()
19
+
20
+ body: dict = {"name": args.name}
21
+ if args.key:
22
+ body["key"] = args.key
23
+ if args.description:
24
+ body["description"] = args.description
25
+ if args.parent_id:
26
+ body["parentId"] = args.parent_id
27
+
28
+ client = get_client()
29
+ result = client.post("/teams", body)
30
+
31
+ output_json({
32
+ "id": result["id"],
33
+ "name": result["name"],
34
+ "key": result.get("key", ""),
35
+ })
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Set estimate on an issue."""
2
+ """Set estimate on an issue via the FlyDocs Relay API."""
3
3
 
4
4
  import sys
5
5
  from pathlib import Path
@@ -16,23 +16,14 @@ try:
16
16
  except ValueError:
17
17
  fail("Estimate must be a number (1-5)")
18
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
- )
19
+ if estimate not in (1, 2, 3, 5):
20
+ fail("Estimate must be 1 (XS), 2 (S), 3 (M), or 5 (L)")
33
21
 
34
- if not result.get("data", {}).get("issueUpdate", {}).get("success"):
35
- fail(f"Failed to set estimate: {result}")
22
+ client = get_client()
23
+ result = client.put(f"/issues/{ref}/estimate", {"estimate": estimate})
36
24
 
37
- issue = result["data"]["issueUpdate"]["issue"]
38
- output_json({"success": True, "issue": issue["identifier"], "estimate": issue["estimate"]})
25
+ output_json({
26
+ "success": result.get("success", True),
27
+ "issue": result.get("issue", ref),
28
+ "estimate": result.get("estimate", estimate),
29
+ })
@@ -1,53 +1,43 @@
1
- """FlyDocs Cloud API — Linear GraphQL client.
1
+ """FlyDocs Cloud API — Relay REST client.
2
2
 
3
- Central module for all Linear API operations.
3
+ Central module for all relay API operations.
4
4
  Loads config from .flydocs/config.json and API key from .env files.
5
5
 
6
6
  Usage:
7
7
  from flydocs_api import get_client
8
8
  client = get_client()
9
- result = client.query("{ viewer { id name } }")
9
+ result = client.post("/issues", {"title": "New issue", "type": "feature"})
10
10
  """
11
11
 
12
12
  import json
13
13
  import os
14
14
  import sys
15
15
  import time
16
- import urllib.request
17
- import urllib.error
18
- from datetime import datetime, timezone
19
16
  from pathlib import Path
20
17
  from typing import Optional
21
18
 
22
19
 
23
20
  class FlyDocsClient:
24
- """Client for Linear GraphQL API with retry logic and config awareness."""
21
+ """Client for FlyDocs Relay REST API with retry logic and config awareness."""
25
22
 
26
- API_URL = "https://api.linear.app/graphql"
27
- MAX_RETRIES = 5
23
+ DEFAULT_BASE_URL = "https://app.flydocs.ai/api/relay"
24
+ LOCAL_BASE_URL = "http://localhost:3000/api/relay"
25
+ MAX_RETRIES = 3
28
26
  RETRY_DELAY = 2
29
27
 
30
28
  def __init__(self):
31
29
  self.project_root = find_project_root()
32
30
  self.config_path = self.project_root / ".flydocs" / "config.json"
33
- self.log_path = self.project_root / ".flydocs" / "logs" / "linear-ops.jsonl"
31
+ self.log_path = self.project_root / ".flydocs" / "logs" / "relay-ops.jsonl"
34
32
 
35
33
  self.config = self._load_config()
36
34
  self.api_key = self._load_api_key()
37
35
  if not self.api_key:
38
- print("ERROR: LINEAR_API_KEY not found", file=sys.stderr)
36
+ print("ERROR: FLYDOCS_API_KEY not found", file=sys.stderr)
39
37
  print("Set in environment or .env/.env.local file", file=sys.stderr)
40
38
  sys.exit(1)
41
39
 
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
40
+ self.base_url = self._resolve_base_url()
51
41
 
52
42
  def _load_config(self) -> dict:
53
43
  if self.config_path.exists():
@@ -56,12 +46,12 @@ class FlyDocsClient:
56
46
  return {}
57
47
 
58
48
  def _load_api_key(self) -> Optional[str]:
59
- if os.environ.get("LINEAR_API_KEY"):
60
- return os.environ["LINEAR_API_KEY"]
49
+ if os.environ.get("FLYDOCS_API_KEY"):
50
+ return os.environ["FLYDOCS_API_KEY"]
61
51
  for name in [".env.local", ".env"]:
62
52
  env_file = self.project_root / name
63
53
  if env_file.exists():
64
- key = self._parse_env_file(env_file, "LINEAR_API_KEY")
54
+ key = self._parse_env_file(env_file, "FLYDOCS_API_KEY")
65
55
  if key:
66
56
  return key
67
57
  return None
@@ -78,176 +68,118 @@ class FlyDocsClient:
78
68
  return v if v else None
79
69
  return None
80
70
 
81
- def query(self, query: str, variables: Optional[dict] = None) -> dict:
82
- payload = {"query": query}
83
- if variables:
84
- payload["variables"] = variables
71
+ def _resolve_base_url(self) -> str:
72
+ """Resolve base URL: env var > config > default."""
73
+ env_url = os.environ.get("FLYDOCS_RELAY_URL")
74
+ if env_url:
75
+ return env_url.rstrip("/")
76
+ config_url = self.config.get("relay", {}).get("url")
77
+ if config_url:
78
+ return config_url.rstrip("/")
79
+ return self.DEFAULT_BASE_URL
80
+
81
+ def _request(self, method: str, path: str, body: Optional[dict] = None,
82
+ params: Optional[dict] = None) -> dict:
83
+ """Make an HTTP request to the relay API."""
84
+ import urllib.request
85
+ import urllib.error
86
+ import urllib.parse
87
+
88
+ url = f"{self.base_url}{path}"
89
+ if params:
90
+ filtered = {k: v for k, v in params.items() if v is not None and v != ""}
91
+ if filtered:
92
+ url += "?" + urllib.parse.urlencode(filtered, doseq=True)
85
93
 
86
- data = json.dumps(payload).encode("utf-8")
87
94
  headers = {
95
+ "Authorization": f"Bearer {self.api_key}",
88
96
  "Content-Type": "application/json",
89
- "Authorization": self.api_key,
97
+ "Accept": "application/json",
90
98
  }
91
99
 
100
+ data = json.dumps(body).encode("utf-8") if body else None
101
+
92
102
  for attempt in range(self.MAX_RETRIES):
93
103
  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)
104
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
105
+ with urllib.request.urlopen(req, timeout=15) as resp:
106
+ response_body = resp.read().decode("utf-8")
107
+ result = json.loads(response_body) if response_body else {}
108
+ self._log_operation(method, path, resp.status, result)
103
109
  return result
104
110
  except urllib.error.HTTPError as e:
111
+ error_body = e.read().decode("utf-8") if e.fp else ""
112
+ try:
113
+ error_data = json.loads(error_body) if error_body else {}
114
+ except json.JSONDecodeError:
115
+ error_data = {"error": error_body}
116
+ self._log_operation(method, path, e.code, error_data)
117
+
105
118
  if e.code == 429 and attempt < self.MAX_RETRIES - 1:
106
119
  delay = self.RETRY_DELAY * (2 ** attempt)
107
120
  print(f"Rate limited, retrying in {delay}s...", file=sys.stderr)
108
121
  time.sleep(delay)
109
122
  continue
110
- raise
123
+ if e.code >= 500 and attempt < self.MAX_RETRIES - 1:
124
+ delay = self.RETRY_DELAY * (2 ** attempt)
125
+ print(f"Server error ({e.code}), retrying in {delay}s...", file=sys.stderr)
126
+ time.sleep(delay)
127
+ continue
128
+
129
+ error_msg = error_data.get("error", f"HTTP {e.code}")
130
+ error_code = error_data.get("code", "UNKNOWN")
131
+ provider = error_data.get("provider_error", "")
132
+ msg = f"Relay API error ({error_code}): {error_msg}"
133
+ if provider:
134
+ msg += f" — provider: {provider}"
135
+ fail(msg)
111
136
  except (urllib.error.URLError, TimeoutError):
112
137
  if attempt < self.MAX_RETRIES - 1:
113
138
  delay = self.RETRY_DELAY * (2 ** attempt)
114
139
  print(f"Network error, retrying in {delay}s...", file=sys.stderr)
115
140
  time.sleep(delay)
116
141
  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")
142
+ fail("Network error: unable to reach relay API")
143
+
144
+ fail("Max retries exceeded")
145
+ return {} # unreachable but satisfies type checker
146
+
147
+ def get(self, path: str, params: Optional[dict] = None) -> dict | list:
148
+ """GET request to relay API."""
149
+ return self._request("GET", path, params=params)
150
+
151
+ def post(self, path: str, body: Optional[dict] = None) -> dict:
152
+ """POST request to relay API."""
153
+ return self._request("POST", path, body=body)
154
+
155
+ def put(self, path: str, body: Optional[dict] = None) -> dict:
156
+ """PUT request to relay API."""
157
+ return self._request("PUT", path, body=body)
158
+
159
+ def patch(self, path: str, body: Optional[dict] = None) -> dict:
160
+ """PATCH request to relay API."""
161
+ return self._request("PATCH", path, body=body)
162
+
163
+ def _log_operation(self, method: str, path: str, status: int, result: dict | list):
164
+ """Log operation metadata to local log file."""
165
+ try:
166
+ from datetime import datetime, timezone
167
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
168
+ entry = {
169
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
170
+ "method": method,
171
+ "path": path,
172
+ "status": status,
173
+ "success": 200 <= status < 300,
174
+ }
175
+ with open(self.log_path, "a") as f:
176
+ f.write(json.dumps(entry) + "\n")
177
+ except Exception:
178
+ pass # logging should never break operations
248
179
 
249
180
 
250
181
  def find_project_root() -> Path:
182
+ """Walk up from cwd to find .flydocs/ directory."""
251
183
  current = Path.cwd()
252
184
  while current != current.parent:
253
185
  if (current / ".flydocs").is_dir():
@@ -260,6 +192,7 @@ _client: Optional[FlyDocsClient] = None
260
192
 
261
193
 
262
194
  def get_client() -> FlyDocsClient:
195
+ """Get or create singleton client."""
263
196
  global _client
264
197
  if _client is None:
265
198
  _client = FlyDocsClient()
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Get full details for an issue."""
2
+ """Get full details for an issue via the FlyDocs Relay API."""
3
3
 
4
4
  import argparse
5
5
  import sys
@@ -14,64 +14,11 @@ parser.add_argument("--fields", choices=["basic", "full"], default="full",
14
14
  help="basic = no comments, full = with comments (default)")
15
15
  args = parser.parse_args()
16
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
-
17
+ params: dict = {}
23
18
  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})
19
+ params["fields"] = "basic"
50
20
 
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
- ]
21
+ client = get_client()
22
+ result = client.get(f"/issues/{args.ref}", params=params)
76
23
 
77
- output_json(output)
24
+ output_json(result)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Create a relationship between two issues."""
2
+ """Create a link between two issues via the FlyDocs Relay API."""
3
3
 
4
4
  import sys
5
5
  from pathlib import Path
@@ -7,41 +7,22 @@ from pathlib import Path
7
7
  sys.path.insert(0, str(Path(__file__).parent))
8
8
  from flydocs_api import get_client, output_json, fail
9
9
 
10
- LINK_TYPES = {"blocks": "blocks", "related": "relatedTo", "duplicate": "duplicate"}
11
-
12
10
  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()
11
+ fail("Usage: link.py <ref> <related_ref> <type>")
20
12
 
21
- issue_uuid = client.resolve_issue_id(ref)
22
- if not issue_uuid:
23
- fail(f"Issue not found: {ref}")
13
+ ref, related_ref, link_type = sys.argv[1], sys.argv[2], sys.argv[3]
24
14
 
25
- related_uuid = client.resolve_issue_id(related_ref)
26
- if not related_uuid:
27
- fail(f"Related issue not found: {related_ref}")
15
+ valid_types = ("blocks", "related", "duplicate")
16
+ if link_type not in valid_types:
17
+ fail(f"Link type must be one of: {', '.join(valid_types)}")
28
18
 
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})
19
+ client = get_client()
20
+ result = client.post(f"/issues/{ref}/link", {
21
+ "relatedRef": related_ref,
22
+ "type": link_type,
23
+ })
24
+
25
+ output_json({
26
+ "success": result.get("success", True),
27
+ "type": result.get("type", link_type),
28
+ })
@@ -1,35 +1,28 @@
1
1
  #!/usr/bin/env python3
2
- """List cycles (sprints)."""
2
+ """List cycles via the FlyDocs Relay API."""
3
3
 
4
4
  import argparse
5
5
  import sys
6
6
  from pathlib import Path
7
7
 
8
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)
9
+ from flydocs_api import get_client, output_json
10
+
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(description="List cycles")
14
+ parser.add_argument("--active", action="store_true")
15
+ args = parser.parse_args()
16
+
17
+ params: dict = {}
18
+ if args.active:
19
+ params["active"] = "true"
20
+
21
+ client = get_client()
22
+ result = client.get("/cycles", params=params)
23
+
24
+ output_json(result)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()