diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index 0bfb56a..e465ad5 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -21,15 +21,9 @@ on: # zizmor: ignore[dangerous-triggers] - created pull_request_target: types: - - assigned - - closed - - edited - labeled - opened - - reopened - - review_request_removed - review_requested - - unassigned schedule: - cron: 1 0 * * 1 workflow_dispatch: {} @@ -41,9 +35,6 @@ jobs: name: Run automations uses: learningequality/.github/.github/workflows/automation.yml@main secrets: - CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} - CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} - GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/automation-group-issues.yml b/.github/workflows/automation-group-issues.yml new file mode 100644 index 0000000..4438def --- /dev/null +++ b/.github/workflows/automation-group-issues.yml @@ -0,0 +1,52 @@ +# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# Reusable workflow called from automation.yml: the automations triggered by issues. +name: "Automation: Issues" +on: + workflow_call: + secrets: + LE_BOT_APP_ID: + description: "Forwarded to: issue-open, issue-label-header, good-first-issue-comment, community-contribution-label" + required: true + LE_BOT_PRIVATE_KEY: + description: "Forwarded to: issue-open, issue-label-header, good-first-issue-comment, community-contribution-label" + required: true +jobs: + issue-open: + name: Issue Open + if: ${{ github.event_name == 'issues' && contains(fromJSON('["opened","reopened"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + issue-label-header: + name: Issue Label Header + if: ${{ github.event_name == 'issues' && contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name == 'help wanted' }} + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + good-first-issue-comment: + name: Good First Issue Comment + if: ${{ github.event_name == 'issues' && github.event.action == 'labeled' && (github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted') }} + uses: learningequality/.github/.github/workflows/good-first-issue-comment.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + community-contribution-label: + name: Community Contribution Label + if: ${{ github.event_name == 'issues' && contains(fromJSON('["assigned","unassigned"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/community-contribution-label.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/automation-group-pull-request-target.yml b/.github/workflows/automation-group-pull-request-target.yml new file mode 100644 index 0000000..6bb2f97 --- /dev/null +++ b/.github/workflows/automation-group-pull-request-target.yml @@ -0,0 +1,56 @@ +# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# Reusable workflow called from automation.yml: the automations triggered by pull_request_target. +name: "Automation: Pull Request Target" +on: + workflow_call: + secrets: + LE_BOT_APP_ID: + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply" + required: true + LE_BOT_PRIVATE_KEY: + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply" + required: true + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: + description: "Forwarded to: contributor-pr-reply" + required: false +jobs: + review-requested: + name: Review Requested + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'review_requested' }} + uses: learningequality/.github/.github/workflows/review-requested.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + pull-request-label: + name: Pull Request Label + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'labeled' }} + uses: learningequality/.github/.github/workflows/pull-request-label.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + dependabot-reviewer: + name: Dependabot Reviewer + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]' }} + uses: learningequality/.github/.github/workflows/dependabot-reviewer.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + contributor-pr-reply: + name: Contributor PR Reply + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }} + uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index aa6c3d2..fea55fd 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -2,25 +2,26 @@ # Do not edit it by hand - edit the registry and run: # node scripts/generate-automation.js # -# Reusable workflow: one job per automation, gated on the original triggering event/action. +# Reusable workflow: one job per automation, or per group of automations sharing trigger events, +# gated on the original triggering event/action. name: Automation on: workflow_call: secrets: CONTRIBUTIONS_SHEET_NAME: - description: "Forwarded to: update-pr-spreadsheet" + description: Unused - accepted until every consumer stops forwarding it required: false CONTRIBUTIONS_SPREADSHEET_ID: - description: "Forwarded to: update-pr-spreadsheet" + description: Unused - accepted until every consumer stops forwarding it required: false GH_UPLOADER_GCP_SA_CREDENTIALS: - description: "Forwarded to: update-pr-spreadsheet" + description: Unused - accepted until every consumer stops forwarding it required: false LE_BOT_APP_ID: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, update-pr-spreadsheet, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" required: true LE_BOT_PRIVATE_KEY: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, update-pr-spreadsheet, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" required: true SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: description: "Forwarded to: contributor-pr-reply, contributor-issue-comment, unassign-inactive-issues" @@ -29,86 +30,20 @@ on: description: "Forwarded to: contributor-issue-comment" required: false jobs: - review-requested: - name: Review Requested - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'review_requested' }} - uses: learningequality/.github/.github/workflows/review-requested.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - pull-request-label: - name: Pull Request Label - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'labeled' }} - uses: learningequality/.github/.github/workflows/pull-request-label.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - dependabot-reviewer: - name: Dependabot Reviewer - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]' }} - uses: learningequality/.github/.github/workflows/dependabot-reviewer.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - contributor-pr-reply: - name: Contributor PR Reply - if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }} - uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main + pull-request-target: + name: Pull Request Target + if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'review_requested') || (github.event_name == 'pull_request_target' && github.event.action == 'labeled') || (github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]') || (github.event_name == 'pull_request_target' && github.event.action == 'opened') }} + uses: learningequality/.github/.github/workflows/automation-group-pull-request-target.yml@main permissions: contents: read secrets: LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} - issue-open: - name: Issue Open - if: ${{ github.event_name == 'issues' && contains(fromJSON('["opened","reopened"]'), github.event.action) }} - uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - issue-label-header: - name: Issue Label Header - if: ${{ github.event_name == 'issues' && contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name == 'help wanted' }} - uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - good-first-issue-comment: - name: Good First Issue Comment - if: ${{ github.event_name == 'issues' && github.event.action == 'labeled' && (github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted') }} - uses: learningequality/.github/.github/workflows/good-first-issue-comment.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - update-pr-spreadsheet: - name: Update PR Spreadsheet - if: ${{ github.event_name == 'pull_request_target' && contains(fromJSON('["assigned","unassigned","opened","closed","reopened","edited","review_requested","review_request_removed"]'), github.event.action) }} - uses: learningequality/.github/.github/workflows/update-pr-spreadsheet.yml@main - permissions: - contents: read - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} - CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} - GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} - community-contribution-label: - name: Community Contribution Label - if: ${{ github.event_name == 'issues' && contains(fromJSON('["assigned","unassigned"]'), github.event.action) }} - uses: learningequality/.github/.github/workflows/community-contribution-label.yml@main + issues: + name: Issues + if: ${{ (github.event_name == 'issues' && contains(fromJSON('["opened","reopened"]'), github.event.action)) || (github.event_name == 'issues' && contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name == 'help wanted') || (github.event_name == 'issues' && github.event.action == 'labeled' && (github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted')) || (github.event_name == 'issues' && contains(fromJSON('["assigned","unassigned"]'), github.event.action)) }} + uses: learningequality/.github/.github/workflows/automation-group-issues.yml@main permissions: contents: read secrets: diff --git a/.github/workflows/update-pr-spreadsheet.yml b/.github/workflows/update-pr-spreadsheet.yml index 40e0394..dca9c80 100644 --- a/.github/workflows/update-pr-spreadsheet.yml +++ b/.github/workflows/update-pr-spreadsheet.yml @@ -1,33 +1,21 @@ name: Update community pull requests spreadsheet on: - workflow_call: - secrets: - LE_BOT_APP_ID: - description: 'GitHub App ID for authentication' - required: true - LE_BOT_PRIVATE_KEY: - description: 'GitHub App Private Key for authentication' - required: true - CONTRIBUTIONS_SPREADSHEET_ID: - required: true - CONTRIBUTIONS_SHEET_NAME: - required: true - GH_UPLOADER_GCP_SA_CREDENTIALS: - required: true + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + inputs: + days: + description: 'Include PRs updated in the last N days' + default: '2' + dry_run: + description: 'Log the rows instead of writing them' + type: boolean + default: false +permissions: + contents: read jobs: - check-if-contributor: - name: Check if author is contributor - uses: learningequality/.github/.github/workflows/is-contributor.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - with: - username: ${{ github.event.pull_request.user.login }} - author_association: ${{ github.event.pull_request.author_association }} update-spreadsheet: - needs: [check-if-contributor] runs-on: ubuntu-latest - if: ${{ needs.check-if-contributor.outputs.is_contributor == 'true' }} steps: - name: Generate App Token id: generate-token @@ -38,9 +26,7 @@ jobs: - name: Checkout .github repository uses: actions/checkout@v7 with: - repository: learningequality/.github - ref: main - token: ${{ steps.generate-token.outputs.token }} + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v7 with: @@ -59,3 +45,5 @@ jobs: CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} GOOGLE_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} + LOOKBACK_DAYS: ${{ inputs.days || '2' }} + DRY_RUN: ${{ inputs.dry_run || false }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f22de7..7a23477 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: rev: v0.14.0 hooks: - id: yamlfmt - exclude: '^tests/cassettes|^\.github/workflows/automation(-caller)?\.yml$|^automation-template\.yml$' + exclude: '^tests/cassettes|^\.github/workflows/automation(-caller|-group-.+)?\.yml$|^automation-template\.yml$' - repo: https://github.com/rhysd/actionlint rev: v1.7.7 hooks: @@ -26,5 +26,5 @@ repos: name: Check automation.yml / automation-template.yml are up to date entry: node scripts/generate-automation.js --check language: system - files: '^(automation-registry\.yml|scripts/generate-automation\.js|\.github/workflows/automation(-caller)?\.yml|automation-template\.yml)$' + files: '^(automation-registry\.yml|scripts/generate-automation\.js|\.github/workflows/automation(-caller|-group-.+)?\.yml|automation-template\.yml)$' pass_filenames: false diff --git a/automation-registry.yml b/automation-registry.yml index 4e5d72f..2080521 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -5,16 +5,23 @@ # leaf - the reusable workflow file (in .github/workflows/) it dispatches to # on - events/types (or schedule/workflow_dispatch) it should be triggered by; # unioned across all enabled automations to build automation-template.yml's `on:` block -# if - the dispatch condition used as the job's `if:` in automation.yml +# if - the dispatch condition used as the automation's job `if:` # secrets - secrets to declare on automation.yml and forward to the leaf; # required/optional: whether the leaf workflow requires the secret to be set # permissions - job-level permissions map; defaults to { contents: read } when absent; # set explicitly for automations whose leaf uses the default GITHUB_TOKEN with write access # +# retired_secrets lists secrets no automation uses that automation.yml still accepts, because GitHub +# fails a caller that forwards an undeclared secret. Remove one once every consumer has synced. +# # Run `node scripts/generate-automation.js` after editing this file to regenerate # automation.yml and automation-template.yml. `node scripts/generate-automation.js --check` # fails if the generated files are out of date (enforced by pre-commit + CI). +retired_secrets: + - CONTRIBUTIONS_SHEET_NAME + - CONTRIBUTIONS_SPREADSHEET_ID + - GH_UPLOADER_GCP_SA_CREDENTIALS automations: - name: review-requested enabled: true @@ -102,19 +109,6 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - - name: update-pr-spreadsheet - enabled: true - leaf: update-pr-spreadsheet.yml - on: - pull_request_target: - types: [assigned, unassigned, opened, closed, reopened, edited, review_requested, review_request_removed] - if: "github.event_name == 'pull_request_target' && contains(fromJSON('[\"assigned\",\"unassigned\",\"opened\",\"closed\",\"reopened\",\"edited\",\"review_requested\",\"review_request_removed\"]'), github.event.action)" - secrets: - LE_BOT_APP_ID: required - LE_BOT_PRIVATE_KEY: required - CONTRIBUTIONS_SPREADSHEET_ID: optional - CONTRIBUTIONS_SHEET_NAME: optional - GH_UPLOADER_GCP_SA_CREDENTIALS: optional - name: community-contribution-label enabled: true leaf: community-contribution-label.yml diff --git a/automation-template.yml b/automation-template.yml index 3208db2..321b23a 100644 --- a/automation-template.yml +++ b/automation-template.yml @@ -22,15 +22,9 @@ on: # zizmor: ignore[dangerous-triggers] - created pull_request_target: types: - - assigned - - closed - - edited - labeled - opened - - reopened - - review_request_removed - review_requested - - unassigned schedule: - cron: 1 0 * * 1 workflow_dispatch: {} @@ -42,9 +36,6 @@ jobs: name: Run automations uses: learningequality/.github/.github/workflows/automation.yml@main secrets: - CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} - CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} - GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/docs/automation.md b/docs/automation.md index 2ca1737..13ca3f7 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1,28 +1,38 @@ # Automation entry point Every learningequality repo that wants our shared bot automations (review-request routing, -contributor replies, issue header management, the community-contribution spreadsheet, etc.) -copies a single file: `automation-template.yml`. There is nothing else to maintain per repo — -no per-automation caller, no hand-written `on:` block to keep in sync. +contributor replies, issue header management, etc.) copies a single file: `automation-template.yml`. +There is nothing else to maintain per repo — no per-automation caller, no hand-written `on:` block +to keep in sync. + +The community-contribution spreadsheet is not one of them. `update-pr-spreadsheet.yml` runs daily +in this repo and upserts every public contributor PR in the org updated in the last two days. Run it +by hand with `dry_run` to log the rows without writing, or a larger `days` to backfill. ## How it fits together ``` consumer repo's .github/workflows/automation.yml (copy of automation-template.yml) - -> .github/workflows/automation.yml (this repo, reusable, one job per automation) - -> leaf workflow (e.g. review-requested.yml) - -> is-contributor.yml (where applicable) + -> .github/workflows/automation.yml (this repo, reusable) + -> automation-group-.yml (automations sharing trigger events) + -> leaf workflow (e.g. review-requested.yml) + -> is-contributor.yml (where applicable) ``` -That's 4 levels deep, GitHub's maximum for reusable workflow nesting - `automation.yml` calls -leaf workflows directly rather than going through an intermediate dispatcher. +GitHub posts a check on the PR for every job in a run, even a skipped one, but a skipped call to a +reusable workflow posts only one. Automations sharing trigger events therefore sit behind one group +job, so an event expands only the group it can match. An automation alone on its events is called +directly from `automation.yml`. + +GitHub allows 10 levels of nesting and 50 reusable workflow calls per run. `automation-registry.yml` is the single source of truth: one entry per automation, declaring its leaf workflow, the events/types (or schedule) that should trigger it, the `if:` condition used to dispatch it, which secrets it needs, and its job-level permissions (defaults to `contents: read`). `scripts/generate-automation.js` reads the registry and writes: -- `.github/workflows/automation.yml` - the reusable workflow jobs +- `.github/workflows/automation.yml` and `.github/workflows/automation-group-*.yml` - the reusable + workflow jobs - `automation-template.yml` - the file every consumer repo copies, including the generated exhaustive `on:` block (the union of every enabled automation's events/types) - `.github/workflows/automation-caller.yml` - this repo's own copy of the template, so `.github` @@ -43,9 +53,6 @@ No edits required. Then set the secrets: | `LE_BOT_PRIVATE_KEY` | yes | GitHub App private key for bot authentication | | `SLACK_WEBHOOK_URL` | no | Slack `#support-dev` channel webhook | | `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` | no | Slack `#support-dev-notifications` channel webhook | -| `CONTRIBUTIONS_SPREADSHEET_ID` | no | Google Sheets spreadsheet ID for PR tracking | -| `CONTRIBUTIONS_SHEET_NAME` | no | Sheet name within the spreadsheet | -| `GH_UPLOADER_GCP_SA_CREDENTIALS` | no | GCP service account credentials for Sheets access | Every automation except `resolve-bot-pr-threads` authenticates as `learning-equality-bot[bot]`, the GitHub App behind `LE_BOT_APP_ID`, so the two required secrets must be set. `resolve-bot-pr-threads` @@ -61,8 +68,8 @@ leaf workflow as an empty string, and every automation that needs it fails at ru 1. Edit `automation-registry.yml`: add a new entry, or flip an existing one's `enabled: true/false` (e.g. to switch `holiday-message` on/off seasonally - no per-repo changes needed, every consumer picks it up the next time they pull `automation.yml@main`). -2. Run `node scripts/generate-automation.js` to regenerate `automation.yml`, `automation-template.yml`, - and `automation-caller.yml`. +2. Run `node scripts/generate-automation.js` to regenerate `automation.yml`, the group workflows, + `automation-template.yml`, and `automation-caller.yml`. 3. Commit the registry change together with the regenerated files (pre-commit will refuse the commit otherwise). 4. If the change adds a new event/type that no existing automation triggers on, every consumer repo diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 70d873b..94628da 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -18,8 +18,8 @@ the `if:` condition on each job did not match, so check the event first. Some automations run only when `is-contributor` is true, meaning the author is not a member of the organization. An org member cannot trigger them, so they skip. These are `review-requested`, -`pull-request-label`, `contributor-pr-reply`, `contributor-issue-comment`, and -`update-pr-spreadsheet`, plus `holiday-message` when enabled. +`pull-request-label`, `contributor-pr-reply`, and `contributor-issue-comment`, plus +`holiday-message` when enabled. Use a second GitHub account that is not in the organization. It needs no permissions, secrets, or app. To open a pull request, fork `test-actions` and open one back to it. `pull_request_target` then @@ -28,9 +28,11 @@ no fork is needed. Keep that account out of the organization. Adding it makes these automations skip again. -`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because -`CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A -repository secret takes precedence over an organization secret with the same name. +## Testing the spreadsheet update + +`update-pr-spreadsheet` runs in `.github`, not `test-actions`, and writes to the production sheet. +Test it from a branch with `gh workflow run update-pr-spreadsheet.yml --ref -f dry_run=true`, +which logs the rows it would write. ## Testing the sync workflow @@ -50,6 +52,9 @@ Point a caller at your branch, for example: `uses: learningequality/.github/.github/workflows/automation.yml@my-branch` +Only that file comes from the branch. What it calls still comes from `main`, so to test a changed +group or leaf workflow, point the caller at that file instead. + Put that caller at a path other than `.github/workflows/automation.yml`. The sync reads exactly that path, so a branch-pinned caller there looks like drift and gets a pull request on every run. diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 269c19b..3988f2b 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -1,12 +1,12 @@ #!/usr/bin/env node /** - * Generates .github/workflows/automation.yml and automation-template.yml - * from automation-registry.yml. + * Generates .github/workflows/automation.yml, its automation-group-*.yml + * workflows, and automation-template.yml from automation-registry.yml. * * Usage: - * node scripts/generate-automation.js regenerate both files + * node scripts/generate-automation.js regenerate the files * node scripts/generate-automation.js --check fail (exit 1) if regenerating - * would change either file + * would change any file */ const fs = require('fs'); const path = require('path'); @@ -14,11 +14,12 @@ const yaml = require('js-yaml'); const ROOT = path.join(__dirname, '..'); const REGISTRY_PATH = path.join(ROOT, 'automation-registry.yml'); -const AUTOMATION_PATH = path.join(ROOT, '.github', 'workflows', 'automation.yml'); +const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows'); +const GROUP_PREFIX = 'automation-group-'; const TEMPLATE_PATH = path.join(ROOT, 'automation-template.yml'); // This repo is consumer zero: it runs the same template against itself // instead of a hand-copied caller, so it stays in sync automatically. -const DOGFOOD_PATH = path.join(ROOT, '.github', 'workflows', 'automation-caller.yml'); +const DOGFOOD_PATH = path.join(WORKFLOWS_DIR, 'automation-caller.yml'); const GENERATED_HEADER = (description) => `# This file is generated by scripts/generate-automation.js from automation-registry.yml. # Do not edit it by hand - edit the registry and run: @@ -44,7 +45,10 @@ function unquoteOnKey(content, suffix) { function loadRegistry() { const raw = yaml.load(fs.readFileSync(REGISTRY_PATH, 'utf8')); - return raw.automations.filter((a) => a.enabled); + return { + automations: raw.automations.filter((a) => a.enabled), + retiredSecrets: raw.retired_secrets || [], + }; } function titleCase(name) { @@ -91,42 +95,94 @@ function collectSecrets(automations) { return secrets; } -function buildAutomationYml(automations, secrets) { +function callJob(name, condition, file, permissions, secretNames) { + const job = { + name, + if: `\${{ ${condition} }}`, + uses: `learningequality/.github/.github/workflows/${file}@main`, + permissions, + }; + if (secretNames.length) { + job.secrets = {}; + for (const s of secretNames) job.secrets[s] = `\${{ secrets.${s} }}`; + } + return job; +} + +function leafJob(a) { + const permissions = a.permissions || DEFAULT_PERMISSIONS; + return callJob(titleCase(a.name), a.if, a.leaf, permissions, Object.keys(a.secrets || {})); +} + +function reusableYml(name, automations, jobs, description, retiredSecrets = []) { + const secrets = collectSecrets(automations); + const retired = retiredSecrets.filter((s) => !secrets[s]); const secretsYaml = {}; - for (const name of Object.keys(secrets).sort()) { - const info = secrets[name]; - secretsYaml[name] = { - description: `Forwarded to: ${info.usedBy.join(', ')}`, - required: info.required, - }; + for (const secret of [...Object.keys(secrets), ...retired].sort()) { + const info = secrets[secret]; + secretsYaml[secret] = info + ? { description: `Forwarded to: ${info.usedBy.join(', ')}`, required: info.required } + : { description: 'Unused - accepted until every consumer stops forwarding it', required: false }; } + const doc = { name, on: { workflow_call: { secrets: secretsYaml } }, jobs }; + return unquoteOnKey(GENERATED_HEADER(description) + yaml.dump(doc, DUMP_OPTS)); +} - const jobs = {}; +function groupByEvents(automations) { + const groups = new Map(); for (const a of automations) { - const job = { - name: titleCase(a.name), - if: `\${{ ${a.if} }}`, - uses: `learningequality/.github/.github/workflows/${a.leaf}@main`, - }; - job.permissions = a.permissions || DEFAULT_PERMISSIONS; - const secretNames = Object.keys(a.secrets || {}); - if (secretNames.length) { - job.secrets = {}; - for (const s of secretNames) job.secrets[s] = `\${{ secrets.${s} }}`; + const key = Object.keys(a.on || {}).sort().join('-').replace(/_/g, '-'); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(a); + } + return groups; +} + +// GitHub posts a check on the PR for every job in a run, even a skipped one, +// but a skipped call to a reusable workflow posts only one. +function buildReusableWorkflows(automations, retiredSecrets = []) { + const files = {}; + const jobs = {}; + const addJob = (id, job) => { + if (jobs[id]) throw new Error(`Job id "${id}" is used by both a group and an automation in automation.yml.`); + jobs[id] = job; + }; + + for (const [key, members] of groupByEvents(automations)) { + if (members.length === 1) { + addJob(members[0].name, leafJob(members[0])); + continue; } - jobs[a.name] = job; + const file = `${GROUP_PREFIX}${key}.yml`; + const groupJobs = {}; + for (const a of members) groupJobs[a.name] = leafJob(a); + files[file] = reusableYml( + `Automation: ${titleCase(key)}`, + members, + groupJobs, + `Reusable workflow called from automation.yml: the automations triggered by ${Object.keys(members[0].on).sort().join(', ')}.` + ); + addJob( + key, + callJob( + titleCase(key), + members.map((a) => `(${a.if})`).join(' || '), + file, + unionPermissions(members), + Object.keys(collectSecrets(members)).sort() + ) + ); } - const doc = { - name: 'Automation', - on: { workflow_call: { secrets: secretsYaml } }, + files['automation.yml'] = reusableYml( + 'Automation', + automations, jobs, - }; - return unquoteOnKey( - GENERATED_HEADER( - 'Reusable workflow: one job per automation, gated on the original triggering event/action.' - ) + yaml.dump(doc, DUMP_OPTS) + 'Reusable workflow: one job per automation, or per group of automations sharing trigger events,\n' + + '# gated on the original triggering event/action.', + retiredSecrets ); + return files; } function unionOn(automations) { @@ -195,17 +251,26 @@ function buildTemplateYml(automations, secrets, description) { function main() { const check = process.argv.includes('--check'); - const automations = loadRegistry(); + const { automations, retiredSecrets } = loadRegistry(); const secrets = collectSecrets(automations); const targets = [ - [AUTOMATION_PATH, buildAutomationYml(automations, secrets)], + ...Object.entries(buildReusableWorkflows(automations, retiredSecrets)).map(([file, content]) => [ + path.join(WORKFLOWS_DIR, file), + content, + ]), [TEMPLATE_PATH, buildTemplateYml(automations, secrets, 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + '# No edits are needed - the on: block is the exhaustive union of every enabled automation.')], [DOGFOOD_PATH, buildTemplateYml(automations, secrets, 'This repo\'s own automation caller - .github acts as consumer zero and stays in sync automatically.')], ]; + const targetPaths = new Set(targets.map(([target]) => target)); + const stale = fs + .readdirSync(WORKFLOWS_DIR) + .filter((f) => f.startsWith(GROUP_PREFIX)) + .map((f) => path.join(WORKFLOWS_DIR, f)) + .filter((f) => !targetPaths.has(f)); if (check) { let drifted = false; @@ -216,6 +281,10 @@ function main() { drifted = true; } } + for (const target of stale) { + console.error(`Stale ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\``); + drifted = true; + } process.exit(drifted ? 1 : 0); } @@ -224,6 +293,12 @@ function main() { fs.writeFileSync(target, content); console.log(`Wrote ${path.relative(ROOT, target)}`); } + for (const target of stale) { + fs.unlinkSync(target); + console.log(`Removed ${path.relative(ROOT, target)}`); + } } -main(); +if (require.main === module) main(); + +module.exports = { buildReusableWorkflows }; diff --git a/scripts/generate-automation.test.js b/scripts/generate-automation.test.js new file mode 100644 index 0000000..eee234d --- /dev/null +++ b/scripts/generate-automation.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const yaml = require('js-yaml'); + +const { buildReusableWorkflows } = require('./generate-automation'); + +const WORKFLOWS = 'learningequality/.github/.github/workflows'; +const GROUP_FILE = 'automation-group-pull-request-target.yml'; + +const prLabel = { + name: 'pr-label', + leaf: 'pr-label.yml', + on: { pull_request_target: { types: ['labeled'] } }, + if: "github.event.action == 'labeled'", + secrets: { LE_BOT_APP_ID: 'required' }, +}; +const prOpened = { + name: 'pr-opened', + leaf: 'pr-opened.yml', + on: { pull_request_target: { types: ['opened'] } }, + if: "github.event.action == 'opened'", + secrets: { SLACK_WEBHOOK_URL: 'optional' }, + permissions: { contents: 'read', 'pull-requests': 'write' }, +}; +const sweep = { + name: 'sweep', + leaf: 'sweep.yml', + on: { schedule: { cron: '1 0 * * 1' }, workflow_dispatch: {} }, + if: "github.event_name == 'schedule'", + secrets: {}, +}; + +function build(automations, retiredSecrets) { + const files = buildReusableWorkflows(automations, retiredSecrets); + const parsed = {}; + for (const [file, content] of Object.entries(files)) parsed[file] = yaml.load(content); + return parsed; +} + +test('automations sharing trigger events are called through one group workflow', () => { + const files = build([prLabel, prOpened, sweep]); + + assert.deepEqual(Object.keys(files).sort(), [GROUP_FILE, 'automation.yml']); + assert.deepEqual(Object.keys(files['automation.yml'].jobs), ['pull-request-target', 'sweep']); + + const groupJob = files['automation.yml'].jobs['pull-request-target']; + assert.equal(groupJob.name, 'Pull Request Target'); + assert.equal( + groupJob.if, + "${{ (github.event.action == 'labeled') || (github.event.action == 'opened') }}", + ); + assert.equal(groupJob.uses, `${WORKFLOWS}/${GROUP_FILE}@main`); + assert.deepEqual(groupJob.permissions, { contents: 'read', 'pull-requests': 'write' }); + assert.deepEqual(groupJob.secrets, { + LE_BOT_APP_ID: '${{ secrets.LE_BOT_APP_ID }}', + SLACK_WEBHOOK_URL: '${{ secrets.SLACK_WEBHOOK_URL }}', + }); + + const group = files[GROUP_FILE]; + assert.deepEqual(Object.keys(group.jobs), ['pr-label', 'pr-opened']); + assert.equal(group.jobs['pr-label'].uses, `${WORKFLOWS}/pr-label.yml@main`); + assert.deepEqual(Object.keys(group.on.workflow_call.secrets), [ + 'LE_BOT_APP_ID', + 'SLACK_WEBHOOK_URL', + ]); +}); + +test('an automation alone on its trigger events is called directly', () => { + const files = build([prLabel, sweep]); + + assert.deepEqual(Object.keys(files), ['automation.yml']); + assert.equal(files['automation.yml'].jobs['pr-label'].uses, `${WORKFLOWS}/pr-label.yml@main`); + assert.equal(files['automation.yml'].jobs.sweep.uses, `${WORKFLOWS}/sweep.yml@main`); +}); + +test('retired secrets are still accepted by automation.yml but forwarded nowhere', () => { + const files = build([prLabel, prOpened], ['OLD_SECRET']); + + assert.deepEqual(files['automation.yml'].on.workflow_call.secrets.OLD_SECRET.required, false); + for (const job of Object.values(files['automation.yml'].jobs)) { + assert.equal(Object.keys(job.secrets || {}).includes('OLD_SECRET'), false); + } + assert.equal('OLD_SECRET' in files[GROUP_FILE].on.workflow_call.secrets, false); +}); + +test('a group whose job id collides with an automation name is rejected', () => { + const clash = { ...sweep, name: 'pull-request-target' }; + assert.throws(() => buildReusableWorkflows([prLabel, prOpened, clash]), /pull-request-target/); +}); diff --git a/scripts/update-pr-spreadsheet.js b/scripts/update-pr-spreadsheet.js index f21add2..7aac108 100644 --- a/scripts/update-pr-spreadsheet.js +++ b/scripts/update-pr-spreadsheet.js @@ -1,25 +1,66 @@ const { google } = require('googleapis'); +const { isContributor } = require('./utils'); -// Extract relevant data from the event payload -const extractPRData = payload => { - const pr = payload.pull_request; - return { - merged_at: pr.merged_at, - html_url: pr.html_url, - user_login: pr.user.login, - title: pr.title, - repo_name: pr.base.repo.name, - created_at: pr.created_at, - requested_reviewers: pr.requested_reviewers?.map(r => r.login).join(',') || '', - assignees: pr.assignees?.map(a => a.login).join(',') || '', - user_site_admin: pr.user.site_admin, - user_type: pr.user.type, - author_association: pr.author_association, - state: pr.state, - }; -}; +const COLUMNS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + +const toDate = timestamp => (timestamp ? timestamp.split('T')[0].replace("'", '') : ''); + +const toRow = pr => [ + pr.merged_at ? toDate(pr.merged_at) : pr.state, + pr.html_url || '', + pr.user.login || '', + pr.title || '', + pr.base.repo.name || '', + toDate(pr.created_at), + pr.requested_reviewers?.map(r => r.login).join(',') || '', + pr.assignees?.map(a => a.login).join(',') || '', +]; + +async function collectRows({ github, context, core }, since) { + const org = context.repo.owner; + const items = await github.paginate(github.rest.search.issuesAndPullRequests, { + q: `org:${org} is:pr is:public -repo:${org}/test-actions updated:>=${since}`, + per_page: 100, + }); + + const rows = []; + for (const item of items) { + const { login } = item.user; + if (!(await isContributor(login, item.author_association, { github, context, core }))) { + continue; + } + const repo = item.repository_url.split('/').pop(); + const pull_number = item.number; + const { data: pr } = await github.rest.pulls.get({ owner: org, repo, pull_number }); + rows.push(toRow(pr)); + } + return rows; +} + +function planSheetChanges(existingRows, rows, sheetName) { + const rowIndexByUrl = new Map(); + for (let i = 1; i < existingRows.length; i++) { + const url = existingRows[i][1]; + if (!rowIndexByUrl.has(url)) rowIndexByUrl.set(url, i); + } + + const updates = []; + const appends = []; + for (const row of rows) { + const index = rowIndexByUrl.get(row[1]); + if (index === undefined) { + appends.push(row); + continue; + } + row.forEach((value, col) => { + if (existingRows[index][col] !== value) { + updates.push({ range: `${sheetName}!${COLUMNS[col]}${index + 1}`, values: [[value]] }); + } + }); + } + return { updates, appends }; +} -// Set up GoogleAuth for Google Sheets API async function authorize(googleCredentials) { try { const credentials = JSON.parse(googleCredentials); @@ -34,103 +75,49 @@ async function authorize(googleCredentials) { } } -// Update Google Sheets with pull request data -async function updateSpreadsheet(pullRequest, sheetId, sheetName, googleCredentials) { - const sheets = await authorize(googleCredentials); - const prData = [ - pullRequest.merged_at - ? pullRequest.merged_at.split('T')[0].replace("'", '') - : pullRequest.state === 'closed' - ? 'closed' - : pullRequest.state, - pullRequest.html_url || '', - pullRequest.user_login || '', - pullRequest.title || '', - pullRequest.repo_name || '', - pullRequest.created_at ? pullRequest.created_at.split('T')[0].replace("'", '') : '', - pullRequest.requested_reviewers || '', - pullRequest.assignees || '', - ]; +module.exports = async ({ github, context, core }) => { + const sheetId = process.env.CONTRIBUTIONS_SPREADSHEET_ID; + const sheetName = process.env.CONTRIBUTIONS_SHEET_NAME; + const lookbackDays = Number(process.env.LOOKBACK_DAYS); + const dryRun = process.env.DRY_RUN === 'true'; try { - // Fetch existing rows from the sheet + const since = toDate(new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString()); + const rows = await collectRows({ github, context, core }, since); + core.info(`Found ${rows.length} contributor PRs updated since ${since}.`); + + if (dryRun) { + for (const row of rows) core.info(JSON.stringify(row)); + return; + } + + const sheets = await authorize(process.env.GOOGLE_CREDENTIALS); const { data } = await sheets.spreadsheets.values.get({ spreadsheetId: sheetId, range: sheetName, }); + const { updates, appends } = planSheetChanges(data.values || [], rows, sheetName); - const existingRows = data.values || []; - let rowToUpdate = null; - - // Find the row that corresponds to the pull request URL - for (let i = 1; i < existingRows.length; i++) { - if (existingRows[i][1] === pullRequest.html_url) { - rowToUpdate = i + 1; // Get the row number to update - break; - } + if (updates.length) { + await sheets.spreadsheets.values.batchUpdate({ + spreadsheetId: sheetId, + resource: { data: updates, valueInputOption: 'RAW' }, + }); } - - if (rowToUpdate) { - // Check if row data has changed and update if necessary - const existingData = existingRows[rowToUpdate - 1]; - const prDataString = JSON.stringify(prData); - const existingDataString = JSON.stringify(existingData); - - if (prDataString !== existingDataString) { - // console.log(`Detected changes for row ${rowToUpdate}.`); - - const updates = []; - const columns = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; - for (let col = 0; col < prData.length; col++) { - if (existingData[col] !== prData[col]) { - updates.push({ - range: `${sheetName}!${columns[col]}${rowToUpdate}`, - values: [[prData[col]]], - }); - } - } - - if (updates.length > 0) { - // Batch update changed columns - await sheets.spreadsheets.values.batchUpdate({ - spreadsheetId: sheetId, - resource: { - data: updates, - valueInputOption: 'RAW', - }, - }); - // console.log(`Updated row ${rowToUpdate} in Google Sheets.`); - } else { - // console.log(`No changes detected for row ${rowToUpdate}.`); - } - } else { - // console.log(`No changes detected for row ${rowToUpdate}.`); - } - } else { - // Append new row starting from column B + if (appends.length) { await sheets.spreadsheets.values.append({ spreadsheetId: sheetId, range: `${sheetName}!A:H`, valueInputOption: 'RAW', - resource: { values: [prData] }, + resource: { values: appends }, }); - // console.log(`Added new row to Google Sheets.`); } - } catch (error) { - throw new Error(`Failed to update spreadsheet: ${error.message}`); - } -} - -module.exports = async ({ context, core }) => { - const sheetId = process.env.CONTRIBUTIONS_SPREADSHEET_ID; - const sheetName = process.env.CONTRIBUTIONS_SHEET_NAME; - const googleCredentials = process.env.GOOGLE_CREDENTIALS; - - try { - const prData = extractPRData(context.payload); - await updateSpreadsheet(prData, sheetId, sheetName, googleCredentials); + core.info(`Updated ${updates.length} cells and appended ${appends.length} rows.`); } catch (error) { core.setFailed(`An error occurred: ${error.message}`); throw error; } }; + +module.exports.collectRows = collectRows; +module.exports.planSheetChanges = planSheetChanges; diff --git a/scripts/update-pr-spreadsheet.test.js b/scripts/update-pr-spreadsheet.test.js new file mode 100644 index 0000000..5f42396 --- /dev/null +++ b/scripts/update-pr-spreadsheet.test.js @@ -0,0 +1,120 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { collectRows, planSheetChanges } = require('./update-pr-spreadsheet'); + +const core = { info() {}, warning() {}, setFailed() {} }; +const context = { repo: { owner: 'learningequality', repo: '.github' } }; + +function pr(overrides = {}) { + return { + number: 1, + merged_at: null, + state: 'open', + html_url: 'https://github.com/learningequality/kolibri/pull/1', + user: { login: 'outsider' }, + title: 'Fix it', + base: { repo: { name: 'kolibri' } }, + created_at: '2026-09-20T10:00:00Z', + requested_reviewers: [{ login: 'reviewer' }], + assignees: [{ login: 'assignee' }], + author_association: 'CONTRIBUTOR', + ...overrides, + }; +} + +function searchItem(p) { + return { + number: p.number, + user: p.user, + author_association: p.author_association, + repository_url: `https://api.github.com/repos/learningequality/${p.base.repo.name}`, + }; +} + +function fakeGithub(prs) { + const queries = []; + const search = () => {}; + return { + queries, + rest: { + search: { issuesAndPullRequests: search }, + pulls: { + get: async ({ repo, pull_number }) => ({ + data: prs.find(p => p.base.repo.name === repo && p.number === pull_number), + }), + }, + }, + paginate: async (method, params) => { + assert.equal(method, search); + queries.push(params.q); + return prs.map(searchItem); + }, + }; +} + +test('only contributor PRs updated since the cutoff become rows', async () => { + const github = fakeGithub([ + pr(), + pr({ number: 2, user: { login: 'core-dev' }, author_association: 'MEMBER' }), + pr({ number: 3, user: { login: 'dependabot[bot]' }, author_association: 'NONE' }), + pr({ number: 4, user: { login: 'boss' }, author_association: 'OWNER' }), + ]); + + const rows = await collectRows({ github, context, core }, '2026-09-23'); + + assert.deepEqual(github.queries, [ + 'org:learningequality is:pr is:public -repo:learningequality/test-actions updated:>=2026-09-23', + ]); + assert.deepEqual(rows, [ + [ + 'open', + 'https://github.com/learningequality/kolibri/pull/1', + 'outsider', + 'Fix it', + 'kolibri', + '2026-09-20', + 'reviewer', + 'assignee', + ], + ]); +}); + +test('a merged PR shows its merge date and a closed one shows closed', async () => { + const github = fakeGithub([ + pr({ merged_at: '2026-09-24T08:00:00Z', state: 'closed' }), + pr({ + number: 2, + state: 'closed', + html_url: 'https://github.com/learningequality/kolibri/pull/2', + }), + ]); + + const rows = await collectRows({ github, context, core }, '2026-09-23'); + + assert.deepEqual( + rows.map(r => r[0]), + ['2026-09-24', 'closed'], + ); +}); + +test('changed cells of known PRs are updated and unknown PRs appended', () => { + const existing = [ + ['Status', 'URL', 'Author', 'Title', 'Repo', 'Created', 'Reviewers', 'Assignees'], + ['open', 'https://x/pull/1', 'a', 'Old title', 'kolibri', '2026-09-20', '', ''], + ['open', 'https://x/pull/2', 'b', 'Same', 'studio', '2026-09-21', '', ''], + ]; + const rows = [ + ['2026-09-24', 'https://x/pull/1', 'a', 'New title', 'kolibri', '2026-09-20', '', ''], + ['open', 'https://x/pull/2', 'b', 'Same', 'studio', '2026-09-21', '', ''], + ['open', 'https://x/pull/3', 'c', 'Brand new', 'kolibri', '2026-09-24', '', ''], + ]; + + const { updates, appends } = planSheetChanges(existing, rows, 'Sheet1'); + + assert.deepEqual(updates, [ + { range: 'Sheet1!A2', values: [['2026-09-24']] }, + { range: 'Sheet1!D2', values: [['New title']] }, + ]); + assert.deepEqual(appends, [rows[2]]); +});