Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/author-ready.yml
Original file line number Diff line number Diff line change
@@ -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 }}
36 changes: 36 additions & 0 deletions .github/workflows/auto-commit-queue.yml
Original file line number Diff line number Diff line change
@@ -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 }}
76 changes: 76 additions & 0 deletions tools/actions/author-ready.sh
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review comments can be plain comments. This automation only counts GitHub inline code review comments (only these comments have a "resolve" button).


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
93 changes: 93 additions & 0 deletions tools/actions/auto-commit-queue.sh
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions tools/actions/pr-state.sh
Original file line number Diff line number Diff line change
@@ -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");
'
Loading