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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pxtx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pxtx.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pxtx = pxtx.cli:main
@@ -0,0 +1 @@
1
+ pxtx