diff --git a/.github/workflows/hourly-pr-disposition.yml b/.github/workflows/hourly-pr-disposition.yml index 020d6b9a..b6cab94d 100644 --- a/.github/workflows/hourly-pr-disposition.yml +++ b/.github/workflows/hourly-pr-disposition.yml @@ -1,8 +1,6 @@ name: Hourly PR disposition on: - schedule: - - cron: "11 * * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/hourly-pr-maintenance.yml b/.github/workflows/hourly-pr-maintenance.yml new file mode 100644 index 00000000..e39b096f --- /dev/null +++ b/.github/workflows/hourly-pr-maintenance.yml @@ -0,0 +1,31 @@ +name: Hourly PR maintenance + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +concurrency: + group: hourly-pr-maintenance + cancel-in-progress: false + +permissions: + actions: write + checks: read + contents: write + id-token: write + pull-requests: write + +jobs: + maintain-pull-requests: + uses: ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@6eb06cdd08c79a06f7b390069d4ffa49e2eb7dba + with: + base_branch: develop + project_flow: git-flow + max_prs: "100" + trigger_reviews: true + review_dispatch_limit: "100" + enable_auto_merge: true + merge_mode: direct_or_auto + update_branches: true + branch_update_limit: "100" diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c3e4db..803e9407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Hourly pull-request maintenance now delegates review dispatch, branch updates, current-head gate revalidation, and policy-governed merge handling to the centrally governed scheduler pinned by immutable commit; the former repository-local disposition workflow remains available only as a manual fail-closed fallback so duplicate scheduled merge authorities cannot race. - Production container builds now use digest-pinned Docker base images while retaining readable Maven/Temurin tags, preventing upstream tag movement from silently changing reviewed build inputs. - Durable `POST /api/etl/jobs` submissions now return RFC 9110 `202 Accepted`, a stable pending-job representation, `Location` status-monitor metadata, and explicit replay metadata without changing the synchronous `/api/etl/process` contract. The incomplete intake controller is fail-closed and requires explicit `xtrmetl.etl.jobs.intake-enabled=true` operator opt-in until worker execution and terminal payload clearing are implemented. - Concurrent requests using the same authenticated-principal-scoped semantic idempotency key now return immediate RFC 9457 `409 etl_idempotency_request_in_progress` responses through PostgreSQL `pg_try_advisory_xact_lock`; retries after completion still replay the committed response. diff --git a/docs/hourly-pr-disposition.md b/docs/hourly-pr-disposition.md index 4c8964be..a216e44e 100644 --- a/docs/hourly-pr-disposition.md +++ b/docs/hourly-pr-disposition.md @@ -1,12 +1,37 @@ -# Hourly pull-request disposition loop +# Pull-request maintenance and manual disposition -mightyETL runs `.github/workflows/hourly-pr-disposition.yml` at minute 11 of every hour and on manual dispatch. +mightyETL runs `.github/workflows/hourly-pr-maintenance.yml` at minute 17 of every hour and on manual dispatch. The repository-local caller is intentionally small: it delegates the queue to the centrally governed `ContextualWisdomLab/.github` reusable scheduler pinned to commit `6eb06cdd08c79a06f7b390069d4ffa49e2eb7dba`. -The workflow automates only the merge-disposition portion of the maintenance loop. It does not invent product changes, bypass reviews, or execute code from pull-request branches. +The central route performs the operational sequence needed to keep the pull-request queue moving: -## Eligibility gates +1. inspect up to 100 open pull requests targeting `develop`; +2. request missing current-head reviews within the bounded run budget; +3. update eligible outdated branches; +4. re-evaluate current-head reviews, review threads, checks, commit statuses, and mergeability; +5. use policy-governed direct merge or auto-merge only when the protected repository rules permit it. -A pull request is merged only when all of the following hold: +Branch protection, expected-head semantics, exact-head review evidence, and required checks remain authoritative. A scheduled run cannot manufacture an approval, reinterpret a failed or pending check as successful, or bypass a repository rule. + +## One scheduled authority + +`.github/workflows/hourly-pr-maintenance.yml` is the only scheduled pull-request maintenance authority in this repository. It uses single-flight concurrency with cancellation disabled, so a later hourly tick cannot discard an in-progress queue evaluation. + +The former `.github/workflows/hourly-pr-disposition.yml` remains available through `workflow_dispatch` only as a **manual fail-closed fallback**. It is intentionally unscheduled so there is **no duplicate scheduled merge authority** racing the central review, branch-update, and merge scheduler. + +Use the manual fallback only when the central reusable workflow is unavailable and an operator needs to disposition already reviewed, already green pull requests. It does not request reviews, repair code, update branches, or execute pull-request contents. + +## Central caller security properties + +- The reusable workflow is referenced by immutable commit SHA rather than a mutable branch or tag. +- The caller grants only `actions`, `checks`, `contents`, `id-token`, and `pull-requests` permissions required by the central scheduler. +- The caller does not inherit repository secrets. +- `NVIDIA_NIM_API_KEY` and independent reviewer credentials are not passed to the merge scheduler. +- The central scheduler obtains its bounded GitHub App authority through its own OIDC exchange and validates the live target repository, base branch, pull-request number, and current head before mutation. +- Neither the caller nor the manual fallback checks out or executes untrusted pull-request code. + +## Manual fallback eligibility gates + +The manual fallback merges a pull request only when all of the following hold: 1. The base branch is `develop` and the pull request is not a draft. 2. The author is the configured trusted maintainer and the head branch belongs to this repository. @@ -18,18 +43,8 @@ A pull request is merged only when all of the following hold: 8. No other reported check has failed or remains pending, commit status contexts are successful, and GitHub reports the pull request as cleanly mergeable. 9. The merge request includes the expected head SHA, preventing a time-of-check/time-of-use merge after the branch moves. -Eligible pull requests are squash-merged. GitHub branch protection remains authoritative and can still reject a merge. A rejection is recorded for that pull request without aborting disposition of the remaining queue. - -## Security properties - -- The scheduled workflow runs from the protected default branch, not from untrusted pull-request code. -- It does not check out or execute pull-request contents. -- It uses the repository-scoped `GITHUB_TOKEN` with only `contents`, `pull-requests`, `checks`, and `statuses` permissions. -- External forks and untrusted authors are never merged unattended. -- Changes to workflow files require a separate explicit label and therefore remain manual by default. -- GraphQL review-thread pagination prevents unresolved comments beyond the first page from being ignored. -- The expected head SHA prevents a branch update from being merged under stale check results. +Eligible pull requests are squash-merged. GitHub branch protection can still reject a merge; that rejection affects only the current pull request and does not abort inspection of the remaining queue. ## Scope boundary -The hourly workflow disposes eligible pull requests. Product discovery, implementation, review remediation, release decisions, and buyer-gap analysis still require an authenticated development agent or maintainer session. This separation prevents a cron job from manufacturing or approving unreviewed code while keeping a green, fully reviewed queue from remaining open unnecessarily. +The hourly caller and manual fallback dispose pull-request work that already exists. Buyer-gap discovery, test-first implementation, scientific validation, release decisions, and product development remain separate authenticated development-agent responsibilities. This separation keeps merge authority deterministic while allowing the OpenCode development loop to use `NVIDIA_NIM_API_KEY` without giving a model credential to the scheduler. diff --git a/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrMaintenanceCallerTest.java b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrMaintenanceCallerTest.java new file mode 100644 index 00000000..8baa619a --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/documentation/HourlyPrMaintenanceCallerTest.java @@ -0,0 +1,150 @@ +package com.xtrmetl.etl.documentation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the repository-local, hourly caller for the centrally governed pull-request maintenance + * workflow. + * + *

The caller schedules review inspection, bounded repair dispatch, current-head check + * revalidation, and policy-governed merge handling without copying the central implementation or + * inheriting unrelated repository secrets.

+ */ +class HourlyPrMaintenanceCallerTest { + + private static String workflow; + private static String legacyDispositionWorkflow; + private static String documentation; + private static String changelog; + + /** + * Reads the production caller and its operator evidence once with normalized line endings. + * + * @throws IOException when a required repository artifact exists but cannot be read + */ + @BeforeAll + static void readWorkflow() throws IOException { + Path root = projectRoot(); + Path workflowPath = root.resolve( + ".github/workflows/hourly-pr-maintenance.yml" + ); + assertTrue(Files.exists(workflowPath), "The hourly PR maintenance caller must exist"); + workflow = readNormalized(workflowPath); + legacyDispositionWorkflow = readNormalized( + root.resolve(".github/workflows/hourly-pr-disposition.yml") + ); + documentation = readNormalized(root.resolve("docs/hourly-pr-disposition.md")); + changelog = readNormalized(root.resolve("CHANGELOG.md")); + } + + /** Verifies one serialized run each hour with an explicit manual recovery trigger. */ + @Test + void schedulesOneHourlySerializedMaintenanceRun() { + assertTrue(workflow.contains("cron: \"17 * * * *\"")); + assertTrue(workflow.contains("workflow_dispatch:")); + assertTrue(workflow.contains("group: hourly-pr-maintenance")); + assertTrue(workflow.contains("cancel-in-progress: false")); + } + + /** Verifies immutable central implementation reuse rather than repository-local duplication. */ + @Test + void pinsTheCentralSchedulerToAnImmutableCommit() { + assertTrue(workflow.contains( + "uses: ContextualWisdomLab/.github/.github/workflows/" + + "pr-review-merge-scheduler.yml@" + + "6eb06cdd08c79a06f7b390069d4ffa49e2eb7dba" + )); + assertTrue(workflow.contains("base_branch: develop")); + assertTrue(workflow.contains("max_prs: \"100\"")); + assertTrue(workflow.contains("trigger_reviews: true")); + assertTrue(workflow.contains("enable_auto_merge: true")); + assertTrue(workflow.contains("merge_mode: direct_or_auto")); + assertTrue(workflow.contains("update_branches: true")); + } + + /** Verifies the caller grants only the capabilities required by the reusable scheduler. */ + @Test + void grantsBoundedSchedulerPermissionsWithoutSecretInheritance() { + assertTrue(workflow.contains("actions: write")); + assertTrue(workflow.contains("checks: read")); + assertTrue(workflow.contains("contents: write")); + assertTrue(workflow.contains("id-token: write")); + assertTrue(workflow.contains("pull-requests: write")); + assertFalse(workflow.contains("secrets: inherit")); + assertFalse(workflow.contains("COPILOT_GITHUB_TOKEN")); + assertFalse(workflow.contains("NVIDIA_NIM_API_KEY")); + assertFalse(workflow.contains("security-events: write")); + } + + /** Verifies the previous local merger remains available only as a manual fail-closed fallback. */ + @Test + void keepsLegacyDispositionAsManualOnlyFallback() { + assertTrue(legacyDispositionWorkflow.contains("workflow_dispatch:")); + assertFalse(legacyDispositionWorkflow.contains("schedule:")); + assertFalse(legacyDispositionWorkflow.contains("cron: \"11 * * * *\"")); + } + + /** Verifies operators can identify the one scheduled authority and its manual fallback. */ + @Test + void documentsCentralAuthorityAndManualFallback() { + assertTrue(documentation.contains(".github/workflows/hourly-pr-maintenance.yml")); + assertTrue(documentation.contains("minute 17")); + assertTrue(documentation.contains("centrally governed")); + assertTrue(documentation.contains(".github/workflows/hourly-pr-disposition.yml")); + assertTrue(documentation.contains("manual fail-closed fallback")); + assertTrue(documentation.contains("no duplicate scheduled merge authority")); + } + + /** Verifies the maintenance-authority change is discoverable in release history. */ + @Test + void recordsMaintenanceAuthorityChange() { + assertTrue(changelog.contains("Hourly pull-request maintenance")); + assertTrue(changelog.contains("manual fail-closed fallback")); + } + + /** + * Reads one UTF-8 repository artifact and normalizes platform line endings. + * + * @param path repository artifact to read + * @return normalized UTF-8 text + * @throws IOException when the artifact cannot be read + */ + private static String readNormalized(Path path) throws IOException { + return Files.readString(path, StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .replace('\r', '\n'); + } + + /** + * Finds the repository root from either reactor-root or module-local Maven execution. + * + * @return absolute repository root + */ + private static Path projectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPomParent = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPomParent = current; + } + current = current.getParent(); + } + if (lastPomParent != null) { + return lastPomParent; + } + throw new IllegalStateException("Could not find project root"); + } +}