pxtx 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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.
pxtx-0.1.0/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # pxtx
2
+
3
+ Command-line client for [pxtx](https://github.com/pretalx/pxtx), the pretalx
4
+ issue tracker. Talks to the REST API over HTTP; pairs equally well with a
5
+ human at the keyboard and a claude-code instance.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install pxtx
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Create `~/.config/pxtx/config.toml`:
16
+
17
+ ```toml
18
+ url = "https://tracker.pretalx.com"
19
+ token = "pxtx_..."
20
+ # Repo assumed when a GitHub reference is specified without a repo (e.g. GH-42).
21
+ default_repo = "pretalx/pretalx"
22
+ ```
23
+
24
+ Any of these can also be supplied via environment variables: `PXTX_URL`,
25
+ `PXTX_TOKEN`, `PXTX_DEFAULT_REPO`, `PXTX_CONFIG` (path override).
26
+
27
+ ## Commands
28
+
29
+ ```
30
+ pxtx issue new --title "..." [--priority want] [--effort 2-6h] [--milestone 25.1]
31
+ pxtx issue list [--status open,wip] [--mine] [--priority want]
32
+ pxtx issue show PX-47 [--comments]
33
+ pxtx issue close PX-47 [--wontfix]
34
+ pxtx issue comment PX-47 "message" # or --stdin
35
+ pxtx take PX-47 # assignee=you, status=wip
36
+ pxtx pr PX-47 <ref> # link a GitHub PR (idempotent)
37
+ pxtx milestone list
38
+ pxtx activity log [PX-47] [--since 1h]
39
+ ```
40
+
41
+ Append `--json` (as a top-level flag, e.g. `pxtx --json issue show PX-47`) to
42
+ get the raw API response instead of a human-readable summary.
43
+
44
+ ## Actor
45
+
46
+ The server records an `actor` alongside every API action (activity log,
47
+ comment authorship). When run inside a claude-code session
48
+ (`CLAUDECODE=1`), the CLI auto-derives `claude-<git-branch>` and sends it
49
+ as `X-Pxtx-Actor` on every request, so a single shared API token can
50
+ still attribute work to the right agent. Outside claude-code the header
51
+ is omitted and the server falls back to the token name. Override
52
+ explicitly with `--actor NAME` before the subcommand:
53
+
54
+ ```
55
+ pxtx --actor rixx issue list --mine
56
+ ```
57
+
58
+ `--mine` and `pxtx take` both use this resolved actor.
@@ -0,0 +1,116 @@
1
+ [project]
2
+ name = "pxtx"
3
+ dynamic = ["version"]
4
+ description = "Command-line client for pxtx (pretalx issue tracker)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ authors = [
9
+ {name = "Tobias Kunze", email = "r@rixx.de"},
10
+ ]
11
+
12
+ dependencies = [
13
+ "requests~=2.32",
14
+ ]
15
+
16
+ [project.scripts]
17
+ pxtx = "pxtx.cli:main"
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "coverage",
22
+ "freezegun",
23
+ "pytest",
24
+ "pytest-cov",
25
+ "pytest-sugar",
26
+ "responses",
27
+ "ruff",
28
+ ]
29
+
30
+ [build-system]
31
+ build-backend = "setuptools.build_meta"
32
+ requires = ["setuptools", "wheel"]
33
+
34
+ [tool.setuptools]
35
+ include-package-data = true
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = {attr = "pxtx.__version__"}
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+ include = ["pxtx*"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = "tests"
46
+ pythonpath = "src"
47
+ filterwarnings = [
48
+ "error",
49
+ "ignore::pytest.PytestUnraisableExceptionWarning",
50
+ ]
51
+ addopts = "-p no:doctest -p no:pastebin -p no:nose"
52
+
53
+ [tool.coverage.run]
54
+ branch = true
55
+ source = ["src/pxtx"]
56
+ omit = [
57
+ "*/__init__.py",
58
+ "*/__main__.py",
59
+ ]
60
+ relative_files = true
61
+
62
+ [tool.coverage.report]
63
+ fail_under = 100
64
+ exclude_also = [
65
+ "def __repr__",
66
+ "def __str__",
67
+ "NotImplementedError",
68
+ ]
69
+
70
+ [tool.ruff]
71
+ extend-exclude = ["scripts"]
72
+
73
+ [tool.ruff.format]
74
+ skip-magic-trailing-comma = true
75
+
76
+ [tool.ruff.lint]
77
+ select = ["ALL"]
78
+ ignore = [
79
+ "ANN",
80
+ "ARG",
81
+ "BLE001",
82
+ "C901",
83
+ "COM812",
84
+ "D",
85
+ "E501",
86
+ "EM",
87
+ "FBT",
88
+ "FIX",
89
+ "PLC0415",
90
+ "PLR",
91
+ "RET502",
92
+ "RET503",
93
+ "RUF001",
94
+ "RUF012",
95
+ "RUF100",
96
+ "SLF001",
97
+ "T201",
98
+ "TRY003",
99
+ ]
100
+
101
+ [tool.ruff.lint.per-file-ignores]
102
+ "tests/**.py" = [
103
+ "DTZ",
104
+ "RUF",
105
+ "S",
106
+ "S101",
107
+ "SLF001",
108
+ "TRY",
109
+ "PLR2004",
110
+ ]
111
+
112
+ [tool.ruff.lint.flake8-pytest-style]
113
+ parametrize-values-type = "tuple"
114
+
115
+ [tool.ruff.lint.isort]
116
+ split-on-trailing-comma = false
pxtx-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from pxtx.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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