diff --git a/.github/workflows/cime_machine_config_update.yml b/.github/workflows/cime_machine_config_update.yml new file mode 100644 index 00000000..f147ff15 --- /dev/null +++ b/.github/workflows/cime_machine_config_update.yml @@ -0,0 +1,74 @@ +name: Daily config_machines update check + +on: + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + +env: + PIXI_ENV: py314 + ISSUE_TITLE: Daily config_machines drift detected + PRIMARY_ASSIGNEE: xylar + REPORT_JSON: cime_machine_config_report.json + REPORT_MARKDOWN: cime_machine_config_report.md + +jobs: + check-config-machines: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + + - name: Set up Pixi + uses: prefix-dev/setup-pixi@v0.9.5 + with: + pixi-version: v0.62.2 + cache: ${{ hashFiles('pixi.lock') != '' }} + environments: ${{ env.PIXI_ENV }} + + - name: Install mache from main + run: | + pixi run -e ${PIXI_ENV} python -m pip install --no-deps \ + --no-build-isolation -e . + + - name: Generate machine update report + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + pixi run -e ${PIXI_ENV} python utils/update_cime_machine_config.py \ + --json-output ${REPORT_JSON} \ + --markdown-output ${REPORT_MARKDOWN} \ + --run-url ${RUN_URL} + + - name: Upload machine update report + uses: actions/upload-artifact@v4 + with: + name: cime-machine-config-report + path: | + ${{ env.REPORT_JSON }} + ${{ env.REPORT_MARKDOWN }} + + - name: Synchronize automation issue + env: + GH_CLI_TOKEN: ${{ secrets.GH_CLI_TOKEN }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ -z "${GH_CLI_TOKEN}" ]; then + echo "GH_CLI_TOKEN is not configured; report generated" \ + " but no issue was synchronized." + exit 0 + fi + + pixi run -e ${PIXI_ENV} python \ + utils/manage_cime_machine_config_issue.py \ + --report-json ${REPORT_JSON} \ + --report-markdown ${REPORT_MARKDOWN} \ + --repository ${GITHUB_REPOSITORY} \ + --token ${GH_CLI_TOKEN} \ + --issue-title "${ISSUE_TITLE}" \ + --base-branch ${DEFAULT_BRANCH} \ + --primary-assignee ${PRIMARY_ASSIGNEE} diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..c7809009 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,41 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + - pixi.toml + - pyproject.toml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + - pixi.toml + - pyproject.toml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Pixi + uses: prefix-dev/setup-pixi@v0.9.5 + with: + pixi-version: v0.62.2 + cache: ${{ hashFiles('pixi.lock') != '' }} + environments: py314 + + - name: Install mache in the Pixi environment + run: | + pixi run -e py314 python -m pip install --no-deps \ + --no-build-isolation -e . + + - name: Verify the agent environment + run: | + pixi run -e py314 python --version + pixi run -e py314 mache --help diff --git a/docs/developers_guide/adding_new_machine.md b/docs/developers_guide/adding_new_machine.md index 4f72da67..afc15a6c 100644 --- a/docs/developers_guide/adding_new_machine.md +++ b/docs/developers_guide/adding_new_machine.md @@ -13,6 +13,10 @@ can be added to mache. This list is a *copy* of the which we try to keep up-to-date. If you wish to add a machine that is not included in this list, you must contact the E3SM-Project developers to add your machine. + +For details on the automated workflow that detects upstream drift in this file +and assigns follow-up work to Copilot, see +{doc}`config_machines_updates`. ::: (dev-new-config-file)= diff --git a/docs/developers_guide/config_machines_updates.md b/docs/developers_guide/config_machines_updates.md new file mode 100644 index 00000000..ef0d508f --- /dev/null +++ b/docs/developers_guide/config_machines_updates.md @@ -0,0 +1,246 @@ +# Automated `config_machines.xml` updates + +This page describes the automation that watches for upstream changes to +E3SM's `config_machines.xml`, opens or refreshes a Copilot task when drift is +detected, and explains how maintainers are expected to review the resulting +pull request. + +## Goal + +`mache` keeps a repository-local copy of the upstream E3SM machine list in +`mache/cime_machine_config/config_machines.xml`. + +The automation added here does **not** edit that file directly. Instead, it: + +1. Compares the copy in `mache` against the current upstream E3SM source. +2. Produces a structured report describing any drift for supported machines. +3. Creates or updates one GitHub issue that assigns the work to Copilot. +4. Lets Copilot open a PR that updates `config_machines.xml` and any related + Spack configuration. + +This keeps the source-of-truth update in a reviewed pull request rather than a +silent CI-side commit. + +## Pieces of the automation + +### Daily workflow + +`.github/workflows/cime_machine_config_update.yml` +: Runs once a day at `0 8 * * *` and can also be started manually with + `workflow_dispatch`. + +The job: + +1. Checks out `main`. +2. Sets up the `py314` Pixi environment. +3. Installs `mache` from the checked-out repository. +4. Runs `utils/update_cime_machine_config.py`. +5. Uploads the generated JSON and Markdown report artifacts. +6. Runs `utils/manage_cime_machine_config_issue.py` when `GH_CLI_TOKEN` is + configured. + +### Copilot environment workflow + +`.github/workflows/copilot-setup-steps.yml` +: Defines the setup steps the Copilot cloud agent can use on the default + branch so it starts from a working Pixi environment with `mache` installed. + +### Drift report builder + +`utils/update_cime_machine_config.py` +: Downloads the current upstream E3SM `config_machines.xml`, compares it with + `mache/cime_machine_config/config_machines.xml`, prints a short console + summary, and optionally writes: + + - a JSON report for machine-readable automation, + - a Markdown issue body for Copilot and human reviewers. + +`mache/cime_machine_config/report.py` +: Contains the structured comparison logic. It determines which supported + machines changed, identifies module and environment-variable drift, infers + related package groups, and lists candidate Spack template files to review. + +### Issue synchronization + +`utils/manage_cime_machine_config_issue.py` +: Owns the GitHub-side lifecycle for the automation issue. + +If drift exists, it creates or updates the issue. + +If no drift exists, it closes the existing issue. + +If Copilot assignment fails, it falls back to creating or updating the same +issue without Copilot assignment so the report is still visible. + +### Tests + +`tests/test_cime_machine_config_report.py` +: Verifies that the report builder detects relevant drift and that the rendered + issue body contains the required maintainer instructions. + +## How `config_machines.xml` gets updated + +The important point is that the scheduled workflow never edits +`mache/cime_machine_config/config_machines.xml` itself. + +The update path is: + +1. The workflow detects drift between the `mache` copy and upstream E3SM. +2. The workflow creates or refreshes a GitHub issue. +3. Copilot is assigned to that issue. +4. Copilot opens a pull request against `main`. +5. That PR updates `mache/cime_machine_config/config_machines.xml` first, then + any related Spack templates or version strings that the report indicates + should be reviewed. +6. A maintainer reviews and merges the PR. +7. The next daily run compares the merged repository state against upstream + again. + +If the PR fully resolved the drift, the issue is closed automatically on the +next run. + +If only part of the drift was resolved, the issue stays open and its body is +updated to reflect the remaining work. + +## What Copilot is told to do + +Copilot receives instructions from two places. + +### Fixed API-level instructions + +`utils/manage_cime_machine_config_issue.py` adds the following guidance in the +`agent_assignment` payload: + +- Use the issue body as the task definition. +- Update `config_machines.xml` first. +- Then update related Spack templates and version strings. +- Add TODO comments in the PR when prefix or path changes need reviewer + confirmation. + +### Generated issue-body instructions + +`mache/cime_machine_config/report.py` renders the issue body for the current +drift and includes: + +- the timestamp and upstream source URL, +- the workflow run URL, +- the list of affected supported machines, +- the required work list, +- per-machine details such as package groups, prefix or path variables, and + candidate Spack templates to inspect. + +The required work section tells Copilot to: + +- update `mache/cime_machine_config/config_machines.xml` for the affected + supported machines, +- update Spack templates and version strings when module or environment drift + implies different package versions, +- keep the PR focused when the change is only version or module drift, +- add a TODO in the PR instead of guessing when a new prefix or path is not + obvious. + +## Why this does not create a new issue every day + +The workflow is designed to reuse one open issue rather than create a new one +for every scheduled run. + +`utils/manage_cime_machine_config_issue.py` looks for an existing open issue +with the fixed title stored in the workflow environment: + +- `ISSUE_TITLE: Daily config_machines drift detected` + +The lifecycle is: + +1. If no matching open issue exists and drift is detected, create one. +2. If a matching open issue already exists and drift is still present, update + that same issue. +3. If no drift remains and the issue exists, close it. + +That means an unresolved drift while you are away does **not** produce a fresh +issue every day. The same issue remains open and is refreshed in place. + +A new issue would only be created if one of these is true: + +- the existing automation issue was manually closed while drift still exists, +- the issue title configured in the workflow was changed, +- the existing issue was deleted or otherwise no longer appears as an open + issue in the repository. + +## Reviewer workflow + +When Copilot opens a PR from this issue, the reviewer should check the changes +in this order. + +### 1. `config_machines.xml` changes + +Verify that the PR updates +`mache/cime_machine_config/config_machines.xml` only for supported machines +reported by the workflow, and that those changes match the current upstream +E3SM machine definitions. + +In practice, the easiest cross-check is to compare the PR against the report +artifact from the workflow run that opened or refreshed the issue. + +### 2. Related Spack updates + +If the report lists package groups or candidate Spack templates, check that the +PR updated the relevant `mache/spack/*.yaml` inputs and any version strings +that should track the new module or environment values. + +If the report does not indicate Spack-relevant drift, the PR should usually be +limited to `config_machines.xml`. + +### 3. Ambiguous path or prefix changes + +When upstream changes a path-like variable such as `NETCDF_PATH`, the correct +replacement in `mache` may not be obvious from the XML alone. + +In that case, the expected behavior is **not** to guess. The PR should leave a +TODO note for the reviewer and explain what needs confirmation. + +### 4. Validation + +At minimum, reviewers or PR authors should run the same local checks used by +development in this repository. + +Generate the current report locally: + +```bash +pixi run -e py314 python utils/update_cime_machine_config.py \ + --json-output /tmp/cime_machine_config_report.json \ + --markdown-output /tmp/cime_machine_config_report.md +``` + +Run the focused tests: + +```bash +pixi run -e py314 pytest tests/test_cime_machine_config_report.py +``` + +Run pre-commit on changed files before merging: + +```bash +pixi run -e py314 pre-commit run --files +``` + +## Manual dry run for maintainers + +To exercise the detection path without waiting for the cron schedule: + +1. Trigger the workflow manually with `workflow_dispatch`, or +2. Run `utils/update_cime_machine_config.py` locally in the Pixi environment. + +If `GH_CLI_TOKEN` is not configured, the workflow still generates and uploads +the report artifacts but skips issue synchronization. + +That is a safe way to validate the comparison and report rendering logic +without asking Copilot to act on the result. + +## Operational notes + +- `GH_CLI_TOKEN` should be a user token with access to create and update + issues in the repository. A classic PAT with `repo` scope is sufficient. +- Copilot assignment additionally depends on Copilot cloud agent being enabled + for the repository. +- The workflow uses the repository's current `main` branch as the comparison + baseline and as the branch Copilot is asked to target. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index f34e5070..1772ca48 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ users_guide/sync/diags developers_guide/quick_start developers_guide/contributing developers_guide/deploy +developers_guide/config_machines_updates developers_guide/adding_new_machine developers_guide/spack developers_guide/jigsaw diff --git a/mache/cime_machine_config/report.py b/mache/cime_machine_config/report.py new file mode 100644 index 00000000..1a67b22c --- /dev/null +++ b/mache/cime_machine_config/report.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import difflib +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone + +from lxml import etree + +from mache.spack.list import list_machine_compiler_mpilib +from mache.spack.shared import ( + PATH_LIKE_ENV_VARS, + classify_env_var_package_group, + classify_module_command_package_group, +) + +ISSUE_MARKER = '' +PREFIX_ENV_SUFFIXES = ('_DIR', '_HOME', '_PATH', '_PREFIX', '_ROOT') + + +@dataclass +class BlockChange: + selectors: dict[str, str] + added: list[str] + removed: list[str] + + +@dataclass +class MachineUpdate: + machine: str + diff_lines: list[str] + module_changes: list[BlockChange] + env_var_changes: list[BlockChange] + package_groups: list[str] + prefix_vars: list[str] + spack_templates_to_review: list[str] + + @property + def has_updates(self) -> bool: + return bool( + self.diff_lines + or self.module_changes + or self.env_var_changes + or self.package_groups + or self.prefix_vars + or self.spack_templates_to_review + ) + + +@dataclass +class UpdateReport: + generated_at: str + upstream_url: str + supported_machines: list[str] + machines: list[MachineUpdate] + + @property + def has_updates(self) -> bool: + return any(machine.has_updates for machine in self.machines) + + def to_dict(self) -> dict[str, object]: + data = asdict(self) + data['has_updates'] = self.has_updates + return data + + +def build_update_report(old_xml, new_xml, supported_machines, upstream_url): + """Build a structured report for supported machine config changes.""" + + old_root = etree.parse(old_xml).getroot() + new_root = etree.parse(new_xml).getroot() + template_map = _get_machine_template_map() + + machines: list[MachineUpdate] = [] + for machine in supported_machines: + old_machine = old_root.find(f".//machine[@MACH='{machine}']") + new_machine = new_root.find(f".//machine[@MACH='{machine}']") + update = _build_machine_update( + machine=machine, + old_machine=old_machine, + new_machine=new_machine, + template_map=template_map, + ) + if update.has_updates: + machines.append(update) + + return UpdateReport( + generated_at=datetime.now(timezone.utc).isoformat(), + upstream_url=upstream_url, + supported_machines=list(supported_machines), + machines=machines, + ) + + +def render_update_issue(report, run_url=None): + """Render the report as a GitHub issue body for Copilot work.""" + + if not report.has_updates: + lines = [ + ISSUE_MARKER, + '', + 'No supported machine updates were detected in the latest check.', + ] + return '\n'.join(lines) + + lines = [ + ISSUE_MARKER, + '', + 'The daily config_machines check found upstream changes for one or', + 'more supported machines.', + '', + f'- Generated: {report.generated_at}', + f'- Upstream source: {report.upstream_url}', + ] + + if run_url: + lines.append(f'- Workflow run: {run_url}') + + lines.extend( + [ + '', + 'Required work:', + '', + '- Update mache/cime_machine_config/config_machines.xml for the', + ' affected supported machines.', + '- If module or environment changes imply different', + ' system-package', + ' versions, update the corresponding mache Spack templates and', + ' version strings for the affected toolchains.', + '- If the only follow-up is module or version drift, keep the PR', + ' focused on those updates.', + '- If any prefix or path values changed and the correct', + ' replacement', + ' is not obvious, add a TODO comment in the PR for the reviewer', + ' instead of guessing.', + '', + 'Affected machines: ' + f'{", ".join(update.machine for update in report.machines)}', + ] + ) + + for machine in report.machines: + lines.extend(_render_machine_section(machine)) + + return '\n'.join(lines) + + +def write_report_json(report, filename): + """Write the report to a JSON file.""" + + with open(filename, 'w', encoding='utf-8') as handle: + json.dump(report.to_dict(), handle, indent=2) + handle.write('\n') + + +def _build_machine_update(machine, old_machine, new_machine, template_map): + diff_lines = _get_machine_diff( + old_machine=old_machine, + new_machine=new_machine, + ) + module_changes = _compare_block_maps( + old_blocks=_collect_module_blocks(old_machine), + new_blocks=_collect_module_blocks(new_machine), + ) + env_var_changes = _compare_block_maps( + old_blocks=_collect_env_var_blocks(old_machine), + new_blocks=_collect_env_var_blocks(new_machine), + ) + + package_groups = _get_package_groups( + module_changes=module_changes, + env_var_changes=env_var_changes, + ) + prefix_vars = _get_prefix_vars(env_var_changes) + + spack_templates = [] + if package_groups or prefix_vars: + spack_templates = template_map.get(machine, []) + + return MachineUpdate( + machine=machine, + diff_lines=diff_lines, + module_changes=module_changes, + env_var_changes=env_var_changes, + package_groups=package_groups, + prefix_vars=prefix_vars, + spack_templates_to_review=spack_templates, + ) + + +def _collect_module_blocks(machine): + blocks: dict[tuple[tuple[str, str], ...], set[str]] = {} + if machine is None: + return blocks + + for module_system in machine.findall('module_system'): + for modules in module_system.findall('modules'): + key = _selector_key(modules) + values = blocks.setdefault(key, set()) + for command in modules.findall('command'): + name = command.get('name', '').strip() + value = (command.text or '').strip() + entry = name if value == '' else f'{name} {value}' + values.add(entry) + + return blocks + + +def _collect_env_var_blocks(machine): + blocks: dict[tuple[tuple[str, str], ...], set[str]] = {} + if machine is None: + return blocks + + for env_vars in machine.findall('environment_variables'): + key = _selector_key(env_vars) + values = blocks.setdefault(key, set()) + for env_var in env_vars.findall('env'): + name = env_var.get('name', '').strip() + value = (env_var.text or '').strip() + values.add(f'{name}={value}') + + return blocks + + +def _compare_block_maps(old_blocks, new_blocks): + changes: list[BlockChange] = [] + keys = sorted(set(old_blocks) | set(new_blocks)) + for key in keys: + old_values = old_blocks.get(key, set()) + new_values = new_blocks.get(key, set()) + added = sorted(new_values - old_values) + removed = sorted(old_values - new_values) + if added or removed: + changes.append( + BlockChange( + selectors=dict(key), + added=added, + removed=removed, + ) + ) + + return changes + + +def _get_machine_diff(old_machine, new_machine): + old_text = _machine_to_text(old_machine) + new_text = _machine_to_text(new_machine) + return list( + difflib.unified_diff( + old_text.splitlines(), + new_text.splitlines(), + fromfile='old', + tofile='new', + lineterm='', + ) + ) + + +def _machine_to_text(machine): + if machine is None: + return '' + + return etree.tostring(machine, pretty_print=True).decode('utf-8') + + +def _get_package_groups(*, module_changes, env_var_changes): + groups: set[str] = set() + + for change in module_changes: + for entry in change.added + change.removed: + _name, _sep, value = entry.partition(' ') + if value == '': + continue + group = classify_module_command_package_group(value) + if group is not None: + groups.add(group) + + for change in env_var_changes: + for entry in change.added + change.removed: + name, _sep, value = entry.partition('=') + group = classify_env_var_package_group(name, value) + if group is not None: + groups.add(group) + + return sorted(groups) + + +def _get_prefix_vars(env_var_changes): + prefix_vars: set[str] = set() + + for change in env_var_changes: + for entry in change.added + change.removed: + name, _sep, value = entry.partition('=') + if _is_prefix_var(name=name, value=value): + prefix_vars.add(name) + + return sorted(prefix_vars) + + +def _is_prefix_var(*, name, value): + if name in PATH_LIKE_ENV_VARS: + return True + + if name.endswith(PREFIX_ENV_SUFFIXES): + return True + + return '/' in value or '${' in value or '$ENV{' in value + + +def _selector_key(element): + return tuple(sorted((key, value) for key, value in element.attrib.items())) + + +def _get_machine_template_map(): + template_map: dict[str, list[str]] = {} + for machine, compiler, mpilib in list_machine_compiler_mpilib(): + filename = f'{machine}_{compiler}_{mpilib}.yaml' + template_map.setdefault(machine, []).append(filename) + + for _machine, filenames in template_map.items(): + filenames.sort() + + return template_map + + +def _render_machine_section(machine): + lines = [ + '', + f'## {machine.machine}', + '', + ] + + if machine.package_groups: + lines.append( + f'- Package groups to review: {", ".join(machine.package_groups)}' + ) + else: + lines.append('- Package groups to review: none detected') + + if machine.prefix_vars: + lines.append( + '- Prefix/path variables changed: ' + f'{", ".join(machine.prefix_vars)}' + ) + else: + lines.append('- Prefix/path variables changed: none detected') + + if machine.spack_templates_to_review: + lines.append( + '- Spack templates to review: ' + f'{", ".join(machine.spack_templates_to_review)}' + ) + else: + lines.append('- Spack templates to review: none matched') + + if machine.module_changes: + lines.extend( + _render_change_details( + 'Module changes', + machine.module_changes, + ) + ) + + if machine.env_var_changes: + lines.extend( + _render_change_details( + 'Environment variable changes', + machine.env_var_changes, + ) + ) + + if machine.diff_lines: + lines.extend( + [ + '', + '
', + 'XML diff', + '', + '```diff', + *machine.diff_lines, + '```', + '
', + ] + ) + + return lines + + +def _render_change_details(title, changes): + lines = [ + '', + '
', + f'{title}', + '', + ] + + for change in changes: + selector_text = _format_selectors(change.selectors) + lines.append(f'- Selector: {selector_text}') + for entry in change.added: + lines.append(f' - Added: {entry}') + for entry in change.removed: + lines.append(f' - Removed: {entry}') + + lines.append('
') + return lines + + +def _format_selectors(selectors): + if len(selectors) == 0: + return 'all matching toolchains' + + return ', '.join(f'{key}={value}' for key, value in selectors.items()) diff --git a/tests/test_cime_machine_config_report.py b/tests/test_cime_machine_config_report.py new file mode 100644 index 00000000..a0702d86 --- /dev/null +++ b/tests/test_cime_machine_config_report.py @@ -0,0 +1,109 @@ +from mache.cime_machine_config.report import ( + build_update_report, + render_update_issue, +) + + +def test_build_update_report_detects_spack_related_drift( + monkeypatch, tmp_path +): + old_xml = tmp_path / 'old.xml' + new_xml = tmp_path / 'new.xml' + old_xml.write_text( + '\n' + ' \n' + ' \n' + ' \n' + ' netcdf/4.9.2\n' + ' \n' + ' \n' + ' \n' + ' /old/netcdf\n' + ' \n' + ' \n' + '\n', + encoding='utf-8', + ) + new_xml.write_text( + '\n' + ' \n' + ' \n' + ' \n' + ' netcdf/4.9.3\n' + ' \n' + ' \n' + ' \n' + ' /new/netcdf\n' + ' \n' + ' \n' + '\n', + encoding='utf-8', + ) + + monkeypatch.setattr( + 'mache.cime_machine_config.report.list_machine_compiler_mpilib', + lambda: [('test-machine', 'gnu', 'mpich')], + ) + + report = build_update_report( + old_xml=old_xml, + new_xml=new_xml, + supported_machines=['test-machine'], + upstream_url='https://example.invalid/config_machines.xml', + ) + + assert report.has_updates is True + assert len(report.machines) == 1 + + machine = report.machines[0] + assert machine.machine == 'test-machine' + assert machine.package_groups == ['netcdf'] + assert machine.prefix_vars == ['NETCDF_PATH'] + assert machine.spack_templates_to_review == ['test-machine_gnu_mpich.yaml'] + assert machine.module_changes[0].selectors == { + 'compiler': 'gnu', + 'mpilib': 'mpich', + } + assert machine.module_changes[0].added == ['load netcdf/4.9.3'] + assert machine.module_changes[0].removed == ['load netcdf/4.9.2'] + + +def test_render_update_issue_includes_required_instructions( + monkeypatch, tmp_path +): + old_xml = tmp_path / 'old.xml' + new_xml = tmp_path / 'new.xml' + xml_text = ( + '\n' + ' \n' + ' \n' + ' \n' + ' cmake/3.20.0\n' + ' \n' + ' \n' + ' \n' + '\n' + ) + old_xml.write_text(xml_text, encoding='utf-8') + new_xml.write_text(xml_text.replace('3.20.0', '3.30.0'), encoding='utf-8') + + monkeypatch.setattr( + 'mache.cime_machine_config.report.list_machine_compiler_mpilib', + lambda: [('test-machine', 'gnu', 'mpich')], + ) + + report = build_update_report( + old_xml=old_xml, + new_xml=new_xml, + supported_machines=['test-machine'], + upstream_url='https://example.invalid/config_machines.xml', + ) + markdown = render_update_issue( + report, + run_url='https://github.example/actions/runs/1', + ) + + assert 'Update mache/cime_machine_config/config_machines.xml' in markdown + assert 'TODO comment in the PR for the reviewer' in markdown + assert 'test-machine_gnu_mpich.yaml' in markdown + assert 'https://github.example/actions/runs/1' in markdown diff --git a/utils/manage_cime_machine_config_issue.py b/utils/manage_cime_machine_config_issue.py new file mode 100644 index 00000000..5e231164 --- /dev/null +++ b/utils/manage_cime_machine_config_issue.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json + +import requests + +COPILOT_LOGIN = 'copilot-swe-agent[bot]' +DEFAULT_API_VERSION = '2022-11-28' + + +def main(): + """Create, update, or close the automation issue for config drift.""" + + parser = argparse.ArgumentParser( + description='Synchronize the config_machines automation issue.', + ) + parser.add_argument('--report-json', required=True) + parser.add_argument('--report-markdown', required=True) + parser.add_argument('--repository', required=True) + parser.add_argument('--token', required=True) + parser.add_argument('--issue-title', required=True) + parser.add_argument('--base-branch', required=True) + parser.add_argument('--primary-assignee', default='') + args = parser.parse_args() + + with open(args.report_json, encoding='utf-8') as handle: + report = json.load(handle) + + with open(args.report_markdown, encoding='utf-8') as handle: + body = handle.read() + + session = _make_session(args.token) + owner, repo = _split_repository(args.repository) + issue = _find_existing_issue( + session=session, + owner=owner, + repo=repo, + issue_title=args.issue_title, + ) + + if report['has_updates']: + create_or_update_issue( + session=session, + owner=owner, + repo=repo, + issue=issue, + issue_title=args.issue_title, + body=body, + primary_assignee=args.primary_assignee, + base_branch=args.base_branch, + ) + return + + if issue is not None: + close_issue( + session=session, + owner=owner, + repo=repo, + issue_number=issue['number'], + ) + print(f'Closed issue #{issue["number"]} because no updates remain.') + return + + print('No updates detected and no open automation issue found.') + + +def create_or_update_issue( + *, + session, + owner, + repo, + issue, + issue_title, + body, + primary_assignee, + base_branch, +): + """Create or update the automation issue, with Copilot fallback.""" + + payload = build_issue_payload( + issue_title=issue_title, + body=body, + primary_assignee=primary_assignee, + owner=owner, + repo=repo, + base_branch=base_branch, + assign_copilot=True, + ) + + try: + if issue is None: + created = _post_issue( + session=session, + owner=owner, + repo=repo, + payload=payload, + ) + print( + 'Created automation issue ' + f'#{created["number"]} with Copilot assignment.' + ) + else: + updated = _patch_issue( + session=session, + owner=owner, + repo=repo, + issue_number=issue['number'], + payload=payload, + ) + print( + 'Updated automation issue ' + f'#{updated["number"]} with Copilot assignment.' + ) + return + except requests.HTTPError as error: + warning = ( + 'Automation note: Copilot assignment failed. Check that Copilot ' + 'cloud agent is enabled for this repository and that the token ' + 'used by this workflow is a user token with write access to ' + 'actions, contents, issues, and pull requests.\n\n' + ) + fallback_payload = build_issue_payload( + issue_title=issue_title, + body=warning + body, + primary_assignee=primary_assignee, + owner=owner, + repo=repo, + base_branch=base_branch, + assign_copilot=False, + ) + if issue is None: + created = _post_issue( + session=session, + owner=owner, + repo=repo, + payload=fallback_payload, + ) + print( + 'Created automation issue ' + f'#{created["number"]} without Copilot assignment: {error}' + ) + else: + updated = _patch_issue( + session=session, + owner=owner, + repo=repo, + issue_number=issue['number'], + payload=fallback_payload, + ) + print( + 'Updated automation issue ' + f'#{updated["number"]} without Copilot assignment: {error}' + ) + + +def close_issue(*, session, owner, repo, issue_number): + """Close the automation issue once drift has been resolved.""" + + payload = {'state': 'closed', 'state_reason': 'completed'} + _patch_issue( + session=session, + owner=owner, + repo=repo, + issue_number=issue_number, + payload=payload, + ) + + +def build_issue_payload( + *, + issue_title, + body, + primary_assignee, + owner, + repo, + base_branch, + assign_copilot, +): + """Build the REST payload for creating or updating the issue.""" + + assignees = [] + if primary_assignee != '': + assignees.append(primary_assignee) + if assign_copilot: + assignees.append(COPILOT_LOGIN) + + payload = { + 'title': issue_title, + 'body': body, + 'assignees': assignees, + } + if assign_copilot: + payload['agent_assignment'] = { + 'target_repo': f'{owner}/{repo}', + 'base_branch': base_branch, + 'custom_instructions': ( + 'Use the issue body as the task definition. Update ' + 'config_machines.xml first, then the related Spack ' + 'templates and version strings. Add TODO comments in the ' + 'PR when prefix or path changes need reviewer confirmation.' + ), + 'custom_agent': '', + 'model': '', + } + + return payload + + +def _make_session(token): + session = requests.Session() + session.headers.update( + { + 'Accept': 'application/vnd.github+json', + 'Authorization': f'Bearer {token}', + 'X-GitHub-Api-Version': DEFAULT_API_VERSION, + } + ) + return session + + +def _find_existing_issue(*, session, owner, repo, issue_title): + issues = _request_json( + session=session, + method='GET', + url=f'https://api.github.com/repos/{owner}/{repo}/issues', + params={'state': 'open', 'per_page': 100}, + ) + for issue in issues: + if 'pull_request' in issue: + continue + if issue.get('title') == issue_title: + return issue + return None + + +def _post_issue(*, session, owner, repo, payload): + return _request_json( + session=session, + method='POST', + url=f'https://api.github.com/repos/{owner}/{repo}/issues', + json_payload=payload, + ) + + +def _patch_issue(*, session, owner, repo, issue_number, payload): + return _request_json( + session=session, + method='PATCH', + url=f'https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}', + json_payload=payload, + ) + + +def _request_json(*, session, method, url, params=None, json_payload=None): + response = session.request( + method, + url, + params=params, + json=json_payload, + timeout=60, + ) + response.raise_for_status() + if response.status_code == 204 or response.text == '': + return None + return response.json() + + +def _split_repository(repository): + owner, repo = repository.split('/', maxsplit=1) + return owner, repo + + +if __name__ == '__main__': + main() diff --git a/utils/update_cime_machine_config.py b/utils/update_cime_machine_config.py index e865360a..c2d47dda 100755 --- a/utils/update_cime_machine_config.py +++ b/utils/update_cime_machine_config.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 import argparse +import os +import tempfile import mache.version -from mache.cime_machine_config import ( - compare_machine_configs, - extract_supported_machines, +from mache.cime_machine_config.report import ( + build_update_report, + render_update_issue, + write_report_json, ) from mache.io import download_file from mache.machines import get_supported_machines @@ -17,8 +20,8 @@ def main(): them, then compare the machine configurations between the old and new XML. """ parser = argparse.ArgumentParser( - description='Get and display the updates made to ' - 'config_supported_machines.xml', + description='Compare supported machine config_machines entries ' + 'against the latest upstream CIME source.', ) parser.add_argument( @@ -28,32 +31,99 @@ def main(): version=mache.version.__version__, help='Show version number and exit', ) - - parser.parse_args() - - url = ( - 'https://raw.githubusercontent.com/E3SM-Project/E3SM/refs/heads/' - 'master/cime_config/machines/config_machines.xml' + parser.add_argument( + '--upstream-url', + default=( + 'https://raw.githubusercontent.com/E3SM-Project/E3SM/' + 'refs/heads/master/cime_config/machines/config_machines.xml' + ), + help='The upstream config_machines.xml URL to compare against.', ) - new_filename = 'new_config_machines.xml' - download_file(url, new_filename) - machines = get_supported_machines() - extract_supported_machines( - new_filename, 'new_config_supported_machines.xml', machines + parser.add_argument( + '--json-output', + help='Optional JSON file to write the structured update report to.', + ) + parser.add_argument( + '--markdown-output', + help='Optional Markdown file to write the issue body to.', + ) + parser.add_argument( + '--run-url', + help='Optional workflow run URL to include in the rendered report.', + ) + parser.add_argument( + '--work-dir', + help='Optional directory for temporary XML comparison files.', ) - old_filename = 'mache/cime_machine_config/config_machines.xml' - extract_supported_machines( - old_filename, 'old_config_supported_machines.xml', machines + args = parser.parse_args() + + report = build_report( + upstream_url=args.upstream_url, + work_dir=args.work_dir, ) + print_console_report(report) + + if args.json_output: + write_report_json(report, args.json_output) + + if args.markdown_output: + markdown = render_update_issue(report, run_url=args.run_url) + with open(args.markdown_output, 'w', encoding='utf-8') as handle: + handle.write(markdown) + handle.write('\n') - for machine in machines: - compare_machine_configs( - 'old_config_supported_machines.xml', - 'new_config_supported_machines.xml', - machine, + +def build_report(*, upstream_url, work_dir=None): + """Download upstream config and return a structured drift report.""" + + if work_dir is not None: + return _build_report_in_dir( + upstream_url=upstream_url, + work_dir=work_dir, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + return _build_report_in_dir( + upstream_url=upstream_url, + work_dir=temp_dir, ) +def print_console_report(report): + """Print a concise human-readable summary to stdout.""" + + if not report.has_updates: + print('No supported machine updates detected.') + return + + print('Supported machine updates detected:') + for machine in report.machines: + print(f'- {machine.machine}') + if machine.package_groups: + package_groups = ', '.join(machine.package_groups) + print(f' package groups: {package_groups}') + if machine.prefix_vars: + prefix_vars = ', '.join(machine.prefix_vars) + print(f' prefix/path vars: {prefix_vars}') + if machine.spack_templates_to_review: + templates = ', '.join(machine.spack_templates_to_review) + print(f' spack templates: {templates}') + + +def _build_report_in_dir(*, upstream_url, work_dir): + machines = get_supported_machines() + upstream_filename = os.path.join(work_dir, 'upstream_config_machines.xml') + old_filename = 'mache/cime_machine_config/config_machines.xml' + + download_file(upstream_url, upstream_filename) + return build_update_report( + old_xml=old_filename, + new_xml=upstream_filename, + supported_machines=machines, + upstream_url=upstream_url, + ) + + if __name__ == '__main__': main()