From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from ffbox0-bg.ffmpeg.org (ffbox0-bg.ffmpeg.org [79.124.17.100]) by master.gitmailbox.com (Postfix) with ESMTPS id 937E54B835 for ; Sun, 28 Sep 2025 09:19:21 +0000 (UTC) Authentication-Results: ffbox; dkim=fail (body hash mismatch (got b'RR7lkwhkaWiFkaWcEba/USFMIhl1X8iTCvNiWDK22Us=', expected b'kvGBMiysg5T2jNu43GAKakAyPD91eTAmaMF0A2dGoYY=')) header.d=niedermayer.cc header.a=rsa-sha256 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=ffmpeg.org; i=@ffmpeg.org; q=dns/txt; s=mail; t=1759051142; h=date : to : message-id : references : mime-version : in-reply-to : reply-to : subject : list-id : list-archive : list-archive : list-help : list-owner : list-post : list-subscribe : list-unsubscribe : from : cc : content-type : from; bh=CkPUXImOI60Mo1yEDh9pUO0QuvBtiADxmjxuAxdkWQM=; b=sxRVUvOxlaM/1VqB9aCdt89cre/QGZuvUL/qqSsmUc6IMhnhwzh53dw8TGqpnsspddVuG tTcdG/WTpAoPahmuIwVZ0lpprxc4KCrHW9U3WZntLnQhNkxWu1ia6jPSW5Uz/lpN9ODGpX/ 3Mcq+vQK0C8v2rtr42wd9qUatP7yx524Grz6JZ8O5ZG8ki92ZTiXLHZzUbjzaso1br480kV N68TyAaqJ7ZHoL8EoQsPtIKWl+CpDuGp49E90X/SJjfj1/reQPpAKQi//DO4ODdsSx8ig29 +Mj3DCUoJYCSL2LErCj9U7B4oIZepvFCmYdvK+dXa5vodqDNxsDuPAWVcvfw== Received: from [172.19.0.4] (unknown [172.19.0.4]) by ffbox0-bg.ffmpeg.org (Postfix) with ESMTP id BF8EB68ED8A; Sun, 28 Sep 2025 12:19:02 +0300 (EEST) ARC-Seal: i=1; cv=none; a=rsa-sha256; d=ffmpeg.org; s=arc; t=1759051138; b=e2qWXx4L5KFpOQPP6ERXO8Kj3/94rwYUYkXXNJL+c8lVwkwfvPK09ZbSApyU2YxY6/gdN buf0xYsmkeVn9ZvzgZmDCbPACOag1ZXmIYhpzyj+lxee/1tww7AgIlOokCWvQjYU4TEAZFW FIL0fEB/lwyRYMJYa/qpPvzZI3E/4ztdCuVmy7PDi8Ah4/zAi9YICn//NJ9AeVqJjiP244X 6kbOH1At68v2Z8CM9FESPB9GGhrehx9AW5bZPe0fuujivGKecd29wqODNAfETOAbndte0SG pludGdzfRH0ajXWnBQZCPWH9sXoXGPuxn0103XDZrx1ZTGFrxuevcvCFHqbg== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=ffmpeg.org; s=arc; t=1759051138; h=from : sender : reply-to : subject : date : message-id : to : cc : mime-version : content-type : content-transfer-encoding : content-id : content-description : resent-date : resent-from : resent-sender : resent-to : resent-cc : resent-message-id : in-reply-to : references : list-id : list-help : list-unsubscribe : list-subscribe : list-post : list-owner : list-archive; bh=RR7lkwhkaWiFkaWcEba/USFMIhl1X8iTCvNiWDK22Us=; b=smNqIOX6kEn47kV9Sfzaa4voAVn9TADRZmjQ9UQ4D9pfqpvgrAZ7tJrfEXrLhI24Vw4JU JbQQPRWGLaoJUpZKx6bSmbVP47p0ssP9WJNh6RrtYiILNqQpnSQY1ZHBcejB+6+efLyFCTi 4dAUlHsdiQsIhPLKqGXZlXMZ68THv4L/jly5ItIZhc7PE8Mdl22GKPduS/hDwD4Hsn7gcdZ drPTvgovU8BFz3t3qLoUgh/yuQzfpU3jWoHRpMC+f8BZ8+/Ya/TNo9NWM2X0gsh4GCRfwZO i9TpTpPB3tJMKU5tPe+Uz4I+6Z2SOdp6vvvmgCv2t2Ow7hIJZla2DdZkj/mw== ARC-Authentication-Results: i=1; ffmpeg.org; dkim=pass header.d=niedermayer.cc; arc=none; dmarc=none Authentication-Results: ffmpeg.org; dkim=pass header.d=niedermayer.cc; arc=none (Message is not ARC signed); dmarc=none Received: from relay1-d.mail.gandi.net (relay1-d.mail.gandi.net [217.70.183.193]) by ffbox0-bg.ffmpeg.org (Postfix) with ESMTPS id B419368ED4C for ; Sun, 28 Sep 2025 12:18:44 +0300 (EEST) Received: by mail.gandi.net (Postfix) with ESMTPSA id C6CB543282 for ; Sun, 28 Sep 2025 09:18:43 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=niedermayer.cc; s=gm1; t=1759051124; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:mime-version:mime-version:content-type:content-type: in-reply-to:in-reply-to:references:references; bh=kvGBMiysg5T2jNu43GAKakAyPD91eTAmaMF0A2dGoYY=; b=D5GwpPoyDCeZ9/rfm+LXWYwZ9Fq3MoWHlJlA4qg5bbBok3C9Lj6GYhDmutv7f6obSb0nZK cf/azTLr9j8Mg45e9rlzOfJAWHyYt5jjD9MIjLGw1fRqQVAxmIHyCtY3oXjKB+cqYzct09 sTBPo5O5Fau6AtVIbk0sbjbs6amvmmRQqFien32lD4qkZsH+BkXgU4CtcIeoKEzn9sFO5h 3GikULuwLSndUfIp9l7y6E+AdmbJlgEmkWAeHIxdr9g/qITd+C3/mZwFtQe4c8ErQ8qI16 OSW19PAQqmhmm6K/H3UK7Dd2LJRl/t4hL7fqzpZKPXwFy1F+6h+3TpxuDO0EFA== Date: Sun, 28 Sep 2025 11:18:40 +0200 To: FFmpeg development discussions and patches Message-ID: <20250928091840.GC29660@pb2> References: <20250914212317.GG29660@pb2> <4679850.LvFx2qVVIh@basile.remlab.net> MIME-Version: 1.0 In-Reply-To: <4679850.LvFx2qVVIh@basile.remlab.net> X-GND-State: clean X-GND-Score: -70 X-GND-Cause: gggruggvucftvghtrhhoucdtuddrgeeffedrtdeggdejgeejhecutefuodetggdotefrodftvfcurfhrohhfihhlvgemucfitefpfffkpdcuggftfghnshhusghstghrihgsvgenuceurghilhhouhhtmecufedtudenucesvcftvggtihhpihgvnhhtshculddquddttddmnegfrhhlucfvnfffucdlfedtmdenucfjughrpeffhffvuffkfhggtggujgesghdtreertddtudenucfhrhhomhepofhitghhrggvlhcupfhivgguvghrmhgrhigvrhcuoehmihgthhgrvghlsehnihgvuggvrhhmrgihvghrrdgttgeqnecuggftrfgrthhtvghrnhepveeufeegfeduteeugffgiedvteetkefgudektdejueffveehgeeludelffdugfehnecuffhomhgrihhnpehffhhmphgvghdrohhrghenucfkphepgedurdeiiedrieehrddujeeinecuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehinhgvthepgedurdeiiedrieehrddujeeipdhhvghloheplhhotggrlhhhohhsthdpmhgrihhlfhhrohhmpehmihgthhgrvghlsehnihgvuggvrhhmrgihvghrrdgttgdpnhgspghrtghpthhtohepuddprhgtphhtthhopehffhhmphgvghdquggvvhgvlhesfhhfmhhpvghgrdhorhhg X-GND-Sasl: michael@niedermayer.cc Message-ID-Hash: XV6NKALMBO4RYIUUPYCAGZZ5DH2NDUAV X-Message-ID-Hash: XV6NKALMBO4RYIUUPYCAGZZ5DH2NDUAV X-MailFrom: SRS0=bo2q=4H=niedermayer.cc=michael@ffmpeg.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; header-match-ffmpeg-devel.ffmpeg.org-0; header-match-ffmpeg-devel.ffmpeg.org-1; header-match-ffmpeg-devel.ffmpeg.org-2; header-match-ffmpeg-devel.ffmpeg.org-3; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list Reply-To: FFmpeg development discussions and patches Subject: [FFmpeg-devel] Re: [RFC] Issue tracker List-Id: FFmpeg development discussions and patches Archived-At: Archived-At: List-Archive: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Michael Niedermayer via ffmpeg-devel Cc: Michael Niedermayer Content-Type: multipart/mixed; boundary="===============5667018540504755362==" Archived-At: List-Archive: List-Post: --===============5667018540504755362== Content-Type: multipart/signed; micalg=pgp-sha512; protocol="application/pgp-signature"; boundary="wx/oEoyJWlAciYRN" Content-Disposition: inline --wx/oEoyJWlAciYRN Content-Type: multipart/mixed; boundary="h0DN6r1M1pWsn6Gj" Content-Disposition: inline --h0DN6r1M1pWsn6Gj Content-Type: text/plain; charset=iso-8859-1 Content-Disposition: inline Content-Transfer-Encoding: quoted-printable Hi Remi On Sun, Sep 28, 2025 at 10:54:14AM +0300, R=E9mi Denis-Courmont via ffmpeg-= devel wrote: > Le maanantaina 15. syyskuuta 2025, 0.23.17 It=E4-Euroopan kes=E4aika Mich= ael=20 > Niedermayer via ffmpeg-devel a =E9crit : [...] > > we can have custom ticket states and custom > > workflow on tickets. >=20 > We can have custom labels for tickets and MRs. >=20 > However before FFmpeg can consider migrating from Trac to Forgejo, we hav= e to=20 > consider the (anti)spam implications. I suppose there is not much spam ye= t=20 > because Gitlab is vastly more popular, but that's probably only a matter = of=20 > time. >=20 > If code.ffmpeg.org needs to be locked down with manual approval like=20 > FreeDesktop and VideoLAN's Gitlab instances, then migrating the bug repor= ting=20 > there may not be such a great idea. Unless we specifically want to restri= ct bug=20 > reporting to trusted community members. I agree, anti-spam is a big deal. trac has quite extensive anti spam capabi= lities. >=20 > Otherwise, I don't really see any benefit to Trac. Forgejo would allow us= to=20 > link issues and MRs, and presumably also automatically close issues. We j= ust=20 > need to agree whether to migrate existing issues or not (and if we do, fi= nd=20 > someone to do the SQL wizardry). AI generated migration script attacht. This is untested and is very unlikely to work as is, but parts of it could be usefull i guess or it could serve as a starting point. thx [...] --=20 Michael GnuPG fingerprint: 9FF2128B147EF6730BADF133611EC787040B0FAB During times of universal deceit, telling the truth becomes a revolutionary act. -- George Orwell --h0DN6r1M1pWsn6Gj Content-Type: text/x-python; charset=utf-8 Content-Disposition: attachment; filename="trac_to_forgejo_migrator.py" Content-Transfer-Encoding: quoted-printable #!/usr/bin/env python3 """ Trac =E2=86=92 Forgejo issue migrator (tickets, comments, labels, milestone= s, attachments-as-links) =E2=9C=85 Features - Migrates Trac tickets (title, body, metadata) to Forgejo issues - Preserves reporter/owner via user mapping - Converts common Trac wiki markup =E2=86=92 Markdown (lightweight, optiona= l) - Replays comments with original author & timestamp banners - Recreates labels (component, type, priority, status/resolution) if missing - Recreates milestones (by name) if missing and assigns to issues - Closes issues that were closed in Trac and adds a resolution label - Adds links for attachments (keeps them downloadable from your Trac site) - Idempotent: keeps a trac_id =E2=86=94 forgejo_issue index mapping file - Dry=E2=80=91run mode =E2=9A=99=EF=B8=8F Requirements - Python 3.8+ - pip install requests pyyaml =F0=9F=A7=B0 Usage 1) Create a config YAML (example below) as config.yaml 2) Run: =20 python3 trac_to_forgejo_migrator.py --config config.yaml --dry-run # p= review =20 python3 trac_to_forgejo_migrator.py --config config.yaml # = migrate Example config.yaml ------------------- forgejo: base_url: "https://forgejo.example.org" # no trailing slash owner: "your-org" repo: "your-repo" token: "" # needs repo:write, admin:repo_= hook for labels/milestones rate_limit_sleep_sec: 0.2 # politeness pause between API= calls trac: # SQLite DB path of your Trac instance (read-only). For MySQL/PostgreSQL,= see notes below. sqlite_path: "/var/lib/trac/project/db/trac.db" # Base URL of your Trac site (used to link back to original ticket & atta= chments) base_url: "https://trac.example.org" migrate: # restrict to a subset (omit or set to [] for all) include_ticket_ids: [] exclude_ticket_ids: [] # when True, prefix titles with "[Trac #ID]" for easy cross-reference prefix_trac_id_in_title: true # If a Forgejo issue already exists for a Trac id (mapping file), it will= be skipped dry_run: false mapping_files: # JSON file created/updated by this script to remember Trac=E2=86=92Forge= jo mapping id_map_path: "./trac_forgejo_id_map.json" users: # Map Trac usernames =E2=86=92 Forgejo usernames (for assignees); reporte= r shown in body regardless # Unmapped users are left as plain text. "michael": "michael-niedermayer" "alice": "alice" labels: # Optional color overrides (6-hex without #). Missing labels get a stable= hash color. colors: "component/avcodec": "0366d6" "resolution/fixed": "2ea043" markdown: # If you have pandoc installed and want better conversion, set to path li= ke "/usr/bin/pandoc" pandoc_path: null # Or use the built-in lightweight converter (recommended default) use_lightweight_converter: true notes: # For MySQL/PostgreSQL backends, you can export SQLite first (or extend t= he code to use PyMySQL/psycopg2) # Attachments: this script links to Trac's attachment URLs; to *upload* i= nto Forgejo later, see TODOs in code. """ =66rom __future__ import annotations import argparse import dataclasses import datetime as dt import hashlib import json import os import re import sqlite3 import subprocess import sys import time =66rom typing import Any, Dict, Iterable, List, Optional, Tuple import requests try: import yaml # type: ignore except Exception: # pragma: no cover print("ERROR: pyyaml is required. pip install pyyaml", file=3Dsys.stder= r) sys.exit(2) ISO =3D "%Y-%m-%d %H:%M:%S%z" # ---------------------------- Utilities ---------------------------- def ts_from_trac(microseconds_since_epoch: int) -> dt.datetime: """Trac stores timestamps as microseconds since epoch.""" # Trac uses microseconds epoch (int). Interpret as UTC. sec =3D microseconds_since_epoch / 1_000_000.0 return dt.datetime.fromtimestamp(sec, tz=3Ddt.timezone.utc) def banner(author: str, when: dt.datetime) -> str: return f"_Originally posted by **{author or 'unknown'}** on {when.strft= ime('%Y-%m-%d %H:%M:%S %Z')}_\n\n" def stable_color(name: str) -> str: """Derive a 6-hex color from a name (no leading '#').""" h =3D hashlib.sha1(name.encode("utf-8")).hexdigest() return h[:6] # ----------------------- Lightweight Trac=E2=86=92MD ---------------------= -- TRIPLE_SINGLE =3D re.compile(r"'''(.*?)'''", re.DOTALL) DOUBLE_SINGLE =3D re.compile(r"''(.*?)''", re.DOTALL) HEADING =3D re.compile(r"^(=3D+)[ \t]*(.*?)[ \t]*=3D+$", re.MULTILINE) WIKI_LINK =3D re.compile(r"\[(?:wiki:)?([^\] ]+)(?:\s+([^\]]+))?\]") HTTP_LINK =3D re.compile(r"\[(https?://[^\] ]+)(?:\s+([^\]]+))?\]") BR =3D re.compile(r"\[\[BR\]\]") def trac_to_markdown(text: str) -> str: """Very small subset of Trac wiki =E2=86=92 Markdown conversions. Keeps unknown markup as-is to avoid data loss. """ if not text: return "" out =3D text # line breaks out =3D BR.sub(" \n", out) # bold/italic out =3D TRIPLE_SINGLE.sub(r"**\1**", out) out =3D DOUBLE_SINGLE.sub(r"*\1*", out) # headings: '=3D=3D Title =3D=3D' =E2=86=92 '## Title' def _h(m: re.Match) -> str: level =3D len(m.group(1)) level =3D min(max(level, 1), 6) return f"{'#' * level} {m.group(2).strip()}" out =3D HEADING.sub(_h, out) # external links [http://url label] out =3D HTTP_LINK.sub(lambda m: f"[{m.group(2) or m.group(1)}]({m.group= (1)})", out) # wiki-ish links [wiki:Page Label] or [Page Label] =E2=86=92 [Label](Pa= ge) def _wiki(m: re.Match) -> str: target =3D m.group(1) label =3D m.group(2) or target # naive: leave as code-ish if it looks like CamelCase wiki page return f"[{label}]({target})" out =3D WIKI_LINK.sub(_wiki, out) return out def maybe_pandoc(text: str, pandoc_path: Optional[str]) -> str: if not text: return "" if not pandoc_path: return text try: p =3D subprocess.run( [pandoc_path, "-f", "trac", "-t", "gfm"], input=3Dtext.encode("utf-8"), stdout=3Dsubprocess.PIPE, stderr=3Dsubprocess.PIPE, check=3DTrue, ) return p.stdout.decode("utf-8") except Exception as e: # fall back silently return text # ----------------------------- Trac DB ----------------------------- @dataclasses.dataclass class TracTicket: id: int type: str time: dt.datetime changetime: dt.datetime component: Optional[str] severity: Optional[str] priority: Optional[str] owner: Optional[str] reporter: Optional[str] cc: Optional[str] version: Optional[str] milestone: Optional[str] status: str resolution: Optional[str] summary: str description: str @dataclasses.dataclass class TracChange: ticket: int time: dt.datetime author: Optional[str] field: str oldvalue: Optional[str] newvalue: Optional[str] @dataclasses.dataclass class TracAttachment: ticket: int filename: str author: Optional[str] time: dt.datetime description: Optional[str] size: Optional[int] class Trac: def __init__(self, sqlite_path: str): self.sqlite_path =3D sqlite_path self.conn =3D sqlite3.connect(sqlite_path) self.conn.row_factory =3D sqlite3.Row def fetch_ticket_ids(self) -> List[int]: cur =3D self.conn.execute("SELECT id FROM ticket ORDER BY id ASC") return [row[0] for row in cur.fetchall()] def fetch_ticket(self, tid: int) -> TracTicket: row =3D self.conn.execute( "SELECT id, type, time, changetime, component, severity, priori= ty, owner, reporter, cc, version, milestone, status, resolution, summary, d= escription FROM ticket WHERE id=3D?", (tid,), ).fetchone() if not row: raise KeyError(f"Ticket {tid} not found") return TracTicket( id=3Drow["id"], type=3Drow["type"] or "", time=3Dts_from_trac(row["time"]), changetime=3Dts_from_trac(row["changetime"]), component=3Drow["component"], severity=3Drow["severity"], priority=3Drow["priority"], owner=3Drow["owner"], reporter=3Drow["reporter"], cc=3Drow["cc"], version=3Drow["version"], milestone=3Drow["milestone"], status=3Drow["status"] or "new", resolution=3Drow["resolution"], summary=3Drow["summary"] or "", description=3Drow["description"] or "", ) def fetch_changes(self, tid: int) -> List[TracChange]: cur =3D self.conn.execute( "SELECT ticket, time, author, field, oldvalue, newvalue FROM ti= cket_change WHERE ticket=3D? ORDER BY time ASC, rowid ASC", (tid,), ) out: List[TracChange] =3D [] for r in cur.fetchall(): out.append( TracChange( ticket=3Dr["ticket"], time=3Dts_from_trac(r["time"]), author=3Dr["author"], field=3Dr["field"], oldvalue=3Dr["oldvalue"], newvalue=3Dr["newvalue"], ) ) return out def fetch_attachments(self, tid: int) -> List[TracAttachment]: # Trac 'attachment' table schema: type, id, filename, size, time, d= escription, author, ipnr cur =3D self.conn.execute( "SELECT filename, size, time, description, author FROM attachme= nt WHERE type=3D'ticket' AND id=3D? ORDER BY time ASC", (str(tid),), ) out: List[TracAttachment] =3D [] for r in cur.fetchall(): out.append( TracAttachment( ticket=3Dtid, filename=3Dr["filename"], author=3Dr["author"], time=3Dts_from_trac(r["time"]), description=3Dr["description"], size=3Dr["size"], ) ) return out # --------------------------- Forgejo Client --------------------------- class Forgejo: def __init__(self, base_url: str, owner: str, repo: str, token: str, ra= te_sleep: float =3D 0.0): self.base =3D base_url.rstrip('/') self.owner =3D owner self.repo =3D repo self.session =3D requests.Session() self.session.headers.update({ 'Authorization': f'token {token}', 'Accept': 'application/json', }) self.rate_sleep =3D rate_sleep # ---- helpers ---- def _url(self, path: str) -> str: return f"{self.base}/api/v1/repos/{self.owner}/{self.repo}{path}" def _req(self, method: str, url: str, **kw) -> requests.Response: r =3D self.session.request(method, url, timeout=3D60, **kw) if self.rate_sleep: time.sleep(self.rate_sleep) if r.status_code >=3D 400: raise RuntimeError(f"HTTP {r.status_code} for {url}: {r.text[:4= 00]}") return r # ---- labels ---- def list_labels(self) -> Dict[str, Dict[str, Any]]: url =3D self._url("/labels") r =3D self._req('GET', url) items =3D r.json() return {it['name']: it for it in items} def ensure_label(self, name: str, color: Optional[str] =3D None, descri= ption: str =3D "") -> int: existing =3D self.list_labels() if name in existing: return existing[name]['id'] url =3D self._url("/labels") payload =3D { 'name': name, 'color': f"#{(color or stable_color(name)).lstrip('#')}", 'description': description, } r =3D self._req('POST', url, json=3Dpayload) return r.json()['id'] # ---- milestones ---- def list_milestones(self) -> Dict[str, Dict[str, Any]]: url =3D self._url("/milestones") r =3D self._req('GET', url) items =3D r.json() return {it['title']: it for it in items} def ensure_milestone(self, title: str) -> Optional[int]: if not title: return None existing =3D self.list_milestones() if title in existing: return existing[title]['id'] url =3D self._url("/milestones") r =3D self._req('POST', url, json=3D{'title': title}) return r.json()['id'] # ---- issues ---- def create_issue(self, title: str, body: str, labels: List[str], assign= ees: List[str], milestone_id: Optional[int]) -> Dict[str, Any]: # Ensure labels exist and collect ids label_ids: List[int] =3D [] for ln in labels: if not ln: continue lid =3D self.ensure_label(ln) label_ids.append(lid) payload: Dict[str, Any] =3D { 'title': title, 'body': body, 'assignees': assignees or [], 'labels': label_ids, } if milestone_id: payload['milestone'] =3D milestone_id url =3D self._url('/issues') r =3D self._req('POST', url, json=3Dpayload) return r.json() def close_issue(self, index: int) -> None: url =3D self._url(f'/issues/{index}') self._req('PATCH', url, json=3D{'state': 'closed'}) def comment(self, index: int, body: str) -> Dict[str, Any]: url =3D self._url(f'/issues/{index}/comments') r =3D self._req('POST', url, json=3D{'body': body}) return r.json() # ----------------------------- Migration ----------------------------- @dataclasses.dataclass class Config: forgejo_base: str forgejo_owner: str forgejo_repo: str forgejo_token: str rate_sleep: float trac_sqlite: str trac_base_url: str include_ids: List[int] exclude_ids: List[int] prefix_trac_id_in_title: bool id_map_path: str user_map: Dict[str, str] label_colors: Dict[str, str] pandoc_path: Optional[str] use_light_conv: bool dry_run: bool @staticmethod def from_yaml(d: Dict[str, Any]) -> 'Config': forgejo =3D d.get('forgejo', {}) trac =3D d.get('trac', {}) migrate =3D d.get('migrate', {}) mapping_files =3D d.get('mapping_files', {}) users =3D d.get('users', {}) labels =3D d.get('labels', {}) markdown =3D d.get('markdown', {}) return Config( forgejo_base=3Dforgejo['base_url'], forgejo_owner=3Dforgejo['owner'], forgejo_repo=3Dforgejo['repo'], forgejo_token=3Dforgejo['token'], rate_sleep=3Dfloat(forgejo.get('rate_limit_sleep_sec', 0) or 0), trac_sqlite=3Dtrac['sqlite_path'], trac_base_url=3Dtrac['base_url'].rstrip('/'), include_ids=3Dlist(migrate.get('include_ticket_ids') or []), exclude_ids=3Dlist(migrate.get('exclude_ticket_ids') or []), prefix_trac_id_in_title=3Dbool(migrate.get('prefix_trac_id_in_t= itle', True)), id_map_path=3Dmapping_files.get('id_map_path', './trac_forgejo_= id_map.json'), user_map=3D{str(k): str(v) for k, v in (users or {}).items()}, label_colors=3Dlabels.get('colors', {}), pandoc_path=3Dmarkdown.get('pandoc_path'), use_light_conv=3Dbool(markdown.get('use_lightweight_converter',= True)), dry_run=3Dbool(migrate.get('dry_run', False)), ) class Migrator: def __init__(self, cfg: Config): self.cfg =3D cfg self.trac =3D Trac(cfg.trac_sqlite) self.forge =3D Forgejo(cfg.forgejo_base, cfg.forgejo_owner, cfg.for= gejo_repo, cfg.forgejo_token, rate_sleep=3Dcfg.rate_sleep) self.id_map =3D self._load_id_map(cfg.id_map_path) # ---------- mapping persistence ---------- def _load_id_map(self, path: str) -> Dict[str, Any]: if os.path.exists(path): with open(path, 'r', encoding=3D'utf-8') as f: return json.load(f) return {} def _save_id_map(self) -> None: tmp =3D self.cfg.id_map_path + ".tmp" with open(tmp, 'w', encoding=3D'utf-8') as f: json.dump(self.id_map, f, indent=3D2, ensure_ascii=3DFalse) os.replace(tmp, self.cfg.id_map_path) def _map_set(self, trac_id: int, issue_index: int) -> None: self.id_map[str(trac_id)] =3D {'issue_index': issue_index} self._save_id_map() def _map_get(self, trac_id: int) -> Optional[int]: entry =3D self.id_map.get(str(trac_id)) if entry: return int(entry['issue_index']) return None # ---------- conversion helpers ---------- def _convert_body(self, t: TracTicket) -> str: header =3D [ f"**Trac ticket #{t.id}**", f"Reporter: `{t.reporter or ''}`", f"Owner: `{t.owner or ''}`", f"Created: {t.time.strftime('%Y-%m-%d %H:%M:%S %Z')}", f"Last change: {t.changetime.strftime('%Y-%m-%d %H:%M:%S %Z')}", f"Status: {t.status}" + (f" ({t.resolution})" if t.resolution else ""), f"Original: {self.cfg.trac_base_url}/ticket/{t.id}", "", "---", "", ] body_raw =3D t.description or "" if self.cfg.pandoc_path: body_md =3D maybe_pandoc(body_raw, self.cfg.pandoc_path) elif self.cfg.use_light_conv: body_md =3D trac_to_markdown(body_raw) else: body_md =3D body_raw return "\n".join(header) + body_md def _labels_for(self, t: TracTicket) -> List[str]: labs: List[str] =3D [] if t.component: labs.append(f"component/{t.component}") if t.type: labs.append(f"type/{t.type}") if t.priority: labs.append(f"priority/{t.priority}") if t.severity: labs.append(f"severity/{t.severity}") if t.version: labs.append(f"version/{t.version}") for kw in (t.keywords or '').replace(',', ' ').split(): labs.append(f"keyword/{kw}") # status/resolution as labels (state handled separately) labs.append(f"status/{t.status}") if t.resolution: labs.append(f"resolution/{t.resolution}") return labs def _ensure_labels(self, labels: Iterable[str]) -> None: for ln in labels: if not ln: continue color =3D self.cfg.label_colors.get(ln) desc =3D "Imported from Trac" if self.cfg.dry_run: print(f"DRY-RUN: ensure label {ln} ({color or stable_color(= ln)})") else: self.forge.ensure_label(ln, color=3Dcolor, description=3Dde= sc) def _ensure_milestone(self, t: TracTicket) -> Optional[int]: if not t.milestone: return None if self.cfg.dry_run: print(f"DRY-RUN: ensure milestone {t.milestone}") return None return self.forge.ensure_milestone(t.milestone) def _assignees(self, t: TracTicket) -> List[str]: owner =3D (t.owner or '').strip() if not owner: return [] mapped =3D self.cfg.user_map.get(owner) return [mapped] if mapped else [] # ---------- comments & attachments ---------- def _emit_comments(self, idx: int, t: TracTicket, changes: List[TracCha= nge]) -> None: # Only 'comment' field entries produce issue comments; other change= s are summarized. # We'll also emit non-empty status/owner changes to preserve histor= y. for ch in changes: if ch.field =3D=3D 'comment' and (ch.newvalue or '').strip(): text =3D ch.newvalue or '' if self.cfg.pandoc_path: text_md =3D maybe_pandoc(text, self.cfg.pandoc_path) elif self.cfg.use_light_conv: text_md =3D trac_to_markdown(text) else: text_md =3D text body =3D banner(ch.author or 'unknown', ch.time) + text_md if self.cfg.dry_run: print(f"DRY-RUN: add comment to #{idx}: {body[:80]!r}..= =2E") else: self.forge.comment(idx, body) elif ch.field in {"status", "owner", "milestone", "priority", "= component", "resolution"}: # summarize as small audit trail comment oldv =3D ch.oldvalue or "" newv =3D ch.newvalue or "" if oldv =3D=3D newv: continue body =3D banner(ch.author or 'unknown', ch.time) + f"**Chan= ge**: `{ch.field}` =E2=86=92 `{newv}` (was `{oldv}`)" if self.cfg.dry_run: print(f"DRY-RUN: add audit comment to #{idx}: {body[:80= ]!r}...") else: self.forge.comment(idx, body) def _emit_attachments(self, idx: int, t: TracTicket, atts: List[TracAtt= achment]) -> None: if not atts: return for a in atts: url =3D f"{self.cfg.trac_base_url}/attachment/ticket/{t.id}/{a.= filename}" meta =3D f"Attachment: **{a.filename}** ({a.size or '?'} bytes)= =E2=80=94 {url}" body =3D banner(a.author or 'unknown', a.time) + meta if self.cfg.dry_run: print(f"DRY-RUN: add attachment note to #{idx}: {body}") else: self.forge.comment(idx, body) # ---------- main unit of work ---------- def migrate_one(self, tid: int) -> None: if self.cfg.exclude_ids and tid in self.cfg.exclude_ids: print(f"Skip ticket {tid} (excluded)") return mapped =3D self._map_get(tid) if mapped: print(f"Ticket {tid} already migrated as issue #{mapped}") return t =3D self.trac.fetch_ticket(tid) labels =3D self._labels_for(t) self._ensure_labels(labels) ms_id =3D self._ensure_milestone(t) title =3D t.summary if self.cfg.prefix_trac_id_in_title: title =3D f"[Trac #{t.id}] {title}" body =3D self._convert_body(t) assignees =3D self._assignees(t) if self.cfg.dry_run: print(f"DRY-RUN: would create issue for Trac {tid}: title=3D{ti= tle!r}, labels=3D{labels}, assignees=3D{assignees}, milestone=3D{ms_id}") idx =3D -1 else: issue =3D self.forge.create_issue(title, body, labels, assignee= s, ms_id) idx =3D int(issue['number']) if 'number' in issue else int(issu= e['index']) self._map_set(tid, idx) print(f"Created Forgejo issue #{idx} for Trac {tid}") # comments & attachments changes =3D self.trac.fetch_changes(tid) atts =3D self.trac.fetch_attachments(tid) if self.cfg.dry_run: print(f"DRY-RUN: would add {sum(1 for c in changes if c.field= =3D=3D'comment' and (c.newvalue or '').strip())} comments and {len(atts)} a= ttachments to issue #{idx}") else: self._emit_comments(idx, t, changes) self._emit_attachments(idx, t, atts) # close if needed if t.status.lower() =3D=3D 'closed': if self.cfg.dry_run: print(f"DRY-RUN: would close issue #{idx}") else: self.forge.close_issue(idx) print(f"Closed issue #{idx} (resolution=3D{t.resolution})") def run(self) -> None: if self.cfg.include_ids: ids =3D self.cfg.include_ids else: ids =3D self.trac.fetch_ticket_ids() for tid in ids: try: self.migrate_one(tid) except Exception as e: print(f"ERROR migrating ticket {tid}: {e}", file=3Dsys.stde= rr) # ----------------------------- CLI entry ----------------------------- def load_config(path: str) -> Config: with open(path, 'r', encoding=3D'utf-8') as f: d =3D yaml.safe_load(f) return Config.from_yaml(d) def main() -> None: ap =3D argparse.ArgumentParser(description=3D"Migrate Trac tickets to F= orgejo issues") ap.add_argument('--config', required=3DTrue, help=3D'Path to YAML confi= g') ap.add_argument('--dry-run', action=3D'store_true', help=3D'Force dry-r= un (overrides config)') args =3D ap.parse_args() cfg =3D load_config(args.config) if args.dry_run: cfg.dry_run =3D True mig =3D Migrator(cfg) mig.run() if __name__ =3D=3D '__main__': main() --h0DN6r1M1pWsn6Gj-- --wx/oEoyJWlAciYRN Content-Type: application/pgp-signature; name="signature.asc" -----BEGIN PGP SIGNATURE----- iF0EABEKAB0WIQSf8hKLFH72cwut8TNhHseHBAsPqwUCaNj9bAAKCRBhHseHBAsP q2lVAKCBq0yVlT3JKSdDuA9is48fyXvq1wCeIrlmrGGoKZco3lPjz8//5tGVlCc= =a4I4 -----END PGP SIGNATURE----- --wx/oEoyJWlAciYRN-- --===============5667018540504755362== Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Content-Disposition: inline _______________________________________________ ffmpeg-devel mailing list -- ffmpeg-devel@ffmpeg.org To unsubscribe send an email to ffmpeg-devel-leave@ffmpeg.org --===============5667018540504755362==--