Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81119a3bd9 | |||
| f97ac952f7 | |||
| 493a787940 | |||
| 7ab967bfaa | |||
| edb2f5a627 | |||
| 62027db76c | |||
| 9479babfe9 | |||
| e330a11e8f |
@@ -8,30 +8,36 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
|
||||||
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
|
||||||
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
|
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment, close, evict |
|
||||||
|
| `/tea:page` skill | A discussion's artifacts as a named, ordered tree of pages — import, title, index. Entirely offline |
|
||||||
|
| `/tea:wiki` skill | Moves page trees between a local space and a Gitea wiki — fetch a subtree, publish one |
|
||||||
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
|
||||||
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
|
||||||
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
|
||||||
|
|
||||||
## The layering
|
## The layering
|
||||||
|
|
||||||
An issue is a unit of work first and a Gitea row second. Those are two layers,
|
An issue is a unit of work first and a Gitea row second. A page tree is a
|
||||||
and knowledge flows one way:
|
discussion's artifacts first and a wiki second. Each is two layers, and
|
||||||
|
knowledge flows one way:
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
skills/issue DOMAIN what an issue is: format, validation, dependency graph
|
||||||
|
skills/page DOMAIN what a page tree is: title <-> path, order, the index
|
||||||
▲ offline — no tracker, no network, stdlib only
|
▲ offline — no tracker, no network, stdlib only
|
||||||
│ imports
|
│ imports
|
||||||
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
|
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
|
||||||
|
skills/wiki BRIDGE md <-> Gitea wiki JSON; transport is sync's _gitea.py
|
||||||
▲
|
▲
|
||||||
│ calls
|
│ calls
|
||||||
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
||||||
```
|
```
|
||||||
|
|
||||||
Delete `skills/sync` and the domain layer keeps working — issues that live only
|
Delete `skills/sync` and the issue domain keeps working; delete `skills/wiki`
|
||||||
on your machine are first-class, not drafts waiting to be uploaded. That is the
|
and page trees keep working. Work that lives only on your machine is
|
||||||
point of the split: you can plan, write, validate, and track work without a
|
first-class, not a draft waiting to be uploaded. That is the point of the
|
||||||
tracker, and publish only what you choose to.
|
split: you can plan, write, validate, and organize without a tracker, and
|
||||||
|
publish only what you choose to.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -120,11 +126,15 @@ session — the pinned login is enforced on every call it makes.
|
|||||||
agents/
|
agents/
|
||||||
tea-runner.md subagent (Haiku) that executes the scripts
|
tea-runner.md subagent (Haiku) that executes the scripts
|
||||||
hooks/
|
hooks/
|
||||||
hooks.json registers the PreToolUse hook
|
hooks.json registers the PreToolUse hooks
|
||||||
tea-guard.sh the guard (Python 3, no deps)
|
tea-guard.sh the guard (Python 3, no deps)
|
||||||
|
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
|
||||||
skills/
|
skills/
|
||||||
auth/SKILL.md /tea:auth skill
|
auth/ /tea:auth — the identity layer
|
||||||
issue/ /tea:issue — the domain layer, offline
|
SKILL.md
|
||||||
|
scripts/pin.py where the login pin is and how it is found —
|
||||||
|
imported by _gitea.py AND by tea-guard.sh
|
||||||
|
issue/ /tea:issue — the issue domain, offline
|
||||||
SKILL.md
|
SKILL.md
|
||||||
references/format.md canonical issue format (identity, types, templates)
|
references/format.md canonical issue format (identity, types, templates)
|
||||||
scripts/ Python 3, stdlib only, no network:
|
scripts/ Python 3, stdlib only, no network:
|
||||||
@@ -135,6 +145,7 @@ skills/
|
|||||||
issue_check.py validate against the format
|
issue_check.py validate against the format
|
||||||
issue_ac.py list the body's checkboxes; tick one
|
issue_ac.py list the body's checkboxes; tick one
|
||||||
issue_tree.py draw the dependency graph
|
issue_tree.py draw the dependency graph
|
||||||
|
issue_evict.py drop closed issues the tracker also has
|
||||||
issue_index.py rebuild tmp/issues/INDEX.md
|
issue_index.py rebuild tmp/issues/INDEX.md
|
||||||
sync/ /tea:sync — the bridge to Gitea
|
sync/ /tea:sync — the bridge to Gitea
|
||||||
SKILL.md
|
SKILL.md
|
||||||
@@ -145,11 +156,33 @@ skills/
|
|||||||
push.py tmp/issues/ -> Gitea, then drops the local file
|
push.py tmp/issues/ -> Gitea, then drops the local file
|
||||||
remote.py discovery listing to stdout
|
remote.py discovery listing to stdout
|
||||||
comment.py post or edit a comment
|
comment.py post or edit a comment
|
||||||
|
close.py the state field, both ways
|
||||||
|
evict.py refresh state: from Gitea, then evict
|
||||||
|
labels.py put the canonical label set into a repository
|
||||||
|
page/ /tea:page — the page-tree domain, offline
|
||||||
|
SKILL.md
|
||||||
|
references/pages.md canonical page-tree format
|
||||||
|
scripts/ Python 3, stdlib only, no network:
|
||||||
|
page.py domain module: title <-> path, ordering,
|
||||||
|
the manifest, importing, the index
|
||||||
|
page_import.py copy a directory of markdown into a space
|
||||||
|
page_index.py write the table-of-contents page
|
||||||
|
page_ls.py the tree, the titles, one state tag per page
|
||||||
|
wiki/ /tea:wiki — the bridge to a Gitea wiki
|
||||||
|
SKILL.md
|
||||||
|
scripts/
|
||||||
|
wikimap.py md <-> Gitea wiki JSON, pure, no I/O
|
||||||
|
wiki_ls.py what the wiki holds
|
||||||
|
wiki_pull.py wiki -> tmp/wiki/<space>/
|
||||||
|
wiki_push.py tmp/wiki/<space>/ -> wiki (additive)
|
||||||
use/ /tea:use — tea CLI reference (non-issue entities)
|
use/ /tea:use — tea CLI reference (non-issue entities)
|
||||||
SKILL.md
|
SKILL.md
|
||||||
references/tea/ command docs
|
references/tea/ command docs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`AGENTS.md` carries the same layout with the reasoning behind it; if the two
|
||||||
|
ever disagree, `AGENTS.md` is the one being worked from.
|
||||||
|
|
||||||
## Local issue store
|
## Local issue store
|
||||||
|
|
||||||
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
|
||||||
|
|||||||
+177
-29
@@ -27,6 +27,27 @@ Rules:
|
|||||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
||||||
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
||||||
|
|
||||||
|
"A `tea` command" means the shell would RUN `tea`, not that the string contains
|
||||||
|
the word. The guard used to ask the second question — a substring search over
|
||||||
|
the whole command line — and in a repository whose subject *is* the CLI that is
|
||||||
|
a different question with the same answer far too often: an issue title, a
|
||||||
|
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
|
||||||
|
a message telling the operator to add `--login` to `git commit`. Worse, the
|
||||||
|
advice was unfollowable: the only way past the guard was to reword the prose.
|
||||||
|
|
||||||
|
So the command is tokenized (heredoc bodies dropped, line continuations
|
||||||
|
folded, backticks and newlines treated as boundaries) and only words in
|
||||||
|
*command position* count — the first word, and the first word after `;`, `&&`,
|
||||||
|
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
|
||||||
|
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
|
||||||
|
`-m` message is one token, and one token is never a command. Compound commands
|
||||||
|
stay guarded segment by segment, substitutions included, and every `tea` in the
|
||||||
|
line is checked — not just the first.
|
||||||
|
|
||||||
|
If the line cannot be tokenized at all (unbalanced quotes), the old substring
|
||||||
|
test decides. That direction fails closed: it over-matches, and over-matching
|
||||||
|
blocks.
|
||||||
|
|
||||||
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
||||||
rewrite; exit 2 + stderr to block.
|
rewrite; exit 2 + stderr to block.
|
||||||
"""
|
"""
|
||||||
@@ -46,6 +67,131 @@ except Exception:
|
|||||||
|
|
||||||
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
||||||
|
|
||||||
|
# Operators after which the next word is a command again.
|
||||||
|
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
|
||||||
|
# Words that stand in front of a command without being one.
|
||||||
|
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
|
||||||
|
"if", "then", "else", "elif", "while", "until", "do", "!"}
|
||||||
|
|
||||||
|
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
|
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
|
||||||
|
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
||||||
|
# A login flag and its value, in the ORIGINAL text — this is what gets
|
||||||
|
# rewritten, so it works on the raw string rather than on tokens.
|
||||||
|
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
|
||||||
|
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
|
||||||
|
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
|
||||||
|
|
||||||
|
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
|
||||||
|
'(the guard substitutes the operator-pinned login). '
|
||||||
|
'Run /tea:auth if no login is pinned.')
|
||||||
|
|
||||||
|
|
||||||
|
def named_login(raw):
|
||||||
|
return ('do not name the login yourself (got `%s`). Write exactly '
|
||||||
|
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
||||||
|
'the operator pinned via /tea:auth. This prevents acting under '
|
||||||
|
'the wrong identity.' % raw)
|
||||||
|
|
||||||
|
|
||||||
|
def unquote(value):
|
||||||
|
for q in ('"', "'"):
|
||||||
|
if len(value) >= 2 and value[0] == q and value[-1] == q:
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def strip_heredocs(cmd):
|
||||||
|
"""Drop heredoc bodies. They are data the shell feeds to a command, not
|
||||||
|
commands — and a commit message quoting a raw `tea api` call is exactly the
|
||||||
|
thing that used to be unwritable."""
|
||||||
|
lines, kept, i = cmd.split("\n"), [], 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
kept.append(line)
|
||||||
|
i += 1
|
||||||
|
for m in HEREDOC.finditer(line):
|
||||||
|
delim, dash = m.group(2), m.group(0).startswith("<<-")
|
||||||
|
while i < len(lines):
|
||||||
|
probe = lines[i].strip() if dash else lines[i].rstrip()
|
||||||
|
i += 1
|
||||||
|
if probe == delim:
|
||||||
|
break
|
||||||
|
return "\n".join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def shell_words(cmd):
|
||||||
|
"""Tokens, with operators as tokens of their own and quotes honored.
|
||||||
|
|
||||||
|
Backticks and newlines become separators before tokenizing: shlex knows
|
||||||
|
neither, and both start a command. Inside quotes that substitution is
|
||||||
|
harmless — the token still spans the quotes, and a token is never a
|
||||||
|
command."""
|
||||||
|
text = strip_heredocs(cmd)
|
||||||
|
text = re.sub(r"\\\n", " ", text)
|
||||||
|
text = text.replace("`", " ; ").replace("\n", " ; ")
|
||||||
|
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
||||||
|
lex.whitespace_split = True
|
||||||
|
return list(lex)
|
||||||
|
|
||||||
|
|
||||||
|
def tea_invocations(words):
|
||||||
|
"""The argument list of every `tea` the shell would actually run."""
|
||||||
|
found, current, expect, skip = [], None, True, False
|
||||||
|
for w in words:
|
||||||
|
if skip:
|
||||||
|
skip = False
|
||||||
|
continue
|
||||||
|
if REDIRECT.match(w):
|
||||||
|
skip = True # the target of a redirection is not a command
|
||||||
|
continue
|
||||||
|
if w in SEPARATORS:
|
||||||
|
current, expect = None, True
|
||||||
|
continue
|
||||||
|
if expect:
|
||||||
|
if ASSIGNMENT.match(w) or w in TRANSPARENT:
|
||||||
|
continue
|
||||||
|
expect = False
|
||||||
|
if w.rsplit("/", 1)[-1] == "tea":
|
||||||
|
current = []
|
||||||
|
found.append(current)
|
||||||
|
continue
|
||||||
|
if current is not None:
|
||||||
|
current.append(w)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def is_meta(args):
|
||||||
|
"""Login enumeration and `--version`/`--help`: no identity is used, and
|
||||||
|
/tea:auth needs `tea logins list` while no pin exists yet."""
|
||||||
|
if not args:
|
||||||
|
return False
|
||||||
|
if args[0] in ("--version", "-v", "--help", "-h", "help"):
|
||||||
|
return True
|
||||||
|
return args[0] in ("logins", "login") and len(args) > 1 \
|
||||||
|
and args[1] in ("list", "ls")
|
||||||
|
|
||||||
|
|
||||||
|
def login_value(args):
|
||||||
|
"""The login as written, or None if the flag is absent."""
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if a in ("--login", "-l"):
|
||||||
|
return args[i + 1] if i + 1 < len(args) else ""
|
||||||
|
if a.startswith("--login=") or a.startswith("-l="):
|
||||||
|
return a.split("=", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(cmd, login):
|
||||||
|
"""Every placeholder login in the line, replaced by the pin. Every one:
|
||||||
|
a command may run `tea` twice, and half a rewrite leaves the second call
|
||||||
|
with an unset variable and no login at all."""
|
||||||
|
def repl(m):
|
||||||
|
if unquote(m.group(3)) in PLACEHOLDERS:
|
||||||
|
return m.group(1) + m.group(2) + shlex.quote(login)
|
||||||
|
return m.group(0)
|
||||||
|
return LOGIN_FLAG.sub(repl, cmd)
|
||||||
|
|
||||||
|
|
||||||
def block(msg):
|
def block(msg):
|
||||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
||||||
@@ -81,34 +227,37 @@ def main():
|
|||||||
tool_input = payload.get("tool_input") or {}
|
tool_input = payload.get("tool_input") or {}
|
||||||
cmd = tool_input.get("command") or ""
|
cmd = tool_input.get("command") or ""
|
||||||
|
|
||||||
# Not a `tea` invocation → not our concern.
|
try:
|
||||||
if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd):
|
runs = tea_invocations(shell_words(cmd))
|
||||||
allow_passthrough()
|
except ValueError:
|
||||||
|
# Unbalanced quotes: what the shell would run is not knowable here.
|
||||||
|
# Fall back to the substring test — it over-matches, and over-matching
|
||||||
|
# blocks rather than lets an unpinned call through.
|
||||||
|
runs = None
|
||||||
|
|
||||||
# Whitelist: login enumeration + meta. No identity is used; /tea:auth
|
if runs is None:
|
||||||
# needs `tea logins list` while no pin exists yet.
|
if not LOOKS_LIKE_TEA.search(cmd):
|
||||||
if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd):
|
allow_passthrough()
|
||||||
allow_passthrough()
|
m = LOGIN_FLAG.search(cmd)
|
||||||
|
if not m:
|
||||||
# Locate --login / -l and its value (logins never contain spaces).
|
block(NO_LOGIN)
|
||||||
m = re.search(r'(--login|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
|
if unquote(m.group(3)) not in PLACEHOLDERS:
|
||||||
if not m:
|
block(named_login(m.group(3)))
|
||||||
block('every `tea` command must include --login "$GITEA_LOGIN" '
|
else:
|
||||||
'(the guard substitutes the operator-pinned login). '
|
# The word appears but nothing runs it → not our concern. This is the
|
||||||
'Run /tea:auth if no login is pinned.')
|
# branch that lets prose about the CLI be written at all.
|
||||||
|
if not runs:
|
||||||
raw_val = m.group(3)
|
allow_passthrough()
|
||||||
inner = raw_val
|
for args in runs:
|
||||||
for q in ('"', "'"):
|
if is_meta(args):
|
||||||
if len(inner) >= 2 and inner[0] == q and inner[-1] == q:
|
continue
|
||||||
inner = inner[1:-1]
|
raw = login_value(args)
|
||||||
break
|
if raw is None:
|
||||||
|
block(NO_LOGIN)
|
||||||
if inner not in PLACEHOLDERS:
|
if unquote(raw) not in PLACEHOLDERS:
|
||||||
block('do not name the login yourself (got `%s`). Write exactly '
|
block(named_login(raw))
|
||||||
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
if all(is_meta(args) for args in runs):
|
||||||
'the operator pinned via /tea:auth. This prevents acting under '
|
allow_passthrough()
|
||||||
'the wrong identity.' % raw_val)
|
|
||||||
|
|
||||||
if pin is None:
|
if pin is None:
|
||||||
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||||
@@ -123,8 +272,7 @@ def main():
|
|||||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||||
'the file at call time, so it takes effect with no restart.')
|
'the file at call time, so it takes effect with no restart.')
|
||||||
|
|
||||||
new_cmd = cmd[:m.start(3)] + shlex.quote(login) + cmd[m.end(3):]
|
rewrite(tool_input, substitute(cmd, login),
|
||||||
rewrite(tool_input, new_cmd,
|
|
||||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42
|
|||||||
| `assignees` | domain | logins; may be empty |
|
| `assignees` | domain | logins; may be empty |
|
||||||
| `milestone` | domain | title, or `none` |
|
| `milestone` | domain | title, or `none` |
|
||||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
||||||
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
|
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them. Set it with `issue_new.py --wiki "<title>"` (repeatable) or by editing the line. The tracker has no field for it, so it is never sent — and a pull, which merges nothing but checkbox state, does not bring it back |
|
||||||
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||||
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
| `gitea` | sync | the handle in that tracker: `owner/repo#N` |
|
||||||
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
| `branch` | sync | the tracker's branch link (Gitea `ref`); push fills an empty one with the current git branch, and never overwrites a filled one |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ domain has. The file name is the id:
|
|||||||
assignees: [naudachu]
|
assignees: [naudachu]
|
||||||
milestone: v0.2
|
milestone: v0.2
|
||||||
depends: [migrate-schema]
|
depends: [migrate-schema]
|
||||||
|
wiki: [Simple Chains/Ideas/Chain core]
|
||||||
origin: gitea
|
origin: gitea
|
||||||
gitea: owner/repo#42
|
gitea: owner/repo#42
|
||||||
synced: 2026-08-07T18:40:00Z
|
synced: 2026-08-07T18:40:00Z
|
||||||
@@ -113,8 +114,9 @@ ISSUE_ROOT = store_root()
|
|||||||
|
|
||||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||||
# sorted, so the sync layer can add fields without touching this list.
|
# sorted, so the sync layer can add fields without touching this list.
|
||||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends", "origin"]
|
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
"wiki", "origin"]
|
||||||
|
LIST_KEYS = {"labels", "assignees", "depends", "wiki"}
|
||||||
STATES = ("open", "closed")
|
STATES = ("open", "closed")
|
||||||
|
|
||||||
# `origin` is "does this issue exist anywhere but here" — a fact about the
|
# `origin` is "does this issue exist anywhere but here" — a fact about the
|
||||||
@@ -247,8 +249,8 @@ class Issue(object):
|
|||||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||||
|
|
||||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||||
assignees=None, milestone="", depends=None, origin=LOCAL,
|
assignees=None, milestone="", depends=None, wiki=None,
|
||||||
extra=None):
|
origin=LOCAL, extra=None):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -257,6 +259,10 @@ class Issue(object):
|
|||||||
self.assignees = list(assignees or [])
|
self.assignees = list(assignees or [])
|
||||||
self.milestone = milestone or ""
|
self.milestone = milestone or ""
|
||||||
self.depends = list(depends or [])
|
self.depends = list(depends or [])
|
||||||
|
# Page TITLES this work is written up in — names for documents, which
|
||||||
|
# is why they are domain-owned. What a title resolves to is /tea:page's
|
||||||
|
# business, and this layer never asks: no path, no URL, no lookup.
|
||||||
|
self.wiki = list(wiki or [])
|
||||||
self.origin = origin or LOCAL
|
self.origin = origin or LOCAL
|
||||||
self.extra = dict(extra or {})
|
self.extra = dict(extra or {})
|
||||||
|
|
||||||
@@ -302,7 +308,7 @@ class Issue(object):
|
|||||||
state=meta.get("state") or "open",
|
state=meta.get("state") or "open",
|
||||||
labels=lst("labels"), assignees=lst("assignees"),
|
labels=lst("labels"), assignees=lst("assignees"),
|
||||||
milestone="" if ms == "none" else ms,
|
milestone="" if ms == "none" else ms,
|
||||||
depends=lst("depends"),
|
depends=lst("depends"), wiki=lst("wiki"),
|
||||||
origin=meta.get("origin") or LOCAL, extra=extra)
|
origin=meta.get("origin") or LOCAL, extra=extra)
|
||||||
|
|
||||||
def to_text(self):
|
def to_text(self):
|
||||||
@@ -314,6 +320,7 @@ class Issue(object):
|
|||||||
"assignees": self.assignees,
|
"assignees": self.assignees,
|
||||||
"milestone": self.milestone or "none",
|
"milestone": self.milestone or "none",
|
||||||
"depends": self.depends,
|
"depends": self.depends,
|
||||||
|
"wiki": self.wiki,
|
||||||
"origin": self.origin,
|
"origin": self.origin,
|
||||||
})
|
})
|
||||||
body = self.body.strip() or "(no body)"
|
body = self.body.strip() or "(no body)"
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ def main():
|
|||||||
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
||||||
ap.add_argument("--depends", action="append", default=[],
|
ap.add_argument("--depends", action="append", default=[],
|
||||||
help="id this issue depends on; repeat")
|
help="id this issue depends on; repeat")
|
||||||
|
ap.add_argument("--wiki", action="append", default=[],
|
||||||
|
help="page title this work is written up in; repeat")
|
||||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||||
help="store root (default: <repo>/tmp/issues)")
|
help="store root (default: <repo>/tmp/issues)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
@@ -180,7 +182,7 @@ def main():
|
|||||||
id=id, title=args.title,
|
id=id, title=args.title,
|
||||||
body=with_depends(TEMPLATES[args.type], args.depends),
|
body=with_depends(TEMPLATES[args.type], args.depends),
|
||||||
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
||||||
depends=args.depends)
|
depends=args.depends, wiki=args.wiki)
|
||||||
|
|
||||||
# The first issue in a fresh checkout has to create the store, but it says
|
# The first issue in a fresh checkout has to create the store, but it says
|
||||||
# so — and it says where, because the path is absolute.
|
# so — and it says where, because the path is absolute.
|
||||||
|
|||||||
@@ -355,8 +355,11 @@ A milestone must already exist in the repo — push attaches, it does not create
|
|||||||
|
|
||||||
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
`branch:` is Gitea's `ref`, the branch the work actually lives on. Push fills
|
||||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||||
and writes it back into the issue file; a value already there is never
|
and sends it up as `ref`; a value already there is never
|
||||||
overwritten, neither on create nor on `--update`. On a detached HEAD or outside
|
overwritten, neither on create nor on `--update`. Nothing is written back to
|
||||||
|
the issue file — there is no file left to write to, because a successful push
|
||||||
|
deletes it. The branch comes back on disk with the next `pull.py <n>`, from
|
||||||
|
the tracker. On a detached HEAD or outside
|
||||||
a git repo no `ref` is sent and a warning names the issues that went up without
|
a git repo no `ref` is sent and a warning names the issues that went up without
|
||||||
one. Reading the branch is the only thing these scripts ask git for — they
|
one. Reading the branch is the only thing these scripts ask git for — they
|
||||||
never check out, create, or write anything.
|
never check out, create, or write anything.
|
||||||
|
|||||||
+36
-1
@@ -58,6 +58,37 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
|
|||||||
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
||||||
Config lives in `$XDG_CONFIG_HOME/tea`.
|
Config lives in `$XDG_CONFIG_HOME/tea`.
|
||||||
|
|
||||||
|
### `--repo` takes a slug — except where a checkout is required
|
||||||
|
|
||||||
|
A few commands touch local git, not just the API, and for those `--repo`
|
||||||
|
**must be a path to a checkout**; a slug is rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: local repository required: execute from a repo dir, or specify a path with --repo
|
||||||
|
```
|
||||||
|
|
||||||
|
The message reads like the flag is missing even when it was passed. Confirmed
|
||||||
|
for `pulls create`, `pulls checkout` and `pulls clean` (tea 0.14.x). Everything
|
||||||
|
that is only an API call — `pulls list`, `milestones`, `releases`, `times`,
|
||||||
|
`labels`, `issues` — takes the slug from any directory.
|
||||||
|
|
||||||
|
Three working forms for `pulls create`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. cwd inside the checkout, no --repo at all
|
||||||
|
tea pulls create --login "$GITEA_LOGIN" --head feat/x --base main \
|
||||||
|
--title "…" --description "…"
|
||||||
|
|
||||||
|
# 2. from anywhere, --repo as a PATH (this is also the git-worktree answer:
|
||||||
|
# point it at the main checkout)
|
||||||
|
tea pulls create --login "$GITEA_LOGIN" --repo /path/to/checkout \
|
||||||
|
--head feat/x --base main --title "…" --description "…"
|
||||||
|
|
||||||
|
# 3. no checkout in reach — POST it, where owner/repo is a slug again
|
||||||
|
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/pull/x.json \
|
||||||
|
repos/{owner}/{repo}/pulls
|
||||||
|
```
|
||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
||||||
@@ -125,7 +156,11 @@ are still fine via entity commands. Always the placeholder, never a login name.
|
|||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
- Pass `-o json` for structured output when parsing programmatically.
|
- Pass `-o json` for structured output when parsing programmatically — on
|
||||||
|
**entity commands only**. On `tea api`, `-o` is a *file name*: `-o json`
|
||||||
|
writes the response body to a file called `json` and leaves stdout empty.
|
||||||
|
The response is already JSON, so there is nothing to format; use `-` for
|
||||||
|
stdout, or leave the flag off.
|
||||||
- Use `--fields, -f` to narrow columns.
|
- Use `--fields, -f` to narrow columns.
|
||||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||||
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,aut
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `list, ls` (`--state`)
|
- `list, ls` (`--state`)
|
||||||
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing.
|
- `checkout, co <idx>` — check out PR locally. `--branch/-b` creates a local branch if missing. Needs a checkout, same as `create`: `--repo` is a path here, not a slug.
|
||||||
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash.
|
- `clean <idx>` — delete local and remote feature branches for a closed PR. `--ignore-sha` matches branch by name instead of commit hash. Needs a checkout, same as `create`.
|
||||||
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
- `create, c` — create a PR. `--head <user:branch>`, `--base/-b`, `--allow-maintainer-edits/--edits`, `--agit`, `--topic`, plus all issue-style fields (`--title`, `--description`, `--assignees`, `--labels`, `--milestone`, `--deadline`, `--referenced-version`).
|
||||||
|
**Needs a local checkout.** `--repo owner/repo` is *not* accepted here — the
|
||||||
|
slug fails with `local repository required: execute from a repo dir, or
|
||||||
|
specify a path with --repo`, whose advice reads like the flag was missing.
|
||||||
|
Run it with cwd inside the checkout and no `--repo`, or pass `--repo
|
||||||
|
/path/to/checkout`. From a git worktree, point `--repo` at the main
|
||||||
|
checkout. With no checkout in reach, `POST repos/{owner}/{repo}/pulls`
|
||||||
|
through `tea api`, which takes the slug.
|
||||||
- `close <idx>...`, `reopen, open <idx>...`
|
- `close <idx>...`, `reopen, open <idx>...`
|
||||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
||||||
- `review <idx>` — interactive review.
|
- `review <idx>` — interactive review.
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/
|
|||||||
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
||||||
- `--header/-H key:value` (repeatable)
|
- `--header/-H key:value` (repeatable)
|
||||||
- `--include/-i` — write status + response headers to stderr.
|
- `--include/-i` — write status + response headers to stderr.
|
||||||
- `--output/-o <file>` — write response body to file (`-` = stdout).
|
- `--output/-o <file>` — write response body to file (`-` = stdout). **Not the entity commands' format flag**: `-o json` here creates a file named `json` and prints nothing. The body is already JSON.
|
||||||
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
- Quote the endpoint if it contains `?` or `&` to prevent shell expansion.
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Up
|
|||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--login, -l <name>` | use a specific login from the config |
|
| `--login, -l <name>` | use a specific login from the config |
|
||||||
| `--repo, -r <owner/repo>` | override repository context (local path or slug) |
|
| `--repo, -r <owner/repo>` | override repository context (local path or slug). **A slug only works where the command is pure API.** `pulls create`, `pulls checkout` and `pulls clean` need a real checkout and read this flag as a path — see [SKILL.md](../../SKILL.md) |
|
||||||
| `--remote, -R <name>` | discover login from this git remote |
|
| `--remote, -R <name>` | discover login from this git remote |
|
||||||
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json` |
|
| `--output, -o <fmt>` | output format: `simple, table, csv, tsv, yaml, json`. **Entity commands only** — on `tea api` the same flag is a FILE NAME, see [HELPERS](./helpers.md) |
|
||||||
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
| `--page, -p <n>` / `--limit, --lm <n>` | pagination (defaults 1 / 30) |
|
||||||
| `--fields, -f <list>` | which columns to print |
|
| `--fields, -f <list>` | which columns to print |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
What the guard guards: `tea` the command, not `tea` the word.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
The bug these tests hold down: the guard asked whether the string contained
|
||||||
|
`tea` surrounded by whitespace, so in a repository *about* the CLI it blocked
|
||||||
|
prose. An issue title, a commit message quoting a raw call, `grep -rn " tea "`
|
||||||
|
and `echo tea` were all refused, with a message telling the operator to add
|
||||||
|
`--login` to `git commit`. The advice could not be followed — the only way
|
||||||
|
past was to reword the sentence.
|
||||||
|
|
||||||
|
Two lines are held at once here, and neither may move without the other: the
|
||||||
|
four false positives pass, and every shape that really runs the CLI — after
|
||||||
|
`&&`, after a pipe, in a subshell, in a substitution, twice in one line — is
|
||||||
|
still blocked or still rewritten. A test that only proved the first would be
|
||||||
|
satisfied by deleting the guard.
|
||||||
|
|
||||||
|
No network and no `tea` binary: the hook is pure decision-making, so the
|
||||||
|
fixture is a directory with a pin in it and a JSON payload on stdin.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
GUARD = os.path.join(REPO, "hooks", "tea-guard.sh")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(REPO, "skills", "auth", "scripts"))
|
||||||
|
import pin # noqa: E402
|
||||||
|
|
||||||
|
LOGIN = "fixture/user"
|
||||||
|
|
||||||
|
ALLOW, BLOCK, REWRITE = "allow", "block", "rewrite"
|
||||||
|
|
||||||
|
|
||||||
|
class GuardCase(unittest.TestCase):
|
||||||
|
"""One temp project with one pinned login; the hook run as the harness
|
||||||
|
runs it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="tea-guard-")
|
||||||
|
self.root = os.path.realpath(self._tmp.name)
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
path = pin.settings_path(self.root)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(json.dumps({"env": {pin.ENV_KEY: LOGIN}}))
|
||||||
|
|
||||||
|
def run_guard(self, cmd):
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("PYTHONPATH", None)
|
||||||
|
env[pin.PROJECT_DIR_ENV] = self.root
|
||||||
|
p = subprocess.run([sys.executable, GUARD],
|
||||||
|
input=json.dumps({"tool_input": {"command": cmd},
|
||||||
|
"cwd": self.root}),
|
||||||
|
cwd=self.root, env=env,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def verdict(self, cmd):
|
||||||
|
p = self.run_guard(cmd)
|
||||||
|
if p.returncode == 2:
|
||||||
|
return BLOCK, p.stderr
|
||||||
|
self.assertEqual(p.returncode, 0, p.stderr)
|
||||||
|
if not p.stdout.strip():
|
||||||
|
return ALLOW, ""
|
||||||
|
got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"]
|
||||||
|
return REWRITE, got
|
||||||
|
|
||||||
|
def assertVerdict(self, cmd, expected):
|
||||||
|
kind, detail = self.verdict(cmd)
|
||||||
|
self.assertEqual(kind, expected,
|
||||||
|
"%r → %s (%s)" % (cmd, kind, detail.strip()))
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the four false positives, verbatim from the report
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestProseAboutTheCliRuns(GuardCase):
|
||||||
|
|
||||||
|
def test_an_issue_title_may_name_the_command(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
'python3 skills/issue/scripts/issue_new.py --type bug '
|
||||||
|
'--title "Warn that tea pulls create needs the repo checkout" '
|
||||||
|
'--label comp/use --severity low', ALLOW)
|
||||||
|
|
||||||
|
def test_a_commit_message_may_quote_a_raw_call(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
"git add -A && git commit -F- <<'EOF'\n"
|
||||||
|
"feat: close issues through a script\n"
|
||||||
|
"\n"
|
||||||
|
"Единственным способом сменить state был сырой вызов\n"
|
||||||
|
"tea api -X PATCH ... repos/OWNER/REPO/issues/N\n"
|
||||||
|
"EOF", ALLOW)
|
||||||
|
|
||||||
|
def test_a_one_line_commit_message_may_too(self):
|
||||||
|
self.assertVerdict('git commit -m "route it through tea api"', ALLOW)
|
||||||
|
|
||||||
|
def test_searching_the_repository_for_the_word(self):
|
||||||
|
for cmd in ('grep -rn " tea " docs/',
|
||||||
|
'grep -rn "tea api" skills/',
|
||||||
|
'echo tea'):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
def test_the_word_as_a_bare_argument_is_still_an_argument(self):
|
||||||
|
"""`echo tea` was the smallest case in the report; these are the same
|
||||||
|
shape with the word in other argument positions."""
|
||||||
|
for cmd in ('ls tea', 'cat notes/tea', 'python3 x.py tea api'):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# and the real thing is still guarded
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRealInvocationsStayGuarded(GuardCase):
|
||||||
|
|
||||||
|
def test_a_bare_call_without_a_login_is_blocked(self):
|
||||||
|
detail = self.assertVerdict("tea issues list", BLOCK)
|
||||||
|
self.assertIn("--login", detail)
|
||||||
|
|
||||||
|
def test_the_placeholder_is_rewritten_to_the_pin(self):
|
||||||
|
got = self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" --state open', REWRITE)
|
||||||
|
self.assertIn(LOGIN, got)
|
||||||
|
self.assertNotIn("GITEA_LOGIN", got)
|
||||||
|
|
||||||
|
def test_a_login_named_by_hand_is_blocked(self):
|
||||||
|
detail = self.assertVerdict("tea issues list --login somebody", BLOCK)
|
||||||
|
self.assertIn("do not name the login", detail)
|
||||||
|
|
||||||
|
def test_another_variable_is_not_the_placeholder(self):
|
||||||
|
self.assertVerdict('tea issues list --login "$OTHER"', BLOCK)
|
||||||
|
|
||||||
|
def test_compound_commands_are_read_segment_by_segment(self):
|
||||||
|
for cmd in ('cd /tmp && tea issues list',
|
||||||
|
'echo x | tea api -X GET repos/x/y',
|
||||||
|
'( tea issues list )',
|
||||||
|
'cd /tmp; tea issues list',
|
||||||
|
'FOO=1 tea issues list',
|
||||||
|
'sudo tea issues list',
|
||||||
|
'xargs tea issues list'):
|
||||||
|
self.assertVerdict(cmd, BLOCK)
|
||||||
|
|
||||||
|
def test_substitutions_are_read_too(self):
|
||||||
|
for cmd in ('echo $(tea whoami)',
|
||||||
|
'x=$(tea whoami)',
|
||||||
|
'echo `tea whoami`'):
|
||||||
|
self.assertVerdict(cmd, BLOCK)
|
||||||
|
|
||||||
|
def test_a_guarded_call_beside_prose_that_mentions_the_word(self):
|
||||||
|
"""The two halves of the bug in one line: the guard must ignore the
|
||||||
|
argument and still catch the call."""
|
||||||
|
self.assertVerdict(
|
||||||
|
'git commit -m "route it through tea api" && tea issues list',
|
||||||
|
BLOCK)
|
||||||
|
|
||||||
|
def test_an_absolute_path_to_the_binary_is_the_binary(self):
|
||||||
|
self.assertVerdict("/usr/local/bin/tea issues list", BLOCK)
|
||||||
|
|
||||||
|
def test_every_call_in_the_line_is_rewritten(self):
|
||||||
|
"""A half-rewritten line leaves the second call with an unset variable
|
||||||
|
and therefore no login at all."""
|
||||||
|
got = self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" && '
|
||||||
|
'tea pulls list --login "$GITEA_LOGIN"', REWRITE)
|
||||||
|
self.assertEqual(got.count(LOGIN), 2)
|
||||||
|
self.assertNotIn("GITEA_LOGIN", got)
|
||||||
|
|
||||||
|
def test_a_second_unguarded_call_is_not_covered_by_the_first(self):
|
||||||
|
self.assertVerdict(
|
||||||
|
'tea issues list --login "$GITEA_LOGIN" && tea pulls list', BLOCK)
|
||||||
|
|
||||||
|
def test_prose_naming_the_whitelisted_form_does_not_launder_a_call(self):
|
||||||
|
"""`tea logins list` is allowed because it uses no identity. Quoting
|
||||||
|
that phrase must not turn the call beside it into a whitelisted one."""
|
||||||
|
self.assertVerdict(
|
||||||
|
'echo "run tea logins list first" && tea issues list', BLOCK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheWhitelistStillApplies(GuardCase):
|
||||||
|
|
||||||
|
def test_login_enumeration_needs_no_pin(self):
|
||||||
|
for cmd in ("tea logins list", "tea logins ls",
|
||||||
|
"tea --version", "tea --help"):
|
||||||
|
self.assertVerdict(cmd, ALLOW)
|
||||||
|
|
||||||
|
def test_a_whitelisted_call_next_to_a_guarded_one_does_not_excuse_it(self):
|
||||||
|
self.assertVerdict("tea logins list && tea issues list", BLOCK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnparseableLinesFailClosed(GuardCase):
|
||||||
|
"""An unbalanced quote means the shell's reading and ours may differ. The
|
||||||
|
old substring test decides — it over-matches, and over-matching blocks."""
|
||||||
|
|
||||||
|
def test_an_unterminated_quote_around_a_call_still_blocks(self):
|
||||||
|
self.assertVerdict('tea issues list --state "open', BLOCK)
|
||||||
|
|
||||||
|
def test_an_unterminated_quote_with_no_call_is_still_allowed(self):
|
||||||
|
self.assertVerdict('echo "unterminated', ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
`wiki:` is a domain field, and the parser now agrees with the format.
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
The bug: `references/format.md` put `wiki:` in the table of domain fields,
|
||||||
|
between `depends` and `origin`, and `issue.py` had never heard of it. The
|
||||||
|
field fell into `extra` and rendered with the foreign keys — sorted in beside
|
||||||
|
`branch`, `gitea`, `synced`, `url`, i.e. AFTER the sync fields, which the same
|
||||||
|
document forbids one line further down. A list written without brackets parsed
|
||||||
|
as a single string, and nothing could set the field but a text editor.
|
||||||
|
|
||||||
|
These tests pin the resolution: implemented in the domain, rendered among the
|
||||||
|
domain fields, parsed as a list in both forms, and reachable from the command
|
||||||
|
line. The layer rule rides along — a title is a name for a document, so the
|
||||||
|
field carries titles and this layer never resolves one.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
|
||||||
|
|
||||||
|
sys.path.insert(0, ISSUE_SCRIPTS)
|
||||||
|
import issue # noqa: E402
|
||||||
|
|
||||||
|
TITLES = ["Simple Chains/Ideas/Chain core", "Simple Chains/Ideas/Transport"]
|
||||||
|
|
||||||
|
SYNCED = """\
|
||||||
|
---
|
||||||
|
id: wire-sqlc-appclick
|
||||||
|
state: open
|
||||||
|
labels: [type/task]
|
||||||
|
assignees: []
|
||||||
|
milestone: none
|
||||||
|
depends: [migrate-schema]
|
||||||
|
wiki: [Simple Chains/Ideas/Chain core]
|
||||||
|
origin: gitea
|
||||||
|
branch: feat/wire-sqlc
|
||||||
|
gitea: claude-skills/tea#42
|
||||||
|
synced: 2026-08-09T18:40:00Z
|
||||||
|
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||||
|
---
|
||||||
|
# Wire sqlc into the appclick repo layer
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Тело роли не играет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheFieldIsInTheDomain(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_it_is_a_domain_key_and_a_list_key(self):
|
||||||
|
self.assertIn("wiki", issue.DOMAIN_KEYS)
|
||||||
|
self.assertIn("wiki", issue.LIST_KEYS)
|
||||||
|
|
||||||
|
def test_it_renders_between_depends_and_origin(self):
|
||||||
|
"""`format.md` states the order and says domain fields render first.
|
||||||
|
The old behavior put it after the sync fields."""
|
||||||
|
order = issue.DOMAIN_KEYS
|
||||||
|
self.assertEqual(order[order.index("depends") + 1], "wiki")
|
||||||
|
self.assertEqual(order[order.index("wiki") + 1], "origin")
|
||||||
|
|
||||||
|
def test_it_survives_a_round_trip_among_the_domain_fields(self):
|
||||||
|
iss = issue.Issue.from_text(SYNCED, id="wire-sqlc-appclick")
|
||||||
|
self.assertEqual(iss.wiki, ["Simple Chains/Ideas/Chain core"])
|
||||||
|
self.assertNotIn("wiki", iss.extra)
|
||||||
|
|
||||||
|
text = iss.to_text()
|
||||||
|
keys = [line.split(":", 1)[0]
|
||||||
|
for line in text.splitlines()[1:]
|
||||||
|
if line != "---" and ":" in line]
|
||||||
|
keys = keys[:keys.index("origin") + 1]
|
||||||
|
self.assertEqual(keys[-3:], ["depends", "wiki", "origin"])
|
||||||
|
self.assertLess(keys.index("wiki"), keys.index("origin"))
|
||||||
|
|
||||||
|
again = issue.Issue.from_text(text, id="wire-sqlc-appclick")
|
||||||
|
self.assertEqual(again.wiki, iss.wiki)
|
||||||
|
|
||||||
|
def test_a_bracketless_list_is_still_a_list(self):
|
||||||
|
"""Without membership in LIST_KEYS this parsed as one string —
|
||||||
|
`wiki: A, B` became the single title "A, B"."""
|
||||||
|
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
|
||||||
|
"wiki: %s" % ", ".join(TITLES))
|
||||||
|
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||||
|
|
||||||
|
def test_the_bracketed_form_parses_the_same_way(self):
|
||||||
|
text = SYNCED.replace("wiki: [Simple Chains/Ideas/Chain core]",
|
||||||
|
"wiki: [%s]" % ", ".join(TITLES))
|
||||||
|
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||||
|
|
||||||
|
def test_an_absent_field_is_an_empty_list_and_renders_as_one(self):
|
||||||
|
text = "\n".join(l for l in SYNCED.splitlines()
|
||||||
|
if not l.startswith("wiki:"))
|
||||||
|
iss = issue.Issue.from_text(text)
|
||||||
|
self.assertEqual(iss.wiki, [])
|
||||||
|
self.assertIn("wiki: []", iss.to_text())
|
||||||
|
|
||||||
|
def test_the_titles_are_carried_verbatim(self):
|
||||||
|
"""A title with a slash in it is one title — the slash is hierarchy
|
||||||
|
inside the name, not a path this layer walks."""
|
||||||
|
iss = issue.Issue(id="x", title="X", wiki=TITLES)
|
||||||
|
self.assertIn("wiki: [%s]" % ", ".join(TITLES), iss.to_text())
|
||||||
|
|
||||||
|
def test_the_domain_still_knows_nothing_about_a_wiki_it_could_reach(self):
|
||||||
|
"""The layer rule: titles only. No page path, no sub_url, no HTTP."""
|
||||||
|
with open(os.path.join(ISSUE_SCRIPTS, "issue.py")) as f:
|
||||||
|
body = f.read()
|
||||||
|
for banned in ("sub_url", "content_base64", "urllib"):
|
||||||
|
self.assertNotIn(banned, body)
|
||||||
|
imports = [l for l in body.splitlines()
|
||||||
|
if l.startswith("import ") or l.startswith("from ")]
|
||||||
|
self.assertNotIn("import subprocess", imports)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueNewCanSetIt(unittest.TestCase):
|
||||||
|
"""The script run for real, in a throwaway store — never the developer's."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="tea-wiki-field-")
|
||||||
|
self.out = os.path.join(os.path.realpath(self._tmp.name), "issues")
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
|
||||||
|
def new(self, *args):
|
||||||
|
p = subprocess.run(
|
||||||
|
[sys.executable, os.path.join(ISSUE_SCRIPTS, "issue_new.py"),
|
||||||
|
"--type", "task", "--title", "Write the chain core up",
|
||||||
|
"--out", self.out] + list(args),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
|
||||||
|
with open(os.path.join(self.out, "write-the-chain-core-up.md")) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_the_flag_repeats_into_a_list(self):
|
||||||
|
text = self.new("--wiki", TITLES[0], "--wiki", TITLES[1])
|
||||||
|
self.assertIn("wiki: [%s]" % ", ".join(TITLES), text)
|
||||||
|
self.assertEqual(issue.Issue.from_text(text).wiki, TITLES)
|
||||||
|
|
||||||
|
def test_without_the_flag_the_field_is_present_and_empty(self):
|
||||||
|
self.assertIn("wiki: []", self.new())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user