refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: auth
|
||||
description: Pin the Gitea login used by the tea CLI in this project. Run when the tea-guard hook reports no login is pinned, or when the user types /tea:auth. Enumerates available logins, makes the OPERATOR pick one, and persists it to .claude/settings.local.json. The pin takes effect immediately — no restart.
|
||||
---
|
||||
|
||||
# /tea:auth — pin the project Gitea login
|
||||
|
||||
Goal: have the **operator** select exactly one `tea` login for this project and
|
||||
persist it to `.claude/settings.local.json` under `env.GITEA_LOGIN`. The
|
||||
`tea-guard` hook reads this file at call time and rewrites every
|
||||
`--login "$GITEA_LOGIN"` to the pinned value, so the choice takes effect
|
||||
**immediately, with no session restart**.
|
||||
|
||||
## The one hard rule: the operator chooses, never you
|
||||
|
||||
Picking the wrong identity is the exact failure this command exists to prevent.
|
||||
So:
|
||||
|
||||
- **ALWAYS** present the choice with `AskUserQuestion` and let the operator
|
||||
pick — even if memory, context, the repo URL, or a previous session suggests
|
||||
a "likely" login. Do **not** auto-select from memory or infer it. A wrong
|
||||
guess writes under the wrong account.
|
||||
- The only exception: exactly **one** login exists on the machine — then
|
||||
propose it and still confirm before writing.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Enumerate logins (allowed by the guard even with no pin):
|
||||
`tea logins list -o json`
|
||||
2. **No logins:** stop and ask the operator to run `tea logins add` themselves
|
||||
— it is interactive (prompts for URL/token). Do not run it for them.
|
||||
3. **One login:** propose it; confirm before writing.
|
||||
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
|
||||
`url` so the operator's choice is unambiguous. Never decide for them.
|
||||
5. Merge the chosen name into the **project root's**
|
||||
`.claude/settings.local.json` under `env` (do not clobber other keys):
|
||||
```json
|
||||
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
|
||||
```
|
||||
**In a git worktree, write it to the main checkout, never to the worktree.**
|
||||
A worktree is deleted when the branch is done, taking a pin written into it
|
||||
with it, and one repository with two pins is one repository with two
|
||||
identities. Both the guard and the scripts already reach the main checkout's
|
||||
pin from inside any worktree — so there is nothing to pin a second time.
|
||||
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
|
||||
write beside.
|
||||
6. Done — it is live. The guard resolves the pin from the file on the next
|
||||
`tea` call; no restart needed. Tell the operator which login is now pinned,
|
||||
and which file it went in.
|
||||
|
||||
## Where the pin is looked for
|
||||
|
||||
One search order, written once in `scripts/pin.py` and imported by both the
|
||||
`tea-guard` hook and the sync transport — they cannot disagree about a
|
||||
directory, and a test asserts neither keeps a copy of the walk.
|
||||
|
||||
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
|
||||
`cwd`), then the current directory. Each is searched up its parent chain; only
|
||||
if that finds nothing does the search cross into the main working tree of a
|
||||
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
|
||||
is never a source — a plugin pointed at somebody else's project must take the
|
||||
identity from that project, not from where it happens to be installed.
|
||||
|
||||
If a script reports "no login pinned", that is the honest answer: nothing was
|
||||
found anywhere on that order. Pin one — at the project root.
|
||||
|
||||
## Identity-safety rules
|
||||
|
||||
- NEVER run commands that mutate logins or global login state:
|
||||
`tea logins add/edit/delete/default`, `tea logout`. Read-only
|
||||
`tea logins list` is the only allowed login command.
|
||||
- If a `tea` call fails with a permission/scope error, report it. Do NOT try to
|
||||
fix it by switching to, or editing, a different login.
|
||||
- If you ever see `no gitea login detected, falling back to login '...'`, treat
|
||||
it as a hard failure: stop, do not act on the result, surface it.
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pin.py — where the operator's Gitea login pin is, and how it is found.
|
||||
|
||||
**The search order lives here and nowhere else.** The `tea-guard` hook imports
|
||||
this module; so does the transport every sync script runs on. Two
|
||||
copies of the order is exactly how a git worktree came to have a working hook
|
||||
and a dead transport in the same directory: `tea` resolved the login, the
|
||||
scripts said "no login pinned", and the error told the operator to pin what was
|
||||
already pinned.
|
||||
|
||||
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
|
||||
hook runs before every Bash call and must not fork a process to answer this.
|
||||
|
||||
The pin is a file the OPERATOR owns and `/tea:auth` writes:
|
||||
|
||||
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
|
||||
|
||||
## Search order
|
||||
|
||||
Start directories, in order, first hit wins:
|
||||
|
||||
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
|
||||
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
|
||||
nothing and go straight to 3
|
||||
3. the current directory
|
||||
|
||||
Each start directory is searched the same way:
|
||||
|
||||
a. up the parent chain, from the directory itself to the filesystem root
|
||||
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
|
||||
of that repository's main working tree
|
||||
|
||||
(b) is the whole point of this module. A worktree is a *sibling* of the main
|
||||
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
|
||||
therefore only ever in the main checkout — is not on the parent chain of (a).
|
||||
Git knows the two trees are one repository: a worktree's `.git` is a FILE
|
||||
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
|
||||
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
|
||||
this reads the files.
|
||||
|
||||
## Why the search does not start at __file__
|
||||
|
||||
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
|
||||
*are* anchored on their own module's location. Two different questions:
|
||||
|
||||
where does this installation keep its files a fact about the plugin
|
||||
whose login does this project run under a fact about the project
|
||||
|
||||
A plugin installed outside any repository and pointed at somebody else's tree
|
||||
must answer the second one from the tree it was pointed at. Anchoring the pin
|
||||
on `__file__` would make the plugin's own directory an identity source, which
|
||||
is how a checkout ends up acting under a login nobody chose for it. So the
|
||||
search runs from the working directory upward — and reaches a worktree's main
|
||||
checkout by asking git, not by walking somewhere else.
|
||||
|
||||
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
|
||||
caller says so. This module never guesses a login.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
SETTINGS_PARTS = (".claude", "settings.local.json")
|
||||
ENV_KEY = "GITEA_LOGIN"
|
||||
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
|
||||
|
||||
|
||||
def settings_path(root):
|
||||
"""The pin file for a project root. The one place this path is spelled."""
|
||||
return os.path.join(root, *SETTINGS_PARTS)
|
||||
|
||||
|
||||
def read_pin(path):
|
||||
"""The login in a settings file, or None.
|
||||
|
||||
Unreadable, not JSON, no `env`, empty string — all the same answer. A
|
||||
broken file is not a login and is not worth a traceback in a hook."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
value = (json.load(f).get("env") or {}).get(ENV_KEY)
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def parents(start):
|
||||
"""`start` and every ancestor of it, up to the filesystem root."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
yield d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return
|
||||
d = parent
|
||||
|
||||
|
||||
def gitdir_of(d):
|
||||
"""The private git directory `d/.git` points at, or None.
|
||||
|
||||
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
|
||||
directory and there is nothing to follow."""
|
||||
p = os.path.join(d, ".git")
|
||||
if not os.path.isfile(p):
|
||||
return None
|
||||
try:
|
||||
with open(p) as f:
|
||||
head = f.read(4096)
|
||||
except OSError:
|
||||
return None
|
||||
for line in head.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("gitdir:"):
|
||||
target = line[len("gitdir:"):].strip()
|
||||
if not target:
|
||||
return None
|
||||
if not os.path.isabs(target):
|
||||
target = os.path.join(d, target)
|
||||
return os.path.abspath(target)
|
||||
return None
|
||||
|
||||
|
||||
def main_worktree(d):
|
||||
"""If `d` is a linked worktree, the main working tree of its repository.
|
||||
|
||||
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
|
||||
file holds a path to `<main>/.git`; the main working tree is its parent.
|
||||
The `.git` basename check keeps this to worktrees: a submodule's `.git`
|
||||
is a pointer too, but it points into `<super>/.git/modules/…`, and the
|
||||
tree it belongs to is already on the parent chain."""
|
||||
gitdir = gitdir_of(d)
|
||||
if not gitdir or not os.path.isdir(gitdir):
|
||||
return None
|
||||
common = gitdir
|
||||
marker = os.path.join(gitdir, "commondir")
|
||||
if os.path.isfile(marker):
|
||||
try:
|
||||
with open(marker) as f:
|
||||
rel = f.read().strip()
|
||||
except OSError:
|
||||
rel = ""
|
||||
if rel:
|
||||
common = os.path.abspath(os.path.join(gitdir, rel))
|
||||
if os.path.basename(common) != ".git":
|
||||
return None
|
||||
root = os.path.dirname(common)
|
||||
if root and os.path.isdir(root) and root != os.path.abspath(d):
|
||||
return root
|
||||
return None
|
||||
|
||||
|
||||
def search(start):
|
||||
"""(login, path) for one start directory: the parent chain, then the main
|
||||
checkout of any worktree met on it. (None, None) when there is no pin.
|
||||
|
||||
The chain comes first and always wins, so the worktree branch can only
|
||||
ever find a pin that walking up would not have found at all."""
|
||||
hops = []
|
||||
for d in parents(start):
|
||||
login = read_pin(settings_path(d))
|
||||
if login:
|
||||
return login, settings_path(d)
|
||||
root = main_worktree(d)
|
||||
if root and root not in hops:
|
||||
hops.append(root)
|
||||
for root in hops:
|
||||
# One level of indirection, never two: a main checkout is not itself a
|
||||
# linked worktree, so this loop cannot chain and cannot cycle.
|
||||
for d in parents(root):
|
||||
login = read_pin(settings_path(d))
|
||||
if login:
|
||||
return login, settings_path(d)
|
||||
return None, None
|
||||
|
||||
|
||||
def start_dirs(hint=None):
|
||||
"""The ordered, deduplicated start directories.
|
||||
|
||||
`hint` is the caller's own idea of where the work is happening — the hook
|
||||
passes the `cwd` from its payload, which is the directory the Bash command
|
||||
will actually run in. A script has no payload and passes nothing."""
|
||||
try:
|
||||
cwd = os.getcwd()
|
||||
except OSError: # cwd deleted out from under us
|
||||
cwd = None
|
||||
out = []
|
||||
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
|
||||
if not d:
|
||||
continue
|
||||
d = os.path.abspath(d)
|
||||
if d not in out:
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def find_pin(hint=None):
|
||||
"""(login, path) for the first start directory that has a pin, else
|
||||
(None, None). The entry point; everything above is its parts."""
|
||||
for start in start_dirs(hint):
|
||||
login, path = search(start)
|
||||
if login:
|
||||
return login, path
|
||||
return None, None
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: issue
|
||||
description: Work with this project's issues as units of work — create, read, grep, validate, and walk their dependency graph. Entirely offline; issues are local markdown files and need no tracker. Load when the user asks to file/create an issue, read or find issues, check an issue against the format, or see what depends on what. For pushing to or pulling from Gitea, load /tea:sync instead.
|
||||
---
|
||||
|
||||
# /tea:issue — issues as units of work
|
||||
|
||||
An issue is a markdown file in `tmp/issues/`. This skill covers everything you
|
||||
do **with** an issue: writing one, reading one, checking it against the
|
||||
canonical format, and walking the dependency graph.
|
||||
|
||||
**Nothing here touches the network.** No `tea`, no Gitea, no login. An issue
|
||||
that lives only on this machine is a first-class issue, not a draft waiting to
|
||||
be uploaded. Synchronizing with a tracker is a separate, optional layer —
|
||||
`/tea:sync`.
|
||||
|
||||
Read [`references/format.md`](references/format.md) before creating or editing
|
||||
an issue. It is the single source of truth for identity, metadata, types,
|
||||
labels, templates, and language rules.
|
||||
|
||||
## Identity: the slug
|
||||
|
||||
The file name is the id and the id is a slug — `tmp/issues/wire-sqlc-appclick.md`.
|
||||
It never changes, not when the title changes and not when the issue is pushed
|
||||
somewhere. Tracker numbers live in a metadata field (`gitea: owner/repo#42`),
|
||||
never in a file name and never in `depends:`.
|
||||
|
||||
Consequence worth internalizing: **`#42` means nothing in this layer.** Refer to
|
||||
issues by id.
|
||||
|
||||
## Scripts
|
||||
|
||||
All offline, all in `<skill-base-dir>/scripts/`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
|
||||
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
|
||||
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
|
||||
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
|
||||
| `issue_evict.py [id…] [--dry-run]` | remove closed issues from the store; **never** an `origin: local` one |
|
||||
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
|
||||
| `issue.py` | the domain module the others import — not a command |
|
||||
|
||||
```
|
||||
tmp/issues/INDEX.md table of every issue — read this first
|
||||
tmp/issues/wire-sqlc-appclick.md metadata block + `# Title` + body
|
||||
tmp/issues/wire-sqlc.comments.md comment thread (written by /tea:sync only)
|
||||
tmp/issues/tree-<id>.md saved graph (issue_tree.py --write)
|
||||
```
|
||||
|
||||
## Where the store is
|
||||
|
||||
`<repo root>/tmp/issues` — **not** `tmp/issues` relative to wherever you are
|
||||
standing. The scripts resolve it by walking up from their own file to the
|
||||
nearest `.git` or `AGENTS.md`, so they all see one store no matter which
|
||||
directory you run them from, and a `cd` earlier in the session changes nothing.
|
||||
|
||||
`--out` overrides that and is taken **literally**: an absolute path is used as
|
||||
given, a relative one stays relative to the current directory. Nothing rewrites
|
||||
what you typed.
|
||||
|
||||
Two things follow, and both are deliberate:
|
||||
|
||||
- A store that is not there reports `does not exist`; a store with no issues in
|
||||
it reports `is empty`. They are different problems.
|
||||
- No script conjures a store as a side effect of writing. Only `issue_new.py`
|
||||
creates one — the first issue in a fresh checkout — and it says so on stderr.
|
||||
|
||||
## Reading: grep, don't parse
|
||||
|
||||
Metadata is one field per line with inline lists precisely so plain `grep`
|
||||
works. `INDEX.md` first, then the files:
|
||||
|
||||
```bash
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md # all bugs
|
||||
grep -l 'origin: local' tmp/issues/*.md # never pushed anywhere
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||
grep -A3 '## Acceptance criteria' tmp/issues/wire-*.md
|
||||
grep -c '^- \[ \]' tmp/issues/wire-sqlc-appclick.md # open checkboxes
|
||||
```
|
||||
|
||||
Read whole files only for the issues the task actually needs.
|
||||
|
||||
## Creating an issue
|
||||
|
||||
1. **Read the format**: [`references/format.md`](references/format.md).
|
||||
2. **Pick the type** — `bug`, `task`, `refactor`, `test`, `feature` (a
|
||||
container for several issues with one business value), or `draft` (an idea
|
||||
not ready for work). If it is not obvious from the request, ask the user
|
||||
(one question).
|
||||
3. **Scaffold it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_new.py \
|
||||
--type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick --depends migrate-schema
|
||||
```
|
||||
English imperative title with no type prefix; `--depends` takes ids.
|
||||
4. **Fill the sections** with Edit — every section of the template present and
|
||||
in order, headers English, prose Russian. `## Spec` gets a repo path, a URL,
|
||||
or the literal `none`; ask the user if you cannot determine which.
|
||||
5. **Check it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||
```
|
||||
|
||||
One file = one issue. Several related issues = several files, linked through
|
||||
`depends:`.
|
||||
|
||||
The issue is now real and complete. Publishing it to Gitea is a separate
|
||||
decision — `/tea:sync` — and does not change the file's status here.
|
||||
|
||||
## Editing an issue
|
||||
|
||||
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
|
||||
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
|
||||
the table. Checkboxes are the exception — use `issue_ac.py`, below.
|
||||
|
||||
If the issue is synced (`origin: gitea`), the file is a working copy: your edit
|
||||
is local until you run `push.py --update` from `/tea:sync`, and that push
|
||||
**deletes the file** once Gitea has it. Closing one of those is `close.py` from
|
||||
`/tea:sync` — it moves the state on both sides in a single run; editing
|
||||
`state:` here alone would only ever tell this machine. Nothing tracks drift, and with one copy
|
||||
at a time there is little to track — a file that is still here has not been
|
||||
pushed. Get it back with `pull.py <n>`; the slug does not change.
|
||||
|
||||
## Ticking checkboxes
|
||||
|
||||
A checkbox is the one part of a body that is **state** and not prose, so it has
|
||||
a command of its own. Never rewrite a body just to tick a box: the rewrite
|
||||
re-flows lines and re-words sentences, and the issue's diff swells around a
|
||||
change that means one character.
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
|
||||
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||
```
|
||||
|
||||
With no flag it prints the numbered list with each item's state, grouped by the
|
||||
heading the item sits under. `--check` / `--uncheck` take that number or a
|
||||
substring of the item's text (case-insensitive).
|
||||
|
||||
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
|
||||
`type/feature` keeps its children as checkboxes under `## Issues`, and they
|
||||
are numbered in the same list. The script is named after the section most
|
||||
boxes live in, nothing more.
|
||||
- **A substring must match exactly one item.** Two matches is an error that
|
||||
lists them; pick by number instead. It never guesses.
|
||||
- **Exactly one character of the file changes.** Metadata, wording, wrapping
|
||||
and trailing whitespace all come back byte for byte, so `git diff` and the
|
||||
tracker's diff show the tick and nothing else.
|
||||
- Examples inside a ``` fence are markup, not state — they are skipped.
|
||||
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
|
||||
boxes), recomputed from the body on every build and stored in no field.
|
||||
`issue_ac.py` rebuilds the index after a successful tick.
|
||||
|
||||
Getting the tick to the tracker is a separate step — `push.py --update` in
|
||||
`/tea:sync`.
|
||||
|
||||
## Writing a proper description
|
||||
|
||||
Issues get filed on the run — "comments aren't pulled", "the guard broke".
|
||||
That is a request, not a statement of work: no reproduction steps, no
|
||||
`path/file:line`, acceptance criteria nobody can check. Rewriting one into the
|
||||
canonical format is a procedure, not improvisation.
|
||||
|
||||
1. **Read the issue whole**, and everything it points at — the ids in
|
||||
`depends:`, the `## Spec` target, the files it names.
|
||||
2. **Determine the type and its template.** The `type/*` label selects one of
|
||||
the templates in [`references/format.md`](references/format.md), and that
|
||||
template's section list is the shape you are aiming at. If the label is
|
||||
missing or wrong, decide it now and fix `labels:`; promoting a `type/draft`
|
||||
to a concrete type is this same step.
|
||||
3. **Locate the anchor points in the code.** Grep the repo for every file,
|
||||
symbol, command, and error string the issue mentions, until you can name
|
||||
lines:
|
||||
```bash
|
||||
grep -rn 'GITEA_LOGIN' hooks/ skills/
|
||||
```
|
||||
Work that does not exist yet still has anchor points — the files the change
|
||||
will land in, and the ones that will call it.
|
||||
4. **Gather the missing context.** What has to be there when you are done:
|
||||
- code references in the `path/file.ext:line` form, for every place the
|
||||
change lands;
|
||||
- reproduction steps — exact commands and their real output (`type/bug`
|
||||
splits them across `## Steps to reproduce` / `## Expected` / `## Actual`);
|
||||
- acceptance criteria that are objectively checkable: a command that exits
|
||||
0, a file that exists, a section that is present — not aspirations;
|
||||
- a real value for `## Spec` — a repo path, a URL, or the literal `none`.
|
||||
|
||||
**A missing fact is either found in the repository or becomes a question to
|
||||
the user. Inventing one is forbidden.** Ask in one batch, and keep `none` in
|
||||
`## Spec` as the legitimate answer it is — never a plausible-looking link.
|
||||
5. **Rewrite the sections** with Edit: every section of the template, in the
|
||||
template's order, English headers and Russian prose. Replace the body; do
|
||||
not append a second telling of the same issue below the old one.
|
||||
6. **Check it:**
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_check.py wire-sqlc-appclick
|
||||
```
|
||||
Errors mean malformed, warnings mean the type's template is not fully
|
||||
filled in. Re-run `issue_index.py` if the labels changed.
|
||||
|
||||
The procedure is identical for `origin: local` and `origin: gitea` — it works
|
||||
on `tmp/issues/<id>.md`, and this layer does not know the difference. Getting
|
||||
the rewritten body into the tracker is a separate decision — `push.py --update`
|
||||
in `/tea:sync` — and is no part of this.
|
||||
|
||||
## Evicting closed issues
|
||||
|
||||
The store is a working set, not an archive. A closed issue is not a unit of
|
||||
work any more, and one command takes it out — no `rm`, no rebuilding `INDEX.md`
|
||||
by hand:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py --dry-run # what would go
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py # every closed one
|
||||
python3 <skill-base-dir>/scripts/issue_evict.py old-thing # just this one
|
||||
```
|
||||
|
||||
Two conditions, both read off the file, and the second one is the whole safety
|
||||
argument:
|
||||
|
||||
| `state:` | `origin:` | what eviction does |
|
||||
|---|---|---|
|
||||
| `closed` | a tracker | removes `<id>.md` and every sidecar under that slug |
|
||||
| `closed` | `local` | **keeps it, always**, and says why |
|
||||
| `open` | anything | keeps it |
|
||||
|
||||
**`origin: local` is never evicted, in any state, not even when you name it on
|
||||
the command line.** That file *is* the issue; there is no copy to fetch back.
|
||||
Only a file whose own metadata says the work lives somewhere else may go — the
|
||||
same trade `push.py` makes when it drops a file the tracker just confirmed.
|
||||
|
||||
- `--dry-run` prints what would go and writes nothing at all, `INDEX.md`
|
||||
included.
|
||||
- `INDEX.md` is rebuilt afterwards, so the table and the directory agree. It is
|
||||
rebuilt only when something was actually removed.
|
||||
- `.remote.json` is **not** pruned, deliberately: it is the number → slug
|
||||
ledger, and its entries are supposed to outlive the files they name (that is
|
||||
what makes `pull.py <n>` land on the same slug after a push). An evicted issue
|
||||
is in exactly the state a pushed one is.
|
||||
- **This is not a one-off migration.** `pull.py <n>` fetches an issue in any
|
||||
state — a number is an address, not a query — so a closed issue pulled after
|
||||
an eviction lands on disk again. Not a regression: evict it again when you are
|
||||
done reading it.
|
||||
|
||||
This command is offline and decides from `state:` in the file, which is only as
|
||||
fresh as the last pull. To have the tracker's answer instead — an issue closed
|
||||
in the web UI five minutes ago — use `/tea:sync`'s `evict.py`, which refreshes
|
||||
`state:` first and then calls exactly this decision.
|
||||
|
||||
## Dependency graph
|
||||
|
||||
`depends:` is the authoritative edge list; the body's `## Depends on` section
|
||||
is prose for humans. `issue_check.py` warns when they disagree.
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py # all roots
|
||||
python3 <skill-base-dir>/scripts/issue_tree.py wire-sqlc-appclick --write
|
||||
```
|
||||
|
||||
A `type/feature` plus its children read as one document: draw the tree once for
|
||||
the shape, then grep the files.
|
||||
|
||||
## Layering rule
|
||||
|
||||
This skill must keep working with `skills/sync/` deleted. Every import under
|
||||
`scripts/` is stdlib, and `subprocess` is not among them:
|
||||
|
||||
```bash
|
||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
||||
```
|
||||
|
||||
If you find yourself wanting a tracker concept here — an issue number, a login,
|
||||
an HTTP call — it belongs in `/tea:sync`.
|
||||
@@ -0,0 +1,372 @@
|
||||
# Issue format
|
||||
|
||||
Canonical format for every issue in this project, whether it ever reaches a
|
||||
tracker or not. Designed to be unambiguous for both humans and LLMs: fixed
|
||||
English section headers in a fixed order, verifiable acceptance criteria, one
|
||||
issue = one deliverable.
|
||||
|
||||
Nothing here depends on Gitea. How these files are mapped onto a tracker is the
|
||||
sync layer's business — see `/tea:sync`.
|
||||
|
||||
## Identity
|
||||
|
||||
An issue is one file, `tmp/issues/<id>.md`, and `id` is a slug: lowercase
|
||||
ASCII, digits, single dashes, derived from the title. **The slug is the
|
||||
identity.** It is stable for the life of the issue — a retitled issue keeps its
|
||||
slug; an issue pushed to a tracker, deleted locally and fetched back a month
|
||||
later keeps it too. Tracker numbers are a foreign key stored in a field, never
|
||||
the name of anything.
|
||||
|
||||
```
|
||||
tmp/issues/wire-sqlc-appclick.md
|
||||
```
|
||||
|
||||
A slug never contains a dot, which is how the store tells an issue from the
|
||||
files parked beside it (`<id>.comments.md`).
|
||||
|
||||
Stability is a promise the format makes, so something has to keep it once the
|
||||
file is gone. That is the sync layer's problem and its answer is a marker in the
|
||||
body — see `/tea:sync`; the domain neither writes nor reads it, and it never
|
||||
appears in the file on disk.
|
||||
|
||||
## Metadata block
|
||||
|
||||
One field per line, lists inline, so plain `grep` works without a parser:
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
branch: feat/wire-sqlc
|
||||
gitea: claude-skills/tea#42
|
||||
remote-updated: 2026-08-09T18:24:01Z
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
…
|
||||
```
|
||||
|
||||
| Field | Owner | Meaning |
|
||||
|---|---|---|
|
||||
| `id` | domain | slug; equals the file name |
|
||||
| `state` | domain | `open` or `closed` |
|
||||
| `labels` | domain | see namespaces below; exactly one `type/*` |
|
||||
| `assignees` | domain | logins; may be empty |
|
||||
| `milestone` | domain | title, or `none` |
|
||||
| `depends` | domain | ids this issue depends on — **the authoritative graph** |
|
||||
| `origin` | domain | `local`, or the name of a tracker this also lives in |
|
||||
| `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 |
|
||||
| `url`, `synced`, `remote-updated`, `comments` | sync | bookkeeping |
|
||||
|
||||
Domain fields render first, in the order above; sync fields follow, sorted.
|
||||
|
||||
`origin` is domain-owned on purpose: *whether* a piece of work exists anywhere
|
||||
but here is a fact about the work. *Where* that is, and how to reach it, is the
|
||||
sync layer's business — the domain carries `gitea:` and the rest through
|
||||
load/save verbatim and never reads them. That passthrough is why one file can
|
||||
represent a local issue and a synced one without a second format.
|
||||
|
||||
`origin: local` is a **complete state, not a pending one.** An issue that never
|
||||
leaves this machine is valid and finished work; pushing it is optional and
|
||||
nothing here treats it as a draft.
|
||||
|
||||
It is not a *permanent* state, and it is what the file's fate depends on:
|
||||
|
||||
| `origin:` | what the file is | what a push does to it | what eviction does to it |
|
||||
|---|---|---|---|
|
||||
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file | **nothing, ever** — in any state, named or not |
|
||||
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file | removes it once `state: closed` |
|
||||
|
||||
**A successful push deletes `tmp/issues/<id>.md`** (and `<id>.comments.md`), on
|
||||
create and on `--update` alike. What is in the store is what has not left this
|
||||
machine; everything else is fetched again when it is needed. The rule, its
|
||||
safety conditions, and how the slug survives are `/tea:sync`'s to state.
|
||||
|
||||
**A closed issue is evicted from the store** by `issue_evict.py` — same trade,
|
||||
one condition more: the work is done *and* it exists somewhere else. An
|
||||
`origin: local` issue is never evicted, because there is nowhere to fetch it
|
||||
back from. The store is a working set, not an archive; `pull.py <n>` fetches a
|
||||
closed issue again whenever it is wanted.
|
||||
|
||||
The `id` never changes across that round trip, which is why `depends:` in other
|
||||
issues keeps working. That is the format's promise; the mechanism is not.
|
||||
|
||||
## Language rules
|
||||
|
||||
- **Issue title**: English, imperative mood, no type prefix — the type lives in
|
||||
the label, not the title. Good: `Fix tea-guard crash on empty settings file`.
|
||||
Bad: `fix: crash`, `[bug] crash`, `Крашится гвард`.
|
||||
- **Section headers**: the exact English literals below, as `##` headings, in
|
||||
the given order. Do not translate, rename, or reorder them.
|
||||
- **Body prose** (text inside sections): Russian.
|
||||
|
||||
## Label namespaces
|
||||
|
||||
Four namespaces classify an issue. Two are exclusive (at most one label from
|
||||
the namespace), two are free-form:
|
||||
|
||||
| Namespace | Exclusive | Purpose |
|
||||
|---|---|---|
|
||||
| `type/*` | yes | What kind of work; primarily its business value. Mandatory, exactly one. |
|
||||
| `severity/*` | yes | Business impact. At most one; apply when the impact is known. |
|
||||
| `tech/*` | no | Technology the issue is bound to. Any number. |
|
||||
| `comp/*` | no | System component of this repo. Any number; no preset — project-specific. |
|
||||
|
||||
### `type/*` — mandatory, exactly one
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `type/bug` | Something behaves incorrectly in existing code |
|
||||
| `type/task` | Implementation of new functionality |
|
||||
| `type/refactor` | Internal restructuring: file moves, architecture; behavior must not change |
|
||||
| `type/test` | Writing or fixing tests |
|
||||
| `type/feature` | Container: several issues delivering one unit of business value |
|
||||
| `type/draft` | Idea captured for later; not ready for work |
|
||||
|
||||
### `severity/*` — at most one
|
||||
|
||||
`severity/low`, `severity/medium`, `severity/high`, `severity/showstopper`,
|
||||
`severity/critical`.
|
||||
|
||||
### `tech/*` — any number
|
||||
|
||||
Technology-bound labels, e.g. `tech/sql` (pgx, sqlc, sql-migrate — persistent
|
||||
storage), `tech/obs` (grafana, loki, prometheus, alloy — observability),
|
||||
`tech/postgres`.
|
||||
|
||||
### `comp/*` — any number
|
||||
|
||||
Components of this repo's system, e.g. `comp/appclick`. No preset list —
|
||||
derive from the project.
|
||||
|
||||
> Label **colors** are not part of the format: a hex code is how a tracker
|
||||
> paints a chip, not what an issue is. They live in `skills/sync/scripts/map.py`
|
||||
> and are applied on push.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`depends:` in the metadata block is the graph, and it holds **ids**:
|
||||
|
||||
```markdown
|
||||
depends: [migrate-schema, add-pool-cfg]
|
||||
```
|
||||
|
||||
An optional `## Depends on` section, placed right after `## Spec`, carries the
|
||||
human explanation — one reference per line, with a reason where it helps:
|
||||
|
||||
```markdown
|
||||
## Depends on
|
||||
- migrate-schema — нужна схема БД из этого issue
|
||||
- add-pool-cfg
|
||||
```
|
||||
|
||||
The section is prose and is passed to and from a tracker unchanged; only
|
||||
`depends:` is walked when the graph is computed. Keeping them consistent is on
|
||||
you — `issue_check.py` warns when the section names an id that `depends:` does
|
||||
not list. Omit the section when there are no dependencies; never write an empty
|
||||
one.
|
||||
|
||||
A `type/feature` container writes the same relation under `## Issues` instead
|
||||
(see the template below). Same direction, same rule: every id named there also
|
||||
belongs in that issue's `depends:`. The warning names whichever of the two
|
||||
sections the reference actually came from.
|
||||
|
||||
Draw the graph with `issue_tree.py`. The reverse direction is a grep:
|
||||
|
||||
```bash
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
||||
```
|
||||
|
||||
## Shared rules
|
||||
|
||||
- `## Summary` is always the first section; `## Acceptance criteria` is always
|
||||
present (exception: `type/draft`). These two are the anchors every reader
|
||||
(human or LLM) relies on.
|
||||
- `## Spec` is **mandatory in every type**. Its value is a repo path
|
||||
(`docs/specs/auth.md`), a URL, or the literal `none` when no spec exists.
|
||||
Never omit the section and never invent a link — `none` is an explicit,
|
||||
valid answer.
|
||||
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
|
||||
checkable condition, not an aspiration.
|
||||
- A checkbox is **item markup, not a property of one section**: `- [ ]`
|
||||
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
|
||||
`## Acceptance criteria`. An item that wraps continues on an indented line
|
||||
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
|
||||
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
|
||||
on exactly these rules and rewrites one character; progress (`3/7`) is
|
||||
counted off the body and is never a metadata field.
|
||||
- Code references use the `path/file.ext:line` form; related issues by id.
|
||||
- Screenshots are allowed but their content must be duplicated as text — an
|
||||
LLM reading these files cannot see images.
|
||||
- If acceptance criteria grow past ~5 unrelated items, split the issue (or
|
||||
promote it to a `type/feature` container with child issues).
|
||||
|
||||
## Template: `type/bug`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что сломано и где проявляется, одно-два предложения.
|
||||
|
||||
## Spec
|
||||
`docs/specs/auth.md`, URL — или `none`.
|
||||
|
||||
## Steps to reproduce
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Expected
|
||||
Что должно было произойти.
|
||||
|
||||
## Actual
|
||||
Что происходит на самом деле: вывод команды, лог.
|
||||
|
||||
## Environment
|
||||
Только релевантное: версии, ОС, конфигурация.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] баг не воспроизводится по шагам выше
|
||||
- [ ] добавлена проверка на регрессию (если применимо)
|
||||
```
|
||||
|
||||
## Template: `type/task`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что нужно сделать, одно-два предложения.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
- [ ] …
|
||||
|
||||
## Constraints
|
||||
Что НЕ входит в объём; технические рамки. (опционально)
|
||||
```
|
||||
|
||||
## Template: `type/refactor`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что перестраиваем и в каких файлах (`path/file:line`).
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
||||
|
||||
## Invariants
|
||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||
```
|
||||
|
||||
## Template: `type/test`
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Что покрываем тестами и где (`path/file:line`).
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||
|
||||
## Test cases
|
||||
- сценарий → ожидаемый результат
|
||||
- …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] перечисленные кейсы покрыты и зелёные
|
||||
- [ ] тесты проходят в CI
|
||||
```
|
||||
|
||||
## Template: `type/feature`
|
||||
|
||||
A container: one unit of business value delivered by several child issues.
|
||||
Child issues carry their own `type/*` (`task`, `bug`, `test`, …) and know
|
||||
nothing about the container.
|
||||
|
||||
**The container depends on its children, never the reverse.** Every child id
|
||||
goes in the container's own `depends:` and, as prose, in its `## Issues`
|
||||
section; a child's `depends:` is for that child's real dependencies and must
|
||||
not point back at the container. Keep implementation detail in the children;
|
||||
the feature body stays at business level.
|
||||
|
||||
That direction is not a convention picked at random. "The container is closed
|
||||
when its children are closed" *is* a dependency relation. "This child belongs
|
||||
to that feature" is a membership relation, and membership has no place in a
|
||||
dependency graph. Pointed the other way the two rules contradict each other:
|
||||
the moment the container listed a child that already depended on it,
|
||||
`issue_check.py` would report `ERROR cycle`. With the edge going down, the
|
||||
graph reads as nesting — `issue_tree.py` draws the container as the root with
|
||||
its children beneath it — and the check is green.
|
||||
|
||||
So the container's metadata block carries the children:
|
||||
|
||||
```markdown
|
||||
depends: [wire-sqlc-appclick, add-pool-cfg]
|
||||
```
|
||||
|
||||
and its body repeats them for a human:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Бизнес-ценность одним-двумя предложениями.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none`.
|
||||
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Issues
|
||||
- [ ] wire-sqlc-appclick — краткое описание части
|
||||
- [ ] add-pool-cfg — краткое описание части
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
- [ ] проверяемое условие уровня фичи (например, e2e-сценарий работает)
|
||||
```
|
||||
|
||||
## Template: `type/draft`
|
||||
|
||||
A parking spot for ideas that are not fleshed out yet. Minimal structure, no
|
||||
acceptance criteria required. Before implementation starts, a draft MUST be
|
||||
promoted: relabeled to a concrete type and rewritten into that type's template.
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Идея одним-двумя предложениями.
|
||||
|
||||
## Spec
|
||||
Ссылка или `none` (для драфтов обычно `none`).
|
||||
|
||||
## Notes
|
||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||
```
|
||||
|
||||
## Containers beyond `type/feature`
|
||||
|
||||
- **Milestone** — a set of issues with an optional time bound. Locally it is
|
||||
just the `milestone:` field; a tracker-side milestone must already exist for
|
||||
a push to attach the issue to it.
|
||||
- **Project** — a set of issues tracked by status columns (Backlog, ToDo,
|
||||
InProgress, Ready, Done). Not represented in this format and not reachable
|
||||
through the Gitea API — web UI only.
|
||||
@@ -0,0 +1,743 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""
|
||||
issue.py — what an issue IS. The domain layer.
|
||||
|
||||
Not a command; the module every other issue script builds on. It knows the
|
||||
canonical markdown format, the label taxonomy, validation, and the dependency
|
||||
graph. It knows NOTHING about any tracker: no Gitea, no `tea`, no logins, no HTTP, no
|
||||
issue numbers. The layering rule is mechanically checkable — every import in
|
||||
this directory is stdlib, and `subprocess` is not among them:
|
||||
|
||||
grep -rhn '^import\|^from' skills/issue/scripts/ | sort -u
|
||||
|
||||
Delete skills/sync/ entirely and this layer keeps working: issues that live
|
||||
only on this machine are first-class, not drafts on their way somewhere.
|
||||
|
||||
Identity is a slug derived from the title, and it is the only identity the
|
||||
domain has. The file name is the id:
|
||||
|
||||
tmp/issues/wire-sqlc-appclick.md
|
||||
|
||||
---
|
||||
id: wire-sqlc-appclick
|
||||
state: open
|
||||
labels: [type/task, tech/sql]
|
||||
assignees: [naudachu]
|
||||
milestone: v0.2
|
||||
depends: [migrate-schema]
|
||||
origin: gitea
|
||||
gitea: owner/repo#42
|
||||
synced: 2026-08-07T18:40:00Z
|
||||
---
|
||||
# Wire sqlc into the appclick repo layer
|
||||
|
||||
## Summary
|
||||
...
|
||||
|
||||
Keys above `origin:` are owned here. Everything below is written by the sync
|
||||
layer; this module carries those keys through load/save verbatim and never
|
||||
reads them. That passthrough is what lets one file represent both a local
|
||||
issue and a synced one without the domain learning a second vocabulary.
|
||||
|
||||
Every metadata field is one line and lists are inline, so plain grep works
|
||||
without a parser:
|
||||
|
||||
grep -l 'labels:.*type/bug' tmp/issues/*.md
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
|
||||
"""
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# where the store lives
|
||||
# --------------------------------------------------------------------------
|
||||
# `<repo root>/tmp/issues`, absolute, resolved once at import.
|
||||
#
|
||||
# It used to be the relative `tmp/issues`, which made "the store" whatever
|
||||
# directory the shell happened to be standing in. One `cd` — and a `cd` outlives
|
||||
# the command that ran it — was enough for readers to report an empty store on a
|
||||
# full one and for writers to quietly build a second store beside the first.
|
||||
#
|
||||
# The anchor is THIS FILE, not the working directory. A script's own location is
|
||||
# a fact about the installation; cwd is a fact about the last `cd`. Walking up
|
||||
# from __file__ therefore hands every script in both layers the same answer no
|
||||
# matter where it is invoked from — including from inside tmp/issues itself.
|
||||
#
|
||||
# An explicit --out still wins over all of this, and is used exactly as typed: a
|
||||
# relative --out stays relative to cwd, because that is what the operator asked
|
||||
# for. There is no environment override; the store is where the repo is.
|
||||
|
||||
STORE_PARTS = ("tmp", "issues")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None.
|
||||
|
||||
Markers, not a fixed number of `..` hops: how deep this file sits below the
|
||||
root is an implementation detail of the repo layout, and the layout is not
|
||||
a promise."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||
return d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def store_root(start=None):
|
||||
"""Absolute path of the issue store.
|
||||
|
||||
`start` overrides the anchor and exists so the resolution can be exercised
|
||||
against a scratch tree. When these scripts are not inside a repository at
|
||||
all, cwd gets a turn; failing that the historical cwd-relative location
|
||||
stands, made absolute so an error message can name the directory it really
|
||||
looked in."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *STORE_PARTS)
|
||||
return os.path.abspath(os.path.join(*STORE_PARTS))
|
||||
|
||||
|
||||
ISSUE_ROOT = store_root()
|
||||
|
||||
# Domain-owned metadata, in render order. Foreign keys render after these,
|
||||
# sorted, so the sync layer can add fields without touching this list.
|
||||
DOMAIN_KEYS = ["id", "state", "labels", "assignees", "milestone", "depends",
|
||||
"origin"]
|
||||
LIST_KEYS = {"labels", "assignees", "depends"}
|
||||
STATES = ("open", "closed")
|
||||
|
||||
# `origin` is "does this issue exist anywhere but here" — a fact about the
|
||||
# work, so it is owned here. Its value is `local` or a tracker's name; what
|
||||
# that name means, and the handle that goes with it (`gitea: owner/repo#42`),
|
||||
# stay foreign keys this layer carries but never reads.
|
||||
LOCAL = "local"
|
||||
|
||||
# type/* is mandatory and exclusive; severity/* is optional and exclusive;
|
||||
# tech/* and comp/* are free-form. Colors are NOT here — a hex code is how
|
||||
# Gitea paints a chip, which makes it the sync layer's business.
|
||||
TYPES = {
|
||||
"bug": "Something behaves incorrectly in existing code",
|
||||
"task": "Implementation of new functionality",
|
||||
"refactor": "Internal restructuring; behavior must not change",
|
||||
"test": "Writing or fixing tests",
|
||||
"feature": "Container: several issues delivering one unit of business value",
|
||||
"draft": "Idea captured for later; not ready for work",
|
||||
}
|
||||
SEVERITIES = ("low", "medium", "high", "showstopper", "critical")
|
||||
EXCLUSIVE_NS = ("type/", "severity/")
|
||||
|
||||
# Sections every type must carry. type/draft is exempt from acceptance criteria.
|
||||
REQUIRED_SECTIONS = ["## Summary", "## Spec"]
|
||||
AC_SECTION = "## Acceptance criteria"
|
||||
DEPENDS_SECTION = "## Depends on"
|
||||
ISSUES_SECTION = "## Issues"
|
||||
# Both sections name what an issue depends on, so both are edge sources and
|
||||
# both point the same way. In a `type/feature` that reads container -> child:
|
||||
# "the container is closed when its children are closed" IS a dependency.
|
||||
# "a child belongs to a feature" is membership, and membership has no place in
|
||||
# a dependency graph — which is why a child never names its container back.
|
||||
DEP_SECTIONS = (DEPENDS_SECTION, ISSUES_SECTION)
|
||||
# Per-type sections from the templates — absence is a warning, not a stop.
|
||||
EXPECTED_SECTIONS = {
|
||||
"bug": ["## Steps to reproduce", "## Expected", "## Actual", "## Environment"],
|
||||
"task": ["## Motivation"],
|
||||
"refactor": ["## Motivation", "## Invariants"],
|
||||
"test": ["## Motivation", "## Test cases"],
|
||||
"feature": ["## Motivation", ISSUES_SECTION],
|
||||
"draft": ["## Notes"],
|
||||
}
|
||||
|
||||
TITLE_PREFIX = re.compile(
|
||||
r'^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)', re.I)
|
||||
CYRILLIC = re.compile(r'[а-яё]', re.I)
|
||||
SLUG_OK = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# identity
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def slugify(text, maxlen=48):
|
||||
"""Title -> id. Titles are English by format rule, so ASCII is enough;
|
||||
anything else is dropped rather than transliterated."""
|
||||
s = re.sub(r'[^a-z0-9]+', '-', (text or "").lower()).strip("-")
|
||||
if len(s) > maxlen:
|
||||
s = s[:maxlen].rsplit("-", 1)[0] or s[:maxlen]
|
||||
return s.strip("-") or "issue"
|
||||
|
||||
|
||||
def unique_id(root, base, taken=()):
|
||||
"""`base`, or base-2, base-3… when the slug is already used."""
|
||||
used = set(taken) | set(all_ids(root))
|
||||
if base not in used:
|
||||
return base
|
||||
for i in range(2, 1000):
|
||||
cand = "%s-%d" % (base, i)
|
||||
if cand not in used:
|
||||
return cand
|
||||
raise ValueError("cannot allocate an id for %r" % base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# metadata block
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parse_meta(text):
|
||||
"""Split a file into (meta, title, body).
|
||||
|
||||
meta values are strings, or lists for the inline `[a, b]` form. title is
|
||||
the first `# ` heading below the block and is stripped out of body."""
|
||||
meta, rest = {}, text
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
for line in text[3:end].strip().splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
k, v = line.split(":", 1)
|
||||
k, v = k.strip(), v.strip()
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
v = [x.strip() for x in v[1:-1].split(",") if x.strip()]
|
||||
elif k in LIST_KEYS:
|
||||
v = [x.strip() for x in v.split(",") if x.strip()]
|
||||
meta[k] = v
|
||||
rest = text[end + 4:]
|
||||
rest = rest.lstrip("\n")
|
||||
|
||||
title = ""
|
||||
m = re.match(r'^#\s+(.+?)\s*\n', rest)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
rest = rest[m.end():].lstrip("\n")
|
||||
return meta, title, rest
|
||||
|
||||
|
||||
def render_meta(meta):
|
||||
"""Domain keys in DOMAIN_KEYS order, foreign keys after them, sorted.
|
||||
Lists stay on one line so grep sees them whole."""
|
||||
lines = ["---"]
|
||||
foreign = sorted(k for k in meta if k not in DOMAIN_KEYS)
|
||||
for k in DOMAIN_KEYS + foreign:
|
||||
if k not in meta:
|
||||
continue
|
||||
v = meta[k]
|
||||
if isinstance(v, (list, tuple)):
|
||||
v = "[%s]" % ", ".join(str(x) for x in v)
|
||||
lines.append("%s: %s" % (k, v))
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the issue
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Issue(object):
|
||||
"""One unit of work. `extra` holds metadata this layer does not own."""
|
||||
|
||||
def __init__(self, id="", title="", body="", state="open", labels=None,
|
||||
assignees=None, milestone="", depends=None,
|
||||
origin=LOCAL, extra=None):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.state = state or "open"
|
||||
self.labels = list(labels or [])
|
||||
self.assignees = list(assignees or [])
|
||||
self.milestone = milestone or ""
|
||||
self.depends = list(depends or [])
|
||||
self.origin = origin or LOCAL
|
||||
self.extra = dict(extra or {})
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
"""True while this issue exists nowhere but here.
|
||||
|
||||
A complete state, not a pending one — and the state in which this file
|
||||
is the only copy of the work. An issue whose `origin` names somewhere
|
||||
else can be fetched from there again; this one cannot."""
|
||||
return self.origin == LOCAL
|
||||
|
||||
# -- taxonomy views ----------------------------------------------------
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
for l in self.labels:
|
||||
if l.startswith("type/"):
|
||||
return l.split("/", 1)[1]
|
||||
return ""
|
||||
|
||||
@property
|
||||
def severity(self):
|
||||
for l in self.labels:
|
||||
if l.startswith("severity/"):
|
||||
return l.split("/", 1)[1]
|
||||
return ""
|
||||
|
||||
# -- serialization -----------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text, id=None):
|
||||
meta, title, body = parse_meta(text)
|
||||
extra = {k: v for k, v in meta.items() if k not in DOMAIN_KEYS}
|
||||
|
||||
def lst(key):
|
||||
v = meta.get(key) or []
|
||||
return [v] if isinstance(v, str) else list(v)
|
||||
|
||||
ms = meta.get("milestone") or ""
|
||||
return cls(id=id or meta.get("id") or "",
|
||||
title=title, body=body.strip(),
|
||||
state=meta.get("state") or "open",
|
||||
labels=lst("labels"), assignees=lst("assignees"),
|
||||
milestone="" if ms == "none" else ms,
|
||||
depends=lst("depends"),
|
||||
origin=meta.get("origin") or LOCAL, extra=extra)
|
||||
|
||||
def to_text(self):
|
||||
meta = dict(self.extra)
|
||||
meta.update({
|
||||
"id": self.id,
|
||||
"state": self.state,
|
||||
"labels": self.labels,
|
||||
"assignees": self.assignees,
|
||||
"milestone": self.milestone or "none",
|
||||
"depends": self.depends,
|
||||
"origin": self.origin,
|
||||
})
|
||||
body = self.body.strip() or "(no body)"
|
||||
return "%s\n# %s\n\n%s\n" % (render_meta(meta), self.title, body)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# body sections
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def section_body(body, header):
|
||||
"""Text under `header`, up to the next `## ` heading."""
|
||||
out, active = [], False
|
||||
for line in (body or "").splitlines():
|
||||
if line.startswith("## "):
|
||||
if active:
|
||||
break
|
||||
active = line.strip() == header
|
||||
continue
|
||||
if active:
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def body_dep_ref_sections(body):
|
||||
"""[(section, ref)] for every reference under one of DEP_SECTIONS — never
|
||||
from prose, or a graph walk would drag in half the backlog. Refs are
|
||||
whatever was written there (slugs, and `#N` on issues that came from a
|
||||
tracker), deduplicated on first sight.
|
||||
|
||||
The section is carried out with the ref so a caller can name the one the
|
||||
reader actually has in front of them: a container's children come from
|
||||
`## Issues`, and pointing at `## Depends on` would name a section that is
|
||||
not in the file."""
|
||||
out, seen, section = [], set(), ""
|
||||
for line in (body or "").splitlines():
|
||||
if line.startswith("## "):
|
||||
head = line.strip()
|
||||
section = head if head in DEP_SECTIONS else ""
|
||||
continue
|
||||
if not section:
|
||||
continue
|
||||
for tok in re.findall(r'#(\d+)|\b([a-z0-9]+(?:-[a-z0-9]+)+)\b', line):
|
||||
ref = ("#" + tok[0]) if tok[0] else tok[1]
|
||||
if ref not in seen:
|
||||
seen.add(ref)
|
||||
out.append((section, ref))
|
||||
return out
|
||||
|
||||
|
||||
def body_dep_refs(body):
|
||||
"""Just the refs, in order of first appearance."""
|
||||
return [ref for _, ref in body_dep_ref_sections(body)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# checkboxes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# A checkbox is the one part of a body that is *state* and not prose, so the
|
||||
# format gives it markup of its own (references/format.md:163-164). It is item
|
||||
# markup, not a property of one section: `## Acceptance criteria` is the usual
|
||||
# home, but a type/feature keeps its children as checkboxes under `## Issues`
|
||||
# (format.md:275-277). The scan is therefore over the whole text and the
|
||||
# heading is only recorded, never required.
|
||||
CHECKBOX_RE = re.compile(
|
||||
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
|
||||
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
|
||||
# Any list item — a sibling ends the item above it, checkbox or not.
|
||||
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
|
||||
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
|
||||
|
||||
Checkbox = collections.namedtuple(
|
||||
"Checkbox", "index line end_line checked text section")
|
||||
|
||||
|
||||
def checkboxes(text):
|
||||
"""Every checkbox item in `text`, in document order.
|
||||
|
||||
A pure function of the string it is given — no I/O, no store, no tracker.
|
||||
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
|
||||
whole file to get file-relative ones; nothing else changes.
|
||||
|
||||
Returns a list of `Checkbox` namedtuples:
|
||||
|
||||
index 1-based position in this list — what a user types to pick it
|
||||
line 1-based line of the `- [ ]` marker, in the text given
|
||||
end_line 1-based last line of the item, continuation lines included
|
||||
checked True for `[x]` / `[X]`, False for `[ ]`
|
||||
text the item's text; continuation lines joined with one space
|
||||
section nearest preceding `## ` heading, "" above the first one
|
||||
|
||||
Rules:
|
||||
|
||||
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
|
||||
line is part of the item above it, never an item of its own; the item
|
||||
runs to the next blank line, heading, code fence, or list marker.
|
||||
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
|
||||
example of the markup, not a box anybody may tick.
|
||||
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
|
||||
lists are seen too.
|
||||
"""
|
||||
lines = (text or "").splitlines()
|
||||
items, section, fence = [], "", ""
|
||||
for n, line in enumerate(lines, 1):
|
||||
m = FENCE_RE.match(line)
|
||||
if m:
|
||||
tok = m.group(1)
|
||||
if not fence:
|
||||
fence = tok
|
||||
elif tok[0] == fence[0] and len(tok) >= len(fence):
|
||||
fence = ""
|
||||
continue
|
||||
if fence:
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
section = line.strip()
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
section = ""
|
||||
continue
|
||||
m = CHECKBOX_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
end, parts = n, [m.group("text").strip()]
|
||||
for k in range(n, len(lines)): # lines[k] is line number k + 1
|
||||
nxt = lines[k]
|
||||
if (not nxt.strip() or nxt.startswith("#")
|
||||
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
|
||||
break
|
||||
end = k + 1
|
||||
parts.append(nxt.strip())
|
||||
items.append(Checkbox(len(items) + 1, n, end,
|
||||
m.group("box") != " ",
|
||||
" ".join(p for p in parts if p), section))
|
||||
return items
|
||||
|
||||
|
||||
def set_checkbox(text, item, checked=True):
|
||||
"""Return `text` with one checkbox set to `checked`.
|
||||
|
||||
Pure, and deliberately surgical: exactly one character of the input
|
||||
changes — the one between the brackets. Everything else, including
|
||||
trailing whitespace and the item's own wording, comes back byte for byte.
|
||||
That is the whole point of the function: ticking a box must not produce a
|
||||
diff wider than the state that changed.
|
||||
|
||||
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
|
||||
line number will point at the wrong line — or a 1-based line number.
|
||||
Already in the requested state is a no-op: `text` is returned unchanged,
|
||||
and an existing `[X]` keeps its capital.
|
||||
"""
|
||||
line_no = item.line if isinstance(item, Checkbox) else int(item)
|
||||
off = 0
|
||||
for n, raw in enumerate(text.splitlines(True), 1):
|
||||
if n == line_no:
|
||||
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
|
||||
if not m:
|
||||
raise ValueError("line %d is not a checkbox item" % line_no)
|
||||
if (m.group("box") != " ") == bool(checked):
|
||||
return text
|
||||
box = off + m.start("box")
|
||||
return text[:box] + ("x" if checked else " ") + text[box + 1:]
|
||||
off += len(raw)
|
||||
raise ValueError("line %d is past the end of the text" % line_no)
|
||||
|
||||
|
||||
def checkbox_progress(text):
|
||||
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
|
||||
|
||||
Computed on the fly, on purpose. Progress is not a metadata field: it is
|
||||
the body read back, and the body is the only place the state lives."""
|
||||
items = checkboxes(text)
|
||||
return sum(1 for c in items if c.checked), len(items)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# validation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def validate(issue, known_ids=None):
|
||||
"""Return (errors, warnings). Errors mean the issue is not well-formed in
|
||||
the canonical format; warnings mean it deviates from its type template."""
|
||||
err, warn = [], []
|
||||
|
||||
if not issue.id:
|
||||
err.append("no `id:` — the slug is the issue's identity")
|
||||
elif not SLUG_OK.match(issue.id):
|
||||
err.append("id %r is not a slug (lowercase, digits, single dashes)" % issue.id)
|
||||
|
||||
if issue.state not in STATES:
|
||||
err.append("state %r must be one of: %s" % (issue.state, ", ".join(STATES)))
|
||||
|
||||
types = [l for l in issue.labels if l.startswith("type/")]
|
||||
if len(types) != 1:
|
||||
err.append("need exactly one type/* label, found %d: %s"
|
||||
% (len(types), ", ".join(types) or "none"))
|
||||
elif issue.type not in TYPES:
|
||||
err.append("unknown type %r — known: %s" % (issue.type, ", ".join(sorted(TYPES))))
|
||||
if len([l for l in issue.labels if l.startswith("severity/")]) > 1:
|
||||
err.append("at most one severity/* label")
|
||||
if issue.severity and issue.severity not in SEVERITIES:
|
||||
warn.append("unknown severity %r" % issue.severity)
|
||||
|
||||
if not issue.title:
|
||||
err.append("no `# Title` heading below the metadata block")
|
||||
else:
|
||||
if TITLE_PREFIX.match(issue.title):
|
||||
err.append("title carries a type prefix (%r) — the type lives in the label"
|
||||
% issue.title[:24])
|
||||
if CYRILLIC.search(issue.title):
|
||||
err.append("title must be English, imperative mood (prose stays Russian)")
|
||||
|
||||
for h in REQUIRED_SECTIONS:
|
||||
if h not in issue.body:
|
||||
err.append("missing section %s" % h)
|
||||
if issue.type != "draft" and AC_SECTION not in issue.body:
|
||||
err.append("missing section %s" % AC_SECTION)
|
||||
if "## Spec" in issue.body and not section_body(issue.body, "## Spec"):
|
||||
err.append("## Spec is empty — put a repo path, a URL, or the literal `none`")
|
||||
|
||||
for h in EXPECTED_SECTIONS.get(issue.type, []):
|
||||
if h not in issue.body:
|
||||
warn.append("type/%s template usually has %s" % (issue.type, h))
|
||||
|
||||
if issue.id in issue.depends:
|
||||
err.append("depends on itself")
|
||||
if known_ids is not None:
|
||||
for d in issue.depends:
|
||||
if d not in known_ids:
|
||||
warn.append("depends on %r, which is not in the store" % d)
|
||||
|
||||
# `depends:` is the machine-readable graph; the body section is prose for
|
||||
# humans. They drift silently unless something says so. Name the section
|
||||
# the reference actually came from — for a container that is `## Issues`.
|
||||
listed = set(issue.depends)
|
||||
for section, ref in body_dep_ref_sections(issue.body):
|
||||
if not ref.startswith("#") and ref not in listed:
|
||||
warn.append("%s mentions %r but `depends:` does not list it"
|
||||
% (section, ref))
|
||||
|
||||
# An unticked checkbox is never a finding — neither an error nor a
|
||||
# warning. `- [ ]` is work not done yet, which is the normal state of a
|
||||
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
|
||||
|
||||
return err, warn
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# store
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class StoreMissing(Exception):
|
||||
"""The store directory is not there.
|
||||
|
||||
Deliberately a different answer from "the store is empty". One is a path
|
||||
that does not exist, the other is a repository with no issues filed yet, and
|
||||
conflating the two is exactly what made a missed directory look like an
|
||||
empty backlog."""
|
||||
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
Exception.__init__(self, "store %s does not exist" % root)
|
||||
|
||||
|
||||
def store_exists(root):
|
||||
return os.path.isdir(root)
|
||||
|
||||
|
||||
def require_store(root):
|
||||
"""Assert the store is there before reading or writing it."""
|
||||
if not os.path.isdir(root):
|
||||
raise StoreMissing(root)
|
||||
return root
|
||||
|
||||
|
||||
def create_store(root):
|
||||
"""Create the store; True when it actually made the directory.
|
||||
|
||||
Only the commands that legitimately bootstrap a store call this — issue_new
|
||||
and pull — and both announce it. Nothing creates a store as a side effect of
|
||||
a write any more: a missing directory is something to report, not something
|
||||
to conjure."""
|
||||
if os.path.isdir(root):
|
||||
return False
|
||||
os.makedirs(root)
|
||||
return True
|
||||
|
||||
|
||||
def store_error(root):
|
||||
"""Why `root` cannot be read as a store, or None when it holds issues.
|
||||
|
||||
The two messages are distinct on purpose — see StoreMissing."""
|
||||
if not os.path.isdir(root):
|
||||
return ("store %s does not exist — nothing was created; pass --out to "
|
||||
"point elsewhere" % root)
|
||||
if not all_ids(root):
|
||||
return "store %s exists but is empty" % root
|
||||
return None
|
||||
|
||||
|
||||
def path_of(root, id):
|
||||
return os.path.join(root, "%s.md" % id)
|
||||
|
||||
|
||||
def all_ids(root):
|
||||
"""Every issue in the store, by slug.
|
||||
|
||||
An issue file is named by its slug and a slug has no dot in it (SLUG_OK),
|
||||
so `<id>.comments.md` — the thread the sync layer parks beside an issue —
|
||||
is not one, and neither is anything else that grew a second extension.
|
||||
Without that rule `wire-sqlc.comments` reads as an issue called
|
||||
`wire-sqlc.comments`, and a bare `push.py` tries to file the comment thread
|
||||
as a unit of work."""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
return sorted(f[:-3] for f in os.listdir(root)
|
||||
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
|
||||
and "." not in f[:-3])
|
||||
|
||||
|
||||
def slug_files(root, id):
|
||||
"""Every file the store holds under one slug — the issue and its sidecars.
|
||||
|
||||
`<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
|
||||
companion another layer parked there (`<id>.comments.md` is the one that
|
||||
exists today). `all_ids` already refuses to read those as issues because a
|
||||
slug has no dot in it; this is the same rule read the other way round.
|
||||
|
||||
Which is how the domain can remove an issue *completely* without learning
|
||||
what any of those companions are: it does not need to know that a comment
|
||||
thread exists to know that a file named after this issue belongs to it and
|
||||
goes when it goes. The issue's own file comes first — it is the headline of
|
||||
any receipt printed from this list.
|
||||
|
||||
A missing store is an empty list, not an error: nothing is there to remove.
|
||||
"""
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
own, sidecars = [], []
|
||||
for name in sorted(os.listdir(root)):
|
||||
if not name.startswith("%s." % id):
|
||||
continue
|
||||
p = os.path.join(root, name)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
(own if name == "%s.md" % id else sidecars).append(p)
|
||||
return own + sidecars
|
||||
|
||||
|
||||
def load(root, id):
|
||||
with open(path_of(root, id)) as f:
|
||||
return Issue.from_text(f.read(), id=id)
|
||||
|
||||
|
||||
def load_all(root):
|
||||
return {i: load(root, i) for i in all_ids(root)}
|
||||
|
||||
|
||||
def save(root, issue):
|
||||
require_store(root)
|
||||
p = path_of(root, issue.id)
|
||||
with open(p, "w") as f:
|
||||
f.write(issue.to_text())
|
||||
return p
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# dependency graph
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def graph(issues):
|
||||
"""{id: [dep ids]} from the `depends:` metadata — the authoritative edge
|
||||
list. Body prose is never walked."""
|
||||
return {i: list(iss.depends) for i, iss in issues.items()}
|
||||
|
||||
|
||||
def dependents(issues, id):
|
||||
"""Who depends on `id` (the upward direction)."""
|
||||
return sorted(i for i, iss in issues.items() if id in iss.depends)
|
||||
|
||||
|
||||
def topo_order(ids, edges):
|
||||
"""Dependencies first. Cycles are broken deterministically rather than
|
||||
raising: a cycle is a data problem for the caller to report, not a reason
|
||||
to refuse to order the rest."""
|
||||
order, state = [], {}
|
||||
|
||||
def visit(n):
|
||||
if state.get(n) == "done":
|
||||
return
|
||||
if state.get(n) == "open":
|
||||
return # cycle — leave the back edge unresolved
|
||||
state[n] = "open"
|
||||
for d in edges.get(n, []):
|
||||
if d in edges:
|
||||
visit(d)
|
||||
state[n] = "done"
|
||||
order.append(n)
|
||||
|
||||
for n in ids:
|
||||
visit(n)
|
||||
return order
|
||||
|
||||
|
||||
def find_cycles(edges):
|
||||
"""List of id lists, one per cycle found. Empty when the graph is a DAG."""
|
||||
cycles, state, stack = [], {}, []
|
||||
|
||||
def visit(n):
|
||||
state[n] = "open"
|
||||
stack.append(n)
|
||||
for d in edges.get(n, []):
|
||||
if d not in edges:
|
||||
continue
|
||||
if state.get(d) == "open":
|
||||
cycles.append(stack[stack.index(d):] + [d])
|
||||
elif d not in state:
|
||||
visit(d)
|
||||
stack.pop()
|
||||
state[n] = "done"
|
||||
|
||||
for n in edges:
|
||||
if n not in state:
|
||||
visit(n)
|
||||
return cycles
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
|
||||
|
||||
issue_ac.py wire-sqlc-appclick numbered list with state
|
||||
issue_ac.py wire-sqlc-appclick --check 3 by number
|
||||
issue_ac.py wire-sqlc-appclick --check регресс by substring
|
||||
issue_ac.py wire-sqlc-appclick --uncheck 3
|
||||
|
||||
A checkbox is the one part of a body that is *state* and not prose. Everything
|
||||
else is written once; boxes get ticked as the work goes, and until now the only
|
||||
ways to tick one were a human with an editor or a model rewriting the whole
|
||||
body — the second worse than the first, because the rewrite re-flows the text
|
||||
and the issue's diff swells around a change of one character. This changes that
|
||||
one character and nothing else.
|
||||
|
||||
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
|
||||
in the body is listed and tickable: a type/feature keeps its children under
|
||||
`## Issues`, and binding this to one heading would silently lose half of them.
|
||||
|
||||
A substring picks an item only when it picks exactly one. Two matches is an
|
||||
error listing both — a coin flip would tick the wrong box and look like it
|
||||
worked.
|
||||
|
||||
Delivering the changed body to a tracker is not part of this: that is
|
||||
`push.py --update` in /tea:sync.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
NUMBER = re.compile(r'^\d+$')
|
||||
|
||||
|
||||
def box(c):
|
||||
return "[x]" if c.checked else "[ ]"
|
||||
|
||||
|
||||
def listing(items):
|
||||
"""The numbered list, grouped by the heading each item sits under."""
|
||||
out, section = [], None
|
||||
for c in items:
|
||||
if c.section != section:
|
||||
section = c.section
|
||||
out.append("")
|
||||
out.append(section or "(above the first heading)")
|
||||
out.append(" %2d %s %s" % (c.index, box(c), c.text))
|
||||
return out
|
||||
|
||||
|
||||
def select(items, needle):
|
||||
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
|
||||
needle = (needle or "").strip()
|
||||
if not needle:
|
||||
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
|
||||
if NUMBER.match(needle):
|
||||
n = int(needle)
|
||||
if not 1 <= n <= len(items):
|
||||
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
|
||||
return items[n - 1]
|
||||
hits = [c for c in items if needle.lower() in c.text.lower()]
|
||||
if not hits:
|
||||
sys.exit("issue_ac.py: nothing matches %r" % needle)
|
||||
if len(hits) > 1:
|
||||
sys.exit("\n".join(
|
||||
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
|
||||
% (needle, len(hits))]
|
||||
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
|
||||
return hits[0]
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="List and tick an issue's checkboxes (offline)")
|
||||
ap.add_argument("id", help="issue id (the slug, without .md)")
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
|
||||
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
path = issue.path_of(args.out, args.id)
|
||||
if not os.path.exists(path):
|
||||
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
|
||||
# newline="": no translation in either direction. Byte-for-byte means the
|
||||
# line endings too — reading a CRLF file in text mode and writing it back
|
||||
# would rewrite every line while claiming to have changed one character.
|
||||
with open(path, newline="") as f:
|
||||
text = f.read()
|
||||
|
||||
# The whole file, not just the body: line numbers then point at the file,
|
||||
# and the metadata block is rewritten by nobody. Round-tripping through
|
||||
# Issue.to_text() would re-render metadata and re-strip the body, which is
|
||||
# exactly the byte-level churn this script exists to avoid.
|
||||
items = issue.checkboxes(text)
|
||||
needle = args.check if args.check is not None else args.uncheck
|
||||
|
||||
if not items:
|
||||
if needle is not None:
|
||||
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
|
||||
print("%s — no checkboxes" % args.id)
|
||||
return 0
|
||||
|
||||
if needle is None:
|
||||
done = sum(1 for c in items if c.checked)
|
||||
print("%s — %d/%d %s" % (args.id, done, len(items), path))
|
||||
print("\n".join(listing(items)))
|
||||
return 0
|
||||
|
||||
checked = args.check is not None
|
||||
item = select(items, needle)
|
||||
new = issue.set_checkbox(text, item, checked)
|
||||
verb = "checked" if checked else "unchecked"
|
||||
if new == text:
|
||||
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
|
||||
return 0
|
||||
|
||||
with open(path, "w", newline="") as f:
|
||||
f.write(new)
|
||||
issue_index.build(args.out)
|
||||
|
||||
done, total = issue.checkbox_progress(new)
|
||||
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
|
||||
print("%s — %d/%d %s:%d" % (args.id, done, total, path, item.line))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_check.py — validate issues against the canonical format. Offline.
|
||||
|
||||
The same check the sync layer runs before it pushes anything, available on its
|
||||
own so a local-only issue can be held to the format without a tracker being
|
||||
involved. Errors mean malformed; warnings mean it deviates from its type's
|
||||
template or its graph looks suspect.
|
||||
|
||||
issue_check.py every issue in the store
|
||||
issue_check.py wire-sqlc one issue
|
||||
issue_check.py --quiet exit code only (0 clean, 1 errors)
|
||||
|
||||
Format reference: ../references/format.md
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Validate local issues (offline)")
|
||||
ap.add_argument("ids", nargs="*", help="ids to check (default: all)")
|
||||
ap.add_argument("--quiet", action="store_true", help="exit code only")
|
||||
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
problem = issue.store_error(args.out)
|
||||
if problem:
|
||||
sys.exit("issue_check.py: %s" % problem)
|
||||
|
||||
issues = issue.load_all(args.out)
|
||||
ids = args.ids or sorted(issues)
|
||||
for i in ids:
|
||||
if i not in issues:
|
||||
sys.exit("issue_check.py: no issue %r in %s" % (i, args.out))
|
||||
|
||||
known = set(issues)
|
||||
bad = 0
|
||||
for i in ids:
|
||||
err, warn = issue.validate(issues[i], known_ids=known)
|
||||
if args.strict:
|
||||
err, warn = err + warn, []
|
||||
if err:
|
||||
bad += 1
|
||||
if args.quiet:
|
||||
continue
|
||||
if not err and not warn:
|
||||
print("ok %s" % i)
|
||||
continue
|
||||
for e in err:
|
||||
print("ERROR %s: %s" % (i, e))
|
||||
for w in warn:
|
||||
print("warn %s: %s" % (i, w))
|
||||
|
||||
for c in issue.find_cycles(issue.graph(issues)):
|
||||
bad += 1
|
||||
if not args.quiet:
|
||||
print("ERROR cycle: %s" % " -> ".join(c))
|
||||
|
||||
if not args.quiet:
|
||||
print("%d issue(s) checked, %d with errors" % (len(ids), bad))
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_evict.py — closed issues leave the store. Offline.
|
||||
|
||||
issue_evict.py every closed issue that is not origin: local
|
||||
issue_evict.py old-thing … only these
|
||||
issue_evict.py --dry-run print what would go; touch nothing
|
||||
|
||||
The store is a working set, not an archive. A closed issue is not a unit of
|
||||
work any more, and `pull.py` has kept new ones out of filter mode for a while —
|
||||
but the files already on disk were nobody's job, so the only way to remove one
|
||||
was `rm` past every script, followed by rebuilding `INDEX.md` by hand. This is
|
||||
that job.
|
||||
|
||||
WHAT IS EVICTED, and it is two conditions, both read off the file:
|
||||
|
||||
state: closed the work is done
|
||||
origin: <tracker> the work is somewhere else too
|
||||
|
||||
TWO CONDITIONS, AND THE SECOND ONE IS THE WHOLE SAFETY ARGUMENT. `origin:
|
||||
local` means this file IS the issue — there is no other copy and deleting it
|
||||
deletes the work. It is therefore never evicted, in any state, not even when
|
||||
named explicitly on the command line: a closed local issue is reported and
|
||||
kept. The only files that go are ones whose own metadata says the work can be
|
||||
fetched back (`pull.py <n>`), which is the same trade `push.py` makes when it
|
||||
drops a file the tracker has just confirmed.
|
||||
|
||||
That parallel is exact except for where the confirmation comes from. Push has
|
||||
to ask Gitea, because it is Gitea that just changed. Eviction asks the file,
|
||||
because `state:` and `origin:` are domain fields and the answer is already in
|
||||
the store — which is why this command lives in the domain layer and needs no
|
||||
network, no login, and no `tea`. See `skills/sync/scripts/evict.py` for the
|
||||
variant that refreshes `state:` from the tracker first; it makes the deletion
|
||||
decision by calling `run()` below, so there is exactly one implementation of
|
||||
"what may be evicted" and it is this one.
|
||||
|
||||
NOT A ONE-OFF MIGRATION. `pull.py <n>` fetches an issue in any state — a number
|
||||
is an address, not a query — so a closed issue pulled after an eviction lands on
|
||||
disk again. That is the tracker being asked a direct question, not a regression,
|
||||
and the answer is to evict again when you are done with it.
|
||||
|
||||
`.remote.json` is deliberately NOT pruned. It is the local number -> slug
|
||||
ledger, its entries outlive the files they name (that is what makes `pull.py
|
||||
<n>` land on the same slug after a push deleted the file), and an evicted issue
|
||||
is in exactly that state. `INDEX.md` is rebuilt, because it *is* a view of the
|
||||
directory.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
CLOSED = "closed"
|
||||
|
||||
# Why an issue was kept, in the receipt. `LOCAL_REASON` is the one that matters:
|
||||
# it is printed whether or not the issue was named, because "this closed thing
|
||||
# is still here" needs an answer every time.
|
||||
LOCAL_REASON = "origin: %s — this file IS the issue" % issue.LOCAL
|
||||
|
||||
|
||||
def classify(issues, ids=None):
|
||||
"""Split the store into (evict, protected, still_open).
|
||||
|
||||
Pure — it reads the loaded issues and decides; nothing here touches disk.
|
||||
|
||||
evict closed, and lives in a tracker too: safe to remove
|
||||
protected closed, but `origin: local`: the only copy of the work
|
||||
still_open not closed
|
||||
|
||||
`ids` restricts the question to those issues; without it the whole store is
|
||||
considered. A protected issue is returned as such even when it was named
|
||||
explicitly — naming a file does not make deleting it safe.
|
||||
"""
|
||||
chosen = list(ids) if ids else sorted(issues)
|
||||
evict, protected, still_open = [], [], []
|
||||
for id in chosen:
|
||||
iss = issues[id]
|
||||
if iss.state != CLOSED:
|
||||
still_open.append(id)
|
||||
elif iss.is_local:
|
||||
protected.append(id)
|
||||
else:
|
||||
evict.append(id)
|
||||
return evict, protected, still_open
|
||||
|
||||
|
||||
def remove(root, id):
|
||||
"""Delete everything the store holds under one slug; return the paths.
|
||||
|
||||
Deliberately dumb, and for the same reason `push.drop_local` is: it takes an
|
||||
id, not a decision. Whether an issue may go is settled by `classify` before
|
||||
this is reached, so the dangerous half of the operation has no branches in
|
||||
it at all. There is exactly one call site.
|
||||
"""
|
||||
gone = []
|
||||
for p in issue.slug_files(root, id):
|
||||
os.remove(p)
|
||||
gone.append(p)
|
||||
return gone
|
||||
|
||||
|
||||
def run(root, issues, ids=None, dry_run=False, out=None):
|
||||
"""Classify, report, remove, rebuild the index. Returns (gone, kept).
|
||||
|
||||
The one implementation of eviction, called both by `main` below and by the
|
||||
sync layer's `evict.py` — which does nothing to this decision except hand
|
||||
over issues whose `state:` it has just refreshed from the tracker.
|
||||
|
||||
`gone` is {id: [paths]} and is empty on a dry run; `kept` is
|
||||
[(id, why)] for everything considered and not removed.
|
||||
"""
|
||||
out = out or sys.stdout
|
||||
evict, protected, still_open = classify(issues, ids)
|
||||
|
||||
gone, kept = {}, []
|
||||
for id in evict:
|
||||
paths = issue.slug_files(root, id) if dry_run else remove(root, id)
|
||||
if not dry_run:
|
||||
gone[id] = paths
|
||||
out.write("%-11s %s\n" % ("would evict" if dry_run else "evicted", id))
|
||||
for p in paths:
|
||||
out.write(" %s\n" % p)
|
||||
for id in protected:
|
||||
kept.append((id, LOCAL_REASON))
|
||||
out.write("%-11s %s closed, %s\n" % ("kept", id, LOCAL_REASON))
|
||||
# An open issue is the normal case and says nothing worth a line — unless
|
||||
# the operator named it, in which case they are owed the reason.
|
||||
for id in still_open:
|
||||
kept.append((id, "state: %s" % issues[id].state))
|
||||
if ids:
|
||||
out.write("%-11s %s state: %s\n" % ("kept", id, issues[id].state))
|
||||
|
||||
if dry_run:
|
||||
out.write("%d issue(s) would be evicted, %d kept — nothing was touched\n"
|
||||
% (len(evict), len(kept)))
|
||||
return gone, kept
|
||||
|
||||
out.write("%d issue(s) evicted, %d kept\n" % (len(gone), len(kept)))
|
||||
# Only when something actually went: the index is a view of the directory,
|
||||
# and rewriting it after a run that changed nothing is a write nobody asked
|
||||
# for.
|
||||
if gone:
|
||||
path, n = issue_index.build(root)
|
||||
out.write("index: %s — %d issue(s)\n" % (path, n))
|
||||
return gone, kept
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Evict closed issues from the local store (offline)")
|
||||
ap.add_argument("ids", nargs="*",
|
||||
help="issue ids (default: every closed issue in the store)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print what would be removed; touch nothing")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
sys.exit("issue_evict.py: store %s does not exist — nothing to evict" % root)
|
||||
|
||||
issues = issue.load_all(root)
|
||||
missing = [i for i in args.ids if i not in issues]
|
||||
if missing:
|
||||
sys.exit("issue_evict.py: no such issue(s) in the store: %s"
|
||||
% ", ".join(missing))
|
||||
|
||||
run(root, issues, args.ids, args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_index.py — rebuild tmp/issues/INDEX.md from what is on disk. Offline.
|
||||
|
||||
A map of the local store, nothing else. The `origin` column is the only place
|
||||
the index acknowledges that a tracker exists: `local` means the issue has never
|
||||
left this machine, `gitea` means the sync layer has pushed or pulled it. Both
|
||||
are ordinary issues here.
|
||||
|
||||
The store is <repo root>/tmp/issues unless --out says otherwise; an existing
|
||||
store with nothing in it gets an "_empty_" table, a store that is not there is
|
||||
an error rather than a directory to create.
|
||||
|
||||
Usage:
|
||||
issue_index.py [--out DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
def cell(v):
|
||||
if isinstance(v, (list, tuple)):
|
||||
return ", ".join(str(x) for x in v) or "—"
|
||||
v = str(v or "").strip()
|
||||
return v.replace("|", "\\|") or "—"
|
||||
|
||||
|
||||
def progress(body):
|
||||
"""`3/7` for a body with checkboxes, "" for one without.
|
||||
|
||||
Counted from the body every time the index is built and stored nowhere —
|
||||
the boxes are the state, and a second copy of it in a metadata field would
|
||||
be wrong by the next edit."""
|
||||
done, total = issue.checkbox_progress(body)
|
||||
return "%d/%d" % (done, total) if total else ""
|
||||
|
||||
|
||||
def build(root):
|
||||
# An index of a store that is not there is not an empty index, it is a bad
|
||||
# path. Raising beats writing INDEX.md into a directory nobody asked for.
|
||||
issue.require_store(root)
|
||||
issues = issue.load_all(root)
|
||||
rows = []
|
||||
for i in sorted(issues):
|
||||
iss = issues[i]
|
||||
rest = [l for l in iss.labels if not l.startswith("type/")]
|
||||
rows.append({
|
||||
"id": i,
|
||||
"state": cell(iss.state),
|
||||
"progress": progress(iss.body),
|
||||
"type": cell(iss.type),
|
||||
"labels": cell(rest),
|
||||
"title": cell(iss.title),
|
||||
"milestone": cell(iss.milestone),
|
||||
"depends": cell(iss.depends),
|
||||
"origin": cell(iss.origin),
|
||||
})
|
||||
|
||||
listing = os.listdir(root) if os.path.isdir(root) else []
|
||||
trees = sorted(f for f in listing if re.match(r'^tree-.+\.md$', f))
|
||||
|
||||
out = ["# Issue store", "",
|
||||
"Every issue this project knows about. `origin: local` means it "
|
||||
"exists nowhere else — a complete state, not a pending one. Any "
|
||||
"other value names the tracker it also lives in; the handle is in "
|
||||
"the file. `progress` counts the body's checkboxes, ticked over "
|
||||
"total, and is blank for an issue that has none — read off the "
|
||||
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
|
||||
"tick a box with `issue_ac.py`.", ""]
|
||||
if rows:
|
||||
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
|
||||
"|---|---|---|---|---|---|---|---|---|"]
|
||||
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
|
||||
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
|
||||
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
|
||||
else:
|
||||
out.append("_empty_")
|
||||
|
||||
if trees:
|
||||
out += ["", "## Dependency trees", ""]
|
||||
out += ["- [%s](%s)" % (t, t) for t in trees]
|
||||
|
||||
cycles = issue.find_cycles(issue.graph(issues))
|
||||
if cycles:
|
||||
out += ["", "## Dependency cycles", ""]
|
||||
out += ["- %s" % " -> ".join(c) for c in cycles]
|
||||
|
||||
out.append("")
|
||||
path = os.path.join(root, "INDEX.md")
|
||||
with open(path, "w") as f:
|
||||
f.write("\n".join(out))
|
||||
return path, len(rows)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Rebuild the local issue index (offline)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
# An existing store with nothing in it is a legitimate thing to index — it
|
||||
# gets an "_empty_" table. A store that is not there is not.
|
||||
try:
|
||||
path, n = build(args.out)
|
||||
except issue.StoreMissing as e:
|
||||
sys.exit("issue_index.py: %s — nothing was created; create an issue with "
|
||||
"issue_new.py, or pass --out" % e)
|
||||
print("%s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_new.py — create an issue in the local store. Offline, always.
|
||||
|
||||
The issue is real the moment this writes the file. Nothing is pending, nothing
|
||||
is a draft awaiting a tracker: `origin: local` is a complete state and pushing
|
||||
it to Gitea later (see /tea:sync) is optional.
|
||||
|
||||
While it says `local`, this file is the ONLY copy of the work — the store, not
|
||||
a cache of anything. That is what a push changes: it hands the issue to the
|
||||
tracker and removes the file.
|
||||
|
||||
issue_new.py --type task --title "Wire sqlc into the appclick repo layer" \
|
||||
--label tech/sql --label comp/appclick
|
||||
|
||||
issue_new.py --type bug --title "Fix tea-guard crash on empty settings" \
|
||||
--depends wire-sqlc-appclick --milestone v0.2
|
||||
|
||||
Writes tmp/issues/<slug>.md prefilled with the type's template, prints the
|
||||
path, and rebuilds INDEX.md. Fill the sections in an editor or with Edit; run
|
||||
issue_check.py when done.
|
||||
|
||||
Body prose is Russian, section headers and the title are English — see
|
||||
../references/format.md.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
|
||||
SPEC = """## Spec
|
||||
none
|
||||
"""
|
||||
|
||||
TEMPLATES = {
|
||||
"bug": """## Summary
|
||||
Что сломано и где проявляется, одно-два предложения.
|
||||
|
||||
""" + SPEC + """
|
||||
## Steps to reproduce
|
||||
1. …
|
||||
2. …
|
||||
|
||||
## Expected
|
||||
Что должно было произойти.
|
||||
|
||||
## Actual
|
||||
Что происходит на самом деле: вывод команды, лог.
|
||||
|
||||
## Environment
|
||||
Только релевантное: версии, ОС, конфигурация.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] баг не воспроизводится по шагам выше
|
||||
- [ ] добавлена проверка на регрессию (если применимо)
|
||||
""",
|
||||
"task": """## Summary
|
||||
Что нужно сделать, одно-два предложения.
|
||||
|
||||
""" + SPEC + """
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие
|
||||
- [ ] …
|
||||
""",
|
||||
"refactor": """## Summary
|
||||
Что перестраиваем и в каких файлах (`path/file:line`).
|
||||
|
||||
""" + SPEC + """
|
||||
## Motivation
|
||||
Чем плохо текущее состояние: дублирование, связность, читаемость.
|
||||
|
||||
## Invariants
|
||||
Что НЕ должно измениться: поведение, публичные API, форматы данных.
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] проверяемое условие (тесты зелёные, старый путь удалён, …)
|
||||
""",
|
||||
"test": """## Summary
|
||||
Что покрываем тестами и где (`path/file:line`).
|
||||
|
||||
""" + SPEC + """
|
||||
## Motivation
|
||||
Зачем: регрессия после бага, пробел в покрытии, флаки-тест.
|
||||
|
||||
## Test cases
|
||||
- сценарий → ожидаемый результат
|
||||
- …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] перечисленные кейсы покрыты и зелёные
|
||||
- [ ] тесты проходят в CI
|
||||
""",
|
||||
"feature": """## Summary
|
||||
Бизнес-ценность одним-двумя предложениями.
|
||||
|
||||
""" + SPEC + """
|
||||
## Motivation
|
||||
Какую проблему пользователя/системы это решает.
|
||||
|
||||
## Issues
|
||||
- [ ] slug-дочернего-issue — краткое описание части
|
||||
- [ ] …
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] все дочерние issues закрыты
|
||||
- [ ] проверяемое условие уровня фичи
|
||||
""",
|
||||
"draft": """## Summary
|
||||
Идея одним-двумя предложениями.
|
||||
|
||||
""" + SPEC + """
|
||||
## Notes
|
||||
Свободные заметки: что известно, открытые вопросы, варианты.
|
||||
""",
|
||||
}
|
||||
|
||||
DEPENDS_BLOCK = """## Depends on
|
||||
%s
|
||||
"""
|
||||
|
||||
|
||||
def with_depends(body, depends):
|
||||
"""Insert `## Depends on` right after `## Spec`, per the format."""
|
||||
if not depends:
|
||||
return body
|
||||
block = DEPENDS_BLOCK % "\n".join("- %s" % d for d in depends)
|
||||
lines, out, placed = body.splitlines(True), [], False
|
||||
for line in lines:
|
||||
if not placed and line.startswith("## ") and not line.startswith("## Summary") \
|
||||
and not line.startswith("## Spec") and out:
|
||||
out.append(block + "\n")
|
||||
placed = True
|
||||
out.append(line)
|
||||
if not placed:
|
||||
out.append("\n" + block)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Create a local issue from its type template")
|
||||
ap.add_argument("--type", required=True, choices=sorted(issue.TYPES),
|
||||
help="issue type (becomes the exclusive type/* label)")
|
||||
ap.add_argument("--title", required=True, help="English, imperative, no type prefix")
|
||||
ap.add_argument("--id", help="slug (default: derived from the title)")
|
||||
ap.add_argument("--label", action="append", default=[],
|
||||
help="extra label, e.g. tech/sql; repeat")
|
||||
ap.add_argument("--severity", choices=list(issue.SEVERITIES), help="severity/* label")
|
||||
ap.add_argument("--milestone", default="", help="milestone title")
|
||||
ap.add_argument("--assignee", action="append", default=[], help="assignee; repeat")
|
||||
ap.add_argument("--depends", action="append", default=[],
|
||||
help="id this issue depends on; repeat")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
labels = ["type/%s" % args.type]
|
||||
if args.severity:
|
||||
labels.append("severity/%s" % args.severity)
|
||||
labels += [l for l in args.label if l not in labels]
|
||||
|
||||
id = args.id or issue.unique_id(args.out, issue.slugify(args.title))
|
||||
if args.id and not issue.SLUG_OK.match(args.id):
|
||||
sys.exit("issue_new.py: --id %r is not a slug (lowercase, digits, single dashes)"
|
||||
% args.id)
|
||||
if os.path.exists(issue.path_of(args.out, id)):
|
||||
sys.exit("issue_new.py: %s already exists" % issue.path_of(args.out, id))
|
||||
|
||||
known = set(issue.all_ids(args.out))
|
||||
for d in args.depends:
|
||||
if d not in known:
|
||||
sys.stderr.write("warning: depends on %r, which is not in the store yet\n" % d)
|
||||
|
||||
iss = issue.Issue(
|
||||
id=id, title=args.title,
|
||||
body=with_depends(TEMPLATES[args.type], args.depends),
|
||||
labels=labels, assignees=args.assignee, milestone=args.milestone,
|
||||
depends=args.depends)
|
||||
|
||||
# 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.
|
||||
if issue.create_store(args.out):
|
||||
sys.stderr.write("created store %s\n" % os.path.abspath(args.out))
|
||||
|
||||
path = issue.save(args.out, iss)
|
||||
issue_index.build(args.out)
|
||||
print("%s [type/%s] %s" % (path, args.type, args.title))
|
||||
print("fill the sections, then: issue_check.py %s" % id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
issue_tree.py — draw the dependency graph of the local store. Offline.
|
||||
|
||||
Edges come from the `depends:` metadata, which is the authoritative edge list;
|
||||
prose in the body is never walked. Because the graph is slugs all the way down,
|
||||
this works identically for issues that were never pushed anywhere.
|
||||
|
||||
issue_tree.py every root (nothing depends on it)
|
||||
issue_tree.py wire-sqlc-appclick one subtree
|
||||
issue_tree.py --depth 2 --write
|
||||
|
||||
Downwards is what this draws (what an issue depends on). The other direction is
|
||||
a grep, not a flag:
|
||||
|
||||
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import issue # noqa: E402
|
||||
|
||||
|
||||
def label(id, issues, seen, edges):
|
||||
iss = issues.get(id)
|
||||
if not iss:
|
||||
return "%s (not in the store)" % id
|
||||
tail = " (see above)" if id in seen and edges.get(id) else ""
|
||||
return "%s [%s] %s — %s %s.md%s" % (
|
||||
id, iss.type or "-", iss.title, iss.state, id, tail)
|
||||
|
||||
|
||||
def render(roots, issues, edges, depth):
|
||||
lines, seen = [], set()
|
||||
|
||||
def walk(id, prefix, is_last, is_root, level):
|
||||
connector = "" if is_root else ("└── " if is_last else "├── ")
|
||||
lines.append(prefix + connector + label(id, issues, seen, edges))
|
||||
if id in seen or level >= depth:
|
||||
return
|
||||
seen.add(id)
|
||||
kids = edges.get(id) or []
|
||||
child_prefix = prefix if is_root else prefix + (" " if is_last else "│ ")
|
||||
for i, k in enumerate(kids):
|
||||
walk(k, child_prefix, i == len(kids) - 1, False, level + 1)
|
||||
|
||||
for r in roots:
|
||||
if r in seen:
|
||||
continue # already drawn as somebody's child — one tree, not two
|
||||
walk(r, "", True, True, 0)
|
||||
lines.append("")
|
||||
|
||||
head = roots[0] if len(roots) == 1 else "%d root(s)" % len(roots)
|
||||
out = "# Dependency tree — %s\n\n```\n%s```\n" % (head, "\n".join(lines))
|
||||
cycles = issue.find_cycles(edges)
|
||||
if cycles:
|
||||
out += "\n## Cycles\n\n" + "\n".join("- %s" % " -> ".join(c) for c in cycles) + "\n"
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Draw the local dependency graph (offline)")
|
||||
ap.add_argument("ids", nargs="*", help="roots (default: issues nothing depends on)")
|
||||
ap.add_argument("--depth", type=int, default=6, help="max depth (default: 6)")
|
||||
ap.add_argument("--write", action="store_true",
|
||||
help="also write tmp/issues/tree-<slug>.md")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
problem = issue.store_error(args.out)
|
||||
if problem:
|
||||
sys.exit("issue_tree.py: %s" % problem)
|
||||
|
||||
issues = issue.load_all(args.out)
|
||||
edges = issue.graph(issues)
|
||||
|
||||
roots = args.ids
|
||||
for r in roots:
|
||||
if r not in issues:
|
||||
sys.exit("issue_tree.py: no issue %r in %s" % (r, args.out))
|
||||
if not roots:
|
||||
depended_on = {d for deps in edges.values() for d in deps}
|
||||
roots = sorted(i for i in issues if i not in depended_on) or sorted(issues)
|
||||
|
||||
text = render(roots, issues, edges, args.depth)
|
||||
sys.stdout.write(text)
|
||||
if args.write:
|
||||
slug = roots[0] if len(roots) == 1 else "all"
|
||||
path = os.path.join(args.out, "tree-%s.md" % slug)
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
print("written: %s" % path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,537 @@
|
||||
---
|
||||
name: sync
|
||||
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments, close and reopen them. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, comment on one, or close/reopen one. Working with an issue's content (writing, grepping, validating, dependency graph) is /tea:issue and needs no network.
|
||||
---
|
||||
|
||||
# /tea:sync — the bridge between the local store and Gitea
|
||||
|
||||
One job: translate between `tmp/issues/<id>.md` and Gitea's JSON, and carry the
|
||||
result over the wire. Everything about **what an issue is** — format, types,
|
||||
validation, the dependency graph — belongs to `/tea:issue` and is imported from
|
||||
there, never redefined here.
|
||||
|
||||
Direction of knowledge, and it is one-way:
|
||||
|
||||
```
|
||||
skills/issue domain what an issue is offline, no tracker
|
||||
▲
|
||||
│ imports
|
||||
skills/sync bridge map.py md <-> Gitea JSON, pure, no I/O
|
||||
_gitea.py login, tea api, pagination, filters
|
||||
```
|
||||
|
||||
`skills/issue` never imports anything from here.
|
||||
|
||||
## Never read an issue through raw `tea`
|
||||
|
||||
`tea issues <n> -o json` and `tea api .../issues/<n>` dump the full payload —
|
||||
avatars, nested user objects, every comment body — into your context whether
|
||||
you need it or not. Use `pull.py`: it writes flat markdown and prints a compact
|
||||
index.
|
||||
|
||||
## Scripts
|
||||
|
||||
In `<skill-base-dir>/scripts/`. None of them take `--login`: they resolve the
|
||||
operator's pin from `.claude/settings.local.json` through
|
||||
`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls,
|
||||
not merely the same file, so a directory where `tea` works is a directory where
|
||||
these work. That includes a **git worktree**, whose untracked pin sits in the
|
||||
main checkout: the search crosses to it through the `gitdir:` in `.git`, and
|
||||
there is nothing to pin a second time. No pin anywhere → exit with a pointer to
|
||||
`/tea:auth`.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `remote.py [--state] [--label] [--milestone] [-q TEXT] [--limit N]` | discovery: one line per Gitea issue to stdout, writes nothing; `--limit` caps the **listing** (default 30) |
|
||||
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT [--limit N]` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty; follows dependencies by default (`--no-deps` to stop); `--limit` caps what is **stored** (default 100) |
|
||||
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
|
||||
| `evict.py [id…] [--dry-run]` | refresh `state:` from Gitea, then evict the issues it reports closed; `origin: local` is never asked about and never removed |
|
||||
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
|
||||
| `close.py <id…> [--reopen] [--dry-run]` | set `state` in Gitea and in the local copy with it; explicit ids only, no bulk filter |
|
||||
| `labels.py [--dry-run] [--fix]` | bootstrap the canonical `type/*` + `severity/*` set in a repo; exact names left alone, lookalikes reported, drift fixed only with `--fix` |
|
||||
| `map.py`, `_gitea.py` | the two layers the commands import — not commands |
|
||||
|
||||
Key forms for `<key>`: `42`, `#42`, `owner/repo#42`, or a full issue URL. Repo
|
||||
defaults to the current directory's git remote; add `--repo owner/repo` outside
|
||||
one.
|
||||
|
||||
`--out` defaults to `issue.ISSUE_ROOT` on every one of them — the domain layer's
|
||||
`<repo root>/tmp/issues`, resolved from the scripts' own location rather than
|
||||
cwd. Both layers therefore address the same store by construction, from any
|
||||
directory. Pass `--out` to override; a relative one stays relative to cwd. Only
|
||||
`pull.py` will create a missing store, and it says so on stderr.
|
||||
|
||||
## Identity mapping
|
||||
|
||||
The local id is a slug; Gitea's is a number. While a working copy exists, the
|
||||
pair is in the file:
|
||||
|
||||
```
|
||||
origin: gitea
|
||||
gitea: claude-skills/tea#42
|
||||
url: https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
synced: 2026-08-09T18:40:00Z
|
||||
```
|
||||
|
||||
But the file is deleted on push, so the pair also lives in two places that
|
||||
outlast it: `tmp/issues/.remote.json` (number → slug) and the `<!-- tea:id … -->`
|
||||
marker in the issue body on the Gitea side. See [How the slug comes
|
||||
back](#how-the-slug-comes-back).
|
||||
|
||||
`.remote.json` used to be described as an index over the files. It is not one
|
||||
any more — the files are a subset of what it knows, and its entries deliberately
|
||||
outlive them. It is the local **ledger**, and `_gitea.rebuild_map` merges into it
|
||||
rather than reconstructing it, so a rebuild can never drop a pushed issue.
|
||||
Nothing prunes it: "no file" no longer means "no such issue". Delete it anyway
|
||||
and nothing is lost — the next pull reads the slug off the marker and writes the
|
||||
entry back.
|
||||
|
||||
A retitled issue keeps its slug: neither record is keyed by the title.
|
||||
|
||||
## Pulling
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/pull.py 42
|
||||
python3 <skill-base-dir>/scripts/pull.py --milestone 6 # id or title
|
||||
python3 <skill-base-dir>/scripts/pull.py --label type/bug --state all
|
||||
python3 <skill-base-dir>/scripts/pull.py -q sqlc --limit 20
|
||||
python3 <skill-base-dir>/scripts/pull.py 40 --no-deps # this issue only
|
||||
```
|
||||
|
||||
Do not loop over numbers to pull a group — pass the filter. The list endpoint
|
||||
carries the issue bodies, so a milestone costs **one request per 50 issues**,
|
||||
not one per issue. Filters AND together; `--state` defaults to `open`;
|
||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
||||
|
||||
**A pull is how a pushed issue comes back.** Push deleted the file, so this is
|
||||
not refreshing a copy you kept — it is how the copy comes to exist. It lands
|
||||
under the same slug it had before, even after a rename in Gitea and even on a
|
||||
machine that has never seen the issue; see [How the slug comes
|
||||
back](#how-the-slug-comes-back).
|
||||
|
||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||
local edits are lost, with one exception: [checkbox
|
||||
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
||||
disk.
|
||||
|
||||
**Closed issues stay out of the store.** In filter mode they are enumerated
|
||||
but not written: `--state all` still shows the whole picture, only `--state
|
||||
closed` puts one on disk, and the number left out goes to stderr. An issue
|
||||
already on disk is refreshed either way — the local copy learns it was closed
|
||||
instead of staying open forever. Key mode is exempt: `pull.py 1` fetches a
|
||||
closed issue as always, because an address is not a bulk read.
|
||||
|
||||
**`--limit N` bounds the write, not the selection.** N is how many issues this
|
||||
run leaves in the store — written, or left in place by `--cached`. Closed ones
|
||||
that were enumerated and thrown away do not spend it, so `--limit 20` over a
|
||||
milestone whose first 30 issues are closed still writes 20, as long as 20 open
|
||||
ones are there to write. Pagination follows the budget rather than the other way
|
||||
round:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| budget full | the next page is never requested |
|
||||
| pages run out | fewer than N, and that is the honest answer |
|
||||
| filter matches almost only closed issues | at most 4× the pages N would need if nothing were dropped, then a warning on stderr and a short answer — raising `--limit` raises that ceiling too |
|
||||
| dependencies | outside the count: a blocker is followed because a stored issue named it, not because the filter selected it — so `--limit 20` can leave more than 20 files behind |
|
||||
|
||||
`remote.py --limit` means something else, deliberately: it caps the **listing**,
|
||||
closed issues included. It writes nothing, so there is no write for a limit to
|
||||
bound — enumeration is its whole job.
|
||||
|
||||
**Comments come with every pull** — there is no flag. An issue that has a
|
||||
thread gets `tmp/issues/<id>.comments.md` beside it, in key mode and in filter
|
||||
mode alike, and the issue's output line says how many. An issue with none
|
||||
costs nothing: the count arrives in the list payload, so no request is made
|
||||
and no file is written — and a file left over from a thread that has since
|
||||
been emptied is deleted. `--cached` skips the thread along with the body, so a
|
||||
skipped issue makes one request for its links and no other.
|
||||
|
||||
**Dependencies come with every pull too, and this one costs.** A pull answers
|
||||
with the unit of work — the issue and what blocks it — so `depends:` is filled
|
||||
from Gitea's native graph and every blocker is pulled as well, recursively, down
|
||||
to `--depth` (default 3). It has to come from the native graph: the body's
|
||||
`## Depends on` section holds slugs, never `#N`, so there is no edge to recover
|
||||
from the text. `--no-deps` turns off both halves. `--deps` is still accepted and
|
||||
does nothing — it names the default.
|
||||
|
||||
| | requests |
|
||||
|---|---|
|
||||
| every issue that lands in the store | **+1** — `GET …/issues/{n}/dependencies`, fetched once and used twice (fills `depends:`, steers the walk) |
|
||||
| every blocker the selection did not already carry | **+1** to fetch it, then its own links, until `--depth` |
|
||||
| a closed issue filter mode drops | 0 — nothing was stored, so there is no unit of work to complete |
|
||||
| `--milestone X` over 50 open issues | 1 list request + 50, plus a pair per outside blocker — it used to be 1 |
|
||||
| the same with `--no-deps` | 1 |
|
||||
|
||||
**A blocker the filter did not select still lands in the store, deliberately.**
|
||||
`--milestone X` can leave an issue from milestone Y on disk; `--label` can leave
|
||||
an unlabelled one. It is there because a stored issue names it, not because it
|
||||
matched. The exception is a closed blocker: closed is not a unit of work, filter
|
||||
mode drops it like any other closed issue, and the `depends:` edge to it goes
|
||||
with it — nothing is left pointing at a file that is not there. Key mode
|
||||
(`pull.py 42`) has no such rule and stores it.
|
||||
|
||||
Two traps this handles for you:
|
||||
|
||||
- **Gitea silently ignores an unresolvable milestone filter** and returns the
|
||||
whole backlog. `pull.py` resolves the milestone first (exiting with the real
|
||||
ones if it does not exist) and re-checks every returned issue locally. Never
|
||||
trust a raw `tea api ...issues?milestones=X` for this.
|
||||
- **Projects are not fetchable.** The projects API is not exposed (404 on
|
||||
Gitea 1.26 for `repos/…/projects`, `orgs/…/projects`, `projects/{id}`). Use
|
||||
milestones or labels; project columns live in the web UI only.
|
||||
|
||||
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
||||
extra requests.
|
||||
|
||||
### Checkboxes are the one exception
|
||||
|
||||
A checkbox is state, not prose, and it is the one thing a pull does **not**
|
||||
overwrite. For a checkbox line whose **text** matches a line in the local copy,
|
||||
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
|
||||
tick it in both, the tick survives.
|
||||
|
||||
| part of the body | what a pull does to it |
|
||||
|---|---|
|
||||
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
|
||||
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
|
||||
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
|
||||
| any issue the store has never seen | written exactly as the server sent it |
|
||||
|
||||
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
|
||||
item only travels `[ ]` → `[x]`, so joining the two sides is a set union, not a
|
||||
conflict to resolve. No base version is kept and nothing is compared against
|
||||
one; one rule for one line type replaces the whole mechanism.
|
||||
|
||||
**The price, and it is real: a box unticked in the web UI comes back on the next
|
||||
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
|
||||
then `push.py --update` — the body goes up whole and the server follows.
|
||||
|
||||
Matching is on the item's text after the domain parser has stripped it and
|
||||
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
|
||||
tick. Rewording one does not: different text is a different item. The same text
|
||||
twice in a body is read as a set — one ticked local copy ticks every server line
|
||||
with that text.
|
||||
|
||||
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
|
||||
imported, never reimplemented here. The rule itself is
|
||||
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
|
||||
|
||||
## Pushing
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/push.py --dry-run # validate, no network
|
||||
python3 <skill-base-dir>/scripts/push.py # every local-only issue
|
||||
python3 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
|
||||
python3 <skill-base-dir>/scripts/push.py --update wire-sqlc-appclick # PATCH
|
||||
```
|
||||
|
||||
**A successful push DELETES the local file** — `tmp/issues/<id>.md` and
|
||||
`<id>.comments.md` — and prints the number and URL the issue now lives at:
|
||||
|
||||
```
|
||||
created wire-sqlc-appclick #42 https://git.noodles.cam/claude-skills/tea/issues/42
|
||||
dropped /repo/tmp/issues/wire-sqlc-appclick.md
|
||||
pull.py 42 to work on it again
|
||||
```
|
||||
|
||||
Once the tracker has the issue, the tracker *is* the issue. What is left in the
|
||||
store is what has not left this machine. There is no second copy, so there is
|
||||
nothing to reconcile and no "is mine the fresh one?" to answer — see
|
||||
[Drift](#drift).
|
||||
|
||||
**`--update` deletes too. One rule, no exception.** A PATCH is a push; an issue
|
||||
that has just been sent is no more local than one that was just created. Edit an
|
||||
issue by pulling it, changing it, pushing it — the copy is gone again after.
|
||||
|
||||
### What has to be true before anything is deleted
|
||||
|
||||
In order, and the delete is last:
|
||||
|
||||
1. the transport returned — `tea` ran and exited 0 (a non-2xx exits the run), and
|
||||
2. the answer is an object carrying a positive integer `number`, and on
|
||||
`--update` **the same number that was PATCHed** (`push.confirmed_number`), and
|
||||
3. `.remote.json` has been written with number → slug.
|
||||
|
||||
Network down, a 422, an empty body, an answer for a different issue: the file is
|
||||
still there and the run stops with the path in the error. An `origin: local`
|
||||
issue that was not sent — including a local-only dependency that push only read
|
||||
to warn about — is never touched. `--dry-run` deletes nothing and sends nothing.
|
||||
|
||||
### How the slug comes back
|
||||
|
||||
The slug is the issue's identity and the format promises it is stable for life,
|
||||
so it cannot live only in a file that push is about to delete. Two records, and
|
||||
the durable one is not local:
|
||||
|
||||
| where | survives | how |
|
||||
|---|---|---|
|
||||
| `<!-- tea:id wire-sqlc-appclick -->` | a rename in the web UI, a lost `.remote.json`, a fresh clone, another machine | first line of the **tracker-side** body; an HTML comment, so Gitea renders nothing |
|
||||
| `tmp/issues/.remote.json` | the file being deleted | number → slug, written before the delete |
|
||||
|
||||
`pull.py` consults the ledger first (it is the one that knows about files on
|
||||
disk right now), then the marker, then falls back to slugifying the title for an
|
||||
issue filed in the web UI that has never had a local name. A marker is only
|
||||
taken at its word when that slug is free — it never overwrites an issue already
|
||||
in the store.
|
||||
|
||||
**The marker never appears in the local file.** `map.to_payload` puts exactly
|
||||
one at the top on the way up, `map.from_api` strips every one on the way down.
|
||||
Strip-all-then-prepend-one is the whole mechanism, which is why a body cannot
|
||||
accumulate them however many round trips it makes, and why a body that somehow
|
||||
gained two is cleaned on the next pull.
|
||||
|
||||
`depends:` survives the same round trip through Gitea's native links (below):
|
||||
push writes them, every `pull.py` reads them back, and the ledger turns the
|
||||
numbers into the slugs they had here.
|
||||
|
||||
Before anything is sent, `/tea:issue`'s validator runs (exactly one `type/*`,
|
||||
at most one `severity/*`, English title with no type prefix, `## Summary` /
|
||||
`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why
|
||||
when you use it.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Issues go up in topological order, dependencies first, and **the graph goes up
|
||||
with them**. Once an issue has its number, every `depends:` entry that also has
|
||||
one becomes a native Gitea link, so the tracker shows the blocking panel and
|
||||
refuses to close a blocked issue before its blocker.
|
||||
|
||||
The two directions are symmetric, and they use the same endpoint:
|
||||
|
||||
| | direction | endpoint |
|
||||
|---|---|---|
|
||||
| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` |
|
||||
| `pull.py` (default; `--no-deps` off) | native links → `depends:` | `GET …/issues/{n}/dependencies` |
|
||||
|
||||
The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the
|
||||
**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the
|
||||
url depend on the issue in the form"). `owner`/`repo` travel with it, so a
|
||||
dependency in another repo links correctly.
|
||||
|
||||
- Topological order means the blocker already has its number — no second pass.
|
||||
- A link the tracker already has is skipped: push GETs the existing ones first,
|
||||
so a repeat push is a no-op and a 409 never happens. Should a link fail
|
||||
anyway, it is a warning, not a dead run — the issues are already created.
|
||||
- `--update` carries links that appeared in `depends:` after the first push.
|
||||
- `--dry-run` prints every link it would make (`#?` for a number this run has
|
||||
not handed out yet) and makes no request at all.
|
||||
- **Removing a link is out of scope.** Push only adds. A dependency deleted
|
||||
from `depends:` leaves its Gitea link standing; drop it in the web UI or with
|
||||
`tea api -X DELETE …/issues/N/dependencies`.
|
||||
|
||||
A dependency that is still local-only is reported, not silently dropped: it has
|
||||
no number, so it gets no link. The body's `## Depends on` prose is sent verbatim
|
||||
either way — nothing is lost, but the tracker shows no edge until that issue is
|
||||
pushed too.
|
||||
|
||||
Missing labels are created with the canonical color and, for `type/*` and
|
||||
`severity/*`, `exclusive: true` — `tea labels create` cannot set that field
|
||||
(tea 0.14.2), so it goes through `tea api`. Colors live in `map.py`; the names
|
||||
and their meaning come from the domain taxonomy.
|
||||
|
||||
That is per-push and piecemeal: a repo only ever grows the labels its issues
|
||||
happened to use, so filtering by `type/bug` in the web UI stays impossible
|
||||
until someone pushes a bug. `labels.py` lays down the whole set — the 11
|
||||
`type/*` and `severity/*` names — in one run:
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/labels.py --dry-run # the plan, no writes
|
||||
python3 <skill-base-dir>/scripts/labels.py # create what is missing
|
||||
```
|
||||
|
||||
It reads the repo's labels first. An exactly-matching name is never re-created
|
||||
and never patched. A **lookalike** — `bug`, `Bug`, `type: bug`, `kind/bug` —
|
||||
is reported with its id and left alone: renaming somebody else's label is a
|
||||
decision, not a migration. A color or `exclusive` that drifted is printed, and
|
||||
changed only under `--fix`. Running it twice creates nothing. `tech/*` and
|
||||
`comp/*` are open-ended by design and stay push-created.
|
||||
|
||||
Labels belong to the repository, not to any issue, so this one runs on a
|
||||
checkout with no store and leaves it that way — nothing here reads `tmp/issues/`
|
||||
and nothing creates it. The request bodies go to `tmp/payload/` (below).
|
||||
|
||||
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
|
||||
an empty one with the current git branch (`git rev-parse --abbrev-ref HEAD`)
|
||||
and sends it up as `ref`; a value already there is never
|
||||
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
|
||||
one. Reading the branch is the only thing these scripts ask git for — they
|
||||
never check out, create, or write anything.
|
||||
|
||||
The branch comes from the **current directory**, so run `push.py` from the tree
|
||||
the work is on. In a git worktree that is the worktree, and it is now also
|
||||
where the pin resolves from: the old workaround for the pin — run the scripts
|
||||
with cwd in the main checkout — sent the main checkout's branch as `ref`, which
|
||||
is the one thing `branch:` exists to record.
|
||||
|
||||
## Closing and reopening
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/close.py wire-sqlc-appclick # by slug
|
||||
python3 <skill-base-dir>/scripts/close.py 42 '#43' # by number
|
||||
python3 <skill-base-dir>/scripts/close.py --reopen 42
|
||||
python3 <skill-base-dir>/scripts/close.py --dry-run 42 43 # no request at all
|
||||
```
|
||||
|
||||
`close.py` is the only supported way to move `state:`. Never hand-roll
|
||||
`tea api -X PATCH -d '{"state":"closed"}' repos/OWNER/REPO/issues/N`: it spells
|
||||
out the owner, the repo and the request body — the three things this layer
|
||||
exists to hide — and it needs a `Bash(tea api *)` permission that also covers
|
||||
`-X DELETE` on the repository.
|
||||
|
||||
**State only.** The payload is `{"state": …}` and nothing else — no title, no
|
||||
body, no labels, no milestone. Closing is not an edit; editing is `pull.py` →
|
||||
change → `push.py --update`.
|
||||
|
||||
**Explicit ids only.** There is no `--milestone` and no `--label`: which issues
|
||||
are finished is a judgement about content, and this script only carries one
|
||||
out, one named id at a time. Deleting an issue is out of scope too — Gitea can,
|
||||
and it is not an operation of this workflow.
|
||||
|
||||
What may be named, and what happens to the local copy:
|
||||
|
||||
| named | resolved through | local file |
|
||||
|---|---|---|
|
||||
| a slug with a file on disk | its `gitea:` field | `state:` rewritten, `synced:` refreshed |
|
||||
| a slug whose file push dropped | `.remote.json` | none to write — say so and move on |
|
||||
| `42`, `#42`, `owner/repo#42`, a URL | the key itself; the ledger supplies the slug | rewritten when a file of that slug is there |
|
||||
| a slug with `origin: local` | — | **refused**: it is not in the tracker, and the error names the id |
|
||||
|
||||
The local file is written only after the tracker has confirmed *this* write: an
|
||||
object carrying the very number that was PATCHed, in the state that was asked
|
||||
for. A non-2xx, a `tea` that would not run, an answer for another issue, a 200
|
||||
that still says `open` — the run stops and the file is byte for byte what it
|
||||
was. `--dry-run` prints the same lines and makes no request at all, so it needs
|
||||
no pinned login.
|
||||
|
||||
Gitea refuses to close an issue that its own dependency graph still blocks. The
|
||||
refusal arrives as a non-2xx with the tracker's own words: close the blockers
|
||||
first, or unlink them in the web UI.
|
||||
|
||||
The index is rebuilt when at least one local file changed, so `INDEX.md` never
|
||||
outlives the state it reports. Nothing is deleted here — unlike a push, a close
|
||||
leaves the working copy where it is.
|
||||
|
||||
## Evicting what the tracker says is closed
|
||||
|
||||
```bash
|
||||
python3 <skill-base-dir>/scripts/evict.py --dry-run # ask, report, change nothing
|
||||
python3 <skill-base-dir>/scripts/evict.py # and remove them
|
||||
python3 <skill-base-dir>/scripts/evict.py old-thing # just this one
|
||||
```
|
||||
|
||||
Eviction itself belongs to `/tea:issue` (`issue_evict.py`) and is offline: the
|
||||
decision is `state: closed` plus an `origin:` that names a tracker, both read
|
||||
off the file. This script adds one thing in front of it — a `state:` that is not
|
||||
stale — and then calls that same decision. There is one implementation of "what
|
||||
may be evicted" and it is in the domain.
|
||||
|
||||
Why it exists: a local `state:` is only as fresh as the last pull, so an issue
|
||||
closed in the web UI still reads `open` here and the offline command correctly
|
||||
leaves it alone. The workaround was `pull.py 11 12 13 14 15` — which writes the
|
||||
five closed files back to disk before anything can remove them.
|
||||
|
||||
Order of operations, and it is the safety argument:
|
||||
|
||||
1. every candidate's state is fetched — **all** of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number that was asked about and a
|
||||
state the domain recognizes (`evict.confirmed_state`, the counterpart of
|
||||
`push.confirmed_number`);
|
||||
3. only then does the eviction run.
|
||||
|
||||
**A failed call evicts nothing** — not even the candidates whose answers had
|
||||
already arrived, and no refreshed `state:` is written back either. Stricter than
|
||||
push, which deletes as it goes, and free: evictions have no order between them,
|
||||
so there is no reason to start before every answer is in.
|
||||
|
||||
- A **candidate** is an issue carrying a `gitea:` handle. `origin: local` has
|
||||
none, is never asked about, and is never removed. An `origin: gitea` issue
|
||||
whose handle is missing or unparseable cannot be verified — it is reported on
|
||||
stderr and kept.
|
||||
- No `--repo`: the repo comes from each issue's own handle, so a store holding
|
||||
issues from two repos is checked against both.
|
||||
- One GET per candidate. The store is a working set that push keeps small, and a
|
||||
wrong answer here deletes a file — so each issue is asked about by its own
|
||||
address rather than inferred from a list a `--limit` could have truncated.
|
||||
- A state that disagrees with the file is written back, so the store stops lying
|
||||
about the issues that stay too. `--dry-run` makes no writes at all.
|
||||
- `.remote.json` is not pruned; see [How the slug comes
|
||||
back](#how-the-slug-comes-back) — an evicted issue is exactly as findable as a
|
||||
pushed one.
|
||||
- **`pull.py <n>` still fetches a closed issue.** A number is an address, not a
|
||||
query. A closed issue pulled after an eviction is back on disk, and that is
|
||||
the tracker answering the question it was asked, not a regression.
|
||||
|
||||
## What crosses the boundary, and what does not
|
||||
|
||||
| domain | Gitea | note |
|
||||
|---|---|---|
|
||||
| `id` (slug) | `<!-- tea:id … -->` | first line of the tracker-side body; stripped out of the local copy |
|
||||
| title | `title` | verbatim, both directions |
|
||||
| body | `body` | verbatim up except the marker; verbatim down except the marker and checkbox state, which is unioned |
|
||||
| `state` | `state` | same vocabulary |
|
||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||
| `assignees` | `assignees[]` | logins |
|
||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
||||
| `depends` | native links | slugs here, `IssueMeta` there; push writes them, every pull reads them (`--no-deps` opts out) |
|
||||
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||
|
||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
||||
and is passed through **unchanged** in both directions: a pull seeds `depends:`
|
||||
from the `#N` it finds there, a push never rewrites what the author wrote. A
|
||||
translator that edits prose churns the body on every round trip. The edge the
|
||||
tracker acts on is the native link, not the text — which is exactly why the
|
||||
text can be left alone.
|
||||
|
||||
Comments are **pull-only** in the store: `<id>.comments.md` is written by
|
||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||
|
||||
## Drift
|
||||
|
||||
There is none tracked, and since push started deleting what it sends there is
|
||||
very little left to track. A published issue has **one** copy — Gitea's —
|
||||
except while somebody is working on it, and that window closes at the next
|
||||
push. Nothing watches Gitea, nothing reconciles, nothing warns that a synced
|
||||
issue changed upstream. `synced:` tells you how old your working copy is;
|
||||
`remote-updated:` what the server said at that moment. Re-pull when it matters,
|
||||
and push when you are done so there is nothing to be stale.
|
||||
|
||||
The old question — "I edited this locally, does the server have it, whose text
|
||||
is newer?" — is answered by the store's contents rather than by a mechanism: a
|
||||
file that is here has not been pushed.
|
||||
|
||||
Checkbox state is not an exception to this. The union a pull applies reads only
|
||||
the two bodies in front of it — there is no base version, no history, and no
|
||||
way for it to report that anything diverged. One rule for one line type,
|
||||
precisely so the mechanism this section rules out is not needed.
|
||||
|
||||
## Rich payloads for everything else
|
||||
|
||||
Every body these scripts send is written to `<repo>/tmp/payload/<name>.json`
|
||||
first and passed as `-d @file`, then kept for a retry or a look at what actually
|
||||
went up. One gitignored directory for all of them, chosen by the transport and
|
||||
not by the caller. **It is not a store**: nothing in it is anybody's only copy,
|
||||
and it is never `tmp/issues/` — a command that touches no issue must not leave
|
||||
an issue store behind.
|
||||
|
||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||
(pulls, releases, PATCHing something these scripts do not cover), entity
|
||||
subcommands like `tea pulls create` hang on a large or formatted body — an
|
||||
empty-looking positional triggers the `$EDITOR` fallback on a TTY that does not
|
||||
exist, and the harness eventually kills the process (exit 144 = 128 + SIGURG on
|
||||
macOS). Write the JSON payload to `$PWD/tmp/` first and POST it with
|
||||
`tea api -d @file`. Procedure and endpoint table: `/tea:use`.
|
||||
|
||||
## Login
|
||||
|
||||
Every `tea` call made by hand must carry the literal placeholder
|
||||
`--login "$GITEA_LOGIN"`; the `tea-guard` hook substitutes the operator's pin.
|
||||
Set it with `/tea:auth`. Details in `/tea:use`.
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
_gitea.py — transport. Everything that talks to Gitea, and nothing else.
|
||||
|
||||
Not a command. This module knows logins, HTTP verbs, pagination, and Gitea's
|
||||
query quirks. It does NOT know what an issue is: no sections, no acceptance
|
||||
criteria, no type taxonomy. Payload shapes come from map.py; the domain model
|
||||
lives one layer further out in skills/issue/scripts/issue.py.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN).
|
||||
Where that file is searched for is NOT written here — skills/auth/scripts/pin.py
|
||||
owns the search order, and the tea-guard hook imports the same module, so `tea`
|
||||
and the scripts can never disagree about which login a directory runs under. No
|
||||
script here accepts a login argument: the operator's pin is the only identity
|
||||
they will use. No pin -> exit with a pointer to /tea:auth.
|
||||
|
||||
Also holds the id map (tmp/issues/.remote.json), which pairs a remote key with
|
||||
a local slug, and the paths of the store-side files this layer writes. All of
|
||||
it is transport bookkeeping, not domain data — the domain never reads any of
|
||||
it, and losing the map still costs a re-pull and not information: the slug it
|
||||
records also travels in the issue body as `<!-- tea:id … -->` (map.py), so a
|
||||
pull rebuilds the entry from the tracker. See `rebuild_map`.
|
||||
|
||||
Request bodies go to tmp/payload/, which is this module's own scratchpad and
|
||||
NOT a store: nothing in it is anybody's only copy, and writing one must never
|
||||
materialize tmp/issues/ on a checkout that has none. Bootstrapping labels
|
||||
touches no issue at all — it used to leave a store behind anyway, because the
|
||||
request file had nowhere else to live. One directory, every caller, resolved
|
||||
from this file the way the two domains resolve theirs.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
REMOTE_MAP = ".remote.json"
|
||||
|
||||
# How far past the ideal page count a `keep`-bounded listing may scan before it
|
||||
# gives up (see list_issues). The ideal is what `limit` would need if every
|
||||
# payload counted; the slack pays for the ones that do not. It is a bound on
|
||||
# requests, deliberately small: "fetch until N are kept" without one is "fetch
|
||||
# the whole tracker" on any repo whose filter matches mostly closed issues.
|
||||
PAGE_SLACK = 4
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# where request bodies land
|
||||
# --------------------------------------------------------------------------
|
||||
# Anchored on THIS FILE, like issue.store_root, so every caller — sync,
|
||||
# whatever comes next — writes to one directory whatever it was invoked from. Visible and top-level under tmp/, not a dotdir hidden
|
||||
# inside somebody's store, because a scratchpad that looks like store contents
|
||||
# is how this went wrong the first time. `tmp/` is already gitignored.
|
||||
|
||||
PAYLOAD_PARTS = ("tmp", "payload")
|
||||
|
||||
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
|
||||
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
|
||||
# git; the agents-sync hook only ever puts one at a repository root.
|
||||
REPO_MARKERS = (".git", "AGENTS.md")
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def die(msg, code=1):
|
||||
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
sys.stderr.write("warning: %s\n" % msg)
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def repo_root(start):
|
||||
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
|
||||
d = os.path.abspath(start)
|
||||
while True:
|
||||
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
|
||||
return d
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
def payload_root(start=None):
|
||||
"""Absolute path of the request-body scratchpad.
|
||||
|
||||
`start` overrides the anchor so the resolution can be exercised against a
|
||||
scratch tree. Outside a repository, cwd gets a turn, then the cwd-relative
|
||||
location stands — made absolute so an error can name the directory it
|
||||
really wrote to."""
|
||||
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
|
||||
root = repo_root(anchor)
|
||||
if root:
|
||||
return os.path.join(root, *PAYLOAD_PARTS)
|
||||
return os.path.abspath(os.path.join(*PAYLOAD_PARTS))
|
||||
|
||||
|
||||
PAYLOAD_ROOT = payload_root()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# login
|
||||
# --------------------------------------------------------------------------
|
||||
# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the
|
||||
# single written copy of the search order, and the tea-guard hook calls the
|
||||
# same function. When the two had a copy each, a git worktree got a hook that
|
||||
# resolved the pin and a transport that did not — in the same directory.
|
||||
#
|
||||
# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those
|
||||
# are anchored on their own file, this is not, and both are right. Where an
|
||||
# installation keeps its files is a fact about the installation; whose login a
|
||||
# project runs under is a fact about the project, and a plugin installed
|
||||
# outside any repository must not answer it from its own directory. See the
|
||||
# module docstring in pin.py.
|
||||
|
||||
_AUTH_SCRIPTS = os.path.abspath(
|
||||
os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts"))
|
||||
if _AUTH_SCRIPTS not in sys.path:
|
||||
sys.path.append(_AUTH_SCRIPTS)
|
||||
import pin # noqa: E402
|
||||
|
||||
|
||||
def require_login():
|
||||
"""The operator's pinned login, or exit pointing at /tea:auth.
|
||||
|
||||
No pin found is reported as exactly that. It stays a truthful message: the
|
||||
fix for "the pin is somewhere this search does not reach" belongs in
|
||||
pin.py, never in a hint here that sends the operator to pin it twice."""
|
||||
login, _ = pin.find_pin()
|
||||
if not login:
|
||||
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
|
||||
return login
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# api
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def api(login, endpoint, method="GET", payload=None, payload_name=None,
|
||||
allow_fail=False):
|
||||
"""Call `tea api`; return parsed JSON (None on an empty body).
|
||||
|
||||
payload (a dict) is written to PAYLOAD_ROOT/<name>.json and passed as
|
||||
-d @file — the file survives the call for retries and debugging. Where
|
||||
that is, is not the caller's business and never was: the directory is
|
||||
this layer's scratchpad, and the one time it was a caller's decision it
|
||||
got pointed at the issue store. allow_fail returns None instead of
|
||||
exiting when the call fails."""
|
||||
cmd = ["tea", "api", "--login", login]
|
||||
if method != "GET":
|
||||
cmd += ["-X", method]
|
||||
if payload is not None:
|
||||
os.makedirs(PAYLOAD_ROOT, exist_ok=True)
|
||||
path = os.path.join(PAYLOAD_ROOT, "%s.json" % (payload_name or "request"))
|
||||
with open(path, "w") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
cmd += ["-d", "@" + path]
|
||||
cmd.append(endpoint)
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s %s` failed:\n%s" % (method, endpoint, (r.stderr or r.stdout).strip()))
|
||||
body = r.stdout.strip()
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
if allow_fail:
|
||||
return None
|
||||
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, body[:500]))
|
||||
|
||||
|
||||
def pages(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page, yielding each page as it arrives.
|
||||
|
||||
A generator, because a caller whose budget is spent on what it *keeps*
|
||||
cannot be served by a function that fetches everything first: the page after
|
||||
the one that completed the budget must never be requested. Stop consuming
|
||||
and no further request is made."""
|
||||
sep = "&" if "?" in endpoint else "?"
|
||||
for page in range(1, max_pages + 1):
|
||||
batch = api(login, "%s%spage=%d&limit=%d" % (endpoint, sep, page, limit), **kw)
|
||||
if not isinstance(batch, list) or not batch:
|
||||
return
|
||||
yield batch
|
||||
if len(batch) < limit:
|
||||
return # a short page is the last one
|
||||
|
||||
|
||||
def paginate(login, endpoint, limit=50, max_pages=40, **kw):
|
||||
"""GET a list endpoint page by page; return the concatenated list."""
|
||||
out = []
|
||||
for batch in pages(login, endpoint, limit=limit, max_pages=max_pages, **kw):
|
||||
out.extend(batch)
|
||||
return out
|
||||
|
||||
|
||||
def repo_base(repo=None):
|
||||
"""API prefix. Without --repo, let tea fill {owner}/{repo} from CWD."""
|
||||
return "repos/%s" % repo if repo else "repos/{owner}/{repo}"
|
||||
|
||||
|
||||
def repo_slug(login, repo=None):
|
||||
"""owner/repo as a literal string — needed for remote keys, which must not
|
||||
contain tea's {owner}/{repo} placeholder."""
|
||||
if repo:
|
||||
return repo
|
||||
got = api(login, "repos/{owner}/{repo}", allow_fail=True)
|
||||
if isinstance(got, dict) and got.get("full_name"):
|
||||
return got["full_name"]
|
||||
die("cannot determine owner/repo from the CWD — pass --repo owner/repo")
|
||||
|
||||
|
||||
def parse_key(key):
|
||||
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / a URL."""
|
||||
key = key.strip()
|
||||
m = re.match(r'^https?://[^/]+/([^/]+)/([^/]+)/issues/(\d+)/?$', key)
|
||||
if m:
|
||||
return int(m.group(3)), "%s/%s" % (m.group(1), m.group(2))
|
||||
m = re.match(r'^([\w.-]+/[\w.-]+)#(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(2)), m.group(1)
|
||||
m = re.match(r'^#?(\d+)$', key)
|
||||
if m:
|
||||
return int(m.group(1)), None
|
||||
die("cannot parse issue key %r (want 42, #42, owner/repo#42, or an issue URL)" % key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# filters
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def resolve_milestone(login, base, value):
|
||||
"""(id, title) for a milestone given by id or title. Exits if unknown.
|
||||
|
||||
Gitea silently IGNORES an unresolvable `milestones=` filter and returns the
|
||||
whole backlog, so the milestone must be resolved before it is trusted."""
|
||||
got = paginate(login, "%s/milestones?state=all" % base, limit=100)
|
||||
for m in got or []:
|
||||
if str(m.get("id")) == str(value) or m.get("title") == str(value):
|
||||
return m["id"], m.get("title", "")
|
||||
have = ", ".join("%s (id %d)" % (m.get("title", ""), m["id"]) for m in got or [])
|
||||
die("no milestone %r in this repo — have: %s" % (value, have or "none"))
|
||||
|
||||
|
||||
def matches(payload, milestone_id=None, labels=()):
|
||||
"""Client-side re-check of a server-side filter — see resolve_milestone."""
|
||||
if payload.get("pull_request"):
|
||||
return False
|
||||
if milestone_id is not None and (payload.get("milestone") or {}).get("id") != milestone_id:
|
||||
return False
|
||||
names = {l.get("name", "") for l in payload.get("labels") or []}
|
||||
return all(l in names for l in labels)
|
||||
|
||||
|
||||
def list_issues(login, base, state="open", labels=(), query=None,
|
||||
milestone=None, limit=100, keep=None):
|
||||
"""Filtered issue payloads. Returns (payloads, milestone_title).
|
||||
|
||||
One request per page, and the payload already carries the issue bodies — a
|
||||
whole milestone costs one call per 50 issues, not one per issue.
|
||||
|
||||
`limit` counts the payloads the CALLER cares about, not the ones the server
|
||||
returned. Without `keep` those are the same thing and this behaves as it
|
||||
always did. With it, `keep(payload)` says whether a payload counts, pages
|
||||
keep coming until `limit` of them have, and the returned list carries the
|
||||
ones that did not count too — they were enumerated, and a caller that has
|
||||
something to say about them (pull.py: "N closed, not stored") still can.
|
||||
|
||||
What `keep` means is the caller's business; this module only counts. Two
|
||||
boundaries hold whatever it decides:
|
||||
|
||||
- **Stop at the limit.** The page after the one that completed the budget
|
||||
is not requested — `pages` is a generator and this loop returns out of it.
|
||||
- **Stop at the page budget.** A predicate that rejects everything must not
|
||||
turn a bounded read into a walk of the whole tracker, so a filtered read
|
||||
may scan at most `PAGE_SLACK` times the pages `limit` would need if every
|
||||
payload counted. Hitting that with an unfilled budget is a warning, not a
|
||||
silent short answer: the caller asked for N and is told it got fewer."""
|
||||
if limit < 1:
|
||||
die("--limit must be 1 or more, got %d" % limit)
|
||||
ms_id, ms_title = (None, None)
|
||||
if milestone is not None:
|
||||
ms_id, ms_title = resolve_milestone(login, base, milestone)
|
||||
|
||||
params = {"state": state, "type": "issues"}
|
||||
if labels:
|
||||
params["labels"] = ",".join(labels)
|
||||
if query:
|
||||
params["q"] = query
|
||||
if ms_title:
|
||||
params["milestones"] = ms_title
|
||||
endpoint = "%s/issues?%s" % (base, urllib.parse.urlencode(params))
|
||||
|
||||
per_page = min(limit, 50)
|
||||
ideal = max(1, -(-limit // per_page))
|
||||
budget = ideal if keep is None else ideal * PAGE_SLACK
|
||||
|
||||
got, kept, seen_pages, last_full = [], 0, 0, False
|
||||
for batch in pages(login, endpoint, limit=per_page, max_pages=budget):
|
||||
seen_pages += 1
|
||||
last_full = len(batch) == per_page
|
||||
for p in batch:
|
||||
if not matches(p, ms_id, labels):
|
||||
continue
|
||||
got.append(p)
|
||||
if keep is None or keep(p):
|
||||
kept += 1
|
||||
if kept >= limit:
|
||||
return got, ms_title
|
||||
if keep is not None and seen_pages >= budget and last_full:
|
||||
warn("scanned %d page(s) and stopped %d short of --limit %d — there may"
|
||||
" be more; narrow the filter or raise --limit" % (budget, limit - kept, limit))
|
||||
return got, ms_title
|
||||
|
||||
|
||||
def get_issue(login, base, number):
|
||||
payload = api(login, "%s/issues/%d" % (base, number))
|
||||
if not isinstance(payload, dict) or "number" not in payload:
|
||||
die("issue #%d not found" % number)
|
||||
return payload
|
||||
|
||||
|
||||
def get_comments(login, base, number):
|
||||
return paginate(login, "%s/issues/%d/comments" % (base, number))
|
||||
|
||||
|
||||
def native_deps(login, base, number):
|
||||
"""Gitea's own issue-dependency links; empty when unsupported."""
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||
return [i["number"] for i in got] if isinstance(got, list) else []
|
||||
|
||||
|
||||
def native_dep_pairs(login, base, number):
|
||||
"""The same links as {(owner/repo, number)} — what a repeat push compares
|
||||
against so it does not POST a link the tracker already has.
|
||||
|
||||
A bare number is ambiguous the moment a dependency lives in another repo,
|
||||
and IssueMeta lets it, so the repo travels with it. The pair is a transport
|
||||
fact; formatting it as `owner/repo#42` is map.py's job, not this module's."""
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True)
|
||||
out = set()
|
||||
for i in got if isinstance(got, list) else []:
|
||||
repo = (i.get("repository") or {}).get("full_name") or ""
|
||||
if "number" in i:
|
||||
out.add((repo, int(i["number"])))
|
||||
return out
|
||||
|
||||
|
||||
def add_dependency(login, base, number, dep_repo, dep_number):
|
||||
"""Make issue `number` depend on `dep_repo#dep_number`. True on success.
|
||||
|
||||
Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1):
|
||||
|
||||
POST /repos/{owner}/{repo}/issues/{index}/dependencies
|
||||
body: IssueMeta — {"index": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
"Make the issue in the url depend on the issue in the form."
|
||||
|
||||
The URL names the blocked issue and the body the blocker, which is the same
|
||||
direction native_deps reads back ("all issues that block this issue"). A
|
||||
link that already exists answers 409, so a failure here is reported and not
|
||||
fatal: one missing cross-link must not abort a push that has already
|
||||
created issues. Callers pre-filter with native_dep_pairs."""
|
||||
owner, _, name = (dep_repo or "").partition("/")
|
||||
if not owner or not name:
|
||||
return False
|
||||
payload = {"index": int(dep_number), "owner": owner, "repo": name}
|
||||
got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload,
|
||||
payload_name="dep-%d-%d" % (number, dep_number), allow_fail=True)
|
||||
return got is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# labels
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def ensure_labels(login, base, specs, root):
|
||||
"""Map label name -> id, creating what the repo is missing.
|
||||
|
||||
`specs` is {name: {"color", "description", "exclusive"}} handed in by the
|
||||
caller — this module does not know which namespaces are exclusive or what
|
||||
they mean. Cached in <root>/.labels.json; the cache is refreshed from the
|
||||
API before anything is created."""
|
||||
cache_path = os.path.join(root, ".labels.json")
|
||||
cache = {}
|
||||
if os.path.isfile(cache_path):
|
||||
try:
|
||||
with open(cache_path) as f:
|
||||
cache = json.load(f)
|
||||
except Exception:
|
||||
cache = {}
|
||||
|
||||
if any(n not in cache for n in specs):
|
||||
cache = {l["name"]: l["id"] for l in paginate(login, "%s/labels" % base, limit=100)}
|
||||
|
||||
for name, spec in specs.items():
|
||||
if name in cache:
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
created = api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not created or "id" not in created:
|
||||
die("could not create label %r" % name)
|
||||
cache[name] = created["id"]
|
||||
sys.stderr.write("created label %s%s\n"
|
||||
% (name, " (exclusive)" if spec.get("exclusive") else ""))
|
||||
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(cache, f, indent=2, sort_keys=True)
|
||||
return {n: cache[n] for n in specs}
|
||||
|
||||
|
||||
def resolve_milestone_id(login, base, title):
|
||||
"""Milestone id for a title, or None when the repo has no such milestone."""
|
||||
if not title or title == "none":
|
||||
return None
|
||||
for m in paginate(login, "%s/milestones?state=all" % base, limit=100) or []:
|
||||
if m.get("title") == title:
|
||||
return m["id"]
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# store-side files this layer owns
|
||||
# --------------------------------------------------------------------------
|
||||
# The issue file itself is the domain's (`issue.path_of`). The one file the sync
|
||||
# layer puts beside it is named here, in one place, because three commands have
|
||||
# to agree on it: pull.py writes the thread, comment.py refetches it, push.py
|
||||
# deletes it along with the issue it just sent.
|
||||
|
||||
def comments_path(root, id):
|
||||
"""An issue's comment thread — beside it, under the same slug.
|
||||
|
||||
A path, not a concept the domain needs: a thread is pulled from Gitea and
|
||||
never pushed back, so the domain has no reason to know the file exists."""
|
||||
return os.path.join(root, "%s.comments.md" % id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# id map: remote key <-> local slug
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def map_path(root):
|
||||
return os.path.join(root, REMOTE_MAP)
|
||||
|
||||
|
||||
def load_map(root):
|
||||
"""{"owner/repo#42": "wire-sqlc-appclick"} — the local slug ledger.
|
||||
|
||||
Entries outlive the files they name, and that is now the normal case rather
|
||||
than a leak: `push.py` deletes an issue's file the moment Gitea confirms it,
|
||||
and the entry it leaves behind is what lets the next `pull.py 42` land on
|
||||
the same slug. Nothing prunes them, because "no file" no longer means "no
|
||||
such issue". A stale entry costs one json line and is corrected the next
|
||||
time that number is pulled."""
|
||||
p = map_path(root)
|
||||
if not os.path.isfile(p):
|
||||
return {}
|
||||
try:
|
||||
with open(p) as f:
|
||||
got = json.load(f)
|
||||
return got if isinstance(got, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_map(root, m):
|
||||
os.makedirs(root, exist_ok=True)
|
||||
with open(map_path(root), "w") as f:
|
||||
json.dump(m, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def rebuild_map(root, issues):
|
||||
"""Fold the `gitea:` fields still on disk into the id map. Returns it.
|
||||
|
||||
This used to say "the files are the source of truth; .remote.json is only an
|
||||
index over them", and that stopped being true the day push started deleting
|
||||
the file it had just sent. A pushed issue leaves no `gitea:` field behind to
|
||||
read, so the files are now a SUBSET of what the map knows, and a rebuild
|
||||
from them alone would throw away every entry it cannot see.
|
||||
|
||||
So the contradiction is resolved by moving the source of truth, not by
|
||||
keeping this function honest about files:
|
||||
|
||||
Gitea the issue, and — in `<!-- tea:id … -->` — its slug
|
||||
.remote.json a local number -> slug ledger, a cache of that marker
|
||||
tmp/issues/*.md whatever happens to be checked out right now
|
||||
|
||||
Which makes this a MERGE and never a replacement: it starts from what is
|
||||
already recorded and adds what the remaining files say. What it cannot
|
||||
recover — a pushed-and-dropped issue whose ledger entry was also lost — is
|
||||
not lost either; the next `pull.py <n>` reads the slug off the marker and
|
||||
writes the entry back."""
|
||||
m = load_map(root)
|
||||
for id, iss in issues.items():
|
||||
key = iss.extra.get("gitea")
|
||||
if key:
|
||||
m[key] = id
|
||||
save_map(root, m)
|
||||
return m
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
close.py — change an issue's state in Gitea, and in the local copy with it.
|
||||
|
||||
The one regular tracker operation that used to have no script: closing. Without
|
||||
it the only way to move `state:` was a raw `tea api -X PATCH -d '{"state":
|
||||
"closed"}' repos/OWNER/REPO/issues/N`, which spells out the owner, the repo and
|
||||
the request body — the three things `_gitea.py` exists to hide — and which needs
|
||||
`Bash(tea api *)`, a permission that also covers `-X DELETE` on the repository.
|
||||
|
||||
close.py wire-sqlc-appclick one issue, by slug
|
||||
close.py wire-sqlc-appclick 42 #43 several, by slug or number
|
||||
close.py --reopen 42 the same thing backwards
|
||||
close.py --dry-run 42 43 what would happen, no request at all
|
||||
|
||||
STATE ONLY. This script sends `{"state": …}` and nothing else: no title, no
|
||||
body, no labels, no milestone. Editing an issue is `pull.py` -> edit ->
|
||||
`push.py --update`; closing it is not an edit.
|
||||
|
||||
**What may be named.** A local slug, or a Gitea key (`42`, `#42`,
|
||||
`owner/repo#42`, an issue URL) — the same forms `pull.py` takes. Both are
|
||||
needed, and for the same reason: a push deletes the local file, so most issues
|
||||
in the tracker have no slug on disk to name them by. A slug is resolved through
|
||||
the file's `gitea:` field when the file is there, and through the ledger
|
||||
(`.remote.json`) when push has already dropped it.
|
||||
|
||||
**An `origin: local` issue cannot be closed.** It is not in the tracker, so
|
||||
there is nothing to close there, and the run stops naming the id rather than
|
||||
quietly editing one field of a local file. Delete it, or push it first.
|
||||
|
||||
**Explicit ids only.** No `--milestone`, no `--label`, no "close everything
|
||||
that looks done". Which issues are finished is a judgement about content; this
|
||||
script only carries it out, one named id at a time. Nothing here deletes an
|
||||
issue either — Gitea can, and it is not an operation of this workflow.
|
||||
|
||||
The local file is written only after the tracker has confirmed the write:
|
||||
|
||||
1. `tea` ran and exited 0 (a non-2xx exits the run inside `_gitea.api`), and
|
||||
2. the answer is an object carrying the very number that was PATCHed, and
|
||||
3. its `state` is the state we asked for.
|
||||
|
||||
Anything else and the file is left exactly as it was — see `confirmed`. An
|
||||
issue whose local copy is gone (pushed and dropped) is closed in Gitea and
|
||||
nothing is written; the state comes down with the next `pull.py`.
|
||||
|
||||
Gitea refuses to close an issue that its own dependency graph still blocks. That
|
||||
refusal arrives as a non-2xx and stops the run with the tracker's own words:
|
||||
close the blockers first, or unlink them in the web UI.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
# What `_gitea.parse_key` accepts, asked as a question instead of an assertion:
|
||||
# parse_key exits on anything it cannot read, and here "not a key" is the normal
|
||||
# case — it means the argument is a slug. A slug never contains `#`, `/` or `:`,
|
||||
# so the two vocabularies cannot collide.
|
||||
KEY_RE = re.compile(r'^(#?\d+|[\w.-]+/[\w.-]+#\d+|https?://\S+)$')
|
||||
|
||||
|
||||
def looks_like_key(arg):
|
||||
return bool(KEY_RE.match((arg or "").strip()))
|
||||
|
||||
|
||||
def ledger_pairs(remote_map, repo=None):
|
||||
"""[(repo, number, slug)] from `.remote.json`, filtered to `repo`.
|
||||
|
||||
A `--repo` that was not given means "whatever the ledger holds": resolving
|
||||
the repo's real name costs a request, and a dry run is required to make
|
||||
none. The ambiguity that opens — one number under two repos — is caught at
|
||||
lookup time rather than papered over."""
|
||||
out = []
|
||||
for key, slug in sorted(remote_map.items()):
|
||||
r, n = gmap.parse_remote_key(key)
|
||||
if n:
|
||||
if repo is None or r == repo:
|
||||
out.append((r, n, slug))
|
||||
return out
|
||||
|
||||
|
||||
def one(candidates, what, arg):
|
||||
"""The single `(repo, value)` in `candidates`, None when empty, or exit.
|
||||
|
||||
Two answers mean the ledger knows this number (or this slug) under more than
|
||||
one repository, and only `--repo` can settle that."""
|
||||
got = sorted(set(candidates))
|
||||
if len(got) > 1:
|
||||
_gitea.die("%r matches %s under more than one repo (%s) — pass "
|
||||
"--repo owner/repo" % (arg, what, ", ".join(r for r, _v in got)))
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
def resolve(arg, issues, pairs):
|
||||
"""(id, number, repo) for one argument. Either of `id` and `repo` is None
|
||||
when nothing this machine holds names it.
|
||||
|
||||
Order, and it is the order of what is most authoritative about this machine:
|
||||
a file on disk, then the ledger, then nothing. A key skips straight to the
|
||||
ledger — its number is already the tracker's answer, and the slug is only
|
||||
wanted so the local copy, if there is one, can be kept honest.
|
||||
|
||||
`repo` travels out with the number because a key may name one
|
||||
(`owner/repo#42`) and a `gitea:` field always does. Sending a foreign key to
|
||||
whatever repo the CWD happens to be in would close somebody else's issue of
|
||||
the same number, so the caller reconciles them before anything goes out."""
|
||||
if looks_like_key(arg):
|
||||
number, repo = _gitea.parse_key(arg)
|
||||
hit = one([(r, s) for r, n, s in pairs
|
||||
if n == number and (repo is None or r == repo)], "a slug", arg)
|
||||
return (hit[1] if hit else None), number, repo or (hit[0] if hit else None)
|
||||
|
||||
iss = issues.get(arg)
|
||||
if iss is not None:
|
||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||
if not number:
|
||||
_gitea.die("%s is not in the tracker (origin: %s, no gitea: field) — "
|
||||
"there is no state there to change; push.py %s first"
|
||||
% (arg, iss.origin, arg))
|
||||
return arg, number, repo
|
||||
|
||||
hit = one([(r, n) for r, n, s in pairs if s == arg], "a number", arg)
|
||||
if hit:
|
||||
return arg, hit[1], hit[0] # pushed, and its file went with the push
|
||||
_gitea.die("no issue %r in the store or the ledger — pass a Gitea number "
|
||||
"(42, #42, owner/repo#42, a URL) to close one this machine has "
|
||||
"never seen" % arg)
|
||||
|
||||
|
||||
def confirmed(got, number, state):
|
||||
"""True when the tracker's answer confirms THIS write, and nothing else.
|
||||
|
||||
The gate in front of the local write, and deliberately boring: an answer
|
||||
counts only when it is an object carrying the very number that was PATCHed
|
||||
(`bool` rejected explicitly — `True` is an `int`) and the state that was
|
||||
asked for. A non-2xx and a `tea` that would not run never reach here at all;
|
||||
`_gitea.api` exits on both, so the file survives those by never being
|
||||
written."""
|
||||
if not isinstance(got, dict):
|
||||
return False
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||
return False
|
||||
return got.get("state") == state
|
||||
|
||||
|
||||
def apply_state(root, iss, state, got):
|
||||
"""Write the confirmed state onto the local file; return its path.
|
||||
|
||||
`state:` is the domain's own field, so it is set on the issue and written
|
||||
out by the domain's own writer. The sync-owned freshness fields travel with
|
||||
it: the answer that authorized this write is also the newest thing the
|
||||
tracker has said about the issue, so `synced:` and `remote-updated:` are
|
||||
stamped from it rather than left describing an older read."""
|
||||
iss.state = state
|
||||
iss.extra["synced"] = _gitea.now_iso()
|
||||
if got.get("updated_at"):
|
||||
iss.extra["remote-updated"] = got["updated_at"]
|
||||
return issue.save(root, iss)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Close (or reopen) issues in Gitea")
|
||||
ap.add_argument("ids", nargs="+",
|
||||
help="local ids, or Gitea keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--reopen", action="store_true",
|
||||
help="set the state back to open instead of closed")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print what would change; makes no request at all")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
state = "open" if args.reopen else "closed"
|
||||
verb = "reopen" if args.reopen else "close"
|
||||
past = "reopened" if args.reopen else "closed"
|
||||
|
||||
# A store that is not there is not an error here: a number needs no local
|
||||
# file, and closing an issue whose copy was dropped by push is the normal
|
||||
# case. `load_all` reads an absent directory as an empty one.
|
||||
issues = issue.load_all(root)
|
||||
pairs = ledger_pairs(_gitea.load_map(root), args.repo)
|
||||
|
||||
# Every argument is resolved before anything is sent, so a typo in the third
|
||||
# id does not leave the first two closed.
|
||||
targets = []
|
||||
for arg in args.ids:
|
||||
got = resolve(arg, issues, pairs)
|
||||
if got not in targets:
|
||||
targets.append(got)
|
||||
|
||||
# One run, one repo. An explicit --repo is the operator's word and wins;
|
||||
# without one, the repo comes from what the ids themselves said, and two
|
||||
# answers are a question rather than a guess — `repo_base` would otherwise
|
||||
# let `tea` fill the blank from the CWD and close the wrong #42.
|
||||
named = {r for _i, _n, r in targets if r}
|
||||
if not args.repo and len(named) > 1:
|
||||
_gitea.die("all ids must belong to one repo, got: %s" % ", ".join(sorted(named)))
|
||||
repo_arg = args.repo or (sorted(named)[0] if named else None)
|
||||
|
||||
if args.dry_run:
|
||||
for id, number, _repo in targets:
|
||||
iss = issues.get(id)
|
||||
where = ("%s (state: %s)" % (issue.path_of(root, id), iss.state)
|
||||
if iss is not None else "no local copy")
|
||||
print("would %s %s #%d — %s" % (verb, id or "?", number, where))
|
||||
print("%d issue(s) would be %s; no request was made"
|
||||
% (len(targets), past))
|
||||
return
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(repo_arg)
|
||||
|
||||
touched = 0
|
||||
for id, number, _repo in targets:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, number), "PATCH",
|
||||
{"state": state}, payload_name="state-%d" % number)
|
||||
# The gate. Above it nothing local has been written; below it the file
|
||||
# is about to say something the tracker had better agree with.
|
||||
if not confirmed(got, number, state):
|
||||
_gitea.die("#%d: %s failed — the tracker's answer does not confirm the "
|
||||
"write (%.200r). Nothing local was changed."
|
||||
% (number, verb, got))
|
||||
|
||||
print("%s %s #%d %s" % (past, id or "?", number,
|
||||
got.get("html_url", "")))
|
||||
|
||||
iss = issues.get(id)
|
||||
if iss is None:
|
||||
print(" no local copy — pull.py %d to get one" % number)
|
||||
continue
|
||||
print(" state: %s %s" % (state, apply_state(root, iss, state, got)))
|
||||
touched += 1
|
||||
|
||||
if touched:
|
||||
path, n = issue_index.build(root)
|
||||
print("index: %s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
comment.py — post or edit a comment on a synced issue.
|
||||
|
||||
The last issue operation that used to be hand-rolled (`mkdir tmp/comment`,
|
||||
`jq -Rs`, `tea api -X POST`). Entity commands like `tea comment` hang on a
|
||||
multi-line body — an empty-looking positional triggers the $EDITOR fallback on
|
||||
a TTY that does not exist — so everything goes through `tea api` with the
|
||||
payload written to a file first.
|
||||
|
||||
comment.py wire-sqlc-appclick --file notes.md
|
||||
comment.py wire-sqlc-appclick --body "готово, задеплоено"
|
||||
comment.py wire-sqlc-appclick --file fix.md --edit 1234
|
||||
|
||||
The target is a local id, not a number: this layer resolves it through the
|
||||
`gitea:` field. A local-only issue cannot be commented on — there is nothing to
|
||||
comment on yet. After a successful write the comment thread is refetched into
|
||||
<id>.comments.md so the local copy is not stale.
|
||||
|
||||
Comments are pull-only in the store: nothing round-trips them back, and editing
|
||||
<id>.comments.md by hand changes nothing in Gitea.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Comment on a synced issue")
|
||||
ap.add_argument("id", help="local issue id (must already be in Gitea)")
|
||||
src = ap.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--file", help="markdown file holding the comment body")
|
||||
src.add_argument("--body", help="comment body inline (short, single-line)")
|
||||
ap.add_argument("--edit", type=int, metavar="COMMENT_ID",
|
||||
help="PATCH an existing comment instead of posting a new one")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
_gitea.die("store %s does not exist — nothing was created" % root)
|
||||
if not os.path.isfile(issue.path_of(root, args.id)):
|
||||
_gitea.die("no issue %r in %s" % (args.id, root))
|
||||
iss = issue.load(root, args.id)
|
||||
|
||||
number = gmap.number_of(iss)
|
||||
if not number:
|
||||
_gitea.die("%s is local-only (no gitea: field) — push it first" % args.id)
|
||||
|
||||
if args.file:
|
||||
if not os.path.isfile(args.file):
|
||||
_gitea.die("no such file: %s" % args.file)
|
||||
with open(args.file) as f:
|
||||
body = f.read().strip()
|
||||
else:
|
||||
body = args.body.strip()
|
||||
if not body:
|
||||
_gitea.die("empty comment body")
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
|
||||
if args.edit:
|
||||
got = _gitea.api(login, "%s/issues/comments/%d" % (base, args.edit), "PATCH",
|
||||
{"body": body}, payload_name="comment-%d" % args.edit)
|
||||
verb = "edited"
|
||||
else:
|
||||
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
|
||||
{"body": body}, payload_name="comment-%s" % args.id)
|
||||
verb = "posted"
|
||||
if not isinstance(got, dict) or "id" not in got:
|
||||
_gitea.die("%s failed, unexpected response" % verb)
|
||||
|
||||
comments = _gitea.get_comments(login, base, number)
|
||||
cpath = os.path.join(root, "%s.comments.md" % args.id)
|
||||
if comments:
|
||||
with open(cpath, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
elif os.path.isfile(cpath):
|
||||
os.remove(cpath)
|
||||
|
||||
print("%s comment %s on %s (#%d) %s"
|
||||
% (verb, got["id"], args.id, number, got.get("html_url", "")))
|
||||
print("thread: %s (%d comment(s))" % (cpath, len(comments)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
evict.py — ask Gitea which stored issues are closed, then evict those.
|
||||
|
||||
evict.py check every synced issue in the store, evict the
|
||||
ones Gitea says are closed
|
||||
evict.py old-thing … only these
|
||||
evict.py --dry-run ask, report, change nothing
|
||||
|
||||
The offline command is `/tea:issue`'s `issue_evict.py`, and it is the one that
|
||||
decides and deletes — this script adds exactly one thing in front of it: a
|
||||
`state:` that is not stale. A local `state:` is only as fresh as the last pull,
|
||||
so an issue closed in the web UI an hour ago still reads `open` here and the
|
||||
offline command will (correctly) leave it alone. That is the gap this closes,
|
||||
and it is the observed workflow: before this existed the operator had to
|
||||
`pull.py 11 12 13 14 15` first, which re-wrote the five closed files onto disk
|
||||
before anything could remove them.
|
||||
|
||||
Order of operations, and it is the whole safety argument:
|
||||
|
||||
1. every candidate's state is fetched — ALL of them, before anything is
|
||||
removed;
|
||||
2. each answer must be an object carrying the number we asked about and a
|
||||
state from the domain's own vocabulary (`confirmed_state`);
|
||||
3. only then is the eviction run, by handing the refreshed issues to
|
||||
`issue_evict.run` — the same decision, the same deletion, the same
|
||||
protection of `origin: local`, in one place.
|
||||
|
||||
A `tea` that will not run, a non-2xx, an answer for another issue, a state
|
||||
nobody recognizes: the run stops at step 2 and NOTHING is deleted, not even the
|
||||
issues whose answers had already arrived. That is stricter than `push.py`, which
|
||||
deletes as it goes, and it costs nothing here — there is no ordering constraint
|
||||
between evictions, so there is no reason to start before every answer is in.
|
||||
|
||||
A candidate is an issue carrying a `gitea:` handle. `origin: local` work has
|
||||
none, is never asked about, and is never evicted — it is not in the tracker to
|
||||
be closed. An `origin: gitea` issue whose handle is missing or unparseable
|
||||
cannot be verified, so it is reported and kept rather than guessed at.
|
||||
|
||||
Cost: one GET per candidate. The store is a working set that push keeps small,
|
||||
and a wrong answer here deletes a file, so each issue is asked about by its own
|
||||
address rather than inferred from a list that a `--limit` could have truncated.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_evict # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def candidates(issues, ids=None):
|
||||
"""(checkable, unverifiable) — which issues the tracker can be asked about.
|
||||
|
||||
checkable is [(id, repo, number)] read off the `gitea:` handle, so an issue
|
||||
that lives in another repo is asked about there. unverifiable is
|
||||
[(id, why)]: it names a tracker but carries no handle to reach it by, which
|
||||
is a file to report, never one to delete on a guess.
|
||||
|
||||
An `origin: local` issue is in neither list. It has no handle because it has
|
||||
never left this machine, and asking Gitea about it is not a question that
|
||||
has an answer.
|
||||
"""
|
||||
checkable, unverifiable = [], []
|
||||
for id in (list(ids) if ids else sorted(issues)):
|
||||
iss = issues[id]
|
||||
if iss.is_local:
|
||||
continue
|
||||
repo, number = gmap.parse_remote_key(iss.extra.get("gitea", ""))
|
||||
if not repo or not number:
|
||||
unverifiable.append((id, "origin: %s but no usable `gitea:` handle"
|
||||
% iss.origin))
|
||||
continue
|
||||
checkable.append((id, repo, number))
|
||||
return checkable, unverifiable
|
||||
|
||||
|
||||
def confirmed_state(got, number):
|
||||
"""The state Gitea confirmed for `number`, or None — the deletion gate.
|
||||
|
||||
The counterpart of `push.confirmed_number`, and written the same way: boring,
|
||||
and saying no by default, because everything downstream of a `str` return
|
||||
here may delete a file. An answer counts only when it is a dict, carries the
|
||||
very number we asked about, and names a state the domain recognizes.
|
||||
|
||||
`bool` is rejected explicitly: `True` is an `int` in Python, and an answer
|
||||
about issue `true` is not an answer about issue 42.
|
||||
|
||||
What it does not have to catch, because it never gets here: a non-2xx or a
|
||||
`tea` that would not run at all — `_gitea.api` exits on both.
|
||||
"""
|
||||
if not isinstance(got, dict):
|
||||
return None
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n != number:
|
||||
return None
|
||||
state = got.get("state")
|
||||
return state if state in issue.STATES else None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Evict issues Gitea reports as closed from the local store")
|
||||
ap.add_argument("ids", nargs="*",
|
||||
help="issue ids (default: every synced issue in the store)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="ask the tracker and report; write and delete nothing")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
root = args.out
|
||||
if not issue.store_exists(root):
|
||||
_gitea.die("store %s does not exist — nothing to evict" % root)
|
||||
issues = issue.load_all(root)
|
||||
missing = [i for i in args.ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
|
||||
checkable, unverifiable = candidates(issues, args.ids)
|
||||
for id, why in unverifiable:
|
||||
_gitea.warn("%s: %s — kept, and not asked about" % (id, why))
|
||||
if not checkable:
|
||||
print("nothing to check: no issue in the store carries a `gitea:` handle")
|
||||
return 0
|
||||
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- every answer first, deletions after -----------------------------
|
||||
fresh = {}
|
||||
for id, repo, number in checkable:
|
||||
got = _gitea.api(login, "%s/issues/%d" % (_gitea.repo_base(repo), number))
|
||||
state = confirmed_state(got, number)
|
||||
if state is None:
|
||||
_gitea.die("%s: the tracker's answer for %s#%d does not confirm a state "
|
||||
"(%.200r). Nothing was evicted."
|
||||
% (id, repo, number, got))
|
||||
fresh[id] = state
|
||||
|
||||
# The store stops lying even about the issues that stay: an answer already
|
||||
# paid for is written back when it disagrees with the file. This is the only
|
||||
# write this script makes, and a dry run makes none.
|
||||
for id, state in sorted(fresh.items()):
|
||||
was = issues[id].state
|
||||
if was == state:
|
||||
continue
|
||||
print("state %s %s -> %s" % (id, was, state))
|
||||
issues[id].state = state
|
||||
if not args.dry_run:
|
||||
issue.save(root, issues[id])
|
||||
|
||||
issue_evict.run(root, issues, [id for id, _, _ in checkable], args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
labels.py — put the canonical label set into a repository, in one run.
|
||||
|
||||
Every `type/*` and every `severity/*` the domain taxonomy defines, created up
|
||||
front instead of trickling in as a side effect of whichever push first happens
|
||||
to use one. Until a name exists in the repository nobody can filter by it in
|
||||
the web UI, so somebody makes their own with a foreign color and without
|
||||
`exclusive`, and the set arrives in pieces over months.
|
||||
|
||||
labels.py --dry-run print the plan, write nothing
|
||||
labels.py create whatever is missing
|
||||
labels.py --fix also patch color / `exclusive` drift
|
||||
labels.py --repo owner/repo outside the repository's own checkout
|
||||
|
||||
No label name is spelled out in this file. The names are assembled from the
|
||||
domain — issue.TYPES, issue.SEVERITIES, issue.EXCLUSIVE_NS — and painted by
|
||||
map.label_specs; add a type over in skills/issue and the next run creates it.
|
||||
`tea labels create` cannot set `exclusive` (tea 0.14.2), so creation goes
|
||||
through `tea api`.
|
||||
|
||||
The repository's own labels are read before anything is written. A name that
|
||||
matches exactly is left alone — never re-created, never patched; a color or
|
||||
`exclusive` that disagrees with the spec is reported, and corrected only under
|
||||
--fix. A name that merely RESEMBLES a canonical one (the same tail, up to
|
||||
case, separator and whatever namespace is in front: `X`, `x`, `kind/x`,
|
||||
`type: x` against `type/x`) is reported with its id and never touched —
|
||||
renaming somebody else's label is a decision, not a step.
|
||||
|
||||
Out of scope by design: `tech/*` and `comp/*`, which are open-ended and get
|
||||
created by push as they come up, and deleting or renaming anything at all.
|
||||
Only repository labels are read; an organization's own labels sit behind a
|
||||
different endpoint and are neither read nor written.
|
||||
|
||||
The issue store is out of scope too, and not incidentally. A label belongs to
|
||||
the repository, not to any issue, so this command neither reads tmp/issues/ nor
|
||||
creates it — the taxonomy it paints comes from the domain MODULE, and the
|
||||
request bodies it sends go to the transport's own tmp/payload/.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the canonical set
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Which taxonomy collection fills which exclusive namespace. Both sides are the
|
||||
# domain's — this dict is only the join between them, and it is the whole
|
||||
# reason no name has to be repeated here.
|
||||
MEMBERS = {"type/": issue.TYPES, "severity/": issue.SEVERITIES}
|
||||
|
||||
|
||||
def canonical_names():
|
||||
"""Every name in the canonical set, in taxonomy order.
|
||||
|
||||
Which namespaces are exclusive is issue.EXCLUSIVE_NS; what lives in each
|
||||
is MEMBERS, i.e. the domain again. A namespace the domain declares but
|
||||
MEMBERS does not know about is handed back separately — better reported
|
||||
than quietly missing from the set."""
|
||||
names, orphan = [], []
|
||||
for ns in issue.EXCLUSIVE_NS:
|
||||
if ns in MEMBERS:
|
||||
names += [ns + m for m in MEMBERS[ns]]
|
||||
else:
|
||||
orphan.append(ns)
|
||||
return names, orphan
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# lookalikes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
WORDS = re.compile(r'[^a-z0-9]+')
|
||||
|
||||
|
||||
def akin(name):
|
||||
"""Comparison keys for a label name: its tail, and the whole name squashed.
|
||||
|
||||
Case, separators and the namespace in front are noise — what a person
|
||||
meant is the tail. `x`, `X`, `kind/x` all reduce to the same tail as
|
||||
`type/x`, and `severity: x y` to the same squashed form as `severity/xy`.
|
||||
Two names resemble each other when these sets intersect."""
|
||||
parts = [p for p in WORDS.split(name.lower()) if p]
|
||||
return {parts[-1], "".join(parts)} if parts else set()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# plan
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def color_of(value):
|
||||
"""Gitea reports colors bare, map.py writes them with a `#`. Same color."""
|
||||
return (value or "").lstrip("#").lower()
|
||||
|
||||
|
||||
def drift_of(spec, got):
|
||||
"""Where an existing label disagrees with the spec, as (field, is, want).
|
||||
|
||||
Only color and `exclusive` — a description somebody rewrote is theirs, and
|
||||
the name matched exactly or we would not be here."""
|
||||
out = []
|
||||
if color_of(got.get("color")) != color_of(spec.get("color")):
|
||||
out.append(("color", color_of(got.get("color")), color_of(spec.get("color"))))
|
||||
if bool(got.get("exclusive")) != bool(spec.get("exclusive")):
|
||||
out.append(("exclusive", str(bool(got.get("exclusive"))).lower(),
|
||||
str(bool(spec.get("exclusive"))).lower()))
|
||||
return out
|
||||
|
||||
|
||||
def plan(specs, existing):
|
||||
"""(rows, similar) for one repository, decided before anything is written.
|
||||
|
||||
A row is (name, spec, got, drift), one per canonical label in taxonomy
|
||||
order: `got` is the repository's own payload when that exact name is
|
||||
already there (None when it is not), `drift` what disagrees with the spec.
|
||||
|
||||
`similar` is (name, id, [canonical it resembles]) for the repository's
|
||||
other labels. They are reported and left alone: this script owns the
|
||||
canonical names, not everything that looks like one."""
|
||||
by_name = dict((l.get("name", ""), l) for l in existing or [])
|
||||
|
||||
rows = []
|
||||
for name in specs:
|
||||
got = by_name.get(name)
|
||||
rows.append((name, specs[name], got, drift_of(specs[name], got) if got else []))
|
||||
|
||||
keys = dict((name, akin(name)) for name in specs)
|
||||
similar = []
|
||||
for l in existing or []:
|
||||
name = l.get("name", "")
|
||||
if name in specs:
|
||||
continue
|
||||
mine = akin(name)
|
||||
hits = [n for n in specs if keys[n] & mine]
|
||||
if hits:
|
||||
similar.append((name, l.get("id"), hits))
|
||||
return rows, similar
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# run
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Create the canonical type/* and severity/* labels in a repository")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="print the plan; not one writing request")
|
||||
ap.add_argument("--fix", action="store_true",
|
||||
help="also patch color/exclusive on labels that already exist")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
args = ap.parse_args()
|
||||
|
||||
names, orphan = canonical_names()
|
||||
for ns in orphan:
|
||||
_gitea.warn("namespace %r is exclusive in the domain but has no members here "
|
||||
"— nothing created for it" % ns)
|
||||
specs = gmap.label_specs(names)
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
|
||||
# Read first, always: the plan is decided against the repository itself,
|
||||
# never against tmp/issues/.labels.json. That cache is what makes
|
||||
# _gitea.ensure_labels cheap for push.py and wrong for a bootstrap — it
|
||||
# answers "what did we create last time", and the answer here has to be
|
||||
# "what does the repository have right now".
|
||||
existing = _gitea.paginate(login, "%s/labels" % base, limit=100)
|
||||
rows, similar = plan(specs, existing)
|
||||
|
||||
fixed, drifted = 0, 0
|
||||
for name, spec, got, drift in rows:
|
||||
mark = " exclusive" if spec.get("exclusive") else ""
|
||||
|
||||
if got is None:
|
||||
if args.dry_run:
|
||||
print("create %-20s %s%s" % (name, spec["color"], mark))
|
||||
continue
|
||||
payload = dict(spec, name=name)
|
||||
new = _gitea.api(login, "%s/labels" % base, "POST", payload,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
if not new or "id" not in new:
|
||||
_gitea.die("could not create label %r" % name)
|
||||
print("created %-20s id %-5s %s%s" % (name, new["id"], spec["color"], mark))
|
||||
continue
|
||||
|
||||
if not drift:
|
||||
print("present %-20s id %s" % (name, got.get("id")))
|
||||
continue
|
||||
|
||||
drifted += 1
|
||||
shown = ", ".join("%s %s -> %s" % d for d in drift)
|
||||
if not args.fix:
|
||||
print("present %-20s id %-5s drift: %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
if args.dry_run:
|
||||
print("fix %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
continue
|
||||
# Gitea 1.26 patches only the fields it is given, but the unchanged
|
||||
# name and description ride along anyway: they cost nothing and an
|
||||
# older server that reads an absent field as empty would blank them.
|
||||
patch = {"name": name, "description": got.get("description") or ""}
|
||||
for field, _is, _want in drift:
|
||||
patch[field] = spec[field]
|
||||
_gitea.api(login, "%s/labels/%s" % (base, got.get("id")), "PATCH", patch,
|
||||
payload_name="label-%s" % name.replace("/", "-"))
|
||||
fixed += 1
|
||||
print("fixed %-20s id %-5s %s" % (name, got.get("id"), shown))
|
||||
|
||||
for name, id, hits in similar:
|
||||
_gitea.warn("%r (id %s) resembles %s — left alone; rename it by hand or ignore it"
|
||||
% (name, id, ", ".join(hits)))
|
||||
|
||||
missing = sum(1 for r in rows if r[2] is None)
|
||||
print("%d canonical label(s): %d %s, %d present%s%s"
|
||||
% (len(rows), missing, "to create" if args.dry_run else "created",
|
||||
len(rows) - missing,
|
||||
" (%d drifted, %d fixed)" % (drifted, fixed) if drifted else "",
|
||||
", %d similar" % len(similar) if similar else ""))
|
||||
if drifted and not args.fix:
|
||||
print("drift is shown, not applied — re-run with --fix to patch color/exclusive")
|
||||
if args.dry_run:
|
||||
print("dry-run — nothing was written")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
map.py — md <-> Gitea JSON. The whole translation, and only the translation.
|
||||
|
||||
Pure functions: no network, no filesystem, no argparse. Give it a payload and
|
||||
it hands back a domain Issue; give it an Issue and it hands back a request
|
||||
body. That purity is the point — it can be reasoned about and tested without a
|
||||
Gitea anywhere, and it is the single file to open when the two representations
|
||||
disagree.
|
||||
|
||||
Direction of knowledge: this module imports the domain (issue.py) and is
|
||||
imported by the transport's callers. The domain never imports this.
|
||||
|
||||
What crosses the boundary, and what does not:
|
||||
|
||||
domain Gitea note
|
||||
----------------------------------------------------------------------
|
||||
id (slug) body marker `<!-- tea:id … -->`, first line of
|
||||
the tracker-side body; stripped out
|
||||
of the local copy — see below
|
||||
title title verbatim, both ways
|
||||
body body verbatim up, verbatim down except
|
||||
the marker and checkbox state — see
|
||||
with_id_marker / merge_checkbox_state
|
||||
state state open/closed, same vocabulary
|
||||
labels labels[] names both ways; ids only on write
|
||||
assignees assignees[] logins
|
||||
milestone milestone.title resolved to an id on write
|
||||
depends — slugs; #N is translated at the edge
|
||||
— number, html_url lands in extra as gitea:/url:
|
||||
— ref extra as branch:; push fills it from git
|
||||
|
||||
`depends:` is the authoritative graph and is always slugs. The body's
|
||||
`## Depends on` section is human prose and is passed through UNCHANGED in both
|
||||
directions: a pull seeds `depends:` from the `#N` it finds there, and a push
|
||||
never rewrites what the author wrote. Deliberate — a translator that edits
|
||||
prose churns the body on every round trip.
|
||||
|
||||
The ONE thing this module does add to a body is the id marker, and it does so
|
||||
because the slug now has to survive a push: `push.py` deletes the local file,
|
||||
so the tracker has to remember what the issue was called here. See
|
||||
`with_id_marker`.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.normpath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "issue", "scripts")))
|
||||
import issue # noqa: E402
|
||||
|
||||
# How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
||||
# an issue IS, which is exactly why it lives here and not in the domain.
|
||||
LABEL_COLORS = {
|
||||
"type/bug": "#ee0701",
|
||||
"type/task": "#0e8a16",
|
||||
"type/refactor": "#1d76db",
|
||||
"type/test": "#fbca04",
|
||||
"type/feature": "#5319e7",
|
||||
"type/draft": "#cccccc",
|
||||
"severity/low": "#c2e0c6",
|
||||
"severity/medium": "#fbca04",
|
||||
"severity/high": "#eb6420",
|
||||
"severity/showstopper": "#ee0701",
|
||||
"severity/critical": "#b60205",
|
||||
}
|
||||
DEFAULT_COLOR = "#ededed"
|
||||
|
||||
# What this bridge writes into the domain's `origin:` field. The domain records
|
||||
# that an issue exists somewhere else; only this module knows where.
|
||||
ORIGIN = "gitea"
|
||||
|
||||
# Metadata key for Gitea's `ref` — the branch an issue is pinned to. A sync
|
||||
# field: its value is a git branch name and means exactly `ref`, so the domain
|
||||
# carries it in `extra` and never reads it.
|
||||
BRANCH_KEY = "branch"
|
||||
|
||||
|
||||
def label_specs(names):
|
||||
"""{name: {color, description, exclusive}} for the transport to create.
|
||||
|
||||
Exclusivity and meaning come from the domain taxonomy; only the color is
|
||||
decided here. `tea labels create` cannot set `exclusive` (as of 0.14.2),
|
||||
which is why these go through the API."""
|
||||
out = {}
|
||||
for name in names:
|
||||
desc = ""
|
||||
if name.startswith("type/"):
|
||||
desc = issue.TYPES.get(name.split("/", 1)[1], "")
|
||||
out[name] = {
|
||||
"color": LABEL_COLORS.get(name, DEFAULT_COLOR),
|
||||
"description": desc,
|
||||
"exclusive": name.startswith(issue.EXCLUSIVE_NS),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def remote_key(repo, number):
|
||||
"""Stable cross-repo handle: owner/repo#42."""
|
||||
return "%s#%d" % (repo, int(number))
|
||||
|
||||
|
||||
def parse_remote_key(key):
|
||||
repo, _, num = (key or "").rpartition("#")
|
||||
return (repo, int(num)) if repo and num.isdigit() else (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# the id marker: the slug, kept tracker-side
|
||||
# --------------------------------------------------------------------------
|
||||
# `push.py` deletes the local file once the tracker has confirmed the write, so
|
||||
# the slug — the issue's ONLY identity in the domain — cannot live only on this
|
||||
# machine any more. It rides up in the body as an HTML comment:
|
||||
#
|
||||
# <!-- tea:id wire-sqlc-appclick -->
|
||||
#
|
||||
# Why the body and not `.remote.json`: the map is a local file, and "the local
|
||||
# copy is not the record" is the whole point of deleting it. A marker in the
|
||||
# body survives a rename in the web UI, a lost `.remote.json`, a fresh clone,
|
||||
# and a second machine — none of which the map does. Why an HTML comment: Gitea
|
||||
# renders markdown, so it is invisible to a human reader, and it comes back
|
||||
# verbatim on every API read.
|
||||
#
|
||||
# WHERE: the first line of the tracker-side body, followed by one blank line.
|
||||
# First because it is the one position that does not depend on what sections the
|
||||
# issue happens to have, and because a human who does look at the raw markdown
|
||||
# finds it before the prose rather than buried in it.
|
||||
#
|
||||
# WHAT THE LOCAL FILE SEES: nothing. `from_api` strips every marker before the
|
||||
# body is written to disk, so `tmp/issues/<id>.md` holds exactly what the author
|
||||
# wrote — checkbox line numbers, `issue_check.py`, and diffs are all unaffected,
|
||||
# and the slug is already the file's name, so a copy of it in the body would be
|
||||
# duplicated state.
|
||||
#
|
||||
# WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
|
||||
# strip-all-then-prepend-one. `with_id_marker` never appends to what is there,
|
||||
# and `strip_id_marker` removes EVERY marker line, not the first. So a body that
|
||||
# somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on the
|
||||
# next pull and goes back up with exactly one. There is no code path that adds
|
||||
# a marker to a body that has not just been stripped.
|
||||
|
||||
_MARKER_LINE = re.compile(r'^[ \t]*<!--[ \t]*tea:id[ \t]+(\S+)[ \t]*-->[ \t]*$')
|
||||
|
||||
|
||||
def id_marker(id):
|
||||
"""The marker line for a slug. One place formats it, one regex reads it."""
|
||||
return "<!-- tea:id %s -->" % id
|
||||
|
||||
|
||||
def id_in_body(body):
|
||||
"""The slug a tracker-side body claims, or None.
|
||||
|
||||
The FIRST valid marker wins; a second one is ignored here and removed by
|
||||
`strip_id_marker` on the way in. The captured text must be a slug by the
|
||||
domain's own rule — a marker holding anything else is not a slug and is
|
||||
treated as if it were not there, so a mangled comment falls back to the
|
||||
title instead of naming a file after garbage."""
|
||||
for line in (body or "").splitlines():
|
||||
m = _MARKER_LINE.match(line)
|
||||
if m and issue.SLUG_OK.match(m.group(1)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def strip_id_marker(body):
|
||||
"""`body` with every marker line removed. Idempotent.
|
||||
|
||||
A body that carries no marker is returned byte for byte — the common case
|
||||
(an issue filed in the web UI) costs nothing and is not reformatted. When a
|
||||
marker is removed from the top, the blank line it was written with goes with
|
||||
it, so the round trip is exact: strip(with_id_marker(b, id)) == b."""
|
||||
text = body or ""
|
||||
if not any(_MARKER_LINE.match(l) for l in text.splitlines()):
|
||||
return text
|
||||
kept = [l for l in text.splitlines() if not _MARKER_LINE.match(l)]
|
||||
return "\n".join(kept).lstrip("\n")
|
||||
|
||||
|
||||
def with_id_marker(body, id):
|
||||
"""`body` with exactly one marker, as its first line.
|
||||
|
||||
Strip-then-prepend, always — that is the guarantee that a body can never end
|
||||
up with two, however many it arrived with."""
|
||||
return "%s\n\n%s" % (id_marker(id), strip_id_marker(body))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea -> domain
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def numbers_in_body(body):
|
||||
"""`#N` referenced from the body's dependency sections, as ints. Used only
|
||||
to seed `depends:` on the first pull."""
|
||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
||||
|
||||
|
||||
def merge_checkbox_state(remote_body, local_body):
|
||||
"""The remote body with every tick the local copy already had put back.
|
||||
|
||||
The one exception to "a pull overwrites the body", and it is deliberately
|
||||
the narrowest one that works. A tick is **monotone** — an item only ever
|
||||
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
||||
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
||||
set is a set of item TEXTS, and an item comes out ticked when either side
|
||||
has it ticked. Everything else in the body is still the remote's word.
|
||||
|
||||
Matching is on `Checkbox.text`, which the domain parser has already
|
||||
stripped and rejoined with single spaces, so rewrapping a long item does
|
||||
not cost it its tick. It is otherwise literal: reword an item and it is a
|
||||
different item — the tick stays with the wording it was put on.
|
||||
|
||||
**The same text more than once** is read as the rule says, as a set: one
|
||||
ticked local item ticks every remote item with that text. The alternative —
|
||||
pairing duplicates up by order — is the reading that can still drop a tick
|
||||
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
||||
nothing), and dropping a tick is the bug this exists to fix. Two items
|
||||
whose text is identical are the same item to whoever reads them.
|
||||
|
||||
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
||||
pull, an empty store — returns the remote body untouched.
|
||||
|
||||
The price, accepted explicitly: UNticking is not monotone, so a box
|
||||
unticked in the web UI comes back on the next pull. Untick locally, push.
|
||||
"""
|
||||
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
||||
if not ticked:
|
||||
return remote_body
|
||||
body = remote_body
|
||||
# set_checkbox trades one character for one character, so line numbers read
|
||||
# off `remote_body` stay valid against the partially rewritten `body`.
|
||||
for c in issue.checkboxes(remote_body):
|
||||
if not c.checked and c.text in ticked:
|
||||
body = issue.set_checkbox(body, c.line, True)
|
||||
return body
|
||||
|
||||
|
||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
||||
local_body=None):
|
||||
"""Build a domain Issue from a Gitea issue payload.
|
||||
|
||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
||||
target has not been pulled yet are dropped from `depends:` (the body still
|
||||
names them, so nothing is lost) rather than invented.
|
||||
|
||||
`local_body` is the body of the copy already in the store, when there is
|
||||
one. It contributes exactly one thing: its ticked checkboxes survive the
|
||||
overwrite (merge_checkbox_state). Pass None and the remote body is taken
|
||||
whole, which is what a first pull does.
|
||||
|
||||
The id marker is stripped before anything else looks at the body: it is
|
||||
transport bookkeeping, and the caller has already read the slug off it
|
||||
(`pull.id_for`). Everything downstream — checkboxes, `#N` references, what
|
||||
lands on disk — sees the body the author wrote."""
|
||||
body = merge_checkbox_state(
|
||||
strip_id_marker((payload.get("body") or "").strip()), local_body)
|
||||
id_for_number = id_for_number or {}
|
||||
|
||||
numbers = list(numbers_in_body(body))
|
||||
for n in extra_numbers:
|
||||
if n not in numbers:
|
||||
numbers.append(n)
|
||||
depends, unresolved = [], []
|
||||
for n in numbers:
|
||||
slug = id_for_number.get(n)
|
||||
if slug and slug != id and slug not in depends:
|
||||
depends.append(slug)
|
||||
elif not slug:
|
||||
unresolved.append(n)
|
||||
|
||||
extra = {
|
||||
"gitea": remote_key(repo, payload["number"]),
|
||||
"url": payload.get("html_url", ""),
|
||||
"synced": synced or "",
|
||||
}
|
||||
if payload.get("ref"):
|
||||
extra[BRANCH_KEY] = payload["ref"]
|
||||
if payload.get("updated_at"):
|
||||
extra["remote-updated"] = payload["updated_at"]
|
||||
if payload.get("comments"):
|
||||
extra["comments"] = payload["comments"]
|
||||
|
||||
iss = issue.Issue(
|
||||
id=id,
|
||||
title=payload.get("title", ""),
|
||||
body=body,
|
||||
state=payload.get("state") or "open",
|
||||
labels=[l.get("name", "") for l in payload.get("labels") or []],
|
||||
assignees=[a.get("login", "") for a in payload.get("assignees") or []],
|
||||
milestone=(payload.get("milestone") or {}).get("title") or "",
|
||||
depends=depends,
|
||||
origin=ORIGIN,
|
||||
extra=extra)
|
||||
return iss, unresolved
|
||||
|
||||
|
||||
def render_comments(comments):
|
||||
"""Comment thread as flat markdown. Read-only: nothing writes it back."""
|
||||
out = []
|
||||
for c in comments:
|
||||
out.append("## comment %s — %s — %s" % (
|
||||
c.get("id"), (c.get("user") or {}).get("login", ""),
|
||||
(c.get("created_at") or "")[:10]))
|
||||
out.append("")
|
||||
out.append((c.get("body") or "(empty)").strip())
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# domain -> Gitea
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def to_payload(iss, label_ids=None, milestone_id=None, include_state=False):
|
||||
"""Request body for POST /issues or PATCH /issues/{n}.
|
||||
|
||||
The prose is sent verbatim — see the module docstring on why slugs in
|
||||
`## Depends on` are not rewritten to `#N`. The one addition is the id
|
||||
marker, prepended (never appended) so the tracker remembers the slug after
|
||||
push has deleted the local file. `from_api` takes it straight back off, so
|
||||
the body still round-trips byte for byte."""
|
||||
payload = {"title": iss.title,
|
||||
"body": with_id_marker(iss.body.strip(), iss.id)}
|
||||
if label_ids is not None:
|
||||
payload["labels"] = [label_ids[l] for l in iss.labels if l in label_ids]
|
||||
if iss.assignees:
|
||||
payload["assignees"] = list(iss.assignees)
|
||||
if milestone_id is not None:
|
||||
payload["milestone"] = milestone_id
|
||||
if include_state:
|
||||
payload["state"] = iss.state
|
||||
# An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||
# clear whatever is set on the Gitea side, so the key is left out instead.
|
||||
branch = (iss.extra.get(BRANCH_KEY) or "").strip()
|
||||
if branch:
|
||||
payload["ref"] = branch
|
||||
return payload
|
||||
|
||||
|
||||
def apply_remote(iss, payload, repo, synced):
|
||||
"""Stamp the sync-owned fields onto an issue after a successful write.
|
||||
Mutates and returns it; `origin` is the one domain field this touches."""
|
||||
iss.origin = ORIGIN
|
||||
iss.extra["gitea"] = remote_key(repo, payload["number"])
|
||||
iss.extra["url"] = payload.get("html_url", "")
|
||||
iss.extra["synced"] = synced
|
||||
if payload.get("updated_at"):
|
||||
iss.extra["remote-updated"] = payload["updated_at"]
|
||||
return iss
|
||||
|
||||
|
||||
def number_of(iss):
|
||||
"""Gitea number for an already-synced issue, or None."""
|
||||
_repo, n = parse_remote_key(iss.extra.get("gitea", ""))
|
||||
return n
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pull.py — Gitea issues -> the local store.
|
||||
|
||||
Writes flat markdown the domain layer owns and prints a compact index; the raw
|
||||
API payload never reaches the conversation.
|
||||
|
||||
**This is how you get a pushed issue back.** `push.py` deletes the local file
|
||||
once Gitea has confirmed it, so pulling is not a refresh of a copy you kept —
|
||||
it is how the copy comes to exist. It lands under the SAME slug it had before,
|
||||
even after a rename in the web UI and even on a machine that has never seen the
|
||||
issue: the slug travels in the body as `<!-- tea:id … -->`, and
|
||||
tmp/issues/.remote.json indexes it by number. See `id_for` for the order those
|
||||
are consulted in. The marker itself is stripped out of what is written to disk.
|
||||
|
||||
Two ways to name what to pull:
|
||||
|
||||
pull.py 42 [17 …] by key: 42 | #42 | owner/repo#42 | URL
|
||||
pull.py --milestone 6 by filter: whole milestone in ONE request
|
||||
pull.py --label type/bug --state all
|
||||
pull.py -q sqlc --limit 20
|
||||
|
||||
Filter mode costs one request per 50 issues — the list payload already carries
|
||||
the bodies. Gitea silently ignores an unresolvable `milestones=` filter and
|
||||
returns the whole backlog, so the milestone is resolved up front and every
|
||||
issue is re-checked locally. Projects are NOT filterable: the projects API is
|
||||
not exposed (404 on Gitea 1.26) — use milestones or labels, or the web UI.
|
||||
|
||||
A closed issue is not a unit of work, so filter mode enumerates it but leaves
|
||||
it out of the store: `--state all` still shows the whole picture, and only
|
||||
`--state closed` writes one. An issue already on disk is refreshed either way,
|
||||
so the local copy learns it was closed instead of staying open forever, and the
|
||||
count of the ones left out goes to stderr. Key mode is exempt: an address is not
|
||||
a bulk read, and `pull.py 1` fetches a closed issue as it always did.
|
||||
|
||||
**`--limit` is on the write, not on the selection.** It counts the issues this
|
||||
run puts in the store — written, or left in place by `--cached` — and never the
|
||||
closed ones it enumerated and threw away. `--limit 20` over a milestone whose
|
||||
first 30 issues are closed still writes 20, if 20 open ones are there to write:
|
||||
pages keep coming until the budget is full. Two boundaries keep that honest:
|
||||
|
||||
- Pages stop the moment the budget is full. Never one page more.
|
||||
- A filtered read may scan at most `_gitea.PAGE_SLACK` times the pages the limit
|
||||
would need if nothing were dropped. A filter that matches almost only closed
|
||||
issues therefore ends in a warning and a short answer, not in a walk of the
|
||||
whole tracker. Narrow the filter, or raise `--limit`, which raises the budget
|
||||
with it.
|
||||
- Dependencies are outside the count: a blocker is followed because a stored
|
||||
issue named it, not because the filter selected it. `--limit 20` can
|
||||
therefore leave more than 20 files behind — the budget counts the selection's
|
||||
writes, and the graph is not part of the selection.
|
||||
|
||||
`remote.py` is the deliberate exception, and it is not the same flag twice: it
|
||||
writes nothing at all, so there is no write to bound and its `--limit` means
|
||||
what it says — how many lines to print.
|
||||
|
||||
Comments ride along by default, in both modes and for every issue written:
|
||||
the thread lands in tmp/issues/<id>.comments.md, beside the issue. It costs
|
||||
nothing when there is nothing to fetch — the payload already carries the
|
||||
comment count, so an issue with none makes no request, and a file left over
|
||||
from an earlier pull is deleted. An absent file therefore means "no comments",
|
||||
never "not asked for". The thread is pull-only: editing it changes nothing in
|
||||
Gitea (post with comment.py).
|
||||
|
||||
**Dependencies come with every pull.** A pull answers with the whole unit of
|
||||
work — the issue and what blocks it — so `depends:` is filled from Gitea's
|
||||
native dependency graph and every blocker is pulled too, recursively, down to
|
||||
`--depth` (default 3). That graph is the only source there is: `map.from_api`
|
||||
writes slugs into the `## Depends on` prose and never `#N`, so an edge cannot be
|
||||
recovered from the body. `--no-deps` turns off both halves — no `depends:`, no
|
||||
recursion, and no request spent on either. `--deps` is still accepted and now
|
||||
does nothing; it names what already happens.
|
||||
|
||||
What it costs, stated rather than hidden:
|
||||
|
||||
- **One request per issue that lands in the store** — `GET …/issues/{n}/dependencies`,
|
||||
fetched once and used twice, since the same links both fill `depends:` and
|
||||
tell the walk where to go next. A closed issue that filter mode drops costs
|
||||
nothing: nothing was stored, so there is no unit of work to complete.
|
||||
- **One request per blocker the selection did not already carry** — a `GET` for
|
||||
the issue itself, then its own links, and so on until `--depth`.
|
||||
- So `--milestone X` over 50 open issues is one list request + 50 link requests
|
||||
+ one pair for every blocker outside the milestone, where it used to be one
|
||||
request flat. `--no-deps` is the way back to one.
|
||||
|
||||
**In filter mode a blocker the filter did not select still lands in the store,
|
||||
and that is deliberate.** `--milestone X` can leave an issue from milestone Y on
|
||||
disk and `--label` an unlabelled one: a blocker is followed because a stored
|
||||
issue names it, not because it matched. The one blocker that does not land is a
|
||||
closed one — closed is not a unit of work, filter mode drops it the way it drops
|
||||
any other closed issue, and the `depends:` edge to it goes with it, so nothing
|
||||
points at a file that is not there. Key mode has no such rule and stores it.
|
||||
|
||||
Other flags:
|
||||
--no-deps do not fill depends:, do not follow blockers
|
||||
--deps accepted, does nothing: it is the default now
|
||||
--depth N how deep to follow blockers (default 3)
|
||||
--cached skip issues already on disk (body AND comments)
|
||||
--repo owner/repo default: auto-detect from the CWD git remote
|
||||
|
||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
||||
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
|
||||
on either side wins for any item whose text matches, because a tick is monotone
|
||||
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
||||
has the rule and its price). `--cached` skips an issue before any of that: it is
|
||||
not read and not merged — it still costs its one link request, because a cached
|
||||
issue's blockers can be missing from disk even when it is not (`--cached
|
||||
--no-deps` is the free one). Draw the graph afterwards with the domain's own
|
||||
issue_tree.py — it needs no network.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def id_for(payload, store_ids, remote_map, repo, root):
|
||||
"""The slug this remote issue belongs under. Three sources, in order.
|
||||
|
||||
1. **`.remote.json`, keyed by number.** The local ledger, and the only one
|
||||
that knows about a file sitting on disk right now, so it wins. A
|
||||
retitled issue keeps the slug it was first pulled under.
|
||||
2. **The `<!-- tea:id … -->` marker in the body** (`gmap.id_in_body`). What
|
||||
makes push -> delete -> pull a round trip rather than a rename: the
|
||||
ledger can be lost (a fresh clone, another machine, a deleted
|
||||
`.remote.json`) and the tracker still remembers what this issue is called
|
||||
here — even after the title was changed in the web UI.
|
||||
3. **The title, slugified.** Issues filed in the web UI have no marker and
|
||||
have never had a local name; this is where they get one.
|
||||
|
||||
A marker is only taken at its word when the slug is free. If a file of that
|
||||
name is already in the store, or the ledger has it under another number, the
|
||||
marker is a collision and not an identity — the name is uniquified
|
||||
(`marked-2`) rather than allowed to overwrite somebody else's issue."""
|
||||
got = remote_map.get(gmap.remote_key(repo, payload["number"]))
|
||||
if got:
|
||||
return got
|
||||
marked = gmap.id_in_body(payload.get("body") or "")
|
||||
if marked and marked not in store_ids and marked not in set(remote_map.values()):
|
||||
return marked
|
||||
return issue.unique_id(root, marked or issue.slugify(payload.get("title", "")),
|
||||
taken=store_ids)
|
||||
|
||||
|
||||
def lands_in_store(payload, drop_closed, store_ids, remote_map, repo, root):
|
||||
"""Would this payload leave a file in the store? The `--limit` predicate.
|
||||
|
||||
It has to be the same test the walk below applies, or the budget is spent on
|
||||
issues that never land — which is the bug this exists to prevent. So: a
|
||||
closed issue counts only when the store already has it (it is refreshed, and
|
||||
that is a write); anything else counts, including one `--cached` will skip,
|
||||
because a skipped issue is still an issue the store holds when the run ends.
|
||||
|
||||
Cheap in the common case: only a closed payload costs an `id_for`, and that
|
||||
is a lookup plus, at worst, a stat."""
|
||||
if not (drop_closed and payload.get("state") == "closed"):
|
||||
return True
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
return os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
|
||||
def comments_path(root, id):
|
||||
"""Where an issue's comment thread lives — beside it, under the same slug.
|
||||
Named in `_gitea` because push.py has to delete the same file."""
|
||||
return _gitea.comments_path(root, id)
|
||||
|
||||
|
||||
def sync_comments(login, base, root, id, number, count):
|
||||
"""Bring <id>.comments.md in line with the server; return it, or None when
|
||||
the issue has no thread.
|
||||
|
||||
`count` is the payload's own comment count, so an issue with none costs no
|
||||
request. A file from an earlier pull is removed when the thread is empty:
|
||||
the absence of the file is the answer, not a gap in what was asked for."""
|
||||
path = comments_path(root, id)
|
||||
comments = _gitea.get_comments(login, base, number) if count else []
|
||||
if comments:
|
||||
with open(path, "w") as f:
|
||||
f.write(gmap.render_comments(comments))
|
||||
return path
|
||||
if os.path.isfile(path):
|
||||
os.remove(path) # stale thread from an earlier pull
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Pull Gitea issues into the local store")
|
||||
ap.add_argument("keys", nargs="*", help="issue keys: 42, #42, owner/repo#42, URL")
|
||||
ap.add_argument("--milestone", help="pull a whole milestone (id or title)")
|
||||
ap.add_argument("--label", action="append", default=[],
|
||||
help="filter by label; repeat for AND")
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"],
|
||||
help="filter mode only (default: open)")
|
||||
ap.add_argument("--limit", type=int, default=100,
|
||||
help="filter mode: how many issues to STORE, not to enumerate"
|
||||
" (default: 100)")
|
||||
# Dependencies are the default: a pull answers with the unit of work, not
|
||||
# one row of it. `--deps` stays accepted so the calls and command tables
|
||||
# written against the old default keep working — it now sets what is
|
||||
# already set.
|
||||
ap.add_argument("--no-deps", dest="deps", action="store_false",
|
||||
help="do not fill depends: and do not follow blockers")
|
||||
ap.add_argument("--deps", dest="deps", action="store_true",
|
||||
help="accepted, does nothing: dependencies are followed by default")
|
||||
ap.set_defaults(deps=True)
|
||||
ap.add_argument("--depth", type=int, default=3, help="max dependency depth (default: 3)")
|
||||
ap.add_argument("--cached", action="store_true",
|
||||
help="skip issues already on disk instead of refetching")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
filtered = bool(args.milestone or args.label or args.query)
|
||||
if args.keys and filtered:
|
||||
_gitea.die("pass issue keys OR filters, not both")
|
||||
if not args.keys and not filtered:
|
||||
_gitea.die("nothing to pull: pass issue keys, or --milestone / --label / -q")
|
||||
|
||||
root = args.out
|
||||
# A first pull into a fresh checkout has to create the store; it says so,
|
||||
# and the path is absolute, so it cannot be a stray cwd.
|
||||
if issue.create_store(root):
|
||||
sys.stderr.write("created store %s\n" % os.path.abspath(root))
|
||||
|
||||
login = _gitea.require_login()
|
||||
|
||||
# ---- which repo ------------------------------------------------------
|
||||
repo_arg = args.repo
|
||||
if not repo_arg and args.keys:
|
||||
repos = {_gitea.parse_key(k)[1] for k in args.keys} - {None}
|
||||
if len(repos) > 1:
|
||||
_gitea.die("all keys must belong to one repo, got: %s" % ", ".join(sorted(repos)))
|
||||
repo_arg = repos.pop() if repos else None
|
||||
base = _gitea.repo_base(repo_arg)
|
||||
repo = _gitea.repo_slug(login, repo_arg)
|
||||
|
||||
issues = issue.load_all(root)
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
store_ids = set(issues)
|
||||
number_of_id = {gmap.parse_remote_key(k)[1]: v for k, v in remote_map.items()
|
||||
if gmap.parse_remote_key(k)[0] == repo}
|
||||
|
||||
# A closed issue is not a unit of work: filter mode enumerates it but keeps
|
||||
# it out of the store unless the operator named the state. A key is an
|
||||
# address, not a bulk read, so key mode is exempt.
|
||||
drop_closed = filtered and args.state != "closed"
|
||||
|
||||
written, skipped, dropped, pending = [], [], [], []
|
||||
|
||||
# ---- seeds -----------------------------------------------------------
|
||||
if filtered:
|
||||
# The limit bounds the write, so the transport is told what a write is
|
||||
# and counts those; the closed ones it enumerated on the way come back
|
||||
# in the list anyway, to be reported and dropped below.
|
||||
payloads, ms_title = _gitea.list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit,
|
||||
keep=lambda p: lands_in_store(p, drop_closed, store_ids, remote_map,
|
||||
repo, root))
|
||||
if not payloads:
|
||||
_gitea.die("no issues match that filter")
|
||||
what = []
|
||||
if args.milestone:
|
||||
what.append("milestone %s" % ms_title)
|
||||
what += ["label %s" % l for l in args.label]
|
||||
if args.query:
|
||||
what.append("q=%r" % args.query)
|
||||
sys.stderr.write("%d issue(s) match %s (%s)\n"
|
||||
% (len(payloads), " + ".join(what), args.state))
|
||||
queue = [(p, 0) for p in payloads]
|
||||
seen_numbers = {p["number"] for p in payloads}
|
||||
else:
|
||||
numbers = [_gitea.parse_key(k)[0] for k in args.keys]
|
||||
queue = [(_gitea.get_issue(login, base, n), 0) for n in numbers]
|
||||
seen_numbers = set(numbers)
|
||||
|
||||
# ---- walk ------------------------------------------------------------
|
||||
while queue:
|
||||
payload, depth = queue.pop(0)
|
||||
number = payload["number"]
|
||||
id = id_for(payload, store_ids, remote_map, repo, root)
|
||||
stored = os.path.isfile(issue.path_of(root, id))
|
||||
|
||||
# Closed and not already ours: nothing is written and nothing is asked
|
||||
# of the server for it — not its comments, not its links, and its own
|
||||
# blockers are not followed. The slug stays unclaimed too, so no other
|
||||
# issue ends up pointing `depends:` at a missing file.
|
||||
if drop_closed and payload.get("state") == "closed" and not stored:
|
||||
dropped.append(number)
|
||||
continue # not stored: no unit of work here, so no links are fetched
|
||||
|
||||
store_ids.add(id)
|
||||
number_of_id[number] = id
|
||||
|
||||
# The native links, fetched ONCE for the two things they are for:
|
||||
# filling this issue's `depends:` and telling the walk where to go next.
|
||||
# One request per issue that lands in the store, and only one — the cost
|
||||
# the docstring quotes is this line.
|
||||
deps = _gitea.native_deps(login, base, number) if args.deps else []
|
||||
|
||||
if args.cached and stored:
|
||||
skipped.append(id) # body and thread unread; only the links cost
|
||||
else:
|
||||
# The copy already on disk, as it was when this run started. It
|
||||
# contributes its ticked checkboxes and nothing else; None when
|
||||
# the store has never seen this issue.
|
||||
prev = issues.get(id)
|
||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||
id_for_number=number_of_id,
|
||||
extra_numbers=deps,
|
||||
synced=_gitea.now_iso(),
|
||||
local_body=prev.body if prev else None)
|
||||
issue.save(root, iss)
|
||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
written.append(id)
|
||||
pending.append((id, unresolved))
|
||||
|
||||
if args.deps and depth < args.depth:
|
||||
child_numbers = gmap.numbers_in_body(payload.get("body") or "") + deps
|
||||
for n in child_numbers:
|
||||
if n in seen_numbers:
|
||||
continue
|
||||
seen_numbers.add(n)
|
||||
queue.append((_gitea.get_issue(login, base, n), depth + 1))
|
||||
|
||||
# Nothing is dropped in silence — say how many closed ones stayed out.
|
||||
if dropped:
|
||||
sys.stderr.write("%d closed issue(s) enumerated, not stored"
|
||||
" (--state closed to pull them)\n" % len(dropped))
|
||||
|
||||
# ---- second pass: dependencies that were not yet known on first write --
|
||||
for id, unresolved in pending:
|
||||
newly = [number_of_id[n] for n in unresolved
|
||||
if n in number_of_id and number_of_id[n] != id]
|
||||
if not newly:
|
||||
continue
|
||||
iss = issue.load(root, id)
|
||||
for slug in newly:
|
||||
if slug not in iss.depends:
|
||||
iss.depends.append(slug)
|
||||
issue.save(root, iss)
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
index_path, _ = issue_index.build(root)
|
||||
|
||||
# Compact output — the only thing that lands in the model's context. The
|
||||
# thread rides on the issue's own line; no file means no comments.
|
||||
graph = False
|
||||
for id in sorted(set(written) | set(skipped)):
|
||||
iss = issue.load(root, id)
|
||||
graph = graph or bool(iss.depends)
|
||||
note = " (cached)" if id in skipped else ""
|
||||
cpath = comments_path(root, id)
|
||||
if os.path.isfile(cpath):
|
||||
note += " +%s comments: %s" % (iss.extra.get("comments") or "?", cpath)
|
||||
print("%s [%s] %s — %s %s%s" % (
|
||||
id, ", ".join(iss.labels) or "no labels", iss.title, iss.state,
|
||||
issue.path_of(root, id), note))
|
||||
print("index: %s" % index_path)
|
||||
# Now that dependencies are the default, the hint is worth printing when
|
||||
# there is something to draw, not on every run that could have drawn it.
|
||||
if graph:
|
||||
print("graph: run issue_tree.py (offline) to draw it")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
push.py — local store -> Gitea, and the local copy goes away.
|
||||
|
||||
**A successful push deletes `tmp/issues/<id>.md` and `<id>.comments.md`.** Once
|
||||
the tracker has the issue, the tracker IS the issue: what is left in the store
|
||||
is only what has not left this machine. Get it back with `pull.py <n>` — it
|
||||
comes back under the same slug, because the slug travelled up in the body as
|
||||
`<!-- tea:id … -->` (map.with_id_marker) and is also recorded in
|
||||
`.remote.json`. That is the reversal of "pushing is additive, the file is never
|
||||
deleted"; it is deliberate, and AGENTS.md and references/format.md say so too.
|
||||
|
||||
ONE RULE, NO EXCEPTION: `--update` deletes as well. A PATCH is a push, and an
|
||||
issue that has just been sent is no more local than one that was just created.
|
||||
Two rules would put back exactly the question this removes — "is my copy the
|
||||
fresh one?".
|
||||
|
||||
The deletion is the LAST thing that happens to an issue, and only after:
|
||||
|
||||
1. the api call returned (it did not raise, and `tea` exited 0), and
|
||||
2. the answer is a dict carrying a plausible `number`, and on `--update`
|
||||
the very number that was PATCHed (`confirmed_number`), and
|
||||
3. `.remote.json` has been written with number -> slug.
|
||||
|
||||
Network down, non-2xx, a body that does not confirm the write, a mismatched
|
||||
number: the file stays and the run stops. Nothing here removes a file it has not
|
||||
just watched Gitea accept, and nothing removes a file for an issue it did not
|
||||
send — `origin: local` work that has never been pushed is never touched.
|
||||
|
||||
push.py every local-only issue, dependencies first
|
||||
push.py wire-sqlc-appclick one issue
|
||||
push.py --update <id …> PATCH issues that are already in Gitea
|
||||
push.py --dry-run validate only, no network, nothing deleted
|
||||
|
||||
Before anything is sent, each issue is validated against the canonical format
|
||||
by the domain layer (exactly one type/*, English title with no type prefix,
|
||||
`## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts
|
||||
anyway; say why when you use it.
|
||||
|
||||
Dependencies are pushed in topological order so a parent is created after the
|
||||
issues it depends on. A dependency that is still local-only is reported, not
|
||||
silently dropped — the body's `## Depends on` prose is sent verbatim either
|
||||
way, so nothing is lost, but the tracker shows no edge for it.
|
||||
|
||||
The graph goes up with them. Once an issue has its number, every `depends:`
|
||||
entry that also has one becomes a **native Gitea link** — the same
|
||||
`/dependencies` that every `pull.py` reads back, so the tracker shows the
|
||||
blocking panel and refuses to close a blocked issue first. Topological order
|
||||
means the blocker already has its number by then; no second pass is needed.
|
||||
`--update` links whatever appeared in `depends:` since the last push. A link
|
||||
the tracker already has is skipped, not re-POSTed. A dependency that stayed
|
||||
local has no number and becomes no link — only the warning above.
|
||||
|
||||
REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted
|
||||
from `depends:` leaves its Gitea link standing, and nothing here will notice.
|
||||
Unlink it in the web UI, or by hand with
|
||||
`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`.
|
||||
|
||||
The `## Depends on` prose itself is never touched — slugs stay slugs and are
|
||||
not rewritten to `#N`, so the body survives a pull -> push round trip byte for
|
||||
byte. The link lives in Gitea's own graph, not in the text.
|
||||
|
||||
Missing labels are created with the canonical color and, for type/* and
|
||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||
|
||||
`branch:` carries Gitea's `ref`, the branch the work lives on. An empty one is
|
||||
filled with the current git branch and goes up with the issue; one that is
|
||||
already set is sent as written and never overwritten. Detached HEAD, or no repo
|
||||
at all: no `ref` is sent and a warning says so. It is not written back to the
|
||||
file any more — there is no file to write it back to; it comes down with the
|
||||
next pull.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import issue_index # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def select(issues, ids, update):
|
||||
"""Which issues to send, and refuse the ambiguous combinations."""
|
||||
if ids:
|
||||
missing = [i for i in ids if i not in issues]
|
||||
if missing:
|
||||
_gitea.die("no such issue(s) in the store: %s" % ", ".join(missing))
|
||||
chosen = list(ids)
|
||||
else:
|
||||
chosen = sorted(i for i in issues
|
||||
if update or not issues[i].extra.get("gitea"))
|
||||
if not chosen:
|
||||
_gitea.die("nothing to push: every issue in the store is already in Gitea "
|
||||
"(use --update to PATCH them, or issue_new.py to make one)")
|
||||
if not update:
|
||||
already = [i for i in chosen if issues[i].extra.get("gitea")]
|
||||
if already:
|
||||
_gitea.die("already in Gitea: %s — pass --update to PATCH them"
|
||||
% ", ".join(already))
|
||||
return chosen
|
||||
|
||||
|
||||
def ledger_keys(remote_map, repo=None):
|
||||
"""slug -> remote key, the reverse of `.remote.json`.
|
||||
|
||||
Where a dependency's number comes from once push has deleted its file. The
|
||||
forward map is keyed by number because that is what a pull has in hand; a
|
||||
push has a slug, so it needs the other direction. Same-repo entries win if a
|
||||
slug somehow appears under two keys."""
|
||||
out = {}
|
||||
for key, slug in sorted(remote_map.items()):
|
||||
if slug not in out or gmap.parse_remote_key(key)[0] == repo:
|
||||
out[slug] = key
|
||||
return out
|
||||
|
||||
|
||||
def dep_state(iss, issues, pushing, key_of_id=None):
|
||||
"""What each `depends:` entry is, as far as linking is concerned.
|
||||
|
||||
Yields (slug, remote_key, in_run) per dependency this run can say anything
|
||||
about:
|
||||
|
||||
remote_key where the dependency lives in Gitea, or None while it is
|
||||
local-only
|
||||
in_run this push is about to give it one
|
||||
|
||||
A dependency's key is read from its `gitea:` field when the file is still
|
||||
on disk, and from the ledger (`key_of_id`) when it is not — which, since
|
||||
push deletes what it sends, is the normal state of an already-published
|
||||
blocker. Without that fallback the graph would quietly lose an edge every
|
||||
time a blocker was pushed before its dependent: the file is gone, the field
|
||||
goes with it, and the link is never made.
|
||||
|
||||
A slug that is neither in the store nor in the ledger is dropped; it names
|
||||
nothing this machine has ever seen, and validate() has already warned.
|
||||
|
||||
In the real run remote_key is all that matters — topological order means an
|
||||
in-run blocker has already been stamped by the time its dependent is sent.
|
||||
`--dry-run` has no numbers to stamp, so it leans on in_run to say which
|
||||
links are coming and which cannot exist at all."""
|
||||
key_of_id = key_of_id or {}
|
||||
out = []
|
||||
for d in iss.depends:
|
||||
dep = issues.get(d)
|
||||
key = (dep.extra.get("gitea") if dep is not None else None) or key_of_id.get(d)
|
||||
if dep is None and not key:
|
||||
continue
|
||||
out.append((d, key or None, d in pushing))
|
||||
return out
|
||||
|
||||
|
||||
def confirmed_number(got, sent_number=None):
|
||||
"""The number Gitea confirmed for a write, or None — the deletion gate.
|
||||
|
||||
Every local file this script removes is removed because this function
|
||||
returned an int, so it is written to be boring and to say no by default.
|
||||
An answer counts only when it is a dict carrying a positive integer
|
||||
`number`, and, when `sent_number` is given (a PATCH, where we already know
|
||||
which issue we addressed), the same number we sent.
|
||||
|
||||
`bool` is rejected explicitly: `True` is an `int` in Python and `number:
|
||||
true` is not a confirmation of anything.
|
||||
|
||||
What this does NOT have to catch, because it never gets here: a non-2xx
|
||||
answer or a `tea` that failed to run at all — `_gitea.api` exits on both,
|
||||
and an exception in the transport propagates. The file survives all three
|
||||
by never reaching the delete."""
|
||||
if not isinstance(got, dict):
|
||||
return None
|
||||
n = got.get("number")
|
||||
if isinstance(n, bool) or not isinstance(n, int) or n <= 0:
|
||||
return None
|
||||
if sent_number is not None and n != sent_number:
|
||||
return None
|
||||
return n
|
||||
|
||||
|
||||
def drop_local(root, id):
|
||||
"""Delete the local copy of an issue and its thread; return what went.
|
||||
|
||||
Deliberately dumb: it takes an id, not a decision. Whether an issue may be
|
||||
dropped is decided by the caller, before this is reached, so the dangerous
|
||||
half of the operation has no branches in it at all. There is exactly one
|
||||
call site.
|
||||
|
||||
A missing file is not an error — an issue with no comments has no thread."""
|
||||
gone = []
|
||||
for p in (issue.path_of(root, id), _gitea.comments_path(root, id)):
|
||||
if os.path.isfile(p):
|
||||
os.remove(p)
|
||||
gone.append(p)
|
||||
return gone
|
||||
|
||||
|
||||
def git_branch():
|
||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
||||
read, never write. A detached HEAD prints `HEAD` and outside a repo git
|
||||
exits non-zero; both mean "no branch to name", which is not an error."""
|
||||
try:
|
||||
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True)
|
||||
except OSError:
|
||||
return None
|
||||
name = r.stdout.strip()
|
||||
if r.returncode != 0 or not name or name == "HEAD":
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Push local issues to Gitea")
|
||||
ap.add_argument("ids", nargs="*", help="issue ids (default: every local-only issue)")
|
||||
ap.add_argument("--update", action="store_true",
|
||||
help="PATCH issues that already carry a gitea: field")
|
||||
ap.add_argument("--dry-run", action="store_true", help="validate only, no network")
|
||||
ap.add_argument("--force", action="store_true", help="push despite format violations")
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = args.out
|
||||
problem = issue.store_error(root)
|
||||
if problem:
|
||||
_gitea.die("%s — create an issue with issue_new.py first" % problem)
|
||||
issues = issue.load_all(root)
|
||||
|
||||
chosen = select(issues, args.ids, args.update)
|
||||
|
||||
# ---- validate (domain layer, no network) -----------------------------
|
||||
known = set(issues)
|
||||
blocked = False
|
||||
for id in chosen:
|
||||
err, warn = issue.validate(issues[id], known_ids=known)
|
||||
for w in warn:
|
||||
_gitea.warn("%s: %s" % (id, w))
|
||||
for e in err:
|
||||
sys.stderr.write("%s: %s\n" % (id, e))
|
||||
if err:
|
||||
blocked = True
|
||||
if blocked and not args.force:
|
||||
_gitea.die("format violations (see above); --force overrides")
|
||||
|
||||
# ---- dependencies first ----------------------------------------------
|
||||
edges = {i: [d for d in issues[i].depends if d in issues] for i in chosen}
|
||||
order = [i for i in issue.topo_order(chosen, edges) if i in set(chosen)]
|
||||
for c in issue.find_cycles(edges):
|
||||
_gitea.warn("dependency cycle: %s" % " -> ".join(c))
|
||||
|
||||
# ---- branch: -> Gitea `ref` ------------------------------------------
|
||||
# Only an empty field is filled: a branch written by hand is the author's
|
||||
# decision and push does not argue with it. Nothing to read (detached HEAD,
|
||||
# no repo) is not an error — the issue goes up without a `ref`. The value is
|
||||
# set on the in-memory issue only; the file it came from is about to be
|
||||
# deleted, and the branch comes back with the next pull.
|
||||
blank = [id for id in order if not issues[id].extra.get(gmap.BRANCH_KEY)]
|
||||
branch = git_branch() if blank else None
|
||||
if branch:
|
||||
for id in blank:
|
||||
issues[id].extra[gmap.BRANCH_KEY] = branch
|
||||
elif blank:
|
||||
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
||||
"— no `ref` on: %s" % ", ".join(blank))
|
||||
|
||||
pushing = set(order)
|
||||
|
||||
if args.dry_run:
|
||||
links = 0
|
||||
# The ledger costs no request, so a dry run resolves an already-pushed
|
||||
# blocker the same way the real run does.
|
||||
key_of_id = ledger_keys(_gitea.load_map(root), args.repo)
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
print("ok %s [type/%s] %s (%s)"
|
||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
||||
# Not one request is made here: everything below is read off the
|
||||
# store. `#?` is a number this run has not handed out yet.
|
||||
for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id):
|
||||
if key:
|
||||
print(" link -> %s (%s)" % (key, slug))
|
||||
links += 1
|
||||
elif in_run:
|
||||
print(" link -> #? (%s, created by this run)" % slug)
|
||||
links += 1
|
||||
else:
|
||||
print(" no link: %s is local-only" % slug)
|
||||
print("%d issue(s) would be %s, %d dependency link(s) would be created"
|
||||
% (len(order), "updated" if args.update else "created", links))
|
||||
return
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
repo = _gitea.repo_slug(login, args.repo)
|
||||
|
||||
wanted = sorted({l for id in order for l in issues[id].labels})
|
||||
label_ids = _gitea.ensure_labels(login, base, gmap.label_specs(wanted), root) \
|
||||
if wanted else {}
|
||||
|
||||
milestone_ids = {}
|
||||
remote_map = _gitea.load_map(root) or _gitea.rebuild_map(root, issues)
|
||||
key_of_id = ledger_keys(remote_map, repo)
|
||||
|
||||
for id in order:
|
||||
iss = issues[id]
|
||||
|
||||
# Local-only means "this machine has never sent it": no `gitea:` on the
|
||||
# file AND no entry in the ledger. A blocker whose file push already
|
||||
# dropped is in the ledger and is not one of these.
|
||||
unsynced = [d for d in iss.depends
|
||||
if d in issues and not issues[d].extra.get("gitea")
|
||||
and d not in key_of_id and d not in pushing]
|
||||
if unsynced:
|
||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
||||
% (id, ", ".join(unsynced)))
|
||||
|
||||
ms_id = None
|
||||
if iss.milestone:
|
||||
if iss.milestone not in milestone_ids:
|
||||
milestone_ids[iss.milestone] = _gitea.resolve_milestone_id(
|
||||
login, base, iss.milestone)
|
||||
ms_id = milestone_ids[iss.milestone]
|
||||
if ms_id is None:
|
||||
_gitea.warn("%s: milestone %r does not exist in %s — not set"
|
||||
% (id, iss.milestone, repo))
|
||||
|
||||
sent_number = gmap.number_of(iss)
|
||||
if sent_number:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id, include_state=True)
|
||||
got = _gitea.api(login, "%s/issues/%d" % (base, sent_number), "PATCH",
|
||||
payload, payload_name="issue-%s" % id)
|
||||
verb = "updated"
|
||||
else:
|
||||
payload = gmap.to_payload(iss, label_ids, ms_id)
|
||||
got = _gitea.api(login, "%s/issues" % base, "POST", payload,
|
||||
payload_name="issue-%s" % id)
|
||||
verb = "created"
|
||||
# The gate. Below this line the local file is going to be deleted, so
|
||||
# anything short of a confirmed write has to stop the run here.
|
||||
number = confirmed_number(got, sent_number)
|
||||
if number is None:
|
||||
_gitea.die("%s: %s failed — the tracker's answer does not confirm the "
|
||||
"write (%.200r). %s is untouched."
|
||||
% (id, verb, got, issue.path_of(root, id)))
|
||||
|
||||
# The number is confirmed, so the ledger learns it now — before the
|
||||
# label fix-up below, which can still fail, and well before the file is
|
||||
# removed. `.remote.json` is what a later `pull.py N` uses to land on
|
||||
# this slug again; an interrupted run must cost a re-pull, not a slug.
|
||||
remote_map[gmap.remote_key(repo, number)] = id
|
||||
key_of_id[id] = gmap.remote_key(repo, number)
|
||||
_gitea.save_map(root, remote_map)
|
||||
|
||||
# Gitea occasionally drops labels on create — re-apply rather than
|
||||
# trust the echo.
|
||||
applied = {l.get("name", "") for l in got.get("labels") or []}
|
||||
missing = [l for l in iss.labels if l in label_ids and l not in applied]
|
||||
if missing:
|
||||
_gitea.api(login, "%s/issues/%d/labels" % (base, number), "PUT",
|
||||
{"labels": [label_ids[l] for l in iss.labels if l in label_ids]},
|
||||
payload_name="labels-%s" % id)
|
||||
_gitea.warn("%s: labels re-applied via PUT (%s)" % (id, ", ".join(missing)))
|
||||
|
||||
# The in-memory issue is stamped even though its file is going: the rest
|
||||
# of this loop reads `gitea:` off it to link dependencies, and a later
|
||||
# issue in topological order asks the same of this one.
|
||||
gmap.apply_remote(iss, got, repo, _gitea.now_iso())
|
||||
|
||||
# Where the issue lives now. The number and the URL lead because this
|
||||
# is the receipt: in a moment the local path is gone and this is the
|
||||
# only address the issue has.
|
||||
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
||||
|
||||
# ---- the graph, as Gitea's own links ------------------------------
|
||||
# Blockers came first in topological order, so each one that is going
|
||||
# to have a number has one already — stamped on the in-memory issue
|
||||
# above, or read out of the ledger for one whose file an earlier push
|
||||
# already dropped. The GET is the idempotence check: it costs one
|
||||
# request per issue that has dependencies at all, and it is what makes
|
||||
# a repeat push a no-op.
|
||||
wanted_links = [(slug, gmap.parse_remote_key(key))
|
||||
for slug, key, _ in dep_state(iss, issues, pushing, key_of_id)
|
||||
if key]
|
||||
if wanted_links:
|
||||
have = _gitea.native_dep_pairs(login, base, number)
|
||||
for slug, (drepo, dnum) in wanted_links:
|
||||
if not dnum or (drepo, dnum) in have:
|
||||
continue
|
||||
if _gitea.add_dependency(login, base, number, drepo, dnum):
|
||||
print(" depends on %s#%d (%s)" % (drepo, dnum, slug))
|
||||
else:
|
||||
_gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by "
|
||||
"hand, or `pull.py %d` and push it again"
|
||||
% (id, number, drepo, dnum, slug, number))
|
||||
|
||||
# ---- and now the local copy goes ----------------------------------
|
||||
# The last thing that happens to this issue, after the write, the
|
||||
# ledger, and the links. A failure above is a warning and lands here
|
||||
# anyway: the issue IS in Gitea, so keeping a stale file beside it
|
||||
# would put back exactly the two-copies question this removes.
|
||||
for p in drop_local(root, id):
|
||||
print(" dropped %s" % p)
|
||||
print(" pull.py %d to work on it again" % number)
|
||||
|
||||
_gitea.save_map(root, remote_map)
|
||||
path, n = issue_index.build(root)
|
||||
print("index: %s — %d issue(s)" % (path, n))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
remote.py — what exists in Gitea, one line each.
|
||||
|
||||
Discovery only: prints to stdout and writes nothing. The local store is a
|
||||
store, not a search-results folder, so a listing never lands in it. Pick the
|
||||
numbers here, then pull them.
|
||||
|
||||
#42 open type/task, tech/sql Wire sqlc into the repo layer
|
||||
└─ local: wire-sqlc-appclick
|
||||
|
||||
The second line appears when the issue is already in the local store, so it is
|
||||
obvious what a pull would refresh versus what it would add.
|
||||
|
||||
Usage:
|
||||
remote.py [--state open|closed|all] [--label L]… [-q TEXT]
|
||||
[--milestone M] [--limit N] [--repo owner/repo]
|
||||
|
||||
`--limit` here caps the LISTING: N lines out, closed ones among them. That is
|
||||
not what the same flag means to `pull.py`, and the difference is not an
|
||||
oversight — pull.py bounds what it writes, and this command writes nothing, so
|
||||
there is nothing else for a limit to bound. Enumeration is the whole job.
|
||||
|
||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path[:0] = [_HERE, os.path.normpath(os.path.join(_HERE, "..", "..", "issue", "scripts"))]
|
||||
|
||||
import _gitea # noqa: E402
|
||||
import issue # noqa: E402
|
||||
import map as gmap # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="List Gitea issues (stdout only, no files)")
|
||||
ap.add_argument("--state", default="open", choices=["open", "closed", "all"])
|
||||
ap.add_argument("--label", action="append", default=[],
|
||||
help="filter by label; repeat for AND")
|
||||
ap.add_argument("-q", "--query", help="search text in title/body")
|
||||
ap.add_argument("--milestone", help="milestone id or title")
|
||||
ap.add_argument("--limit", type=int, default=30)
|
||||
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
|
||||
ap.add_argument("--out", default=issue.ISSUE_ROOT,
|
||||
help="store root (default: <repo>/tmp/issues)")
|
||||
args = ap.parse_args()
|
||||
|
||||
login = _gitea.require_login()
|
||||
base = _gitea.repo_base(args.repo)
|
||||
payloads, ms_title = _gitea.list_issues(
|
||||
login, base, state=args.state, labels=args.label, query=args.query,
|
||||
milestone=args.milestone, limit=args.limit)
|
||||
|
||||
remote_map = _gitea.load_map(args.out)
|
||||
repo = _gitea.repo_slug(login, args.repo) if remote_map else None
|
||||
|
||||
for p in payloads:
|
||||
labels = ", ".join(l.get("name", "") for l in p.get("labels") or []) or "-"
|
||||
print("#%-5d %-7s %-38s %s" % (p["number"], p.get("state", ""),
|
||||
labels[:38], p.get("title", "")))
|
||||
local = remote_map.get(gmap.remote_key(repo, p["number"])) if repo else None
|
||||
if local:
|
||||
print("%13s└─ local: %s" % ("", local))
|
||||
|
||||
scope = " in milestone %s" % ms_title if ms_title else ""
|
||||
hint = ("--milestone %s" % args.milestone) if args.milestone else "<n>"
|
||||
print("%d issue(s)%s — pull them with: pull.py %s" % (len(payloads), scope, hint))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: use
|
||||
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, pulls, releases, milestones, labels, actions, webhooks, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" — the tea-guard hook substitutes the operator-pinned login; set it with /tea:auth. Issues are NOT handled here: use /tea:issue to work on them and /tea:sync to move them to and from Gitea.
|
||||
---
|
||||
|
||||
# /tea:use — tea CLI reference
|
||||
|
||||
Reference material for the `tea` CLI (Gitea's official command-line client).
|
||||
Use these docs to look up commands, flags, filters, and output fields before
|
||||
running `tea` via Bash.
|
||||
|
||||
## Issues are somewhere else
|
||||
|
||||
Do **not** reach for `tea issues` or `tea api .../issues/...` to read or create
|
||||
an issue. Two skills own that, and they keep the payload out of your context:
|
||||
|
||||
| Skill | Scope |
|
||||
|---|---|
|
||||
| `/tea:issue` | issues as units of work — create, read, grep, validate, dependency graph. Offline. |
|
||||
| `/tea:sync` | moving issues between the local store and Gitea — pull, push, comment. |
|
||||
|
||||
This skill covers everything else Gitea has: pulls, releases, milestones,
|
||||
labels, repos, branches, actions, webhooks, notifications, times.
|
||||
|
||||
## Login: always write the placeholder, never a name (enforced)
|
||||
|
||||
Every `tea` invocation that touches Gitea MUST carry the login as the **literal
|
||||
placeholder** `--login "$GITEA_LOGIN"` (or `-l "$GITEA_LOGIN"`). Do **not**
|
||||
substitute an actual login name yourself.
|
||||
|
||||
The **`tea-guard`** PreToolUse hook enforces this and resolves it:
|
||||
|
||||
- no `--login` → blocked.
|
||||
- `--login "$GITEA_LOGIN"` → the hook reads the operator's pinned login from
|
||||
`.claude/settings.local.json` (`env.GITEA_LOGIN`) **at call time** and
|
||||
rewrites the command to use that literal before it runs.
|
||||
- `--login <some-name>` or any other variable → blocked. You may not choose the
|
||||
login; only the operator does (via `/tea:auth`).
|
||||
- no login pinned → blocked with a pointer to run `/tea:auth`.
|
||||
|
||||
Why: without an explicit login `tea` silently falls back to the machine's
|
||||
default (possibly the user's personal account), and a login *you* pick may be
|
||||
the wrong identity. Pinning is the operator's decision; the hook guarantees it.
|
||||
The pin takes effect immediately — no restart. Only `tea logins list` and
|
||||
`tea --version/--help` are exempt from the guard.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Identify the entity in the request: pulls, labels, milestones, releases,
|
||||
times, repos, branches, actions, webhooks, notifications, etc.
|
||||
2. Find the matching command in the index below.
|
||||
3. Run it via Bash with the placeholder login, e.g.
|
||||
`tea pulls list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
|
||||
(The hook rewrites `"$GITEA_LOGIN"` to the operator-pinned login.)
|
||||
|
||||
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
|
||||
`--repo owner/repo` (or `-r`). Login is **not** auto-detected — it is pinned
|
||||
per-project by the operator (see `/tea:auth`) and injected by the guard.
|
||||
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
|
||||
|
||||
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
|
||||
- [ENTITIES](references/tea/entities.md) — issues, pulls, labels, milestones, releases, times, repos, branches, actions, webhooks, comment
|
||||
- [HELPERS](references/tea/helpers.md) — open, notifications, clone, api
|
||||
- [MISC](references/tea/misc.md) — whoami, admin
|
||||
- [SETUP](references/tea/setup.md) — logins, logout, ssh-keys
|
||||
|
||||
The canonical issue format moved to
|
||||
[`../issue/references/format.md`](../issue/references/format.md) — it describes
|
||||
local files, not `tea` commands.
|
||||
|
||||
## Rich payloads — write to `$PWD/tmp/` first, then `tea api`
|
||||
|
||||
Entity subcommands (`tea comment`, `tea pulls create`, `tea releases create`, …)
|
||||
are built for humans at a TTY. With a large or formatted body they can hang
|
||||
silently — an empty-looking positional arg triggers `$EDITOR` fallback, or a
|
||||
scope/confirm prompt waits on a TTY that doesn't exist. The harness eventually
|
||||
kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
|
||||
|
||||
**Rule:** for any non-trivial body (multi-line, or containing markdown / code
|
||||
fences / backticks / pipes / tables), bypass entity commands. Save the full
|
||||
request payload to `$PWD/tmp/` first, then POST via `tea api`.
|
||||
|
||||
Issues and issue comments are already wrapped — use `/tea:sync` rather than
|
||||
hand-rolling their JSON. The procedure below covers everything else.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Ensure the target dir exists: `mkdir -p tmp/{kind}` where `{kind}` is
|
||||
`pull`, `release`, etc.
|
||||
2. Write the **complete request body as JSON** to `$PWD/tmp/{kind}/<slug>.json`.
|
||||
One file = one request. Use a quoted heredoc to avoid shell expansion:
|
||||
```bash
|
||||
mkdir -p tmp/release
|
||||
cat > tmp/release/v0-2-0.json <<'EOF'
|
||||
{"tag_name": "v0.2.0", "name": "v0.2.0", "body": "## Changes\n\nMulti-line markdown with `code`."}
|
||||
EOF
|
||||
```
|
||||
Newlines inside the body must be encoded as `\n` in the JSON string. If
|
||||
composing programmatically, pipe through
|
||||
`jq -Rs '{body: .}' < body.md > tmp/release/v0-2-0.json`.
|
||||
3. POST with `tea api`, passing the file with `-d @<path>`:
|
||||
```bash
|
||||
tea api --login "$GITEA_LOGIN" \
|
||||
-X POST -d @tmp/release/v0-2-0.json \
|
||||
repos/{owner}/{repo}/releases
|
||||
```
|
||||
4. Keep the file. `tmp/` should be gitignored; the saved payload is useful for
|
||||
retries, edits (`PATCH`), and debugging failed posts.
|
||||
|
||||
### Common endpoints
|
||||
|
||||
| Action | Method + endpoint |
|
||||
|---|---|
|
||||
| Create PR | `POST repos/{owner}/{repo}/pulls` |
|
||||
| Edit PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
|
||||
| Comment on a PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
|
||||
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
|
||||
| Create release | `POST repos/{owner}/{repo}/releases` |
|
||||
| Create milestone | `POST repos/{owner}/{repo}/milestones` |
|
||||
|
||||
Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`)
|
||||
are still fine via entity commands. Always the placeholder, never a login name.
|
||||
|
||||
## Tips
|
||||
|
||||
- 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.
|
||||
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
|
||||
- If a `tea` command is blocked by `tea-guard`: either you forgot
|
||||
`--login "$GITEA_LOGIN"`, you wrote a literal login name instead of the
|
||||
placeholder (not allowed — let the guard substitute), or no login is pinned
|
||||
(run `/tea:auth`).
|
||||
@@ -0,0 +1,137 @@
|
||||
# tea CLI — ENTITIES
|
||||
|
||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
||||
|
||||
## `tea issues` (aliases: `issue`, `i`)
|
||||
Without args lists issues; with `<index>` shows issue detail.
|
||||
|
||||
Shared filters: `--state {all|open|closed}` (default: open), `--kind {issues|pulls|all}`, `--keyword/-k`, `--labels/-L`, `--milestones/-m`, `--author/-A`, `--assignee/-a`, `--mentions/-M`, `--owner/--org`, `--from/-F`, `--until/-u`, `--comments`. Available fields: `index,state,kind,author,author-id,url,title,body,created,updated,deadline,assignees,milestone,labels,comments,owner,repo`.
|
||||
|
||||
Subcommands:
|
||||
- `list, ls` — list (same filters as above).
|
||||
- `create, c` — create an issue. Options: `--title/-t`, `--description/-d`, `--assignees/-a`, `--labels/-L`, `--milestone/-m`, `--deadline/-D`, `--referenced-version/-v` (commit hash or tag).
|
||||
- `edit, e <idx>...` — edit. `--title`, `--description`, `--add-assignees/-a`, `--add-labels/-L`, `--remove-labels`, `--milestone`, `--deadline`, `--referenced-version`. To unset a value pass an empty string (`--milestone ""`).
|
||||
- `reopen, open <idx>...`
|
||||
- `close <idx>...`
|
||||
|
||||
## `tea pulls` (aliases: `pull`, `pr`)
|
||||
Without args lists PRs; with `<index>` shows PR detail. Fields: `index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci`.
|
||||
|
||||
Subcommands:
|
||||
- `list, ls` (`--state`)
|
||||
- `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. 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`).
|
||||
**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>...`
|
||||
- `edit, e <idx>...` — like `issues edit` plus `--add-reviewers/-r`, `--remove-reviewers`.
|
||||
- `review <idx>` — interactive review.
|
||||
- `approve, lgtm, a <idx> [comment]`
|
||||
- `reject <idx> <reason>`
|
||||
- `merge, m <idx>` — `--style/-s {merge|rebase|squash|rebase-merge}` (default merge), `--title/-t`, `--message/-m`.
|
||||
- `review-comments, rc <idx>` — list review comments. Fields: `id,body,reviewer,path,line,resolver,created,updated,url`.
|
||||
- `resolve <comment-id>` / `unresolve <comment-id>`
|
||||
|
||||
## `tea labels` (alias: `label`)
|
||||
- `list, ls` — `--save/-s` dumps labels to a file.
|
||||
- `create, c` — `--name`, `--color`, `--description`, `--file` (bulk import from file).
|
||||
- `update` — `--id`, `--name`, `--color`, `--description`.
|
||||
- `delete, rm` — `--id`.
|
||||
|
||||
## `tea milestones` (aliases: `milestone`, `ms`)
|
||||
Fields: `title,state,items_open,items_closed,items,duedate,description,created,updated,closed,id`.
|
||||
|
||||
- `list, ls` (`--state`)
|
||||
- `create, c` — `--title/-t`, `--description/-d`, `--deadline/--expires/-x`, `--state`.
|
||||
- `close <name>...` — `--force/-f` deletes instead of closing.
|
||||
- `reopen, open <name>...`
|
||||
- `delete, rm <name>`
|
||||
- `issues, i <name>` — manage milestone contents:
|
||||
- `add, a <name> <issue-idx>`
|
||||
- `remove, r <name> <issue-idx>`
|
||||
|
||||
## `tea releases` (aliases: `release`, `r`)
|
||||
- `list, ls`
|
||||
- `create, c [<tag>]` — `--tag`, `--target` (branch/commit), `--title/-t`, `--note/-n`, `--note-file/-f`, `--draft/-d`, `--prerelease/-p`, `--asset/-a <path>` (repeatable).
|
||||
- `edit, e <tag>...` — `--tag`, `--target`, `--title/-t`, `--note/-n`, `--draft/-d <bool>`, `--prerelease/-p <bool>`.
|
||||
- `delete, rm <tag>...` — `--confirm/-y` required; `--delete-tag` also removes the git tag.
|
||||
- `assets, asset, a` — manage release attachments:
|
||||
- `list, ls <tag>`
|
||||
- `create, c <tag> <asset>...`
|
||||
- `delete, rm <tag> <attachment-name>...` — `--confirm/-y`.
|
||||
|
||||
## `tea times` (aliases: `time`, `t`)
|
||||
Time tracking on issues/PRs. Fields: `id,created,repo,issue,user,duration`. Command-level: `--from/-f`, `--until/-u`, `--total/-t`, `--mine/-m`.
|
||||
|
||||
- `add, a <issue> <duration>` — e.g. `tea times add 1 1h25m`.
|
||||
- `delete, rm <issue> <time-id>`
|
||||
- `reset <issue>`
|
||||
- `list, ls [username | #issue]` — username filters by user on the repo; `#N` filters by issue; `--mine` aggregates across all repos.
|
||||
|
||||
## `tea organizations` (aliases: `organization`, `org`)
|
||||
- `list, ls`
|
||||
- `create, c <name>` — `--full-name/-n`, `--description/-d`, `--website/-w`, `--location/-L`, `--visibility/-v`, `--repo-admins-can-change-team-access`.
|
||||
- `delete, rm <name>`
|
||||
|
||||
## `tea repos` (alias: `repo`)
|
||||
Fields: `description,forks,id,name,owner,stars,ssh,updated,url,permission,type`.
|
||||
|
||||
- `list, ls` — `--watched/-w`, `--starred/-s`, `--owner/-O`, `--type/-T {fork|mirror|source}`.
|
||||
- `search, s [term]` — `--topic/-t`, `--type/-T`, `--owner/-O`, `--private {true|false}`, `--archived {true|false}`.
|
||||
- `create, c` — `--name`, `--owner/-O`, `--private`, `--description/--desc`, `--init`, `--labels`, `--gitignores/--git`, `--license`, `--readme`, `--branch`, `--template`, `--trustmodel {committer|collaborator|collaborator+committer}`, `--object-format {sha1|sha256}`.
|
||||
- `create-from-template, ct` — `--template/-t`, `--name/-n`, `--owner/-O`, `--private`, `--description/--desc`, copy toggles: `--content`, `--githooks`, `--avatar`, `--labels`, `--topics`, `--webhooks`.
|
||||
- `fork, f` — `--owner/-O` (default: current user).
|
||||
- `migrate, m` — `--name`, `--owner`, `--clone-url`, `--service {git|gitea|gitlab|gogs}`, `--mirror`, `--mirror-interval`, `--private`, `--template`, copy toggles: `--wiki`, `--issues`, `--labels`, `--pull-requests`, `--releases`, `--milestones`, `--lfs`, `--lfs-endpoint`, auth: `--auth-user`, `--auth-password`, `--auth-token`.
|
||||
- `delete, rm` — `--name`, `--owner/-O`, `--force/-f`.
|
||||
- `edit, e` — `--name`, `--description/--desc`, `--website`, `--private <bool>`, `--template <bool>`, `--archived <bool>`, `--default-branch`.
|
||||
|
||||
## `tea branches` (aliases: `branch`, `b`)
|
||||
Fields: `name,protected,user-can-merge,user-can-push,protection`.
|
||||
|
||||
- `list, ls`
|
||||
- `protect, P <branch>` — enable branch protection.
|
||||
- `unprotect, U <branch>` — remove protection.
|
||||
- `rename, rn <old> <new>`
|
||||
|
||||
## `tea actions` (alias: `action`)
|
||||
CI management: secrets, variables, workflow definitions, workflow runs.
|
||||
|
||||
### `tea actions secrets` (alias: `secret`)
|
||||
- `list, ls`
|
||||
- `create, add, set <name> [value]` — `--file` or `--stdin` to read the value.
|
||||
- `delete, remove, rm <name>` — `--confirm/-y`.
|
||||
|
||||
### `tea actions variables` (aliases: `variable`, `vars`, `var`)
|
||||
- `list, ls` — `--name` to fetch a single variable.
|
||||
- `set, create, update <name> [value]` — `--file`, `--stdin`.
|
||||
- `delete, remove, rm <name>` — `--confirm/-y`.
|
||||
|
||||
### `tea actions runs` (alias: `run`)
|
||||
- `list, ls` — `--status {success|failure|pending|queued|in_progress|skipped|canceled}`, `--branch`, `--event`, `--actor`, `--since`, `--until`.
|
||||
- `view, show, get <run-id>` — `--jobs` prints the jobs table.
|
||||
- `delete, remove, rm, cancel <run-id>` — `--confirm/-y`.
|
||||
- `logs, log <run-id>` — `--job <id>`, `--follow/-f` (requires the job to be in progress).
|
||||
|
||||
### `tea actions workflows` (alias: `workflow`)
|
||||
- `list, ls`
|
||||
- `view, show, get <workflow-id>`
|
||||
- `dispatch, trigger, run <workflow-id>` — `--ref/-r`, `--input/-i key=value` (repeatable), `--follow/-f`.
|
||||
- `enable <workflow-id>`
|
||||
- `disable <workflow-id>` — `--confirm/-y`.
|
||||
|
||||
## `tea webhooks` (aliases: `webhook`, `hooks`, `hook`)
|
||||
Scope is selected by flag: `--repo`, `--org`, `--global`.
|
||||
|
||||
- `list, ls`
|
||||
- `create, c <webhook-url>` — `--type {gitea|gogs|slack|discord|dingtalk|telegram|msteams|feishu|wechatwork|packagist}` (default: gitea), `--secret`, `--events` (default: push), `--active`, `--branch-filter`, `--authorization-header`.
|
||||
- `update, edit, u <id>` — `--url`, `--secret`, `--events`, `--active` / `--inactive`, `--branch-filter`, `--authorization-header`.
|
||||
- `delete, rm <id>` — `--confirm/-y`.
|
||||
|
||||
## `tea comment, c <issue/pr index> [body]`
|
||||
Add a comment to an issue or PR. Body may be passed as an argument or supplied interactively.
|
||||
@@ -0,0 +1,30 @@
|
||||
# tea CLI — HELPERS
|
||||
|
||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
||||
|
||||
## `tea open, o`
|
||||
Open the current repository/context in a web browser.
|
||||
|
||||
## `tea notifications` (aliases: `notification`, `n`)
|
||||
Defaults to the current repo; `--mine/-m` aggregates across all your repos. Fields: `id,status,updated,index,type,state,title,repository`. Filters: `--types/-t {issue|pull|repository|commit}`, `--states/-s {pinned|unread|read}` (default: `unread,pinned`).
|
||||
|
||||
- `ls, list`
|
||||
- `read, r [all | <id>]`
|
||||
- `unread, u [all | <id>]`
|
||||
- `pin, p [all | <id>]`
|
||||
- `unpin [all | <id>]`
|
||||
|
||||
## `tea clone, C <repo-slug> [target-dir]`
|
||||
Clone without requiring a local git install. Accepts slug forms: `gitea/tea`, `tea`, `gitea.com/gitea/tea`, `git@gitea.com:gitea/tea`, `https://gitea.com/gitea/tea`, `ssh://gitea.com:22/gitea/tea`. A host in the slug overrides `--login`. Options: `--depth/-d`, `--login/-l`.
|
||||
|
||||
## `tea api <endpoint>`
|
||||
Authenticated HTTP request to the Gitea API. Endpoints are auto-prefixed with `/api/v1/` unless they start with `/api/` or `http(s)://`. Placeholders `{owner}` and `{repo}` are filled from the repo context.
|
||||
|
||||
- `--method/-X {GET|POST|PUT|PATCH|DELETE}` (default GET; switches to POST automatically when a body is provided)
|
||||
- `--field/-f key=value` — string field on body (repeatable).
|
||||
- `--Field/-F key=value` — typed field (numbers, booleans, null, JSON arrays/objects); `@file` or `@-` (stdin); `"null"` forces literal string.
|
||||
- `--data/-d` — raw JSON body (`@file` / `@-`). Incompatible with `-f`/`-F`.
|
||||
- `--header/-H key:value` (repeatable)
|
||||
- `--include/-i` — write status + response headers to stderr.
|
||||
- `--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.
|
||||
@@ -0,0 +1,36 @@
|
||||
# tea CLI — Index
|
||||
|
||||
Version: `tea 0.14.1` (go-sdk v0.25.1). Source: recursive `--help` traversal. Upstream: https://gitea.com/gitea/tea
|
||||
|
||||
`tea` is a productivity helper for Gitea. It uses the current git repository context (`$PWD`) — owner/repo/login are auto-detected when inside a repo. Config is persisted in `$XDG_CONFIG_HOME/tea`.
|
||||
|
||||
## Global options
|
||||
|
||||
- `--debug, --vvv` — enable debug mode
|
||||
- `--help, -h`, `--version, -v`
|
||||
|
||||
## Common flags (present on nearly every command)
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--login, -l <name>` | use a specific login from the config |
|
||||
| `--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 |
|
||||
| `--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) |
|
||||
| `--fields, -f <list>` | which columns to print |
|
||||
|
||||
## Command categories
|
||||
|
||||
```
|
||||
ENTITIES: issues, pulls, labels, milestones, releases, times,
|
||||
organizations, repos, branches, actions, webhooks, comment
|
||||
HELPERS: open, notifications, clone, api
|
||||
MISC: whoami, admin
|
||||
SETUP: logins, logout, ssh-keys
|
||||
```
|
||||
|
||||
- [ENTITIES](./entities.md)
|
||||
- [HELPERS](./helpers.md)
|
||||
- [MISC](./misc.md)
|
||||
- [SETUP](./setup.md)
|
||||
@@ -0,0 +1,17 @@
|
||||
# tea CLI — MISCELLANEOUS
|
||||
|
||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
||||
|
||||
## `tea whoami`
|
||||
Show the currently logged in user.
|
||||
|
||||
## `tea admin, a`
|
||||
Operations requiring admin access on the Gitea instance.
|
||||
|
||||
### `tea admin users` (alias: `u`)
|
||||
Fields: `id,login,full_name,email,avatar_url,language,is_admin,restricted,prohibit_login,location,website,description,visibility,activated,lastlogin_at,created_at`.
|
||||
|
||||
- `list, ls`
|
||||
- `create, add, new` — `--username/-u`, `--password/-p` / `--password-file` / `--password-stdin`, `--email/-e`, `--full-name`, `--admin`, `--restricted`, `--prohibit-login`, `--no-must-change-password`, `--visibility {public|limited|private}`.
|
||||
- `edit, update, e, u <username>` — paired flags: `--password` (or `--password-file`/`--password-stdin`), `--email/-e`, `--full-name`, `--description`, `--website`, `--location`, `--admin`/`--no-admin`, `--restricted`/`--no-restricted`, `--prohibit-login`/`--allow-login`, `--active`/`--inactive`, `--no-must-change-password`, `--visibility`, `--max-repo-creation` (-1 = unlimited), `--allow-git-hook`/`--no-allow-git-hook`, `--allow-import-local`/`--no-allow-import-local`, `--allow-create-organization`/`--no-allow-create-organization`.
|
||||
- `delete, rm, remove <username>` — `--confirm/-y`.
|
||||
@@ -0,0 +1,19 @@
|
||||
# tea CLI — SETUP
|
||||
|
||||
See [`./index.md`](./index.md) for global options and common flags shared by all commands.
|
||||
|
||||
## `tea logins` (alias: `login`)
|
||||
- `list, ls`
|
||||
- `add` — interactive when called without args. `--name/-n`, `--url/-u` (`$GITEA_SERVER_URL`), `--token/-t` (`$GITEA_SERVER_TOKEN`), `--user` (`$GITEA_SERVER_USER`), `--password/--pwd` (`$GITEA_SERVER_PASSWORD`), `--otp` (`$GITEA_SERVER_OTP`), `--scopes` (`$GITEA_SCOPES`), `--ssh-key/-s`, `--ssh-agent-principal/-c`, `--ssh-agent-key/-a`, `--insecure/-i`, `--no-version-check/--nv`, `--helper/-j`, `--oauth/-o` (plus `--client-id`, `--redirect-url`).
|
||||
- `edit, e` — interactive.
|
||||
- `delete, rm <name>`
|
||||
- `default [<login>]` — get or set the default login.
|
||||
- `oauth-refresh [<login>]` — refresh an OAuth token (opens browser if the refresh token is also expired).
|
||||
|
||||
## `tea logout <name>`
|
||||
Remove a stored login.
|
||||
|
||||
## `tea ssh-keys` (alias: `ssh-key`)
|
||||
- `list, ls`
|
||||
- `add <key-file>` — `--title/-t` (defaults to filename without extension).
|
||||
- `delete, rm <key-id>` — `--confirm/-y`.
|
||||
Reference in New Issue
Block a user