From 4aa873270635d82ef1e864701d24591a2c138090 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 9 Feb 2026 16:19:48 +0100 Subject: [PATCH] Update templates and CI; refactor labels tool (#27) --- .copier-answers.yml | 2 +- .github/workflows/cleanup.yml | 7 +- .github/workflows/release-pr.yml | 20 +- README.md | 2 +- docs/docs/introduction/index.md | 2 +- tools/update_github_labels.py | 359 +++++++++++++++++++------------ 6 files changed, 233 insertions(+), 159 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 8cf85a9..46840c5 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,6 +1,6 @@ # WARNING: Do not edit this file manually. # Any changes will be overwritten by Copier. -_commit: v0.4.1-1-gd3a985d +_commit: v0.4.6 _src_path: gh:easyscience/templates app_docs_url: https://easyscience.github.io/peasy-app app_doi: 10.5281/zenodo.18163581 diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 7d9d11e..7305679 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -53,8 +53,13 @@ on: - skipped - success dry_run: - description: 'Only log actions, do not perform any delete operations.' + description: 'Only log actions, do not perform any delete operations (dry run).' required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' jobs: del-runs: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e4b6a7e..b12c477 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -1,21 +1,14 @@ -# This workflow creates an automated release PR from `develop` into `master`. +# This workflow creates an automated release PR from a source branch into the default branch. # # Usage: # - Triggered manually via workflow_dispatch. -# - Creates a PR titled "Release: merge develop into master". -# - Adds the label "[maintainer] auto-pull-request" so it is excluded from changelogs. +# - Creates a PR titled "Release: merge into ". +# - Adds the label "[bot] pull request" so it is excluded from changelogs. # - The PR body makes clear that this is automation only (no review needed). -# -# Required repo config: -# https://github.com/organizations/easyscience/settings/secrets/actions -# https://github.com/organizations/easyscience/settings/variables/actions -# - Actions secret: EASYSCIENCE_APP_KEY (GitHub App private key PEM) -# - Actions variable: EASYSCIENCE_APP_ID (GitHub App ID) -name: Release PR (develop/feature -> master) +name: 'Release PR ()' on: - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: inputs: source_branch: @@ -28,7 +21,6 @@ permissions: contents: read pull-requests: write -# Set the environment variables to be used in all jobs defined in this workflow env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} SOURCE_BRANCH: ${{ inputs.source_branch || 'develop' }} @@ -50,6 +42,8 @@ jobs: private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} - name: Create PR from ${{ env.SOURCE_BRANCH }} to ${{ env.DEFAULT_BRANCH }} + env: + GH_TOKEN: ${{ steps.bot.outputs.token }} run: | gh pr create \ --base ${{ env.DEFAULT_BRANCH }} \ @@ -59,5 +53,3 @@ jobs: --body "This PR is created automatically to trigger the release pipeline. It merges the accumulated changes from \`${{ env.SOURCE_BRANCH }}\` into \`${{ env.DEFAULT_BRANCH }}\`. ⚠️ It is labeled \`[bot] pull request\` and is excluded from release notes and version bump logic." - env: - GH_TOKEN: ${{ steps.bot.outputs.token }} diff --git a/README.md b/README.md index 7b24190..10dd29c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ against experimental data. -**EasyPeasy** is available both as a Python library and as a +**EasyPeasy** is developed both as a Python library and as a cross-platform desktop application. Here, we focus on the Python library. For the graphical user interface diff --git a/docs/docs/introduction/index.md b/docs/docs/introduction/index.md index c3a638f..88423c1 100644 --- a/docs/docs/introduction/index.md +++ b/docs/docs/introduction/index.md @@ -10,7 +10,7 @@ icon: material/information-slab-circle calculations based on a theoretical model and refining its parameters against experimental data. -**EasyPeasy** is available both as a Python library and as a +**EasyPeasy** is developed both as a Python library and as a cross-platform desktop application. Here, we focus on the Python library. For the graphical user interface diff --git a/tools/update_github_labels.py b/tools/update_github_labels.py index a18043d..615da8e 100644 --- a/tools/update_github_labels.py +++ b/tools/update_github_labels.py @@ -1,5 +1,4 @@ -""" -Set/update GitHub labels for current or specified easyscience +"""Set/update GitHub labels for current or specified easyscience repository. Requires: @@ -21,187 +20,282 @@ import subprocess import sys from dataclasses import dataclass -from typing import Iterable - EASYSCIENCE_ORG = 'easyscience' -# --- Label definitions ----------------------------------------------------------- +# Data structures -BASIC_GITHUB_LABELS = [ - 'bug', - 'documentation', - 'duplicate', - 'enhancement', - 'good first issue', - 'help wanted', - 'invalid', - 'question', - 'wontfix', -] -NEW_BASIC_LABEL_NAMES = [ - '[scope] bug', - '[scope] documentation', - '[maintainer] duplicate', - '[scope] enhancement', - '[maintainer] good first issue', - '[maintainer] help wanted', - '[maintainer] invalid', - '[maintainer] question', - '[maintainer] wontfix', -] +@dataclass(frozen=True) +class Label: + """A GitHub label with name, color, and description.""" -SCOPE_LABELS = [ - ('bug', 'Bug report or fix (major.minor.PATCH)'), - ('documentation', 'Documentation only changes (major.minor.patch.POST)'), - ('enhancement', 'Adds/improves features (major.MINOR.patch)'), - ('maintenance', 'Code/tooling cleanup, no feature or bugfix (major.minor.PATCH)'), - ('significant', 'Breaking or major changes (MAJOR.minor.patch)'), - ('⚠️ label needed', 'Automatically added to issues and PRs without a [scope] label'), -] + name: str + color: str + description: str = '' -MAINTAINER_LABELS = [ - ('duplicate', 'Already reported or submitted'), - ('good first issue', 'Good entry-level issue for newcomers'), - ('help wanted', 'Needs additional help to resolve or implement'), - ('invalid', 'Invalid, incorrect or outdated'), - ('question', 'Needs clarification, discussion, or more information'), - ('wontfix', 'Will not be fixed or continued'), -] -PRIORITY_LABELS = [ - ('lowest', 'Very low urgency'), - ('low', 'Low importance'), - ('medium', 'Normal/default priority'), - ('high', 'Should be prioritized soon'), - ('highest', 'Urgent. Needs attention ASAP'), - ('⚠️ label needed', 'Automatically added to issues without a [priority] label'), -] +@dataclass(frozen=True) +class LabelRename: + """Mapping from old label name to new label name.""" + + old: str + new: str -BOT_LABEL = ( - '[bot] pull request', - 'Automated release PR. Excluded from changelog/versioning', -) -COLORS = { - 'scope': 'd73a4a', - 'maintainer': '0e8a16', - 'priority': 'fbca04', - 'bot': '5319e7', -} +class Colors: + """Hex color codes for label groups.""" + SCOPE = 'd73a4a' + MAINTAINER = '0e8a16' + PRIORITY = 'fbca04' + BOT = '5319e7' -# --- Helpers -------------------------------------------------------------------- + +LABEL_RENAMES = [ + LabelRename('bug', '[scope] bug'), + LabelRename('documentation', '[scope] documentation'), + LabelRename('duplicate', '[maintainer] duplicate'), + LabelRename('enhancement', '[scope] enhancement'), + LabelRename('good first issue', '[maintainer] good first issue'), + LabelRename('help wanted', '[maintainer] help wanted'), + LabelRename('invalid', '[maintainer] invalid'), + LabelRename('question', '[maintainer] question'), + LabelRename('wontfix', '[maintainer] wontfix'), +] + +LABELS = [ + # Scope labels + Label( + '[scope] bug', + Colors.SCOPE, + 'Bug report or fix (major.minor.PATCH)', + ), + Label( + '[scope] documentation', + Colors.SCOPE, + 'Documentation only changes (major.minor.patch.POST)', + ), + Label( + '[scope] enhancement', + Colors.SCOPE, + 'Adds/improves features (major.MINOR.patch)', + ), + Label( + '[scope] maintenance', + Colors.SCOPE, + 'Code/tooling cleanup, no feature or bugfix (major.minor.PATCH)', + ), + Label( + '[scope] significant', + Colors.SCOPE, + 'Breaking or major changes (MAJOR.minor.patch)', + ), + Label( + '[scope] ⚠️ label needed', + Colors.SCOPE, + 'Automatically added to issues and PRs without a [scope] label', + ), + # Maintainer labels + Label( + '[maintainer] duplicate', + Colors.MAINTAINER, + 'Already reported or submitted', + ), + Label( + '[maintainer] good first issue', + Colors.MAINTAINER, + 'Good entry-level issue for newcomers', + ), + Label( + '[maintainer] help wanted', + Colors.MAINTAINER, + 'Needs additional help to resolve or implement', + ), + Label( + '[maintainer] invalid', + Colors.MAINTAINER, + 'Invalid, incorrect or outdated', + ), + Label( + '[maintainer] question', + Colors.MAINTAINER, + 'Needs clarification, discussion, or more information', + ), + Label( + '[maintainer] wontfix', + Colors.MAINTAINER, + 'Will not be fixed or continued', + ), + # Priority labels + Label( + '[priority] lowest', + Colors.PRIORITY, + 'Very low urgency', + ), + Label( + '[priority] low', + Colors.PRIORITY, + 'Low importance', + ), + Label( + '[priority] medium', + Colors.PRIORITY, + 'Normal/default priority', + ), + Label( + '[priority] high', + Colors.PRIORITY, + 'Should be prioritized soon', + ), + Label( + '[priority] highest', + Colors.PRIORITY, + 'Urgent. Needs attention ASAP', + ), + Label( + '[priority] ⚠️ label needed', + Colors.PRIORITY, + 'Automatically added to issues without a [priority] label', + ), + # Bot label + Label( + '[bot] pull request', + Colors.BOT, + 'Automated release PR. Excluded from changelog/versioning', + ), +] + + +# Helpers @dataclass(frozen=True) class CmdResult: + """Result of a shell command execution.""" + returncode: int stdout: str stderr: str -def run_cmd(args: list[str], *, dry_run: bool, check: bool = True) -> CmdResult: +def run_cmd( + args: list[str], + *, + dry_run: bool, + check: bool = True, +) -> CmdResult: """Run a command (or print it in dry-run mode).""" cmd_str = ' '.join(shlex.quote(a) for a in args) if dry_run: - print(f'{cmd_str}') + print(f' [dry-run] {cmd_str}') return CmdResult(0, '', '') proc = subprocess.run( - args, + args=args, text=True, capture_output=True, ) - res = CmdResult(proc.returncode, proc.stdout.strip(), proc.stderr.strip()) + result = CmdResult( + proc.returncode, + proc.stdout.strip(), + proc.stderr.strip(), + ) if check and proc.returncode != 0: - raise RuntimeError(f'Command failed ({proc.returncode}): {cmd_str}\n{res.stderr}') + raise RuntimeError(f'Command failed ({proc.returncode}): {cmd_str}\n{result.stderr}') - return res + return result -def get_current_repo_name_with_owner() -> str: - res = subprocess.run( - ['gh', 'repo', 'view', '--json', 'nameWithOwner'], +def get_current_repo() -> str: + """Get the current repository name in 'owner/repo' format.""" + result = subprocess.run( + args=[ + 'gh', + 'repo', + 'view', + '--json', + 'nameWithOwner', + ], text=True, capture_output=True, check=True, ) - data = json.loads(res.stdout) - nwo = data.get('nameWithOwner') - if not nwo or '/' not in nwo: + data = json.loads(result.stdout) + name_with_owner = data.get('nameWithOwner', '') + + if '/' not in name_with_owner: raise RuntimeError('Could not determine current repository name') - return nwo + return name_with_owner -def try_rename_label(repo: str, old: str, new: str, *, dry_run: bool) -> None: - try: - run_cmd( - ['gh', 'label', 'edit', old, '--name', new, '--repo', repo], - dry_run=dry_run, - ) - print(f'Rename: {old!r} → {new!r}') - except Exception: - print(f'Skip rename (label not found): {old!r}') + +def rename_label( + repo: str, + rename: LabelRename, + *, + dry_run: bool, +) -> None: + """Rename a label, silently skipping if it doesn't exist.""" + result = run_cmd( + args=[ + 'gh', + 'label', + 'edit', + rename.old, + '--name', + rename.new, + '--repo', + repo, + ], + dry_run=dry_run, + check=False, + ) + + if dry_run or result.returncode == 0: + print(f' Rename: {rename.old!r} → {rename.new!r}') + else: + print(f' Skip (not found): {rename.old!r}') def upsert_label( repo: str, - name: str, - color: str, - description: str, + label: Label, *, dry_run: bool, ) -> None: + """Create or update a label.""" run_cmd( [ 'gh', 'label', 'create', - name, + label.name, '--color', - color, + label.color, '--description', - description, + label.description, '--force', '--repo', repo, ], dry_run=dry_run, ) - print(f'Upsert label: {name!r}') + print(f' Upsert: {label.name!r}') -def upsert_group( - repo: str, - prefix: str, - color: str, - items: Iterable[tuple[str, str]], - *, - dry_run: bool, -) -> None: - for short, desc in items: - upsert_label( - repo, - f'[{prefix}] {short}', - color, - desc, - dry_run=dry_run, - ) - - -# --- Main ----------------------------------------------------------------------- +# Main def main() -> int: + """Entry point: parse arguments and sync labels.""" parser = argparse.ArgumentParser(description='Sync GitHub labels for easyscience repos') parser.add_argument( '--repo', - help='Target repository in the form easyscience/', + help='Target repository (owner/name)', ) parser.add_argument( '--dry-run', @@ -210,41 +304,24 @@ def main() -> int: ) args = parser.parse_args() - if args.repo: - repo = args.repo - else: - repo = get_current_repo_name_with_owner() - - org, _ = repo.split('/', 1) + repo = args.repo or get_current_repo() + org = repo.split('/')[0] if org.lower() != EASYSCIENCE_ORG: - print( - f"Refusing to run: repository {repo!r} is not under '{EASYSCIENCE_ORG}'.", - file=sys.stderr, - ) + print(f"Error: repository '{repo}' is not under '{EASYSCIENCE_ORG}'", file=sys.stderr) return 2 - print(f'Target repository: {repo}') + print(f'Repository: {repo}') if args.dry_run: - print('Running in DRY-RUN mode (no changes will be made)\n') - - # 1) Rename basic labels - for old, new in zip(BASIC_GITHUB_LABELS, NEW_BASIC_LABEL_NAMES, strict=True): - try_rename_label(repo, old, new, dry_run=args.dry_run) - - # 2) Scope / Maintainer / Priority groups - upsert_group(repo, 'scope', COLORS['scope'], SCOPE_LABELS, dry_run=args.dry_run) - upsert_group(repo, 'maintainer', COLORS['maintainer'], MAINTAINER_LABELS, dry_run=args.dry_run) - upsert_group(repo, 'priority', COLORS['priority'], PRIORITY_LABELS, dry_run=args.dry_run) - - # 3) Bot label - upsert_label( - repo, - BOT_LABEL[0], - COLORS['bot'], - BOT_LABEL[1], - dry_run=args.dry_run, - ) + print('Mode: DRY-RUN (no changes will be made)\n') + + print('\nRenaming default labels...') + for rename in LABEL_RENAMES: + rename_label(repo, rename, dry_run=args.dry_run) + + print('\nUpserting labels...') + for label in LABELS: + upsert_label(repo, label, dry_run=args.dry_run) print('\nDone.') return 0