merge: write issue dependencies to Gitea on push

This commit is contained in:
naudachu
2026-08-10 15:41:37 +05:00
4 changed files with 556 additions and 9 deletions
+40
View File
@@ -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
# --------------------------------------------------------------------------
+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
@@ -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))