pxtx 0.1.0__py3-none-any.whl
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.
- pxtx/__init__.py +1 -0
- pxtx/__main__.py +4 -0
- pxtx/cli.py +365 -0
- pxtx/client.py +118 -0
- pxtx/config.py +48 -0
- pxtx/display.py +90 -0
- pxtx-0.1.0.dist-info/METADATA +76 -0
- pxtx-0.1.0.dist-info/RECORD +11 -0
- pxtx-0.1.0.dist-info/WHEEL +5 -0
- pxtx-0.1.0.dist-info/entry_points.txt +2 -0
- pxtx-0.1.0.dist-info/top_level.txt +1 -0
pxtx/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pxtx/__main__.py
ADDED
pxtx/cli.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
|
|
11
|
+
from pxtx.client import ApiError, Client
|
|
12
|
+
from pxtx.config import ConfigError, load_config
|
|
13
|
+
from pxtx.display import (
|
|
14
|
+
format_activity_row,
|
|
15
|
+
format_issue_detail,
|
|
16
|
+
format_issue_row,
|
|
17
|
+
format_milestone_row,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PRIORITY_MAP = {"want": 1, "should": 2, "could": 3, "whatev": 4, "lol": 5}
|
|
21
|
+
EFFORT_MAP = {"<1h": 30, "1-2h": 90, "2-6h": 240, "1d": 480, ">1d": 960}
|
|
22
|
+
|
|
23
|
+
SINCE_UNITS = {"m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_issue_id(value):
|
|
31
|
+
"""Accept ``PX-47``, ``px-47``, or the bare number."""
|
|
32
|
+
match = re.fullmatch(r"(?:PX-)?(\d+)", value.strip(), flags=re.IGNORECASE)
|
|
33
|
+
if not match:
|
|
34
|
+
raise argparse.ArgumentTypeError(f"not an issue id: {value}")
|
|
35
|
+
return int(match.group(1))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_priority_csv(value):
|
|
39
|
+
"""Validate a comma-separated list of priority labels against PRIORITY_MAP."""
|
|
40
|
+
labels = [p.strip() for p in value.split(",") if p.strip()]
|
|
41
|
+
if not labels:
|
|
42
|
+
raise argparse.ArgumentTypeError("priority list is empty")
|
|
43
|
+
known = list(PRIORITY_MAP)
|
|
44
|
+
bad = [label for label in labels if label not in PRIORITY_MAP]
|
|
45
|
+
if bad:
|
|
46
|
+
raise argparse.ArgumentTypeError(
|
|
47
|
+
f"unknown priority {bad[0]!r}; choose from: {', '.join(known)}"
|
|
48
|
+
)
|
|
49
|
+
return labels
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_since(value, *, now=None):
|
|
53
|
+
"""Accept ``1h``/``30m``/``2d``/``1w`` or an ISO timestamp. Return ISO string."""
|
|
54
|
+
match = re.fullmatch(r"(\d+)([mhdwMHDW])", value)
|
|
55
|
+
if match:
|
|
56
|
+
amount, unit = int(match.group(1)), match.group(2).lower()
|
|
57
|
+
kwargs = {SINCE_UNITS[unit]: amount}
|
|
58
|
+
anchor = now or datetime.now(UTC)
|
|
59
|
+
return (anchor - timedelta(**kwargs)).isoformat()
|
|
60
|
+
try:
|
|
61
|
+
datetime.fromisoformat(value)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise CliError(f"not a duration or ISO timestamp: {value}") from exc
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_branch(runner=None):
|
|
68
|
+
runner = runner or _run_git_branch
|
|
69
|
+
return runner()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_git_branch():
|
|
73
|
+
try:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
81
|
+
return None
|
|
82
|
+
branch = result.stdout.strip()
|
|
83
|
+
return branch or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_actor(explicit):
|
|
87
|
+
"""Pick the ``X-Pxtx-Actor`` value sent with every request.
|
|
88
|
+
|
|
89
|
+
Inside a claude-code session (``CLAUDECODE=1``) we derive
|
|
90
|
+
``claude-<branch>`` automatically so activity log entries identify
|
|
91
|
+
which agent did what — humans don't have to remember. Outside that
|
|
92
|
+
context we stay silent and let the server fall back to the token name,
|
|
93
|
+
so a human poking the CLI doesn't accidentally label their edits
|
|
94
|
+
``claude-*``. ``--actor`` overrides both paths.
|
|
95
|
+
"""
|
|
96
|
+
if explicit:
|
|
97
|
+
return explicit
|
|
98
|
+
if os.environ.get("CLAUDECODE") != "1":
|
|
99
|
+
return ""
|
|
100
|
+
branch = get_branch()
|
|
101
|
+
if branch:
|
|
102
|
+
return f"claude-{branch}"
|
|
103
|
+
return "claude"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_json(value, out=None):
|
|
107
|
+
out = out or sys.stdout
|
|
108
|
+
json.dump(value, out, indent=2, default=str)
|
|
109
|
+
out.write("\n")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_issue_new(args, client, config):
|
|
113
|
+
payload = {"title": args.title}
|
|
114
|
+
if args.priority:
|
|
115
|
+
payload["priority"] = PRIORITY_MAP[args.priority]
|
|
116
|
+
if args.effort:
|
|
117
|
+
payload["effort_minutes"] = EFFORT_MAP[args.effort]
|
|
118
|
+
if args.milestone:
|
|
119
|
+
payload["milestone"] = args.milestone
|
|
120
|
+
if args.description:
|
|
121
|
+
payload["description"] = args.description
|
|
122
|
+
if args.assignee:
|
|
123
|
+
payload["assignee"] = args.assignee
|
|
124
|
+
issue = client.create_issue(payload)
|
|
125
|
+
if args.json:
|
|
126
|
+
print_json(issue)
|
|
127
|
+
else:
|
|
128
|
+
print(f"created {issue['slug']}: {issue['title']}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_issue_list(args, client, config):
|
|
132
|
+
filters = {}
|
|
133
|
+
if args.status:
|
|
134
|
+
filters["status"] = args.status
|
|
135
|
+
if args.priority:
|
|
136
|
+
filters["priority"] = ",".join(str(PRIORITY_MAP[p]) for p in args.priority)
|
|
137
|
+
if args.milestone:
|
|
138
|
+
filters["milestone"] = args.milestone
|
|
139
|
+
if args.mine:
|
|
140
|
+
if not client.actor:
|
|
141
|
+
raise CliError(
|
|
142
|
+
"--mine needs an actor (pass --actor or run inside claude-code)"
|
|
143
|
+
)
|
|
144
|
+
filters["assignee"] = client.actor
|
|
145
|
+
elif args.assignee:
|
|
146
|
+
filters["assignee"] = args.assignee
|
|
147
|
+
if args.highlighted:
|
|
148
|
+
filters["is_highlighted"] = "true"
|
|
149
|
+
if args.search:
|
|
150
|
+
filters["search"] = args.search
|
|
151
|
+
issues = list(client.list_issues(**filters))
|
|
152
|
+
if args.json:
|
|
153
|
+
print_json(issues)
|
|
154
|
+
return
|
|
155
|
+
for issue in issues:
|
|
156
|
+
print(format_issue_row(issue))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_issue_show(args, client, config):
|
|
160
|
+
issue = client.get_issue(args.number)
|
|
161
|
+
comments = client.list_comments(args.number) if args.comments else None
|
|
162
|
+
if args.json:
|
|
163
|
+
payload = {"issue": issue}
|
|
164
|
+
if comments is not None:
|
|
165
|
+
payload["comments"] = comments
|
|
166
|
+
print_json(payload)
|
|
167
|
+
return
|
|
168
|
+
print(format_issue_detail(issue, comments))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_issue_take(args, client, config):
|
|
172
|
+
"""Claim an issue: set assignee to the current actor and status to wip."""
|
|
173
|
+
if not client.actor:
|
|
174
|
+
raise CliError("take needs an actor (pass --actor or run inside claude-code)")
|
|
175
|
+
client.update_issue(args.number, {"assignee": client.actor})
|
|
176
|
+
issue = client.transition_issue(args.number, "wip")
|
|
177
|
+
if args.json:
|
|
178
|
+
print_json(issue)
|
|
179
|
+
else:
|
|
180
|
+
print(f"{issue['slug']} → {issue['status']} (assignee: {issue['assignee']})")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
PR_URL_PATTERN = re.compile(
|
|
184
|
+
r"https?://github\.com/([^/]+/[^/]+)/pull/(\d+)(?:[/?#].*)?$", flags=re.IGNORECASE
|
|
185
|
+
)
|
|
186
|
+
PR_SHORT_PATTERN = re.compile(r"([^/\s]+/[^/\s#!]+)[#!](\d+)$")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_pr_ref(value, *, default_repo):
|
|
190
|
+
"""Parse a PR reference into ``(repo, number)``.
|
|
191
|
+
|
|
192
|
+
Accepted forms:
|
|
193
|
+
- ``42`` (bare number, uses ``default_repo``)
|
|
194
|
+
- ``owner/repo#42`` or ``owner/repo!42``
|
|
195
|
+
- ``https://github.com/owner/repo/pull/42``
|
|
196
|
+
"""
|
|
197
|
+
value = value.strip()
|
|
198
|
+
match = PR_URL_PATTERN.match(value)
|
|
199
|
+
if match:
|
|
200
|
+
return match.group(1), int(match.group(2))
|
|
201
|
+
match = PR_SHORT_PATTERN.match(value)
|
|
202
|
+
if match:
|
|
203
|
+
return match.group(1), int(match.group(2))
|
|
204
|
+
if value.isdigit():
|
|
205
|
+
if not default_repo:
|
|
206
|
+
raise CliError("bare PR number needs 'default_repo' in config")
|
|
207
|
+
return default_repo, int(value)
|
|
208
|
+
raise CliError(f"not a PR reference: {value}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_pr_link(args, client, config):
|
|
212
|
+
repo, number = parse_pr_ref(args.ref, default_repo=config.default_repo)
|
|
213
|
+
ref = client.add_github_ref(
|
|
214
|
+
args.number, {"kind": "pr", "repo": repo, "number": number}
|
|
215
|
+
)
|
|
216
|
+
if args.json:
|
|
217
|
+
print_json(ref)
|
|
218
|
+
else:
|
|
219
|
+
print(f"PX-{args.number} ↔ {ref['display']}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_issue_close(args, client, config):
|
|
223
|
+
action = "wontfix" if args.wontfix else "completed"
|
|
224
|
+
issue = client.transition_issue(args.number, action)
|
|
225
|
+
if args.json:
|
|
226
|
+
print_json(issue)
|
|
227
|
+
else:
|
|
228
|
+
print(f"{issue['slug']} → {issue['status']}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_issue_comment(args, client, config):
|
|
232
|
+
body = args.body
|
|
233
|
+
if args.stdin or body is None:
|
|
234
|
+
body = sys.stdin.read()
|
|
235
|
+
if not body.strip():
|
|
236
|
+
raise CliError("comment body is empty")
|
|
237
|
+
comment = client.add_comment(args.number, body)
|
|
238
|
+
if args.json:
|
|
239
|
+
print_json(comment)
|
|
240
|
+
else:
|
|
241
|
+
print(f"added comment #{comment['id']} to PX-{args.number}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_milestone_list(args, client, config):
|
|
245
|
+
milestones = list(client.list_milestones())
|
|
246
|
+
if args.json:
|
|
247
|
+
print_json(milestones)
|
|
248
|
+
return
|
|
249
|
+
for milestone in milestones:
|
|
250
|
+
print(format_milestone_row(milestone))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_activity_log(args, client, config):
|
|
254
|
+
filters = {}
|
|
255
|
+
if args.number is not None:
|
|
256
|
+
filters["issue"] = args.number
|
|
257
|
+
if args.since:
|
|
258
|
+
filters["since"] = parse_since(args.since)
|
|
259
|
+
entries = list(client.activity_log(**filters))
|
|
260
|
+
if args.json:
|
|
261
|
+
print_json(entries)
|
|
262
|
+
return
|
|
263
|
+
for entry in entries:
|
|
264
|
+
print(format_activity_row(entry))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def build_parser():
|
|
268
|
+
parser = argparse.ArgumentParser(prog="pxtx", description="pretalx-tracker CLI")
|
|
269
|
+
parser.add_argument("--json", action="store_true", help="emit raw API JSON")
|
|
270
|
+
parser.add_argument(
|
|
271
|
+
"--actor",
|
|
272
|
+
help=(
|
|
273
|
+
"override the X-Pxtx-Actor header "
|
|
274
|
+
"(default: claude-<branch> inside claude-code, else the token name)"
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
278
|
+
|
|
279
|
+
issue = sub.add_parser("issue", help="manage issues")
|
|
280
|
+
issue_sub = issue.add_subparsers(dest="subcommand", required=True)
|
|
281
|
+
|
|
282
|
+
new = issue_sub.add_parser("new", help="create an issue")
|
|
283
|
+
new.add_argument("--title", required=True)
|
|
284
|
+
new.add_argument("--priority", choices=list(PRIORITY_MAP))
|
|
285
|
+
new.add_argument("--effort", choices=list(EFFORT_MAP))
|
|
286
|
+
new.add_argument("--milestone", help="milestone slug")
|
|
287
|
+
new.add_argument("--description")
|
|
288
|
+
new.add_argument("--assignee")
|
|
289
|
+
new.set_defaults(func=cmd_issue_new)
|
|
290
|
+
|
|
291
|
+
lst = issue_sub.add_parser("list", help="list issues")
|
|
292
|
+
lst.add_argument("--status", help="comma-separated statuses")
|
|
293
|
+
lst.add_argument(
|
|
294
|
+
"--priority",
|
|
295
|
+
type=parse_priority_csv,
|
|
296
|
+
help="comma-separated priority labels (want,should,...)",
|
|
297
|
+
)
|
|
298
|
+
lst.add_argument("--milestone")
|
|
299
|
+
lst.add_argument("--mine", action="store_true", help="filter by current actor")
|
|
300
|
+
lst.add_argument("--assignee")
|
|
301
|
+
lst.add_argument("--highlighted", action="store_true")
|
|
302
|
+
lst.add_argument("--search")
|
|
303
|
+
lst.set_defaults(func=cmd_issue_list)
|
|
304
|
+
|
|
305
|
+
show = issue_sub.add_parser("show", help="show an issue")
|
|
306
|
+
show.add_argument("number", type=parse_issue_id, help="PX-47 or 47")
|
|
307
|
+
show.add_argument("--comments", action="store_true")
|
|
308
|
+
show.set_defaults(func=cmd_issue_show)
|
|
309
|
+
|
|
310
|
+
close = issue_sub.add_parser("close", help="close an issue")
|
|
311
|
+
close.add_argument("number", type=parse_issue_id)
|
|
312
|
+
close.add_argument("--wontfix", action="store_true")
|
|
313
|
+
close.set_defaults(func=cmd_issue_close)
|
|
314
|
+
|
|
315
|
+
comment = issue_sub.add_parser("comment", help="comment on an issue")
|
|
316
|
+
comment.add_argument("number", type=parse_issue_id)
|
|
317
|
+
comment.add_argument("body", nargs="?")
|
|
318
|
+
comment.add_argument("--stdin", action="store_true")
|
|
319
|
+
comment.set_defaults(func=cmd_issue_comment)
|
|
320
|
+
|
|
321
|
+
take = sub.add_parser("take", help="claim an issue (assignee=you, status=wip)")
|
|
322
|
+
take.add_argument("number", type=parse_issue_id, help="PX-47 or 47")
|
|
323
|
+
take.set_defaults(func=cmd_issue_take)
|
|
324
|
+
|
|
325
|
+
pr = sub.add_parser("pr", help="link a github PR to an issue")
|
|
326
|
+
pr.add_argument("number", type=parse_issue_id, help="PX-47 or 47")
|
|
327
|
+
pr.add_argument(
|
|
328
|
+
"ref",
|
|
329
|
+
help="PR: bare number, owner/repo#N, owner/repo!N, or github.com/.../pull/N URL",
|
|
330
|
+
)
|
|
331
|
+
pr.set_defaults(func=cmd_pr_link)
|
|
332
|
+
|
|
333
|
+
milestone = sub.add_parser("milestone", help="manage milestones")
|
|
334
|
+
ms_sub = milestone.add_subparsers(dest="subcommand", required=True)
|
|
335
|
+
ms_list = ms_sub.add_parser("list", help="list milestones")
|
|
336
|
+
ms_list.set_defaults(func=cmd_milestone_list)
|
|
337
|
+
|
|
338
|
+
activity = sub.add_parser("activity", help="activity log")
|
|
339
|
+
act_sub = activity.add_subparsers(dest="subcommand", required=True)
|
|
340
|
+
act_log = act_sub.add_parser("log", help="show activity log")
|
|
341
|
+
act_log.add_argument("number", type=parse_issue_id, nargs="?")
|
|
342
|
+
act_log.add_argument("--since", help="duration (1h, 2d) or ISO timestamp")
|
|
343
|
+
act_log.set_defaults(func=cmd_activity_log)
|
|
344
|
+
|
|
345
|
+
return parser
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main(argv=None):
|
|
349
|
+
parser = build_parser()
|
|
350
|
+
args = parser.parse_args(argv)
|
|
351
|
+
try:
|
|
352
|
+
config = load_config()
|
|
353
|
+
except ConfigError as exc:
|
|
354
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
355
|
+
return 2
|
|
356
|
+
client = Client(config.url, config.token, actor=resolve_actor(args.actor))
|
|
357
|
+
try:
|
|
358
|
+
args.func(args, client, config)
|
|
359
|
+
except CliError as exc:
|
|
360
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
except ApiError as exc:
|
|
363
|
+
print(f"api error: {exc}", file=sys.stderr)
|
|
364
|
+
return 1
|
|
365
|
+
return 0
|
pxtx/client.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
DEFAULT_TIMEOUT = 30.0
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiError(Exception):
|
|
9
|
+
def __init__(self, message, *, status=None, body=None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status = status
|
|
12
|
+
self.body = body
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Client:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
url: str,
|
|
19
|
+
token: str,
|
|
20
|
+
actor: str = "",
|
|
21
|
+
session: requests.Session | None = None,
|
|
22
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
23
|
+
):
|
|
24
|
+
self.url = url.rstrip("/")
|
|
25
|
+
self.token = token
|
|
26
|
+
self.actor = actor
|
|
27
|
+
self.session = session or requests.Session()
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
|
|
30
|
+
def _endpoint(self, path: str) -> str:
|
|
31
|
+
return f"{self.url}/api/v1{path}"
|
|
32
|
+
|
|
33
|
+
def _headers(self) -> dict[str, str]:
|
|
34
|
+
headers = {"Authorization": f"Token {self.token}"}
|
|
35
|
+
if self.actor:
|
|
36
|
+
headers["X-Pxtx-Actor"] = self.actor
|
|
37
|
+
return headers
|
|
38
|
+
|
|
39
|
+
def _request(self, method, path, *, params=None, json=None):
|
|
40
|
+
response = self.session.request(
|
|
41
|
+
method,
|
|
42
|
+
self._endpoint(path),
|
|
43
|
+
headers=self._headers(),
|
|
44
|
+
params=params,
|
|
45
|
+
json=json,
|
|
46
|
+
timeout=self.timeout,
|
|
47
|
+
)
|
|
48
|
+
if response.status_code >= 400:
|
|
49
|
+
self._raise(method, path, response)
|
|
50
|
+
if response.status_code == 204 or not response.content:
|
|
51
|
+
return None
|
|
52
|
+
return response.json()
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _raise(method, path, response):
|
|
56
|
+
try:
|
|
57
|
+
body = response.json()
|
|
58
|
+
except ValueError:
|
|
59
|
+
body = response.text
|
|
60
|
+
raise ApiError(
|
|
61
|
+
f"{method} {path} → {response.status_code}: {body}",
|
|
62
|
+
status=response.status_code,
|
|
63
|
+
body=body,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def paginate(self, path, *, params=None):
|
|
67
|
+
# Cursor-paginated lists return {"next": <url>, "results": [...]}.
|
|
68
|
+
# The first request carries filter params; the server echoes them back
|
|
69
|
+
# into the ``next`` URL so we don't need to re-send them.
|
|
70
|
+
url = self._endpoint(path)
|
|
71
|
+
first = True
|
|
72
|
+
while url:
|
|
73
|
+
response = self.session.get(
|
|
74
|
+
url,
|
|
75
|
+
headers=self._headers(),
|
|
76
|
+
params=params if first else None,
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
)
|
|
79
|
+
first = False
|
|
80
|
+
if response.status_code >= 400:
|
|
81
|
+
self._raise("GET", path, response)
|
|
82
|
+
data = response.json()
|
|
83
|
+
yield from data["results"]
|
|
84
|
+
url = data.get("next")
|
|
85
|
+
|
|
86
|
+
def list_issues(self, **filters):
|
|
87
|
+
return self.paginate(
|
|
88
|
+
"/issues/", params={k: v for k, v in filters.items() if v is not None}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def get_issue(self, number):
|
|
92
|
+
return self._request("GET", f"/issues/{number}/")
|
|
93
|
+
|
|
94
|
+
def create_issue(self, payload):
|
|
95
|
+
return self._request("POST", "/issues/", json=payload)
|
|
96
|
+
|
|
97
|
+
def update_issue(self, number, payload):
|
|
98
|
+
return self._request("PATCH", f"/issues/{number}/", json=payload)
|
|
99
|
+
|
|
100
|
+
def transition_issue(self, number, action, payload=None):
|
|
101
|
+
return self._request("POST", f"/issues/{number}/{action}/", json=payload or {})
|
|
102
|
+
|
|
103
|
+
def add_comment(self, number, body):
|
|
104
|
+
return self._request("POST", f"/issues/{number}/comments/", json={"body": body})
|
|
105
|
+
|
|
106
|
+
def list_comments(self, number):
|
|
107
|
+
return list(self.paginate(f"/issues/{number}/comments/"))
|
|
108
|
+
|
|
109
|
+
def add_github_ref(self, number, payload):
|
|
110
|
+
return self._request("POST", f"/issues/{number}/github-refs/", json=payload)
|
|
111
|
+
|
|
112
|
+
def list_milestones(self):
|
|
113
|
+
return self.paginate("/milestones/")
|
|
114
|
+
|
|
115
|
+
def activity_log(self, **filters):
|
|
116
|
+
return self.paginate(
|
|
117
|
+
"/activity/", params={k: v for k, v in filters.items() if v is not None}
|
|
118
|
+
)
|
pxtx/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "pxtx" / "config.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Config:
|
|
17
|
+
url: str
|
|
18
|
+
token: str
|
|
19
|
+
default_repo: str = "pretalx/pretalx"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(path: Path | None = None) -> Config:
|
|
23
|
+
if path is None:
|
|
24
|
+
env_path = os.environ.get("PXTX_CONFIG")
|
|
25
|
+
path = Path(env_path) if env_path else DEFAULT_CONFIG_PATH
|
|
26
|
+
|
|
27
|
+
data: dict = {}
|
|
28
|
+
if path.exists():
|
|
29
|
+
try:
|
|
30
|
+
data = tomllib.loads(path.read_text())
|
|
31
|
+
except tomllib.TOMLDecodeError as exc:
|
|
32
|
+
raise ConfigError(f"invalid toml in {path}: {exc}") from exc
|
|
33
|
+
|
|
34
|
+
url = os.environ.get("PXTX_URL") or data.get("url")
|
|
35
|
+
token = os.environ.get("PXTX_TOKEN") or data.get("token")
|
|
36
|
+
if not url:
|
|
37
|
+
raise ConfigError(f"missing 'url' (set it in {path} or PXTX_URL)")
|
|
38
|
+
if not token:
|
|
39
|
+
raise ConfigError(f"missing 'token' (set it in {path} or PXTX_TOKEN)")
|
|
40
|
+
|
|
41
|
+
return Config(
|
|
42
|
+
url=url.rstrip("/"),
|
|
43
|
+
token=token,
|
|
44
|
+
default_repo=(
|
|
45
|
+
os.environ.get("PXTX_DEFAULT_REPO")
|
|
46
|
+
or data.get("default_repo", "pretalx/pretalx")
|
|
47
|
+
),
|
|
48
|
+
)
|
pxtx/display.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
PRIORITY_LABELS = {1: "want", 2: "should", 3: "could", 4: "whatev", 5: "lol"}
|
|
6
|
+
EFFORT_LABELS = {30: "<1h", 90: "1-2h", 240: "2-6h", 480: "1d", 960: ">1d"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fmt_time(iso_str):
|
|
10
|
+
if not iso_str:
|
|
11
|
+
return "-"
|
|
12
|
+
return datetime.fromisoformat(iso_str).strftime("%Y-%m-%d %H:%M")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_priority(value):
|
|
16
|
+
return PRIORITY_LABELS.get(value, str(value))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_effort(value):
|
|
20
|
+
if value is None:
|
|
21
|
+
return "-"
|
|
22
|
+
return EFFORT_LABELS.get(value, str(value))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_milestone(value):
|
|
26
|
+
if value is None:
|
|
27
|
+
return "-"
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
return value.get("slug") or "-"
|
|
30
|
+
return str(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_issue_row(issue):
|
|
34
|
+
return "{slug:<7} {status:<9} {priority:<7} {assignee:<20.20} {title}".format(
|
|
35
|
+
slug=issue["slug"],
|
|
36
|
+
status=issue["status"],
|
|
37
|
+
priority=format_priority(issue["priority"]),
|
|
38
|
+
assignee=issue.get("assignee") or "-",
|
|
39
|
+
title=issue["title"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_issue_detail(issue, comments=None):
|
|
44
|
+
lines = [
|
|
45
|
+
f"{issue['slug']}: {issue['title']}",
|
|
46
|
+
"status: {status} priority: {priority} effort: {effort}".format(
|
|
47
|
+
status=issue["status"],
|
|
48
|
+
priority=format_priority(issue["priority"]),
|
|
49
|
+
effort=format_effort(issue.get("effort_minutes")),
|
|
50
|
+
),
|
|
51
|
+
"assignee: {assignee} milestone: {milestone}".format(
|
|
52
|
+
assignee=issue.get("assignee") or "-",
|
|
53
|
+
milestone=format_milestone(issue.get("milestone")),
|
|
54
|
+
),
|
|
55
|
+
"created: {created} updated: {updated}".format(
|
|
56
|
+
created=fmt_time(issue.get("created_at")),
|
|
57
|
+
updated=fmt_time(issue.get("updated_at")),
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
if issue.get("is_highlighted"):
|
|
61
|
+
lines.append("*highlighted*")
|
|
62
|
+
if issue.get("blocked_reason"):
|
|
63
|
+
lines.append(f"blocked reason: {issue['blocked_reason']}")
|
|
64
|
+
if issue.get("description"):
|
|
65
|
+
lines += ["", issue["description"]]
|
|
66
|
+
if comments is not None:
|
|
67
|
+
lines += ["", f"=== comments ({len(comments)}) ==="]
|
|
68
|
+
for c in comments:
|
|
69
|
+
lines.append(f"[{c['author']} · {fmt_time(c['created_at'])}]")
|
|
70
|
+
lines.append(c["body"])
|
|
71
|
+
lines.append("")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_milestone_row(milestone):
|
|
76
|
+
return "{slug:<20} {target:<12} {name}".format(
|
|
77
|
+
slug=milestone["slug"],
|
|
78
|
+
target=milestone.get("target_date") or "-",
|
|
79
|
+
name=milestone["name"],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_activity_row(entry):
|
|
84
|
+
return "{time} {actor:<25.25} {action} {ct}#{oid}".format(
|
|
85
|
+
time=fmt_time(entry["timestamp"]),
|
|
86
|
+
actor=entry.get("actor") or "-",
|
|
87
|
+
action=entry["action_type"],
|
|
88
|
+
ct=entry["content_type"],
|
|
89
|
+
oid=entry["object_id"],
|
|
90
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pxtx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for pxtx (pretalx issue tracker)
|
|
5
|
+
Author-email: Tobias Kunze <r@rixx.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests~=2.32
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: coverage; extra == "dev"
|
|
12
|
+
Requires-Dist: freezegun; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-sugar; extra == "dev"
|
|
16
|
+
Requires-Dist: responses; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# pxtx
|
|
20
|
+
|
|
21
|
+
Command-line client for [pxtx](https://github.com/pretalx/pxtx), the pretalx
|
|
22
|
+
issue tracker. Talks to the REST API over HTTP; pairs equally well with a
|
|
23
|
+
human at the keyboard and a claude-code instance.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install pxtx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configure
|
|
32
|
+
|
|
33
|
+
Create `~/.config/pxtx/config.toml`:
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
url = "https://tracker.pretalx.com"
|
|
37
|
+
token = "pxtx_..."
|
|
38
|
+
# Repo assumed when a GitHub reference is specified without a repo (e.g. GH-42).
|
|
39
|
+
default_repo = "pretalx/pretalx"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Any of these can also be supplied via environment variables: `PXTX_URL`,
|
|
43
|
+
`PXTX_TOKEN`, `PXTX_DEFAULT_REPO`, `PXTX_CONFIG` (path override).
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pxtx issue new --title "..." [--priority want] [--effort 2-6h] [--milestone 25.1]
|
|
49
|
+
pxtx issue list [--status open,wip] [--mine] [--priority want]
|
|
50
|
+
pxtx issue show PX-47 [--comments]
|
|
51
|
+
pxtx issue close PX-47 [--wontfix]
|
|
52
|
+
pxtx issue comment PX-47 "message" # or --stdin
|
|
53
|
+
pxtx take PX-47 # assignee=you, status=wip
|
|
54
|
+
pxtx pr PX-47 <ref> # link a GitHub PR (idempotent)
|
|
55
|
+
pxtx milestone list
|
|
56
|
+
pxtx activity log [PX-47] [--since 1h]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Append `--json` (as a top-level flag, e.g. `pxtx --json issue show PX-47`) to
|
|
60
|
+
get the raw API response instead of a human-readable summary.
|
|
61
|
+
|
|
62
|
+
## Actor
|
|
63
|
+
|
|
64
|
+
The server records an `actor` alongside every API action (activity log,
|
|
65
|
+
comment authorship). When run inside a claude-code session
|
|
66
|
+
(`CLAUDECODE=1`), the CLI auto-derives `claude-<git-branch>` and sends it
|
|
67
|
+
as `X-Pxtx-Actor` on every request, so a single shared API token can
|
|
68
|
+
still attribute work to the right agent. Outside claude-code the header
|
|
69
|
+
is omitted and the server falls back to the token name. Override
|
|
70
|
+
explicitly with `--actor NAME` before the subcommand:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
pxtx --actor rixx issue list --mine
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`--mine` and `pxtx take` both use this resolved actor.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pxtx/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
pxtx/__main__.py,sha256=tRHDGbwRtKuBZ3ekCYXVy8to63FnM7MXCl4b_J7fL6k,83
|
|
3
|
+
pxtx/cli.py,sha256=N1P8JXNZd9yWgXA7oHx3QxdqSklyC--XBri_sGsRY10,12231
|
|
4
|
+
pxtx/client.py,sha256=PTMP7HD45aCFn4f6aQ7NBEQQ9GKrrcA2wYASKsvaUqE,3762
|
|
5
|
+
pxtx/config.py,sha256=i7oql_ZiR_t-bWslbKdX5NWu0UtEM9cl5ruNRWH-U3s,1273
|
|
6
|
+
pxtx/display.py,sha256=JdK4fYxlzr_KIfzU3WbXns3YlULxpzNP4apxpGzhrJc,2795
|
|
7
|
+
pxtx-0.1.0.dist-info/METADATA,sha256=UxrGeTjG6fJaW2LGpJEAl9UzeaXa-VCiUy692AnrBh8,2391
|
|
8
|
+
pxtx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
pxtx-0.1.0.dist-info/entry_points.txt,sha256=FwdEkdy_SOr4KvRZV9CmwH0wpqf9uYQejr7suox3Mmk,39
|
|
10
|
+
pxtx-0.1.0.dist-info/top_level.txt,sha256=8UrtF2ibB78EFWCOEhoknexwHK9wGW_sdJwV4gkOuKs,5
|
|
11
|
+
pxtx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pxtx
|