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
+38 -6
View File
@@ -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.
+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))