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

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 (74) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +1553 -414
  3. package/package.json +1 -1
  4. package/template/.claude/CLAUDE.md +11 -9
  5. package/template/.claude/agents/implementation-agent.md +0 -1
  6. package/template/.claude/agents/pm-agent.md +0 -1
  7. package/template/.claude/agents/research-agent.md +0 -1
  8. package/template/.claude/agents/review-agent.md +0 -1
  9. package/template/.claude/commands/flydocs-setup.md +202 -35
  10. package/template/.claude/commands/flydocs-upgrade.md +342 -0
  11. package/template/.claude/commands/knowledge.md +61 -0
  12. package/template/.claude/skills/flydocs-cloud/SKILL.md +66 -39
  13. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +5 -5
  14. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +17 -27
  15. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +14 -30
  16. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +10 -32
  17. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +15 -25
  18. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +42 -59
  19. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +26 -37
  20. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +24 -31
  21. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +39 -0
  22. package/template/.claude/skills/flydocs-cloud/scripts/delete_milestone.py +21 -0
  23. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +17 -22
  24. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +113 -169
  25. package/template/.claude/skills/flydocs-cloud/scripts/get_estimate_scale.py +23 -0
  26. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +6 -59
  27. package/template/.claude/skills/flydocs-cloud/scripts/link.py +16 -35
  28. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +21 -28
  29. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +16 -77
  30. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +19 -0
  31. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +21 -33
  32. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +24 -38
  33. package/template/.claude/skills/flydocs-cloud/scripts/list_providers.py +19 -0
  34. package/template/.claude/skills/flydocs-cloud/scripts/list_statuses.py +19 -0
  35. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +19 -0
  36. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +10 -19
  37. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +36 -50
  38. package/template/.claude/skills/flydocs-cloud/scripts/refresh_labels.py +87 -0
  39. package/template/.claude/skills/flydocs-cloud/scripts/set_identity.py +38 -0
  40. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +68 -0
  41. package/template/.claude/skills/flydocs-cloud/scripts/set_preferences.py +49 -0
  42. package/template/.claude/skills/flydocs-cloud/scripts/set_provider.py +46 -0
  43. package/template/.claude/skills/flydocs-cloud/scripts/set_status_mapping.py +69 -0
  44. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +42 -0
  45. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +11 -52
  46. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +16 -27
  47. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +43 -54
  48. package/template/.claude/skills/flydocs-cloud/scripts/update_milestone.py +42 -0
  49. package/template/.claude/skills/flydocs-cloud/scripts/validate_setup.py +139 -0
  50. package/template/.claude/skills/flydocs-local/SKILL.md +1 -1
  51. package/template/.claude/skills/flydocs-local/scripts/assign.py +13 -4
  52. package/template/.claude/skills/flydocs-local/scripts/flydocs_api.py +5 -2
  53. package/template/.claude/skills/flydocs-workflow/SKILL.md +23 -18
  54. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +1 -0
  55. package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +105 -0
  56. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +37 -15
  57. package/template/.claude/skills/flydocs-workflow/session.md +24 -16
  58. package/template/.claude/skills/flydocs-workflow/stages/capture.md +8 -3
  59. package/template/.claude/skills/flydocs-workflow/stages/close.md +4 -3
  60. package/template/.claude/skills/flydocs-workflow/stages/implement.md +28 -4
  61. package/template/.claude/skills/flydocs-workflow/stages/refine.md +20 -4
  62. package/template/.claude/skills/flydocs-workflow/stages/review.md +14 -2
  63. package/template/.env.example +16 -7
  64. package/template/.flydocs/config.json +4 -18
  65. package/template/.flydocs/hooks/prompt-submit.py +27 -4
  66. package/template/.flydocs/version +1 -1
  67. package/template/AGENTS.md +8 -8
  68. package/template/CHANGELOG.md +183 -0
  69. package/template/flydocs/knowledge/INDEX.md +38 -53
  70. package/template/flydocs/knowledge/README.md +60 -9
  71. package/template/flydocs/knowledge/templates/decision.md +47 -0
  72. package/template/flydocs/knowledge/templates/feature.md +35 -0
  73. package/template/flydocs/knowledge/templates/note.md +25 -0
  74. package/template/manifest.json +12 -4
@@ -1,53 +1,49 @@
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", {})
40
+ self.workspace_id = self.config.get("workspaceId")
41
+ if not self.workspace_id:
42
+ print("ERROR: workspaceId not found in .flydocs/config.json", file=sys.stderr)
43
+ print("Run 'flydocs setup' to configure your workspace", file=sys.stderr)
44
+ sys.exit(1)
46
45
 
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
46
+ self.base_url = self._resolve_base_url()
51
47
 
52
48
  def _load_config(self) -> dict:
53
49
  if self.config_path.exists():
@@ -56,12 +52,12 @@ class FlyDocsClient:
56
52
  return {}
57
53
 
58
54
  def _load_api_key(self) -> Optional[str]:
59
- if os.environ.get("LINEAR_API_KEY"):
60
- return os.environ["LINEAR_API_KEY"]
55
+ if os.environ.get("FLYDOCS_API_KEY"):
56
+ return os.environ["FLYDOCS_API_KEY"]
61
57
  for name in [".env.local", ".env"]:
62
58
  env_file = self.project_root / name
63
59
  if env_file.exists():
64
- key = self._parse_env_file(env_file, "LINEAR_API_KEY")
60
+ key = self._parse_env_file(env_file, "FLYDOCS_API_KEY")
65
61
  if key:
66
62
  return key
67
63
  return None
@@ -78,176 +74,123 @@ class FlyDocsClient:
78
74
  return v if v else None
79
75
  return None
80
76
 
81
- def query(self, query: str, variables: Optional[dict] = None) -> dict:
82
- payload = {"query": query}
83
- if variables:
84
- payload["variables"] = variables
77
+ def _resolve_base_url(self) -> str:
78
+ """Resolve base URL: env var > config > default."""
79
+ env_url = os.environ.get("FLYDOCS_RELAY_URL")
80
+ if env_url:
81
+ return env_url.rstrip("/")
82
+ config_url = self.config.get("relay", {}).get("url")
83
+ if config_url:
84
+ return config_url.rstrip("/")
85
+ return self.DEFAULT_BASE_URL
86
+
87
+ def _request(self, method: str, path: str, body: Optional[dict] = None,
88
+ params: Optional[dict] = None) -> dict:
89
+ """Make an HTTP request to the relay API."""
90
+ import urllib.request
91
+ import urllib.error
92
+ import urllib.parse
93
+
94
+ url = f"{self.base_url}{path}"
95
+ if params:
96
+ filtered = {k: v for k, v in params.items() if v is not None and v != ""}
97
+ if filtered:
98
+ url += "?" + urllib.parse.urlencode(filtered, doseq=True)
85
99
 
86
- data = json.dumps(payload).encode("utf-8")
87
100
  headers = {
101
+ "Authorization": f"Bearer {self.api_key}",
102
+ "X-Workspace": self.workspace_id,
88
103
  "Content-Type": "application/json",
89
- "Authorization": self.api_key,
104
+ "Accept": "application/json",
90
105
  }
91
106
 
107
+ data = json.dumps(body).encode("utf-8") if body else None
108
+
92
109
  for attempt in range(self.MAX_RETRIES):
93
110
  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)
111
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
112
+ with urllib.request.urlopen(req, timeout=15) as resp:
113
+ response_body = resp.read().decode("utf-8")
114
+ result = json.loads(response_body) if response_body else {}
115
+ self._log_operation(method, path, resp.status, result)
103
116
  return result
104
117
  except urllib.error.HTTPError as e:
118
+ error_body = e.read().decode("utf-8") if e.fp else ""
119
+ try:
120
+ error_data = json.loads(error_body) if error_body else {}
121
+ except json.JSONDecodeError:
122
+ error_data = {"error": error_body}
123
+ self._log_operation(method, path, e.code, error_data)
124
+
105
125
  if e.code == 429 and attempt < self.MAX_RETRIES - 1:
106
126
  delay = self.RETRY_DELAY * (2 ** attempt)
107
127
  print(f"Rate limited, retrying in {delay}s...", file=sys.stderr)
108
128
  time.sleep(delay)
109
129
  continue
110
- raise
130
+ if e.code >= 500 and attempt < self.MAX_RETRIES - 1:
131
+ delay = self.RETRY_DELAY * (2 ** attempt)
132
+ print(f"Server error ({e.code}), retrying in {delay}s...", file=sys.stderr)
133
+ time.sleep(delay)
134
+ continue
135
+
136
+ error_msg = error_data.get("error", f"HTTP {e.code}")
137
+ error_code = error_data.get("code", "UNKNOWN")
138
+ provider = error_data.get("provider_error", "")
139
+ msg = f"Relay API error ({error_code}): {error_msg}"
140
+ if provider:
141
+ msg += f" — provider: {provider}"
142
+ fail(msg)
111
143
  except (urllib.error.URLError, TimeoutError):
112
144
  if attempt < self.MAX_RETRIES - 1:
113
145
  delay = self.RETRY_DELAY * (2 ** attempt)
114
146
  print(f"Network error, retrying in {delay}s...", file=sys.stderr)
115
147
  time.sleep(delay)
116
148
  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")
149
+ fail("Network error: unable to reach relay API")
150
+
151
+ fail("Max retries exceeded")
152
+ return {} # unreachable but satisfies type checker
153
+
154
+ def get(self, path: str, params: Optional[dict] = None) -> dict | list:
155
+ """GET request to relay API."""
156
+ return self._request("GET", path, params=params)
157
+
158
+ def post(self, path: str, body: Optional[dict] = None) -> dict:
159
+ """POST request to relay API."""
160
+ return self._request("POST", path, body=body)
161
+
162
+ def put(self, path: str, body: Optional[dict] = None) -> dict:
163
+ """PUT request to relay API."""
164
+ return self._request("PUT", path, body=body)
165
+
166
+ def patch(self, path: str, body: Optional[dict] = None) -> dict:
167
+ """PATCH request to relay API."""
168
+ return self._request("PATCH", path, body=body)
169
+
170
+ def delete(self, path: str) -> dict:
171
+ """DELETE request to relay API."""
172
+ return self._request("DELETE", path)
173
+
174
+ def _log_operation(self, method: str, path: str, status: int, result: dict | list):
175
+ """Log operation metadata to local log file."""
176
+ try:
177
+ from datetime import datetime, timezone
178
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
179
+ entry = {
180
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
181
+ "method": method,
182
+ "path": path,
183
+ "status": status,
184
+ "success": 200 <= status < 300,
185
+ }
186
+ with open(self.log_path, "a") as f:
187
+ f.write(json.dumps(entry) + "\n")
188
+ except Exception:
189
+ pass # logging should never break operations
248
190
 
249
191
 
250
192
  def find_project_root() -> Path:
193
+ """Walk up from cwd to find .flydocs/ directory."""
251
194
  current = Path.cwd()
252
195
  while current != current.parent:
253
196
  if (current / ".flydocs").is_dir():
@@ -260,6 +203,7 @@ _client: Optional[FlyDocsClient] = None
260
203
 
261
204
 
262
205
  def get_client() -> FlyDocsClient:
206
+ """Get or create singleton client."""
263
207
  global _client
264
208
  if _client is None:
265
209
  _client = FlyDocsClient()
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python3
2
+ """Get the provider's estimate scale via the FlyDocs Relay API.
3
+
4
+ Returns the valid estimate values for the connected provider.
5
+ Linear: fixed scale [0, 1, 2, 3, 5, 8, 13, 21]
6
+ Jira: freeform (any positive number)
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).parent))
13
+ from flydocs_api import get_client, output_json
14
+
15
+
16
+ def main():
17
+ client = get_client()
18
+ result = client.get("/auth/estimates")
19
+ output_json(result)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -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()