feat: merge checkbox state on pull instead of overwriting it
A tick was lost in both directions: pull wrote the server body as-is, push sent the local body as-is, last writer won. Tick it in the web UI and the first `push.py --update` dropped it; tick it locally and the first pull dropped it. The usual answer is drift tracking and a three-way merge, which this repo rejected on purpose. It is not needed. A tick is monotone — an item only travels `[ ]` -> `[x]` — so unioning the two sides is a set union, not conflict resolution. One rule for one line type replaces the whole mechanism, and the store stays "not a mirror". `map.merge_checkbox_state` is pure and does the work; `from_api` takes the local body as an optional argument; `pull.py` hands it the copy already on disk. Checkbox parsing is imported from `skills/issue` (`checkboxes` / `set_checkbox`), never redefined here — the domain layer is untouched. The same item text more than once is read as a set: one ticked local item ticks every server line with that text. Pairing duplicates up by order is the alternative, and it can still drop a tick — which is the bug being fixed. The price is documented, not hidden: unticking is not monotone, so a box unticked in the web UI comes back on the next pull. Untick locally, then push. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-2
@@ -89,7 +89,9 @@ not one per issue. Filters AND together; `--state` defaults to `open`;
|
|||||||
`--limit` to 100. Keys and filters are mutually exclusive.
|
`--limit` to 100. Keys and filters are mutually exclusive.
|
||||||
|
|
||||||
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
**A pull overwrites the local body.** It is a fetch, not a merge — unpushed
|
||||||
local edits are lost. `--cached` skips issues already on disk.
|
local edits are lost, with one exception: [checkbox
|
||||||
|
state](#checkboxes-are-the-one-exception). `--cached` skips issues already on
|
||||||
|
disk.
|
||||||
|
|
||||||
**Closed issues stay out of the store.** In filter mode they are enumerated
|
**Closed issues stay out of the store.** In filter mode they are enumerated
|
||||||
but not written: `--state all` still shows the whole picture, only `--state
|
but not written: `--state all` still shows the whole picture, only `--state
|
||||||
@@ -119,6 +121,39 @@ Two traps this handles for you:
|
|||||||
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
After a pull, draw the graph with `/tea:issue`'s `issue_tree.py` — offline, no
|
||||||
extra requests.
|
extra requests.
|
||||||
|
|
||||||
|
### Checkboxes are the one exception
|
||||||
|
|
||||||
|
A checkbox is state, not prose, and it is the one thing a pull does **not**
|
||||||
|
overwrite. For a checkbox line whose **text** matches a line in the local copy,
|
||||||
|
`[x]` wins from whichever side has it — tick it in the web UI, tick it locally,
|
||||||
|
tick it in both, the tick survives.
|
||||||
|
|
||||||
|
| part of the body | what a pull does to it |
|
||||||
|
|---|---|
|
||||||
|
| prose, headings, everything not a checkbox | overwritten from the server, whole, as before |
|
||||||
|
| a checkbox whose text is in the local copy | `[x]` from **either** side wins |
|
||||||
|
| a checkbox whose text is not in the local copy | taken from the server as it stands, ticked or not |
|
||||||
|
| any issue the store has never seen | written exactly as the server sent it |
|
||||||
|
|
||||||
|
This is not drift tracking — [Drift](#drift) stands. A tick is **monotone**: an
|
||||||
|
item only travels `[ ]` → `[x]`, so joining the two sides is a set union, not a
|
||||||
|
conflict to resolve. No base version is kept and nothing is compared against
|
||||||
|
one; one rule for one line type replaces the whole mechanism.
|
||||||
|
|
||||||
|
**The price, and it is real: a box unticked in the web UI comes back on the next
|
||||||
|
pull.** Unticking is not monotone, so the union cannot see it. Untick locally,
|
||||||
|
then `push.py --update` — the body goes up whole and the server follows.
|
||||||
|
|
||||||
|
Matching is on the item's text after the domain parser has stripped it and
|
||||||
|
rejoined wrapped lines with single spaces, so rewrapping a long item keeps its
|
||||||
|
tick. Rewording one does not: different text is a different item. The same text
|
||||||
|
twice in a body is read as a set — one ticked local copy ticks every server line
|
||||||
|
with that text.
|
||||||
|
|
||||||
|
The parsing is `/tea:issue`'s (`issue.checkboxes` / `issue.set_checkbox`),
|
||||||
|
imported, never reimplemented here. The rule itself is
|
||||||
|
`map.merge_checkbox_state`: pure, and testable without a Gitea anywhere.
|
||||||
|
|
||||||
## Pushing
|
## Pushing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -209,7 +244,8 @@ never check out, create, or write anything.
|
|||||||
| domain | Gitea | note |
|
| domain | Gitea | note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `id` (slug) | — | local only; the tracker never sees it |
|
| `id` (slug) | — | local only; the tracker never sees it |
|
||||||
| title, body | `title`, `body` | verbatim, both directions |
|
| title | `title` | verbatim, both directions |
|
||||||
|
| body | `body` | verbatim up; verbatim down except checkbox state, which is unioned |
|
||||||
| `state` | `state` | same vocabulary |
|
| `state` | `state` | same vocabulary |
|
||||||
| `labels` | `labels[]` | names both ways; ids only on write |
|
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||||
| `assignees` | `assignees[]` | logins |
|
| `assignees` | `assignees[]` | logins |
|
||||||
@@ -235,6 +271,11 @@ nothing reconciles, nothing warns that a synced issue changed upstream.
|
|||||||
`synced:` tells you how old your copy is; `remote-updated:` what the server
|
`synced:` tells you how old your copy is; `remote-updated:` what the server
|
||||||
said at that moment. Re-pull when it matters.
|
said at that moment. Re-pull when it matters.
|
||||||
|
|
||||||
|
Checkbox state is not an exception to this. The union a pull applies reads only
|
||||||
|
the two bodies in front of it — there is no base version, no history, and no
|
||||||
|
way for it to report that anything diverged. One rule for one line type,
|
||||||
|
precisely so the mechanism this section rules out is not needed.
|
||||||
|
|
||||||
## Rich payloads for everything else
|
## Rich payloads for everything else
|
||||||
|
|
||||||
Comments and issues are wrapped by the scripts above. For **other** entities
|
Comments and issues are wrapped by the scripts above. For **other** entities
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ What crosses the boundary, and what does not:
|
|||||||
domain Gitea note
|
domain Gitea note
|
||||||
----------------------------------------------------------------------
|
----------------------------------------------------------------------
|
||||||
id (slug) — local only; the tracker never sees it
|
id (slug) — local only; the tracker never sees it
|
||||||
title, body title, body verbatim, both ways
|
title title verbatim, both ways
|
||||||
|
body body verbatim up, verbatim down except
|
||||||
|
checkbox state — see
|
||||||
|
merge_checkbox_state
|
||||||
state state open/closed, same vocabulary
|
state state open/closed, same vocabulary
|
||||||
labels labels[] names both ways; ids only on write
|
labels labels[] names both ways; ids only on write
|
||||||
assignees assignees[] logins
|
assignees assignees[] logins
|
||||||
@@ -104,13 +107,59 @@ def numbers_in_body(body):
|
|||||||
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
return [int(r[1:]) for r in issue.body_dep_refs(body) if r.startswith("#")]
|
||||||
|
|
||||||
|
|
||||||
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None):
|
def merge_checkbox_state(remote_body, local_body):
|
||||||
|
"""The remote body with every tick the local copy already had put back.
|
||||||
|
|
||||||
|
The one exception to "a pull overwrites the body", and it is deliberately
|
||||||
|
the narrowest one that works. A tick is **monotone** — an item only ever
|
||||||
|
travels `[ ]` -> `[x]` — so the two sides are joined by a set union, not
|
||||||
|
reconciled: no base version, no drift tracking, no conflict to resolve. The
|
||||||
|
set is a set of item TEXTS, and an item comes out ticked when either side
|
||||||
|
has it ticked. Everything else in the body is still the remote's word.
|
||||||
|
|
||||||
|
Matching is on `Checkbox.text`, which the domain parser has already
|
||||||
|
stripped and rejoined with single spaces, so rewrapping a long item does
|
||||||
|
not cost it its tick. It is otherwise literal: reword an item and it is a
|
||||||
|
different item — the tick stays with the wording it was put on.
|
||||||
|
|
||||||
|
**The same text more than once** is read as the rule says, as a set: one
|
||||||
|
ticked local item ticks every remote item with that text. The alternative —
|
||||||
|
pairing duplicates up by order — is the reading that can still drop a tick
|
||||||
|
(local `[ ]` then `[x]`, remote a single line: the ticked one pairs with
|
||||||
|
nothing), and dropping a tick is the bug this exists to fix. Two items
|
||||||
|
whose text is identical are the same item to whoever reads them.
|
||||||
|
|
||||||
|
Pure: no store, no tracker, no I/O. A `local_body` of None or "" — a first
|
||||||
|
pull, an empty store — returns the remote body untouched.
|
||||||
|
|
||||||
|
The price, accepted explicitly: UNticking is not monotone, so a box
|
||||||
|
unticked in the web UI comes back on the next pull. Untick locally, push.
|
||||||
|
"""
|
||||||
|
ticked = {c.text for c in issue.checkboxes(local_body) if c.checked}
|
||||||
|
if not ticked:
|
||||||
|
return remote_body
|
||||||
|
body = remote_body
|
||||||
|
# set_checkbox trades one character for one character, so line numbers read
|
||||||
|
# off `remote_body` stay valid against the partially rewritten `body`.
|
||||||
|
for c in issue.checkboxes(remote_body):
|
||||||
|
if not c.checked and c.text in ticked:
|
||||||
|
body = issue.set_checkbox(body, c.line, True)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def from_api(payload, id, repo, id_for_number=None, extra_numbers=(), synced=None,
|
||||||
|
local_body=None):
|
||||||
"""Build a domain Issue from a Gitea issue payload.
|
"""Build a domain Issue from a Gitea issue payload.
|
||||||
|
|
||||||
id_for_number maps a Gitea number to a local slug — dependencies whose
|
id_for_number maps a Gitea number to a local slug — dependencies whose
|
||||||
target has not been pulled yet are dropped from `depends:` (the body still
|
target has not been pulled yet are dropped from `depends:` (the body still
|
||||||
names them, so nothing is lost) rather than invented."""
|
names them, so nothing is lost) rather than invented.
|
||||||
body = (payload.get("body") or "").strip()
|
|
||||||
|
`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)
|
||||||
id_for_number = id_for_number or {}
|
id_for_number = id_for_number or {}
|
||||||
|
|
||||||
numbers = list(numbers_in_body(body))
|
numbers = list(numbers_in_body(body))
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ Other flags:
|
|||||||
--repo owner/repo default: auto-detect from the CWD git remote
|
--repo owner/repo default: auto-detect from the CWD git remote
|
||||||
|
|
||||||
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
Pulling overwrites the local body: it is a fetch, not a merge. Local edits you
|
||||||
have not pushed are lost. Draw the graph afterwards with the domain's own
|
have not pushed are lost — with exactly one exception, checkbox state. A `[x]`
|
||||||
|
on either side wins for any item whose text matches, because a tick is monotone
|
||||||
|
and unioning the two sides is not conflict resolution (gmap.merge_checkbox_state
|
||||||
|
has the rule and its price). `--cached` skips an issue before any of that: it is
|
||||||
|
not read and not merged. Draw the graph afterwards with the domain's own
|
||||||
issue_tree.py — it needs no network.
|
issue_tree.py — it needs no network.
|
||||||
|
|
||||||
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
Login: the operator's pin from .claude/settings.local.json (see /tea:auth).
|
||||||
@@ -187,13 +191,18 @@ def main():
|
|||||||
store_ids.add(id)
|
store_ids.add(id)
|
||||||
number_of_id[number] = id
|
number_of_id[number] = id
|
||||||
if args.cached and stored:
|
if args.cached and stored:
|
||||||
skipped.append(id) # untouched, and not one request spent on it
|
skipped.append(id) # untouched, unread, and not one request spent
|
||||||
else:
|
else:
|
||||||
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
extra = _gitea.native_deps(login, base, number) if args.deps else []
|
||||||
|
# The copy already on disk, as it was when this run started. It
|
||||||
|
# contributes its ticked checkboxes and nothing else; None when
|
||||||
|
# the store has never seen this issue.
|
||||||
|
prev = issues.get(id)
|
||||||
iss, unresolved = gmap.from_api(payload, id, repo,
|
iss, unresolved = gmap.from_api(payload, id, repo,
|
||||||
id_for_number=number_of_id,
|
id_for_number=number_of_id,
|
||||||
extra_numbers=extra,
|
extra_numbers=extra,
|
||||||
synced=_gitea.now_iso())
|
synced=_gitea.now_iso(),
|
||||||
|
local_body=prev.body if prev else None)
|
||||||
issue.save(root, iss)
|
issue.save(root, iss)
|
||||||
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
sync_comments(login, base, root, id, number, payload.get("comments") or 0)
|
||||||
remote_map[gmap.remote_key(repo, number)] = id
|
remote_map[gmap.remote_key(repo, number)] = id
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Checkbox state survives a pull; everything else in the body does not.
|
||||||
|
|
||||||
|
Two levels, on purpose. `map.merge_checkbox_state` is pure, so most of the rule
|
||||||
|
is pinned down with plain strings and no store anywhere. The pull tests then
|
||||||
|
prove the rule is actually wired into the write path, with the transport
|
||||||
|
stubbed at the one seam `test_push_dependencies.py` uses — `_gitea.api`, the
|
||||||
|
single function that shells out to `tea`. Nothing here touches a network, and
|
||||||
|
no test may ever be made to.
|
||||||
|
|
||||||
|
`skills/*/scripts/` are not packages; they go on sys.path by hand.
|
||||||
|
"""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.parse
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"),
|
||||||
|
os.path.join(_ROOT, "skills", "issue", "scripts")):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|
||||||
|
import _gitea # noqa: E402
|
||||||
|
import issue # noqa: E402
|
||||||
|
import map as gmap # noqa: E402
|
||||||
|
import pull # noqa: E402
|
||||||
|
|
||||||
|
REPO = "claude-skills/tea"
|
||||||
|
BASE = "repos/%s" % REPO
|
||||||
|
|
||||||
|
|
||||||
|
def body(*criteria, **kw):
|
||||||
|
"""A body in the canonical shape, with the given `## Acceptance criteria`."""
|
||||||
|
summary = kw.get("summary", "Прозаическое описание.")
|
||||||
|
return ("## Summary\n%s\n\n## Spec\nnone\n\n## Acceptance criteria\n%s\n"
|
||||||
|
% (summary, "\n".join(criteria))).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# the rule itself — pure, no store, no tracker
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MergeCheckboxStateTest(unittest.TestCase):
|
||||||
|
"""`[x]` wins from whichever side has it, for a matching item text."""
|
||||||
|
|
||||||
|
def test_a_local_tick_survives_the_overwrite(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] первое", "- [ ] второе"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_remote_tick_is_kept(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [x] первое", "- [ ] второе"),
|
||||||
|
body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_both_sides_ticked_is_still_ticked(self):
|
||||||
|
one = body("- [x] первое")
|
||||||
|
self.assertEqual(gmap.merge_checkbox_state(one, one), one)
|
||||||
|
|
||||||
|
def test_the_union_is_taken_item_by_item(self):
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [x] первое", "- [ ] второе", "- [ ] третье"),
|
||||||
|
body("- [ ] первое", "- [x] второе", "- [ ] третье"))
|
||||||
|
self.assertEqual(got, body("- [x] первое", "- [x] второе", "- [ ] третье"))
|
||||||
|
|
||||||
|
def test_an_item_the_local_copy_does_not_have_comes_from_the_server(self):
|
||||||
|
"""Including its state — both states, in both directions."""
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [x] новое сверху", "- [ ] новое снизу"),
|
||||||
|
body("- [x] что-то совсем другое"))
|
||||||
|
self.assertEqual(got, body("- [x] новое сверху", "- [ ] новое снизу"))
|
||||||
|
|
||||||
|
def test_prose_is_not_merged(self):
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
body("- [ ] пункт", summary="Новый текст с сервера."),
|
||||||
|
body("- [x] пункт", summary="Старый локальный текст."))
|
||||||
|
self.assertIn("Новый текст с сервера.", got)
|
||||||
|
self.assertNotIn("Старый локальный текст.", got)
|
||||||
|
self.assertIn("- [x] пункт", got)
|
||||||
|
|
||||||
|
def test_a_heading_the_local_copy_added_is_gone(self):
|
||||||
|
remote = body("- [x] пункт")
|
||||||
|
got = gmap.merge_checkbox_state(remote, remote + "\n\n## Notes\nмои заметки\n")
|
||||||
|
self.assertEqual(got, remote)
|
||||||
|
|
||||||
|
def test_no_local_copy_returns_the_server_body_untouched(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
for local in (None, "", " \n"):
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, local), remote,
|
||||||
|
"local_body=%r rewrote the body" % local)
|
||||||
|
|
||||||
|
def test_nothing_ticked_locally_returns_the_same_object(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [ ] пункт")), remote)
|
||||||
|
|
||||||
|
def test_no_matching_item_returns_the_same_object(self):
|
||||||
|
remote = body("- [ ] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] другой")), remote)
|
||||||
|
|
||||||
|
def test_a_body_with_no_checkboxes_at_all(self):
|
||||||
|
remote = "## Summary\nодна проза\n"
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, "- [x] пункт"), remote)
|
||||||
|
self.assertEqual(gmap.merge_checkbox_state("- [ ] пункт", remote), "- [ ] пункт")
|
||||||
|
|
||||||
|
def test_exactly_one_character_changes(self):
|
||||||
|
"""Ticking a box must not produce a diff wider than the state."""
|
||||||
|
remote = body("- [ ] пункт", "- [ ] второй")
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт", "- [ ] второй"))
|
||||||
|
diff = [i for i, (a, b) in enumerate(zip(remote, got)) if a != b]
|
||||||
|
self.assertEqual(len(remote), len(got))
|
||||||
|
self.assertEqual(len(diff), 1)
|
||||||
|
self.assertEqual((remote[diff[0]], got[diff[0]]), (" ", "x"))
|
||||||
|
|
||||||
|
def test_a_rewrapped_item_keeps_its_tick(self):
|
||||||
|
"""`Checkbox.text` joins continuation lines with one space, which is
|
||||||
|
the whole reason matching survives a reflow."""
|
||||||
|
remote = body("- [ ] длинный пункт, который сервер\n"
|
||||||
|
" перенёс на две строки")
|
||||||
|
got = gmap.merge_checkbox_state(
|
||||||
|
remote, body("- [x] длинный пункт, который сервер перенёс на две строки"))
|
||||||
|
self.assertIn("- [x] длинный пункт", got)
|
||||||
|
|
||||||
|
def test_a_reworded_item_does_not_keep_its_tick(self):
|
||||||
|
"""Different text is a different item. The tick stays with the wording
|
||||||
|
it was put on — this is a match, not a guess."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт про sqlc"),
|
||||||
|
body("- [x] пункт про SQLC"))
|
||||||
|
self.assertEqual(got, body("- [ ] пункт про sqlc"))
|
||||||
|
|
||||||
|
def test_the_marker_style_does_not_have_to_match(self):
|
||||||
|
"""`-`, `*` and `1.` are all checkbox markers to the domain parser, so
|
||||||
|
the item is the same item however the two sides chose to render it."""
|
||||||
|
got = gmap.merge_checkbox_state(body("1. [ ] пункт"), body("* [x] пункт"))
|
||||||
|
self.assertEqual(got, body("1. [x] пункт"))
|
||||||
|
|
||||||
|
def test_a_moved_item_keeps_its_tick(self):
|
||||||
|
"""Matching is on text alone; the section is not part of the key. An
|
||||||
|
item promoted out of `## Acceptance criteria` is the same item."""
|
||||||
|
remote = "## Issues\n- [ ] пункт\n"
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, "## Issues\n- [x] пункт\n")
|
||||||
|
|
||||||
|
def test_duplicate_text_is_read_as_a_set(self):
|
||||||
|
"""The documented reading: one ticked local item ticks every remote
|
||||||
|
line with that text. Pairing duplicates by order is the alternative,
|
||||||
|
and it is the one that can still drop a tick."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт", "- [ ] пункт"),
|
||||||
|
body("- [ ] пункт", "- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт", "- [x] пункт"))
|
||||||
|
|
||||||
|
def test_duplicate_text_never_loses_the_second_tick(self):
|
||||||
|
"""Two local lines, one remote: order-pairing would drop this tick."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"),
|
||||||
|
body("- [ ] пункт", "- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_an_example_inside_a_code_fence_is_not_ticked(self):
|
||||||
|
"""The domain parser skips fences whole, and so does the merge: a
|
||||||
|
`- [ ]` in a fence is markup being shown, not a box anyone may tick."""
|
||||||
|
remote = "## Spec\n```md\n- [ ] пункт\n```\n\n## Acceptance criteria\n- [ ] пункт\n"
|
||||||
|
got = gmap.merge_checkbox_state(remote, body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, remote.replace("## Acceptance criteria\n- [ ] пункт",
|
||||||
|
"## Acceptance criteria\n- [x] пункт"))
|
||||||
|
self.assertIn("```md\n- [ ] пункт\n```", got)
|
||||||
|
|
||||||
|
def test_an_existing_capital_X_is_left_alone(self):
|
||||||
|
remote = body("- [X] пункт")
|
||||||
|
self.assertIs(gmap.merge_checkbox_state(remote, body("- [x] пункт")), remote)
|
||||||
|
|
||||||
|
def test_a_capital_X_locally_still_counts_as_ticked(self):
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [X] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_unticking_in_the_web_does_not_survive(self):
|
||||||
|
"""The accepted price, pinned so nobody 'fixes' it by accident:
|
||||||
|
unticking is not monotone, so a box unticked upstream comes back.
|
||||||
|
Untick locally and push."""
|
||||||
|
got = gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||||
|
self.assertEqual(got, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_parsing_is_the_domain_layer_s(self):
|
||||||
|
"""The acceptance criterion, asserted rather than eyeballed: the rule
|
||||||
|
calls into skills/issue and defines no checkbox syntax of its own."""
|
||||||
|
with mock.patch.object(issue, "checkboxes", wraps=issue.checkboxes) as cb, \
|
||||||
|
mock.patch.object(issue, "set_checkbox", wraps=issue.set_checkbox) as sc:
|
||||||
|
gmap.merge_checkbox_state(body("- [ ] пункт"), body("- [x] пункт"))
|
||||||
|
self.assertTrue(cb.called)
|
||||||
|
self.assertTrue(sc.called)
|
||||||
|
|
||||||
|
def test_no_checkbox_markup_is_spelled_out_in_the_sync_layer(self):
|
||||||
|
"""The same criterion from the other side: the bracket markup itself
|
||||||
|
appears nowhere under skills/sync/scripts. Knowing what `[ ]` looks
|
||||||
|
like is the domain's job, and there is only one copy of it."""
|
||||||
|
sync = os.path.join(_ROOT, "skills", "sync", "scripts")
|
||||||
|
for name in sorted(f for f in os.listdir(sync) if f.endswith(".py")):
|
||||||
|
with open(os.path.join(sync, name)) as f:
|
||||||
|
code = f.read().split('"""')[0::2] # docstrings dropped
|
||||||
|
for chunk in code:
|
||||||
|
for markup in ("[ xX]", "[xX]", "- [ ]", "- [x]"):
|
||||||
|
self.assertNotIn(markup, chunk,
|
||||||
|
"%s spells out %r" % (name, markup))
|
||||||
|
|
||||||
|
|
||||||
|
class FromApiTest(unittest.TestCase):
|
||||||
|
"""The seam between the rule and the translation."""
|
||||||
|
|
||||||
|
PAYLOAD = {"number": 42, "title": "T", "html_url": "u",
|
||||||
|
"body": body("- [ ] пункт")}
|
||||||
|
|
||||||
|
def test_local_body_is_optional_and_defaults_to_no_merge(self):
|
||||||
|
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO)
|
||||||
|
self.assertEqual(iss.body, body("- [ ] пункт"))
|
||||||
|
|
||||||
|
def test_local_body_contributes_its_ticks(self):
|
||||||
|
iss, _ = gmap.from_api(dict(self.PAYLOAD), "an-issue", REPO,
|
||||||
|
local_body=body("- [x] пункт"))
|
||||||
|
self.assertEqual(iss.body, body("- [x] пункт"))
|
||||||
|
|
||||||
|
def test_an_empty_remote_body_does_not_crash(self):
|
||||||
|
iss, _ = gmap.from_api({"number": 42, "title": "T", "body": None},
|
||||||
|
"an-issue", REPO, local_body=body("- [x] пункт"))
|
||||||
|
self.assertEqual(iss.body, "")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# pull.py — the rule wired into the write path
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeGitea(object):
|
||||||
|
"""A `tea api` that answers issues from memory and remembers the calls."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.issues = {}
|
||||||
|
|
||||||
|
def add(self, number, title, text, **kw):
|
||||||
|
p = {"number": number, "title": title, "body": text, "state": "open",
|
||||||
|
"comments": 0, "html_url": "https://git.example/%s/issues/%d" % (REPO, number),
|
||||||
|
"labels": [{"name": "type/task"}], "assignees": [], "milestone": None,
|
||||||
|
"updated_at": "2026-08-10T00:00:00Z"}
|
||||||
|
p.update(kw)
|
||||||
|
self.issues[number] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
def api(self, login, endpoint, method="GET", payload=None, **kw):
|
||||||
|
self.calls.append((method, endpoint))
|
||||||
|
path, _, query = endpoint.partition("?")
|
||||||
|
params = dict(urllib.parse.parse_qsl(query))
|
||||||
|
|
||||||
|
m = re.match(r"^%s/issues/(\d+)$" % re.escape(BASE), path)
|
||||||
|
if m and method == "GET":
|
||||||
|
return self.issues.get(int(m.group(1)))
|
||||||
|
if path == "%s/issues" % BASE and method == "GET":
|
||||||
|
if int(params.get("page", 1)) > 1:
|
||||||
|
return []
|
||||||
|
return [self.issues[n] for n in sorted(self.issues)]
|
||||||
|
raise AssertionError("unstubbed call: %s %s" % (method, endpoint))
|
||||||
|
|
||||||
|
|
||||||
|
class PullTestCase(unittest.TestCase):
|
||||||
|
"""A temp store and a fake transport."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.mkdtemp(prefix="tea-store-")
|
||||||
|
self.fake = FakeGitea()
|
||||||
|
for p in (mock.patch.object(_gitea, "api", self.fake.api),
|
||||||
|
mock.patch.object(_gitea, "require_login", lambda: "test-login")):
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, True)
|
||||||
|
|
||||||
|
def write_local(self, id, text, number=42):
|
||||||
|
issue.save(self.root, issue.Issue(
|
||||||
|
id=id, title="An issue", body=text, labels=["type/task"],
|
||||||
|
origin="gitea", extra={"gitea": "%s#%d" % (REPO, number),
|
||||||
|
"url": "https://git.example/x", "synced": "old"}))
|
||||||
|
|
||||||
|
def run_pull(self, *argv):
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
args = ["pull.py", "--repo", REPO, "--out", self.root] + list(argv)
|
||||||
|
with mock.patch.object(sys, "argv", args), \
|
||||||
|
contextlib.redirect_stdout(out), \
|
||||||
|
contextlib.redirect_stderr(err):
|
||||||
|
pull.main()
|
||||||
|
return out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def stored_body(self, id):
|
||||||
|
return issue.load(self.root, id).body
|
||||||
|
|
||||||
|
def raw(self, id):
|
||||||
|
"""The file on disk, byte for byte — metadata included."""
|
||||||
|
with open(issue.path_of(self.root, id)) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
class PullMergesTicksTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_a_tick_made_locally_survives_the_pull(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_tick_made_in_the_web_lands_locally(self):
|
||||||
|
self.write_local("an-issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_the_rest_of_the_body_is_still_overwritten(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое",
|
||||||
|
summary="Локальная правка прозы."))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] новое с сервера",
|
||||||
|
summary="Серверная проза."))
|
||||||
|
self.run_pull("42")
|
||||||
|
got = self.stored_body("an-issue")
|
||||||
|
self.assertIn("Серверная проза.", got)
|
||||||
|
self.assertNotIn("Локальная правка прозы.", got)
|
||||||
|
self.assertIn("- [x] первое", got)
|
||||||
|
self.assertIn("- [ ] новое с сервера", got)
|
||||||
|
|
||||||
|
def test_an_item_the_server_added_arrives_ticked_if_the_server_ticked_it(self):
|
||||||
|
self.write_local("an-issue", body("- [ ] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [x] новое с сервера"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [ ] первое", "- [x] новое с сервера"))
|
||||||
|
|
||||||
|
def test_filter_mode_merges_too(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||||
|
self.run_pull("--label", "type/task")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||||
|
|
||||||
|
def test_a_retitled_issue_keeps_its_slug_and_its_ticks(self):
|
||||||
|
"""The merge hangs off the local id, which is resolved from the number
|
||||||
|
— a title change must not orphan the ticks."""
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "Completely different title", body("- [ ] первое"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [x] первое"))
|
||||||
|
self.assertEqual(issue.load(self.root, "an-issue").title,
|
||||||
|
"Completely different title")
|
||||||
|
|
||||||
|
|
||||||
|
class PullIntoAnEmptyStoreTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_no_local_file_writes_the_server_body_unchanged(self):
|
||||||
|
self.fake.add(42, "An issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
def test_a_store_that_does_not_exist_yet_is_created_and_not_merged(self):
|
||||||
|
shutil.rmtree(self.root)
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое"))
|
||||||
|
_out, err = self.run_pull("42")
|
||||||
|
self.assertIn("created store", err)
|
||||||
|
self.assertEqual(self.stored_body("an-issue"), body("- [ ] первое"))
|
||||||
|
|
||||||
|
|
||||||
|
class CachedIsUnchangedTest(PullTestCase):
|
||||||
|
|
||||||
|
def test_a_skipped_issue_is_neither_read_nor_merged(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
before = self.raw("an-issue")
|
||||||
|
|
||||||
|
with mock.patch.object(gmap, "merge_checkbox_state") as merge:
|
||||||
|
out, _ = self.run_pull("42", "--cached")
|
||||||
|
|
||||||
|
merge.assert_not_called()
|
||||||
|
self.assertIn("(cached)", out)
|
||||||
|
self.assertEqual(self.raw("an-issue"), before)
|
||||||
|
|
||||||
|
def test_without_cached_the_same_issue_is_merged(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.stored_body("an-issue"),
|
||||||
|
body("- [x] первое", "- [ ] второе"))
|
||||||
|
|
||||||
|
|
||||||
|
class RoundTripTest(PullTestCase):
|
||||||
|
"""Pull twice with no change in between: the second is a no-op."""
|
||||||
|
|
||||||
|
def test_a_repeat_pull_does_not_churn_the_file(self):
|
||||||
|
self.write_local("an-issue", body("- [x] первое", "- [ ] второе"))
|
||||||
|
self.fake.add(42, "An issue", body("- [ ] первое", "- [ ] второе"))
|
||||||
|
self.run_pull("42")
|
||||||
|
first = self.raw("an-issue")
|
||||||
|
self.run_pull("42")
|
||||||
|
self.assertEqual(self.raw("an-issue"), first)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user