Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac301550e | |||
| e629d14585 |
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user