merge: write issue dependencies to Gitea on push
This commit is contained in:
+38
-6
@@ -137,10 +137,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
|
||||
@@ -184,14 +214,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: `<id>.comments.md` is written by
|
||||
`pull.py` and `comment.py`, and editing it by hand changes nothing in Gitea.
|
||||
|
||||
@@ -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": <int>, "owner": "<owner>", "repo": "<name>"}
|
||||
"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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -135,12 +174,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()
|
||||
@@ -159,7 +213,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)))
|
||||
@@ -204,6 +258,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))
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user