From b7b3a13c255c336e2a44ce65ee3955ce506a65ad Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Sat, 24 Oct 2026 20:39:07 +0530 Subject: [PATCH 1/2] ci: draft inactive PRs awaiting author action --- .github/workflows/draft_inactive_prs.yml | 65 ++++++++ ci/scripts/draft_inactive_prs.cjs | 179 +++++++++++++++++++++++ docs/source/contributor-guide/index.md | 14 ++ 3 files changed, 258 insertions(+) create mode 100644 .github/workflows/draft_inactive_prs.yml create mode 100644 ci/scripts/draft_inactive_prs.cjs diff --git a/.github/workflows/draft_inactive_prs.yml b/.github/workflows/draft_inactive_prs.yml new file mode 100644 index 0000000000000..036d8174d5082 --- /dev/null +++ b/.github/workflows/draft_inactive_prs.yml @@ -0,0 +1,65 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Draft inactive PRs + +on: + schedule: + - cron: "0 1 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Report eligible PRs without changing them" + type: boolean + default: true + +permissions: + checks: read + statuses: read + # Required by the convertPullRequestToDraft GraphQL mutation. + contents: write + pull-requests: write + +concurrency: + group: draft-inactive-prs + cancel-in-progress: false + +jobs: + draft: + if: github.repository == 'apache/datafusion' + runs-on: ubuntu-slim + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + with: + script: | + const { run } = require('./ci/scripts/draft_inactive_prs.cjs'); + const dryRun = process.env.DRY_RUN !== 'false'; + const results = await run({ github, context, core, dryRun }); + await core.summary + .addHeading(dryRun ? 'Would mark as draft' : 'Marked as draft') + .addTable([ + [{ data: 'PR', header: true }, { data: 'Reason', header: true }], + ...results.map(({ number, reasons }) => [String(number), reasons.join('; ')]), + ]) + .write(); diff --git a/ci/scripts/draft_inactive_prs.cjs b/ci/scripts/draft_inactive_prs.cjs new file mode 100644 index 0000000000000..e901189998b1e --- /dev/null +++ b/ci/scripts/draft_inactive_prs.cjs @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const { setTimeout } = require("node:timers/promises"); + +const IDLE_DAYS = 14; +const QUERY = `query( + $owner: String!, $repo: String!, $number: Int!, + $checksCursor: String, $reviewsCursor: String, + $loadChecks: Boolean!, $loadReviews: Boolean! +) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id number state isDraft updatedAt headRefOid mergeable + mergeQueueEntry { id } + timelineItems(last: 1, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { + nodes { ... on HeadRefForcePushedEvent { createdAt } } + } + latestOpinionatedReviews(first: 100, after: $reviewsCursor) @include(if: $loadReviews) { + nodes { state submittedAt commit { oid } } + pageInfo { hasNextPage endCursor } + } + commits(last: 1) @include(if: $loadChecks) { + nodes { commit { + pushedDate + statusCheckRollup { + contexts(first: 100, after: $checksCursor) { + nodes { + ... on CheckRun { status conclusion } + ... on StatusContext { state } + } + pageInfo { hasNextPage endCursor } + } + } + } } + } + } + } +}`; + +const sameVersion = (a, b) => + a.headRefOid === b.headRefOid && a.updatedAt === b.updatedAt; + +async function loadPullRequest(github, repo, number) { + const variables = { ...repo, number, loadChecks: true, loadReviews: true }; + let pr; + const checks = []; + const reviews = []; + while (variables.loadChecks || variables.loadReviews) { + const { repository } = await github.graphql(QUERY, variables); + const current = repository.pullRequest; + if (!current || (pr && !sameVersion(pr, current))) return null; + pr = { ...pr, ...current }; + if (variables.loadChecks) { + const connection = + current.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + checks.push(...(connection?.nodes ?? [])); + variables.loadChecks = connection?.pageInfo.hasNextPage ?? false; + variables.checksCursor = connection?.pageInfo.endCursor; + } + if (variables.loadReviews) { + const connection = current.latestOpinionatedReviews; + reviews.push(...connection.nodes); + variables.loadReviews = connection.pageInfo.hasNextPage; + variables.reviewsCursor = connection.pageInfo.endCursor; + } + } + return { ...pr, checks, reviews }; +} + +function reasonsToDraft(pr, cutoff) { + if ( + !pr || + pr.state !== "OPEN" || + pr.isDraft || + pr.mergeQueueEntry || + !(Date.parse(pr.updatedAt) <= cutoff) + ) + return []; + + const reasons = []; + if (pr.mergeable === "CONFLICTING") reasons.push("merge conflicts"); + // The rollup reports FAILURE for cancelled checks too. + if ( + pr.checks.some( + (check) => + (check.status === "COMPLETED" && + ["FAILURE", "TIMED_OUT"].includes(check.conclusion)) || + ["FAILURE", "ERROR"].includes(check.state) + ) + ) + reasons.push("failing CI checks"); + + const forcePush = pr.timelineItems.nodes[0]?.createdAt; + const pushedDate = pr.commits.nodes[0]?.commit.pushedDate; + // pushedDate can be null; also check the reviewed commit and last force push. + if ( + pr.reviews.some( + (review) => + review.state === "CHANGES_REQUESTED" && + review.commit?.oid === pr.headRefOid && + (!forcePush || + Date.parse(review.submittedAt) > Date.parse(forcePush)) && + (!pushedDate || Date.parse(review.submittedAt) > Date.parse(pushedDate)) + ) + ) + reasons.push("changes requested on the latest push"); + return reasons; +} + +async function run({ github, context, core, dryRun = true }) { + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const cutoff = Date.now() - IDLE_DAYS * 24 * 60 * 60 * 1000; + // Collect the list before mutations change updated_at and its pagination. + const prs = await github.paginate(github.rest.pulls.list, { + ...repo, + state: "open", + sort: "updated", + direction: "asc", + per_page: 100, + }); + const results = []; + for (const listed of prs) { + if (listed.draft || !(Date.parse(listed.updated_at) <= cutoff)) continue; + let pr = await loadPullRequest(github, repo, listed.number); + let reasons = reasonsToDraft(pr, cutoff); + if (!reasons.length) continue; + if (!dryRun) { + const fresh = await loadPullRequest(github, repo, listed.number); + if (!fresh || !sameVersion(pr, fresh)) continue; + pr = fresh; + reasons = reasonsToDraft(pr, cutoff); + if (!reasons.length) continue; + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { id } + } + }`, + { id: pr.id } + ); + // Respect GitHub's secondary rate limits between writes. + await setTimeout(1000); + await github.rest.issues.createComment({ + ...repo, + issue_number: pr.number, + body: + `Marked as draft after ${IDLE_DAYS} days without activity: ${reasons.join( + "; " + )}.\n\n` + + "Please address these items and mark **Ready for review** when ready.", + }); + await setTimeout(1000); + } + core.info( + `${dryRun ? "Would draft" : "Drafted"} #${pr.number}: ${reasons.join( + "; " + )}` + ); + results.push({ number: pr.number, reasons }); + } + return results; +} + +module.exports = { run }; diff --git a/docs/source/contributor-guide/index.md b/docs/source/contributor-guide/index.md index 28c1319491e99..a9fc48b4bac46 100644 --- a/docs/source/contributor-guide/index.md +++ b/docs/source/contributor-guide/index.md @@ -205,8 +205,22 @@ The good thing about open code and open development is that any issues in one ch ## Stale PRs +Open, non-draft PRs with no activity for 14 days are automatically marked as +draft if they have merge conflicts, failing CI checks, or an outstanding +changes-requested review on the latest push. PRs awaiting review with none of +these conditions remain ready for review. Cancelled, pending, skipped, or missing +CI checks do not trigger drafting. PRs in the merge queue are excluded. + +The automation leaves a comment explaining why the PR was drafted. After +addressing the listed items, mark the PR **Ready for review**. If you need help +or a CI failure is unrelated to your changes, leave a comment on the PR. + +Maintainers can preview eligible PRs by running the **Draft inactive PRs** +workflow manually with its default `dry_run` input enabled. + Pull requests will be marked with a `stale` label after 60 days of inactivity and then closed 7 days after that. Commenting on the PR will remove the `stale` label. +The automatic draft notification also counts as activity and restarts this timer. ## CI Runners From ef5709fd693e9eda0c2d31c63bdc08c27eb996f3 Mon Sep 17 00:00:00 2001 From: Kumar Ujjawal Date: Sun, 25 Oct 2026 10:55:16 +0530 Subject: [PATCH 2/2] ci: warm PR mergeability before drafting --- ci/scripts/draft_inactive_prs.cjs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/ci/scripts/draft_inactive_prs.cjs b/ci/scripts/draft_inactive_prs.cjs index e901189998b1e..940eec2e8db30 100644 --- a/ci/scripts/draft_inactive_prs.cjs +++ b/ci/scripts/draft_inactive_prs.cjs @@ -27,16 +27,12 @@ const QUERY = `query( pullRequest(number: $number) { id number state isDraft updatedAt headRefOid mergeable mergeQueueEntry { id } - timelineItems(last: 1, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { - nodes { ... on HeadRefForcePushedEvent { createdAt } } - } latestOpinionatedReviews(first: 100, after: $reviewsCursor) @include(if: $loadReviews) { - nodes { state submittedAt commit { oid } } + nodes { state commit { oid } } pageInfo { hasNextPage endCursor } } commits(last: 1) @include(if: $loadChecks) { nodes { commit { - pushedDate statusCheckRollup { contexts(first: 100, after: $checksCursor) { nodes { @@ -105,17 +101,11 @@ function reasonsToDraft(pr, cutoff) { ) reasons.push("failing CI checks"); - const forcePush = pr.timelineItems.nodes[0]?.createdAt; - const pushedDate = pr.commits.nodes[0]?.commit.pushedDate; - // pushedDate can be null; also check the reviewed commit and last force push. if ( pr.reviews.some( (review) => review.state === "CHANGES_REQUESTED" && - review.commit?.oid === pr.headRefOid && - (!forcePush || - Date.parse(review.submittedAt) > Date.parse(forcePush)) && - (!pushedDate || Date.parse(review.submittedAt) > Date.parse(pushedDate)) + review.commit?.oid === pr.headRefOid ) ) reasons.push("changes requested on the latest push"); @@ -133,10 +123,23 @@ async function run({ github, context, core, dryRun = true }) { direction: "asc", per_page: 100, }); + const candidates = prs.filter( + (listed) => !listed.draft && Date.parse(listed.updated_at) <= cutoff + ); + if (!candidates.length) return []; + + // GitHub computes mergeability lazily; warm every candidate before deciding. + for (const listed of candidates) { + await loadPullRequest(github, repo, listed.number); + } + await setTimeout(30_000); + const results = []; - for (const listed of prs) { - if (listed.draft || !(Date.parse(listed.updated_at) <= cutoff)) continue; + for (const listed of candidates) { let pr = await loadPullRequest(github, repo, listed.number); + if (pr?.mergeable === "UNKNOWN") { + core.warning(`Mergeability is still unknown for #${listed.number}.`); + } let reasons = reasonsToDraft(pr, cutoff); if (!reasons.length) continue; if (!dryRun) {