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 +76 -0
- pxtx-0.1.0/README.md +58 -0
- pxtx-0.1.0/pyproject.toml +116 -0
- pxtx-0.1.0/setup.cfg +4 -0
- pxtx-0.1.0/src/pxtx/__init__.py +1 -0
- pxtx-0.1.0/src/pxtx/__main__.py +4 -0
- pxtx-0.1.0/src/pxtx/cli.py +365 -0
- pxtx-0.1.0/src/pxtx/client.py +118 -0
- pxtx-0.1.0/src/pxtx/config.py +48 -0
- pxtx-0.1.0/src/pxtx/display.py +90 -0
- pxtx-0.1.0/src/pxtx.egg-info/PKG-INFO +76 -0
- pxtx-0.1.0/src/pxtx.egg-info/SOURCES.txt +18 -0
- pxtx-0.1.0/src/pxtx.egg-info/dependency_links.txt +1 -0
- pxtx-0.1.0/src/pxtx.egg-info/entry_points.txt +2 -0
- pxtx-0.1.0/src/pxtx.egg-info/requires.txt +10 -0
- pxtx-0.1.0/src/pxtx.egg-info/top_level.txt +1 -0
- pxtx-0.1.0/tests/test_cli.py +757 -0
- pxtx-0.1.0/tests/test_client.py +278 -0
- pxtx-0.1.0/tests/test_config.py +99 -0
- pxtx-0.1.0/tests/test_display.py +172 -0
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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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
|