@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.
- package/README.md +6 -0
- package/dist/cli.js +1259 -370
- package/package.json +1 -1
- package/template/.claude/agents/implementation-agent.md +0 -1
- package/template/.claude/agents/pm-agent.md +0 -1
- package/template/.claude/agents/research-agent.md +0 -1
- package/template/.claude/agents/review-agent.md +0 -1
- package/template/.claude/commands/flydocs-setup.md +109 -26
- package/template/.claude/commands/flydocs-upgrade.md +330 -0
- package/template/.claude/skills/flydocs-cloud/SKILL.md +53 -38
- package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +5 -5
- package/template/.claude/skills/flydocs-cloud/scripts/assign.py +8 -24
- package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +14 -30
- package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +10 -32
- package/template/.claude/skills/flydocs-cloud/scripts/comment.py +15 -25
- package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +21 -58
- package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +26 -37
- package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +24 -31
- package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +39 -0
- package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +10 -19
- package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +103 -170
- package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +6 -59
- package/template/.claude/skills/flydocs-cloud/scripts/link.py +16 -35
- package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +21 -28
- package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +16 -77
- package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +19 -0
- package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +21 -33
- package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +24 -38
- package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +19 -0
- package/template/.claude/skills/flydocs-cloud/scripts/priority.py +10 -19
- package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +36 -50
- package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +68 -0
- package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +41 -0
- package/template/.claude/skills/flydocs-cloud/scripts/transition.py +11 -52
- package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +16 -27
- package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +23 -52
- package/template/.env.example +16 -7
- package/template/.flydocs/config.json +1 -1
- package/template/.flydocs/version +1 -1
- package/template/CHANGELOG.md +144 -0
- 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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
35
|
-
|
|
22
|
+
client = get_client()
|
|
23
|
+
result = client.put(f"/issues/{ref}/estimate", {"estimate": estimate})
|
|
36
24
|
|
|
37
|
-
|
|
38
|
-
|
|
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 —
|
|
1
|
+
"""FlyDocs Cloud API — Relay REST client.
|
|
2
2
|
|
|
3
|
-
Central module for all
|
|
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.
|
|
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
|
|
21
|
+
"""Client for FlyDocs Relay REST API with retry logic and config awareness."""
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
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" / "
|
|
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:
|
|
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.
|
|
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("
|
|
60
|
-
return os.environ["
|
|
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, "
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
"
|
|
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(
|
|
95
|
-
with urllib.request.urlopen(req, timeout=
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
24
|
+
output_json(result)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Create a
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
if not
|
|
27
|
-
fail(f"
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if args.active:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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()
|