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
|
`## Spec` / `## Acceptance criteria` present). `--force` posts anyway — say why
|
||||||
when you use it.
|
when you use it.
|
||||||
|
|
||||||
Issues go up in topological order, dependencies first. A dependency that is
|
### Dependencies
|
||||||
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
|
Issues go up in topological order, dependencies first, and **the graph goes up
|
||||||
until that issue is pushed too.
|
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
|
Missing labels are created with the canonical color and, for `type/*` and
|
||||||
`severity/*`, `exclusive: true` — `tea labels create` cannot set that field
|
`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 |
|
| `labels` | `labels[]` | names both ways; ids only on write |
|
||||||
| `assignees` | `assignees[]` | logins |
|
| `assignees` | `assignees[]` | logins |
|
||||||
| `milestone` | `milestone.title` | resolved to an id on write |
|
| `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 |
|
| — | `ref` | lands in `branch:`; sent only when non-empty |
|
||||||
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
| — | `number`, `html_url` | lands in `gitea:` / `url:` |
|
||||||
|
|
||||||
`depends:` is always slugs. The body's `## Depends on` section is human prose
|
`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:`
|
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
|
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
|
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.
|
`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 []
|
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
|
# 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
|
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
|
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
|
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
|
Missing labels are created with the canonical color and, for type/* and
|
||||||
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
severity/*, `exclusive: true` — `tea labels create` cannot set that field.
|
||||||
@@ -67,6 +85,27 @@ def select(issues, ids, update):
|
|||||||
return chosen
|
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():
|
def git_branch():
|
||||||
"""The branch HEAD is on, or None. The only git call these scripts make —
|
"""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
|
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) "
|
_gitea.warn("no current git branch (detached HEAD, or outside a git repo) "
|
||||||
"— no `ref` on: %s" % ", ".join(blank))
|
"— no `ref` on: %s" % ", ".join(blank))
|
||||||
|
|
||||||
|
pushing = set(order)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
|
links = 0
|
||||||
for id in order:
|
for id in order:
|
||||||
iss = issues[id]
|
iss = issues[id]
|
||||||
print("ok %s [type/%s] %s (%s)"
|
print("ok %s [type/%s] %s (%s)"
|
||||||
% (id, iss.type or "?", iss.title, ", ".join(iss.labels) or "no labels"))
|
% (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
|
return
|
||||||
|
|
||||||
login = _gitea.require_login()
|
login = _gitea.require_login()
|
||||||
@@ -159,7 +213,7 @@ def main():
|
|||||||
|
|
||||||
unsynced = [d for d in iss.depends
|
unsynced = [d for d in iss.depends
|
||||||
if d in issues and not issues[d].extra.get("gitea")
|
if d in issues and not issues[d].extra.get("gitea")
|
||||||
and d not in order]
|
and d not in pushing]
|
||||||
if unsynced:
|
if unsynced:
|
||||||
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
_gitea.warn("%s: depends on local-only issue(s) %s — no #N cross-link in Gitea"
|
||||||
% (id, ", ".join(unsynced)))
|
% (id, ", ".join(unsynced)))
|
||||||
@@ -204,6 +258,24 @@ def main():
|
|||||||
remote_map[gmap.remote_key(repo, number)] = id
|
remote_map[gmap.remote_key(repo, number)] = id
|
||||||
print("%s %s #%d %s" % (verb, id, number, got.get("html_url", "")))
|
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)
|
_gitea.save_map(root, remote_map)
|
||||||
path, n = issue_index.build(root)
|
path, n = issue_index.build(root)
|
||||||
print("index: %s — %d issue(s)" % (path, n))
|
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