Skip to content
Merged
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
65 changes: 65 additions & 0 deletions .github/workflows/draft_inactive_prs.yml
Original file line number Diff line number Diff line change
@@ -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();
182 changes: 182 additions & 0 deletions ci/scripts/draft_inactive_prs.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// 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 }
latestOpinionatedReviews(first: 100, after: $reviewsCursor) @include(if: $loadReviews) {
nodes { state commit { oid } }
pageInfo { hasNextPage endCursor }
}
commits(last: 1) @include(if: $loadChecks) {
nodes { commit {
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");

if (
pr.reviews.some(
(review) =>
review.state === "CHANGES_REQUESTED" &&
review.commit?.oid === pr.headRefOid
)
)
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 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 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) {
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 };
14 changes: 14 additions & 0 deletions docs/source/contributor-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading