taskops-cli 0.4.0__py3-none-any.whl → 0.4.2__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.
- taskops/_version.py +1 -1
- taskops/_wire.py +48 -0
- taskops/cli/commands.py +45 -42
- taskops/cli/enrol.py +12 -0
- taskops/cli/github.py +196 -0
- taskops/cli/grants.py +59 -0
- taskops/cli/hooks.py +50 -0
- taskops/cli/main.py +42 -91
- taskops/cli/operate.py +59 -38
- taskops/cli/paging.py +75 -0
- taskops/cli/parser.py +140 -0
- taskops/cli/pull.py +162 -0
- taskops/cli/rm.py +63 -0
- taskops/cli/serving.py +23 -19
- taskops/cli/team.py +96 -0
- taskops/cli/window.py +154 -0
- taskops/cli/wording.py +2 -2
- taskops/core/chapters.py +5 -0
- taskops/core/event.py +17 -7
- taskops/core/forge.py +85 -0
- taskops/core/graph.py +4 -0
- taskops/core/holding.py +100 -0
- taskops/core/hours.py +81 -11
- taskops/core/kinds.py +45 -0
- taskops/core/replay.py +1 -1
- taskops/core/reports.py +116 -0
- taskops/core/scope.py +26 -0
- taskops/core/types.py +30 -33
- taskops/gitwork/bind.py +11 -2
- taskops/gitwork/catchup.py +59 -4
- taskops/gitwork/install.py +15 -0
- taskops/gitwork/patch.py +24 -4
- taskops/gitwork/remote.py +14 -1
- taskops/http/admin.py +36 -2
- taskops/http/gitdoor.py +96 -1
- taskops/http/handler.py +15 -1
- taskops/http/ingest.py +10 -5
- taskops/http/login.py +16 -2
- taskops/http/members.py +178 -0
- taskops/http/mounts.py +30 -2
- taskops/http/removal.py +106 -0
- taskops/http/server.py +20 -0
- taskops/http/static.py +30 -0
- taskops/http/upstream.py +60 -8
- taskops/mcp/activity.py +131 -0
- taskops/mcp/before.py +10 -0
- taskops/mcp/boardview.py +10 -1
- taskops/mcp/brief.py +8 -0
- taskops/mcp/chapter.py +154 -0
- taskops/mcp/dossier.py +14 -4
- taskops/mcp/gitmoves.py +9 -110
- taskops/mcp/integrate.py +17 -6
- taskops/mcp/orders.py +79 -0
- taskops/mcp/schema.py +38 -49
- taskops/mcp/server.py +2 -2
- taskops/mcp/tools.py +37 -5
- taskops/store/handover.py +61 -0
- taskops/ui/app.js +11 -10
- taskops/ui/style.css +1 -1
- taskops/verbs/__init__.py +10 -0
- taskops/verbs/_chapter.py +64 -0
- taskops/verbs/_facts.py +41 -5
- taskops/verbs/_rows.py +36 -4
- taskops/verbs/_stories.py +94 -0
- taskops/verbs/_waiting.py +1 -1
- taskops/verbs/_windows.py +95 -0
- taskops/verbs/activity.py +151 -0
- taskops/verbs/assign.py +13 -10
- taskops/verbs/filed.py +114 -0
- taskops/verbs/plan.py +3 -0
- taskops/verbs/project.py +53 -7
- taskops/verbs/pulse.py +27 -0
- taskops/verbs/record.py +23 -6
- taskops/verbs/report.py +72 -26
- taskops/verbs/update.py +9 -39
- taskops_cli-0.4.2.dist-info/METADATA +458 -0
- taskops_cli-0.4.2.dist-info/RECORD +143 -0
- {taskops_cli-0.4.0.dist-info → taskops_cli-0.4.2.dist-info}/WHEEL +1 -1
- taskops_cli-0.4.0.dist-info/METADATA +0 -239
- taskops_cli-0.4.0.dist-info/RECORD +0 -119
- {taskops_cli-0.4.0.dist-info → taskops_cli-0.4.2.dist-info}/entry_points.txt +0 -0
taskops/_version.py
CHANGED
taskops/_wire.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
"""One POST, one envelope, one place that turns an HTTP failure into OUR error.
|
|
2
|
+
And, since the OWNER's `board forge` learned to ask GitHub, one GET that
|
|
3
|
+
deliberately does NOT wear that envelope — `text()` at the bottom says why.
|
|
2
4
|
|
|
3
5
|
{"ok": true, "seq": 41, "data": {...}} -> the data, with `seq` in it
|
|
4
6
|
{"ok": false, "error": {"code", "message"}} -> raised, in the SERVER's words
|
|
@@ -53,6 +55,52 @@ def post(
|
|
|
53
55
|
return _unwrap(envelope, url)
|
|
54
56
|
|
|
55
57
|
|
|
58
|
+
def text(url: str, headers: dict[str, str], timeout: float) -> tuple[int, str]:
|
|
59
|
+
"""GET a foreign body UNDECODED — the status, and whatever bytes came back.
|
|
60
|
+
|
|
61
|
+
`post` unwraps `{"ok", "data"}` because both of its callers speak to a
|
|
62
|
+
taskops door. GitHub does not: it answers its own shape and says what it
|
|
63
|
+
MEANS in the status — 401 is a bad token, 404 is "no repo you can see" — so
|
|
64
|
+
flattening those into one error would throw away the only thing the caller
|
|
65
|
+
can turn into a sentence somebody can act on.
|
|
66
|
+
|
|
67
|
+
Nor is the body decoded here. It briefly was, into an OBJECT, and its two
|
|
68
|
+
callers cannot use that: `https://github.com/<login>.keys` answers
|
|
69
|
+
`text/plain`, one ssh key line each, and `/repos/…/collaborators` answers a
|
|
70
|
+
JSON ARRAY, which `as_object` flattens to `{}` — a page of people read as
|
|
71
|
+
nobody, silently. So the decoding belongs to whoever knows what it asked
|
|
72
|
+
for, and what is shared here is the part that must never differ: the status
|
|
73
|
+
is preserved, and a failure with NO status at all — refused connection, DNS
|
|
74
|
+
miss, timeout — is `Unreachable` rather than an empty answer somebody could
|
|
75
|
+
read as "this person has no keys" and then quietly leave them out of a
|
|
76
|
+
team.
|
|
77
|
+
"""
|
|
78
|
+
request = Request(url, headers=headers, method="GET") # noqa: S310 — https, from our config
|
|
79
|
+
try:
|
|
80
|
+
with urlopen(request, timeout=timeout) as answer: # noqa: S310
|
|
81
|
+
return int(answer.status), _read(answer.read())
|
|
82
|
+
except HTTPError as err:
|
|
83
|
+
return int(err.code), _read(_body_of(err))
|
|
84
|
+
except (URLError, TimeoutError, ValueError) as err:
|
|
85
|
+
raise Unreachable(
|
|
86
|
+
f"{url} did not answer ({err}). Nothing was granted and nothing was written — "
|
|
87
|
+
"a host that cannot ask does not guess."
|
|
88
|
+
) from err
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _body_of(err: HTTPError) -> bytes:
|
|
92
|
+
try:
|
|
93
|
+
return err.read()
|
|
94
|
+
except OSError: # pragma: no cover — a body that dies mid-read
|
|
95
|
+
return b""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read(raw: bytes) -> str:
|
|
99
|
+
"""Undecodable bytes are not an exception here: the STATUS already carried
|
|
100
|
+
the meaning, and a mangled character must not become `Unreachable`."""
|
|
101
|
+
return raw.decode("utf-8", "replace")
|
|
102
|
+
|
|
103
|
+
|
|
56
104
|
def _unwrap(body: dict[str, Any], url: str) -> dict[str, Any]:
|
|
57
105
|
"""v1 let three verbs answer with a bare array, which the client decoder turned
|
|
58
106
|
into `{}` with no error anywhere. Here a missing envelope is loud."""
|
taskops/cli/commands.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""The two commands that CONNECT a repo to a board
|
|
1
|
+
"""The two commands that CONNECT a repo to a board: `init` and `join`.
|
|
2
2
|
|
|
3
|
-
`serve`, `invite` and `ui` — the ones that run a server — live in `serving.py
|
|
4
|
-
`
|
|
3
|
+
`serve`, `invite` and `ui` — the ones that run a server — live in `serving.py`,
|
|
4
|
+
and what the git hooks call is `hooks.py`. Both were split off this file rather
|
|
5
|
+
than growing it: these two WRITE a checkout's configuration once, and neither
|
|
6
|
+
runs a server nor fires on every commit. `main.py` only parses and dispatches.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
@@ -13,10 +15,10 @@ from pathlib import Path
|
|
|
13
15
|
from . import enrol, watch
|
|
14
16
|
from .. import session, identity
|
|
15
17
|
from .._json import query
|
|
16
|
-
from ..board import DIR, find_root, open_board
|
|
18
|
+
from ..board import DIR, find_root, open_board, read_config
|
|
17
19
|
from ..store import log
|
|
18
20
|
from .._errors import TaskopsError
|
|
19
|
-
from ..gitwork import
|
|
21
|
+
from ..gitwork import remote, install, claudefiles
|
|
20
22
|
|
|
21
23
|
# ── the commands ────────────────────────────────────────────────────────────
|
|
22
24
|
|
|
@@ -24,7 +26,7 @@ from ..gitwork import run, bind, remote, install, trailer, claudefiles
|
|
|
24
26
|
def init(here: Path) -> int:
|
|
25
27
|
root = find_root(here)
|
|
26
28
|
(root / DIR / "board").mkdir(parents=True, exist_ok=True)
|
|
27
|
-
(root
|
|
29
|
+
install.write_local(root)
|
|
28
30
|
_wire(root, actor())
|
|
29
31
|
print(f"local board in {root / DIR / 'board'} — the MCP tools are the way in")
|
|
30
32
|
return 0
|
|
@@ -41,14 +43,21 @@ ORPHAN = (
|
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def join(
|
|
44
|
-
here: Path,
|
|
46
|
+
here: Path,
|
|
47
|
+
target: str,
|
|
48
|
+
given: str,
|
|
49
|
+
key: str = "",
|
|
50
|
+
discard: bool = False,
|
|
51
|
+
invite: str = "",
|
|
45
52
|
) -> int:
|
|
46
|
-
"""Connect this repo to a board. Bare like every other verb
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
taskops join
|
|
51
|
-
taskops join my-project --invite <id> first time: enrols the key
|
|
53
|
+
"""Connect this repo to a board. Bare like every other verb: the address is
|
|
54
|
+
CARRIED by the clone, the key is discovered, and the recorded remote is the
|
|
55
|
+
fallback for a checkout that carries nothing:
|
|
56
|
+
|
|
57
|
+
taskops join a clone: board.json travels, done
|
|
58
|
+
taskops join my-project --invite <id> first time by invite: enrols the key
|
|
59
|
+
taskops remote add https://host:8787 no carried address? record the host…
|
|
60
|
+
taskops join my-project …and name the board
|
|
52
61
|
taskops join my-project no key anywhere + public board: read-only
|
|
53
62
|
|
|
54
63
|
The name defaults exactly as `board create`'s does (recorded name, else the
|
|
@@ -64,21 +73,38 @@ def join(
|
|
|
64
73
|
archives (never deletes) before proceeding.
|
|
65
74
|
|
|
66
75
|
With an invite, the invite and the PUBKEY travel in the same call: the server
|
|
67
|
-
burns the invite and enrols the key in one act.
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
burns the invite and enrols the key in one act. The key then signs in ON THE
|
|
77
|
+
SPOT and what lands in remote.json is a SESSION with an expiry — never a
|
|
78
|
+
standing token to copy around. Keys exist so tokens do not travel.
|
|
79
|
+
|
|
80
|
+
**There is no GitHub branch here any more, and there must not be one again.**
|
|
81
|
+
The flag that was here posted the joiner's own GitHub token to the host to be
|
|
82
|
+
verified, which made every dev's credential travel for a fact the OWNER
|
|
83
|
+
already holds. `taskops board forge` enrols the team from the owner's laptop
|
|
84
|
+
instead, so a dev whose key that sync published arrives at the `bare and
|
|
85
|
+
found` branch below: the plain `taskops join`, nothing to prove, nothing to
|
|
86
|
+
type, and no token of theirs anywhere.
|
|
70
87
|
"""
|
|
71
88
|
from . import remote as remote_cli
|
|
72
89
|
|
|
73
90
|
root = find_root(here)
|
|
74
91
|
bare = "://" not in target
|
|
75
92
|
if bare:
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
# v1's whole ambition, restored (its join.py said it in one line: "a
|
|
94
|
+
# clone carries `.taskops/board.json`, so the second developer types
|
|
95
|
+
# two words"). The committed address is read FIRST, so a fresh clone
|
|
96
|
+
# joins with no URL and no `remote add` — the recorded remote is for
|
|
97
|
+
# the checkout that has no carried address, or a DIFFERENT board.
|
|
98
|
+
carried = str(read_config(root).get("url", ""))
|
|
99
|
+
if carried and target in ("", carried.rsplit("/", 1)[-1]):
|
|
100
|
+
target = carried
|
|
101
|
+
else:
|
|
102
|
+
host, name = remote_cli.named(target)
|
|
103
|
+
target = f"{host}/{name}"
|
|
78
104
|
base = target.partition("?")[0]
|
|
79
|
-
_keep_or_archive(root, base, discard)
|
|
80
105
|
params = query(target)
|
|
81
106
|
invite = invite or params.get("invite", "")
|
|
107
|
+
_keep_or_archive(root, base, discard)
|
|
82
108
|
who = given or actor()
|
|
83
109
|
name = who.partition(":")[2] or "me"
|
|
84
110
|
found = Path(key).expanduser() if key else identity.discover_key()
|
|
@@ -123,29 +149,6 @@ def _keep_or_archive(root: Path, url: str, discard: bool) -> None:
|
|
|
123
149
|
print(f" the local board ({len(events)} events) is archived at {archive(local)}")
|
|
124
150
|
|
|
125
151
|
|
|
126
|
-
def hook(here: Path, which: str, rest: list[str]) -> int:
|
|
127
|
-
"""The two GIT hooks; `hook claude` is routed in `main` and never prints.
|
|
128
|
-
|
|
129
|
-
Neither of these may block a commit. Failures print and return 0.
|
|
130
|
-
"""
|
|
131
|
-
root = find_root(here)
|
|
132
|
-
if which == "trailer":
|
|
133
|
-
if rest:
|
|
134
|
-
trailer.stamp_file(Path(rest[0]), run.branch_at(here))
|
|
135
|
-
return 0
|
|
136
|
-
facts = bind.commit_facts(here)
|
|
137
|
-
if facts is None:
|
|
138
|
-
return 0
|
|
139
|
-
try:
|
|
140
|
-
board = open_board(root, actor())
|
|
141
|
-
bind.record(board, root, facts)
|
|
142
|
-
bind.drain(board, root)
|
|
143
|
-
except TaskopsError as err:
|
|
144
|
-
print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
|
|
145
|
-
bind.push_card(here, str(facts["branch"]))
|
|
146
|
-
return 0
|
|
147
|
-
|
|
148
|
-
|
|
149
152
|
# ── plumbing ────────────────────────────────────────────────────────────────
|
|
150
153
|
|
|
151
154
|
|
taskops/cli/enrol.py
CHANGED
|
@@ -4,6 +4,18 @@ Split out of `commands.py` at its own seam — `join` and `board push` both need
|
|
|
4
4
|
exactly this and only this: a repo with a local board has never joined
|
|
5
5
|
anything, so its first push may also be its first introduction to the host,
|
|
6
6
|
and there must not be two redemptions to keep in step.
|
|
7
|
+
|
|
8
|
+
**One introduction lives on this side, and it is the invite.** There was a
|
|
9
|
+
second for a day — it put the joiner's own GitHub token in a request body for
|
|
10
|
+
the host to verify — and it is deleted, not disabled: the owner's `taskops board
|
|
11
|
+
forge` now enrols the whole team from their own laptop (`cli/team.py`), so by the
|
|
12
|
+
time a dev types `taskops join` their key is already in `allowed_signers` and
|
|
13
|
+
there is nothing left to prove at the door. A dev's GitHub token never travels
|
|
14
|
+
anywhere now, which is a stronger rule than never storing one.
|
|
15
|
+
|
|
16
|
+
So this module knows GitHub not at all: `redeem` burns an invite somebody
|
|
17
|
+
minted, `pubkey` reads the public half of a key, and neither leaves a standing
|
|
18
|
+
credential behind.
|
|
7
19
|
"""
|
|
8
20
|
|
|
9
21
|
from __future__ import annotations
|
taskops/cli/github.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The questions the OWNER asks GitHub, from their own laptop, with their own token.
|
|
2
|
+
|
|
3
|
+
collaborators("bernatch22/taskops", "push", token) -> ["ana", "leo", …]
|
|
4
|
+
keys_of("ana") -> ["ssh-ed25519 AAAA…", …]
|
|
5
|
+
|
|
6
|
+
Two calls, and only the FIRST one is authenticated — `<login>.keys` is a public
|
|
7
|
+
endpoint, which is the whole reason this chapter exists: somebody with push on a
|
|
8
|
+
repo has already published the key they push with, so asking them for a second
|
|
9
|
+
one was always redundant.
|
|
10
|
+
|
|
11
|
+
**The token's whole life is inside `collaborators`.** Read by `token()`, put in
|
|
12
|
+
one `Authorization` header per page, dropped with the frame: not returned, not
|
|
13
|
+
printed, not written, and never sent to the taskops host — `http/members.py`
|
|
14
|
+
takes principals and ssh key lines and does not know what GitHub is. That is how
|
|
15
|
+
`taskops board forge` syncs a team without the host ever holding somebody's
|
|
16
|
+
credential.
|
|
17
|
+
|
|
18
|
+
**This is the ONLY place in taskops that talks to GitHub.** A board door used to
|
|
19
|
+
ask the neighbouring question — does the token this stranger just POSTed have
|
|
20
|
+
`need` on the repo — and it is deleted: it made every dev's own credential travel
|
|
21
|
+
to the host for a fact the owner already holds. Here the token never leaves the
|
|
22
|
+
machine that owns it, and the host is told nothing but principals and ssh key
|
|
23
|
+
lines. A second door asking GitHub anything is the thing not to re-introduce.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import json
|
|
30
|
+
import getpass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .. import _wire
|
|
34
|
+
from .._json import as_rows, as_object
|
|
35
|
+
from .._errors import TaskopsError
|
|
36
|
+
|
|
37
|
+
API = "https://api.github.com"
|
|
38
|
+
KEYS = "https://github.com"
|
|
39
|
+
"""The two roots, as module constants so a test can point them at a stub over a
|
|
40
|
+
real socket — mocking `_wire` instead would test neither urllib, nor the header
|
|
41
|
+
the token travels in, nor the pagination, which is where a team of 31 silently
|
|
42
|
+
became a team of 30."""
|
|
43
|
+
|
|
44
|
+
TIMEOUT = 20.0
|
|
45
|
+
PAGE = 100
|
|
46
|
+
"""GitHub's own maximum. Asking for fewer would only make the loop below run
|
|
47
|
+
more times against the same rate budget."""
|
|
48
|
+
|
|
49
|
+
NO_TOKEN = (
|
|
50
|
+
"no GitHub token, and nothing was asked. Any ONE of these gives me one:\n"
|
|
51
|
+
" gh auth login the CLI's token is read automatically\n"
|
|
52
|
+
" export GITHUB_TOKEN=… an environment variable\n"
|
|
53
|
+
" (or paste it at the hidden prompt)\n"
|
|
54
|
+
"No flag takes a token, deliberately: a token in a flag value lands in your shell "
|
|
55
|
+
"history forever, and this one is only ever used for a single command."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def token() -> str:
|
|
60
|
+
"""WHERE the token comes from, in order — and never from a flag value.
|
|
61
|
+
|
|
62
|
+
`gh auth token` first, because the CLI is already installed and already
|
|
63
|
+
authenticated on the machine of anybody who ADMINS a repo, so the common
|
|
64
|
+
case asks the human nothing. Then `$GITHUB_TOKEN`, which is how CI and a
|
|
65
|
+
shell that never installed `gh` say it. Then a HIDDEN prompt.
|
|
66
|
+
|
|
67
|
+
**A flag is not one of the sources and must never become one.** A secret
|
|
68
|
+
passed as an argument is written to `~/.zsh_history` by the shell before the
|
|
69
|
+
process starts, survives every rotation of the token, and is visible in `ps`
|
|
70
|
+
to every user on the box while the command runs. The prompt is the fallback
|
|
71
|
+
precisely because it is the one input that leaves no trace.
|
|
72
|
+
"""
|
|
73
|
+
from ..gitwork import run
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
asked = run.tool("gh", "auth", "token", timeout=20.0)
|
|
77
|
+
except TaskopsError:
|
|
78
|
+
asked = None # no `gh` on PATH, or it hung — the next source answers
|
|
79
|
+
if asked is not None and asked.ok and asked.out.strip():
|
|
80
|
+
return asked.out.strip()
|
|
81
|
+
if os.environ.get("GITHUB_TOKEN", "").strip():
|
|
82
|
+
return os.environ["GITHUB_TOKEN"].strip()
|
|
83
|
+
typed = getpass.getpass("GitHub token (input hidden, used once, stored nowhere): ").strip()
|
|
84
|
+
if not typed:
|
|
85
|
+
raise TaskopsError(NO_TOKEN)
|
|
86
|
+
return typed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def collaborators(repo: str, need: str, secret: str) -> list[str]:
|
|
90
|
+
"""Every human with `need` on `repo`, ALL of them — pagination is not optional.
|
|
91
|
+
|
|
92
|
+
A first page is 30 by default and 100 at most, and a team that outgrows one
|
|
93
|
+
page is exactly the team this command exists for: stopping at page one would
|
|
94
|
+
enrol some of them and report the rest as DRIFT, which reads as a revocation
|
|
95
|
+
list. The loop ends on a short page rather than on `Link: rel="next"`, which
|
|
96
|
+
would be a second condition saying the same thing.
|
|
97
|
+
|
|
98
|
+
The `permission=` filter is GitHub's own, and each row's `permissions` object
|
|
99
|
+
is checked against it because `http/github.py`'s door grants on exactly that
|
|
100
|
+
field — a filter and a door that disagree let somebody in through one and
|
|
101
|
+
never enrol them through the other. `type` drops bots: `github-actions[bot]`
|
|
102
|
+
is not a principal name and has no key to publish.
|
|
103
|
+
"""
|
|
104
|
+
found: list[str] = []
|
|
105
|
+
page = 1
|
|
106
|
+
while True:
|
|
107
|
+
rows = _page(f"{API}/repos/{repo}/collaborators", repo, need, secret, page)
|
|
108
|
+
found.extend(
|
|
109
|
+
str(row.get("login", "")).strip()
|
|
110
|
+
for row in rows
|
|
111
|
+
if str(row.get("login", "")).strip() and _allowed(row, need)
|
|
112
|
+
)
|
|
113
|
+
if len(rows) < PAGE:
|
|
114
|
+
return found
|
|
115
|
+
page += 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _page(url: str, repo: str, need: str, secret: str, page: int) -> list[dict[str, Any]]:
|
|
119
|
+
where = f"{url}?permission={need}&per_page={PAGE}&page={page}"
|
|
120
|
+
status, raw = _wire.text(where, headers(secret), TIMEOUT)
|
|
121
|
+
if status != 200:
|
|
122
|
+
raise TaskopsError(_why(status, repo, need, raw))
|
|
123
|
+
try:
|
|
124
|
+
return as_rows(json.loads(raw or "[]"))
|
|
125
|
+
except ValueError as err: # pragma: no cover — a 200 that is not JSON
|
|
126
|
+
raise TaskopsError(f"GitHub answered {where} with something that is not JSON: {err}") from err
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _allowed(row: dict[str, Any], need: str) -> bool:
|
|
130
|
+
"""A row GitHub filtered in, confirmed against the field the door reads. An
|
|
131
|
+
entry with no `permissions` object at all is trusted to the filter — the
|
|
132
|
+
alternative is dropping a whole team the day that field is renamed."""
|
|
133
|
+
if str(row.get("type", "User")) != "User":
|
|
134
|
+
return False
|
|
135
|
+
seen = as_object(row.get("permissions"))
|
|
136
|
+
return not seen or seen.get(need) is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def keys_of(login: str) -> list[str]:
|
|
140
|
+
"""`https://github.com/<login>.keys` — PUBLIC, no token, no rate budget spent.
|
|
141
|
+
|
|
142
|
+
An empty list is a real answer, not a failure: an account that pushes over
|
|
143
|
+
HTTPS has published no ssh key, and the caller NAMES those people with an
|
|
144
|
+
invite rather than leaving them out. A 404 — renamed or deleted between the
|
|
145
|
+
two calls — collapses to the same empty list for the same reason.
|
|
146
|
+
"""
|
|
147
|
+
status, raw = _wire.text(f"{KEYS}/{login}.keys", {"User-Agent": "taskops"}, TIMEOUT)
|
|
148
|
+
if status != 200:
|
|
149
|
+
return []
|
|
150
|
+
seen: list[str] = []
|
|
151
|
+
for line in raw.splitlines():
|
|
152
|
+
one = line.strip()
|
|
153
|
+
if one and one not in seen:
|
|
154
|
+
seen.append(one)
|
|
155
|
+
return seen
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def headers(secret: str) -> dict[str, str]:
|
|
159
|
+
"""The token's whole life. `X-GitHub-Api-Version` is pinned because the
|
|
160
|
+
shape of `permissions` is what this module reads, and an unpinned API is
|
|
161
|
+
free to reshape it — a silently missing key would read as "no access"."""
|
|
162
|
+
return {
|
|
163
|
+
"Authorization": f"Bearer {secret}",
|
|
164
|
+
"Accept": "application/vnd.github+json",
|
|
165
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
166
|
+
"User-Agent": "taskops",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _why(status: int, repo: str, need: str, raw: str) -> str:
|
|
171
|
+
"""The refusal, and never the token in it — `raw` is GitHub's own body, which
|
|
172
|
+
holds its message and cannot hold the header it was refused for."""
|
|
173
|
+
try:
|
|
174
|
+
said = str(as_object(json.loads(raw or "{}")).get("message", ""))
|
|
175
|
+
except ValueError:
|
|
176
|
+
said = ""
|
|
177
|
+
tail = f" GitHub said: {said}" if said else ""
|
|
178
|
+
if status == 401:
|
|
179
|
+
return (
|
|
180
|
+
f"GitHub rejected that token (401) — expired, revoked or mistyped. Listing the "
|
|
181
|
+
f"collaborators of {repo} needs a token of somebody who can read them.{tail}"
|
|
182
|
+
)
|
|
183
|
+
if status == 403:
|
|
184
|
+
return (
|
|
185
|
+
f"GitHub refused that token (403) listing the collaborators of {repo} — an "
|
|
186
|
+
f"organisation that enforces SSO has to authorise it, and a rate limit answers "
|
|
187
|
+
f"the same way. Nobody was enrolled; the board's declaration stands, so the fix "
|
|
188
|
+
f"is to run the same command again.{tail}"
|
|
189
|
+
)
|
|
190
|
+
if status == 404:
|
|
191
|
+
return (
|
|
192
|
+
f"GitHub has no repository {repo!r} that this token can see — which is also what "
|
|
193
|
+
f"a private repo answers a token without access. Only somebody who can read its "
|
|
194
|
+
f"collaborators can sync them.{tail}"
|
|
195
|
+
)
|
|
196
|
+
return f"GitHub answered HTTP {status} listing the {need} collaborators of {repo} — nothing was enrolled.{tail}"
|
taskops/cli/grants.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`taskops invite` · `taskops revoke` — handing a credential out, and taking it back.
|
|
2
|
+
|
|
3
|
+
taskops invite <who> --board <name> owner only — prints the join line
|
|
4
|
+
taskops revoke --key <SHA256:…> | --invite <id>
|
|
5
|
+
|
|
6
|
+
The laptop half of `http/grants.py`, and the split is that seam and not a line
|
|
7
|
+
count: `operate.py` is about a BOARD (create it, list them, say who may read it,
|
|
8
|
+
name the forge that opens it) and this is about a CREDENTIAL — the two never
|
|
9
|
+
share an argument. They do share the transport, which stays in `operate.py`
|
|
10
|
+
where `push.py` already reaches for it; a third module holding three lines of
|
|
11
|
+
`_wire.post` would be one more file to find and nothing to decide in it.
|
|
12
|
+
|
|
13
|
+
**THE BREAK-GLASS PATH SURVIVES** on both verbs: `--root <dir>` runs the same
|
|
14
|
+
act against the files directly, on the machine that holds them, and it is what
|
|
15
|
+
you use when the server is down or the owner's key is lost. Not deprecated,
|
|
16
|
+
never to be removed — a system whose only door is its own API cannot be
|
|
17
|
+
repaired when that API is what broke. Those two acts live in `cli/admin.py`,
|
|
18
|
+
beside `server init`: that module runs ON the box, this one is the laptop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import admin
|
|
27
|
+
from .remote import address
|
|
28
|
+
from .operate import call, signed_in
|
|
29
|
+
from .._errors import TaskopsError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def invite(args: argparse.Namespace) -> int:
|
|
33
|
+
"""A one-time join line, minted by the server that will honour it."""
|
|
34
|
+
who, name = str(args.who), str(args.board)
|
|
35
|
+
if not who:
|
|
36
|
+
raise TaskopsError("taskops invite <who> --board <name>")
|
|
37
|
+
if args.root: # break-glass: the files, on the box
|
|
38
|
+
return admin.on_box_invite(Path(str(args.root)).expanduser(), who, name)
|
|
39
|
+
if not name:
|
|
40
|
+
raise TaskopsError("which board? taskops invite <who> --board <name>")
|
|
41
|
+
host, _ = address(str(args.host))
|
|
42
|
+
made = call(host, "invite.mint", {"who": who, "board": name}, signed_in(host, args))
|
|
43
|
+
print(f"one-time invite for {who} (id {made['id']}, 7 days):")
|
|
44
|
+
print(f" taskops join \"{host}/{made['board']}?invite={made['token']}\" --key ~/.ssh/id_ed25519")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def revoke(args: argparse.Namespace) -> int:
|
|
49
|
+
"""A key stops signing anybody in; an invite stops being redeemable."""
|
|
50
|
+
key, ident = str(args.key), str(args.invite)
|
|
51
|
+
if bool(key) == bool(ident):
|
|
52
|
+
raise TaskopsError("taskops revoke --key <SHA256:…> | --invite <id> — exactly one")
|
|
53
|
+
if args.root: # break-glass: the files, on the box
|
|
54
|
+
return admin.on_box_revoke(Path(str(args.root)).expanduser(), key, ident)
|
|
55
|
+
host, _ = address(str(args.host))
|
|
56
|
+
verb = "key.revoke" if key else "invite.revoke"
|
|
57
|
+
gone = call(host, verb, {"key": key} if key else {"invite": ident}, signed_in(host, args, "sign_key"))
|
|
58
|
+
print(f"revoked {key or ident} ({gone.get('principal') or gone.get('subject')}) on {host}")
|
|
59
|
+
return 0
|
taskops/cli/hooks.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What the two GIT hooks call — the other half of what `join` installed.
|
|
2
|
+
|
|
3
|
+
Split off `commands.py` at the seam that file's own docstring named ("the two
|
|
4
|
+
commands that CONNECT a repo to a board, **and the git hooks**), the same way
|
|
5
|
+
`serving.py` and `watch.py` were: `init` and `join` WRITE a checkout's
|
|
6
|
+
configuration once, and these two run on every single commit somebody makes in
|
|
7
|
+
it. Nothing here is reachable from a human's fingers — `gitwork/install.py`
|
|
8
|
+
writes the shell scripts that call it, and `main.py` routes them.
|
|
9
|
+
|
|
10
|
+
**Neither of these may ever block a commit.** A failure prints and returns 0.
|
|
11
|
+
The hook is a courtesy that binds a commit to its card; a board that is down,
|
|
12
|
+
a network that is out or a card that was closed a minute ago must cost somebody
|
|
13
|
+
their commit exactly never. That is why the `TaskopsError` below is caught and
|
|
14
|
+
written to stderr instead of raised — visible, and never in the way.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..board import find_root, open_board
|
|
23
|
+
from .._errors import TaskopsError
|
|
24
|
+
from ..gitwork import run, bind, trailer
|
|
25
|
+
from .commands import actor
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hook(here: Path, which: str, rest: list[str]) -> int:
|
|
29
|
+
"""`hook trailer` stamps the message, `hook commit` binds the commit.
|
|
30
|
+
|
|
31
|
+
`hook claude` is a third one and it is routed in `main` instead, because it
|
|
32
|
+
prints NOTHING ever — including the `taskops: …` line every other failure
|
|
33
|
+
here is allowed to write.
|
|
34
|
+
"""
|
|
35
|
+
root = find_root(here)
|
|
36
|
+
if which == "trailer":
|
|
37
|
+
if rest:
|
|
38
|
+
trailer.stamp_file(Path(rest[0]), run.branch_at(here))
|
|
39
|
+
return 0
|
|
40
|
+
facts = bind.commit_facts(here)
|
|
41
|
+
if facts is None:
|
|
42
|
+
return 0
|
|
43
|
+
try:
|
|
44
|
+
board = open_board(root, actor())
|
|
45
|
+
bind.record(board, root, facts)
|
|
46
|
+
bind.drain(board, root)
|
|
47
|
+
except TaskopsError as err:
|
|
48
|
+
print(f"taskops: {err}", file=sys.stderr) # visible, never swallowed
|
|
49
|
+
bind.push_card(here, str(facts["branch"]))
|
|
50
|
+
return 0
|