From 0cf4baa4293a488f6888dca08ed51b732f1a200a Mon Sep 17 00:00:00 2001 From: naudachu Date: Mon, 10 Aug 2026 15:39:52 +0500 Subject: [PATCH] fix: write issue dependencies to Gitea on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local `depends:` graph never reached the tracker. push.py sent dependent issues in topological order but created no native links, so `native_deps` in _gitea.py was a reader with no writer and the slugs in `## Depends on` stayed dead prose for anyone reading the issue in Gitea. Once an issue has its number, every `depends:` entry that also has one now becomes a real link: POST /repos/{owner}/{repo}/issues/{index}/dependencies with the blocker's IssueMeta. Topological order means the blocker is already numbered, so no second pass is needed. Existing links are read back first, so a repeat push is a no-op and never 409s; a link that fails anyway warns rather than aborting a run that has already created issues. `--dry-run` prints the links it would make and touches nothing. The `## Depends on` prose is still passed through verbatim — the edge the tracker acts on is the native link, not the text, which is exactly why the text can be left alone. Removing a link that disappeared from `depends:` is out of scope and now says so in push.py's docstring. Establishes tests/: stdlib unittest, the transport stubbed at _gitea.api, no network. Run with `python3 -m unittest discover -s tests`. The POST body shape was confirmed against the instance's own swagger.v1.json (Gitea 1.26.1), not assumed from upstream docs. Co-Authored-By: Claude Opus 5 (1M context) --- skills/sync/SKILL.md | 44 +++- skills/sync/scripts/_gitea.py | 40 ++++ skills/sync/scripts/push.py | 78 ++++++- tests/test_push_dependencies.py | 403 ++++++++++++++++++++++++++++++++ 4 files changed, 556 insertions(+), 9 deletions(-) create mode 100644 tests/test_push_dependencies.py diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 38ec351..cf6acf7 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -131,10 +131,40 @@ at most one `severity/*`, English title with no type prefix, `## Summary` / `## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why when you use it. -Issues go up in topological order, dependencies first. A dependency that is -still local-only is reported, not silently dropped: the body's `## Depends on` -prose is sent verbatim either way, but the `#N` cross-link will be missing -until that issue is pushed too. +### Dependencies + +Issues go up in topological order, dependencies first, and **the graph goes up +with them**. Once an issue has its number, every `depends:` entry that also has +one becomes a native Gitea link, so the tracker shows the blocking panel and +refuses to close a blocked issue before its blocker. + +The two directions are symmetric, and they use the same endpoint: + +| | direction | endpoint | +|---|---|---| +| `push.py` | `depends:` → native links | `POST …/issues/{n}/dependencies` | +| `pull.py --deps` | native links → `depends:` | `GET …/issues/{n}/dependencies` | + +The POST body is Gitea's `IssueMeta` — `{"index", "owner", "repo"}` naming the +**blocker**, posted to the **blocked** issue's endpoint ("make the issue in the +url depend on the issue in the form"). `owner`/`repo` travel with it, so a +dependency in another repo links correctly. + +- Topological order means the blocker already has its number — no second pass. +- A link the tracker already has is skipped: push GETs the existing ones first, + so a repeat push is a no-op and a 409 never happens. Should a link fail + anyway, it is a warning, not a dead run — the issues are already created. +- `--update` carries links that appeared in `depends:` after the first push. +- `--dry-run` prints every link it would make (`#?` for a number this run has + not handed out yet) and makes no request at all. +- **Removing a link is out of scope.** Push only adds. A dependency deleted + from `depends:` leaves its Gitea link standing; drop it in the web UI or with + `tea api -X DELETE …/issues/N/dependencies`. + +A dependency that is still local-only is reported, not silently dropped: it has +no number, so it gets no link. The body's `## Depends on` prose is sent verbatim +either way — nothing is lost, but the tracker shows no edge until that issue is +pushed too. Missing labels are created with the canonical color and, for `type/*` and `severity/*`, `exclusive: true` — `tea labels create` cannot set that field @@ -178,14 +208,16 @@ never check out, create, or write anything. | `labels` | `labels[]` | names both ways; ids only on write | | `assignees` | `assignees[]` | logins | | `milestone` | `milestone.title` | resolved to an id on write | -| `depends` | — | slugs; seeded from `#N` on pull | +| `depends` | native links | slugs here, `IssueMeta` there; push writes them, `pull --deps` reads them | | — | `ref` | lands in `branch:`; sent only when non-empty | | — | `number`, `html_url` | lands in `gitea:` / `url:` | `depends:` is always slugs. The body's `## Depends on` section is human prose and is passed through **unchanged** in both directions: a pull seeds `depends:` from the `#N` it finds there, a push never rewrites what the author wrote. A -translator that edits prose churns the body on every round trip. +translator that edits prose churns the body on every round trip. The edge the +tracker acts on is the native link, not the text — which is exactly why the +text can be left alone. Comments are **pull-only** in the store: `.comments.md` is written by `pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea. diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py index 7976f56..ff1f3d2 100644 --- a/skills/sync/scripts/_gitea.py +++ b/skills/sync/scripts/_gitea.py @@ -226,6 +226,46 @@ def native_deps(login, base, number): return [i["number"] for i in got] if isinstance(got, list) else [] +def native_dep_pairs(login, base, number): + """The same links as {(owner/repo, number)} — what a repeat push compares + against so it does not POST a link the tracker already has. + + A bare number is ambiguous the moment a dependency lives in another repo, + and IssueMeta lets it, so the repo travels with it. The pair is a transport + fact; formatting it as `owner/repo#42` is map.py's job, not this module's.""" + got = api(login, "%s/issues/%d/dependencies" % (base, number), allow_fail=True) + out = set() + for i in got if isinstance(got, list) else []: + repo = (i.get("repository") or {}).get("full_name") or "" + if "number" in i: + out.add((repo, int(i["number"]))) + return out + + +def add_dependency(login, base, number, dep_repo, dep_number, out_root=None): + """Make issue `number` depend on `dep_repo#dep_number`. True on success. + + Confirmed against the instance's own swagger.v1.json (Gitea 1.26.1): + + POST /repos/{owner}/{repo}/issues/{index}/dependencies + body: IssueMeta — {"index": , "owner": "", "repo": ""} + "Make the issue in the url depend on the issue in the form." + + The URL names the blocked issue and the body the blocker, which is the same + direction native_deps reads back ("all issues that block this issue"). A + link that already exists answers 409, so a failure here is reported and not + fatal: one missing cross-link must not abort a push that has already + created issues. Callers pre-filter with native_dep_pairs.""" + owner, _, name = (dep_repo or "").partition("/") + if not owner or not name: + return False + payload = {"index": int(dep_number), "owner": owner, "repo": name} + got = api(login, "%s/issues/%d/dependencies" % (base, number), "POST", payload, + payload_name="dep-%d-%d" % (number, dep_number), + out_root=out_root, allow_fail=True) + return got is not None + + # -------------------------------------------------------------------------- # labels # -------------------------------------------------------------------------- diff --git a/skills/sync/scripts/push.py b/skills/sync/scripts/push.py index b674032..e044f9c 100644 --- a/skills/sync/scripts/push.py +++ b/skills/sync/scripts/push.py @@ -20,7 +20,25 @@ anyway; say why when you use it. Dependencies are pushed in topological order so a parent is created after the issues it depends on. A dependency that is still local-only is reported, not silently dropped — the body's `## Depends on` prose is sent verbatim either -way, so nothing is lost, but the `#N` cross-links will be missing. +way, so nothing is lost, but the tracker shows no edge for it. + +The graph goes up with them. Once an issue has its number, every `depends:` +entry that also has one becomes a **native Gitea link** — the same +`/dependencies` that `pull.py --deps` reads back, so the tracker shows the +blocking panel and refuses to close a blocked issue first. Topological order +means the blocker already has its number by then; no second pass is needed. +`--update` links whatever appeared in `depends:` since the last push. A link +the tracker already has is skipped, not re-POSTed. A dependency that stayed +local has no number and becomes no link — only the warning above. + +REMOVING a link is OUT OF SCOPE. Push only ever adds: a dependency deleted +from `depends:` leaves its Gitea link standing, and nothing here will notice. +Unlink it in the web UI, or by hand with +`tea api -X DELETE --login "$GITEA_LOGIN" repos/OWNER/REPO/issues/N/dependencies`. + +The `## Depends on` prose itself is never touched — slugs stay slugs and are +not rewritten to `#N`, so the body survives a pull -> push round trip byte for +byte. The link lives in Gitea's own graph, not in the text. Missing labels are created with the canonical color and, for type/* and severity/*, `exclusive: true` — `tea labels create` cannot set that field. @@ -67,6 +85,27 @@ def select(issues, ids, update): return chosen +def dep_state(iss, issues, pushing): + """What each `depends:` entry is, as far as linking is concerned. + + Yields (slug, remote_key, in_run) per dependency that exists in the store: + + remote_key the dependency's `gitea:` value, or None while it is local + in_run this push is about to give it one + + 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.""" + 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)) + return out + + 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 @@ -133,12 +172,27 @@ def main(): _gitea.warn("no current git branch (detached HEAD, or outside a git repo) " "— no `ref` on: %s" % ", ".join(blank)) + pushing = set(order) + if args.dry_run: + links = 0 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")) - print("%d issue(s) would be %s" % (len(order), "updated" if args.update else "created")) + # 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): + if key: + print(" link -> %s (%s)" % (key, slug)) + links += 1 + elif in_run: + print(" link -> #? (%s, created by this run)" % slug) + links += 1 + else: + print(" no link: %s is local-only" % slug) + print("%d issue(s) would be %s, %d dependency link(s) would be created" + % (len(order), "updated" if args.update else "created", links)) return login = _gitea.require_login() @@ -157,7 +211,7 @@ def main(): unsynced = [d for d in iss.depends if d in issues and not issues[d].extra.get("gitea") - and d not in order] + 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))) @@ -202,6 +256,24 @@ def main(): remote_map[gmap.remote_key(repo, number)] = id 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. + wanted_links = [(slug, gmap.parse_remote_key(key)) + for slug, key, _ in dep_state(iss, issues, pushing) if key] + if wanted_links: + have = _gitea.native_dep_pairs(login, base, number) + for slug, (drepo, dnum) in wanted_links: + if not dnum or (drepo, dnum) in have: + continue + if _gitea.add_dependency(login, base, number, drepo, dnum, root): + print(" depends on %s#%d (%s)" % (drepo, dnum, slug)) + else: + _gitea.warn("%s: could not link #%d -> %s#%d (%s) — link it by " + "hand or re-run push" % (id, number, drepo, dnum, slug)) + _gitea.save_map(root, remote_map) path, n = issue_index.build(root) print("index: %s — %d issue(s)" % (path, n)) diff --git a/tests/test_push_dependencies.py b/tests/test_push_dependencies.py new file mode 100644 index 0000000..01e2f60 --- /dev/null +++ b/tests/test_push_dependencies.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Native Gitea dependency links, written by push.py. + +The transport is stubbed at exactly one seam — `_gitea.api`, the single +function that shells out to `tea` — so everything above it runs for real: +argument parsing, validation, topological order, the id map, map.py's payload +shapes and _gitea's own endpoint/body construction. Nothing here touches a +network, and no test may ever be made to. + +`skills/*/scripts/` are not packages; they go on sys.path by hand. +""" +import contextlib +import io +import os +import shutil +import sys +import tempfile +import unittest +from unittest import mock + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +for _p in (os.path.join(_ROOT, "skills", "sync", "scripts"), + os.path.join(_ROOT, "skills", "issue", "scripts")): + if _p not in sys.path: + sys.path.insert(0, _p) + +import _gitea # noqa: E402 +import issue # noqa: E402 +import map as gmap # noqa: E402 +import push # noqa: E402 + +REPO = "claude-skills/tea" +BASE = "repos/%s" % REPO +LABELS = {"type/task": 901, "type/bug": 902, "severity/medium": 903, + "comp/sync": 904} +LABEL_NAMES = {v: k for k, v in LABELS.items()} + +BODY = """## Summary +Прозаическое описание. + +## Spec +skills/issue/references/format.md + +## Depends on +- first-thing — ставит фундамент, без него второй не собрать + +## Acceptance criteria +- [ ] что-нибудь работает +""" + +BODY_NO_DEPS = """## Summary +Прозаическое описание. + +## Spec +skills/issue/references/format.md + +## Acceptance criteria +- [ ] что-нибудь работает +""" + + +class FakeGitea(object): + """A `tea api` that answers from memory and remembers what it was asked. + + Dependency links are kept the way Gitea keeps them: per blocked issue, a + set of (repo, number) blockers. That is what makes the idempotence test + meaningful — the second push sees the link the first one made.""" + + def __init__(self, next_number=101): + self.calls = [] # (method, endpoint, payload) + self.next_number = next_number + self.deps = {} # number -> {(repo, number)} + self.titles = {} # number -> title + self.fail_dependency_post = False + + # -- helpers ----------------------------------------------------------- + + @property + def writes(self): + """Every non-GET call. `--dry-run` must produce an empty list.""" + return [c for c in self.calls if c[0] != "GET"] + + def dep_posts(self): + return [c for c in self.calls + if c[0] == "POST" and c[1].endswith("/dependencies")] + + def issue_payload(self, number, labels=()): + return {"number": number, + "html_url": "https://git.example/%s/issues/%d" % (REPO, number), + "title": self.titles.get(number, ""), + "labels": [{"name": LABEL_NAMES[i]} for i in labels + if i in LABEL_NAMES], + "updated_at": "2026-08-10T00:00:00Z", + "repository": {"full_name": REPO}} + + # -- the seam ---------------------------------------------------------- + + def api(self, login, endpoint, method="GET", payload=None, + payload_name=None, out_root=None, allow_fail=False): + self.calls.append((method, endpoint, payload)) + path = endpoint.split("?")[0] + + if path == "%s/labels" % BASE and method == "GET": + # Every label the run could ask for, so nothing is ever created. + return [{"name": n, "id": i} for n, i in LABELS.items()] + + if path == "%s/issues" % BASE and method == "POST": + number = self.next_number + self.next_number += 1 + self.titles[number] = (payload or {}).get("title", "") + # Echo the labels back, or push re-applies them with a PUT. + return self.issue_payload(number, (payload or {}).get("labels") or []) + + if path.endswith("/dependencies"): + number = int(path.split("/issues/")[1].split("/")[0]) + if method == "GET": + return [dict(self.issue_payload(n), repository={"full_name": r}) + for r, n in sorted(self.deps.get(number, set()))] + if method == "POST": + if self.fail_dependency_post: + return None + key = ("%s/%s" % (payload["owner"], payload["repo"]), + int(payload["index"])) + self.deps.setdefault(number, set()).add(key) + return self.issue_payload(number) + + if "/issues/" in path and method == "PATCH": + number = int(path.rsplit("/", 1)[1]) + self.titles[number] = (payload or {}).get("title", self.titles.get(number, "")) + return self.issue_payload(number, (payload or {}).get("labels") or []) + + raise AssertionError("unstubbed call: %s %s" % (method, endpoint)) + + +class PushTestCase(unittest.TestCase): + """A temp store, a fake transport, and no git.""" + + def setUp(self): + self.root = tempfile.mkdtemp(prefix="tea-store-") + self.fake = FakeGitea() + patches = [ + mock.patch.object(_gitea, "api", self.fake.api), + mock.patch.object(_gitea, "require_login", lambda: "test-login"), + # push reads the current branch from git; a temp store has none and + # the runner's branch would leak into the payload. + mock.patch.object(push, "git_branch", lambda: "test-branch"), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + self.addCleanup(shutil.rmtree, self.root, True) + + # -- fixtures ---------------------------------------------------------- + + def write_issue(self, id, title, body=BODY_NO_DEPS, depends=(), extra=None): + iss = issue.Issue(id=id, title=title, body=body, labels=["type/task"], + depends=list(depends), extra=dict(extra or {})) + issue.save(self.root, iss) + return iss + + def two_issues(self): + """first-thing, and second-thing which depends on it.""" + self.write_issue("first-thing", "First thing") + self.write_issue("second-thing", "Second thing", body=BODY, + depends=["first-thing"]) + + def run_push(self, *argv): + out, err = io.StringIO(), io.StringIO() + args = ["push.py", "--repo", REPO, "--out", self.root] + list(argv) + with mock.patch.object(sys, "argv", args), \ + contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err): + push.main() + return out.getvalue(), err.getvalue() + + def number_of(self, id): + return gmap.number_of(issue.load(self.root, id)) + + +# -------------------------------------------------------------------------- +# _gitea: the POST body, and the pre-check that reads links back +# -------------------------------------------------------------------------- + +class AddDependencyTest(unittest.TestCase): + + def test_post_body_is_issue_meta(self): + """POST /issues/{index}/dependencies with IssueMeta for the BLOCKER. + + Confirmed against the instance's swagger.v1.json (Gitea 1.26.1): + "Make the issue in the url depend on the issue in the form." """ + calls = [] + + def fake_api(login, endpoint, method="GET", payload=None, **kw): + calls.append((method, endpoint, payload)) + return {"number": 102} + + with mock.patch.object(_gitea, "api", fake_api): + ok = _gitea.add_dependency("l", BASE, 102, REPO, 101, out_root=None) + + self.assertTrue(ok) + method, endpoint, payload = calls[0] + self.assertEqual(method, "POST") + self.assertEqual(endpoint, "%s/issues/102/dependencies" % BASE) + self.assertEqual(payload, {"index": 101, "owner": "claude-skills", + "repo": "tea"}) + + def test_blocker_may_live_in_another_repo(self): + """IssueMeta carries owner/repo precisely so it can.""" + seen = {} + + def fake_api(login, endpoint, method="GET", payload=None, **kw): + seen.update(payload or {}) + return {"number": 1} + + with mock.patch.object(_gitea, "api", fake_api): + _gitea.add_dependency("l", BASE, 102, "other-org/infra", 7) + self.assertEqual(seen, {"index": 7, "owner": "other-org", "repo": "infra"}) + + def test_failure_is_reported_not_raised(self): + """409 (link already there) and friends come back as False.""" + with mock.patch.object(_gitea, "api", lambda *a, **k: None): + self.assertFalse(_gitea.add_dependency("l", BASE, 102, REPO, 101)) + + def test_unparseable_repo_makes_no_request(self): + called = [] + with mock.patch.object(_gitea, "api", lambda *a, **k: called.append(1)): + self.assertFalse(_gitea.add_dependency("l", BASE, 102, "tea", 101)) + self.assertEqual(called, []) + + def test_native_dep_pairs_reads_repo_and_number(self): + payload = [{"number": 101, "repository": {"full_name": REPO}}, + {"number": 7, "repository": {"full_name": "other-org/infra"}}] + with mock.patch.object(_gitea, "api", lambda *a, **k: payload): + got = _gitea.native_dep_pairs("l", BASE, 102) + self.assertEqual(got, {(REPO, 101), ("other-org/infra", 7)}) + + def test_native_dep_pairs_empty_when_unsupported(self): + with mock.patch.object(_gitea, "api", lambda *a, **k: None): + self.assertEqual(_gitea.native_dep_pairs("l", BASE, 102), set()) + + +# -------------------------------------------------------------------------- +# push: the whole run +# -------------------------------------------------------------------------- + +class PushCreatesLinksTest(PushTestCase): + + def test_link_created_after_both_have_numbers(self): + """One run, topological order, one native link — no second pass.""" + self.two_issues() + out, _ = self.run_push() + + first, second = self.number_of("first-thing"), self.number_of("second-thing") + self.assertLess(first, second, "blocker must be created first") + self.assertEqual(self.fake.deps.get(second), {(REPO, first)}) + self.assertIn("depends on %s#%d (first-thing)" % (REPO, first), out) + + def test_link_direction_matches_what_pull_reads_back(self): + """The link hangs off the BLOCKED issue, which is where native_deps + looks — push and `pull.py --deps` must agree or the round trip lies.""" + self.two_issues() + self.run_push() + second = self.number_of("second-thing") + with mock.patch.object(_gitea, "api", self.fake.api): + self.assertEqual(_gitea.native_deps("l", BASE, second), + [self.number_of("first-thing")]) + + def test_issue_without_dependencies_makes_no_dependency_request(self): + """Not even the idempotence GET — it is skipped when there is nothing + to link, so the common case costs no extra round trip.""" + self.write_issue("lonely-thing", "Lonely thing") + self.run_push() + self.assertEqual([c for c in self.fake.calls if "dependencies" in c[1]], []) + + +class LocalOnlyDependencyTest(PushTestCase): + + def test_local_dependency_is_warned_and_not_linked(self): + self.two_issues() + out, err = self.run_push("second-thing") + + self.assertEqual(self.fake.dep_posts(), []) + self.assertIn("depends on local-only issue(s) first-thing", err) + self.assertNotIn("depends on ", out) + self.assertIsNone(self.number_of("first-thing")) + + +class IdempotenceTest(PushTestCase): + + def test_repeat_push_does_not_duplicate_the_link(self): + self.two_issues() + self.run_push() + self.assertEqual(len(self.fake.dep_posts()), 1) + + self.run_push("--update") + self.assertEqual(len(self.fake.dep_posts()), 1, "link re-POSTed") + self.assertEqual(self.fake.deps[self.number_of("second-thing")], + {(REPO, self.number_of("first-thing"))}) + + def test_a_failing_link_warns_and_the_run_finishes(self): + """A 409 or any other refusal must not abort a push that has already + created issues.""" + self.two_issues() + self.fake.fail_dependency_post = True + out, err = self.run_push() + + self.assertIn("could not link", err) + self.assertIn("index:", out) # the run completed + self.assertIsNotNone(self.number_of("second-thing")) + + +class UpdateCarriesNewLinksTest(PushTestCase): + + def test_dependency_added_after_the_first_push_is_linked_by_update(self): + self.write_issue("first-thing", "First thing") + self.write_issue("second-thing", "Second thing") + self.run_push() + self.assertEqual(self.fake.dep_posts(), []) + + iss = issue.load(self.root, "second-thing") + iss.depends = ["first-thing"] + iss.body = BODY + issue.save(self.root, iss) + + self.run_push("--update", "second-thing") + self.assertEqual(self.fake.deps[self.number_of("second-thing")], + {(REPO, self.number_of("first-thing"))}) + + +class DryRunTest(PushTestCase): + + def test_dry_run_names_the_links_and_writes_nothing(self): + self.two_issues() + out, _ = self.run_push("--dry-run") + + self.assertEqual(self.fake.calls, [], "--dry-run made a request") + self.assertIn("link -> #? (first-thing, created by this run)", out) + self.assertIn("1 dependency link(s) would be created", out) + + def test_dry_run_shows_a_known_number_when_the_blocker_is_pushed(self): + self.write_issue("first-thing", "First thing", + extra={"gitea": "%s#101" % REPO}) + self.write_issue("second-thing", "Second thing", body=BODY, + depends=["first-thing"]) + out, _ = self.run_push("--dry-run") + + self.assertIn("link -> %s#101 (first-thing)" % REPO, out) + self.assertEqual(self.fake.writes, []) + + def test_dry_run_says_a_local_dependency_gets_no_link(self): + self.two_issues() + out, _ = self.run_push("--dry-run", "second-thing") + self.assertIn("no link: first-thing is local-only", out) + self.assertIn("0 dependency link(s) would be created", out) + + +class BodyIsVerbatimTest(PushTestCase): + + 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() + self.run_push() + + created = [c for c in self.fake.calls + if c[0] == "POST" and c[1] == "%s/issues" % BASE] + sent = [c[2]["body"] for c in created] + second_body = [b for b in sent if "Depends on" in b][0] + + self.assertIn("- first-thing — ставит фундамент", second_body) + self.assertNotIn("#101", second_body) + self.assertEqual(second_body, issue.load(self.root, "second-thing").body) + + 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("--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) + + +class DepStateTest(PushTestCase): + """The classifier both the dry run and the real run read from.""" + + def test_classifies_linked_in_run_and_local(self): + issues = { + "pushed": issue.Issue(id="pushed", extra={"gitea": "%s#101" % REPO}), + "coming": issue.Issue(id="coming"), + "local": issue.Issue(id="local"), + } + iss = issue.Issue(id="dependent", + depends=["pushed", "coming", "local", "ghost"]) + got = push.dep_state(iss, issues, {"coming", "dependent"}) + + self.assertEqual(got, [("pushed", "%s#101" % REPO, False), + ("coming", None, True), + ("local", None, False)]) + + +if __name__ == "__main__": + unittest.main()