18 Commits

Author SHA1 Message Date
naudachu 2ac301550e merge: drop the local copy after a successful push
# Conflicts:
#	AGENTS.md
#	agents/tea-runner.md
2026-08-10 16:40:21 +05:00
naudachu e629d14585 feat: drop the local copy after a successful push
Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.

This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:

  - "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
    by origin:. An origin: local file is the only copy of the work; an
    origin: gitea file is a deletable working copy.
  - "Pushing is additive: the file is never deleted" — it is deleted.
  - "origin: local is a durable state" — complete, but not durable:
    pushing ends it.

Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.

Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.

.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.

Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.

tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:38:16 +05:00
naudachu 1815d91cdf feat: discussion artifacts as wiki pages, in two new layers
A discussion leaves behind a directory of markdown somewhere outside this
repo, and the only durable home for it is the Gitea wiki. Getting it
there by hand means re-deriving the same three things every time: what
each file should be called, where it goes, and whether the page already
exists. Two skills wrap that, along the split the repo already uses.

`skills/page` is domain, offline, stdlib-only, and knows nothing about
Gitea. It imports a directory into a space under `tmp/wiki/`, titles
every file, records the result in `.pages.json`, and writes the index.
`skills/wiki` is the bridge — `wikimap.py` translates, and the transport
is `_gitea.py`, the same one the issue side uses. There is no second
transport, and `tea` has no wiki subcommand to offer one.

The Gitea wiki is flat, and that fact shapes everything

There are no directories. A title of `a/b` is stored as one file named
`a%2Fb.md`, and Gitea escapes it by rules of its own: space becomes `-`,
`/` becomes `%2F`, and a literal `-` forces a trailing `.-` marker so the
two stay distinct. `Chain decisions — DC` under two levels of prefix
comes back as `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC`.

So `sub_url` is the identity, it is read back from whatever the API
returned, and it is never constructed. One built by hand that is almost
right does not fail — it creates a second page and abandons the first.

And a real subdirectory committed into a wiki's git repository is a ghost:
the file exists, the API and the web UI do not see it. `folder/page.md`
in this repo's own wiki is one. Nothing here clones a wiki repo.

A title is a decision, not a derivation

Titles come from the first heading, because there is no mechanical route
from `03-q-01-do-we-know-the-chain-participant-by-name.md` to
`Q-01. Do We Know the Chain Participant by Name`. But they are derived
exactly once. A re-import replaces bodies and keeps titles, so editing a
heading cannot rename a published page — which would not rename it, it
would publish a second one.

`--retitle` opts in. It finds the prior entry by `source` rather than by
path, because the path is derived from the title and a retitle moves it;
looked up by path the page would read as new and the next push would
duplicate it. The old file goes, `sub_url` comes along, and `pushed` is
cleared — a rename can leave the body byte-identical, and push decides by
body hash alone, so a stale hash would skip the rename forever.

Ordering is a `NN-` file-name prefix and never reaches the title. `00-`
means "this is the directory's own page", and that page is named for the
directory, not for its own heading: a child's title has to extend its
parent's exactly, and `ideas/00-intro.md` opens with "Ideas for chain
business requirements".

Path collisions are reported and never resolved. Picking a winner is how
a discussion loses a document.

The index is navigation, not decoration

Nothing draws a tree from flat titles. `page_index.py` writes one as an
ordinary page, nested by title depth rather than by manifest path order —
those disagree, since on disk `Top/System.md` sorts before
`Top/Ideas/Scale.md` while in the hierarchy System is a child and Scale a
grandchild. A parent with no page of its own still gets a node, so its
children are not hidden.

Links use `sub_url` when there is one and Gitea's `[[Title|label]]`
syntax when there is not, so the order is push, rebuild, push.

The same stances as the issue store, for the same reasons

Pull overwrites, push is additive and never deletes, change detection is
one hash and there is no drift model. A page with no `sub_url` has never
been published, and that is a durable state.

Issues gain a `wiki:` field holding page titles — titles, not URLs, so
the reference stays in the domain. It already round-trips as a foreign
key; this documents it.

Verified against a live Gitea 1.26.1: create, update with a message,
unchanged-skip, prefix-filtered pull, byte-identical round trip, and the
per-page revision history carrying the operator's own words. The probe
pages were deleted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:29:55 +05:00
naudachu 257c547e22 merge: merge checkbox state on pull instead of overwriting it 2026-08-10 15:52:50 +05:00
naudachu d4c43464e5 feat: merge checkbox state on pull instead of overwriting it
A tick was lost in both directions: pull wrote the server body as-is,
push sent the local body as-is, last writer won. Tick it in the web UI
and the first `push.py --update` dropped it; tick it locally and the
first pull dropped it.

The usual answer is drift tracking and a three-way merge, which this
repo rejected on purpose. It is not needed. A tick is monotone — an item
only travels `[ ]` -> `[x]` — so unioning the two sides is a set union,
not conflict resolution. One rule for one line type replaces the whole
mechanism, and the store stays "not a mirror".

`map.merge_checkbox_state` is pure and does the work; `from_api` takes
the local body as an optional argument; `pull.py` hands it the copy
already on disk. Checkbox parsing is imported from `skills/issue`
(`checkboxes` / `set_checkbox`), never redefined here — the domain layer
is untouched.

The same item text more than once is read as a set: one ticked local
item ticks every server line with that text. Pairing duplicates up by
order is the alternative, and it can still drop a tick — which is the
bug being fixed.

The price is documented, not hidden: unticking is not monotone, so a box
unticked in the web UI comes back on the next pull. Untick locally, then
push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:52:11 +05:00
naudachu 47f53a7edc test: check the domain layer against sys.stdlib_module_names
The layering test pinned an exact import set, so any new stdlib import in
skills/issue tripped it — 'collections', added by issue_ac.py, did. Assert
the rule AGENTS.md actually states (stdlib only, never subprocess) instead
of a frozen snapshot of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:43:24 +05:00
naudachu 484da64621 merge: tick in-body checkboxes from a domain script
# Conflicts:
#	AGENTS.md
2026-08-10 15:42:38 +05:00
naudachu 31f7c39155 merge: write issue dependencies to Gitea on push 2026-08-10 15:41:37 +05:00
naudachu f230f98f35 merge: reconcile the feature-container convention with the depends validator 2026-08-10 15:41:30 +05:00
naudachu b72f619fda merge: resolve the issue store path independently of the working directory 2026-08-10 15:41:19 +05:00
naudachu 62c8ff976d feat: tick in-body checkboxes from a domain script
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 is worse: the rewrite re-flows
lines and re-words sentences, so the issue's diff swells around a change
that means one character. Progress was invisible too — issue_index.py
builds INDEX.md from metadata and never looked inside a body, so "3 of 7
done" required opening the file.

All three pieces are domain: a checkbox is body syntax, which is part of
the answer to "what is an issue". The parser goes in issue.py so the sync
layer can reuse it instead of redefining the format on its own side.

issue.py gains checkboxes(text) -> [Checkbox(index, line, end_line,
checked, text, section)], plus set_checkbox(text, item, checked) and
checkbox_progress(text). All pure, no I/O, importable from another layer.
The scan covers the whole text, in any section: the type/feature template
keeps child issues as checkboxes under `## Issues`, so binding the parser
to `## Acceptance criteria` would silently lose half of them; the heading
is recorded, never required. Only a marker line opens an item, so a
wrapped continuation line belongs to the item above it rather than
counting as one of its own. A `- [ ]` inside a code fence is an example
of the markup and is skipped. Line numbers are relative to the text
given, which is what lets a caller work on a body or on a whole file.

issue_ac.py lists the items numbered, grouped by heading, and ticks one
by number or by substring. An ambiguous substring is an error that prints
the matches — a coin flip would tick the wrong box and look like it
worked. It patches the file rather than round-tripping through
Issue.to_text(), so exactly one character changes: metadata order,
wording, wrapping, trailing whitespace and CRLF endings all come back
byte for byte, proven by a diff in the tests.

INDEX.md gains a progress column: `3/7` for an issue with checkboxes,
blank for one without. Counted off the body at build time and stored in
no field — a second copy of the state would be wrong by the next edit.

issue_check.py is unchanged and stays that way on purpose: an unticked
box is work not done yet, not a malformed issue, and validate() carries a
comment saying so.

Delivering a tick to the tracker is out of scope — that is push.py
--update in /tea:sync.

format.md gets one clarifying bullet. It said acceptance criteria are
checkboxes but never said what a checkbox is, so the parser had to settle
questions the format left open: any section, wrapped items, fenced
examples. Those rules are now written down where the parser and the sync
layer can both point at them.

tests/ is new, and is the convention: plain stdlib unittest, no pytest
and no third-party deps, since the code under test may not have
dependencies either. Scripts are imported via sys.path.insert and every
fixture is built in a TemporaryDirectory, never in tmp/.

    python3 -m unittest discover -s tests -v     32 tests, OK

skills/issue/scripts/ still imports stdlib only, with no subprocess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:41:15 +05:00
naudachu fb862554ed fix: reconcile the feature-container convention with the depends validator
`format.md` told a child issue to link back to its container through its own
`depends:`, while the validator wanted the container to list its children.
Satisfying both made a cycle, caught as an ERROR, so every `type/feature` with
a filled `## Issues` ended in either a warning or a hard failure — no third
option.

Variant B is chosen: the container depends on its children, and a child never
names its container. "The container is closed when its children are closed" IS
a dependency relation, so it belongs in the graph; "a child belongs to a
feature" is membership, and membership does not. The code already walked the
edge that way — `## Issues` is an edge source pointing container -> child — so
this rewrites the documentation to match instead of inverting the graph, and
the tree draws containers as roots for free.

- format.md: the `type/feature` template states the direction, shows the
  container's `depends:`, and says why the reverse cycles; the Dependencies
  section names `## Issues` as the second edge source.
- issue.py: `body_dep_ref_sections()` carries the section each reference came
  from, so the desync warning names `## Issues` on a container rather than a
  `## Depends on` that is not in the file. `body_dep_refs()` stays as a thin
  wrapper — `skills/sync/scripts/map.py` calls it and is untouched.
- tests/test_container_edges.py: the repo's first tests. Stdlib unittest,
  `python3 -m unittest discover -s tests`.

issue_check.py's cycle detector and issue_tree.py need no change: with the edge
pointing down there is no cycle to break and the container is already the root.

Refs claude-skills/tea#14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:40:28 +05:00
naudachu 6d01ead245 fix: resolve the issue store path independently of the working directory
ISSUE_ROOT was the relative `tmp/issues`, so "the store" was whatever
directory the shell happened to be standing in. It is the --out default
in all eight scripts of both layers, which made one `cd` — and a `cd`
outlives the command that ran it — enough for readers to report an empty
store on a full one and for writers to quietly build a second store
beside the first. `issue_index.py` run from inside tmp/issues left
tmp/issues/tmp/issues/ behind and exited 0.

The anchor is issue.py's own __file__, not cwd. A script's location is a
fact about the installation; cwd is a fact about the last `cd`, and the
scripts are invoked by path from wherever the agent happens to be. From
there `store_root()` walks up to the nearest repo marker — `.git`
(exists(), not isdir(): a worktree's .git is a file) or AGENTS.md for a
copy taken out of git — and joins tmp/issues. Markers rather than a
fixed number of `..` hops, because the layout is not a promise. cwd is
tried only if the scripts are not inside a repository at all.

The function lives in the domain layer and skills/sync imports it, so
both layers agree by construction — the direction the layering rule
allows. skills/issue stays stdlib-only.

An explicit --out still wins and is used exactly as typed: a relative
--out stays relative to cwd, because that is what the operator asked
for. No new environment surface.

Two consequences the issue also asked for:

- Missing is no longer reported as empty. `store_error()` returns one
  message for a path that is not there and another for a store with no
  issues in it.
- Nothing conjures a store as a side effect of a write. save() and
  issue_index.build() require it instead of os.makedirs'ing it; only
  issue_new.py and pull.py create one, and both say so on stderr.

Establishes tests/ — plain stdlib unittest, no pytest, no dependencies.
The store tests build a throwaway repo in a TemporaryDirectory (a .git
marker, a copy of both script layers, fixture issues) and run the real
scripts inside it as subprocesses from five different working
directories; tmp/issues/ is never touched. Against the pre-fix scripts
15 of the 21 fail, reproducing the report exactly — five stray stores,
including tmp/issues/tmp/issues.

    python3 -m unittest discover -s tests -v

Closes claude-skills/tea#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:40:07 +05:00
naudachu 0cf4baa429 fix: write issue dependencies to Gitea on push
The local `depends:` graph never reached the tracker. push.py sent dependent
issues in topological order but created no native links, so `native_deps` in
_gitea.py was a reader with no writer and the slugs in `## Depends on` stayed
dead prose for anyone reading the issue in Gitea.

Once an issue has its number, every `depends:` entry that also has one now
becomes a real link: POST /repos/{owner}/{repo}/issues/{index}/dependencies
with the blocker's IssueMeta. Topological order means the blocker is already
numbered, so no second pass is needed. Existing links are read back first, so
a repeat push is a no-op and never 409s; a link that fails anyway warns rather
than aborting a run that has already created issues. `--dry-run` prints the
links it would make and touches nothing.

The `## Depends on` prose is still passed through verbatim — the edge the
tracker acts on is the native link, not the text, which is exactly why the
text can be left alone. Removing a link that disappeared from `depends:` is
out of scope and now says so in push.py's docstring.

Establishes tests/: stdlib unittest, the transport stubbed at _gitea.api, no
network. Run with `python3 -m unittest discover -s tests`.

The POST body shape was confirmed against the instance's own swagger.v1.json
(Gitea 1.26.1), not assumed from upstream docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:39:52 +05:00
naudachu d8bd927f1d feat: add the tea-runner execution agent
The skills carry meaning, the scripts carry work. Splitting the second
half onto a cheap model keeps the main session's context for the part
that needs judgement.

tea-runner is a Haiku subagent with Bash/Read/Grep/Glob/Skill and
nothing else. It loads /tea:sync or /tea:issue for the command table
rather than carrying its own copy, so the skills stay the single source
of truth for the script surface.

It executes and reports; it decides nothing. No Edit and no Write, so an
issue body is out of reach. No raw tea, no --force, no closing or
retitling, no pushing past the set it was handed, one retry maximum. A
failed validation, a missing type, an unpushed dependency come back as a
question in a `blocked:` line. The reply is a fixed receipt — commands
with ok/FAIL, touched paths, stderr verbatim — never a payload dump.

Knowledge still flows one way: nothing under skills/ knows the agent
exists, and deleting agents/ changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:02:34 +05:00
naudachu 9234d8004f feat: work the sync backlog — comments, labels, refs, closed issues
Five tracker issues, all in the bridge layer except the last.

pull.py fetches comments by default (#6). The thread was reachable only
through --comments, and only for a single issue, so a bulk pull left every
local copy silently incomplete: a missing <id>.comments.md could mean "no
comments" or "never asked". Now every written issue gets its thread, in key
and filter mode alike; an empty one costs no request (the count rides in the
list payload) and writes no file, and a file left over from an earlier pull
is deleted. --cached skips the thread along with the body. The --comments
flag is gone.

labels.py bootstraps the canonical label set (#7). Labels used to appear as a
side effect of the first push that happened to use them, so a repo could not
be filtered by type/bug until somebody pushed a bug. The set is finite and
already described by the domain taxonomy — 6 type/* and 5 severity/* — which
makes it a run, not a decision. Names and exclusivity come from issue.TYPES /
SEVERITIES / EXCLUSIVE_NS, colors from map.label_specs; no list is duplicated.
An exact name is never re-created or patched. Lookalikes (bug, Bug, "type:
bug", kind/bug) are reported with their id and left alone — renaming somebody
else's label is a decision, not a migration. Color or exclusive drift is
printed, and changed only under --fix.

branch: carries Gitea's ref (#8). map.to_payload sends ref only when the field
is non-empty, since ref="" would clear whatever the server has; from_api reads
it back; push fills an empty one from `git rev-parse --abbrev-ref HEAD` and
writes it into the issue file. A hand-written value is never overwritten, on
create or on --update. Detached HEAD and running outside a repo warn and send
no ref. Reading the branch is the only thing these scripts ask of git. The
domain needs no change: unknown keys already ride in Issue.extra and render
after the domain fields.

Bulk pulls no longer store closed issues (#10). Filter mode wrote every
payload the server returned, so --state all dragged the closed backlog into a
store that gets read whole — INDEX.md, grep over tmp/issues/*.md. They are
still enumerated, the number left out goes to stderr, and an issue already on
disk is refreshed either way so the local copy learns it was closed instead of
staying open forever. --state closed stores them, and key mode is exempt: an
address is not a bulk read.

/tea:issue gains a "Writing a proper description" procedure (#9). Six steps
from reading an issue to issue_check.py, the rule that a missing fact is found
in the repository or asked about rather than invented, and the note that the
procedure is identical for origin: local and origin: gitea while delivery to
the tracker belongs to /tea:sync. No new script.

Verified: labels.py run for real against claude-skills/tea (9 created, 2
already present) and idempotent on a second run; pull.py exercised live for
the closed-skip, --state closed, key-mode and comment paths; the push write
path covered offline with the transport stubbed. skills/issue/scripts/ still
imports stdlib only, with no subprocess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:37:57 +05:00
naudachu 091dceec1d refactor: split issue domain from Gitea transport
An issue was a Gitea row that happened to be cached locally: its identity
was the tracker's number (42.md), its dependencies were tracker numbers
(depends: [#12]), and a local issue existed only as a draft that push
deleted on success. Nothing could be planned or tracked without a tracker.

Split into layers, with knowledge flowing one way:

  skills/issue  DOMAIN  what an issue is: format, validation, dep graph
        ^               offline; stdlib imports only, no subprocess
        | imports
  skills/sync   BRIDGE  map.py    md <-> Gitea JSON, pure, no I/O
                        _gitea.py login pin, api, pagination, filters
  skills/use    REFERENCE  tea CLI docs for non-issue entities

skills/issue never imports skills/sync. Delete the sync layer and the
domain keeps working.

Identity is now a slug derived from the title (wire-sqlc-appclick.md) and
is stable across retitles and pushes. Tracker numbers live in a `gitea:`
field, never in a file name and never in `depends:`; the pair is indexed
in .remote.json, which is a cache over the files, not a second source of
truth.

Behavior changes:

- Pushing is additive. The file is never deleted; it gains gitea:/url:/
  synced: and origin: flips from local to gitea. `origin: local` is a
  durable state, not a pending one.
- Pushes go in topological order so dependencies get numbers first.
- The dependency graph is computed offline from `depends:` metadata; body
  prose is passed through unchanged in both directions rather than being
  rewritten between slugs and #N.
- `origin` is domain-owned (whether work exists elsewhere is a fact about
  the work); the handle and how to reach it stay with sync.

Script moves:

  issue_get.py   -> sync/pull.py
  issue_push.py  -> sync/push.py
  issue_list.py  -> sync/remote.py
  issue_index.py -> issue/issue_index.py
  _tea.py        -> split into issue/issue.py, sync/map.py, sync/_gitea.py

New: issue/issue_new.py, issue/issue_check.py, issue/issue_tree.py, and
sync/comment.py — comment posting was the last issue operation still
hand-rolled through raw `tea api`.

references/issue-format.md moves to skills/issue/references/format.md;
label hex colors move out of it into map.py, since a color is how a
tracker paints a chip, not what an issue is.

Verified: offline path end to end (new, check, tree, index, push
--dry-run) and read-only against Gitea (remote listing, pull with
mapping, comment guard). Write paths of push.py and comment.py are not
exercised here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:37:32 +05:00
naudachu 335b0bbd54 feat: local issue cache and draft-then-push workflow
Replace fetch_issue.py with four scripts around a flat, greppable cache in
tmp/issues/. Planning stays offline and issues reach Gitea in one push:

- issue_get.py: fetch by key or by filter (--milestone/--label/-q). The list
  endpoint carries issue bodies, so a whole milestone costs one request per 50
  issues. Gitea silently ignores an unresolvable milestones= filter and returns
  the entire backlog, so the milestone is resolved up front and every returned
  issue is re-checked locally. --deps walks the dependency graph downwards via
  the structured sections plus native dependencies and writes tree-<slug>.md.
- issue_push.py: validate a local draft against the canonical format, create
  missing labels with the right colors and exclusivity, POST, delete the draft.
- issue_list.py: discovery to stdout, writes nothing.
- issue_index.py: rebuild INDEX.md from what is on disk.

Files use one metadata field per line with inline lists so plain grep works
without a parser. This is a cache and a drafting area, not a mirror: no drift
tracking, no sync back.

Projects are not fetchable — the projects API is 404 on Gitea 1.26; documented
alongside the milestone caveat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:35:38 +05:00
42 changed files with 9565 additions and 545 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
{
"name": "tea",
"source": "./",
"description": "Gitea CLI (tea) reference plus a mandatory-login guard. Ships /tea:auth, /tea:use, and a PreToolUse hook that blocks any tea command that would touch Gitea without --login."
"description": "Gitea issues as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph), /tea:sync moves them to and from Gitea, /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login."
}
]
}
+3 -3
View File
@@ -1,10 +1,10 @@
{
"name": "tea",
"description": "Gitea CLI (tea) reference plus a mandatory-login guard. Ships /tea:auth (pin a login), /tea:use (command reference), /tea:issue (create issues in a canonical format), and a PreToolUse hook that blocks any tea command that would touch Gitea without --login.",
"version": "1.1.0",
"description": "Gitea issues and wiki pages as local markdown, cleanly layered: /tea:issue works on issues offline (format, validation, dependency graph) and /tea:page turns a discussion's artifacts into a titled, ordered page tree; /tea:sync and /tea:wiki move each to and from Gitea; /tea:use is the CLI reference, the tea-runner subagent executes the scripts on a cheap model, and a PreToolUse hook blocks any command that would touch Gitea without the operator-pinned login.",
"version": "2.2.0",
"author": {
"name": "naudachu"
},
"license": "MIT",
"keywords": ["gitea", "tea", "cli", "git", "login-guard"]
"keywords": ["gitea", "cli", "git", "issues", "wiki", "login-guard"]
}
+177 -5
View File
@@ -2,12 +2,184 @@
## Project goals
1. **Unify and systematize issue workflow** for the development team with minimal context usage. Issue operations (create, fetch, format) are wrapped in scripts so agents spend tokens on the task, not on re-deriving commands and formats.
2. **Route all Gitea interaction through the `tea` CLI via scripts** instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; the `tea-guard` hook enforces that every `tea` invocation runs under the operator-pinned login.
1. **Unify and systematize issue workflow** for the development team with
minimal context usage. Issue operations are wrapped in scripts so agents
spend tokens on the task, not on re-deriving commands and formats.
2. **Keep the tracker out of the work.** An issue is a unit of work first and a
Gitea row second. The two are separate layers, and the first one does not
know the second exists.
3. **Route all Gitea interaction through the `tea` CLI via scripts** instead of
direct ad-hoc calls wherever possible. Scripts give deterministic,
reviewable behavior; the `tea-guard` hook enforces that every `tea`
invocation runs under the operator-pinned login.
## Layers
The hard rule of this repo. Two domains, two bridges, one transport, and
knowledge flows one way only:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
skills/page DOMAIN what a page tree is: title <-> path, order, the index
▲ offline — no tracker, no network, stdlib imports only
│ imports
skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O
_gitea.py login pin, tea api, pagination, filters
skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O
transport is _gitea.py — there is no second one
skills/use REFERENCE tea CLI docs for everything that is not an issue
skills/auth IDENTITY pin the login the whole tracker side runs under
│ calls
agents/ EXECUTION tea-runner: runs the scripts, reports a receipt
```
A domain never imports its bridge, and the two domains do not import each
other: delete `skills/sync` and issues still work, delete `skills/wiki` and page
trees still work, delete either domain and the other is untouched. The check is
mechanical — every import under a domain's `scripts/` is stdlib, and
`subprocess` is not among them:
```bash
grep -rh '^import \|^from ' skills/issue/scripts/ | sort -u
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
```
If a tracker concept (issue number, login, HTTP call, label color, `sub_url`,
`content_base64`) shows up in a domain layer, it is in the wrong place.
## Repo layout
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
- `skills/use``tea` CLI reference, loaded on demand (`/tea:use`); `scripts/` holds helper scripts (e.g. `fetch_issue.py`), `references/` holds command docs and the canonical issue format
- `skills/issue` — create issues in the canonical format (`/tea:issue`)
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations that don't use the pinned login; `agents-sync` keeps every directory canonical (`AGENTS.md` real file, `CLAUDE.md` symlink to it)
- `skills/issue`issues as units of work (`/tea:issue`), entirely offline
- `references/format.md` — canonical issue format; single source of truth
- `scripts/issue.py` — domain module: slug identity, parse/render, validation,
taxonomy, dependency graph, body checkboxes
- `scripts/issue_new.py` — create a local issue from its type template
- `scripts/issue_check.py` — validate against the format
- `scripts/issue_ac.py` — list the body's checkboxes; tick one by number or
substring, changing exactly one character of the file
- `scripts/issue_tree.py` — draw the dependency graph
- `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md`
- `skills/sync` — move issues between the local store and Gitea (`/tea:sync`)
- `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here
- `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters,
label ids, the remote-id map
- `scripts/pull.py`, `push.py`, `remote.py`, `comment.py`
- `skills/page` — a discussion's artifacts as a page tree (`/tea:page`),
entirely offline
- `references/pages.md` — canonical page-tree format; single source of truth
- `scripts/page.py` — domain module: title ↔ path, ordering, the manifest,
importing a directory of markdown, the index
- `scripts/page_import.py` — copy a directory of markdown into a space,
titling every file
- `scripts/page_index.py` — write the table-of-contents page
- `scripts/page_ls.py` — the tree, the titles, one sync-state tag per page
- `skills/wiki` — move page trees between a local space and a Gitea wiki
(`/tea:wiki`)
- `scripts/wikimap.py` — md ↔ Gitea wiki JSON, pure, no I/O
- `scripts/wiki_ls.py`, `wiki_pull.py`, `wiki_push.py` — transport is
`skills/sync/scripts/_gitea.py`
- `skills/use``tea` CLI reference for everything that is not an issue
(`/tea:use`); `references/tea/` holds the command docs
- `agents/tea-runner.md` — subagent on Haiku that executes the scripts and
returns a compact receipt. Delegate batches (bulk pull, push a named set,
bootstrap labels, rebuild the index), never the thinking: it has no `Edit`
and no `Write`, may not `--force`, and may not decide what an issue says.
Delegating a single call costs more than running it inline — the win is the
loop, the retry, and the error triage.
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations
that don't use the pinned login; `agents-sync` keeps every directory canonical
(`AGENTS.md` real file, `CLAUDE.md` symlink to it)
- `tests/` — stdlib `unittest`, no third-party anything
## Tests
```bash
python3 -m unittest discover -s tests -v
```
Plain `unittest`; no pytest, no dependencies — the scripts under test are
stdlib-only and the tests hold the same line. `skills/*/scripts/` are not
packages, so a test that needs the domain module imports it with
`sys.path.insert`.
**A test never touches `tmp/issues/` or `tmp/wiki/`.** Anything that needs a
store builds a throwaway repository in a `tempfile.TemporaryDirectory()` — a
`.git` marker, a copy of the script layers, fixture issues or artifacts — and
runs the real scripts inside it as subprocesses. That is the only way to test
behavior that depends on where a script is run from, and it keeps the
developer's own store out of the blast radius.
## Local issue store
`tmp/issues/` (gitignored) holds **two kinds of file, and only one of them is a
store.** An `origin: local` issue lives here and nowhere else — this file *is*
the issue, and losing it loses the work. Anything with `origin: gitea` is a
**cache**: the tracker has it, this copy is a working copy, and it is deleted
the moment a push confirms the tracker is up to date.
One flat markdown file per issue, named by its slug, with one metadata field per
line so plain grep works without a parser.
- **The path is `<repo root>/tmp/issues`, resolved from `issue.py`'s own
location, not from cwd.** `issue.store_root()` walks up from `__file__` to the
nearest `.git` or `AGENTS.md` — so every script in both layers sees one store
whatever directory it is run from. An explicit `--out` overrides it and is
used exactly as typed; a relative `--out` stays relative to cwd.
- Nothing creates the store as a side effect of a write. Readers distinguish
"does not exist" from "is empty"; only `issue_new.py` and `pull.py` create it,
and they say so on stderr.
- Identity is the slug (`wire-sqlc-appclick.md`), never a tracker number.
Numbers live in the `gitea:` field.
- `origin: local` is a complete state, not a draft: an issue that never leaves
this machine is valid and finished. It is not a *durable* state, though —
pushing ends it, and the local file goes with it.
- **A successful push deletes the local file** (`<id>.md` and
`<id>.comments.md`), and prints the number and URL the issue now lives at.
`--update` too: one rule, no exception. What is in the store is what has not
left. Get it back with `pull.py <n>`.
- Deletion happens only after a confirmed tracker response and only after
`.remote.json` has been written. Network down, non-2xx, an answer that does
not carry the right number: the file stays and the run stops. A never-pushed
`origin: local` issue is never touched by any of this.
- The slug survives the round trip because it goes up in the body as
`<!-- tea:id … -->` (`map.with_id_marker`) and is indexed by number in
`tmp/issues/.remote.json`. A rename in the web UI, a lost `.remote.json`, a
fresh clone, another machine — the file comes back under the same name and
every `depends:` that points at it still resolves.
- `.remote.json` is therefore no longer "an index over the files": it is the
local number → slug ledger, its entries outlive the files they name, and
nothing prunes them. It is still recoverable — from the markers in Gitea, not
from the files.
- Pulling overwrites the body — a fetch, not a merge. It is also how a pushed
issue comes back at all.
- No drift tracking, and now nothing to track: there is no second copy to
diverge from. `synced:` tells you how old your working copy is.
## Local wiki cache
`tmp/wiki/<space>/` (gitignored) holds page trees — a discussion's artifacts,
organized. Same stance as the issue store, resolved the same way from
`page.py`'s own location, with the same `--out` rule.
- Identity is the **title**, and `/` inside it is the only hierarchy there is.
The Gitea wiki is flat: it escapes a title into one filename by rules of its
own (`space -> -`, `/ -> %2F`, a literal `-` forces a trailing `.-`).
- **`sub_url` is Gitea's address for a page and is never constructed.** It is
read back from the API and stored in `.pages.json`. One built by hand that is
almost right creates a second page instead of editing the first.
- **Never commit a subdirectory into a wiki's git repository.** Gitea does not
see it — the page exists on disk and nowhere in the API or the UI. Do not
clone the wiki repo to work in; use the scripts.
- A title is a decision, not a derivation. A re-import replaces bodies and
keeps titles, so editing a heading cannot silently rename a published page.
`--retitle` opts in, and the rename reaches the wiki on the next push.
- A page with no `sub_url` has never been published — a complete state, the way
`origin: local` is for an issue. **The parallel stops at the push**: a pushed
page stays on disk, a pushed issue does not.
- Change detection is one hash (`pushed`). Pulling overwrites; pushing is
additive and never deletes — the one place the two domains deliberately
disagree, because a page tree is worked on locally and an issue is not.
- The `tea` CLI has no wiki subcommand. `tea api` is the only route, through
`_gitea.py`.
+96 -8
View File
@@ -7,10 +7,32 @@ A Claude Code plugin that gives Claude a reference for the `tea` CLI and enforce
| Piece | What it does |
|---|---|
| `/tea:auth` skill | Prompts you to pick a Gitea login and pins it to the project |
| `/tea:use` skill | Tea CLI reference — loads command docs on demand |
| `/tea:issue` skill | Creates issues in a canonical format (typed labels, fixed sections) |
| `/tea:issue` skill | Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
| `/tea:sync` skill | Moves issues between the local store and Gitea — pull, push, comment |
| `/tea:use` skill | Tea CLI reference for everything that is not an issue — loads command docs on demand |
| `tea-runner` agent | Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
| `tea-guard` hook | PreToolUse hook that blocks or rewrites every `tea` invocation |
## The layering
An issue is a unit of work first and a Gitea row second. Those are two layers,
and knowledge flows one way:
```
skills/issue DOMAIN what an issue is: format, validation, dependency graph
▲ offline — no tracker, no network, stdlib only
│ imports
skills/sync BRIDGE md <-> Gitea JSON, then over the wire
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
```
Delete `skills/sync` and the domain layer keeps working — issues that live only
on your machine are first-class, not drafts waiting to be uploaded. That is the
point of the split: you can plan, write, validate, and track work without a
tracker, and publish only what you choose to.
## Prerequisites
- **Claude Code** — CLI, desktop app, or IDE extension
@@ -40,7 +62,7 @@ This is a Claude Code plugin — install it through the plugin marketplace, not
/plugin install tea@tea
```
The skills (`/tea:auth`, `/tea:use`, `/tea:issue`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-guard` hook load immediately. Use `/plugin` to enable, disable, or update it later.
> The marketplace registration is written to `extraKnownMarketplaces` and the plugin to `enabledPlugins` in your settings automatically — you don't edit those by hand. There is **no** top-level `"plugins"` settings key; if you've added one from older instructions, remove it.
@@ -52,7 +74,9 @@ Run `/tea:auth` once per project. Claude will list your available Gitea logins a
/tea:auth
```
After that, use `/tea:use` to look up commands, or just ask Claude to do something with Gitea and it will load the reference automatically.
After that, just ask Claude to do something with issues or Gitea — it loads the
right skill automatically. `/tea:auth` is only needed for the tracker side;
`/tea:issue` works without any login at all.
## How the login guard works
@@ -67,19 +91,83 @@ This prevents silent fallback to the machine's default login (often a personal a
`tea logins list` and `tea --version / --help` are exempt — they don't touch Gitea data.
## The tea-runner agent
The skills carry meaning; the scripts carry work. `tea-runner` is a subagent on
Haiku that does the second half in its own context and hands back a receipt —
what ran, what it touched, what failed, verbatim.
Delegate a **batch**: pull a milestone and rebuild the index, push the three
issues you just wrote, bootstrap the label set, post a comment from a file you
prepared. Spawning it for a single `pull.py 42` costs more than running the
command yourself; the saving is in the loop, the retry, and reading somebody
else's stderr.
It cannot decide anything. No `Edit`, no `Write`, no `--force`, no closing or
retitling, no raw `tea`, no pushing beyond the set it was handed. A missing
type, a failed validation, an unpushed dependency come back as a question, not
as a guess. The `tea-guard` hook applies to it exactly as it does to the main
session — the pinned login is enforced on every call it makes.
## Project layout
```
.claude-plugin/
plugin.json plugin manifest
marketplace.json marketplace catalog (makes `/plugin install` work)
agents/
tea-runner.md subagent (Haiku) that executes the scripts
hooks/
hooks.json registers the PreToolUse hook
tea-guard.sh the guard (Python 3, no deps)
skills/
auth/SKILL.md /tea:auth skill
use/SKILL.md /tea:use skill
use/references/tea/ tea CLI reference docs
use/references/issue-format.md canonical issue format (types, templates)
issue/SKILL.md /tea:issue skill
issue/ /tea:issue — the domain layer, offline
SKILL.md
references/format.md canonical issue format (identity, types, templates)
scripts/ Python 3, stdlib only, no network:
issue.py domain module: slug identity, parse/render,
validation, taxonomy, dependency graph,
body checkboxes
issue_new.py create a local issue from its type template
issue_check.py validate against the format
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_index.py rebuild tmp/issues/INDEX.md
sync/ /tea:sync — the bridge to Gitea
SKILL.md
scripts/
map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py transport: login pin, tea api, pagination, filters
pull.py Gitea -> tmp/issues/
push.py tmp/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
use/ /tea:use — tea CLI reference (non-issue entities)
SKILL.md
references/tea/ command docs
```
## Local issue store
Issues live in `tmp/issues/` (gitignore it) as flat markdown with one metadata
field per line — so `grep -l 'labels:.*type/bug' tmp/issues/*.md` works without
a parser.
An `origin: local` file **is** the issue — the store, and the only copy.
Anything with `origin: gitea` is a working copy of something the tracker
already has, and it is deleted as soon as a push confirms the tracker is up to
date:
- Identity is a slug (`wire-sqlc-appclick.md`), never a tracker number. Numbers
live in a `gitea:` field.
- `origin: local` is a complete state. An issue that never leaves your machine
is valid and finished — but it is not permanent: pushing ends it.
- **A successful push deletes the local file** (`--update` too) and prints the
number and URL it now lives at. Only after a confirmed response: a failed
call leaves the file exactly where it was. Get it back with `pull.py <n>` —
same slug, same `depends:`, even after a rename in Gitea.
- Pulling overwrites the body: a fetch, not a merge. It is also how a pushed
issue comes back.
- Nothing tracks drift, and there is no second copy to drift. A file that is
still here has not been pushed.
+124
View File
@@ -0,0 +1,124 @@
---
name: tea-runner
description: Executes the tea plugin's scripts and reports back a compact receipt. Use for the mechanical half of tracker work — bulk pulls, pushing issues the caller already named, posting a comment from a file, bootstrapping labels, rebuilding the index or the tree. It runs commands; it never decides what an issue should say. Delegate a batch, not a single call.
tools: Bash, Read, Grep, Glob, Skill
model: haiku
---
# tea-runner — the execution layer
You run this project's issue scripts and hand back a short receipt. You are the
fourth layer of the plugin, below the three that carry meaning:
```
skills/issue DOMAIN what an issue is
skills/sync BRIDGE md <-> Gitea, over the wire
skills/use REFERENCE tea CLI docs
│ calls
tea-runner EXECUTION runs the scripts, reports the result
```
Knowledge still flows one way. You call those layers; nothing in them knows you
exist. **You hold no opinion about content.** Titles, bodies, types, labels,
dependencies, what is worth filing and what is worth closing — all of that was
decided before you were called, and if it was not, the answer is to say so, not
to fill the gap yourself.
## Where the commands come from
Load the skill, do not remember the flags:
- `/tea:sync``pull.py`, `push.py`, `comment.py`, `remote.py`, `labels.py`
- `/tea:issue``issue_check.py`, `issue_tree.py`, `issue_index.py`,
`issue_new.py`, `issue_ac.py`
- `/tea:wiki``wiki_ls.py`, `wiki_pull.py`, `wiki_push.py`
- `/tea:page``page_import.py`, `page_index.py`, `page_ls.py`
Invoke `Skill` with the one that owns the task at the start, and use the command
table it gives you verbatim. The skill is the single source of
truth for the script surface; a flag you recall from another session is a
guess. If the skill does not document a flag, it does not exist — report that
instead of trying it.
## Hard rules
1. **No raw `tea`.** Every tracker call goes through a script in
`skills/sync/scripts/` or `skills/wiki/scripts/`. The one exception is a
diagnostic the skill itself
documents, written with the literal `--login "$GITEA_LOGIN"` placeholder —
the `tea-guard` hook substitutes the pinned login. Never name a login.
2. **No writing to issue files.** You have no `Edit` and no `Write`. Scripts
write files; you do not. If a task needs a body edited or a metadata field
changed by hand, stop and say which file and which field. `issue_ac.py` is
the one script that touches a body, and it changes a single character: tick
only the items the caller named, by the number or the substring the caller
gave. Whether a criterion is actually met is a judgement about content, and
content is never yours.
3. **Push only what you were told to push.** `push.py` and `wiki_push.py`
publish to a tracker other people read, **and `push.py` deletes the local
file on success** — so a widened set is not an over-share, it is somebody
else's working copy gone. Run them with the ids, titles, or filter the
caller named. Never widen the set, never run a bare `push.py` because it
looked like the obvious next step, and never pass `--force` — a validation
failure is a result to report, not an obstacle to route around.
`wiki_push.py` needs `-m`; use the caller's words, never your own summary.
Report the number and URL `push.py` printed; that is now the only address
the issue has.
4. **Do not close, delete, or retitle anything** on either side. On the wiki
that means no `--retitle`: renaming a published page abandons the old one.
The one deletion you may cause is push's own, on the issue you were told to
push.
5. **One retry, maximum.** A command that fails twice is a finding. Do not
permute flags looking for one that works.
6. **No payload dumps.** Never run `tea issues -o json`, never `cat` a pulled
issue body back into your report. The scripts print compact output by
design; the caller reads the files it needs from disk.
## Procedure
1. Load the skill you need.
2. Run the commands. Prefer one filtered call over a loop —
`pull.py --milestone 6` is one request per 50 issues, `pull.py 41 42 43…`
is one per issue.
3. If a command exits non-zero, capture the last lines of stderr and stop that
branch. Keep going on independent branches.
4. Report.
## Report format
Your final message is the return value. Keep it under ~20 lines. No preamble,
no restatement of the request, no advice about what to do next.
```
ran:
pull.py --milestone 6 --state all ok 7 issues, 3 threads
issue_index.py ok INDEX.md rebuilt
push.py wire-sqlc-appclick FAIL exit 1
touched: tmp/issues/{a,b,c}.md, tmp/issues/INDEX.md
failed: push.py wire-sqlc-appclick
ERROR wire-sqlc-appclick: missing section '## Acceptance criteria'
blocked: none
```
- `ran` — one line per command: what, ok/FAIL, and the one number that matters.
- `touched` — paths only. Never contents.
- `failed` — the command, then stderr verbatim, trimmed to the lines that name
the cause. Quote it exactly; do not paraphrase an error.
- `blocked` — what you refused to decide, phrased as the question the caller
has to answer. `none` when there is nothing.
## Known stops
Report these and halt; none of them is yours to resolve.
| Condition | Report |
|---|---|
| no login pinned (`tea-guard` blocks, or a script points at `/tea:auth`) | `blocked: no pinned login — operator must run /tea:auth` |
| `issue_check.py` errors before a push | the validator's own lines, verbatim |
| a dependency is still `origin: local` | name the id; the caller decides whether to push it |
| a milestone or label does not exist in the repo | the script prints the real ones — pass that list through |
| a script asks for a decision (type, label, `--force`) | `blocked:` with the question |
+219 -45
View File
@@ -1,57 +1,231 @@
---
name: issue
description: Create a Gitea issue in the project's canonical format. Run when the user asks to file/create an issue, or types /tea:issue. Ensures exclusive type/* labels exist, composes the body from the type's template, and posts via tea api. Format lives in the use skill's references.
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 — create an issue in the canonical format
# /tea:issue — issues as units of work
Thin procedure on top of the canonical format defined in
[`../use/references/issue-format.md`](../use/references/issue-format.md).
Read that file first — it is the single source of truth for types, labels,
templates, and language rules. Login rules are the same as everywhere:
always `--login "$GITEA_LOGIN"`, never a literal name (see `/tea:use`).
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.
## Steps
**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`.
1. **Read the format**: load `../use/references/issue-format.md`.
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_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` (for
ideas not ready for work). If it is not obvious from the request, ask the
user (one question).
3. **Ensure labels exist**: `tea labels list --login "$GITEA_LOGIN" -o json`.
For each missing **exclusive** label (`type/*`, and `severity/*` when
used), create it via `tea api` with `"exclusive": true` exactly as shown
in the format doc. Do NOT use `tea labels create` for these — it cannot
set exclusivity. Non-exclusive `tech/*` and `comp/*` labels may be created
either way; apply them when the technology or component is evident.
4. **Compose title and body** per the format: English imperative title without
a type prefix; the type's template with all sections present, in order,
headers in English, prose in Russian; `## Spec` filled with a repo path,
a URL, or the literal `none` — ask the user if you cannot determine which.
If the issue depends on others, add a `## Depends on` section right after
`## Spec` (one `#N` per line); omit it otherwise.
5. **Post via tmp/ + tea api** (the body is always multi-line, so entity
commands are off the table — see "Rich payloads" in `/tea:use`):
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
mkdir -p tmp/issue
# write {"title": "...", "body": "...", "labels": [<type-label-id>]} as JSON
tea api --login "$GITEA_LOGIN" -X POST -d @tmp/issue/<slug>.json \
repos/{owner}/{repo}/issues
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
```
The create endpoint takes label **IDs** (integers), not names — take them
from the `tea labels list` output of step 3 (or from the create response).
The `labels` array holds every applied label: the `type/*` ID plus any
`severity/*`, `tech/*`, `comp/*` IDs. If labels fail to attach on create,
fall back to `PUT repos/{owner}/{repo}/issues/{n}/labels` with
`{"labels": [<id>]}`.
6. **Report**: show the issue URL and the applied labels.
## Editing an existing issue
One file = one issue. Several related issues = several files, linked through
`depends:`.
When asked to bring an existing issue to the format: fetch it with the use
skill's script (`python3 ../use/scripts/fetch_issue.py <n>` relative to this
skill's base dir — writes `tmp/issue/<n>/data` + comments, prints a compact
index; no `--login`, it resolves the pin itself), restructure the body into
the type's template without losing information, then
`PATCH repos/{owner}/{repo}/issues/{n}` with the new title/body and ensure
exactly one `type/*` label is set.
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. 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.
## 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`.
+370
View File
@@ -0,0 +1,370 @@
# 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. Source spec: the project wiki
([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
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]
wiki: [Simple Chains/Ideas/Chain core]
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** |
| `wiki` | domain | page **titles** this issue is written up in; may be empty. Titles, not URLs — a title is a name for a document and stays in this layer, a URL is tracker bookkeeping. `/tea:page` owns what those titles mean; `page_ls.py --titles` prints them |
| `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 this is the one place where the file's fate
depends on it:
| `origin:` | what the file is | what a push does to it |
|---|---|---|
| `local` | the issue itself — the only copy there is | creates it in the tracker, then deletes the file |
| a tracker | a working copy of something the tracker already has | updates the tracker, then deletes the file |
**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.
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.
+713
View File
@@ -0,0 +1,713 @@
#!/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 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
+134
View File
@@ -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())
+72
View File
@@ -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())
+116
View File
@@ -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()
+197
View File
@@ -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()
+99
View File
@@ -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()
+88
View File
@@ -0,0 +1,88 @@
---
name: page
description: Organize a discussion's artifacts into a named, ordered tree of wiki pages — import a directory of markdown, give every file a title, build the index, see what a space holds. Entirely offline; pages are local markdown files and need no wiki. Load when the user asks to turn notes/artifacts into wiki pages, organize or re-title a page tree, or rebuild a table of contents. For fetching from or publishing to a Gitea wiki, load /tea:wiki instead.
---
# /tea:page — discussion artifacts as a page tree
A discussion produces artifacts wherever the discussion happened — a directory
of markdown with numbered files and subdirectories. This skill turns that into
a **space**: a named, ordered tree of pages with a manifest, living under
`tmp/wiki/`.
**Nothing here touches the network.** No `tea`, no Gitea, no login. A space that
never leaves this machine is a finished thing, not a draft waiting for an
upload. Publishing is a separate, optional layer — `/tea:wiki`.
Read [`references/pages.md`](references/pages.md) before importing or
re-titling. It is the single source of truth for titles, ordering, paths, the
manifest, and the index.
## Identity: the title
`Simple Chains/Ideas/Chain core`. The `/` is the only hierarchy there is — the
wiki this feeds is flat and has no directories. The local path is derived from
the title (`Simple-Chains/Ideas/Chain-core.md`); the reverse never happens.
A title is chosen **once**, at import or at pull, and then it is a fact in the
manifest. Editing a heading does not rename a page. Renaming is `--retitle`,
and on a published page it orphans the old one.
## Scripts
All offline, all in `<skill-base-dir>/scripts/`.
| Script | What it does |
|---|---|
| `page_import.py --from DIR [--space S] [--prefix T]` | copy a directory of markdown into a space, titling every file |
| `page_index.py [--space S] [--prefix T]` | write the table-of-contents page — the navigation the flat wiki cannot provide |
| `page_ls.py [--space S] [--prefix T]` | the tree, the titles, and one sync-state tag per page |
| `page.py` | the domain module the others import — not a command |
```
tmp/wiki/claude-skills/tea/ a space
.pages.json the manifest — titles, order, sync bookkeeping
Simple-Chains.md the index page
Simple-Chains/Ideas.md title: Simple Chains/Ideas
Simple-Chains/Ideas/Chain-core.md title: Simple Chains/Ideas/Chain core
```
## The usual run
```bash
python3 scripts/page_import.py \
--from ~/proj/tmp/simple-chains \
--space claude-skills/tea --prefix "Simple Chains" --dry-run
```
`--dry-run` first, always: it prints every path and the title it would get, and
that listing is the only chance to catch a heading that titles a page badly
before the name becomes a decision. Drop the flag to write.
Then the index, then look at it:
```bash
python3 scripts/page_index.py --space claude-skills/tea --prefix "Simple Chains"
python3 scripts/page_ls.py --space claude-skills/tea --prefix "Simple Chains"
```
`page_ls.py` tags each page `local` (never published), `synced` (published and
unchanged), or `ahead` (edited since it was published). `local` is a complete
state.
## Where the cache is
`<repo root>/tmp/wiki`**not** `tmp/wiki` 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 cache no matter which
directory they are run from.
`--out` overrides that and is taken **literally**: an absolute path is used as
given, a relative one stays relative to the current directory.
## Re-importing is the normal refresh
The discussion continues, the artifacts change, run the same import again.
Bodies are replaced, titles are kept, `sub_url` and the rest of the wiki
bookkeeping survive — so the next push updates the pages that already exist
instead of publishing a second copy of each.
+173
View File
@@ -0,0 +1,173 @@
# The page-tree format
Canonical. Everything about how a discussion's artifacts become named, ordered,
navigable pages lives here. The scripts implement this document; when they
disagree, this document is right.
## The one fact that shapes everything: the wiki is flat
Gitea's wiki has no directories. It has a list of pages, each stored as one
file whose name Gitea escapes from the title:
| title | file Gitea writes | `sub_url` |
|---|---|---|
| `Abstract Issue` | `Abstract-Issue.md` | `Abstract-Issue` |
| `zz-probe/child` | `zz-probe%2Fchild.-.md` | `zz-probe%2Fchild.-` |
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC.md` | same, minus `.md` |
Three rules are visible in that table, and all three are Gitea's to change:
space becomes `-`; `/` becomes `%2F`; a **literal** `-` in the title forces a
trailing `.-` marker so it stays distinguishable from a space.
Two consequences run through the whole design.
**Hierarchy lives in the title and nowhere else.** `/` inside a title is the
only nesting there is. A real subdirectory committed into the wiki's git
repository — `folder/page.md` — is invisible to the API and to the web UI. It
is a ghost file. Never create one.
**`sub_url` is identity and is never constructed.** It is read back from
whatever the API returned and stored in the manifest. A hand-built one that is
almost right does not fail loudly; it creates a second page and abandons the
first.
## The space
```
tmp/wiki/claude-skills/tea/ a SPACE
.pages.json the manifest
Simple-Chains.md title: Simple Chains (the index)
Simple-Chains/
Ideas.md title: Simple Chains/Ideas
Ideas/
Chain-core.md title: Simple Chains/Ideas/Chain core
```
A space is a directory holding a page tree and one manifest. Its name is
normally the `owner/repo` it syncs with, and to the domain layer that is an
opaque relative path — `--space docs` and `--space a/b/c` are equally valid.
The path is `<repo root>/tmp/wiki`, resolved from `page.py`'s own location and
not from the working directory. `--out` overrides it and is used exactly as
typed. Nothing creates a space as a side effect of a write: the scripts say so
on stderr when they make one.
## The manifest
`.pages.json`, one entry per page, keyed by the file's path inside the space.
```json
{
"space": "claude-skills/tea",
"pages": {
"Simple-Chains/Ideas/Chain-core.md": {
"title": "Simple Chains/Ideas/Chain core",
"order": 2,
"pushed": "9a1ab2e3bfd45f7c7ba323d9d8cd59642d6f0540",
"remote-updated": "2026-08-10T11:15:39Z",
"sha": "fc8ec1779d910850f49bfef60dd5a0e737bbdc8a",
"sub_url": "Simple-Chains%2FIdeas%2FChain-core",
"synced": "2026-08-10T11:15:39Z",
"url": "https://git.noodles.cam/…/wiki/Simple-Chains%2FIdeas%2FChain-core"
}
}
}
```
| key | owner | meaning |
|---|---|---|
| `title` | domain | the page's name; `/` is hierarchy |
| `order` | domain | sort key from a `NN-` file-name prefix; absent when there was none |
| `sub_url` | wiki | Gitea's address for the page — **the identity** |
| `pushed` | wiki | sha1 of the bytes last published; the whole of change detection |
| `sha` | wiki | the wiki commit the local copy came from |
| `synced` | wiki | when this copy was fetched or pushed |
| `url` | wiki | browser link |
| `remote-updated` | wiki | the page's last commit date in the wiki |
The domain layer writes `title` and `order`, carries everything else through
load and save verbatim, and never reads it. A page with no `sub_url` has never
been published — a complete state, not a pending one, exactly as `origin: local`
is for an issue.
## How a source file gets its title
Applied at import, once. Three rules, in order:
1. **`order 0`, or a file literally named `index` / `readme`, is the page for
the directory it sits in.** `ideas/00-intro.md` becomes `…/Ideas`, not a
child of it. Its title comes from the **directory name**, never from its own
heading — a child's title has to extend its parent's exactly, and that file
opens with "Ideas for chain business requirements", which no child would
ever be prefixed by.
2. **Otherwise the file's first markdown heading**, sanitized. It is what a
human wrote for a human: there is no mechanical route from
`03-q-01-do-we-know-the-chain-participant-by-name.md` to
`Q-01. Do We Know the Chain Participant by Name`.
3. **No heading: the file name**, made readable — `NN-` stripped, `-` and `_`
to spaces, first letter raised. Only the first letter: title-casing would
wreck `Q-01`, `sqlc`, and `APNs`.
Sanitizing a title drops markdown markup (`` ` ``, `*`, `_` — a page list does
not render markdown) and turns `/` into `-`, because a slash inside a heading
would silently invent a level of hierarchy the author did not ask for.
### A title is a decision, not a derivation
Once a page is in the manifest its title stays put. Re-importing replaces the
body and leaves the title alone, so editing a heading cannot rename a page —
which matters because renaming a **published** page does not move it, it
creates a second one and orphans the first. `--retitle` opts into that
explicitly.
The reverse direction does not exist. A path is derived from a title; a title
is never derived from a path. `02-chain-core` proves why: those dashes are
real, and undoing "space became dash" would eat them.
## Ordering
A leading `NN-` on a file name is sort order and nothing else — it never
reaches the title. `00` is special and means "this is the directory's own
page". Pages with an order sort before pages without one: an explicit `NN-` is
a decision, its absence is not.
The wiki cannot hold ordering, so `order` is local-only and survives a pull.
## Paths
A path is one component per title segment, spaces to `-`, with characters a
shell has to quote dropped — apostrophes and quotes and commas. `Don't send to
this one` keeps its apostrophe in the title and loses it in
`Dont-send-to-this-one.md`.
Two titles can land on one path. That is reported and never resolved
automatically: picking a winner is how a discussion loses a document. Rename a
source, or rename the page in the wiki, and run it again.
## The index page
The wiki will not draw a tree from titles, so an index page is the navigation,
not a nicety. `page_index.py` writes one as an ordinary page in the space — it
is pushed by the same command as everything else.
Nesting follows the **titles**, not the manifest's path order; those two
disagree, because on disk `Simple-Chains/System.md` sorts before
`Simple-Chains/Ideas/Scale.md` while in the hierarchy System is a child and
Scale a grandchild. A parent with no page of its own still gets a node, so its
children are not hidden.
Links: a published page is linked by its `sub_url`, the only address Gitea
guarantees. A page that has never been pushed gets Gitea's `[[Title|label]]`
wiki-link syntax, which resolves the escaping on the server at render time.
Rebuilding the index after a push upgrades those links to exact ones — so the
order is **push, rebuild the index, push again**.
## What the sync does not do
- **No merge.** A pull overwrites the local body. `synced` tells you how old
your copy is; re-pull when it matters.
- **No drift tracking.** `pushed` answers one question — is the local file
different from what was published — and answers it with a hash.
- **No deletes.** Pushing is additive. A page removed locally stays in the
wiki; removing a published page is an explicit act, done in the web UI or
with a `DELETE` through `/tea:use`.
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env python3
r"""
page.py — what a PAGE TREE is. The domain layer for wiki artifacts.
Not a command; the module the other page scripts build on. It knows how a
directory of markdown becomes a named, ordered tree of pages, and it knows
NOTHING about any wiki: no Gitea, no `tea`, no logins, no HTTP, no `sub_url`.
The layering rule is mechanically checkable — every import in this directory is
stdlib, and `subprocess` is not among them:
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
Delete skills/wiki/ entirely and this layer keeps working: a discussion's
artifacts organized into a tree on this machine are a finished thing, not a
draft waiting for an upload.
tmp/wiki/claude-skills/tea/ <- a SPACE
.pages.json <- the manifest
Simple-Chains/
Ideas.md title: Simple Chains/Ideas
Ideas/
Chain-core.md title: Simple Chains/Ideas/Chain core
A space is a directory holding a page tree and one manifest. The space's name
("claude-skills/tea") is an opaque relative path to this module — it happens to
be an owner/repo pair, and this layer never learns that.
Why a manifest at all
---------------------
Because the wiki's own page identity is not derivable from a file path, and
guessing at it is how you get duplicate pages. The manifest is the record of
what each local file IS, written once at import or pull and never re-derived.
Domain keys in a manifest entry are `title` and `order`. Everything else —
`sub_url`, `sha`, `synced`, `pushed` — is written by the wiki layer, carried
through load/save verbatim, and never read here. That passthrough is what lets
one manifest describe both a local-only tree and a published one without the
domain learning a second vocabulary.
Titles
------
The title is the identity that matters, and `/` inside it is the ONLY
hierarchy there is — the wiki this feeds has no directories. A local path is
derived from the title, never the reverse:
title "Simple Chains/Ideas/Chain core"
path "Simple-Chains/Ideas/Chain-core.md"
That direction is deliberate. Deriving a title back from a path would have to
undo `-`-for-space, and `02-chain-core` proves it cannot: the dashes there are
real. So a title is chosen ONCE, at import or at pull, and then it is a fact in
the manifest. Renaming is an explicit act, not a side effect of editing a
heading.
Ordering
--------
A leading `NN-` on a file name is sort order and nothing else — it never
reaches the title. `order 0` is special: it is the directory's own page, so
`ideas/00-intro.md` becomes the page "…/Ideas" rather than a child of it.
"""
import hashlib
import json
import os
import re
# --------------------------------------------------------------------------
# where the cache lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/wiki`, absolute, resolved once at import — the same anchoring
# rule the issue store uses, and for the same reason: a script's own location is
# a fact about the installation, cwd is a fact about the last `cd`. Walking up
# from __file__ hands every script in both layers one answer no matter where it
# is invoked from.
#
# The twenty lines below are duplicated from the issue domain rather than
# imported from it. Two domains that do not know about each other is worth more
# than the duplication is worth saving: skills/page must keep working with
# skills/issue deleted, exactly as skills/issue keeps working with skills/sync
# deleted.
STORE_PARTS = ("tmp", "wiki")
# `.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__))
MANIFEST = ".pages.json"
# Written here; read here. Everything else in an entry belongs to the wiki
# layer and is passed through untouched.
DOMAIN_KEYS = ("title", "order", "source")
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 store_root(start=None):
"""Absolute path of the wiki cache root.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the historical
cwd-relative location stands — made absolute so an error 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))
WIKI_ROOT = store_root()
def space_root(space, root=None):
"""Directory of one space. `space` is an opaque relative path — it may
contain `/` (it usually does) and is used as typed."""
return os.path.join(root or WIKI_ROOT, *space.split("/"))
# --------------------------------------------------------------------------
# names, titles, order
# --------------------------------------------------------------------------
# Characters a title may not carry into a path. `/` is absent on purpose: it is
# the hierarchy separator and is split on before this ever applies.
_UNSAFE = re.compile(r'[\\:*?"<>|\x00-\x1f]+')
# Inline code in a heading is markup, not a name: `Inventory — \`P-NN\`` is a
# page called "Inventory — P-NN", and a page list does not render markdown.
_MARKUP = re.compile(r"[`*_]+")
# Dropped from a PATH but kept in a title. An apostrophe in "Don't send to this
# one" belongs in the name and does not belong in something a shell has to
# quote.
_PATH_NOISE = re.compile(r"['‘’\"“”,]+")
_DASHES = re.compile(r"-{2,}")
_ORDER = re.compile(r"^(\d+)[-_. ]+(.*)$")
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
def order_of(name):
"""The `NN-` sort key on a file or directory name, or None.
`00-intro.md` -> 0, `02-chain-core.md` -> 2, `handoff.md` -> None. Zero is
a real answer and not None; callers distinguish them."""
m = _ORDER.match(strip_ext(name))
return int(m.group(1)) if m else None
def strip_ext(name):
stem, ext = os.path.splitext(name)
return stem if ext.lower() in (".md", ".markdown") else name
def strip_order(name):
"""`02-chain-core` -> `chain-core`; a name that is only digits is left
alone, because stripping it would leave nothing to call the page."""
m = _ORDER.match(strip_ext(name))
return m.group(2) if m and m.group(2) else strip_ext(name)
def title_from_name(name):
"""Fallback title: the file or directory name made readable.
`02-chain-core.md` -> `Chain core`. Only the first letter is raised —
title-casing would wreck `Q-01`, `sqlc`, `APNs`, and every other name that
already knows how it is spelled."""
t = strip_order(name).replace("_", " ").replace("-", " ").strip()
t = re.sub(r"\s+", " ", t)
return t[:1].upper() + t[1:] if t else t
def title_from_body(text):
"""The document's first markdown heading, or None.
Preferred over the file name because it is what a human wrote for a human:
`03-q-01-do-we-know-the-chain-participant-by-name.md` opens with
`## Q-01. Do We Know the Chain Participant by Name`, and there is no
mechanical route from the first string to the second. Only the first
heading is consulted, and only before any prose — a heading further down is
a section, not a name."""
for line in text.splitlines():
if not line.strip():
continue
m = _HEADING.match(line)
return m.group(1).strip() if m else None
return None
def sanitize_title(title):
"""Make a string safe to be one title SEGMENT.
`/` becomes `-`: a slash inside a heading would silently invent a level of
hierarchy that the author did not ask for, and inventing structure is worse
than losing a slash."""
t = _MARKUP.sub("", _UNSAFE.sub("", title.replace("/", "-")))
return re.sub(r"\s+", " ", t).strip(" .-") or "untitled"
def join_title(*parts):
"""Join title segments with the hierarchy separator, dropping empties."""
return "/".join(p for p in parts if p)
def path_segment(segment):
"""One title segment as one path component."""
s = _PATH_NOISE.sub("", _MARKUP.sub("", _UNSAFE.sub("", segment)))
s = re.sub(r"\s+", "-", s.replace("/", "-").strip())
return _DASHES.sub("-", s).strip("-.") or "untitled"
def path_for_title(title):
"""Relative path, inside a space, for a title. Always ends in `.md`."""
parts = [path_segment(p) for p in title.split("/") if p.strip()]
if not parts:
parts = ["untitled"]
return os.path.join(*parts) + ".md"
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
def blank_manifest(space):
return {"space": space, "pages": {}}
def manifest_path(space, root=None):
return os.path.join(space_root(space, root), MANIFEST)
def load_manifest(space, root=None):
"""The space's manifest, or a blank one.
A missing manifest and an empty one are the same thing to every caller here
— but they are NOT the same thing to a caller deciding whether to print
"no such space". That distinction is `os.path.isdir(space_root(...))`, and
the commands make it themselves rather than reading it out of a dict."""
p = manifest_path(space, root)
if not os.path.isfile(p):
return blank_manifest(space)
with open(p, encoding="utf-8") as f:
m = json.load(f)
m.setdefault("space", space)
m.setdefault("pages", {})
return m
def save_manifest(manifest, root=None):
"""Write the manifest, keys sorted, one page per line-block.
Sorted and indented because this file lands in a diff every time anything
syncs, and a diff nobody can read is a diff nobody checks."""
p = manifest_path(manifest["space"], root)
os.makedirs(os.path.dirname(p), exist_ok=True)
ordered = {"space": manifest["space"], "pages": {}}
for path, e in sorted(manifest.get("pages", {}).items()):
ordered["pages"][path] = {k: e[k] for k in DOMAIN_KEYS if k in e}
ordered["pages"][path].update(
{k: v for k, v in sorted(e.items()) if k not in DOMAIN_KEYS})
with open(p, "w", encoding="utf-8") as f:
json.dump(ordered, f, ensure_ascii=False, indent=2, sort_keys=False)
f.write("\n")
return p
def entry(title, order=None, source=None, **extra):
"""A manifest entry. Domain keys first, passthrough after — the same
render order the issue layer uses, for the same reason: it makes a diff of
the file readable."""
e = {"title": title}
if order is not None:
e["order"] = order
if source is not None:
e["source"] = source
e.update({k: v for k, v in extra.items() if v is not None})
return e
def find_by_source(manifest, source, prefix=""):
"""(relpath, entry) for the page imported from this source file, or
(None, None).
The path is derived from the title, so a retitle moves it — and looking a
page up by its new path would find nothing, treat it as new, and publish a
duplicate beside the page it was meant to rename. Source is the one link
that survives a rename, which is why it is recorded at all.
Scoped by title prefix, so importing the same directory twice under two
prefixes gives two independent trees rather than one fighting over itself.
"""
for path, e in manifest.get("pages", {}).items():
if e.get("source") != source:
continue
if prefix and not (e.get("title", "") == prefix
or e.get("title", "").startswith(prefix + "/")):
continue
return path, e
return None, None
def sort_key(relpath, e):
"""Order a tree for display and for an index.
Directory by directory, `order` first and unnumbered pages after — an
explicit `NN-` is a decision, its absence is not. Ties break on title so
the output is stable."""
d = os.path.dirname(relpath)
o = e.get("order")
return (d, 0 if o is not None else 1, o if o is not None else 0,
e.get("title", relpath))
def sorted_pages(manifest):
"""[(relpath, entry)] in tree order."""
return sorted(manifest.get("pages", {}).items(),
key=lambda kv: sort_key(kv[0], kv[1]))
def by_title(manifest):
return {e["title"]: (p, e) for p, e in manifest.get("pages", {}).items()
if e.get("title")}
def children_of(manifest, prefix):
"""Every page at or under a title prefix.
The wiki this feeds is flat, so "children" is a prefix test on the title
and nothing more — there is no tree to walk, only a naming convention to
trust."""
out = []
for p, e in sorted_pages(manifest):
t = e.get("title", "")
if t == prefix or t.startswith(prefix + "/"):
out.append((p, e))
return out
def body_hash(text):
"""sha1 of the exact bytes a page would be published as.
This is the whole of change detection: a page is worth pushing when what is
on disk hashes differently from what was pushed last. No timestamps, no
drift model — the same stance the issue store takes."""
if isinstance(text, str):
text = text.encode("utf-8")
return hashlib.sha1(text).hexdigest()
# --------------------------------------------------------------------------
# importing a directory of markdown
# --------------------------------------------------------------------------
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules"}
MD_EXT = (".md", ".markdown")
def walk_markdown(src):
"""Every markdown file under `src`, as paths relative to it, depth first
and sorted so an import is reproducible."""
out = []
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = sorted(d for d in dirnames
if d not in SKIP_DIRS and not d.startswith("."))
rel = os.path.relpath(dirpath, src)
rel = "" if rel == "." else rel
for f in sorted(filenames):
if f.lower().endswith(MD_EXT) and not f.startswith("."):
out.append(os.path.join(rel, f) if rel else f)
return out
def title_for_source(relpath, text, prefix=""):
"""The title a source file gets on import.
Three rules, in this order, and the reference doc spells out why:
1. `order 0` (`00-intro.md`, or a literal `index`/`readme`) is the page for
the directory it sits in. Its title comes from the DIRECTORY name, not
from its own heading — a child's title must extend its parent's exactly,
and `ideas/00-intro.md` opens with "Ideas for chain business
requirements", which no child would ever be prefixed by.
2. Any other file takes its first heading, sanitized.
3. No heading: the file name, made readable.
"""
parts = relpath.replace(os.sep, "/").split("/")
name = parts[-1]
dirs = [sanitize_title(title_from_name(d)) for d in parts[:-1]]
stem = strip_ext(name).lower()
if order_of(name) == 0 or stem in ("index", "readme"):
# The directory's own page. At the root of the import that is the
# prefix itself.
return join_title(prefix, *dirs)
own = title_from_body(text)
own = sanitize_title(own) if own else sanitize_title(title_from_name(name))
return join_title(prefix, *dirs, own)
def plan_import(src, prefix="", read=None):
"""Work out what an import would produce, without writing anything.
Returns (pages, collisions):
pages [{"source", "path", "title", "order", "text"}] in tree order
collisions [(path, [title, title, ...])] — two sources landing on one
file. Reported, never resolved: the wiki would end up with
two pages fighting over one local copy, and picking a winner
for the operator is how a discussion loses a document."""
def default_read(p):
with open(p, encoding="utf-8") as f:
return f.read()
read = read or default_read
pages, seen = [], {}
for rel in walk_markdown(src):
source = os.path.join(src, rel)
text = read(source)
title = title_for_source(rel, text, prefix)
path = path_for_title(title)
seen.setdefault(path, []).append(title)
# `source` is kept relative to the import root, not absolute: it is the
# only durable link between a file on the far side and the page it
# became, and it has to survive the artifacts directory being moved.
pages.append({"source": source, "rel": rel.replace(os.sep, "/"),
"path": path, "title": title,
"order": order_of(os.path.basename(rel)), "text": text})
pages.sort(key=lambda p: sort_key(p["path"], p))
collisions = [(p, t) for p, t in sorted(seen.items()) if len(t) > 1]
return pages, collisions
# --------------------------------------------------------------------------
# rendering
# --------------------------------------------------------------------------
def title_tree(manifest, prefix=""):
"""Group pages into a parent -> children map keyed by title.
Built from the titles, not from the manifest's path order. Those two
disagree: on disk `Simple-Chains/System.md` sorts before
`Simple-Chains/Ideas/Scale.md`, while in the hierarchy Scale is a
grandchild of Simple Chains and System is a child. Nesting has to follow
the titles, because the titles are the only hierarchy there is.
A parent with no page of its own still gets a node: `Simple Chains/Parked`
can have children while nothing is published at that title, and dropping
its children because it is missing would hide them entirely."""
kids, entries = {}, {}
for _, e in manifest.get("pages", {}).items():
title = e.get("title")
if not title:
continue
if prefix and not (title == prefix or title.startswith(prefix + "/")):
continue
entries[title] = e
parts = title.split("/")
# Every ancestor gets a node, so a gap in the chain does not orphan a
# subtree.
for i in range(len(parts), 0, -1):
kids.setdefault("/".join(parts[:i - 1]), set()).add("/".join(parts[:i]))
return kids, entries
def render_index(manifest, prefix="", heading=None):
"""A table-of-contents page for a space or a subtree.
Nested markdown list, indented by title depth. The wiki is flat and will
not draw this for you, so the index IS the navigation.
Links: a published page is linked by its `sub_url`, which is the only
address Gitea guarantees. A page that has never been pushed has no sub_url
yet, so it gets Gitea's own `[[Title]]` wiki-link syntax — which resolves
the escaping itself, at render time, on the server. Rebuilding the index
after a push upgrades those links to exact ones."""
kids, entries = title_tree(manifest, prefix)
lines = ["# %s" % (heading or prefix or "Contents"), ""]
def order_key(title):
e = entries.get(title) or {}
o = e.get("order")
return (0 if o is not None else 1, o if o is not None else 0, title)
def walk(node, depth):
for child in sorted(kids.get(node, ()), key=order_key):
e = entries.get(child) or {}
label = child.split("/")[-1]
sub = e.get("sub_url")
link = "[%s](%s)" % (label, sub) if sub else "[[%s|%s]]" % (child, label)
lines.append("%s- %s" % (" " * depth, link))
walk(child, depth + 1)
walk(prefix, 0)
lines.append("")
return "\n".join(lines)
def tree_lines(manifest, mark=None):
"""The space as an ascii tree, for a terminal.
`mark(relpath, entry)` returns a short state tag shown after the title —
the wiki layer passes sync state through it, and this module stays unaware
of what the tags mean."""
out, last_dir = [], None
for path, e in sorted_pages(manifest):
d = os.path.dirname(path)
if d != last_dir:
out.append("%s/" % d if d else ".")
last_dir = d
tag = mark(path, e) if mark else ""
out.append(" %-40s %s%s" % (os.path.basename(path),
e.get("title", ""),
(" " + tag) if tag else ""))
return out
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
page_import.py — pull a directory of markdown into a space. Offline.
This is the "wiki organization" step, and it is the only step where a page gets
its name. A discussion produces artifacts wherever the discussion happened:
~/…/mpns/feat/simple-chains/tmp/simple-chains/
handoff.md scope.md
ideas/00-intro.md ideas/02-chain-core.md
questions/03-q-01-do-we-know-the-chain-participant-by-name.md
Import copies that tree into a space under `tmp/wiki/`, gives every file a
title, and records both in the manifest. Nothing here talks to a wiki; the
result is a complete, readable, greppable tree whether or not it is ever
published.
page_import.py --from DIR --space claude-skills/tea --prefix "Simple Chains"
Simple-Chains/Handoff.md Simple Chains/Handoff
Simple-Chains/Ideas.md Simple Chains/Ideas
Simple-Chains/Ideas/Chain-core.md Simple Chains/Ideas/Chain core
Re-importing is safe and is the normal way to refresh: a page already in the
manifest keeps its title (a title is a decision, not a derivation) and only its
body is replaced. `--retitle` opts into re-deriving titles, which is a rename
and, for pages already published, will orphan the old ones — so it is never the
default.
Usage:
page_import.py --from DIR [--space SPACE] [--prefix TITLE]
[--retitle] [--dry-run] [--out DIR]
"""
import argparse
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--from", dest="src", required=True,
help="directory of markdown to import")
ap.add_argument("--space", default="local",
help="space to import into (default: local)")
ap.add_argument("--prefix", default="",
help="title every imported page hangs under")
ap.add_argument("--retitle", action="store_true",
help="re-derive titles of pages already in the manifest "
"(a rename; orphans published pages)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
src = os.path.abspath(a.src)
if not os.path.isdir(src):
die("not a directory: %s" % a.src)
root = a.out or page.WIKI_ROOT
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
pages, collisions = page.plan_import(src, prefix)
if not pages:
die("no markdown found under %s" % src)
if collisions:
for path, titles in collisions:
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
die("%d path collision(s); rename the sources and retry" % len(collisions))
manifest = page.load_manifest(a.space, root)
known = manifest["pages"]
dest_root = page.space_root(a.space, root)
# Asked before anything is written: nothing should create a space as a
# silent side effect of a write, and saying so on stderr is how the
# operator learns a typo in --space made a second one.
created = not os.path.isdir(dest_root)
new = changed = same = moved = 0
for p in pages:
# Looked up by SOURCE, not by path: a retitle moves the path, and a
# lookup that missed would treat the page as new and publish a
# duplicate beside the one it was meant to rename.
prior_path, prior = page.find_by_source(manifest, p["rel"], prefix)
if prior is None:
prior_path, prior = p["path"], known.get(p["path"])
# A title already in the manifest is a decision that was made once.
# Re-deriving it on every import would let an edited heading silently
# rename a published page — which does not rename it, it creates a
# second one and abandons the first.
title = p["title"] if (a.retitle or not prior) else prior["title"]
relpath = page.path_for_title(title)
dest = os.path.join(dest_root, relpath)
state = "new"
if prior and relpath != prior_path:
state = "moved"
elif prior and os.path.isfile(dest):
with open(dest, encoding="utf-8") as f:
state = "same" if f.read() == p["text"] else "changed"
elif prior:
state = "changed"
new += state == "new"
changed += state == "changed"
same += state == "same"
moved += state == "moved"
print("%-7s %-44s %s" % (state, relpath, title))
if a.dry_run:
continue
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(p["source"], dest)
# Passthrough keys survive: a re-import must not cost a page its
# sub_url, or the next push would publish a duplicate.
e = dict(prior or {})
e.update(page.entry(title, p["order"], p["rel"]))
if state == "moved":
# The old copy goes, the entry moves with its bookkeeping intact.
# The page in the wiki is still at its old sub_url; the next push
# sends the new title, which is what renames it there.
old = os.path.join(dest_root, prior_path)
if os.path.isfile(old):
os.remove(old)
known.pop(prior_path, None)
# A rename can leave the body byte-identical, and push decides by
# body hash alone. Clearing it is what makes the next push send the
# new title instead of skipping the page as unchanged.
e.pop("pushed", None)
known[relpath] = e
if a.dry_run:
print("\ndry run — nothing written")
return 0
path = page.save_manifest(manifest, root)
if created:
sys.stderr.write("created space %s\n" % dest_root)
print("\n%d new, %d changed, %d unchanged%s -> %s"
% (new, changed, same,
", %d renamed" % moved if moved else "", os.path.dirname(path)))
if moved:
sys.stderr.write(
"%d page(s) renamed. A published page is renamed in the wiki by "
"the next push, not by this import.\n" % moved)
return 0
if __name__ == "__main__":
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
page_index.py — write a table-of-contents page into a space. Offline.
The wiki this feeds is flat: a title like `Simple Chains/Ideas/Chain core` has
hierarchy in its name and nowhere else, and Gitea will not draw you a tree from
it. An index page is therefore not a nicety, it is the navigation.
Written as an ordinary page in the space, so it is pushed by the same command
as everything else and needs no special case anywhere downstream. Links are
written by TITLE rather than by URL — the wiki resolves those itself, and a
link written that way survives every filename-escaping rule this layer
deliberately refuses to model.
page_index.py --space claude-skills/tea --prefix "Simple Chains"
-> Simple-Chains.md, title `Simple Chains`
page_index.py --space claude-skills/tea --title Home
-> Home.md, title `Home`, listing the whole space
Usage:
page_index.py [--space SPACE] [--prefix TITLE] [--title TITLE]
[--dry-run] [--out DIR]
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--space", default="local")
ap.add_argument("--prefix", default="",
help="index only this subtree; also the index's own title")
ap.add_argument("--title", help="title for the index page "
"(default: --prefix, else Home)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(a.space, root)
if not os.path.isdir(space_dir):
die("no such space: %s (looked in %s)" % (a.space, space_dir))
manifest = page.load_manifest(a.space, root)
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
title = a.title or prefix or "Home"
body = page.render_index(manifest, prefix, heading=title)
relpath = page.path_for_title(title)
if a.dry_run:
sys.stdout.write(body)
print("-> %s (%s)" % (relpath, title))
return 0
dest = os.path.join(space_dir, relpath)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding="utf-8") as f:
f.write(body)
# Carries the entry's passthrough keys forward: rebuilding an index must
# update the page that is already published, never publish a second one.
prior = manifest["pages"].get(relpath, {})
e = dict(prior)
e.update(page.entry(title, prior.get("order")))
manifest["pages"][relpath] = e
page.save_manifest(manifest, root)
n = len(page.children_of(manifest, prefix) if prefix
else page.sorted_pages(manifest))
print("%s -> %s (%d entr%s)" % (title, relpath, n - 1,
"y" if n - 1 == 1 else "ies"))
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
page_ls.py — show what a space holds. Offline.
The tree, the titles, and one state tag per page. The tag is the only place
this layer acknowledges that a wiki exists, and it reads it the way the issue
index reads `origin:` — as an opaque fact recorded by somebody else:
local never published; a complete state, not a pending one
synced published, and the file matches what was pushed
ahead published, and the local file has changed since
? published, but nothing recorded what was pushed
Usage:
page_ls.py [--space SPACE] [--prefix TITLE] [--titles] [--out DIR]
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def state_of(space_dir, relpath, e):
if not e.get("sub_url"):
return "local"
pushed = e.get("pushed")
if not pushed:
return "?"
full = os.path.join(space_dir, relpath)
if not os.path.isfile(full):
return "missing"
with open(full, encoding="utf-8") as f:
return "synced" if page.body_hash(f.read()) == pushed else "ahead"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--space", default="local")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--titles", action="store_true",
help="print one title per line and nothing else")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(a.space, root)
# "Does not exist" and "is empty" are different answers and get different
# messages — an empty space is a space somebody made on purpose.
if not os.path.isdir(space_dir):
die("no such space: %s (looked in %s)" % (a.space, space_dir))
manifest = page.load_manifest(a.space, root)
pages = (page.children_of(manifest, a.prefix) if a.prefix
else page.sorted_pages(manifest))
if not pages:
print("space %s is empty" % a.space if not a.prefix
else "nothing at or under %r" % a.prefix)
return 0
if a.titles:
for _, e in pages:
print(e.get("title", ""))
return 0
sub = {p: e for p, e in pages}
view = dict(manifest, pages=sub)
for line in page.tree_lines(view, mark=lambda p, e: state_of(space_dir, p, e)):
print(line)
counts = {}
for p, e in pages:
s = state_of(space_dir, p, e)
counts[s] = counts.get(s, 0) + 1
print("\n%d page(s): %s" % (len(pages),
", ".join("%d %s" % (v, k)
for k, v in sorted(counts.items()))))
return 0
if __name__ == "__main__":
sys.exit(main())
+369
View File
@@ -0,0 +1,369 @@
---
name: sync
description: Move issues between the local store and Gitea — pull issues into tmp/issues/, push local issues up, post comments. Load when the user asks to fetch/read a Gitea issue, publish an issue, list what exists in the tracker, or comment on 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` themselves, the same source
the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`.
| Script | What it does |
|---|---|
| `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing |
| `pull.py <key…>` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/<id>.md`, plus `<id>.comments.md` when the thread is not empty |
| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now |
| `comment.py <id> --file F \| --body TEXT [--edit N]` | post or edit a comment, then refetch the thread |
| `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 --deps # follow dependencies
```
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.
**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 no request at all.
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, `pull.py --deps` 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 --deps` | 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.
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 writes it back into the issue file; a value already there is never
overwritten, neither on create nor on `--update`. 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.
## 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, `pull --deps` reads them |
| — | `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
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`.
+400
View File
@@ -0,0 +1,400 @@
#!/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: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up
from CWD — the same file /tea:auth writes and the tea-guard hook reads. 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`.
"""
import datetime
import json
import os
import subprocess
import re
import sys
import urllib.parse
PAYLOAD_DIR = ".payload"
REMOTE_MAP = ".remote.json"
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")
# --------------------------------------------------------------------------
# login
# --------------------------------------------------------------------------
def find_pin(start_dir=None):
"""Walk up from start_dir; return the login from the first
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
d = os.path.abspath(start_dir or ".")
while True:
p = os.path.join(d, ".claude", "settings.local.json")
if os.path.isfile(p):
try:
with open(p) as f:
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
pass
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def require_login():
login = find_pin(os.getcwd())
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,
out_root=None, allow_fail=False):
"""Call `tea api`; return parsed JSON (None on an empty body).
payload (a dict) is written to <out_root>/.payload/<name>.json and passed
as -d @file — the file survives the call for retries and debugging.
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:
pdir = os.path.join(out_root or ".", PAYLOAD_DIR)
os.makedirs(pdir, exist_ok=True)
path = os.path.join(pdir, "%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 paginate(login, endpoint, limit=50, max_pages=40, **kw):
"""GET a list endpoint page by page; return the concatenated list."""
sep = "&" if "?" in endpoint else "?"
out = []
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:
break
out.extend(batch)
if len(batch) < limit:
break
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):
"""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."""
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)
got = paginate(login, endpoint, limit=per_page,
max_pages=max(1, -(-limit // per_page)))
got = [p for p in got if matches(p, ms_id, labels)]
return got[:limit], 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, out_root=None):
"""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),
out_root=out_root, 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("/", "-"), out_root=root)
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
+101
View File
@@ -0,0 +1,101 @@
#!/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,
out_root=root)
verb = "edited"
else:
got = _gitea.api(login, "%s/issues/%d/comments" % (base, number), "POST",
{"body": body}, payload_name="comment-%s" % args.id,
out_root=root)
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()
+236
View File
@@ -0,0 +1,236 @@
#!/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.
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("/", "-"),
out_root=issue.ISSUE_ROOT)
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("/", "-"),
out_root=issue.ISSUE_ROOT)
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()
+354
View File
@@ -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
+283
View File
@@ -0,0 +1,283 @@
#!/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. The limit is on the write, not on the selection —
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.
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).
Other flags:
--deps [--depth N] follow dependencies and pull them too
--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. 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 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 cap (default: 100)")
ap.add_argument("--deps", action="store_true", help="follow dependencies and pull them")
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:
payloads, ms_title = _gitea.list_issues(
login, base, state=args.state, labels=args.label, query=args.query,
milestone=args.milestone, limit=args.limit)
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 even its comments. 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)
else:
store_ids.add(id)
number_of_id[number] = id
if args.cached and stored:
skipped.append(id) # untouched, unread, and not one request spent
else:
extra = _gitea.native_deps(login, base, number) if args.deps 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=extra,
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 "")
+ _gitea.native_deps(login, base, number))
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.
for id in sorted(set(written) | set(skipped)):
iss = issue.load(root, id)
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)
if args.deps:
print("graph: run issue_tree.py (offline) to draw it")
if __name__ == "__main__":
main()
+417
View File
@@ -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 `pull.py --deps` 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, out_root=root)
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, out_root=root)
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, out_root=root)
_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, root):
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()
+69
View File
@@ -0,0 +1,69 @@
#!/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]
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()
+36 -55
View File
@@ -1,6 +1,6 @@
---
name: use
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, issues, pulls, releases, actions, 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.
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
@@ -9,6 +9,19 @@ 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
@@ -33,12 +46,11 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
## How to use
1. Identify the entity in the request: issues, pulls, labels, milestones,
releases, times, repos, branches, actions, webhooks, comments,
notifications, etc.
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 issues list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
`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
@@ -46,39 +58,6 @@ The pin takes effect immediately — no restart. Only `tea logins list` and
per-project by the operator (see `/tea:auth`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`.
## Reading an issue: use the fetch script, not raw tea calls
To read an existing issue (its body, its discussion), do NOT run
`tea issues <n> -o json` or `tea api .../issues/<n>` directly — the full JSON
payload (avatars, nested user objects, every comment body) lands in your
context whether you need it or not. Instead run the bundled script; the only
input it needs is the issue key:
```bash
python3 <skill-base-dir>/scripts/fetch_issue.py 42
```
Key forms: `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).
It writes trimmed markdown files locally and prints only a compact index:
```
tmp/issue/42/data issue: metadata header + body
tmp/issue/42/comments/ one file per comment: NNN-<comment-id>.md
```
Then Read just the files the task needs — often the `data` file alone, or a
single comment picked from the index (author + date per line). Each comment
file carries its `comment-id`, ready for a `PATCH` via `tea api`.
Notes:
- No `--login` on the script call: the script resolves the operator's pinned
login itself from `.claude/settings.local.json` — same source as the
tea-guard hook. No pin → it exits with a pointer to `/tea:auth`.
- Every run refetches fresh and wipes the issue's `comments/` dir, so stale
files never survive.
## Index
- [tea CLI overview](references/tea/index.md) — global flags, common options, output formats
@@ -86,15 +65,14 @@ Notes:
- [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
- [ISSUE FORMAT](references/issue-format.md) — canonical issue format: label
namespaces (`type/*`, `severity/*` exclusive; `tech/*`, `comp/*` free),
types `bug|task|refactor|test|feature|draft`, templates, dependencies,
title and language rules. MANDATORY whenever creating or editing an issue;
the `/tea:issue` skill is the guided procedure for it.
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 issues create`, `tea pulls create`, …)
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
@@ -104,26 +82,29 @@ kills the process (e.g. exit 144 = 128 + SIGURG on macOS).
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
`comment`, `issue`, `pull`, `release`, etc.
`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/comment
cat > tmp/comment/issue-60.json <<'EOF'
{"body": "## Heading\n\nMulti-line markdown with `code`, | tables |, and ```fences```."}
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/comment/issue-60.json`.
`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/comment/issue-60.json \
repos/{owner}/{repo}/issues/60/comments
-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.
@@ -132,12 +113,12 @@ request payload to `$PWD/tmp/` first, then POST via `tea api`.
| Action | Method + endpoint |
|---|---|
| Comment on issue/PR | `POST repos/{owner}/{repo}/issues/{n}/comments` |
| Edit comment | `PATCH repos/{owner}/{repo}/issues/comments/{id}` |
| Create issue | `POST repos/{owner}/{repo}/issues` |
| Edit issue/PR body or title | `PATCH repos/{owner}/{repo}/issues/{n}` |
| 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.
-245
View File
@@ -1,245 +0,0 @@
# Issue format
Canonical format for every issue created or edited via `tea`. Designed to be
unambiguous for both humans and LLMs: fixed English section headers in a fixed
order, verifiable acceptance criteria, one issue = one deliverable. Source
spec: the project wiki ([Issues-Workflow](https://git.noodles.cam/claude-skills/tea/wiki/Issues-Workflow)).
## 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 (Gitea enforces at most
one label from the scope), 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 | Color | Meaning |
|---|---|---|
| `type/bug` | `#ee0701` | Something behaves incorrectly in existing code |
| `type/task` | `#0e8a16` | Implementation of new functionality |
| `type/refactor` | `#1d76db` | Internal restructuring: file moves, architecture; behavior must not change |
| `type/test` | `#fbca04` | Writing or fixing tests |
| `type/feature` | `#5319e7` | Container: several issues delivering one unit of business value |
| `type/draft` | `#cccccc` | Idea captured for later; not ready for work |
### `severity/*` — at most one
| Label | Color |
|---|---|
| `severity/low` | `#c2e0c6` |
| `severity/medium` | `#fbca04` |
| `severity/high` | `#eb6420` |
| `severity/showstopper` | `#ee0701` |
| `severity/critical` | `#b60205` |
### `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.
### Creating exclusive labels
Gitea enforces exclusivity only if the label was created with
`exclusive: true`. The `tea labels create` command (as of tea 0.14.2) cannot
set that field, so missing `type/*` and `severity/*` labels MUST be created
via `tea api`:
```bash
tea api --login "$GITEA_LOGIN" -X POST \
-d '{"name":"type/bug","color":"#ee0701","exclusive":true,"description":"Something behaves incorrectly in existing code"}' \
repos/{owner}/{repo}/labels
```
`tech/*` and `comp/*` are non-exclusive; either `tea labels create` or
`tea api` works for them.
## Dependencies
An issue may explicitly depend on others. Declare that in an optional
`## Depends on` section placed right after `## Spec`, one `#N` reference per
line:
```markdown
## Depends on
- #12 — нужна схема БД из этого issue
- #15
```
Omit the section when there are no dependencies — never write an empty one.
## 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.
- Code references use the `path/file.ext:line` form; related issues as `#N`.
- Screenshots are allowed but their content must be duplicated as text — an
LLM posting through `tea api` cannot read 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 link
back via `## Depends on` or the `## Issues` list here. Keep implementation
detail in the children; the feature body stays at business level.
```markdown
## Summary
Бизнес-ценность одним-двумя предложениями.
## Spec
Ссылка или `none`.
## Motivation
Какую проблему пользователя/системы это решает.
## Issues
- [ ] #N — краткое описание части
- [ ]
## 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. Manage via
`tea milestones` / `tea milestones issues`.
- **Project** — a set of issues describing one project, tracked by status
columns. Standard statuses: Backlog, ToDo, InProgress, Ready, Done. The
Gitea projects API is not exposed via `tea` subcommands — use the web UI.
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env python3
"""
fetch_issue.py — pull one Gitea issue (+ all comments) to local files.
Token-saving fetcher for Claude sessions: instead of dumping raw API JSON
into the conversation, it writes trimmed markdown files under tmp/issue/
and prints only a compact index. Read the files you actually need.
tmp/issue/<n>/data issue itself (metadata header + body)
tmp/issue/<n>/comments/ one file per comment: NNN-<comment-id>.md
Usage:
fetch_issue.py <key> [--repo owner/repo] [--out DIR]
<key> 42 | #42 | owner/repo#42 | https://host/owner/repo/issues/42
Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking
up from CWD — the same file /tea:auth writes and the tea-guard hook reads.
The script never accepts a login argument: the operator's pin is the only
identity it will use. No pin -> exit with a pointer to /tea:auth.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
def die(msg, code=1):
sys.stderr.write("fetch_issue: " + msg + "\n")
sys.exit(code)
def find_pin(start_dir):
"""Walk up from start_dir; return login from the first
.claude/settings.local.json carrying a non-empty env.GITEA_LOGIN."""
d = os.path.abspath(start_dir or ".")
while True:
p = os.path.join(d, ".claude", "settings.local.json")
if os.path.isfile(p):
try:
with open(p) as f:
v = (json.load(f).get("env") or {}).get("GITEA_LOGIN")
if isinstance(v, str) and v.strip():
return v.strip()
except Exception:
pass
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def parse_key(key):
"""Return (number, repo-or-None) from 42 / #42 / owner/repo#42 / 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)
def tea_api(login, endpoint):
"""GET via `tea api`, return parsed JSON."""
cmd = ["tea", "api", "--login", login, endpoint]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
die("`tea api %s` failed:\n%s" % (endpoint, (r.stderr or r.stdout).strip()))
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
die("`tea api %s` returned non-JSON:\n%s" % (endpoint, r.stdout[:500]))
def day(iso):
return (iso or "")[:10]
def issue_markdown(iss):
labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "none"
assignees = ", ".join(a.get("login", "") for a in iss.get("assignees") or []) or "none"
milestone = (iss.get("milestone") or {}).get("title") or "none"
lines = [
"#%d %s" % (iss["number"], iss.get("title", "")),
"state: %s" % iss.get("state", ""),
"labels: %s" % labels,
"author: %s" % (iss.get("user") or {}).get("login", ""),
"assignees: %s" % assignees,
"milestone: %s" % milestone,
"created: %s" % iss.get("created_at", ""),
"updated: %s" % iss.get("updated_at", ""),
"url: %s" % iss.get("html_url", ""),
"comments: %d" % iss.get("comments", 0),
"",
"---",
"",
iss.get("body") or "(no body)",
"",
]
return "\n".join(lines)
def comment_markdown(c):
lines = [
"comment-id: %d" % c["id"],
"author: %s" % (c.get("user") or {}).get("login", ""),
"created: %s" % c.get("created_at", ""),
"updated: %s" % c.get("updated_at", ""),
"",
"---",
"",
c.get("body") or "(empty)",
"",
]
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description="Fetch a Gitea issue + comments to tmp/issue/<n>/")
ap.add_argument("key", help="issue key: 42, #42, owner/repo#42, or issue URL")
ap.add_argument("--repo", help="owner/repo (default: auto-detect from CWD git remote)")
ap.add_argument("--out", default=os.path.join("tmp", "issue"),
help="output root (default: tmp/issue)")
args = ap.parse_args()
number, key_repo = parse_key(args.key)
repo = args.repo or key_repo # None -> let tea fill {owner}/{repo} from CWD
base = "repos/%s" % repo if repo else "repos/{owner}/{repo}"
login = find_pin(os.getcwd())
if not login:
die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.")
iss = tea_api(login, "%s/issues/%d" % (base, number))
comments = []
page = 1
while page <= 40:
batch = tea_api(login, "%s/issues/%d/comments?page=%d&limit=50" % (base, number, page))
if not isinstance(batch, list) or not batch:
break
comments.extend(batch)
if len(batch) < 50:
break
page += 1
root = os.path.join(args.out, str(number))
cdir = os.path.join(root, "comments")
shutil.rmtree(cdir, ignore_errors=True) # drop stale comments from earlier fetches
os.makedirs(cdir, exist_ok=True)
data_path = os.path.join(root, "data")
with open(data_path, "w") as f:
f.write(issue_markdown(iss))
index = []
for i, c in enumerate(comments, 1):
name = "%03d-%d.md" % (i, c["id"])
with open(os.path.join(cdir, name), "w") as f:
f.write(comment_markdown(c))
index.append((name, (c.get("user") or {}).get("login", ""), day(c.get("created_at"))))
# Compact index — the only thing that lands in the model's context.
labels = ", ".join(l.get("name", "") for l in iss.get("labels") or []) or "no labels"
print("#%d %s [%s] %s%s, updated %s" % (
iss["number"], iss.get("title", ""), iss.get("state", ""), labels,
(iss.get("user") or {}).get("login", ""), day(iss.get("updated_at"))))
print(data_path)
print("comments: %d" % len(comments))
for name, author, created in index:
print("%s %s %s" % (os.path.join(cdir, name), author, created))
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
---
name: wiki
description: Move wiki pages between a local space and Gitea — fetch a page and everything under it as a local cache, publish a page tree with an update message, list what the wiki holds. Load when the user asks to read/fetch a wiki page, cache a wiki subtree for a discussion, or publish artifacts to the wiki. Organizing artifacts into a page tree (titles, ordering, the index) is /tea:page and needs no network.
---
# /tea:wiki — the bridge between a local space and a Gitea wiki
One job: translate between `tmp/wiki/<space>/` and Gitea's wiki JSON, and carry
the result over the wire. Everything about **what a page tree is** — titles,
ordering, paths, the index — belongs to `/tea:page` and is imported from there,
never redefined here.
Transport is `tea api` through `skills/sync/scripts/_gitea.py`: the same login
pin, the same pagination, the same payload files. There is no second transport.
## The wiki is flat, and that is the whole design
Gitea's wiki is a list of pages, not a tree. Nesting exists only inside a
title, as `/`, and Gitea escapes that title into a filename by rules that are
its own:
| title | `sub_url` |
|---|---|
| `Abstract Issue` | `Abstract-Issue` |
| `zz-probe/child` | `zz-probe%2Fchild.-` |
| `Simple Chains/Parked/Chain decisions — DC` | `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC` |
**`sub_url` is the identity and is never constructed.** It is read back from
the API and stored in the manifest. Building one by hand that is almost right
does not fail loudly — it creates a second page and abandons the first.
**Never commit a subdirectory into the wiki's git repository.** A real
`folder/page.md` is invisible to the API and to the web UI. It is a ghost file.
Do not clone the wiki repo to work in; use these scripts.
## Scripts
In `<skill-base-dir>/scripts/`.
| Script | What it does |
|---|---|
| `wiki_ls.py [--prefix T]` | what the wiki actually holds — titles, `sub_url`, last commit. One call, no bodies |
| `wiki_pull.py [--prefix T] [--space S]` | fetch a page and everything under it into a local space |
| `wiki_push.py -m MSG [--prefix T] [PATH…]` | publish; create what is new, update what changed, skip what is not |
| `wikimap.py` | md ↔ wiki JSON, pure — not a command |
## Fetching a subtree as a cache
"A page and its children" is a prefix test on the title, run against one
listing call, followed by one GET per page. There is no tree endpoint and no
bulk-body endpoint.
```bash
python3 scripts/wiki_ls.py --prefix "Simple Chains" # what is there
python3 scripts/wiki_pull.py --prefix "Simple Chains" # cache it locally
```
A pull **overwrites** the local body — a fetch, not a merge. `synced` tells you
how old your copy is; re-pull when it matters. Nothing tracks drift.
The space defaults to the repo's own `owner/repo`, so a pull with no flags
caches this repo's whole wiki into `tmp/wiki/<owner>/<repo>/`.
## Publishing
```bash
python3 scripts/wiki_push.py -m "Import the simple-chains discussion" --dry-run
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
```
`-m` is required and is the wiki commit message — the only record of why a page
changed, and it shows up in `wiki/revisions/<sub_url>`. One operation, one
message.
Change detection is a hash: a page whose file matches `pushed` is skipped.
A page with no `sub_url` is created; one with a `sub_url` is edited in place,
using the title **from the manifest** — sending a different title to the edit
endpoint is a rename and leaves nothing at the old address.
Selection, narrowest first: positional `PATH`-or-`TITLE` arguments (matched
exactly), then `--prefix`, then the whole space.
**Pushing is additive.** A page deleted locally is not deleted in the wiki.
Removing a published page is an explicit act — the web UI, or
`tea api --login "$GITEA_LOGIN" -X DELETE repos/{owner}/{repo}/wiki/page/<sub_url>`.
## Order of operations for a fresh tree
The index links published pages by `sub_url`, which does not exist until the
first push. So:
```bash
python3 ../page/scripts/page_import.py --from DIR --prefix "Simple Chains"
python3 scripts/wiki_push.py -m "Import the simple-chains discussion"
python3 ../page/scripts/page_index.py --prefix "Simple Chains" # now with real links
python3 scripts/wiki_push.py -m "Index"
```
## Linking an issue to a page
An issue's `wiki:` field holds page **titles**, not URLs — a title is a name for
a document and stays in the domain; the URL is bookkeeping. `wiki_ls.py
--titles` prints them one per line, which is what to paste.
## Endpoints, for when a script is not enough
Reach for `/tea:use` and `tea api` directly only for what has no script — a
delete, or a page's history.
| | |
|---|---|
| list | `GET repos/{owner}/{repo}/wiki/pages` |
| read | `GET repos/{owner}/{repo}/wiki/page/{sub_url}` |
| create | `POST repos/{owner}/{repo}/wiki/new``{title, content_base64, message}` |
| edit | `PATCH repos/{owner}/{repo}/wiki/page/{sub_url}` — same body |
| delete | `DELETE repos/{owner}/{repo}/wiki/page/{sub_url}` |
| history | `GET repos/{owner}/{repo}/wiki/revisions/{sub_url}` |
The `tea` CLI has no wiki subcommand. `tea api` is the only route.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
wiki_ls.py — list what is actually in a wiki. One call, no bodies.
Cheap enough to run before a pull: it tells you what titles exist, which is the
only thing a prefix filter can be built from, and it shows the `sub_url` Gitea
settled on for each — worth a look the first time a title contains a dash or a
slash, because the escaping is not what anyone guesses.
wiki_ls.py
wiki_ls.py --prefix "Simple Chains"
wiki_ls.py --repo other/repo --titles
Usage:
wiki_ls.py [--repo owner/repo] [--prefix TITLE] [--titles] [--urls]
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
import _gitea # noqa: E402
import wikimap # noqa: E402
def cell(v):
return (str(v or "").strip().replace("|", "\\|")) or ""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--titles", action="store_true",
help="print one title per line and nothing else")
ap.add_argument("--urls", action="store_true", help="add the browser URL")
a = ap.parse_args()
login = _gitea.require_login()
base = _gitea.repo_base(a.repo)
slug = _gitea.repo_slug(login, a.repo)
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
if not isinstance(listing, list):
_gitea.die("unexpected listing from %s/wiki/pages" % base)
rows = sorted((p for p in listing
if wikimap.matches_prefix(p.get("title") or "", a.prefix)),
key=lambda p: p.get("title") or "")
if not rows:
print("no page at or under %r in %s" % (a.prefix, slug) if a.prefix
else "%s has no wiki pages" % slug)
return 0
if a.titles:
for p in rows:
print(p.get("title") or "")
return 0
head = ["title", "sub_url", "updated", "by"] + (["url"] if a.urls else [])
print("| %s |" % " | ".join(head))
print("|%s|" % "|".join("---" for _ in head))
for p in rows:
c = (p.get("last_commit") or {}).get("author") or {}
row = [cell(p.get("title")), "`%s`" % cell(p.get("sub_url")),
cell((c.get("date") or "")[:10]), cell(c.get("name"))]
if a.urls:
row.append(cell(p.get("html_url")))
print("| %s |" % " | ".join(row))
print("\n%d page(s) in %s" % (len(rows), slug))
return 0
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
wiki_pull.py — fetch wiki pages into a local space.
wiki_pull.py the whole wiki
wiki_pull.py --prefix "Simple Chains" a page and everything under it
wiki_pull.py --repo other/repo --space docs from elsewhere, into a named space
The wiki is flat, so "a page and its children" is a prefix test on the title,
run against one listing call. One GET per page follows. There is no tree
endpoint to ask for a subtree, and no way to fetch bodies in bulk.
Pulling OVERWRITES the local body — a fetch, not a merge, the same stance the
issue store takes. `sha` and `synced` tell you how old your copy is; re-pull
when it matters. Nothing tracks drift.
Usage:
wiki_pull.py [--repo owner/repo] [--prefix TITLE] [--space SPACE]
[--dry-run] [--out DIR]
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
import _gitea # noqa: E402
import page # noqa: E402
import wikimap # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
login = _gitea.require_login()
base = _gitea.repo_base(a.repo)
slug = _gitea.repo_slug(login, a.repo)
space = a.space or slug
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(space, root)
listing = _gitea.paginate(login, "%s/wiki/pages" % base)
if not isinstance(listing, list):
_gitea.die("unexpected listing from %s/wiki/pages" % base)
wanted = [p for p in listing
if wikimap.matches_prefix(p.get("title") or "", a.prefix)]
if not wanted:
if a.prefix:
_gitea.die("no page at or under %r in %s (%d page(s) in the wiki)"
% (a.prefix, slug, len(listing)))
_gitea.die("%s has no wiki pages" % slug)
manifest = page.load_manifest(space, root)
# Two remote titles can land on one local path — Gitea keeps them apart with
# its `.-` marker, a filesystem does not. Caught before anything is written,
# because the failure mode otherwise is one page silently overwriting
# another and the manifest pointing both entries at the survivor.
seen = {}
for p in wanted:
seen.setdefault(page.path_for_title(p["title"]), []).append(p["title"])
clashes = {k: v for k, v in seen.items() if len(v) > 1}
for path, titles in sorted(clashes.items()):
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
created = not os.path.isdir(space_dir)
n = 0
for p in sorted(wanted, key=lambda x: x.get("title") or ""):
title = p["title"]
relpath = page.path_for_title(title)
if relpath in clashes:
continue
if a.dry_run:
print("%-44s %s" % (relpath, title))
n += 1
continue
full = _gitea.api(login, wikimap.page_endpoint(base, p["sub_url"]))
if not isinstance(full, dict):
_gitea.warn("could not read %r; skipped" % title)
continue
text = wikimap.decode(full)
dest = os.path.join(space_dir, relpath)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding="utf-8") as f:
f.write(text)
# The prior entry is the base so `order` — a local decision the wiki
# cannot hold — survives a pull.
e = dict(manifest["pages"].get(relpath, {}))
e.update(wikimap.from_payload(full))
e["synced"] = _gitea.now_iso()
# What is on disk is now exactly what is published, so push has nothing
# to do until the file is edited.
e["pushed"] = page.body_hash(text)
manifest["pages"][relpath] = e
print("%-44s %s" % (relpath, title))
n += 1
if a.dry_run:
print("\ndry run — %d page(s) would be written to %s" % (n, space_dir))
return 1 if clashes else 0
page.save_manifest(manifest, root)
if created:
sys.stderr.write("created space %s\n" % space_dir)
print("\n%d page(s) from %s -> %s" % (n, slug, space_dir))
if clashes:
sys.stderr.write("%d collision(s) skipped — rename them in the wiki\n"
% len(clashes))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
wiki_push.py — publish a local space to a wiki.
wiki_push.py -m "Import the simple-chains discussion"
wiki_push.py --prefix "Simple Chains/Ideas" -m "Rework B-04"
wiki_push.py -m "Fix the send-timing table" Simple-Chains/Ideas/Send-timing.md
Every page in the selection is compared against `pushed` — the hash of what was
last published — and only the ones that differ are sent. That is the whole of
change detection: no timestamps, no drift model.
A page with no `sub_url` is created; a page with one is edited in place. The
title comes from the manifest, never re-derived from the file, because sending
a different title to the edit endpoint is a RENAME and leaves nothing behind at
the old address.
Pushing is additive. A page deleted locally is NOT deleted in the wiki — the
manifest simply stops mentioning it. Removing a published page is an explicit
act; do it in the web UI or with a DELETE through /tea:use.
Usage:
wiki_push.py -m MESSAGE [--space SPACE] [--repo owner/repo]
[--prefix TITLE] [--dry-run] [--out DIR] [PATH-or-TITLE ...]
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(_HERE, "..", "..", "sync", "scripts"))
sys.path.insert(0, os.path.join(_HERE, "..", "..", "page", "scripts"))
import _gitea # noqa: E402
import page # noqa: E402
import wikimap # noqa: E402
def select(manifest, prefix, targets):
"""The pages to consider, in tree order.
A positional argument matches a manifest path or a title, exactly. Exact
because a near-miss that silently selects nothing is indistinguishable from
a clean no-op run, and the operator finds out only when the page never
appears."""
pages = (page.children_of(manifest, prefix) if prefix
else page.sorted_pages(manifest))
if not targets:
return pages, []
want, chosen, hit = set(targets), [], set()
for p, e in pages:
if p in want or e.get("title") in want:
chosen.append((p, e))
hit.add(p if p in want else e.get("title"))
return chosen, sorted(want - hit)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("targets", nargs="*", metavar="PATH-or-TITLE")
ap.add_argument("-m", "--message", required=True,
help="wiki commit message for this push")
ap.add_argument("--repo", help="owner/repo (default: the repo in CWD)")
ap.add_argument("--space", help="local space (default: the owner/repo slug)")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
login = _gitea.require_login()
base = _gitea.repo_base(a.repo)
slug = _gitea.repo_slug(login, a.repo)
space = a.space or slug
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(space, root)
if not os.path.isdir(space_dir):
_gitea.die("no such space: %s (looked in %s). Import or pull first."
% (space, space_dir))
manifest = page.load_manifest(space, root)
if not manifest["pages"]:
_gitea.die("space %s has no pages in its manifest" % space)
chosen, missing = select(manifest, a.prefix, a.targets)
for t in missing:
_gitea.warn("not in the manifest: %s" % t)
if not chosen:
_gitea.die("nothing selected")
created = updated = skipped = 0
for relpath, e in chosen:
title = e.get("title")
full = os.path.join(space_dir, relpath)
if not title:
_gitea.warn("%s has no title in the manifest; skipped" % relpath)
continue
if not os.path.isfile(full):
_gitea.warn("%s is in the manifest but not on disk; skipped" % relpath)
continue
with open(full, encoding="utf-8") as f:
text = f.read()
h = page.body_hash(text)
if e.get("sub_url") and h == e.get("pushed"):
skipped += 1
continue
verb = "create" if not e.get("sub_url") else "update"
print("%-7s %-44s %s" % (verb, relpath, title))
if a.dry_run:
created += verb == "create"
updated += verb == "update"
continue
if verb == "create":
payload = wikimap.new_payload(title, text, a.message)
got = _gitea.api(login, "%s/wiki/new" % base, method="POST",
payload=payload, payload_name="wiki-new",
out_root=space_dir)
else:
payload = wikimap.edit_payload(title, text, a.message)
got = _gitea.api(login, wikimap.page_endpoint(base, e["sub_url"]),
method="PATCH", payload=payload,
payload_name="wiki-edit", out_root=space_dir)
if not isinstance(got, dict) or not got.get("sub_url"):
_gitea.warn("%s: no page returned; the manifest is unchanged for it"
% title)
continue
# sub_url comes back from Gitea and is stored as given. It is the only
# address this page has, and it is not something we could have computed.
e.update(wikimap.from_payload(got))
e["synced"] = _gitea.now_iso()
e["pushed"] = h
manifest["pages"][relpath] = e
created += verb == "create"
updated += verb == "update"
if a.dry_run:
print("\ndry run — %d to create, %d to update, %d unchanged"
% (created, updated, skipped))
return 0
page.save_manifest(manifest, root)
print("\n%d created, %d updated, %d unchanged -> %s wiki"
% (created, updated, skipped, slug))
return 0
if __name__ == "__main__":
sys.exit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
wikimap.py — md <-> Gitea wiki 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 page; give it a page 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 (page.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
----------------------------------------------------------------------
title title verbatim, both ways; `/` is the
only hierarchy either side has
path — local only; derived from the title
order — local only; the wiki cannot sort
body content_base64 base64, utf-8, verbatim
— sub_url lands in the manifest as sub_url
— last_commit.sha lands as sha
— html_url lands as url
sub_url is the identity, and it is NOT derivable
------------------------------------------------
Gitea stores a wiki page as one flat file whose name it escapes from the title,
and the escaping is not a mapping worth reimplementing:
"Abstract Issue" -> Abstract-Issue.md space -> dash
"zz-probe/child" -> zz-probe%2Fchild.-.md / -> %2F, and a
LITERAL dash forces a
`.-` marker so the two
cases stay distinct
Every rule there is Gitea's to change. So `sub_url` is read back from whatever
the API returned and stored; it is never constructed here, and a caller that
needs to address a page fetches the listing rather than guessing. Building one
by hand is how you get a second page instead of an edit.
The wiki is flat, and only titles are structured
------------------------------------------------
There are no directories. A real subdirectory committed into the wiki's git
repository is invisible to the API and to the web UI — a ghost file. All nesting
lives in the title, which is why `page.py` treats `/` as its only separator.
"""
import base64
# A page's whole shape on the wire, for reference and for tests. Gitea also
# returns `commit_count`, `sidebar` and `footer` on a single-page GET; none of
# them describe the page itself, so none of them cross.
WIRE_KEYS = ("title", "sub_url", "html_url", "content_base64", "last_commit")
def decode(payload):
"""content_base64 -> text. Missing content is "" and not None: a page that
exists with an empty body is a real state, and the caller writing a file
should not have to tell the two apart."""
b = payload.get("content_base64") or ""
return base64.b64decode(b).decode("utf-8", "replace") if b else ""
def encode(text):
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def from_payload(payload):
"""Gitea JSON -> the manifest fields the wiki layer owns, plus the title
the domain owns. The caller merges this into the existing entry so that
domain keys it does not mention (`order`) survive."""
commit = payload.get("last_commit") or {}
author = commit.get("author") or {}
return {
"title": payload.get("title") or "",
"sub_url": payload.get("sub_url") or "",
"url": payload.get("html_url") or "",
"sha": commit.get("sha") or "",
"remote-updated": author.get("date") or "",
}
def new_payload(title, text, message):
"""POST /repos/{owner}/{repo}/wiki/new.
`title` carries the hierarchy; Gitea derives the filename from it and
returns the sub_url it settled on. `message` is the wiki commit message —
the operator's words, not a generated one, because this is the only record
of why a page changed."""
return {"title": title, "content_base64": encode(text), "message": message}
def edit_payload(title, text, message):
"""PATCH /repos/{owner}/{repo}/wiki/page/{sub_url}.
The same shape as a create. Sending the unchanged title is a no-op; sending
a different one is a RENAME, which moves the file and leaves nothing at the
old sub_url — so callers pass the title from the manifest unless the
operator asked for a rename."""
return {"title": title, "content_base64": encode(text), "message": message}
def page_endpoint(base, sub_url):
"""The address of one page. `sub_url` goes in verbatim — Gitea hands it
back already escaped (`%2F` and all), and re-encoding it here would produce
a path that resolves to nothing."""
return "%s/wiki/page/%s" % (base, sub_url)
def revisions_endpoint(base, sub_url):
return "%s/wiki/revisions/%s" % (base, sub_url)
def matches_prefix(title, prefix):
"""Is this page at, or under, a title prefix?
The wiki being flat, "children" is exactly this test and nothing more:
there is no tree to walk, only a naming convention to trust. An empty
prefix matches everything."""
if not prefix:
return True
return title == prefix or title.startswith(prefix + "/")
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
Checkbox state survives a pull; everything else in the body does not.
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
is pinned down with plain strings and no store anywhere. The pull tests then
prove the rule is actually wired into the write path, with the transport
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
single function that shells out to `tea`. Nothing here touches a network, and
no test may ever be made to.
`skills/*/scripts/` are not packages; they go on sys.path by hand.
"""
import contextlib
import io
import os
import re
import shutil
import sys
import tempfile
import unittest
import urllib.parse
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import pull # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
def body(*criteria, **kw):
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
summary = kw.get("summary", "Прозаическое описание.")
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
% (summary, "\n".join(criteria))).strip()
# --------------------------------------------------------------------------
# the rule itself — pure, no store, no tracker
# --------------------------------------------------------------------------
class MergeCheckboxStateTest(unittest.TestCase):
"""`[x]` wins from whichever side has it, for a matching item text."""
def test_a_local_tick_survives_the_overwrite(self):
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
body("- [x] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_a_remote_tick_is_kept(self):
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
body("- [ ] первое", "- [ ] второе"))
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
def test_both_sides_ticked_is_still_ticked(self):
one = body("- [x] первое")
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
def test_the_union_is_taken_item_by_item(self):
got = gmap.merge_checkbox_state(
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
"""Including its state — both states, in both directions."""
got = gmap.merge_checkbox_state(
body("- [x] новое сверху", "- [ ] новое снизу"),
body("- [x] что-то совсем другое"))
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
def test_prose_is_not_merged(self):
got = gmap.merge_checkbox_state(
body("- [ ] пункт", summary="Новый текст с сервера."),
body("- [x] пункт", summary="Старый локальный текст."))
self.assertIn("Новый текст с сервера.", got)
self.assertNotIn("Старый локальный текст.", got)
self.assertIn("- [x] пункт", got)
def test_a_heading_the_local_copy_added_is_gone(self):
remote = body("- [x] пункт")
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
self.assertEqual(got, remote)
def test_no_local_copy_returns_the_server_body_untouched(self):
remote = body("- [ ] пункт")
for local in (None, "", " \n"):
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
"local_body=%r rewrote the body" % local)
def test_nothing_ticked_locally_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
def test_no_matching_item_returns_the_same_object(self):
remote = body("- [ ] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
def test_a_body_with_no_checkboxes_at_all(self):
remote = "## Summary\nодна проза\n"
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
def test_exactly_one_character_changes(self):
"""Ticking a box must not produce a diff wider than the state."""
remote = body("- [ ] пункт", "- [ ] второй")
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
self.assertEqual(len(remote), len(got))
self.assertEqual(len(diff), 1)
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
def test_a_rewrapped_item_keeps_its_tick(self):
"""`Checkbox.text` joins continuation lines with one space, which is
the whole reason matching survives a reflow."""
remote = body("- [ ] длинный пункт, который сервер\n"
" перенёс на две строки")
got = gmap.merge_checkbox_state(
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
self.assertIn("- [x] длинный пункт", got)
def test_a_reworded_item_does_not_keep_its_tick(self):
"""Different text is a different item. The tick stays with the wording
it was put on — this is a match, not a guess."""
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
body("- [x] пункт про SQLC"))
self.assertEqual(got, body("- [ ] пункт про sqlc"))
def test_the_marker_style_does_not_have_to_match(self):
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
the item is the same item however the two sides chose to render it."""
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
self.assertEqual(got, body("1. [x] пункт"))
def test_a_moved_item_keeps_its_tick(self):
"""Matching is on text alone; the section is not part of the key. An
item promoted out of `## Acceptance criteria` is the same item."""
remote = "## Issues\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, "## Issues\n- [x] пункт\n")
def test_duplicate_text_is_read_as_a_set(self):
"""The documented reading: one ticked local item ticks every remote
line with that text. Pairing duplicates by order is the alternative,
and it is the one that can still drop a tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
def test_duplicate_text_never_loses_the_second_tick(self):
"""Two local lines, one remote: order-pairing would drop this tick."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
body("- [ ] пункт", "- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_an_example_inside_a_code_fence_is_not_ticked(self):
"""The domain parser skips fences whole, and so does the merge: a
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
"## Acceptance criteria\n- [x] пункт"))
self.assertIn("```md\n- [ ] пункт\n```", got)
def test_an_existing_capital_X_is_left_alone(self):
remote = body("- [X] пункт")
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
def test_a_capital_X_locally_still_counts_as_ticked(self):
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_unticking_in_the_web_does_not_survive(self):
"""The accepted price, pinned so nobody 'fixes' it by accident:
unticking is not monotone, so a box unticked upstream comes back.
Untick locally and push."""
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertEqual(got, body("- [x] пункт"))
def test_parsing_is_the_domain_layer_s(self):
"""The acceptance criterion, asserted rather than eyeballed: the rule
calls into skills/issue and defines no checkbox syntax of its own."""
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
self.assertTrue(cb.called)
self.assertTrue(sc.called)
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
"""The same criterion from the other side: the bracket markup itself
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
like is the domain's job, and there is only one copy of it."""
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
with open(os.path.join(sync, name)) as f:
code = f.read().split('"""')[0::2] # docstrings dropped
for chunk in code:
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
self.assertNotIn(markup, chunk,
"%s spells out %r" % (name, markup))
class FromApiTest(unittest.TestCase):
"""The seam between the rule and the translation."""
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
"body": body("- [ ] пункт")}
def test_local_body_is_optional_and_defaults_to_no_merge(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
self.assertEqual(iss.body, body("- [ ] пункт"))
def test_local_body_contributes_its_ticks(self):
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
local_body=body("- [x] пункт"))
self.assertEqual(iss.body, body("- [x] пункт"))
def test_an_empty_remote_body_does_not_crash(self):
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
"an-issue", REPO, local_body=body("- [x] пункт"))
self.assertEqual(iss.body, "")
# --------------------------------------------------------------------------
# pull.py — the rule wired into the write path
# --------------------------------------------------------------------------
class FakeGitea(object):
"""A `tea api` that answers issues from memory and remembers the calls."""
def __init__(self):
self.calls = []
self.issues = {}
def add(self, number, title, text, **kw):
p = {"number": number, "title": title, "body": text, "state": "open",
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
"updated_at": "2026-08-10T00:00:00Z"}
p.update(kw)
self.issues[number] = p
return p
def api(self, login, endpoint, method="GET", payload=None, **kw):
self.calls.append((method, endpoint))
path, _, query = endpoint.partition("?")
params = dict(urllib.parse.parse_qsl(query))
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
if m and method == "GET":
return self.issues.get(int(m.group(1)))
if path == "%s/issues" % BASE and method == "GET":
if int(params.get("page", 1)) > 1:
return []
return [self.issues[n] for n in sorted(self.issues)]
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PullTestCase(unittest.TestCase):
"""A temp store and a fake transport."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-store-")
self.fake = FakeGitea()
for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
p.start()
self.addCleanup(p.stop)
self.addCleanup(shutil.rmtree, self.root, True)
def write_local(self, id, text, number=42):
issue.save(self.root, issue.Issue(
id=id, title="An issue", body=text, labels=["type/task"],
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
"url": "https://git.example/x", "synced": "old"}))
def run_pull(self, *argv):
out, err = io.StringIO(), io.StringIO()
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
pull.main()
return out.getvalue(), err.getvalue()
def stored_body(self, id):
return issue.load(self.root, id).body
def raw(self, id):
"""The file on disk, byte for byte — metadata included."""
with open(issue.path_of(self.root, id)) as f:
return f.read()
class PullMergesTicksTest(PullTestCase):
def test_a_tick_made_locally_survives_the_pull(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_tick_made_in_the_web_lands_locally(self):
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_the_rest_of_the_body_is_still_overwritten(self):
self.write_local("an-issue", body("- [x] первое",
summary="Локальная правка прозы."))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
summary="Серверная проза."))
self.run_pull("42")
got = self.stored_body("an-issue")
self.assertIn("Серверная проза.", got)
self.assertNotIn("Локальная правка прозы.", got)
self.assertIn("- [x] первое", got)
self.assertIn("- [ ] новое с сервера", got)
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
self.write_local("an-issue", body("- [ ] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [ ] первое", "- [x] новое с сервера"))
def test_filter_mode_merges_too(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое"))
self.run_pull("--label", "type/task")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
"""The merge hangs off the local id, which is resolved from the number
— a title change must not orphan the ticks."""
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "Completely different title", body("- [ ] первое"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
self.assertEqual(issue.load(self.root, "an-issue").title,
"Completely different title")
class PullIntoAnEmptyStoreTest(PullTestCase):
def test_no_local_file_writes_the_server_body_unchanged(self):
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
shutil.rmtree(self.root)
self.fake.add(42, "An issue", body("- [ ] первое"))
_out, err = self.run_pull("42")
self.assertIn("created store", err)
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
class CachedIsUnchangedTest(PullTestCase):
def test_a_skipped_issue_is_neither_read_nor_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
before = self.raw("an-issue")
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
out, _ = self.run_pull("42", "--cached")
merge.assert_not_called()
self.assertIn("(cached)", out)
self.assertEqual(self.raw("an-issue"), before)
def test_without_cached_the_same_issue_is_merged(self):
self.write_local("an-issue", body("- [x] первое"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
self.assertEqual(self.stored_body("an-issue"),
body("- [x] первое", "- [ ] второе"))
class RoundTripTest(PullTestCase):
"""Pull twice with no change in between: the second is a no-op."""
def test_a_repeat_pull_does_not_churn_the_file(self):
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
self.run_pull("42")
first = self.raw("an-issue")
self.run_pull("42")
self.assertEqual(self.raw("an-issue"), first)
if __name__ == "__main__":
unittest.main()
+423
View File
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
Checkbox parsing, ticking, and the INDEX progress column.
Plain stdlib unittest — the scripts under test are stdlib-only by the layering
rule, and their tests have no business dragging in a dependency the code they
cover is forbidden to have. `skills/*/scripts/` are directories of scripts, not
packages, so they go on sys.path the same way the scripts do it to each other.
python3 -m unittest discover -s tests -v
Nothing here touches tmp/, the network, or the real store: every case builds
its own store in a TemporaryDirectory.
"""
import contextlib
import io
import os
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "skills", "issue", "scripts"))
import issue # noqa: E402
import issue_ac # noqa: E402
import issue_check # noqa: E402
import issue_index # noqa: E402
# Boxes in two different sections, a wrapped item, a fenced example, and a
# plain list item that is not a checkbox at all. Line numbers are 1-based:
# the items sit on lines 8, 9, 12, 14 and 15.
BODY = """## Summary
Что-то про задачу.
## Spec
none
## Issues
- [x] wire-sqlc-appclick — первая часть
- [ ] add-pool-cfg — вторая часть
## Acceptance criteria
- [ ] в `issue.py` есть функция разбора чекбоксов тела:
возвращает пункты с номером строки, состоянием и текстом
- [X] чекбоксы ищутся по всему телу
- [ ] пример в блоке кода не считается пунктом:
```markdown
- [ ] это разметка из шаблона, а не галочка
- [x] и эта тоже
```
## Constraints
- не входит в объём: доставка тела в трекер
"""
NO_BOXES = """## Summary
Тело без единой галочки.
## Spec
none
## Notes
- обычный пункт списка
- ещё один
"""
# Metadata deliberately out of canonical order and missing optional keys, one
# item with trailing whitespace: a round-trip through Issue.to_text() would
# rewrite all of that, so this fixture catches a ticking path that re-renders
# the file instead of patching one character of it.
MESSY = """---
origin: local
labels: [type/task]
id: messy-issue
state: open
---
# Messy but valid
## Summary
Тело, которое нельзя перерисовывать.
## Spec
none
## Acceptance criteria
- [ ] первый пункт
- [ ] второй пункт
- [ ] третий пункт
"""
TASK_BODY = """## Summary
Что нужно сделать.
## Spec
none
## Motivation
Зачем это нужно.
## Acceptance criteria
- [ ] ничего ещё не сделано
- [ ] и это тоже не сделано
"""
def sole_difference(before, after):
"""The single character position at which the two strings differ.
Raises AssertionError when they differ in length or in more than one
place — the whole claim of `set_checkbox` is that this never happens."""
assert len(before) == len(after), (
"length changed: %d -> %d" % (len(before), len(after)))
diff = [i for i, (a, b) in enumerate(zip(before, after)) if a != b]
assert len(diff) == 1, "expected 1 differing character, got %d" % len(diff)
return diff[0]
@contextlib.contextmanager
def store(**files):
"""A throwaway issue store: {id: file text}."""
with tempfile.TemporaryDirectory() as root:
for id, text in files.items():
with open(os.path.join(root, "%s.md" % id), "w", newline="") as f:
f.write(text)
yield root
def run(fn, *argv):
"""Call a script entry point, returning (exit code or None, stdout)."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = fn(list(argv))
return rc, buf.getvalue()
class TestParse(unittest.TestCase):
def test_finds_every_box_in_every_section(self):
items = issue.checkboxes(BODY)
self.assertEqual([c.index for c in items], [1, 2, 3, 4, 5])
self.assertEqual([c.line for c in items], [8, 9, 12, 14, 15])
self.assertEqual([c.checked for c in items],
[True, False, False, True, False])
self.assertEqual([c.section for c in items],
["## Issues", "## Issues"] + ["## Acceptance criteria"] * 3)
def test_boxes_outside_acceptance_criteria_are_items_too(self):
# A type/feature keeps its children under `## Issues`; binding the
# parser to one heading would lose them.
under_issues = [c for c in issue.checkboxes(BODY) if c.section == "## Issues"]
self.assertEqual(len(under_issues), 2)
self.assertTrue(under_issues[0].text.startswith("wire-sqlc-appclick"))
def test_continuation_line_is_part_of_the_item(self):
item = issue.checkboxes(BODY)[2]
self.assertEqual((item.line, item.end_line), (12, 13))
self.assertEqual(
item.text,
"в `issue.py` есть функция разбора чекбоксов тела: "
"возвращает пункты с номером строки, состоянием и текстом")
def test_fenced_example_is_not_an_item(self):
texts = [c.text for c in issue.checkboxes(BODY)]
self.assertNotIn("это разметка из шаблона, а не галочка", texts)
self.assertEqual(len(texts), 5)
def test_plain_list_item_is_not_a_checkbox(self):
self.assertNotIn("не входит в объём: доставка тела в трекер",
[c.text for c in issue.checkboxes(BODY)])
def test_markers_and_nesting(self):
text = ("* [ ] star\n"
"+ [x] plus\n"
"1. [ ] ordered\n"
"2) [x] ordered too\n"
" - [ ] nested\n"
"- [x]no space, not an item\n")
items = issue.checkboxes(text)
self.assertEqual([c.text for c in items],
["star", "plus", "ordered", "ordered too", "nested"])
self.assertEqual([c.checked for c in items],
[False, True, False, True, False])
def test_empty_text(self):
self.assertEqual(issue.checkboxes(""), [])
self.assertEqual(issue.checkboxes(None), [])
def test_line_numbers_are_relative_to_the_text_given(self):
# Same body, prefixed with a metadata block: the offsets move with it,
# which is what lets issue_ac.py work on a whole file.
head = "---\nid: x\nstate: open\n---\n# Title\n\n"
shift = head.count("\n")
self.assertEqual([c.line for c in issue.checkboxes(head + BODY)],
[c.line + shift for c in issue.checkboxes(BODY)])
def test_progress(self):
self.assertEqual(issue.checkbox_progress(BODY), (2, 5))
self.assertEqual(issue.checkbox_progress(NO_BOXES), (0, 0))
class TestToggle(unittest.TestCase):
def test_ticking_changes_exactly_one_character(self):
item = issue.checkboxes(BODY)[1] # line 9, unchecked
after = issue.set_checkbox(BODY, item, True)
at = sole_difference(BODY, after)
self.assertEqual(BODY[at], " ")
self.assertEqual(after[at], "x")
self.assertEqual(issue.checkbox_progress(after), (3, 5))
def test_unticking_changes_exactly_one_character(self):
item = issue.checkboxes(BODY)[0] # line 8, checked
after = issue.set_checkbox(BODY, item, False)
at = sole_difference(BODY, after)
self.assertEqual((BODY[at], after[at]), ("x", " "))
def test_every_item_toggles_in_isolation(self):
for item in issue.checkboxes(BODY):
after = issue.set_checkbox(BODY, item, not item.checked)
at = sole_difference(BODY, after)
self.assertEqual(after.splitlines()[item.line - 1].count("["), 1)
self.assertLess(at, len(BODY))
def test_no_op_when_already_in_that_state(self):
items = issue.checkboxes(BODY)
self.assertIs(issue.set_checkbox(BODY, items[0], True), BODY)
self.assertIs(issue.set_checkbox(BODY, items[1], False), BODY)
def test_capital_x_is_left_alone(self):
item = issue.checkboxes(BODY)[3] # `- [X]`
self.assertEqual(issue.set_checkbox(BODY, item, True), BODY)
def test_accepts_a_line_number(self):
after = issue.set_checkbox(BODY, 9, True)
self.assertEqual(after, issue.set_checkbox(BODY, issue.checkboxes(BODY)[1], True))
def test_refuses_a_line_that_is_not_a_checkbox(self):
with self.assertRaises(ValueError):
issue.set_checkbox(BODY, 1, True)
with self.assertRaises(ValueError):
issue.set_checkbox(BODY, 9999, True)
class TestScript(unittest.TestCase):
def test_lists_items_numbered_with_state(self):
with store(**{"messy-issue": MESSY}) as root:
rc, out = run(issue_ac.main, "messy-issue", "--out", root)
self.assertEqual(rc, 0)
self.assertIn("messy-issue — 0/3", out)
self.assertIn("## Acceptance criteria", out)
self.assertIn(" 1 [ ] первый пункт", out)
self.assertIn(" 3 [ ] третий пункт", out)
def test_check_by_number(self):
with store(**{"messy-issue": MESSY}) as root:
rc, out = run(issue_ac.main, "messy-issue", "--check", "2", "--out", root)
with open(os.path.join(root, "messy-issue.md")) as f:
after = f.read()
self.assertEqual(rc, 0)
self.assertIn("checked", out)
self.assertIn("1/3", out)
self.assertEqual(issue.checkbox_progress(after), (1, 3))
def test_check_by_substring(self):
with store(**{"messy-issue": MESSY}) as root:
run(issue_ac.main, "messy-issue", "--check", "ТРЕТИЙ", "--out", root)
with open(os.path.join(root, "messy-issue.md")) as f:
after = f.read()
self.assertTrue(issue.checkboxes(after)[2].checked)
self.assertEqual(issue.checkbox_progress(after), (1, 3))
def test_uncheck(self):
with store(**{"messy-issue": MESSY}) as root:
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
rc, out = run(issue_ac.main, "messy-issue", "--uncheck", "1", "--out", root)
with open(os.path.join(root, "messy-issue.md")) as f:
after = f.read()
self.assertEqual(rc, 0)
self.assertIn("unchecked", out)
self.assertEqual(after, MESSY)
def test_toggling_through_the_script_changes_one_character_of_the_file(self):
with store(**{"messy-issue": MESSY}) as root:
path = os.path.join(root, "messy-issue.md")
with open(path) as f:
before = f.read()
run(issue_ac.main, "messy-issue", "--check", "второй", "--out", root)
with open(path) as f:
after = f.read()
at = sole_difference(before, after)
self.assertEqual((before[at], after[at]), (" ", "x"))
# The metadata block was neither reordered nor completed, and the
# trailing whitespace on the third item survived.
self.assertTrue(after.startswith("---\norigin: local\n"))
self.assertIn("- [ ] третий пункт \n", after)
def test_crlf_line_endings_survive(self):
crlf = MESSY.replace("\n", "\r\n")
with store(**{"messy-issue": crlf}) as root:
path = os.path.join(root, "messy-issue.md")
run(issue_ac.main, "messy-issue", "--check", "1", "--out", root)
with open(path, newline="") as f:
after = f.read()
at = sole_difference(crlf, after)
self.assertEqual((crlf[at], after[at]), (" ", "x"))
self.assertEqual(after.count("\r\n"), crlf.count("\r\n"))
def test_ambiguous_substring_is_an_error_listing_the_matches(self):
with store(**{"messy-issue": MESSY}) as root:
with self.assertRaises(SystemExit) as cm:
run(issue_ac.main, "messy-issue", "--check", "пункт", "--out", root)
with open(os.path.join(root, "messy-issue.md")) as f:
self.assertEqual(f.read(), MESSY) # nothing was picked
msg = str(cm.exception)
self.assertIn("matches 3 items", msg)
for want in ("1 [ ] первый пункт", "2 [ ] второй пункт", "3 [ ] третий пункт"):
self.assertIn(want, msg)
def test_substring_that_matches_nothing(self):
with store(**{"messy-issue": MESSY}) as root:
with self.assertRaises(SystemExit) as cm:
run(issue_ac.main, "messy-issue", "--check", "нетакого", "--out", root)
self.assertIn("nothing matches", str(cm.exception))
def test_number_out_of_range(self):
with store(**{"messy-issue": MESSY}) as root:
with self.assertRaises(SystemExit) as cm:
run(issue_ac.main, "messy-issue", "--check", "9", "--out", root)
self.assertIn("no item 9 — the issue has 3", str(cm.exception))
def test_issue_without_checkboxes(self):
with store(**{"plain": "---\nid: plain\n---\n# Plain\n\n" + NO_BOXES}) as root:
rc, out = run(issue_ac.main, "plain", "--out", root)
self.assertEqual((rc, out.strip()), (0, "plain — no checkboxes"))
with self.assertRaises(SystemExit) as cm:
run(issue_ac.main, "plain", "--check", "1", "--out", root)
self.assertIn("has no checkboxes", str(cm.exception))
def test_unknown_id(self):
with store() as root:
with self.assertRaises(SystemExit) as cm:
run(issue_ac.main, "nope", "--out", root)
self.assertIn("no issue 'nope'", str(cm.exception))
class TestIndexProgress(unittest.TestCase):
def files(self):
boxed = ("---\nid: boxed\nstate: open\nlabels: [type/task]\n"
"origin: local\n---\n# Boxed\n\n" + BODY)
plain = ("---\nid: plain\nstate: open\nlabels: [type/task]\n"
"origin: local\n---\n# Plain\n\n" + NO_BOXES)
return {"boxed": boxed, "plain": plain}
def index(self, root):
issue_index.build(root)
with open(os.path.join(root, "INDEX.md")) as f:
return f.read()
def row(self, text, id):
for line in text.splitlines():
if line.startswith("| [%s]" % id):
return [c.strip() for c in line.split("|")]
self.fail("no row for %r in INDEX.md" % id)
def test_column_exists_and_counts_the_body(self):
with store(**self.files()) as root:
text = self.index(root)
self.assertIn("| id | state | progress | type |", text)
self.assertEqual(self.row(text, "boxed")[3], "2/5")
def test_blank_for_an_issue_without_checkboxes(self):
with store(**self.files()) as root:
text = self.index(root)
self.assertEqual(self.row(text, "plain")[3], "")
def test_recomputed_on_the_fly_not_stored(self):
with store(**self.files()) as root:
self.assertEqual(self.row(self.index(root), "boxed")[3], "2/5")
run(issue_ac.main, "boxed", "--check", "add-pool-cfg", "--out", root)
self.assertEqual(self.row(self.index(root), "boxed")[3], "3/5")
# No metadata field anywhere holds it.
with open(os.path.join(root, "boxed.md")) as f:
head = f.read().split("---")[1]
self.assertNotIn("3/5", head)
self.assertNotIn("progress", head)
class TestCheckIgnoresUntickedBoxes(unittest.TestCase):
"""An unticked box is work not done yet, not a malformed issue."""
def test_validate_reports_nothing(self):
iss = issue.Issue(id="unticked-issue", title="Do the thing",
labels=["type/task"], body=TASK_BODY)
err, warn = issue.validate(iss, known_ids={"unticked-issue"})
self.assertEqual(err, [])
self.assertEqual(warn, [])
def test_issue_check_exits_clean(self):
text = ("---\nid: unticked-issue\nstate: open\nlabels: [type/task]\n"
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n"
"---\n# Do the thing\n\n" + TASK_BODY)
argv = sys.argv
with store(**{"unticked-issue": text}) as root:
sys.argv = ["issue_check.py", "--out", root]
try:
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = issue_check.main()
finally:
sys.argv = argv
out = buf.getvalue()
self.assertEqual(rc, 0, out)
self.assertIn("ok unticked-issue", out)
self.assertNotIn("ERROR", out)
self.assertNotIn("warn ", out)
if __name__ == "__main__":
unittest.main()
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
The container <-> child edge points ONE way: container -> child.
A `type/feature` is closed when its children are closed, and that is a
dependency relation, so the container lists its children in `depends:`. A child
belongs to a feature, which is a membership relation, and membership has no
place in a dependency graph — so a child never names its container back. These
tests pin that direction down in all three places it shows up: the validator,
the desync warning, and the drawn tree.
python3 -m unittest discover -s tests -v
Stdlib only, like the scripts under test. `skills/*/scripts/` are directories,
not packages, so they go on sys.path by hand.
"""
import contextlib
import io
import os
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
import issue # noqa: E402
import issue_check # noqa: E402
import issue_new # noqa: E402
import issue_tree # noqa: E402
def run(module, argv):
"""Call a script's main() with argv, returning (exit_code, stdout).
stderr is swallowed: issue_new.py notes on it when a `--depends` id is not
in the store yet, which is fine and not what these tests are about."""
buf = io.StringIO()
old = sys.argv
sys.argv = [module.__name__ + ".py"] + argv
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
code = module.main()
except SystemExit as e: # argparse / sys.exit("msg")
code = e.code if isinstance(e.code, int) else 1
finally:
sys.argv = old
return (code or 0), buf.getvalue()
def drawn(tree_output):
"""The rows inside the tree's code fence, header and blanks dropped."""
return [l for l in tree_output.splitlines() if l.rstrip().endswith(".md")]
class StoreCase(unittest.TestCase):
"""A scratch store per test. Never touches tmp/issues/."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = self._tmp.name
self.addCleanup(self._tmp.cleanup)
def new(self, type, id, title, depends=()):
argv = ["--type", type, "--id", id, "--title", title, "--out", self.root]
for d in depends:
argv += ["--depends", d]
code, _ = run(issue_new, argv)
self.assertEqual(code, 0, "issue_new.py failed for %s" % id)
def edit(self, id, old, new):
p = issue.path_of(self.root, id)
with open(p) as f:
text = f.read()
self.assertIn(old, text, "%s.md does not contain %r" % (id, old))
with open(p, "w") as f:
f.write(text.replace(old, new))
def fill_issues_section(self, id, *children):
"""Replace the type/feature template's `## Issues` placeholder."""
self.edit(id,
"- [ ] slug-дочернего-issue — краткое описание части\n- [ ] …\n",
"".join("- [ ] %s — часть\n" % c for c in children))
def container_and_child(self):
"""The canonical shape from references/format.md: the container names
the child in `depends:` AND in `## Issues`; the child names nobody."""
self.new("task", "child-y", "Child y")
self.new("feature", "feat-x", "Container x", depends=["child-y"])
self.fill_issues_section("feat-x", "child-y")
class CanonicalContainerIsClean(StoreCase):
"""A container from the template plus a child per format.md: green."""
def test_check_is_silent_and_exits_zero(self):
self.container_and_child()
code, out = run(issue_check, ["--out", self.root])
self.assertEqual(code, 0, out)
self.assertNotIn("ERROR", out)
self.assertNotIn("warn", out)
self.assertIn("ok feat-x", out)
self.assertIn("ok child-y", out)
def test_validate_reports_nothing_for_either_issue(self):
self.container_and_child()
issues = issue.load_all(self.root)
for id in ("feat-x", "child-y"):
err, warn = issue.validate(issues[id], known_ids=set(issues))
self.assertEqual((err, warn), ([], []), id)
def test_the_child_does_not_depend_on_its_container(self):
self.container_and_child()
issues = issue.load_all(self.root)
self.assertEqual(issues["feat-x"].depends, ["child-y"])
self.assertEqual(issues["child-y"].depends, [])
class OnlyOneDirectionIsLegal(StoreCase):
"""format.md and the validator agree on container -> child, and the
reverse edge is an error rather than a matter of taste."""
def test_the_reverse_edge_is_a_cycle(self):
self.container_and_child()
self.edit("child-y", "depends: []", "depends: [feat-x]")
code, out = run(issue_check, ["--out", self.root])
self.assertEqual(code, 1, out)
self.assertIn("ERROR cycle:", out)
self.assertIn("feat-x", out)
self.assertIn("child-y", out)
def test_a_child_pointing_at_its_container_alone_is_not_the_graph(self):
"""The shape format.md used to document: the child depends on the
container and the container's depends: is empty. It no longer matches
what the container's own `## Issues` says, so the check complains."""
self.new("feature", "feat-x", "Container x")
self.new("task", "child-y", "Child y", depends=["feat-x"])
self.fill_issues_section("feat-x", "child-y")
_, out = run(issue_check, ["--out", self.root])
self.assertIn("warn feat-x:", out)
def test_issues_section_is_an_edge_source_pointing_down(self):
body = "## Issues\n- [ ] child-y — часть\n"
self.assertEqual(issue.body_dep_refs(body), ["child-y"])
class WarningNamesItsOwnSection(StoreCase):
"""The desync warning quotes the section the reference came from, not
`## Depends on` unconditionally — a container has no such section."""
def test_container_warning_says_issues(self):
self.new("feature", "feat-x", "Container x")
self.new("task", "child-y", "Child y")
self.fill_issues_section("feat-x", "child-y") # but not depends:
issues = issue.load_all(self.root)
err, warn = issue.validate(issues["feat-x"], known_ids=set(issues))
self.assertEqual(err, [])
self.assertEqual(
warn, ["## Issues mentions 'child-y' but `depends:` does not list it"])
self.assertNotIn("## Depends on", "\n".join(warn))
body = issue.load(self.root, "feat-x").body
self.assertNotIn("## Depends on", body,
"the warning must not name a section that is not in the file")
def test_plain_issue_warning_still_says_depends_on(self):
self.new("task", "child-y", "Child y", depends=["migrate-schema"])
self.edit("child-y", "depends: [migrate-schema]", "depends: []")
issues = issue.load_all(self.root)
_, warn = issue.validate(issues["child-y"])
self.assertEqual(
warn,
["## Depends on mentions 'migrate-schema' but `depends:` does not list it"])
def test_each_reference_is_named_with_its_own_section(self):
body = ("## Depends on\n- migrate-schema\n\n"
"## Issues\n- [ ] child-y — часть\n")
self.assertEqual(
issue.body_dep_ref_sections(body),
[("## Depends on", "migrate-schema"), ("## Issues", "child-y")])
def test_body_dep_refs_still_returns_bare_strings(self):
"""skills/sync/scripts/map.py filters this list for `#N` refs."""
body = "## Depends on\n- migrate-schema\n- #42\n"
refs = issue.body_dep_refs(body)
self.assertEqual(refs, ["migrate-schema", "#42"])
self.assertTrue(all(isinstance(r, str) for r in refs))
def test_tracker_numbers_never_warn(self):
"""`#42` is a tracker handle, not a slug; `depends:` holds ids only."""
self.new("task", "child-y", "Child y")
self.edit("child-y", "## Motivation", "## Depends on\n- #42\n\n## Motivation")
issues = issue.load_all(self.root)
_, warn = issue.validate(issues["child-y"])
self.assertEqual(warn, [])
class TreePutsTheContainerOnTop(StoreCase):
def test_container_is_the_root_and_children_hang_below(self):
self.container_and_child()
code, out = run(issue_tree, ["--out", self.root])
self.assertEqual(code, 0, out)
rows = drawn(out)
self.assertEqual(len(rows), 2, out)
self.assertTrue(rows[0].startswith("feat-x "), out)
self.assertTrue(rows[1].startswith("└── child-y "), out)
self.assertEqual(out.count("child-y ["), 1, "child drawn more than once")
def test_the_container_is_the_only_root(self):
self.container_and_child()
_, out = run(issue_tree, ["--out", self.root])
self.assertIn("# Dependency tree — feat-x", out)
def test_two_children_hang_off_one_container(self):
self.new("task", "child-y", "Child y")
self.new("task", "child-z", "Child z")
self.new("feature", "feat-x", "Container x",
depends=["child-y", "child-z"])
self.fill_issues_section("feat-x", "child-y", "child-z")
code, out = run(issue_check, ["--out", self.root])
self.assertEqual(code, 0, out)
_, tree = run(issue_tree, ["--out", self.root])
self.assertEqual(tree.count("feat-x ["), 1,
"the container must not repeat once per child")
self.assertEqual([r.split(" ")[0] for r in drawn(tree)],
["feat-x", "├──", "└──"], tree)
self.assertIn("├── child-y ", tree)
self.assertIn("└── child-z ", tree)
if __name__ == "__main__":
unittest.main()
+805
View File
@@ -0,0 +1,805 @@
#!/usr/bin/env python3
"""
The local copy is dropped after a successful push, and pulled back on demand.
Two halves, and the second one is the one that matters:
1. **It deletes.** A confirmed create or PATCH removes `tmp/issues/<id>.md` and
`<id>.comments.md`, prints where the issue lives now, and leaves the ledger
behind so the slug can be found again. A pull puts the same file back —
same slug, same `depends:`, same body — including after a rename in Gitea
and on a machine that never had the file.
2. **It does not delete anything else, ever.** A transport that raised, a `tea`
that exited non-zero, an answer without a number, an answer for the wrong
issue, an `origin: local` issue nobody pushed: the file is still on disk.
A bug here destroys work, so every one of those paths is asserted
separately, and the assertion is always the same — `os.path.isfile`.
The transport is stubbed at `_gitea.api`, as `test_push_dependencies.py` does,
with one deliberate exception: the non-2xx test stubs `_gitea.subprocess`
instead and lets the REAL `_gitea.api` run, so "tea exited 1" is proved end to
end rather than assumed.
Nothing here touches a network, and nothing here touches the developer's store:
every test builds its own in a `tempfile.mkdtemp()`.
"""
import contextlib
import io
import json
import os
import shutil
import sys
import tempfile
import types
import unittest
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import pull # noqa: E402
import push # noqa: E402
# Captured before any test patches it — the non-2xx test needs the real thing.
REAL_API = _gitea.api
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
LABELS = {"type/task": 901, "type/bug": 902}
LABEL_NAMES = {v: k for k, v in LABELS.items()}
BODY = """## Summary
Прозаическое описание задачи.
## Spec
skills/issue/references/format.md
## Acceptance criteria
- [ ] что-нибудь работает
"""
BODY_WITH_DEPS = """## Summary
Прозаическое описание задачи.
## Spec
skills/issue/references/format.md
## Depends on
- first-thing — ставит фундамент, без него второй не собрать
## Acceptance criteria
- [ ] что-нибудь работает
"""
# --------------------------------------------------------------------------
# a tracker that can be both pushed to and pulled from
# --------------------------------------------------------------------------
class FakeTracker(object):
"""`tea api` answered from memory, for push AND pull.
It keeps bodies the way Gitea does — verbatim, marker and all — which is
what makes the round-trip tests real: the slug that comes back is the one
that was actually stored on the far side, not one the test handed over."""
def __init__(self, next_number=101):
self.calls = []
self.next_number = next_number
self.issues = {} # number -> payload
self.deps = {} # number -> {(repo, number)}
# Failure injection, one write at a time.
self.raise_on_write = None # an exception instance to raise
self.answer_override = None # what a write answers instead
# -- state -------------------------------------------------------------
def store(self, number, title, body, **kw):
p = {"number": number, "title": title, "body": body, "state": "open",
"comments": 0, "labels": [{"name": "type/task"}], "assignees": [],
"milestone": None, "ref": "test-branch",
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"updated_at": "2026-08-10T00:00:00Z",
"repository": {"full_name": REPO}}
p.update(kw)
self.issues[number] = p
return p
def body_of(self, number):
return self.issues[number]["body"]
def rename(self, number, title):
self.issues[number]["title"] = title
def writes(self):
return [c for c in self.calls if c[0] != "GET"]
# -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False):
self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0]
if path == "%s/labels" % BASE and method == "GET":
return [{"name": n, "id": i} for n, i in LABELS.items()]
if path.endswith("/comments"):
return []
if path.endswith("/dependencies"):
number = int(path.split("/issues/")[1].split("/")[0])
if method == "GET":
return [dict(self.issues[n], repository={"full_name": r})
for r, n in sorted(self.deps.get(number, set()))
if n in self.issues]
if method == "POST":
self.deps.setdefault(number, set()).add(
("%s/%s" % (payload["owner"], payload["repo"]),
int(payload["index"])))
return {"number": number}
if path == "%s/issues" % BASE and method == "POST":
return self._write(
lambda: self.store(self._next(), payload.get("title", ""),
payload.get("body", ""),
labels=self._labels(payload),
ref=payload.get("ref", "")))
if "/issues/" in path and method == "PATCH":
number = int(path.rsplit("/", 1)[1])
return self._write(
lambda: self.store(number, payload.get("title", ""),
payload.get("body", ""),
labels=self._labels(payload),
ref=payload.get("ref", "")))
if "/issues/" in path and method == "GET":
return self.issues.get(int(path.rsplit("/", 1)[1]))
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
# -- helpers -----------------------------------------------------------
def _next(self):
n = self.next_number
self.next_number += 1
return n
def _labels(self, payload):
return [{"name": LABEL_NAMES[i]} for i in (payload or {}).get("labels") or []
if i in LABEL_NAMES]
def _write(self, do):
"""Every create and update goes through here, so a test can make one
fail without knowing which verb it was."""
if self.raise_on_write is not None:
raise self.raise_on_write
got = do()
if self.answer_override is not None:
return self.answer_override
return got
class StoreTestCase(unittest.TestCase):
"""A temp store, a fake tracker, and no git."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-drop-")
self.fake = FakeTracker()
for p in (mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
mock.patch.object(push, "git_branch", lambda: "test-branch")):
p.start()
self.addCleanup(p.stop)
self.addCleanup(shutil.rmtree, self.root, True)
# -- fixtures ----------------------------------------------------------
def write_issue(self, id, title, body=BODY, depends=(), origin=issue.LOCAL,
extra=None):
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
depends=list(depends), origin=origin,
extra=dict(extra or {}))
issue.save(self.root, iss)
return iss
def write_comments(self, id, text="## comment 1 — someone — 2026-08-10\n\nтекст\n"):
p = _gitea.comments_path(self.root, id)
with open(p, "w") as f:
f.write(text)
return p
# -- runners -----------------------------------------------------------
def run_push(self, *argv):
return self._run(push, "push.py", argv)
def run_pull(self, *argv):
return self._run(pull, "pull.py", argv)
def _run(self, mod, name, argv):
# Kept on self so a test that expects SystemExit can still read what
# went to stderr — the run never returns in that case.
self.out, self.err = io.StringIO(), io.StringIO()
args = [name, "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(self.out), \
contextlib.redirect_stderr(self.err):
mod.main()
return self.out.getvalue(), self.err.getvalue()
# -- assertions --------------------------------------------------------
def assertOnDisk(self, id, why=""):
self.assertTrue(os.path.isfile(issue.path_of(self.root, id)),
"%s.md was deleted%s" % (id, why and "" + why))
def assertGone(self, id):
self.assertFalse(os.path.isfile(issue.path_of(self.root, id)),
"%s.md is still on disk" % id)
def ledger(self):
return _gitea.load_map(self.root)
def number_of(self, id):
for key, slug in self.ledger().items():
if slug == id:
return gmap.parse_remote_key(key)[1]
return None
# --------------------------------------------------------------------------
# it deletes
# --------------------------------------------------------------------------
class DropsAfterCreateTest(StoreTestCase):
def test_the_issue_file_is_gone(self):
self.write_issue("a-thing", "A thing")
self.run_push()
self.assertGone("a-thing")
def test_the_comment_thread_goes_with_it(self):
self.write_issue("a-thing", "A thing")
cpath = self.write_comments("a-thing")
self.run_push()
self.assertFalse(os.path.isfile(cpath), "the thread outlived the issue")
def test_a_missing_thread_is_not_an_error(self):
"""Most issues have no comments file. Dropping must not care."""
self.write_issue("a-thing", "A thing")
out, _ = self.run_push()
self.assertIn("dropped", out)
def test_the_output_names_the_number_and_the_url(self):
"""The local path is gone, so this line is the only address left."""
self.write_issue("a-thing", "A thing")
out, _ = self.run_push()
n = self.number_of("a-thing")
self.assertIn("#%d" % n, out)
self.assertIn("https://git.example/%s/issues/%d" % (REPO, n), out)
self.assertIn("pull.py %d" % n, out)
def test_the_ledger_outlives_the_file(self):
"""`.remote.json` does not become garbage when the files go — it
becomes the only local record of which slug this number is."""
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.assertIsNotNone(n)
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
def test_the_ledger_is_written_before_the_file_is_removed(self):
"""Ordering, asserted rather than trusted: if the two were swapped, an
interrupted run would cost the slug and not just a re-pull."""
seen = {}
real_drop = push.drop_local
def spy(root, id):
seen["ledger"] = json.load(open(_gitea.map_path(root)))
return real_drop(root, id)
self.write_issue("a-thing", "A thing")
with mock.patch.object(push, "drop_local", spy):
self.run_push()
self.assertIn("a-thing", (seen.get("ledger") or {}).values())
class DropsAfterUpdateTest(StoreTestCase):
"""One rule, no exception: `--update` deletes too."""
def pushed_then_pulled(self, id="a-thing", body=BODY):
self.write_issue(id, "A thing", body=body)
self.run_push()
self.run_pull(str(self.number_of(id)))
self.assertOnDisk(id, "the pull should have put it back")
return id
def test_patch_deletes_the_file_too(self):
id = self.pushed_then_pulled()
out, _ = self.run_push("--update", id)
self.assertIn("updated", out)
self.assertGone(id)
def test_patch_deletes_the_thread_too(self):
id = self.pushed_then_pulled()
cpath = self.write_comments(id)
self.run_push("--update", id)
self.assertFalse(os.path.isfile(cpath))
def test_the_patch_really_went_out(self):
id = self.pushed_then_pulled()
self.run_push("--update", id)
self.assertTrue([c for c in self.fake.calls if c[0] == "PATCH"])
# --------------------------------------------------------------------------
# it deletes nothing else
# --------------------------------------------------------------------------
class NeverPushedIsNeverDroppedTest(StoreTestCase):
def test_a_local_issue_nobody_selected_stays(self):
self.write_issue("pushed-thing", "Pushed thing")
self.write_issue("kept-thing", "Kept thing")
self.run_push("pushed-thing")
self.assertGone("pushed-thing")
self.assertOnDisk("kept-thing", "it was never pushed")
def test_a_local_only_dependency_stays(self):
"""It is read (for the warning) but never sent, so never dropped."""
self.write_issue("first-thing", "First thing")
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
depends=["first-thing"])
_, err = self.run_push("second-thing")
self.assertIn("depends on local-only issue(s) first-thing", err)
self.assertOnDisk("first-thing", "it was never sent")
def test_dry_run_deletes_nothing(self):
self.write_issue("a-thing", "A thing")
self.run_push("--dry-run")
self.assertOnDisk("a-thing", "--dry-run must not write or delete")
self.assertEqual(self.fake.calls, [])
def test_a_format_violation_stops_before_anything_is_sent(self):
"""No type/* label: validation fails, nothing is sent, nothing goes."""
issue.save(self.root, issue.Issue(id="bad-thing", title="Bad thing",
body=BODY, labels=[]))
with self.assertRaises(SystemExit):
self.run_push("bad-thing")
self.assertOnDisk("bad-thing")
self.assertEqual(self.fake.writes(), [])
class SurvivesEveryFailureTest(StoreTestCase):
"""The criterion that matters most. Each path is asserted on its own."""
def test_a_transport_exception_leaves_the_file(self):
"""`tea` could not be run at all — the exception propagates out of the
push and the delete is never reached."""
self.write_issue("a-thing", "A thing")
self.fake.raise_on_write = OSError("tea: command not found")
with self.assertRaises(OSError):
self.run_push()
self.assertOnDisk("a-thing", "the transport raised")
self.assertEqual(self.ledger(), {})
def test_a_non_2xx_answer_leaves_the_file(self):
"""The real `_gitea.api` against a `tea` that exits 1.
Stubbed one layer lower than every other test here on purpose: this is
the path a 422 or a 500 actually takes, and it ends in `die()`."""
self.write_issue("a-thing", "A thing")
def fake_run(cmd, capture_output=False, text=False):
creating = "-X" in cmd and cmd[cmd.index("-X") + 1] == "POST"
if creating:
return types.SimpleNamespace(
returncode=1, stdout="",
stderr="422 Unprocessable Entity: validation failed")
if cmd[-1].split("?")[0].endswith("/labels"):
return types.SimpleNamespace(
returncode=0, stderr="",
stdout=json.dumps([{"name": n, "id": i}
for n, i in LABELS.items()]))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
with mock.patch.object(_gitea, "api", REAL_API), \
mock.patch.object(_gitea, "subprocess",
types.SimpleNamespace(run=fake_run)), \
self.assertRaises(SystemExit):
self.run_push()
self.assertOnDisk("a-thing", "tea exited non-zero")
def test_an_answer_without_a_number_leaves_the_file(self):
self.write_issue("a-thing", "A thing")
self.fake.answer_override = {"ok": True, "message": "created"}
with self.assertRaises(SystemExit):
self.run_push()
self.assertOnDisk("a-thing", "the answer carried no number")
def test_an_answer_that_is_not_an_object_leaves_the_file(self):
self.write_issue("a-thing", "A thing")
self.fake.answer_override = ["something", "else"]
with self.assertRaises(SystemExit):
self.run_push()
self.assertOnDisk("a-thing")
def test_an_empty_answer_leaves_the_file(self):
"""`tea` exited 0 and printed nothing — api returns None."""
self.write_issue("a-thing", "A thing")
self.fake.answer_override = None
real_write = self.fake._write
self.fake._write = lambda do: (real_write(do), None)[1]
with self.assertRaises(SystemExit):
self.run_push()
self.assertOnDisk("a-thing")
def test_a_patch_answering_for_another_issue_leaves_the_file(self):
"""The mismatched-body case: we PATCHed #101 and #999 answered."""
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.run_pull(str(n))
self.assertOnDisk("a-thing")
self.fake.answer_override = {"number": 999, "html_url": "https://x"}
with self.assertRaises(SystemExit):
self.run_push("--update", "a-thing")
self.assertOnDisk("a-thing", "the tracker answered for a different issue")
def test_the_error_says_the_file_is_untouched(self):
self.write_issue("a-thing", "A thing")
self.fake.answer_override = {"ok": True}
with self.assertRaises(SystemExit):
self.run_push()
self.assertIn("untouched", self.err.getvalue())
def test_a_failure_partway_through_keeps_what_has_not_been_sent(self):
"""Two issues, the second one fails. The first is legitimately gone —
Gitea confirmed it — and the second is still here."""
self.write_issue("aaa-thing", "Aaa thing")
self.write_issue("zzz-thing", "Zzz thing")
real_write = self.fake._write
seen = []
def once(do):
seen.append(1)
if len(seen) > 1:
return {"nope": True}
return real_write(do)
self.fake._write = once
with self.assertRaises(SystemExit):
self.run_push()
self.assertGone("aaa-thing")
self.assertOnDisk("zzz-thing", "its write never succeeded")
# And the one that did go up is in the ledger, so it is findable.
self.assertEqual(list(self.ledger().values()), ["aaa-thing"])
class ConfirmedNumberTest(unittest.TestCase):
"""The gate itself. Everything below it deletes a file."""
def test_a_plain_create_is_confirmed(self):
self.assertEqual(push.confirmed_number({"number": 42}), 42)
def test_a_matching_patch_is_confirmed(self):
self.assertEqual(push.confirmed_number({"number": 42}, 42), 42)
def test_a_mismatched_patch_is_not(self):
self.assertIsNone(push.confirmed_number({"number": 43}, 42))
def test_none_is_not(self):
self.assertIsNone(push.confirmed_number(None))
def test_a_list_is_not(self):
self.assertIsNone(push.confirmed_number([{"number": 42}]))
def test_a_missing_number_is_not(self):
self.assertIsNone(push.confirmed_number({"html_url": "https://x"}))
def test_a_string_number_is_not(self):
self.assertIsNone(push.confirmed_number({"number": "42"}))
def test_true_is_not_a_number(self):
"""`True` is an `int` in Python; `number: true` confirms nothing."""
self.assertIsNone(push.confirmed_number({"number": True}))
def test_zero_and_negatives_are_not(self):
self.assertIsNone(push.confirmed_number({"number": 0}))
self.assertIsNone(push.confirmed_number({"number": -1}))
# --------------------------------------------------------------------------
# the id marker
# --------------------------------------------------------------------------
class IdMarkerTest(unittest.TestCase):
"""map.py, pure — no store, no tracker."""
def test_the_marker_is_the_first_line(self):
got = gmap.with_id_marker("## Summary\nтекст", "a-thing")
self.assertEqual(got.splitlines()[0], "<!-- tea:id a-thing -->")
self.assertEqual(got.splitlines()[1], "")
def test_strip_is_the_exact_inverse(self):
for body in ("## Summary\nтекст", "", "one line",
"## Summary\n\n- [ ] пункт\n\n## Spec\nnone"):
self.assertEqual(gmap.strip_id_marker(gmap.with_id_marker(body, "x")),
body)
def test_a_body_with_no_marker_comes_back_byte_for_byte(self):
body = "## Summary\n\n весь текст \n\n\n"
self.assertEqual(gmap.strip_id_marker(body), body)
def test_marking_twice_still_leaves_one(self):
once = gmap.with_id_marker("текст", "a-thing")
twice = gmap.with_id_marker(once, "a-thing")
self.assertEqual(once, twice)
self.assertEqual(twice.count("tea:id"), 1)
def test_remarking_under_a_new_slug_replaces_rather_than_adds(self):
got = gmap.with_id_marker(gmap.with_id_marker("текст", "old"), "new")
self.assertEqual(got.count("tea:id"), 1)
self.assertEqual(gmap.id_in_body(got), "new")
def test_every_marker_is_removed_not_just_the_first(self):
"""A body hand-edited in the web UI could hold two. It comes back with
none, and the next push writes exactly one."""
mangled = ("<!-- tea:id one -->\n\nтекст\n\n<!-- tea:id two -->\nещё")
self.assertEqual(gmap.strip_id_marker(mangled), "текст\n\nещё")
self.assertEqual(gmap.with_id_marker(mangled, "one").count("tea:id"), 1)
def test_id_in_body_reads_the_first_marker(self):
self.assertEqual(gmap.id_in_body("<!-- tea:id one -->\n\nx"), "one")
self.assertIsNone(gmap.id_in_body("## Summary\nтекст"))
self.assertIsNone(gmap.id_in_body(""))
def test_a_marker_that_is_not_a_slug_is_ignored(self):
"""Better to fall back to the title than to name a file after junk."""
for junk in ("Not A Slug", "../etc/passwd", "-leading", "два-слова"):
self.assertIsNone(gmap.id_in_body("<!-- tea:id %s -->\n\nx" % junk))
def test_the_marker_tolerates_spacing(self):
self.assertEqual(gmap.id_in_body("<!--tea:id a-thing-->"), "a-thing")
self.assertEqual(gmap.id_in_body(" <!-- tea:id a-thing --> "),
"a-thing")
def test_a_marker_inside_prose_is_not_one(self):
"""Only a line that is nothing but the marker counts."""
self.assertIsNone(gmap.id_in_body("см. <!-- tea:id a-thing --> выше"))
def test_to_payload_marks_and_from_api_unmarks(self):
iss = issue.Issue(id="a-thing", title="A thing", body="## Summary\nтекст")
sent = gmap.to_payload(iss)["body"]
self.assertTrue(sent.startswith("<!-- tea:id a-thing -->"))
back, _ = gmap.from_api({"number": 1, "title": "A thing", "body": sent},
"a-thing", REPO)
self.assertEqual(back.body, "## Summary\nтекст")
class MarkerStaysOffDiskTest(StoreTestCase):
def test_the_local_file_never_holds_a_marker(self):
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.assertIn("tea:id a-thing", self.fake.body_of(n))
self.run_pull(str(n))
with open(issue.path_of(self.root, "a-thing")) as f:
self.assertNotIn("tea:id", f.read())
def test_repeated_round_trips_do_not_accumulate_markers(self):
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
for _ in range(3):
self.run_pull(str(n))
self.run_push("--update", "a-thing")
self.assertEqual(self.fake.body_of(n).count("tea:id"), 1)
# --------------------------------------------------------------------------
# the round trip
# --------------------------------------------------------------------------
class RoundTripTest(StoreTestCase):
"""push -> the file is gone -> pull -> the same file is back."""
def two_issues(self):
self.write_issue("first-thing", "First thing")
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
depends=["first-thing"])
def snapshot(self, id):
iss = issue.load(self.root, id)
return (iss.id, iss.title, iss.body, sorted(iss.depends),
sorted(iss.labels), iss.state)
def test_the_file_comes_back_identical(self):
self.two_issues()
before = self.snapshot("second-thing")
self.run_push()
self.assertGone("second-thing")
self.run_pull(str(self.number_of("second-thing")), "--deps")
self.assertEqual(self.snapshot("second-thing"), before)
def test_depends_survives_the_round_trip(self):
"""The edge lives in Gitea's own graph while the files do not exist —
push wrote it, `pull --deps` reads it back, and the ledger turns the
number back into the slug it had here."""
self.two_issues()
self.run_push()
self.assertGone("first-thing")
self.assertGone("second-thing")
self.run_pull(str(self.number_of("second-thing")), "--deps")
self.assertEqual(issue.load(self.root, "second-thing").depends,
["first-thing"])
def test_the_prose_dependency_is_still_the_authors_words(self):
self.two_issues()
self.run_push()
self.run_pull(str(self.number_of("second-thing")), "--deps")
self.assertIn("- first-thing — ставит фундамент",
issue.load(self.root, "second-thing").body)
def test_a_rename_in_gitea_does_not_change_the_slug(self):
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.fake.rename(n, "Completely different title now")
self.run_pull(str(n))
self.assertOnDisk("a-thing")
self.assertFalse(os.path.isfile(
issue.path_of(self.root, "completely-different-title-now")))
self.assertEqual(issue.load(self.root, "a-thing").title,
"Completely different title now")
def test_the_slug_survives_a_rename_with_the_ledger_thrown_away(self):
"""The case `.remote.json` cannot cover: a fresh clone, or another
machine. The marker is the only thing left, and it is enough."""
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.fake.rename(n, "Completely different title now")
os.remove(_gitea.map_path(self.root))
self.run_pull(str(n))
self.assertOnDisk("a-thing")
self.assertEqual(self.ledger(), {gmap.remote_key(REPO, n): "a-thing"})
def test_depends_survives_a_lost_ledger_when_both_come_back(self):
self.two_issues()
self.run_push()
first, second = self.number_of("first-thing"), self.number_of("second-thing")
os.remove(_gitea.map_path(self.root))
self.run_pull(str(first), str(second), "--deps")
self.assertEqual(issue.load(self.root, "second-thing").depends,
["first-thing"])
def test_an_issue_filed_in_the_web_ui_still_gets_a_slug(self):
"""No marker, no ledger entry — the title is the fallback, as before."""
self.fake.store(500, "Filed in the web ui", "## Summary\nтекст")
self.run_pull("500")
self.assertOnDisk("filed-in-the-web-ui")
def test_a_marker_colliding_with_a_local_issue_does_not_overwrite_it(self):
"""A slug is only taken at its word when it is free."""
self.write_issue("a-thing", "A thing", body="## Summary\nмоя локальная")
self.fake.store(500, "Something else",
gmap.with_id_marker("## Summary\nчужая", "a-thing"))
self.run_pull("500")
self.assertIn("моя локальная", issue.load(self.root, "a-thing").body)
self.assertIn("чужая", issue.load(self.root, "a-thing-2").body)
def test_the_branch_ref_comes_back_with_the_issue(self):
"""`branch:` is not written back to a file that is being deleted; it
goes up in the payload and comes down again on the next pull."""
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.run_pull(str(n))
self.assertEqual(issue.load(self.root, "a-thing").extra.get("branch"),
"test-branch")
def test_pushing_the_pulled_copy_back_is_a_no_op_on_the_body(self):
self.write_issue("a-thing", "A thing")
self.run_push()
n = self.number_of("a-thing")
self.run_pull(str(n))
before = self.fake.body_of(n)
self.run_push("--update", "a-thing")
self.assertEqual(self.fake.body_of(n), before)
# --------------------------------------------------------------------------
# the ledger
# --------------------------------------------------------------------------
class StoreListingTest(StoreTestCase):
"""The store layout the drop depends on."""
def test_a_comment_thread_is_not_an_issue(self):
"""`<id>.comments.md` sits in the store beside the issue. A slug has no
dot in it, so it is not a slug and not a unit of work — otherwise a bare
`push.py` files the comment thread as an issue of its own."""
self.write_issue("a-thing", "A thing")
self.write_comments("a-thing")
self.assertEqual(issue.all_ids(self.root), ["a-thing"])
def test_a_bare_push_with_threads_in_the_store_still_works(self):
self.write_issue("a-thing", "A thing")
self.write_comments("a-thing")
self.run_push()
self.assertGone("a-thing")
class LedgerTest(StoreTestCase):
"""`.remote.json` after the files it used to index are gone."""
def test_rebuild_keeps_entries_whose_files_no_longer_exist(self):
"""It used to reconstruct the map from the files and save the result,
which would now silently drop every pushed issue."""
_gitea.save_map(self.root, {gmap.remote_key(REPO, 7): "gone-thing"})
self.write_issue("here-thing", "Here thing", origin="gitea",
extra={"gitea": gmap.remote_key(REPO, 8)})
got = _gitea.rebuild_map(self.root, issue.load_all(self.root))
self.assertEqual(got, {gmap.remote_key(REPO, 7): "gone-thing",
gmap.remote_key(REPO, 8): "here-thing"})
self.assertEqual(_gitea.load_map(self.root), got)
def test_a_second_push_reuses_the_ledger_not_the_files(self):
"""Two pushes, no pull in between for the blocker: its file is gone, so
its number can only come from the ledger — and the link is still made."""
self.write_issue("first-thing", "First thing")
self.run_push("first-thing")
self.assertGone("first-thing")
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
depends=["first-thing"])
out, err = self.run_push("second-thing")
first, second = self.number_of("first-thing"), self.number_of("second-thing")
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
self.assertNotIn("local-only", err)
def test_the_dry_run_resolves_a_dropped_blocker_from_the_ledger(self):
self.write_issue("first-thing", "First thing")
self.run_push("first-thing")
first = self.number_of("first-thing")
self.write_issue("second-thing", "Second thing", body=BODY_WITH_DEPS,
depends=["first-thing"])
out, _ = self.run_push("--dry-run", "second-thing")
self.assertIn("link -> %s#%d (first-thing)" % (REPO, first), out)
def test_ledger_keys_prefers_the_current_repo(self):
m = {"other/repo#7": "a-thing", "%s#9" % REPO: "a-thing"}
self.assertEqual(push.ledger_keys(m, REPO), {"a-thing": "%s#9" % REPO})
if __name__ == "__main__":
unittest.main()
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env python3
"""
How a directory of markdown becomes a page tree, and that the tree survives a
round trip through the wiki layer's bookkeeping.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
so the modules under test are imported by path.
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
repository in a temp directory — a `.git` marker, a copy of both script layers,
a directory of fixture artifacts — and run the real scripts inside it. That is
the only honest way to test behaviour that depends on where a script is run
from, and it keeps the developer's own cache out of the blast radius.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
sys.path.insert(0, PAGE_SCRIPTS)
sys.path.insert(0, WIKI_SCRIPTS)
import page # noqa: E402
import wikimap # noqa: E402
# The fixture mirrors the shape a real discussion leaves behind: numbered files
# for ordering, a `00-` file standing in for its directory, headings that no
# mechanical rule could derive from the file names.
FIXTURE = {
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
"questions/03-q-01-do-we-know-the-participant.md":
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
"notes/plain.md": "No heading here, only prose.\n",
}
def build_artifacts(root):
for rel, text in FIXTURE.items():
p = os.path.join(root, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return root
# --------------------------------------------------------------------------
# names, titles, order — pure
# --------------------------------------------------------------------------
class TestNames(unittest.TestCase):
def test_order_comes_from_a_numeric_prefix(self):
self.assertEqual(page.order_of("02-chain-core.md"), 2)
self.assertEqual(page.order_of("00-intro.md"), 0)
self.assertIsNone(page.order_of("handoff.md"))
def test_zero_is_an_order_and_not_a_missing_one(self):
"""`00-` means "this is the directory's own page", so the difference
between 0 and None decides where a page lands in the tree."""
self.assertIsNot(page.order_of("00-intro.md"), None)
def test_the_prefix_never_reaches_the_title(self):
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
def test_only_the_first_letter_is_raised(self):
"""Title-casing would wreck every name that already knows how it is
spelled."""
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
def test_a_heading_beats_a_file_name(self):
text = "## Q-01. Do We Know the Chain Participant by Name\n"
self.assertEqual(page.title_from_body(text),
"Q-01. Do We Know the Chain Participant by Name")
def test_only_the_first_heading_counts(self):
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
def test_a_heading_after_prose_is_a_section_not_a_name(self):
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
def test_markup_is_stripped_from_a_title(self):
"""A page list does not render markdown, so inline code in a heading is
noise in the name."""
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
"Inventory — P-NN")
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
self.assertEqual(page.sanitize_title("Send/receive timing"),
"Send-receive timing")
class TestPaths(unittest.TestCase):
def test_a_title_becomes_one_path_component_per_segment(self):
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
title = "Simple Chains/Don't send to this one"
self.assertEqual(page.path_for_title(title),
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
self.assertIn("'", title)
def test_an_empty_title_still_produces_a_file(self):
self.assertEqual(page.path_for_title(""), "untitled.md")
# --------------------------------------------------------------------------
# importing
# --------------------------------------------------------------------------
class TestPlanImport(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
self.titles = {p["title"] for p in self.pages}
def tearDown(self):
self.tmp.cleanup()
def test_nothing_collides(self):
self.assertEqual(self.collisions, [])
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
self.assertIn("Simple Chains/Ideas", self.titles)
def test_that_page_is_named_for_the_directory_not_its_heading(self):
"""`ideas/00-intro.md` opens with "Ideas for chain business
requirements". A child's title must extend its parent's exactly, and no
child would ever be prefixed by that."""
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
self.titles)
def test_every_child_extends_its_parents_title(self):
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
self.assertIn("Simple Chains/Questions/"
"Q-01. Do We Know the Chain Participant by Name",
self.titles)
def test_a_file_without_a_heading_falls_back_to_its_name(self):
self.assertIn("Simple Chains/Notes/Plain", self.titles)
def test_the_prefix_hangs_everything_under_one_title(self):
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
def test_numeric_prefixes_order_siblings(self):
ideas = [p for p in self.pages
if p["title"].startswith("Simple Chains/Ideas/")]
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
["Relations", "Chain core"])
def test_a_collision_is_reported_and_not_resolved(self):
"""Two headings that sanitize to one path. Picking a winner is how a
discussion loses a document."""
d = os.path.join(self.tmp.name, "clash")
os.makedirs(d)
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
with open(os.path.join(d, name), "w") as f:
f.write(heading + "\n")
_, collisions = page.plan_import(d)
self.assertEqual(len(collisions), 1)
# --------------------------------------------------------------------------
# the index
# --------------------------------------------------------------------------
class TestIndex(unittest.TestCase):
def setUp(self):
self.m = page.blank_manifest("s")
for title, order in (("Top", None),
("Top/Ideas", 0),
("Top/Ideas/Relations", 1),
("Top/Ideas/Chain core", 2),
("Top/Zeta", None),
("Top/Parked/Decisions", None)):
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
def test_nesting_follows_titles_not_manifest_path_order(self):
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
hierarchy Zeta is a child and Chain core a grandchild."""
body = page.render_index(self.m, "Top")
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
or l.strip().startswith(" - ")]
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
self.assertLess(ideas, core)
self.assertLess(core, zeta)
def test_a_parent_with_no_page_still_holds_its_children(self):
"""Nothing is published at `Top/Parked`; dropping it would hide
Decisions entirely."""
body = page.render_index(self.m, "Top")
self.assertIn("- [[Top/Parked|Parked]]", body)
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
def test_a_published_page_is_linked_by_its_sub_url(self):
"""sub_url is the only address Gitea guarantees, and it appears only
after a push — so rebuilding the index after publishing upgrades the
links."""
rel = page.path_for_title("Top/Ideas")
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
class TestManifest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_a_missing_manifest_loads_blank(self):
m = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(m["pages"], {})
def test_wiki_bookkeeping_survives_a_round_trip(self):
"""The domain never reads sub_url, and must never drop it either — a
lost sub_url is a duplicate page on the next push."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
page.save_manifest(m, self.tmp.name)
back = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
self.assertEqual(back["pages"]["X.md"]["order"], 1)
def test_domain_keys_are_written_first(self):
"""The manifest lands in a diff on every sync; a readable one gets
checked."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
raw = f.read()
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
m = page.blank_manifest("s")
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
m["pages"][page.path_for_title(t)] = page.entry(t)
got = {e["title"] for _, e in page.children_of(m, "Top")}
self.assertEqual(got, {"Top", "Top/A"})
# --------------------------------------------------------------------------
# md <-> wiki JSON
# --------------------------------------------------------------------------
class TestWikiMap(unittest.TestCase):
def test_a_body_survives_encode_and_decode(self):
text = "# Заголовок — DC\n\n- [x] пункт\n"
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
text)
def test_an_empty_page_decodes_to_an_empty_string(self):
"""A page that exists with no body is a real state; the caller writing
a file should not have to tell it from a missing key."""
self.assertEqual(wikimap.decode({}), "")
self.assertEqual(wikimap.decode({"content_base64": None}), "")
def test_from_payload_takes_the_address_gitea_returned(self):
got = wikimap.from_payload({
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
})
self.assertEqual(got["sub_url"], "A%2FB.-")
self.assertEqual(got["sha"], "abc")
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
"""Gitea hands it back already escaped; re-encoding it produces a path
that resolves to nothing."""
self.assertEqual(
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
"repos/o/r/wiki/page/A%2FB.-")
def test_prefix_matching_needs_a_separator(self):
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
def test_an_empty_prefix_matches_everything(self):
self.assertTrue(wikimap.matches_prefix("anything", ""))
def test_a_payload_carries_the_operators_message(self):
p = wikimap.new_payload("A/B", "body", "why it changed")
self.assertEqual(p["message"], "why it changed")
self.assertEqual(wikimap.decode(p), "body")
# --------------------------------------------------------------------------
# the scripts, in a throwaway repository
# --------------------------------------------------------------------------
class TestImportScript(unittest.TestCase):
"""The real scripts, run as subprocesses inside a scratch repo."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = self.tmp.name
os.makedirs(os.path.join(self.root, ".git"))
for layer in ("page", "wiki"):
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
os.path.join(self.root, "skills", layer, "scripts"),
ignore=shutil.ignore_patterns("__pycache__"))
shutil.copytree(SYNC_SCRIPTS,
os.path.join(self.root, "skills", "sync", "scripts"),
ignore=shutil.ignore_patterns("__pycache__"))
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
self.space = os.path.join(self.root, "tmp", "wiki", "s")
def tearDown(self):
self.tmp.cleanup()
def run_script(self, name, *args, cwd=None):
return subprocess.run(
[sys.executable, os.path.join(self.scripts, name)] + list(args),
capture_output=True, text=True, cwd=cwd or self.root)
def manifest(self):
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
return json.load(f)
def do_import(self, *extra):
return self.run_script("page_import.py", "--from", self.src,
"--space", "s", "--prefix", "Top", *extra)
def test_dry_run_writes_nothing(self):
r = self.do_import("--dry-run")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertFalse(os.path.exists(self.space))
def test_import_writes_the_tree_and_the_manifest(self):
self.assertEqual(self.do_import().returncode, 0)
self.assertTrue(os.path.isfile(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
self.assertIn("Top/Ideas/Chain core",
{e["title"] for e in self.manifest()["pages"].values()})
def test_creating_a_space_is_announced(self):
"""Nothing creates a store as a silent side effect of a write — that is
how a typo in --space makes a second one nobody notices."""
self.assertIn("created space", self.do_import().stderr)
def test_the_cache_is_found_from_a_subdirectory(self):
"""The anchor is the script's own location, not cwd. A `cd` outlives
the command that ran it."""
self.do_import()
deep = os.path.join(self.src, "ideas")
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Chain core", r.stdout)
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
# The heading changes. Without the manifest that would rename a
# published page, which does not rename it — it publishes a second one.
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## A completely different heading\n\nchanged\n")
self.do_import()
after = self.manifest()["pages"][rel]
self.assertEqual(after["title"], "Top/Ideas/Chain core")
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
self.assertIn("A completely different heading", f.read())
def test_retitle_moves_the_page_and_keeps_its_address(self):
"""A retitle changes the path, so the entry has to be found by source.
Found by path it would look new, and the next push would publish a
duplicate beside the page it was meant to rename."""
self.do_import()
m = self.manifest()
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## Chain core, renamed\n")
self.do_import("--retitle")
pages = self.manifest()["pages"]
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
moved = pages["Top/Ideas/Chain-core-renamed.md"]
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
self.assertFalse(os.path.exists(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
def test_a_rename_makes_the_next_push_send_the_page(self):
"""The body can be byte-identical after a rename, and push decides by
body hash alone — so a stale `pushed` would skip the rename forever."""
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
body = f.read()
m["pages"][rel]["sub_url"] = "x"
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
body.encode()).hexdigest()
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
src = os.path.join(self.src, "ideas", "02-chain-core.md")
with open(src, encoding="utf-8") as f:
text = f.read()
with open(src, "w") as f:
f.write(text.replace("## Chain core", "## Chain core renamed"))
self.do_import("--retitle")
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
self.assertNotIn("pushed", moved)
def test_ls_reports_an_unpublished_page_as_local(self):
self.do_import()
r = self.run_script("page_ls.py", "--space", "s")
self.assertIn("local", r.stdout)
self.assertNotIn("synced", r.stdout)
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
r = self.run_script("page_ls.py", "--space", "nope")
self.assertNotEqual(r.returncode, 0)
self.assertIn("no such space", r.stderr)
def test_index_is_written_as_an_ordinary_page(self):
self.do_import()
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Top.md", self.manifest()["pages"])
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
body = f.read()
self.assertIn("- [[Top/Ideas|Ideas]]", body)
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
# --------------------------------------------------------------------------
# the layering rule, mechanically
# --------------------------------------------------------------------------
class TestLayering(unittest.TestCase):
def test_the_page_layer_is_stdlib_only(self):
"""skills/page must keep working with skills/wiki deleted — so no
transport, and above all no subprocess, in the domain layer."""
imported = set()
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
for line in f:
if line.startswith(("import ", "from ")):
imported.add(line.split()[1].split(".")[0])
foreign = imported - {"page"} - sys.stdlib_module_names
self.assertEqual(foreign, set(),
"non-stdlib import in the page layer: %s"
% ", ".join(sorted(foreign)))
self.assertNotIn("subprocess", imported)
def test_the_page_layer_never_mentions_a_tracker(self):
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
in the wrong layer."""
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
body = f.read()
for word in banned:
self.assertNotIn(word, body,
"%s mentions %r" % (name, word))
def test_wikimap_is_pure(self):
"""The translation layer holds no transport and no I/O: give it a
payload, get a page; give it a page, get a request body. Checked on the
imports, not on the prose — the docstring names the things it refuses
to do."""
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
imported = {line.split()[1].split(".")[0] for line in f
if line.startswith(("import ", "from "))}
self.assertEqual(imported, {"base64"},
"wikimap.py imports more than the translation needs")
if __name__ == "__main__":
unittest.main()
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""
Native Gitea dependency links, written by push.py.
The transport is stubbed at exactly one seam — `_gitea.api`, the single
function that shells out to `tea` — so everything above it runs for real:
argument parsing, validation, topological order, the id map, map.py's payload
shapes and _gitea's own endpoint/body construction. Nothing here touches a
network, and no test may ever be made to.
`skills/*/scripts/` are not packages; they go on sys.path by hand.
"""
import contextlib
import io
import os
import shutil
import sys
import tempfile
import unittest
from unittest import mock
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
os.path.join(_ROOT, "skills", "issue", "scripts")):
if _p not in sys.path:
sys.path.insert(0, _p)
import _gitea # noqa: E402
import issue # noqa: E402
import map as gmap # noqa: E402
import push # noqa: E402
REPO = "claude-skills/tea"
BASE = "repos/%s" % REPO
LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903,
"comp/sync": 904}
LABEL_NAMES = {v: k for k, v in LABELS.items()}
BODY = """## Summary
Прозаическое описание.
## Spec
skills/issue/references/format.md
## Depends on
- first-thing — ставит фундамент, без него второй не собрать
## Acceptance criteria
- [ ] что-нибудь работает
"""
BODY_NO_DEPS = """## Summary
Прозаическое описание.
## Spec
skills/issue/references/format.md
## Acceptance criteria
- [ ] что-нибудь работает
"""
class FakeGitea(object):
"""A `tea api` that answers from memory and remembers what it was asked.
Dependency links are kept the way Gitea keeps them: per blocked issue, a
set of (repo, number) blockers. That is what makes the idempotence test
meaningful — the second push sees the link the first one made."""
def __init__(self, next_number=101):
self.calls = [] # (method, endpoint, payload)
self.next_number = next_number
self.deps = {} # number -> {(repo, number)}
self.titles = {} # number -> title
self.fail_dependency_post = False
# -- helpers -----------------------------------------------------------
@property
def writes(self):
"""Every non-GET call. `--dry-run` must produce an empty list."""
return [c for c in self.calls if c[0] != "GET"]
def dep_posts(self):
return [c for c in self.calls
if c[0] == "POST" and c[1].endswith("/dependencies")]
def issue_payload(self, number, labels=()):
return {"number": number,
"html_url": "https://git.example/%s/issues/%d" % (REPO, number),
"title": self.titles.get(number, ""),
"labels": [{"name": LABEL_NAMES[i]} for i in labels
if i in LABEL_NAMES],
"updated_at": "2026-08-10T00:00:00Z",
"repository": {"full_name": REPO}}
# -- the seam ----------------------------------------------------------
def api(self, login, endpoint, method="GET", payload=None,
payload_name=None, out_root=None, allow_fail=False):
self.calls.append((method, endpoint, payload))
path = endpoint.split("?")[0]
if path == "%s/labels" % BASE and method == "GET":
# Every label the run could ask for, so nothing is ever created.
return [{"name": n, "id": i} for n, i in LABELS.items()]
if path == "%s/issues" % BASE and method == "POST":
number = self.next_number
self.next_number += 1
self.titles[number] = (payload or {}).get("title", "")
# Echo the labels back, or push re-applies them with a PUT.
return self.issue_payload(number, (payload or {}).get("labels") or [])
if path.endswith("/dependencies"):
number = int(path.split("/issues/")[1].split("/")[0])
if method == "GET":
return [dict(self.issue_payload(n), repository={"full_name": r})
for r, n in sorted(self.deps.get(number, set()))]
if method == "POST":
if self.fail_dependency_post:
return None
key = ("%s/%s" % (payload["owner"], payload["repo"]),
int(payload["index"]))
self.deps.setdefault(number, set()).add(key)
return self.issue_payload(number)
if "/issues/" in path and method == "PATCH":
number = int(path.rsplit("/", 1)[1])
self.titles[number] = (payload or {}).get("title", self.titles.get(number, ""))
return self.issue_payload(number, (payload or {}).get("labels") or [])
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
class PushTestCase(unittest.TestCase):
"""A temp store, a fake transport, and no git."""
def setUp(self):
self.root = tempfile.mkdtemp(prefix="tea-store-")
self.fake = FakeGitea()
patches = [
mock.patch.object(_gitea, "api", self.fake.api),
mock.patch.object(_gitea, "require_login", lambda: "test-login"),
# push reads the current branch from git; a temp store has none and
# the runner's branch would leak into the payload.
mock.patch.object(push, "git_branch", lambda: "test-branch"),
]
for p in patches:
p.start()
self.addCleanup(p.stop)
self.addCleanup(shutil.rmtree, self.root, True)
# -- fixtures ----------------------------------------------------------
def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None,
origin=issue.LOCAL):
iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"],
depends=list(depends), origin=origin,
extra=dict(extra or {}))
issue.save(self.root, iss)
return iss
def repull(self, id, body=BODY_NO_DEPS, depends=()):
"""Put a pushed issue back the way `pull.py` would.
Push deletes the file, so anything that pushes the same issue twice has
to fetch it in between — which is the workflow, not a test artifact.
The slug and the number come from the ledger, exactly as `pull.id_for`
would resolve them."""
number = self.number_of(id)
self.assertIsNotNone(number, "%s was never pushed" % id)
return self.write_issue(id, self.fake.titles[number], body=body,
depends=depends, origin="gitea",
extra={"gitea": "%s#%d" % (REPO, number)})
def two_issues(self):
"""first-thing, and second-thing which depends on it."""
self.write_issue("first-thing", "First thing")
self.write_issue("second-thing", "Second thing", body=BODY,
depends=["first-thing"])
def run_push(self, *argv):
out, err = io.StringIO(), io.StringIO()
args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv)
with mock.patch.object(sys, "argv", args), \
contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(err):
push.main()
return out.getvalue(), err.getvalue()
def number_of(self, id):
"""The number an id was pushed under, or None.
Read off `.remote.json` rather than the issue file: a successful push
deletes the file, and the ledger is what is left behind."""
for key, got in _gitea.load_map(self.root).items():
if got == id:
return gmap.parse_remote_key(key)[1]
return None
# --------------------------------------------------------------------------
# _gitea: the POST body, and the pre-check that reads links back
# --------------------------------------------------------------------------
class AddDependencyTest(unittest.TestCase):
def test_post_body_is_issue_meta(self):
"""POST /issues/{index}/dependencies with IssueMeta for the BLOCKER.
Confirmed against the instance's swagger.v1.json (Gitea 1.26.1):
"Make the issue in the url depend on the issue in the form." """
calls = []
def fake_api(login, endpoint, method="GET", payload=None, **kw):
calls.append((method, endpoint, payload))
return {"number": 102}
with mock.patch.object(_gitea, "api", fake_api):
ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None)
self.assertTrue(ok)
method, endpoint, payload = calls[0]
self.assertEqual(method, "POST")
self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE)
self.assertEqual(payload, {"index": 101, "owner": "claude-skills",
"repo": "tea"})
def test_blocker_may_live_in_another_repo(self):
"""IssueMeta carries owner/repo precisely so it can."""
seen = {}
def fake_api(login, endpoint, method="GET", payload=None, **kw):
seen.update(payload or {})
return {"number": 1}
with mock.patch.object(_gitea, "api", fake_api):
_gitea.add_dependency("l", BASE, 102, "other-org/infra", 7)
self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"})
def test_failure_is_reported_not_raised(self):
"""409 (link already there) and friends come back as False."""
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101))
def test_unparseable_repo_makes_no_request(self):
called = []
with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)):
self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101))
self.assertEqual(called, [])
def test_native_dep_pairs_reads_repo_and_number(self):
payload = [{"number": 101, "repository": {"full_name": REPO}},
{"number": 7, "repository": {"full_name": "other-org/infra"}}]
with mock.patch.object(_gitea, "api", lambda *a, **k: payload):
got = _gitea.native_dep_pairs("l", BASE, 102)
self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)})
def test_native_dep_pairs_empty_when_unsupported(self):
with mock.patch.object(_gitea, "api", lambda *a, **k: None):
self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set())
# --------------------------------------------------------------------------
# push: the whole run
# --------------------------------------------------------------------------
class PushCreatesLinksTest(PushTestCase):
def test_link_created_after_both_have_numbers(self):
"""One run, topological order, one native link — no second pass."""
self.two_issues()
out, _ = self.run_push()
first, second = self.number_of("first-thing"), self.number_of("second-thing")
self.assertLess(first, second, "blocker must be created first")
self.assertEqual(self.fake.deps.get(second), {(REPO, first)})
self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out)
def test_link_direction_matches_what_pull_reads_back(self):
"""The link hangs off the BLOCKED issue, which is where native_deps
looks — push and `pull.py --deps` must agree or the round trip lies."""
self.two_issues()
self.run_push()
second = self.number_of("second-thing")
with mock.patch.object(_gitea, "api", self.fake.api):
self.assertEqual(_gitea.native_deps("l", BASE, second),
[self.number_of("first-thing")])
def test_issue_without_dependencies_makes_no_dependency_request(self):
"""Not even the idempotence GET — it is skipped when there is nothing
to link, so the common case costs no extra round trip."""
self.write_issue("lonely-thing", "Lonely thing")
self.run_push()
self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], [])
class LocalOnlyDependencyTest(PushTestCase):
def test_local_dependency_is_warned_and_not_linked(self):
self.two_issues()
out, err = self.run_push("second-thing")
self.assertEqual(self.fake.dep_posts(), [])
self.assertIn("depends on local-only issue(s) first-thing", err)
self.assertNotIn("depends on ", out)
self.assertIsNone(self.number_of("first-thing"))
class IdempotenceTest(PushTestCase):
def test_repeat_push_does_not_duplicate_the_link(self):
self.two_issues()
self.run_push()
self.assertEqual(len(self.fake.dep_posts()), 1)
self.repull("first-thing")
self.repull("second-thing", body=BODY, depends=["first-thing"])
self.run_push("--update")
self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed")
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
{(REPO, self.number_of("first-thing"))})
def test_a_failing_link_warns_and_the_run_finishes(self):
"""A 409 or any other refusal must not abort a push that has already
created issues."""
self.two_issues()
self.fake.fail_dependency_post = True
out, err = self.run_push()
self.assertIn("could not link", err)
self.assertIn("index:", out) # the run completed
self.assertIsNotNone(self.number_of("second-thing"))
class UpdateCarriesNewLinksTest(PushTestCase):
def test_dependency_added_after_the_first_push_is_linked_by_update(self):
self.write_issue("first-thing", "First thing")
self.write_issue("second-thing", "Second thing")
self.run_push()
self.assertEqual(self.fake.dep_posts(), [])
# The issue comes back from Gitea, and the dependency is added to the
# copy that came back — there is no other copy to add it to.
self.repull("second-thing", body=BODY, depends=["first-thing"])
self.run_push("--update", "second-thing")
self.assertEqual(self.fake.deps[self.number_of("second-thing")],
{(REPO, self.number_of("first-thing"))})
class DryRunTest(PushTestCase):
def test_dry_run_names_the_links_and_writes_nothing(self):
self.two_issues()
out, _ = self.run_push("--dry-run")
self.assertEqual(self.fake.calls, [], "--dry-run made a request")
self.assertIn("link -> #? (first-thing, created by this run)", out)
self.assertIn("1 dependency link(s) would be created", out)
def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self):
self.write_issue("first-thing", "First thing",
extra={"gitea": "%s#101" % REPO})
self.write_issue("second-thing", "Second thing", body=BODY,
depends=["first-thing"])
out, _ = self.run_push("--dry-run")
self.assertIn("link -> %s#101 (first-thing)" % REPO, out)
self.assertEqual(self.fake.writes, [])
def test_dry_run_says_a_local_dependency_gets_no_link(self):
self.two_issues()
out, _ = self.run_push("--dry-run", "second-thing")
self.assertIn("no link: first-thing is local-only", out)
self.assertIn("0 dependency link(s) would be created", out)
class BodyIsVerbatimTest(PushTestCase):
"""The prose is untouched. The id marker is the one thing push adds, and it
comes straight back off — `strip_id_marker` is the inverse."""
def test_depends_on_prose_is_not_rewritten_to_numbers(self):
"""map.py deliberately never edits the prose. Linking must not start."""
self.two_issues()
before = issue.load(self.root, "second-thing").body
self.run_push()
created = [c for c in self.fake.calls
if c[0] == "POST" and c[1] == "%s/issues" % BASE]
sent = [c[2]["body"] for c in created]
second_body = [b for b in sent if "Depends on" in b][0]
self.assertIn("- first-thing — ставит фундамент", second_body)
self.assertNotIn("#101", second_body)
self.assertEqual(gmap.strip_id_marker(second_body), before)
def test_body_survives_a_second_push_unchanged(self):
self.two_issues()
before = issue.load(self.root, "second-thing").body
self.run_push()
self.repull("first-thing")
self.repull("second-thing", body=before, depends=["first-thing"])
self.assertEqual(issue.load(self.root, "second-thing").body, before)
self.run_push("--update")
patched = [c for c in self.fake.calls if c[0] == "PATCH"]
self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched])
class DepStateTest(PushTestCase):
"""The classifier both the dry run and the real run read from."""
def test_classifies_linked_in_run_and_local(self):
issues = {
"pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}),
"coming": issue.Issue(id="coming"),
"local": issue.Issue(id="local"),
}
iss = issue.Issue(id="dependent",
depends=["pushed", "coming", "local", "ghost"])
got = push.dep_state(iss, issues, {"coming", "dependent"})
self.assertEqual(got, [("pushed", "%s#101" % REPO, False),
("coming", None, True),
("local", None, False)])
if __name__ == "__main__":
unittest.main()
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""
Where the issue store is, and that the answer does not depend on cwd.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything — the same rule the scripts under test
live by. `skills/*/scripts/` are not packages, so the domain module is imported
by path.
Most of these tests do not touch this repository at all. They build a throwaway
repo in a temp directory — a `.git` marker, a copy of both script layers, a
store with two issues — and run the real scripts inside it as subprocesses with
different working directories. That is the only honest way to test a cwd bug:
importing the module would resolve the store once, against the wrong tree.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
sys.path.insert(0, ISSUE_SCRIPTS)
import issue # noqa: E402
ALPHA = """\
---
id: alpha-issue
state: open
labels: [type/task]
assignees: []
milestone: none
depends: []
origin: local
---
# Alpha issue
## Summary
Первый issue фикстуры.
## Spec
none
## Motivation
Нужен, чтобы в store что-то лежало.
## Acceptance criteria
- [ ] проверяемое условие
"""
BETA = """\
---
id: beta-issue
state: open
labels: [type/task]
assignees: []
milestone: none
depends: [alpha-issue]
origin: local
---
# Beta issue
## Summary
Второй issue фикстуры, зависит от первого.
## Spec
none
## Depends on
- alpha-issue
## Motivation
Нужен, чтобы у графа было ребро.
## Acceptance criteria
- [ ] проверяемое условие
"""
def run(script, *args, **kw):
"""Run one of the plugin's scripts and return (rc, stdout, stderr)."""
cwd = kw.pop("cwd")
env = dict(os.environ)
env.pop("PYTHONPATH", None) # no leakage from the harness into the child
p = subprocess.run([sys.executable, script] + list(args), cwd=cwd, env=env,
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
class FakeRepo(object):
"""A self-contained repository in a temp directory.
Both script layers are copied in, so `__file__`-anchored resolution lands
inside the fixture and never on the developer's real store.
"""
def __init__(self, with_store=True, issues=(ALPHA, BETA)):
self._tmp = tempfile.TemporaryDirectory()
# realpath: on macOS $TMPDIR is a symlink, and a child process reporting
# its own cwd would otherwise disagree with the path we handed it.
self.root = os.path.realpath(self._tmp.name)
os.makedirs(os.path.join(self.root, ".git")) # the repo marker
skip = shutil.ignore_patterns("__pycache__")
shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip)
shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip)
os.makedirs(self.path("sub", "deeper"))
if with_store:
os.makedirs(self.store)
for text in issues:
id = text.split("id: ", 1)[1].split("\n", 1)[0]
with open(os.path.join(self.store, "%s.md" % id), "w") as f:
f.write(text)
def cleanup(self):
self._tmp.cleanup()
def path(self, *parts):
return os.path.join(self.root, *parts)
@property
def store(self):
return self.path("tmp", "issues")
def script(self, layer, name):
return self.path("skills", layer, "scripts", name)
def everywhere(self):
"""Working directories that must all produce the same answer: the repo
root, a plain subdirectory, a deeper one, the script directory itself,
and — the case from the bug report — inside the store."""
return [self.root, self.path("sub"), self.path("sub", "deeper"),
self.path("skills", "issue", "scripts"), self.store]
# --------------------------------------------------------------------------
# resolution, in isolation
# --------------------------------------------------------------------------
class TestResolution(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def test_repo_root_found_from_any_depth(self):
for start in self.repo.everywhere():
self.assertEqual(issue.repo_root(start), self.repo.root, start)
def test_agents_md_works_as_a_marker(self):
"""A checkout without .git — the plugin copied out of git — still
resolves, because AGENTS.md marks the root too."""
shutil.rmtree(self.repo.path(".git"))
open(self.repo.path("AGENTS.md"), "w").close()
self.assertEqual(issue.repo_root(self.repo.path("sub", "deeper")),
self.repo.root)
def test_nearest_marker_wins(self):
"""A repo inside a repo (a worktree, a vendored copy) resolves to the
inner one, not the outer."""
inner = self.repo.path("sub", "inner")
os.makedirs(os.path.join(inner, ".git"))
self.assertEqual(issue.repo_root(inner), inner)
self.assertEqual(issue.repo_root(self.repo.root), self.repo.root)
def test_store_root_is_repo_root_plus_tmp_issues(self):
self.assertEqual(issue.store_root(self.repo.path("sub", "deeper")),
self.repo.store)
def test_default_root_is_absolute(self):
"""The whole point: a default that cannot mean two directories."""
self.assertTrue(os.path.isabs(issue.ISSUE_ROOT), issue.ISSUE_ROOT)
self.assertEqual(issue.ISSUE_ROOT,
os.path.join(REPO, "tmp", "issues"))
# --------------------------------------------------------------------------
# the acceptance criterion: same answer from any subdirectory
# --------------------------------------------------------------------------
class TestSameFromAnywhere(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def assertSameEverywhere(self, layer, name, *args):
"""Run the script from the repo root and from every other directory;
every result must be byte-identical to the one from the root."""
dirs = self.repo.everywhere()
base = run(self.repo.script(layer, name), *args, cwd=dirs[0])
self.assertEqual(base[0], 0, "%s failed at the repo root:\n%s" % (name, base[2]))
for d in dirs[1:]:
self.assertEqual(run(self.repo.script(layer, name), *args, cwd=d), base,
"%s disagrees when run from %s" % (name, d))
return base
def test_issue_check(self):
rc, out, _ = self.assertSameEverywhere("issue", "issue_check.py")
self.assertIn("ok alpha-issue", out)
self.assertIn("2 issue(s) checked, 0 with errors", out)
def test_issue_tree(self):
_, out, _ = self.assertSameEverywhere("issue", "issue_tree.py")
self.assertIn("beta-issue", out)
self.assertIn("alpha-issue", out)
def test_issue_index(self):
_, out, _ = self.assertSameEverywhere("issue", "issue_index.py")
self.assertIn("2 issue(s)", out)
self.assertIn(os.path.join(self.repo.store, "INDEX.md"), out)
def test_no_second_store_is_ever_created(self):
"""The bug's worst symptom: `issue_index.py` run from inside the store
used to leave tmp/issues/tmp/issues/ behind, silently."""
for d in self.repo.everywhere():
for name in ("issue_index.py", "issue_check.py", "issue_tree.py"):
run(self.repo.script("issue", name), cwd=d)
found = []
for dirpath, dirnames, filenames in os.walk(self.repo.root):
if "__pycache__" in dirnames:
dirnames.remove("__pycache__")
if "INDEX.md" in filenames:
found.append(dirpath)
self.assertEqual(found, [self.repo.store],
"a second store appeared: %s" % found)
# --------------------------------------------------------------------------
# missing is not empty
# --------------------------------------------------------------------------
class TestMissingVersusEmpty(unittest.TestCase):
def test_missing_store_says_missing(self):
repo = FakeRepo(with_store=False)
self.addCleanup(repo.cleanup)
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
msg = out + err
self.assertNotEqual(rc, 0, "%s should fail on a missing store" % name)
self.assertIn("does not exist", msg, name)
self.assertNotIn("is empty", msg, name)
def test_empty_store_says_empty(self):
repo = FakeRepo(issues=())
self.addCleanup(repo.cleanup)
for name in ("issue_check.py", "issue_tree.py"):
rc, out, err = run(repo.script("issue", name), cwd=repo.root)
msg = out + err
self.assertNotEqual(rc, 0, name)
self.assertIn("is empty", msg, name)
self.assertNotIn("does not exist", msg, name)
def test_index_of_an_empty_store_is_legitimate(self):
"""An existing store with nothing in it gets an index saying so. Only a
missing directory is an error."""
repo = FakeRepo(issues=())
self.addCleanup(repo.cleanup)
rc, out, err = run(repo.script("issue", "issue_index.py"), cwd=repo.root)
self.assertEqual(rc, 0, err)
self.assertIn("0 issue(s)", out)
with open(os.path.join(repo.store, "INDEX.md")) as f:
self.assertIn("_empty_", f.read())
# --------------------------------------------------------------------------
# nothing conjures a store
# --------------------------------------------------------------------------
class TestNoSilentCreation(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo(with_store=False)
self.addCleanup(self.repo.cleanup)
def test_readers_and_the_indexer_create_nothing(self):
for d in (self.repo.root, self.repo.path("sub")):
for name in ("issue_check.py", "issue_tree.py", "issue_index.py"):
run(self.repo.script("issue", name), cwd=d)
self.assertFalse(os.path.exists(self.repo.path("tmp")),
"the store was created by a read")
self.assertFalse(os.path.exists(self.repo.path("sub", "tmp")),
"a store was created relative to cwd")
def test_explicit_out_pointing_nowhere_is_an_error_not_a_mkdir(self):
target = self.repo.path("sub", "nowhere")
rc, out, err = run(self.repo.script("issue", "issue_index.py"),
"--out", target, cwd=self.repo.root)
self.assertNotEqual(rc, 0)
self.assertIn("does not exist", out + err)
self.assertFalse(os.path.exists(target))
def test_issue_new_creates_the_store_and_says_so(self):
"""Creating the first issue in a fresh checkout must still work — but
out loud, and at the repo root, not below whatever cwd happens to be."""
rc, out, err = run(self.repo.script("issue", "issue_new.py"),
"--type", "task", "--title", "Bootstrap the store",
cwd=self.repo.path("sub", "deeper"))
self.assertEqual(rc, 0, err)
self.assertIn("created store", err)
self.assertIn(self.repo.store, err)
self.assertTrue(os.path.isfile(
os.path.join(self.repo.store, "bootstrap-the-store.md")))
self.assertFalse(os.path.exists(self.repo.path("sub", "deeper", "tmp")),
"a store was created relative to cwd")
# --------------------------------------------------------------------------
# an explicit --out is the operator's, not ours to rewrite
# --------------------------------------------------------------------------
class TestExplicitOutWins(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def test_absolute_out_is_honored(self):
other = self.repo.path("sub", "other-store")
os.makedirs(other)
shutil.copy(os.path.join(self.repo.store, "alpha-issue.md"), other)
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", other, cwd=self.repo.root)
self.assertEqual(rc, 0, err)
self.assertIn("1 issue(s) checked", out)
def test_relative_out_stays_relative_to_cwd(self):
"""`--out tmp/issues` typed from a subdirectory means that
subdirectory's tmp/issues — which is not there. Auto-resolution must
not step in and "fix" what the operator typed."""
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("tmp", "issues"),
cwd=self.repo.path("sub"))
self.assertNotEqual(rc, 0)
self.assertIn("does not exist", out + err)
# the same relative path from the root does resolve, by cwd alone
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("tmp", "issues"),
cwd=self.repo.root)
self.assertEqual(rc, 0, err)
self.assertIn("2 issue(s) checked", out)
def test_relative_out_can_climb(self):
rc, out, err = run(self.repo.script("issue", "issue_check.py"),
"--out", os.path.join("..", "tmp", "issues"),
cwd=self.repo.path("sub"))
self.assertEqual(rc, 0, err)
self.assertIn("2 issue(s) checked", out)
# --------------------------------------------------------------------------
# both layers, one root
# --------------------------------------------------------------------------
class TestSyncLayerAgrees(unittest.TestCase):
def setUp(self):
self.repo = FakeRepo()
self.addCleanup(self.repo.cleanup)
def _probe(self, layer, cwd):
"""Ask one layer, from `cwd`, which module defines the store and where
it lands. The sync scripts put the issue scripts on sys.path themselves
— `import map` is how they do it — so each layer is asked its own way.
"""
scripts = self.repo.path("skills", layer, "scripts")
entry = "import map, issue" if layer == "sync" else "import issue"
code = ("import sys; sys.path.insert(0, %r)\n%s\n"
"print(issue.__file__)\nprint(issue.ISSUE_ROOT)\n") % (scripts, entry)
env = dict(os.environ)
env.pop("PYTHONPATH", None)
p = subprocess.run([sys.executable, "-c", code], cwd=cwd, env=env,
capture_output=True, text=True)
self.assertEqual(p.returncode, 0, p.stderr)
return p.stdout.strip().splitlines()
def test_both_layers_resolve_the_same_store_from_anywhere(self):
for d in self.repo.everywhere():
mod_i, root_i = self._probe("issue", d)
mod_s, root_s = self._probe("sync", d)
# sync does not redefine the store; it imports the domain module
self.assertEqual(os.path.realpath(mod_i), os.path.realpath(mod_s), d)
self.assertEqual(root_i, self.repo.store, d)
self.assertEqual(root_s, self.repo.store, d)
def test_every_out_flag_defers_to_the_domain_layer(self):
"""Both layers agree by construction, not by coincidence: no script
spells the default out for itself."""
for layer, names in (("issue", ("issue_new.py", "issue_check.py",
"issue_tree.py", "issue_index.py")),
("sync", ("pull.py", "push.py", "remote.py",
"comment.py"))):
for name in names:
with open(os.path.join(REPO, "skills", layer, "scripts", name)) as f:
src = f.read()
self.assertIn('"--out", default=issue.ISSUE_ROOT', src,
"%s/%s does not take its --out default from the "
"domain layer" % (layer, name))
# --------------------------------------------------------------------------
# the layering rule, mechanically
# --------------------------------------------------------------------------
class TestLayering(unittest.TestCase):
def test_domain_layer_is_stdlib_only(self):
"""skills/issue must keep working with skills/sync deleted — so no
transport, and above all no subprocess, in the domain layer."""
imported = set()
for name in sorted(os.listdir(ISSUE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(ISSUE_SCRIPTS, name)) as f:
for line in f:
if line.startswith(("import ", "from ")):
imported.add(line.split()[1].split(".")[0])
local = {"issue", "issue_ac", "issue_index"}
foreign = imported - local - sys.stdlib_module_names
self.assertEqual(foreign, set(),
"non-stdlib import in the domain layer: %s"
% ", ".join(sorted(foreign)))
self.assertNotIn("subprocess", imported)
if __name__ == "__main__":
unittest.main()