merge: drop the local copy after a successful push

# Conflicts:
#	AGENTS.md
#	agents/tea-runner.md
This commit is contained in:
naudachu
2026-08-10 16:40:21 +05:00
14 changed files with 1410 additions and 118 deletions
+37 -13
View File
@@ -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 `<repo root>/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** (`<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
@@ -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`.
+15 -7
View File
@@ -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 <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.
+10 -4
View File
@@ -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
+5 -2
View File
@@ -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 <n>`; the slug does not change.
## Ticking checkboxes
+30 -4
View File
@@ -14,13 +14,22 @@ sync layer's business — see `/tea:sync`.
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, 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 (`<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:
@@ -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/<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
+15 -3
View File
@@ -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 `<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-")))
if f.endswith(".md") and not f.startswith((".", "INDEX", "tree-"))
and "." not in f[:-3])
def load(root, id):
+6 -2
View File
@@ -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
+93 -17
View File
@@ -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 <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, 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 <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 |
@@ -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 `<!-- tea:id … -->`
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 <skill-base-dir>/scripts/push.py wire-sqlc-appclick
python3 <skill-base-dir>/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/<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` /
@@ -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) | `<!-- tea:id … -->` | 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: `<id>.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
+50 -6
View File
@@ -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 `<!-- tea:id … -->` (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 `<!-- 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:
+105 -8
View File
@@ -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 `<!-- 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
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:
#
# <!-- 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
# --------------------------------------------------------------------------
@@ -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:
+34 -9
View File
@@ -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 `<!-- 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:
@@ -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 `<!-- 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
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):
+164 -32
View File
@@ -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/<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
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)
+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()
+41 -11
View File
@@ -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):