gitea port

This commit is contained in:
2026-08-21 15:52:45 +02:00
parent d548ff24af
commit a7d1cc987a
14 changed files with 297 additions and 298 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
Generates the patches section of README.md from patches-list.json
and injects it between <!-- PATCHES_START --> / <!-- PATCHES_END --> markers.
Spoilers are expanded (open by default) if:
1. Total patch count <= AUTO_EXPAND_THRESHOLD.
2. The README marker explicitly says: <!-- PATCHES_START EXPANDED -->
python3 generate_patches_readme.py <server-url> <owner/repo> <branch> [patches-list.json] [README.md]
"""
import json
import re
import sys
from pathlib import Path
if len(sys.argv) < 4:
print("Usage: generate_patches_readme.py <server-url> <owner/repo> <branch> [json] [readme]")
sys.exit(1)
server_url = sys.argv[1].rstrip("/")
repo_full = sys.argv[2]
branch = sys.argv[3]
json_path = Path(sys.argv[4]) if len(sys.argv) > 4 else Path("patches-list.json")
readme_path = Path(sys.argv[5]) if len(sys.argv) > 5 else Path("README.md")
if "/" not in repo_full:
raise ValueError(f"Invalid repo format: {repo_full} (expected owner/repo)")
owner, repo = repo_full.split("/", 1)
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
def pkg_emoji(pkg):
"""Return a standard package emoji regardless of the package name."""
return "📦"
# Group patches by package; patches with no compatiblePackages are universal.
# JSON structure: compatiblePackages is a list of objects with
# { packageName, name, targets: [{ version, isExperimental, description }] }
by_pkg = {} # packageName -> { name, emoji, patches, targets }
universal = {}
for patch in data["patches"]:
cp = patch.get("compatiblePackages")
if not cp:
# Deduplicate universal patches by name
if patch["name"] not in universal:
universal[patch["name"]] = patch
continue
for pkg_entry in cp:
pkg = pkg_entry["packageName"]
name = pkg_entry.get("name") or pkg # fall back to package name if no label
if pkg not in by_pkg:
by_pkg[pkg] = {
"name": name,
"emoji": pkg_emoji(pkg),
"patches": {},
"targets": pkg_entry.get("targets", []),
}
# Deduplicate patches that appear across multiple packages
if patch["name"] not in by_pkg[pkg]["patches"]:
by_pkg[pkg]["patches"][patch["name"]] = patch
def anchor(name):
"""Convert a patch name to a Markdown anchor slug."""
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-")
def patches_table(patches):
"""Render a sorted markdown table of patches with name, description, and options."""
rows = [
"| 💊&nbsp;Patch | 📜&nbsp;Description | ⚙️&nbsp;Options |",
"|----------|----------------|-----------|",
]
for p in sorted(patches, key=lambda x: x["name"]):
a = anchor(p["name"])
options = p.get("options") or []
if options:
# Show only option titles as a bullet list
parts = [opt.get("title") or opt.get("key") or "" for opt in options]
opts_cell = "<br>".join(f"• {t}" for t in parts)
else:
opts_cell = ""
desc = (p.get("description") or "").replace("\n", "<br>")
rows.append(f"| [{p['name']}](#{a}) | {desc} | {opts_cell} |")
return "\n".join(rows)
def versions_table(targets):
"""Render a markdown table of supported versions.
Experimental versions get a 🧪 prefix.
Versions with a description get it shown in a second row below.
"""
if not targets:
return ""
cells = []
for t in targets:
ver = t["version"]
if ver is None:
continue
label = f"🧪&nbsp;{ver}" if t.get("isExperimental") else ver
cells.append(label)
if not cells:
return ""
header = "| " + " | ".join(cells) + " |"
sep = "| " + " | ".join(":---:" for _ in cells) + " |"
rows = [header, sep]
# Optional description row — only rendered if at least one target has one
descs = [(t.get("description") or "").replace("\n", "<br>") for t in targets]
if any(descs):
rows.append("| " + " | ".join(descs) + " |")
return "\n".join(rows)
def spoiler(label, count, targets, tbl, expanded=False):
"""Wrap a patches table in a <details> spoiler with a versions sub-table.
If expanded=True, the spoiler is open by default (for repos with few patches).
"""
noun = "patch" if count == 1 else "patches"
vtbl = versions_table(targets)
versions_section = f"**🎯 Supported versions:**\n\n{vtbl}\n\n" if vtbl else ""
tag = "<details open>" if expanded else "<details>"
return f"""{tag}
<summary>{label}&nbsp;&nbsp;•&nbsp;&nbsp;{count} {noun}</summary>
<br>
{versions_section}{tbl}
</details>"""
def build_content(expanded=False):
"""Build the full generated patches section."""
lines = [
f"> **[v{ver}]({server_url}/{owner}/{repo}/releases/tag/v{ver})**"
f"&nbsp;&nbsp;•&nbsp;&nbsp;`{branch}`&nbsp;&nbsp;•&nbsp;&nbsp;"
f"{total} patches total"
]
# One spoiler per app, in the order they appear in the JSON
for pkg, entry in by_pkg.items():
patches = list(entry["patches"].values())
label = f"{entry['emoji']} {entry['name']}"
lines.append(spoiler(label, len(patches), entry["targets"], patches_table(patches), expanded))
lines.append("")
# Universal patches (no specific app)
if universal:
uni_patches = list(universal.values())
noun = "patch" if len(uni_patches) == 1 else "patches"
tag = "<details open>" if expanded else "<details>"
lines.append(f"""{tag}
<summary>🌐 Universal&nbsp;&nbsp;•&nbsp;&nbsp;{len(uni_patches)} {noun}</summary>
<br>
{patches_table(uni_patches)}
</details>""")
lines.append("")
return "\n".join(lines)
# Build and inject
raw_ver = data["version"]
# Strip leading "v" if present
ver = raw_ver.lstrip("v")
total = sum(len(e["patches"]) for e in by_pkg.values()) + len(universal)
readme = readme_path.read_text(encoding="utf-8")
# Marker pattern — matches both <!-- PATCHES_START --> and <!-- PATCHES_START EXPANDED -->
START_PATTERN = r"<!-- PATCHES_START(?:\s+EXPANDED)?\s*-->"
END_MARKER = "<!-- PATCHES_END -->"
marker_match = re.search(START_PATTERN, readme)
if not marker_match or END_MARKER not in readme:
# Fallback: print to stdout so CI can catch the issue
print(build_content(expanded=False))
sys.stderr.write(
f"⚠️ Markers <!-- PATCHES_START [EXPANDED] --> / {END_MARKER} not found in {readme_path}. "
"Printed to stdout instead.\n"
)
sys.exit(1)
actual_start = marker_match.group(0)
# Auto-expand threshold
AUTO_EXPAND_THRESHOLD = 20
# Spoilers are expanded if:
# 1. Total patch count is small (≤ AUTO_EXPAND_THRESHOLD)
# with only a few patches where collapsing adds no benefit.
# 2. The README marker explicitly requests it: <!-- PATCHES_START EXPANDED -->
expanded = (
total <= AUTO_EXPAND_THRESHOLD or
"EXPANDED" in actual_start
)
generated = build_content(expanded=expanded)
# Replace template links if present.
readme = readme.replace("https://github.com/xyz-user/xyz-patches", f"{server_url}/{repo_full}")
new_readme = re.sub(
rf"{START_PATTERN}.*?{re.escape(END_MARKER)}",
f"{actual_start}\n{generated}\n{END_MARKER}",
readme,
flags=re.DOTALL,
)
readme_path.write_text(new_readme, encoding="utf-8")
print(f"✅ Injected patches section into {readme_path} (v{ver}, branch={branch}, {total} patches, expanded={expanded})")
+128
View File
@@ -0,0 +1,128 @@
const { appendFile, readFile, readdir, writeFile } = require('node:fs/promises');
const path = require('node:path');
function releaseEnvironment(context) {
const env = context.env ?? process.env;
const serverUrl = env.GITEA_URL?.replace(/\/$/, '');
const repository = env.GITEA_REPOSITORY;
const token = env.GITEA_TOKEN;
if (!serverUrl || !repository || !token) {
throw new Error('GITEA_URL, GITEA_REPOSITORY, and GITEA_TOKEN are required');
}
if (!/^https?:\/\//.test(serverUrl)) {
throw new Error('GITEA_URL must be an HTTP(S) URL');
}
if (!/^[^/]+\/[^/]+$/.test(repository)) {
throw new Error('GITEA_REPOSITORY must use the owner/repository format');
}
return { serverUrl, repository, token };
}
async function giteaRequest(url, token, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: `token ${token}`,
...options.headers,
},
});
if (!response.ok) {
const body = (await response.text()).slice(0, 2_000);
throw new Error(`Gitea API request failed (${response.status} ${response.statusText}): ${body}`);
}
return response;
}
module.exports.verifyConditions = async (_pluginConfig, context) => {
releaseEnvironment(context);
};
module.exports.prepare = async (pluginConfig, context) => {
const { nextRelease, cwd } = context;
const notes = nextRelease.notes?.trim();
if (!notes) return;
const { serverUrl, repository } = releaseEnvironment(context);
const changelogPath = path.resolve(cwd, pluginConfig.changelogFile ?? 'CHANGELOG.md');
const releaseJsonPath = path.resolve(cwd, pluginConfig.releaseJsonPath ?? 'patches-bundle.json');
let currentChangelog = '';
try {
currentChangelog = (await readFile(changelogPath, 'utf8')).trim();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
await writeFile(
changelogPath,
`${notes}\n${currentChangelog ? `\n${currentChangelog}\n` : ''}`,
);
const version = nextRelease.version;
const assetName = `patches-${version}.mpp`;
const releaseData = {
created_at: new Date().toISOString().replace(/\.\d{3}Z$/, ''),
description: notes,
download_url: `${serverUrl}/${repository}/releases/download/${nextRelease.gitTag}/${assetName}`,
signature_download_url: '',
version,
};
await writeFile(releaseJsonPath, `${JSON.stringify(releaseData, null, 2)}\n`);
};
module.exports.publish = async (pluginConfig, context) => {
const { serverUrl, repository, token } = releaseEnvironment(context);
const { branch, cwd, nextRelease } = context;
const repositoryPath = repository.split('/').map(encodeURIComponent).join('/');
const apiUrl = `${serverUrl}/api/v1/repos/${repositoryPath}`;
const assetDirectory = path.resolve(
cwd,
pluginConfig.assetDirectory ?? 'patches/build/libs',
);
const assetNames = (await readdir(assetDirectory))
.filter((name) => /\.mpp(?:\.asc)?$/.test(name))
.filter((name) => !/(?:sources|javadoc)/.test(name))
.sort();
if (assetNames.length === 0) {
throw new Error(`No release assets found in ${assetDirectory}`);
}
const releaseResponse = await giteaRequest(`${apiUrl}/releases`, token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: nextRelease.notes,
draft: false,
name: nextRelease.name,
prerelease: Boolean(branch.prerelease),
tag_name: nextRelease.gitTag,
target_commitish: branch.name,
}),
});
const release = await releaseResponse.json();
for (const assetName of assetNames) {
const contents = await readFile(path.join(assetDirectory, assetName));
const form = new FormData();
form.append('attachment', new Blob([contents]), assetName);
await giteaRequest(
`${apiUrl}/releases/${release.id}/assets?name=${encodeURIComponent(assetName)}`,
token,
{ method: 'POST', body: form },
);
}
if (process.env.GITHUB_OUTPUT) {
await appendFile(
process.env.GITHUB_OUTPUT,
`new_release_published=true\nnew_release_git_tag=${nextRelease.gitTag}\n`,
);
}
return { name: release.name, url: release.html_url };
};
+73
View File
@@ -0,0 +1,73 @@
name: Open a PR to main
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
pull-requests: write
contents: read
jobs:
pull-request:
name: Open pull request
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Open pull request
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
SOURCE_BRANCH: ${{ gitea.ref_name }}
TARGET_BRANCH: main
shell: bash
run: |
set -euo pipefail
api_url="${GITEA_URL%/}/api/v1"
auth_header="Authorization: token ${GITEA_TOKEN}"
open_pulls="$(
curl --fail-with-body --silent --show-error \
--get \
--header "$auth_header" \
--data-urlencode "state=open" \
--data-urlencode "base_branch=${TARGET_BRANCH}" \
--data-urlencode "limit=50" \
"${api_url}/repos/${GITEA_REPOSITORY}/pulls"
)"
if jq -e --arg branch "$SOURCE_BRANCH" \
'any(.[]; .head.ref == $branch)' <<<"$open_pulls" >/dev/null; then
echo "Pull request already exists."
exit 0
fi
commits="$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")"
if [[ "$commits" -eq 0 ]]; then
echo "No commits between ${TARGET_BRANCH} and ${SOURCE_BRANCH}. Skipping pull request creation."
exit 0
fi
payload="$(
jq -n \
--arg base "$TARGET_BRANCH" \
--arg head "$SOURCE_BRANCH" \
--arg title 'chore: Merge branch `dev` to `main`' \
--arg body 'When dev (pre-release) is ready, merge this pull request to create a new stable release.' \
'{base: $base, head: $head, title: $title, body: $body}'
)"
curl --fail-with-body --silent --show-error \
--request POST \
--header "$auth_header" \
--header "Content-Type: application/json" \
--data "$payload" \
"${api_url}/repos/${GITEA_REPOSITORY}/pulls" >/dev/null
echo "Pull request created."
+52
View File
@@ -0,0 +1,52 @@
name: Release
on:
workflow_dispatch:
push:
jobs:
release:
name: Release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: npm
- name: Install dependencies
run: npm ci
- name: Release
id: release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF_NAME: ${{ gitea.ref_name }}
MORPHE_REGISTRY_USERNAME: ${{ secrets.MORPHE_REGISTRY_USERNAME }}
MORPHE_REGISTRY_TOKEN: ${{ secrets.MORPHE_REGISTRY_TOKEN }}
run: npx semantic-release
# Semantic Release performs the Gradle build while publishing. Build only
# when no release was produced so every push still verifies compilation.
- name: Verify project compiles
if: steps.release.outputs.new_release_published != 'true'
env:
MORPHE_REGISTRY_USERNAME: ${{ secrets.MORPHE_REGISTRY_USERNAME }}
MORPHE_REGISTRY_TOKEN: ${{ secrets.MORPHE_REGISTRY_TOKEN }}
run: ./gradlew :patches:buildAndroid clean --no-daemon