diff --git a/.github/workflows/author-ready.yml b/.github/workflows/author-ready.yml new file mode 100644 index 000000000000..af75b13b17b5 --- /dev/null +++ b/.github/workflows/author-ready.yml @@ -0,0 +1,56 @@ +name: Author ready + +# Adds `author ready` to pull requests that qualify +on: + schedule: + - cron: 25,55 * * * * + workflow_dispatch: + +permissions: + contents: read + +jobs: + author-ready: + if: github.repository == 'nodejs/node' + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + /tools/actions/author-ready.sh + /tools/actions/pr-state.sh + sparse-checkout-cone-mode: false + + - name: Collect pull requests to evaluate + id: collect + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Pull requests that might have become ready, plus the ones already + # labelled so the label comes back off when it stops holding. + candidates=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --json 'number' \ + --search 'review:approved draft:false -label:blocked -label:wip -label:"author ready"' \ + -t '{{ range . }}{{ .number }} {{ end }}' \ + --limit 200) + labelled=$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --label 'author ready' \ + --json 'number' \ + -t '{{ range . }}{{ .number }} {{ end }}' \ + --limit 200) + numbers=$(printf '%s %s' "$candidates" "$labelled" | + tr ' ' '\n' | grep -v '^$' | sort -un | tr '\n' ' ') + echo "numbers=$numbers" >> "$GITHUB_OUTPUT" + + - name: Add the label where it applies + if: steps.collect.outputs.numbers != '' + run: ./tools/actions/author-ready.sh ${{ steps.collect.outputs.numbers }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/auto-commit-queue.yml b/.github/workflows/auto-commit-queue.yml new file mode 100644 index 000000000000..bb9f7fe6ba33 --- /dev/null +++ b/.github/workflows/auto-commit-queue.yml @@ -0,0 +1,36 @@ +name: Auto Commit Queue + +# `author ready` says a pull request is just about ready to land. +# This runs a bit more aggressively than adding the label, and if +# a PR passes, adds it to the queue. + +on: + schedule: + - cron: 10,40 * * * * + workflow_dispatch: + +permissions: + contents: read + +jobs: + queue-ready-prs: + if: github.repository == 'nodejs/node' + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + /README.md + /tools/actions/auto-commit-queue.sh + /tools/actions/pr-state.sh + sparse-checkout-cone-mode: false + + - name: Queue pull requests that are ready to land + run: ./tools/actions/auto-commit-queue.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/tools/actions/author-ready.sh b/tools/actions/author-ready.sh new file mode 100755 index 000000000000..e3e87c9f29eb --- /dev/null +++ b/tools/actions/author-ready.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +# Keeps the `author ready` label in sync with the definition in +# doc/contributing/collaborator-guide.md. A pull request is author ready when: +# +# * there is a CI run in progress or completed, +# * there is at least one collaborator approval, +# * there are no outstanding review comments. + +set -e + +# shellcheck source=tools/actions/pr-state.sh +. "$(dirname "$0")/pr-state.sh" + +AUTHOR_READY_LABEL="author ready" + +# shellcheck disable=SC2154 +OWNER="${GH_REPO%%/*}" +REPO="${GH_REPO#*/}" + +# shellcheck disable=SC2016 # `$owner` and friends are GraphQL variables +QUERY=' +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) {'"${PR_STATE_FRAGMENT}"'} + } +}' + +# shellcheck disable=SC2016 # `$ci_pattern` and friends are jq variables +ELIGIBLE="${PR_STATE_JQ}"' +.data.repository.pullRequest +| { + missing: [ + if .state == "OPEN" then empty else "not open" end, + if .isDraft then "draft" else empty end, + if .mergeable == "CONFLICTING" then "merge conflict" else empty end, + (labels[] | select(. == "blocked" or . == "wip") | "labelled \(.)"), + (labels[] | select(. == "request-ci" or . == "request-ci-failed") | "CI not started yet"), + if changes_requested then "changes requested" else empty end, + if unresolved_threads then "unresolved review threads" else empty end, + if (.latestOpinionatedReviews.nodes | any(.state == "APPROVED")) + then empty else "not approved" end, + + # "A CI run in progress or completed": for a pull request that needs + # Jenkins, only a Jenkins run counts; otherwise any check does. + if (if jenkins_required then jenkins_ran($ci_pattern) else ci_started end) + then empty else "no CI run" end, + if ci_failing then "CI is failing" else empty end + ], + is_author_ready: has_label($author_ready_label) + }' + +for pr in "$@"; do + state="$( + gh api graphql -f query="${QUERY}" \ + -f owner="${OWNER}" -f repo="${REPO}" -F number="${pr}" | + jq -c --arg ci_pattern "${CI_CONTEXT_PATTERN}" \ + --arg author_ready_label "${AUTHOR_READY_LABEL}" \ + "${ELIGIBLE}" + )" + + missing="$(echo "${state}" | jq -r '.missing | join(", ")')" + is_author_ready="$(echo "${state}" | jq -r '.is_author_ready')" + + if [ -z "${missing}" ] && [ "${is_author_ready}" = "false" ]; then + echo "#${pr}: author ready, adding label" + gh pr edit "${pr}" --add-label "${AUTHOR_READY_LABEL}" + elif [ -n "${missing}" ] && [ "${is_author_ready}" = "true" ]; then + echo "#${pr}: no longer author ready (${missing}), removing label" + gh pr edit "${pr}" --remove-label "${AUTHOR_READY_LABEL}" + elif [ -n "${missing}" ]; then + echo "#${pr}: not author ready (${missing})" + else + echo "#${pr}: author ready, already labelled" + fi +done diff --git a/tools/actions/auto-commit-queue.sh b/tools/actions/auto-commit-queue.sh new file mode 100755 index 000000000000..8441f2498d44 --- /dev/null +++ b/tools/actions/auto-commit-queue.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# Adds `commit-queue` to pull requests that are ready to land, so nobody has to +# come back once the waiting period is over. +# +# * open, not a draft, targets `main`, and `mergeable` is not CONFLICTING +# * at least one approval given against the commit that would land +# * semver-major needs two of those approvals to be from TSC voting members +# * two approvals and 48 hours since it was opened (unless fast-track) +# * every GitHub check passing +# * a green Jenkins run when the pull request is labelled `needs-ci` +# * that CI having run in the last five days, so `main` has not moved on + +set -e + +# shellcheck source=tools/actions/pr-state.sh +. "$(dirname "$0")/pr-state.sh" + +COMMIT_QUEUE_LABEL="commit-queue" + +TSC_MEMBERS="$( + sed -n '/^#### TSC voting members$/,/^#### TSC regular members$/ s/^\* \[\([^]]*\)\].*/\1/p' \ + "$(dirname "$0")/../../README.md" | + tr '[:upper:]' '[:lower:]' | + sort -u | + jq -Rsc 'split("\n") | map(select(length > 0))' +)" + +# shellcheck disable=SC2154 +SEARCH="repo:${GH_REPO} is:pr is:open \ +label:\"author ready\" \ +-label:blocked -label:wip \ +-label:${COMMIT_QUEUE_LABEL} -label:${COMMIT_QUEUE_LABEL}-failed \ +-label:request-ci -label:request-ci-failed" + +# shellcheck disable=SC2016 # `$q` is a GraphQL variable +QUERY=' +query($q: String!) { + search(query: $q, type: ISSUE, first: 50) { + nodes { + ... on PullRequest {'"${PR_STATE_FRAGMENT}"'} + } + } +}' + +# shellcheck disable=SC2016 # `$tsc` and friends are jq variables +READY="${PR_STATE_JQ}"' +[ .data.search.nodes[] + | select(. != null) + | (current_approvals) as $approvals + | ([$approvals[].author.login | ascii_downcase] | map(select(IN($tsc[]))) | length) as $tsc_approvals + | (if has_label("semver-major") and ($tsc | length) > 0 then $tsc_approvals >= 2 + elif has_label("semver-major") then ($approvals | length) >= 2 + else ($approvals | length) >= 1 end) as $approved + | (if has_label("fast-track") then ($approvals | length) >= 2 + elif wait_waived then true + else (($approvals | length) >= 2 and age_seconds >= $wait_multi) + or age_seconds >= $wait_single + end) as $waited + | select( + $approved and $waited + and .state == "OPEN" + and (.isDraft | not) + and .baseRefName == "main" + and .mergeable != "CONFLICTING" + and (changes_requested | not) + and (unresolved_threads | not) + and github_checks_passing + and ((jenkins_required | not) or jenkins_passing($ci_pattern)) + and ci_recent($ci_pattern; $ci_max_age) + ) + | .number +]' + +numbers="$( + gh api graphql -f query="${QUERY}" -f q="${SEARCH}" | + jq -r --arg ci_pattern "${CI_CONTEXT_PATTERN}" \ + --argjson tsc "${TSC_MEMBERS:-[]}" \ + --argjson wait_multi "${WAIT_MULTI_APPROVAL}" \ + --argjson wait_single "${WAIT_SINGLE_APPROVAL}" \ + --argjson ci_max_age "${CI_MAX_AGE}" \ + "${READY} | .[]" +)" + +if [ -z "${numbers}" ]; then + echo "No pull requests are ready for the commit queue." + exit 0 +fi + +for pr in ${numbers}; do + echo "#${pr}: ready to land, adding ${COMMIT_QUEUE_LABEL}" + gh pr edit "${pr}" --add-label "${COMMIT_QUEUE_LABEL}" +done diff --git a/tools/actions/pr-state.sh b/tools/actions/pr-state.sh new file mode 100644 index 000000000000..732017d96a61 --- /dev/null +++ b/tools/actions/pr-state.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# shellcheck disable=SC2034 # everything here is read by the scripts that source it + +CI_CONTEXT_PATTERN="${CI_CONTEXT_PATTERN:-node-test-pull-request}" +WAIT_MULTI_APPROVAL="${WAIT_MULTI_APPROVAL:-172800}" +WAIT_SINGLE_APPROVAL="${WAIT_SINGLE_APPROVAL:-604800}" +CI_MAX_AGE="${CI_MAX_AGE:-432000}" + +PR_STATE_FRAGMENT=' + number + state + isDraft + mergeable + createdAt + baseRefName + labels(first: 100) { nodes { name } } + latestOpinionatedReviews(first: 100, writersOnly: true) { + nodes { state author { login } commit { oid } } + } + reviewThreads(first: 100) { + nodes { isResolved isOutdated } + } + commits(last: 1) { + nodes { + commit { + oid + statusCheckRollup { + contexts(first: 100) { + nodes { + ... on StatusContext { context state createdAt } + ... on CheckRun { name conclusion completedAt } + } + } + } + } + } + } +' + +# shellcheck disable=SC2016 # `$name` and friends are jq parameters +PR_STATE_JQ=' +def labels: .labels.nodes | map(.name); +def has_label($name): labels | index($name) != null; +def changes_requested: .latestOpinionatedReviews.nodes | any(.state == "CHANGES_REQUESTED"); +def unresolved_threads: .reviewThreads.nodes | any((.isResolved | not) and (.isOutdated | not)); +def head_commit: .commits.nodes[0].commit; + +# Only approvals given against the commit that would actually land count +def current_approvals: + # Bound before the map, where `.` is still the pull request rather than a review. + head_commit.oid as $oid + | .latestOpinionatedReviews.nodes + | map(select(.state == "APPROVED" and .commit.oid == $oid)); + +def check_results: (head_commit.statusCheckRollup.contexts.nodes // []) | map(.conclusion // .state); +def ci_started: (check_results | length) > 0; +def ci_failing: + check_results + | any(IN("FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE")); +# PRChecker treats neutral and skipped checks as passing. +def github_checks_passing: ci_started and (check_results | all(IN("SUCCESS", "NEUTRAL", "SKIPPED"))); + +def jenkins_contexts($pattern): + (head_commit.statusCheckRollup.contexts.nodes // []) + | map(select((.context // .name) | test($pattern; "i"))); +def jenkins_ran($pattern): (jenkins_contexts($pattern) | length) > 0; +def jenkins_passing($pattern): + jenkins_ran($pattern) and (jenkins_contexts($pattern) | all((.conclusion // .state) == "SUCCESS")); +def jenkins_required: has_label("needs-ci"); + +def relevant_check_times($pattern): + (if jenkins_required + then jenkins_contexts($pattern) + else (head_commit.statusCheckRollup.contexts.nodes // []) end) + | map(.completedAt // .createdAt) + | map(select(. != null) | fromdateiso8601); +def ci_recent($pattern; $max_age): + relevant_check_times($pattern) | (length > 0) and ((now - max) <= $max_age); + +def age_seconds: now - (.createdAt | fromdateiso8601); +def wait_waived: has_label("fast-track"); +'