diff --git a/.gitea/workflows/sync-upstream.yml b/.gitea/workflows/sync-upstream.yml new file mode 100644 index 0000000000..bfc1621f3b --- /dev/null +++ b/.gitea/workflows/sync-upstream.yml @@ -0,0 +1,41 @@ +name: Propose upstream Tryton updates + +on: + schedule: + - cron: '35 */6 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: upstream-8.0-sync + cancel-in-progress: false + +jobs: + propose-update: + runs-on: ubuntu-24.04 + env: + GITEA_URL: https://code.unpaismejor.org.do + MIRROR_REPOSITORY: tryton-do/tryton-mirror + CUSTOM_REPOSITORY: tryton-do/tryton + TARGET_BRANCH: '8.0' + INTEGRATION_BRANCH: integration/8.0 + UPDATE_BRANCH: automation/upstream-8.0 + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + - name: Check out customized repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: Configure Git identity + run: | + git config user.name 'tryton-sync' + git config user.email 'tryton-sync@noreply.unpaismejor.org.do' + + - name: Propose upstream update + run: ci/sync_upstream.sh diff --git a/ci/sync_upstream.sh b/ci/sync_upstream.sh new file mode 100755 index 0000000000..4571e1e426 --- /dev/null +++ b/ci/sync_upstream.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +required=(GITEA_URL MIRROR_REPOSITORY CUSTOM_REPOSITORY TARGET_BRANCH \ + INTEGRATION_BRANCH UPDATE_BRANCH GITEA_TOKEN) +for variable in "${required[@]}"; do + if [[ -z "${!variable:-}" ]]; then + echo "Missing required environment variable: ${variable}" >&2 + exit 2 + fi +done + +auth_header="Authorization: token ${GITEA_TOKEN}" +mirror_url="${GITEA_URL}/${MIRROR_REPOSITORY}.git" +push_url="${GITEA_URL}/${CUSTOM_REPOSITORY}.git" + +git remote add upstream "${mirror_url}" +git -c "http.extraHeader=${auth_header}" fetch --no-tags upstream \ + "+refs/heads/${TARGET_BRANCH}:refs/remotes/upstream/${TARGET_BRANCH}" +git fetch origin "${INTEGRATION_BRANCH}" + +upstream_ref="upstream/${TARGET_BRANCH}" +base_ref="origin/${INTEGRATION_BRANCH}" + +if git merge-base --is-ancestor "${upstream_ref}" "${base_ref}"; then + echo "${INTEGRATION_BRANCH} already contains the current upstream commit." + exit 0 +fi + +if git ls-remote --exit-code --heads origin "${UPDATE_BRANCH}" >/dev/null 2>&1; then + git fetch origin "${UPDATE_BRANCH}" + git switch --create "${UPDATE_BRANCH}" --track "origin/${UPDATE_BRANCH}" +else + git switch --create "${UPDATE_BRANCH}" "${base_ref}" +fi + +# Keep a reused automation branch based on the latest integration history. +# This merge is intentionally non-destructive: conflicts create an issue and +# leave every published reference untouched. +if ! git merge-base --is-ancestor "${base_ref}" HEAD; then + if ! git merge --no-edit --no-ff "${base_ref}"; then + conflicts=$(git diff --name-only --diff-filter=U | sed '/^$/d') + git merge --abort + python ci/upstream_pr.py conflict "${conflicts}" + exit 1 + fi +fi + +if ! git merge --no-edit --no-ff "${upstream_ref}"; then + conflicts=$(git diff --name-only --diff-filter=U | sed '/^$/d') + git merge --abort + python ci/upstream_pr.py conflict "${conflicts}" + exit 1 +fi + +if git diff --quiet "origin/${UPDATE_BRANCH}" HEAD 2>/dev/null; then + echo 'The existing update branch already contains this upstream revision.' +else + git -c "http.extraHeader=${auth_header}" push "${push_url}" \ + "HEAD:refs/heads/${UPDATE_BRANCH}" +fi + +python ci/upstream_pr.py pull-request "$(git rev-parse "${upstream_ref}")" diff --git a/ci/upstream_pr.py b/ci/upstream_pr.py new file mode 100755 index 0000000000..dfea1a7bf6 --- /dev/null +++ b/ci/upstream_pr.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Create an idempotent Gitea pull request or conflict issue.""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +BASE_URL = os.environ['GITEA_URL'].rstrip('/') + '/api/v1' +REPOSITORY = os.environ['CUSTOM_REPOSITORY'] +TOKEN = os.environ['GITEA_TOKEN'] +TARGET = os.environ['INTEGRATION_BRANCH'] +HEAD = os.environ['UPDATE_BRANCH'] + + +def request(method: str, path: str, payload: dict | None = None): + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request( + f'{BASE_URL}{path}', data=data, method=method, + headers={ + 'Authorization': f'token {TOKEN}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }) + try: + with urllib.request.urlopen(req, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors='replace') + raise RuntimeError(f'Gitea API returned {exc.code}: {detail}') from exc + except urllib.error.URLError as exc: + raise RuntimeError(f'Could not reach the Gitea API: {exc.reason}') from exc + + +def open_pulls(): + query = urllib.parse.urlencode({'state': 'open', 'limit': 50}) + return request('GET', f'/repos/{REPOSITORY}/pulls?{query}') + + +def ensure_pull_request(upstream_sha: str) -> None: + for pull in open_pulls(): + if pull['head']['ref'] == HEAD and pull['base']['ref'] == TARGET: + print(f"Using existing pull request: {pull['html_url']}") + return + pull = request('POST', f'/repos/{REPOSITORY}/pulls', { + 'title': f'Upstream Tryton 8.0 update ({upstream_sha[:12]})', + 'head': HEAD, + 'base': TARGET, + 'body': ( + 'Automated proposal from the private official mirror.\n\n' + 'Review the upstream changes and wait for all required checks. ' + 'This pull request must be merged manually.'), + }) + print(f"Created pull request: {pull['html_url']}") + + +def ensure_conflict_issue(conflicts: str) -> None: + title = 'Upstream Tryton 8.0 synchronization conflict' + query = urllib.parse.urlencode({'state': 'open', 'type': 'issues', 'limit': 50}) + for issue in request('GET', f'/repos/{REPOSITORY}/issues?{query}'): + if issue['title'] == title: + print(f"Conflict issue already open: {issue['html_url']}") + return + issue = request('POST', f'/repos/{REPOSITORY}/issues', { + 'title': title, + 'body': ( + 'The scheduled synchronization could not merge the latest ' + 'official 8.0 changes. No branch was overwritten.\n\n' + f'Conflicting paths:\n```\n{conflicts or "unknown"}\n```'), + }) + print(f"Created conflict issue: {issue['html_url']}") + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit('usage: upstream_pr.py pull-request SHA | conflict PATHS') + if sys.argv[1] == 'pull-request': + ensure_pull_request(sys.argv[2]) + elif sys.argv[1] == 'conflict': + ensure_conflict_issue(sys.argv[2]) + else: + raise SystemExit('usage: upstream_pr.py pull-request SHA | conflict PATHS') + + +if __name__ == '__main__': + main()