fix: write issue dependencies to Gitea on push

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) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 15:39:52 +05:00
parent d8bd927f1d
commit 0cf4baa429
4 changed files with 556 additions and 9 deletions
+75 -3
View File
@@ -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))