diff --git a/AGENTS.md b/AGENTS.md index 8ded64f..7a42a2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,9 +113,14 @@ developer's own store out of the blast radius. ## Local issue store -`tmp/issues/` (gitignored) is **the store, not a cache of Gitea**. One flat -markdown file per issue, named by its slug, with one metadata field per line so -plain grep works without a parser. +`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 `/tmp/issues`, resolved from `issue.py`'s own location, not from cwd.** `issue.store_root()` walks up from `__file__` to the @@ -127,13 +132,30 @@ plain grep works without a parser. 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 durable state. An issue that never leaves this machine is - complete and valid, not a draft. -- Pushing is additive: the file is never deleted, it gains `gitea:` / `url:` / - `synced:`. -- Pulling overwrites the body — a fetch, not a merge. -- No drift tracking. `synced:` tells you how old your copy is; re-pull when it - matters. +- `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** (`.md` and + `.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 `. +- 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 + `` (`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 @@ -153,9 +175,11 @@ organized. Same stance as the issue store, resolved the same way from - 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 durable state, exactly - as `origin: local` is for an issue. +- 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. + 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`. diff --git a/README.md b/README.md index ea2adfe..b913bda 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ skills/ 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 (additive; never deletes) + 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) @@ -154,12 +154,20 @@ 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. -It is **the store, not a cache of Gitea**: +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 durable state. An issue that never leaves your machine is - complete and valid. -- Pushing is additive — the file gains `gitea:` / `url:` / `synced:` and stays - put. Pulling overwrites the body: a fetch, not a merge. -- Nothing tracks drift. `synced:` tells you how old your copy is. +- `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 ` — + 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. diff --git a/agents/tea-runner.md b/agents/tea-runner.md index cc72fc6..741f42c 100644 --- a/agents/tea-runner.md +++ b/agents/tea-runner.md @@ -56,13 +56,19 @@ instead of trying it. 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. 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. + 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 diff --git a/skills/issue/SKILL.md b/skills/issue/SKILL.md index f8c5716..03e2f1e 100644 --- a/skills/issue/SKILL.md +++ b/skills/issue/SKILL.md @@ -115,8 +115,11 @@ 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`), your edit is local until you run -`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically. +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 `; the slug does not change. ## Ticking checkboxes diff --git a/skills/issue/references/format.md b/skills/issue/references/format.md index a44f420..3b74b42 100644 --- a/skills/issue/references/format.md +++ b/skills/issue/references/format.md @@ -14,13 +14,22 @@ sync layer's business — see `/tea:sync`. An issue is one file, `tmp/issues/.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, and an issue pushed to a tracker keeps it too. Tracker numbers are a -foreign key stored in a field, never the name of anything. +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 (`.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: @@ -69,8 +78,25 @@ 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 **durable state, not a pending one.** An issue that never -leaves this machine is complete and valid. Pushing is optional and additive. +`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/.md`** (and `.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 diff --git a/skills/issue/scripts/issue.py b/skills/issue/scripts/issue.py index cfab685..491f5bd 100644 --- a/skills/issue/scripts/issue.py +++ b/skills/issue/scripts/issue.py @@ -262,8 +262,11 @@ class Issue(object): @property def is_local(self): - """True while this issue exists nowhere but here — a durable state, - not a pending one.""" + """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 ---------------------------------------------------- @@ -617,10 +620,19 @@ def path_of(root, 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 `.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-"))) + if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-")) + and "." not in f[:-3]) def load(root, id): diff --git a/skills/issue/scripts/issue_new.py b/skills/issue/scripts/issue_new.py index 92b9a50..c60d95d 100644 --- a/skills/issue/scripts/issue_new.py +++ b/skills/issue/scripts/issue_new.py @@ -3,8 +3,12 @@ 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 durable state, and pushing -it to Gitea later (see /tea:sync) is optional and additive. +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 diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 7241925..25110e1 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -39,7 +39,7 @@ the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`. |---|---| | `remote.py [--state] [--label] [--milestone] [-q TEXT]` | discovery: one line per Gitea issue to stdout, writes nothing | | `pull.py ` or `pull.py --milestone M \| --label L \| -q TEXT` | Gitea → `tmp/issues/.md`, plus `.comments.md` when the thread is not empty | -| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, stamps `gitea:` on success | +| `push.py [id…] [--update] [--dry-run]` | local → Gitea; validates first, **deletes the local file on success** and prints where it lives now | | `comment.py --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 | @@ -56,8 +56,8 @@ directory. Pass `--out` to override; a relative one stays relative to cwd. Only ## Identity mapping -The local id is a slug; Gitea's is a number. The pair is recorded in the issue -file itself: +The local id is a slug; Gitea's is a number. While a working copy exists, the +pair is in the file: ``` origin: gitea @@ -66,12 +66,20 @@ url: https://git.noodles.cam/claude-skills/tea/issues/42 synced: 2026-08-09T18:40:00Z ``` -`tmp/issues/.remote.json` indexes those fields for fast lookup. It is a cache -over the files, not a second source of truth — delete it and the next command -rebuilds it. +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 `` +marker in the issue body on the Gitea side. See [How the slug comes +back](#how-the-slug-comes-back). -A retitled issue keeps its slug: the map is keyed by number, so a pull updates -the existing file instead of creating a second one. +`.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 @@ -88,6 +96,12 @@ 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 @@ -163,9 +177,64 @@ python3 /scripts/push.py wire-sqlc-appclick python3 /scripts/push.py --update wire-sqlc-appclick # PATCH ``` -**Pushing is additive: the local file is never deleted.** It gains `gitea:`, -`url:`, `synced:`, and `origin:` flips to `gitea`. One issue, visible in two -places — not two kinds of file. +**A successful push DELETES the local file** — `tmp/issues/.md` and +`.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 | +|---|---|---| +| `` | 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` / @@ -243,9 +312,9 @@ never check out, create, or write anything. | domain | Gitea | note | |---|---|---| -| `id` (slug) | — | local only; the tracker never sees it | +| `id` (slug) | `` | first line of the tracker-side body; stripped out of the local copy | | title | `title` | verbatim, both directions | -| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned | +| 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 | @@ -266,10 +335,17 @@ Comments are **pull-only** in the store: `.comments.md` is written by ## Drift -There is none tracked. The store is not a mirror: nothing watches Gitea, -nothing reconciles, nothing warns that a synced issue changed upstream. -`synced:` tells you how old your copy is; `remote-updated:` what the server -said at that moment. Re-pull when it matters. +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 diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py index ff1f3d2..7d3783a 100644 --- a/skills/sync/scripts/_gitea.py +++ b/skills/sync/scripts/_gitea.py @@ -13,8 +13,11 @@ 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. It is transport bookkeeping, not domain data — the domain never -reads it, and losing it costs a re-pull, not information. +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 `` (map.py), so a +pull rebuilds the entry from the tracker. See `rebuild_map`. """ import datetime import json @@ -317,6 +320,22 @@ def resolve_milestone_id(login, base, title): 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 # -------------------------------------------------------------------------- @@ -326,7 +345,14 @@ def map_path(root): def load_map(root): - """{"owner/repo#42": "wire-sqlc-appclick"}""" + """{"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 {} @@ -345,9 +371,27 @@ def save_map(root, m): def rebuild_map(root, issues): - """Recover the id map from the `gitea:` fields on disk. The files are the - source of truth; .remote.json is only an index over them.""" - m = {} + """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 `` — 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 ` 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: diff --git a/skills/sync/scripts/map.py b/skills/sync/scripts/map.py index b745cca..8fa46b1 100644 --- a/skills/sync/scripts/map.py +++ b/skills/sync/scripts/map.py @@ -15,11 +15,13 @@ What crosses the boundary, and what does not: domain Gitea note ---------------------------------------------------------------------- - id (slug) — local only; the tracker never sees it + id (slug) body marker ``, 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 - checkbox state — see - merge_checkbox_state + 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 @@ -33,8 +35,14 @@ What crosses the boundary, and what does not: 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( @@ -97,6 +105,85 @@ def parse_remote_key(key): 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: +# +# +# +# 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/.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]*$') + + +def id_marker(id): + """The marker line for a slug. One place formats it, one regex reads it.""" + return "" % 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 # -------------------------------------------------------------------------- @@ -158,8 +245,14 @@ def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=Non `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.""" - body = merge_checkbox_state((payload.get("body") or "").strip(), local_body) + 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)) @@ -220,9 +313,13 @@ def render_comments(comments): def to_payload(iss, label_ids=None, milestone_id=None, include_state=False): """Request body for POST /issues or PATCH /issues/{n}. - The body is sent verbatim — see the module docstring on why slugs in - `## Depends on` are not rewritten to `#N`.""" - payload = {"title": iss.title, "body": iss.body.strip()} + 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: diff --git a/skills/sync/scripts/pull.py b/skills/sync/scripts/pull.py index e0cc1d4..6b522a4 100644 --- a/skills/sync/scripts/pull.py +++ b/skills/sync/scripts/pull.py @@ -3,10 +3,15 @@ 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. An issue already in the store keeps -its slug even when its title changes on the server — identity is the local id, -matched through tmp/issues/.remote.json (and recoverable from the `gitea:` -fields if that file is lost). +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 ``, 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: @@ -66,17 +71,37 @@ import map as gmap # noqa: E402 def id_for(payload, store_ids, remote_map, repo, root): - """Existing slug for this remote issue, or a fresh unique one. A retitled - issue keeps the slug it was first pulled under — the map is by number.""" + """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 `` 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 - return issue.unique_id(root, issue.slugify(payload.get("title", "")), taken=store_ids) + 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.""" - return os.path.join(root, "%s.comments.md" % 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): diff --git a/skills/sync/scripts/push.py b/skills/sync/scripts/push.py index d135105..0a41a36 100644 --- a/skills/sync/scripts/push.py +++ b/skills/sync/scripts/push.py @@ -1,16 +1,36 @@ #!/usr/bin/env python3 """ -push.py — local store -> Gitea. +push.py — local store -> Gitea, and the local copy goes away. -Pushing is additive. The local file is never deleted and never moves: it gains -`gitea:`, `url:` and `synced:`, and `origin:` flips from `local` to `gitea`. -One issue, two places it is visible — not two kinds of file. A local-only issue -is a finished state, not a step on the way to a tracker. +**A successful push deletes `tmp/issues/.md` and `.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 ` — it +comes back under the same slug, because the slug travelled up in the body as +`` (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 PATCH issues that are already in Gitea - push.py --dry-run validate only, no network + 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, @@ -44,9 +64,11 @@ 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 written back to the file; one that is -already set is never touched. Detached HEAD, or no repo at all: no `ref` is -sent and a warning says so. +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). """ @@ -85,27 +107,98 @@ def select(issues, ids, update): return chosen -def dep_state(iss, issues, pushing): +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 that exists in the store: + Yields (slug, remote_key, in_run) per dependency this run can say anything + about: - remote_key the dependency's `gitea:` value, or None while it is local + 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) - if dep is None: - continue # not in the store; validate() already warned - out.append((d, dep.extra.get("gitea") or None, d in pushing)) + 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 @@ -164,7 +257,9 @@ def main(): # ---- 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`. + # 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: @@ -178,13 +273,16 @@ def main(): 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): + for slug, key, in_run in dep_state(iss, issues, pushing, key_of_id): if key: print(" link -> %s (%s)" % (key, slug)) links += 1 @@ -207,13 +305,17 @@ def main(): 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 pushing] + 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))) @@ -228,20 +330,32 @@ def main(): _gitea.warn("%s: milestone %r does not exist in %s — not set" % (id, iss.milestone, repo)) - number = gmap.number_of(iss) - if number: + 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, number), "PATCH", payload, - payload_name="issue-%s" % id, out_root=root) + 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" - if not isinstance(got, dict) or "number" not in got: - _gitea.die("%s: %s failed, unexpected response" % (id, verb)) - number = got["number"] + # 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. @@ -253,18 +367,26 @@ def main(): 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()) - issue.save(root, iss) - remote_map[gmap.remote_key(repo, number)] = id + + # 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 — the store was stamped in place. - # 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. + # 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) if 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: @@ -274,7 +396,17 @@ def main(): 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 re-run push" % (id, number, drepo, dnum, slug)) + "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) diff --git a/tests/test_drop_after_push.py b/tests/test_drop_after_push.py new file mode 100644 index 0000000..a65a1af --- /dev/null +++ b/tests/test_drop_after_push.py @@ -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/.md` and + `.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], "") + 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 = ("\n\nтекст\n\n\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("\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("\n\nx" % junk)) + + def test_the_marker_tolerates_spacing(self): + self.assertEqual(gmap.id_in_body(""), "a-thing") + self.assertEqual(gmap.id_in_body(" "), + "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("см. выше")) + + 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("")) + 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): + """`.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() diff --git a/tests/test_push_dependencies.py b/tests/test_push_dependencies.py index 01e2f60..0c3eb04 100644 --- a/tests/test_push_dependencies.py +++ b/tests/test_push_dependencies.py @@ -153,12 +153,27 @@ class PushTestCase(unittest.TestCase): # -- fixtures ---------------------------------------------------------- - def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None): + 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), extra=dict(extra or {})) + 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") @@ -175,7 +190,14 @@ class PushTestCase(unittest.TestCase): return out.getvalue(), err.getvalue() def number_of(self, id): - return gmap.number_of(issue.load(self.root, 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 # -------------------------------------------------------------------------- @@ -293,6 +315,8 @@ class IdempotenceTest(PushTestCase): 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")], @@ -318,10 +342,9 @@ class UpdateCarriesNewLinksTest(PushTestCase): self.run_push() self.assertEqual(self.fake.dep_posts(), []) - iss = issue.load(self.root, "second-thing") - iss.depends = ["first-thing"] - iss.body = BODY - issue.save(self.root, iss) + # 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")], @@ -356,10 +379,13 @@ class DryRunTest(PushTestCase): 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 @@ -369,16 +395,20 @@ class BodyIsVerbatimTest(PushTestCase): self.assertIn("- first-thing — ставит фундамент", second_body) self.assertNotIn("#101", second_body) - self.assertEqual(second_body, issue.load(self.root, "second-thing").body) + self.assertEqual(gmap.strip_id_marker(second_body), before) def test_body_survives_a_second_push_unchanged(self): self.two_issues() - self.run_push() 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, [c[2]["body"] for c in patched]) - self.assertEqual(before, issue.load(self.root, "second-thing").body) + self.assertIn(before, [gmap.strip_id_marker(c[2]["body"]) for c in patched]) class DepStateTest(PushTestCase):