diff --git a/.gitea/workflows/upstream-watch.yml b/.gitea/workflows/upstream-watch.yml new file mode 100644 index 0000000..da6e215 --- /dev/null +++ b/.gitea/workflows/upstream-watch.yml @@ -0,0 +1,152 @@ +# Watch the upstream web UI for new versions, and open an issue for each one +# so it can be read for features worth writing here. +# +# The console no longer merges upstream (README › Upstream): later versions +# include code under a proprietary license. So the issue lists what changed, +# file by file, grouped by each file's license header, for a reader to know +# which files may be opened at all. Enterprise-only files, and files holding +# Enterprise snippets, are named but must never be opened. +# +# Reads metadata only: the tag list and the compare between two tags from +# GitHub's API. Each changed file is fetched just to read its license header +# lines; nothing of upstream's code is kept, printed, or put in the issue, only +# paths, change counts and licenses. Titles and bodies stay free of the +# upstream project's name, as the rest of the fork's user-visible text does. +# +# Each version since the fork point (v1.0.11) gets one issue, compared with +# the version before it. An existing issue with the same title, open or +# closed, stops a second. Tags are watched rather than releases: upstream has +# tagged versions before publishing them as releases. +# +# Daily 06:47 UTC; run it by hand with workflow_dispatch. DRY_RUN=1 prints +# the issues instead of opening them. +name: upstream-watch + +on: + schedule: + - cron: '47 6 * * *' + workflow_dispatch: + +concurrency: + group: upstream-watch + cancel-in-progress: false + +jobs: + upstream-watch: + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + steps: + - shell: bash + run: | + python3 - <<'PY' + import json, os, re, sys, urllib.parse, urllib.request + + FORK_BASE = "v1.0.11" + UPSTREAM = "stalwartlabs/webui" + DRY_RUN = os.environ.get("DRY_RUN") == "1" + + api = f"{os.environ.get('CI_SERVER_INTERNAL', '')}/api/v1/repos/{os.environ.get('REPO', '')}" + def call(method, url, body=None, token=None, raw=False): + headers = {"User-Agent": "inbuxa-admin-upstream-watch"} + if body is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"token {token}" + req = urllib.request.Request(url, method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read() if raw else json.load(r) + SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + def key(tag): + return tuple(int(x) for x in SEMVER.match(tag).groups()) + def plural(n, word): + return f"{n} {word}" if n == 1 else f"{n} {word}s" + + # Unauthenticated: a public repo, once a day, well inside the limit. + tags = call("GET", f"https://api.github.com/repos/{UPSTREAM}/tags?per_page=100") + newer = sorted((t["name"] for t in tags + if SEMVER.match(t["name"]) and key(t["name"]) > key(FORK_BASE)), key=key) + if not newer: + print(f"Nothing new: {FORK_BASE}, the fork point, is the newest version."); sys.exit(0) + + if DRY_RUN: + existing = set() + else: + existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues" + "&q=Review+upstream+web+UI&limit=50", token=os.environ["TOKEN"])} + + SPDX = re.compile(r"SPDX-License-Identifier:\s*(.+?)\s*(?:\*/|-->)?\s*$", re.M) + def license_of(tag, path): + """One of: enterprise, snippets, dual, other:, none. Reads only the license lines.""" + url = f"https://raw.githubusercontent.com/{UPSTREAM}/{urllib.parse.quote(tag)}/{urllib.parse.quote(path)}" + text = call("GET", url, raw=True).decode("utf-8", "replace") + ids = SPDX.findall(text) + if not ids: + return "none" + head = ids[0] + if head == "LicenseRef-SEL": + return "enterprise" + if "SPDX-SnippetBegin" in text or "LicenseRef-SEL" in ids[1:]: + return "snippets" + if head == "AGPL-3.0-only OR LicenseRef-SEL": + return "dual" + return f"other:{head}" + + GROUPS = [ + ("enterprise", "Enterprise only: never open"), + ("snippets", "Dual, holding Enterprise snippets: never open"), + ("removed", "Removed"), + ("dual", "Dual (AGPL-3.0-only OR LicenseRef-SEL): may be read"), + ("none", "No license header: check before reading"), + ] + + prev = FORK_BASE + for tag in newer: + title = f"Review upstream web UI {tag}" + if title in existing: + print(f"{tag}: issue already exists."); prev = tag; continue + cmp = call("GET", f"https://api.github.com/repos/{UPSTREAM}/compare/{prev}...{tag}") + files = cmp.get("files", []) + groups = {} + for f in files: + if f["status"] == "removed": + group = "removed" + else: + group = license_of(tag, f["filename"]) + line = f"- `{f['filename']}` ({f['status']}, +{f['additions']} −{f['deletions']})" + if f["status"] == "renamed": + line += f", from `{f['previous_filename']}`" + groups.setdefault(group, []).append(line) + + date = cmp["commits"][-1]["commit"]["committer"]["date"][:10] if cmp.get("commits") else "?" + parts = [ + f"Upstream tagged the web UI {tag} ({date}): {plural(cmp.get('total_commits', 0), 'commit')} and " + f"{plural(len(files), 'changed file')} since {prev}. The console was forked at {FORK_BASE} and " + "no longer merges upstream (README › Upstream).", + "Read what changed and note here anything worth writing ourselves, in our own code. " + "Open only files marked *may be read*. Never open a file marked *never open*, " + "and don't hand one to anyone who writes console code.", + ] + if len(files) >= 300: + parts.append("**The compare stops at 300 files, so this list is incomplete.**") + labels = dict(GROUPS) + order = [g for g, _ in GROUPS] + sorted(g for g in groups if g not in labels) + for group in order: + if group not in groups: + continue + label = labels.get(group, f"Other license ({group[6:]}): check before reading") + parts.append(f"
{label}: {len(groups[group])}\n\n" + + "\n".join(sorted(groups[group])) + "\n\n
") + body = "\n\n".join(parts) + + if DRY_RUN: + print(f"=== {title}\n{body}\n") + else: + issue = call("POST", f"{api}/issues", {"title": title, "body": body}, token=os.environ["TOKEN"]) + print(f"{tag}: opened #{issue['number']}.") + prev = tag + PY