diff --git a/.github/workflows/track-review-project.yaml b/.github/workflows/track-review-project.yaml new file mode 100644 index 0000000..ff91537 --- /dev/null +++ b/.github/workflows/track-review-project.yaml @@ -0,0 +1,202 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ + +# This workflow runs whenever a pull request in the repository is marked as +# "Ready for review" (i.e., a non-Draft PR). +name: Track Review Project +on: + workflow_call: + inputs: + runner: + description: 'The runner to use for the job' + required: false + type: string + default: 'ubuntu-24.04' + project_org: + description: 'The organization that owns the project' + required: false + type: string + default: 'MetOffice' + project_number: + description: 'The project number' + required: false + type: number + default: 376 # Simulation Systems Review Tracker + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + track_project: + runs-on: ${{ inputs.runner }} + timeout-minutes: 5 + env: + REPO: ${{ github.repository }} + + steps: + # Required as running from the workflow_run trigger + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download PR data artifact + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "context.json" + })[0]; + + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + + let fs = require('fs'); + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/context.zip`, Buffer.from(download.data)); + + - name: Extract PR Data + run: | + unzip context.zip + pr_num=$(jq -r '.pr_num' context.json) + pr_id=$(jq -r '.pr_id' context.json) + echo "PR_NUM=$pr_num" >> $GITHUB_ENV + echo "PR_ID=$pr_id" >> $GITHUB_ENV + + - name: Assign Author if no assignees + env: + GH_TOKEN: ${{ github.token }} + run: | + pr_data=$(gh pr view -R "$REPO" "$PR_NUM" --json assignees,author) + if [[ $(echo "$pr_data" | jq '.assignees | length') -eq 0 ]]; then + author=$(echo "$pr_data" | jq -r '.author.login') + gh pr edit -R "$REPO" "$PR_NUM" --add-assignee "$author" + fi + + - name: Extract reviewers and determine state + env: + GH_TOKEN: ${{ github.token }} + run: | + pr_data=$(gh pr view -R "$REPO" "$PR_NUM" --json body,reviews,reviewRequests) + + # Extract reviewers from PR body + pr_body=$(echo "$pr_data" | jq -r '.body') + scitech_reviewer=$(echo "$pr_body" | grep -oP "Sci/Tech Reviewer:\s?@\K([\w\-]{1,39})\b" || true) + code_reviewer=$(echo "$pr_body" | grep -oP "Code Reviewer:\s?@\K([\w\-]{1,39})\b" || true) + + echo "Expected SciTech Reviewer: ${scitech_reviewer:-None}" + echo "Expected Code Reviewer: ${code_reviewer:-None}" + echo "SCITECH_REVIEWER=$scitech_reviewer" >> $GITHUB_ENV + echo "CODE_REVIEWER=$code_reviewer" >> $GITHUB_ENV + + # Determine review state + reviews=$(echo "$pr_data" | jq -c '{reviews, reviewRequests}') + sr_requested=$(echo "$reviews" | jq --arg sr "$scitech_reviewer" '[.reviewRequests[] | select(.login == $sr)] | length') + cr_requested=$(echo "$reviews" | jq --arg cr "$code_reviewer" '[.reviewRequests[] | select(.login == $cr)] | length') + + # Determine required state + sr_approved=$(echo "$reviews" | jq --arg sr "$scitech_reviewer" '[.reviews[] | select(.author.login == $sr and .state == "APPROVED")] | length') + sr_changes=$(echo "$reviews" | jq --arg sr "$scitech_reviewer" '[.reviews[] | select(.author.login == $sr and (.state == "CHANGES_REQUESTED" or .state == "COMMENTED"))] | length') + + [[ ! -z "$scitech_reviewer" ]] && [[ "$sr_requested" -eq 0 ]] && [[ "$sr_approved" -eq 0 ]] && [[ "$sr_changes" -eq 0 ]] && echo "SR_REQUEST_REQUIRED=true" >> $GITHUB_ENV + + if [[ -z "$scitech_reviewer" ]] && [[ "$cr_requested" -gt 0 ]]; then + echo "No SR set and CR requested so assuming this is trivial" + else + if [[ "$sr_approved" -eq 0 ]]; then + if [[ -z "$scitech_reviewer" ]] && [[ "$sr_changes" -eq 0 ]]; then + required_state="In Progress" + elif [[ "$sr_changes" -gt 0 ]] && [[ "$sr_requested" -eq 0 ]]; then + required_state="Changes Requested" + else + required_state="SciTech Review" + fi + echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV + exit 0 + fi + fi + + cr_approved=$(echo "$reviews" | jq --arg cr "$code_reviewer" '[.reviews[] | select(.author.login == $cr and .state == "APPROVED")] | length') + cr_changes=$(echo "$reviews" | jq --arg cr "$code_reviewer" '[.reviews[] | select(.author.login == $cr and (.state == "CHANGES_REQUESTED" or .state == "COMMENTED"))] | length') + + if [[ "$cr_approved" -gt 0 ]]; then + required_state="Approved" + elif [[ "cr_changes" -eq 0 ]] && [[ "$cr_requested" -eq 0 ]] && [[ "$sr_approved" -eq 0 ]]; then + required_state="In Progress" + elif [[ "$cr_changes" -gt 0 ]] && [[ "$cr_requested" -eq 0 ]]; then + required_state="Changes Requested" + else + required_state="Code Review" + [[ "$cr_requested" -eq 0 ]] && [[ "$cr_approved" -eq 0 ]] && [[ "$cr_changes" -eq 0 ]] && echo "CR_REQUEST_REQUIRED=true" >> $GITHUB_ENV + fi + + echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV + + - name: Request SciTech review, if needed + if: env.SR_REQUEST_REQUIRED == 'true' && env.REQUIRED_STATE == 'SciTech Review' && env.SCITECH_REVIEWER != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit -R "$REPO" "$PR_NUM" --add-reviewer "$SCITECH_REVIEWER" + + - name: Request code review, if needed + if: env.CR_REQUEST_REQUIRED == 'true' && env.REQUIRED_STATE == 'Code Review' && env.CODE_REVIEWER != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit -R "$REPO" "$PR_NUM" --add-reviewer "$CODE_REVIEWER" + + - name: Update project tracking + env: + GH_TOKEN: ${{ secrets.PROJECT_ACTION_PAT }} + run: | + # Get project data + project_data=$(gh api graphql -f query=' + query($org: String!, $number: Int!) { + organization(login: $org){ + projectV2(number: $number) { + id + fields(first:20) { + nodes { + ... on ProjectV2Field { id name } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + }' -f org=${{ inputs.project_org }} -F number=${{ inputs.project_number }}) + + project_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.id') + status_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id') + status_option_id=$(echo "$project_data" | jq -r --arg status "$REQUIRED_STATE" '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name==$status) | .id') + scitech_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "SciTech Review") | .id') + code_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Code Review") | .id') + + # Add PR to project and update fields + item_id=$(gh api graphql -f query=' + mutation($project:ID!, $pr:ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) { + item { id } + } + }' -f project="$project_id" -f pr="$PR_ID" --jq '.data.addProjectV2ItemById.item.id') + + # Update all fields in single mutation + gh api graphql -f query=' + mutation($project: ID!, $item: ID!, $status_field: ID!, $status_value: String!, $cr_field: ID!, $cr_value: String!, $sr_field: ID!, $sr_value: String!) { + set_status: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $status_field, value: {singleSelectOptionId: $status_value}}) { projectV2Item { id } } + set_code_review: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $cr_field, value: {text: $cr_value}}) { projectV2Item { id } } + set_scitech_review: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $sr_field, value: {text: $sr_value}}) { projectV2Item { id } } + }' -f project="$project_id" -f item="$item_id" -f status_field="$status_field_id" -f status_value="$status_option_id" -f cr_field="$code_field_id" -f cr_value="$CODE_REVIEWER" -f sr_field="$scitech_field_id" -f sr_value="$SCITECH_REVIEWER" --silent diff --git a/.github/workflows/trigger-project-workflow.yaml b/.github/workflows/trigger-project-workflow.yaml new file mode 100644 index 0000000..6fa7e72 --- /dev/null +++ b/.github/workflows/trigger-project-workflow.yaml @@ -0,0 +1,34 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ + +# This workflow acts as a triggering workflow (via on: workflow_run) for the +# track-review-project workflow. It uploads an artifact containing PR data for +# the latter +name: Trigger Review Project + +on: + workflow_call: + +permissions: + contents: read + pull-requests: write + +jobs: + trigger_project: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + PR_NUM: ${{ github.event.pull_request.number }} + PR_ID: ${{ github.event.pull_request.node_id }} + steps: + - name: Triggering Workflow + run: | + echo "{\"pr_num\": $PR_NUM, \"pr_id\": \"$PR_ID\"}" >> context.json + - uses: actions/upload-artifact@v6 + with: + name: context.json + path: context.json + retention-days: 1 diff --git a/track-review-project/README.md b/track-review-project/README.md new file mode 100644 index 0000000..fd1d938 --- /dev/null +++ b/track-review-project/README.md @@ -0,0 +1,56 @@ +# Track Review Project Action + +This action modifies the Simulation Systems Review Tracker project entries in +pull requests. It will automatically fill the reviewer fields based on the +entries in the pull request template. It will change the state based on reviews +requested and performed. If a CR hasn't been requested, it will do so when +moving to the CR state. It will also add the PR author as an assignee if none +exist. + +In order for this to work with forks when reviews are completed, a triggering +workflow is also required. This can be shown by the "Trigger Project Workflow" +yaml file below. The "Track Action Project" workflow is triggered by this +workflow via github "workflow_run" syntax. + +It requires a PAT with permissions to write to projects to be added as a secret +to the repository with the name `PROJECT_ACTION_PAT`. Project edits made using +this token will be shown as performed by the owner of the token. + +## Usage + +```yaml +name: Track Review Project + +on: + workflow_run: + workflows: [Trigger Review Project] + types: + - completed + workflow_dispatch: + +jobs: + track_review_project: + uses: MetOffice/growss/.github/workflows/track-review-project.yaml@main + secrets: inherit + # Optional inputs (with default values) + with: + runner: "ubuntu-22.04" + project_org: "MetOffice" + project_number: 376 + +``` + +```yaml +name: Trigger Review Project + +on: + pull_request_target: + types: ["opened", "synchronize", "reopened", "edited", "review_requested", "review_request_removed"] + pull_request_review: + pull_request_review_comment: + +jobs: + trigger_project_workflow: + uses: MetOffice/growss/.github/workflows/trigger-project-workflow.yaml@main + secrets: inherit +```