#!/usr/bin/env python3 """Import markdown backlog/planning files into Gitea issues.""" from __future__ import annotations import argparse import dataclasses import hashlib import os import pathlib import re import sys from typing import Any, Iterable from gitea_common import GiteaClient, GiteaError, infer_target, label_ids_by_name, load_dotenv, repo_path, repo_root, require_token DEFAULT_PRODUCT_BACKLOG = pathlib.Path("/mnt/DATA/Nextcloud/ADD ideas UG/Products/govoplan/backlog.md") DEFAULT_WEB_TODO = pathlib.Path("/mnt/DATA/git/addideas-govoplan-website/TODO.md") REPO_ROOTS = { "core": pathlib.Path("/mnt/DATA/git/govoplan-core"), "access": pathlib.Path("/mnt/DATA/git/govoplan-access"), "mail": pathlib.Path("/mnt/DATA/git/govoplan-mail"), "files": pathlib.Path("/mnt/DATA/git/govoplan-files"), "campaign": pathlib.Path("/mnt/DATA/git/govoplan-campaign"), "web": pathlib.Path("/mnt/DATA/git/addideas-govoplan-website"), } MODULE_LABELS = { "core": "module/core", "access": "module/access", "mail": "module/mail", "files": "module/files", "campaign": "module/campaign", "web": "area/marketing", } @dataclasses.dataclass(frozen=True) class Candidate: repo_key: str title: str body: str labels: tuple[str, ...] fingerprint: str source: str line: int @dataclasses.dataclass class RepoState: target: Any client: GiteaClient label_ids: dict[str, int] fingerprints: set[str] titles: set[str] def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--env-file", type=pathlib.Path, help="dotenv file to read before using GITEA_* values") parser.add_argument("--product-backlog", type=pathlib.Path, default=DEFAULT_PRODUCT_BACKLOG) parser.add_argument("--access-plan", type=pathlib.Path, help="optional legacy access extraction plan to import") parser.add_argument("--web-todo", type=pathlib.Path, default=DEFAULT_WEB_TODO) parser.add_argument("--apply", action="store_true", help="create missing Gitea issues") args = parser.parse_args() try: load_dotenv(args.env_file) candidates = list(build_candidates(args)) candidates.sort(key=lambda item: (item.repo_key, item.source, item.line, item.title)) print(f"Prepared {len(candidates)} backlog issue candidate(s).") for repo_key, grouped in group_candidates(candidates).items(): print(f"{repo_key}: {len(grouped)} candidate(s)") token = require_token() if args.apply else os.environ.get("GITEA_TOKEN") if not token: print("Dry run without GITEA_TOKEN: duplicate comparison skipped.") print_preview(candidates) return 0 states = load_repo_states(token, sorted({candidate.repo_key for candidate in candidates})) missing = [candidate for candidate in candidates if not already_imported(candidate, states[candidate.repo_key])] skipped = len(candidates) - len(missing) print(f"Skipped {skipped} existing issue candidate(s).") print(f"Missing {len(missing)} issue candidate(s).") print_preview(missing) if not args.apply: print("Dry run only. Re-run with --apply to create missing issues.") return 0 created_by_repo: dict[str, list[str]] = {} for candidate in missing: state = states[candidate.repo_key] validate_labels(candidate, state) issue = state.client.request_json( "POST", repo_path(state.target.owner, state.target.repo, "/issues"), body={ "title": candidate.title, "body": candidate.body, "labels": [state.label_ids[label] for label in candidate.labels], }, ) number = issue.get("number") or issue.get("index") created_by_repo.setdefault(candidate.repo_key, []).append(f"#{number} {candidate.title}") state.fingerprints.add(candidate.fingerprint) state.titles.add(normalize_title(candidate.title)) print("Created issues:") for repo_key, titles in created_by_repo.items(): print(f"{repo_key}:") for title in titles: print(f" {title}") if not created_by_repo: print(" none") return 0 except GiteaError as exc: print(f"error: {exc}", file=sys.stderr) return 1 def build_candidates(args: argparse.Namespace) -> Iterable[Candidate]: if args.product_backlog.exists(): yield from parse_product_backlog(args.product_backlog) if args.access_plan and args.access_plan.exists(): yield from parse_access_plan(args.access_plan) if args.web_todo.exists(): yield from parse_simple_todo(args.web_todo) def parse_product_backlog(path: pathlib.Path) -> Iterable[Candidate]: headings: list[tuple[int, str]] = [] status = "" lines = path.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): line_number = index + 1 heading_match = re.match(r"^(#{2,4})\s+(.+?)\s*$", line) if heading_match: level = len(heading_match.group(1)) title = heading_match.group(2).strip() if level == 2 and title == "Completed Work Archive": break headings = [(h_level, h_title) for h_level, h_title in headings if h_level < level] headings.append((level, title)) status = "" continue status_match = re.match(r"^\*\*Status:\*\*\s*(.+?)\s*$", line) if status_match: status = status_match.group(1).strip() continue item_match = re.match(r"^\s*[-*]\s+\[\s\]\s+(.+?)\s*$", line) if not item_match: continue item = collect_continued_item(lines, index, item_match.group(1)) section = section_title(headings) repo_key, labels = classify_product_item(item, section) prefix = type_prefix(labels) title = format_title(prefix, item) source = str(path) body = body_for_item( fingerprint=make_fingerprint(source, line_number, repo_key, item), source=source, line=line_number, section=section, status=status, item=item, note="Imported from the consolidated GovOPlaN product backlog.", ) yield Candidate( repo_key=repo_key, title=title, body=body, labels=tuple(sorted(set(labels))), fingerprint=make_fingerprint(source, line_number, repo_key, item), source=source, line=line_number, ) def parse_access_plan(path: pathlib.Path) -> Iterable[Candidate]: text = path.read_text(encoding="utf-8") lines = text.splitlines() yield from access_stage_candidates(path, lines) yield from access_immediate_candidates(path, lines) yield from access_decision_candidates(path, lines) def access_stage_candidates(path: pathlib.Path, lines: list[str]) -> Iterable[Candidate]: index = 0 while index < len(lines): match = re.match(r"^### Stage ([1-7]):\s*(.+?)\s*$", lines[index]) if not match: index += 1 continue stage = match.group(1) name = match.group(2).strip() start_line = index + 1 index += 1 block: list[str] = [] while index < len(lines) and not re.match(r"^### Stage \d+:", lines[index]) and not re.match(r"^## ", lines[index]): block.append(lines[index]) index += 1 item = f"Access extraction Stage {stage}: {name}" fingerprint = make_fingerprint(str(path), start_line, "core", item) body = "\n".join( [ f"", "", "Imported from the GovOPlaN access extraction plan.", "", f"- Source: `{path}`", f"- Line: `{start_line}`", f"- Section: `Stage {stage}: {name}`", "", "Plan excerpt:", "", "```markdown", f"### Stage {stage}: {name}", *block, "```", ] ) yield Candidate( repo_key="core", title=format_title("[Task]", item), body=body, labels=labels("core", "type/task", "area/module-system", "module/access"), fingerprint=fingerprint, source=str(path), line=start_line, ) def access_immediate_candidates(path: pathlib.Path, lines: list[str]) -> Iterable[Candidate]: in_section = False for index, line in enumerate(lines): line_number = index + 1 if line == "## Immediate Backlog": in_section = True continue if in_section and line.startswith("## "): break if not in_section: continue item_match = re.match(r"^\s*[-*]\s+\[\s\]\s+(.+?)\s*$", line) if not item_match: continue item = collect_continued_item(lines, index, item_match.group(1)) title_item = access_title_item(item) fingerprint = make_fingerprint(str(path), line_number, "core", item) body = body_for_item( fingerprint=fingerprint, source=str(path), line=line_number, section="Immediate Backlog", status="open", item=item, note="Imported from the GovOPlaN access extraction plan immediate backlog.", ) yield Candidate( repo_key="core", title=format_title("[Task]", title_item), body=body, labels=labels("core", "type/task", "area/auth", "area/module-system", "module/access"), fingerprint=fingerprint, source=str(path), line=line_number, ) def access_decision_candidates(path: pathlib.Path, lines: list[str]) -> Iterable[Candidate]: in_section = False for index, line in enumerate(lines): line_number = index + 1 if line == "## Open Decisions": in_section = True continue if in_section and line.startswith("## "): break if not in_section: continue item_match = re.match(r"^\s*[-*]\s+(.+?)\s*$", line) if not item_match: continue item = collect_continued_item(lines, index, item_match.group(1)) fingerprint = make_fingerprint(str(path), line_number, "core", item) body = body_for_item( fingerprint=fingerprint, source=str(path), line=line_number, section="Open Decisions", status="decision needed", item=item, note="Imported from the GovOPlaN access extraction plan open decisions.", ) yield Candidate( repo_key="core", title=format_title("[Task]", f"Decide: {item}"), body=body, labels=labels("core", "type/task", "status/needs-info", "codex/needs-human", "area/auth", "module/access"), fingerprint=fingerprint, source=str(path), line=line_number, ) def parse_simple_todo(path: pathlib.Path) -> Iterable[Candidate]: lines = path.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): line_number = index + 1 item_match = re.match(r"^\s*[-*]\s+(.+?)\s*$", line) if not item_match: continue item = collect_continued_item(lines, index, item_match.group(1)) fingerprint = make_fingerprint(str(path), line_number, "web", item) yield Candidate( repo_key="web", title=format_title("[Task]", item), body=body_for_item( fingerprint=fingerprint, source=str(path), line=line_number, section="TODO", status="open", item=item, note="Imported from the GovOPlaN website TODO file.", ), labels=labels("web", "type/task", "area/marketing"), fingerprint=fingerprint, source=str(path), line=line_number, ) def classify_product_item(item: str, section: str) -> tuple[str, tuple[str, ...]]: text = f"{section} {item}".lower() repo_key = "core" area = "area/devex" issue_type = "type/task" if "dsar search/export/delete" in text: return "core", labels("core", "type/feature", "area/governance") if "fully stream zip uploads" in text: return "files", labels("files", "type/task", "area/api") if "installer packaging approach" in text: return "core", labels("core", "type/task", "area/devex") if "profile reselection/revalidation" in text: return "campaign", labels("campaign", "type/task", "area/api", "module/mail") if "module boundaries" in text: return "core", labels("core", issue_type, "area/module-system") if "external storage connectors" in text: repo_key = "files" area = "area/api" elif any(phrase in text for phrase in ("report, evidence", "recipient import", "campaign and attachment", "collaboration and advanced governance", "address book, templates")): repo_key = "campaign" area = "area/webui" if any(word in text for word in ("page", "table", "display", "ui", "wizard", "editor", "flow", "polish")) else "area/api" elif "controlled sending" in text: repo_key = "campaign" area = "area/api" elif "mail profiles" in text: repo_key = "mail" area = "area/api" if any(word in text for word in ("smtp", "imap", "mailbox", "mail profile", "deliverability", "bounce", "openpgp", "s/mime", "pop3", "jmap")): repo_key = "mail" area = "area/api" if any(word in text for word in ("seafile", "nextcloud", "webdav", "smb", "connector", "file rename", "managed storage", "live remote file")): repo_key = "files" area = "area/api" if "external storage connectors" not in text and any( word in text for word in ( "campaign", "recipient", "attachment", "zip", "evidence", "wizard", "send", "archive", "message preview", "address book", "carddav", "mailing lists", ) ): repo_key = "campaign" area = "area/webui" if any(word in text for word in ("page", "table", "display", "ui", "wizard", "editor", "flow", "polish")) else "area/api" if repo_key == "core" and any(word in text for word in ("session", "device", "auth", "rbac", "role", "tenant", "governance", "policy", "retention", "dsar", "audit", "encryption", "ldap", "oidc", "saml")): repo_key = "core" area = "area/auth" if any(word in text for word in ("session", "auth", "ldap", "oidc", "saml")) else "area/governance" if repo_key == "core" and any(word in text for word in ("module", "manifest", "module boundaries", "installable", "activatable", "deactivatable", "uninstallable")): repo_key = "core" area = "area/module-system" if repo_key == "core" and any(word in text for word in ("release", "version metadata", "lockfile", "package", "tagging")): repo_key = "core" area = "area/release" if repo_key == "core" and not any( phrase in text for phrase in ( "external storage connectors", "controlled sending", "report, evidence", "recipient import", "campaign and attachment", ) ) and any(word in text for word in ("postgres", "redis", "celery", "worker", "deployment", "installer", "backup", "monitoring", "configuration", "dev profile", ".env")): repo_key = "core" area = "area/devex" if any(word in text for word in ("documentation", "handbook", "docs", "runbook")): area = "area/docs" if "ui lists often start at index 2" in text: issue_type = "type/bug" area = "area/webui" repo_key = "core" if any(word in text for word in ("add ", "implement ", "build ", "provide ", "allow ", "make ", "extend ", "replace ")): issue_type = "type/feature" if any(word in text for word in ("cleanup", "remove legacy", "fragile", "deferred cleanup")): issue_type = "type/debt" return repo_key, labels(repo_key, issue_type, area) def collect_continued_item(lines: list[str], index: int, initial: str) -> str: parts = [initial.strip()] next_index = index + 1 while next_index < len(lines): line = lines[next_index] if not line.startswith((" ", "\t")): break stripped = line.strip() if not stripped: break if re.match(r"^[-*]\s+", stripped) or stripped.startswith("#"): break parts.append(stripped) next_index += 1 return " ".join(parts) def labels(repo_key: str, *extra: str) -> tuple[str, ...]: base = {"status/triage", "source/backlog-import", "codex/ready", MODULE_LABELS[repo_key]} base.update(extra) if "status/needs-info" in base: base.discard("status/triage") return tuple(sorted(base)) def type_prefix(label_set: Iterable[str]) -> str: labels_set = set(label_set) if "type/bug" in labels_set: return "[Bug]" if "type/feature" in labels_set: return "[Feature]" if "type/debt" in labels_set: return "[Debt]" if "type/docs" in labels_set: return "[Docs]" return "[Task]" def format_title(prefix: str, item: str) -> str: clean = re.sub(r"\s+", " ", item).strip().rstrip(".") title = f"{prefix} {clean}" if len(title) <= 180: return title return f"{title[:177].rstrip()}..." def access_title_item(item: str) -> str: lowered = item.lower() if "compatibility wrappers" in lowered: return "Wire core auth compatibility through access capabilities" return item def body_for_item( *, fingerprint: str, source: str, line: int, section: str, status: str, item: str, note: str, ) -> str: lines = [ f"", "", note, "", f"- Source: `{source}`", f"- Line: `{line}`", f"- Section: `{section}`", ] if status: lines.append(f"- Source status: `{status}`") lines.extend( [ "", "Imported backlog item:", "", "```markdown", f"- [ ] {item}", "```", ] ) return "\n".join(lines) def section_title(headings: list[tuple[int, str]]) -> str: return " > ".join(title for _, title in headings) def make_fingerprint(source: str, line: int, repo_key: str, item: str) -> str: stable = "\n".join([source, str(line), repo_key, item]) return hashlib.sha256(stable.encode("utf-8")).hexdigest()[:24] def group_candidates(candidates: list[Candidate]) -> dict[str, list[Candidate]]: grouped: dict[str, list[Candidate]] = {} for candidate in candidates: grouped.setdefault(candidate.repo_key, []).append(candidate) return grouped def load_repo_states(token: str, repo_keys: list[str]) -> dict[str, RepoState]: states: dict[str, RepoState] = {} for repo_key in repo_keys: root = repo_root(REPO_ROOTS[repo_key]) target = infer_target(root) client = GiteaClient(target, token) label_ids = label_ids_by_name(client, target.owner, target.repo) issues = client.paginate(repo_path(target.owner, target.repo, "/issues"), query={"state": "all"}, limit=100) fingerprints: set[str] = set() titles: set[str] = set() for issue in issues: body = str(issue.get("body") or "") if issue.get("state") == "closed" and "Moved to `" in body: continue titles.add(normalize_title(str(issue.get("title") or ""))) fingerprints.update(re.findall(r"codex-backlog-fingerprint:([0-9a-f]{24})", body)) states[repo_key] = RepoState(target=target, client=client, label_ids=label_ids, fingerprints=fingerprints, titles=titles) return states def already_imported(candidate: Candidate, state: RepoState) -> bool: return candidate.fingerprint in state.fingerprints or normalize_title(candidate.title) in state.titles def normalize_title(title: str) -> str: return re.sub(r"[^a-z0-9]+", " ", title.lower()).strip() def validate_labels(candidate: Candidate, state: RepoState) -> None: missing = sorted(label for label in candidate.labels if label not in state.label_ids) if missing: joined = ", ".join(missing) raise GiteaError(f"{candidate.repo_key} is missing labels for {candidate.title!r}: {joined}") def print_preview(candidates: list[Candidate], limit: int = 160) -> None: for repo_key, grouped in group_candidates(candidates).items(): print(f"{repo_key}:") for candidate in grouped[:limit]: print(f" {candidate.title}") if len(grouped) > limit: print(f" ... {len(grouped) - limit} more") if __name__ == "__main__": raise SystemExit(main())