Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
230 changes: 230 additions & 0 deletions .github/issue-assignment-automation-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# Issue-Assignment & PR Lifecycle Automation — Spec

## Goal

Reduce the poor contributor experience caused by premature and competing PRs:
gate PRs on issue assignment, give contributors a self-service `/take` flow, and
clean up abandoned work — **without ever penalizing a contributor for the team's
review latency**.

---

## High-level summary of features

1. **Self-service issue assignment** — contributors claim an issue by commenting
`/take`, and release it with `/untake`. No maintainer action required.
2. **PR assignment gate** — a PR linked to an issue whose author isn't an
assignee is warned (and, once enabled, closed) with clear recovery steps.
PRs with no linked issue are left alone. Maintainers and collaborators are
exempt.
3. **`Awaiting Review` auto-labeling** — while the ball is in the maintainers'
court, the PR is labeled `Awaiting Review` and made exempt from going stale,
so a contributor is never penalized for slow review.
4. **Automatic unassignment of inactive claims** — an issue claimed but left
inactive for 14 days (no PR, no assignee comment) is automatically freed, with
a comment inviting anyone to reclaim it.
5. **Tightened stale-PR lifecycle** — a PR with the ball in the contributor's
court is labeled `Stale` after 14 days of inactivity and auto-closed 7 days
later; the linked issue is freed when that stale PR closes.

The guiding principle throughout: **a contributor's claim — and their PR — should
only ever lapse due to the contributor's own inactivity, never the team's.**

---

## The contributor flow

1. **Find an available issue.** Available = not labeled `Needs Triage` or
`Needs Discussion`, and not already assigned to someone else.
2. **Claim it:** comment `/take`. The bot assigns you and reacts 👍.
- If the issue is still `Needs Triage` / `Needs Discussion`, the bot declines
and explains it isn't ready yet.
- If someone else already holds it, the bot declines and points you to the
takeover policy (you may take over after 14 days of their inactivity).
3. **Open a PR** that links the issue (e.g. `closes #1234`). Because you're an
assignee, the assignment gate passes silently.
- If you open a PR **without** being assigned, the bot labels it
`Needs Issue Assignment` and comments (and, once close-mode is enabled,
closes it) with the recovery steps: *comment `/take` on the issue to get
assigned, then reopen this PR.* You can reopen your own PR yourself.
4. **While waiting on review,** your PR carries `Awaiting Review` and is exempt
from going stale. You keep the issue no matter how long review takes.
5. **If changes are requested and you go quiet for 14 days,** the PR is labeled
`Stale`, then auto-closed 7 days later. The linked issue is freed when the PR
closes — so it and the PR release together.
6. **Change your mind early?** Comment `/untake` to release the issue immediately
so someone else can pick it up.
7. **Claimed an issue but never opened a PR and went quiet for 14 days?** The
issue is automatically unassigned, with a comment inviting anyone to `/take`
it again.

---

## Labels

| Label | Status | Owner | Purpose |
|---|---|---|---|
| `Needs Issue Assignment` | **create** | bot | PR linked to an issue whose author isn't an assignee |
| `Awaiting Review` | **create** | bot | PR where the ball is with maintainers; exempts it from stale |
| `Needs Triage` | exists | human | blocks `/take` |
| `Needs Discussion` | exists | human | blocks `/take` |
| `Stale`, `Blocked` | exist | — | unchanged |

---

## Component 1 — `/take` and `/untake` (github-script, *not* Python)

In `comment-commands.yml`; latency-sensitive and trivial logic. Issue comments
only (`!issue.pull_request`).

**`/take`:**
- Issue has `Needs Triage` or `Needs Discussion` → refuse with a comment
("not available until triaged"); no assignment.
- Issue unassigned → assign commenter, react 👍.
- Issue already assigned to someone else → refuse. There is no manual takeover;
an inactive assignment is freed automatically (Component 4) after
`STALE_ASSIGNEE_DAYS`, after which the issue can be claimed with `/take`.

**`/untake`:**
- Remove the commenter from assignees, react 👍. (Maintainers unassign others via
the GitHub UI — not via command.)

Multi-contributor collaboration: no special automation. A maintainer manually
adds co-assignees; the gate checks "author is *among* assignees."

---

## Component 2 — PR assignment gate (Python, `scripts/`)

- **Trigger:** `pull_request_target`, types `[opened, reopened]`.
- **Exemptions (skip entirely):** `author_association ∈ {OWNER, MEMBER,
COLLABORATOR}` (covers all org/team members incl. pandas-core/pandas-triage,
plus hand-added collaborators) **and** bots (`user.type == 'Bot'`).
- **Linked issues:** resolved via GraphQL `closingIssuesReferences` (authoritative
Development links).
- **Decisions:**
- No linked issue → **do nothing** (rule removed entirely).
- Author is an assignee of ≥1 linked issue → pass (clear `Needs Issue
Assignment` if present).
- Author is *not* an assignee of any linked issue → **warn**: add `Needs Issue
Assignment` + comment. **Close only if `CLOSE_ENABLED == true`** (default
`false`).
- This single rule subsumes the Needs-Triage/Discussion case (those issues can't
be taken → no assignee → flagged).
- **Close comment states recovery order explicitly:** "comment `/take` on issue
#N to get assigned, *then* reopen this PR." Gate re-fires on `reopened`, so the
author's self-service reopen works.

---

## Component 3 — `Awaiting Review` label (Python, `scripts/`)

- **`awaiting_contributor`** ≝ latest `CHANGES_REQUESTED` review is newer than the
latest commit (author hasn't pushed since changes were requested). No
changes-requested, or pushed-after → *not* awaiting_contributor.
- **Apply** `Awaiting Review` to an open, non-draft PR that is **not**
`awaiting_contributor`; **remove** it when the PR becomes draft or
`awaiting_contributor`. The daily batch only scans *open* PRs, so a closed PR
keeps whatever label it last had — harmless, since closed PRs aren't subject to
stale.
- **Trigger:** a **daily reconciliation** over all open PRs, folded into the
scheduled maintenance job (Component 4) so it shares a single runner boot
rather than firing on every push. The batched GraphQL read also returns
current labels, so PRs already in the right state cost no write. Up to ~24h of
label lag is harmless against the 14-day stale window.
- Wired into `stale-pr.yml` `exempt-pr-labels` → an awaiting-review PR **never
goes Stale**.

---

## Component 4 — Issue unassign / inactivity (Python, `scripts/`)

- **Tunable:** `STALE_ASSIGNEE_DAYS = 14` (single top-level constant).
- **Issue stays claimed** if an assignee has **any open linked PR** (Stale or not)
**or** an assignee commented within 14 days (assignee-scoped — third-party
chatter doesn't count).
- **Issue is freed when:**
1. A `Stale`-labeled PR by the assignee is **closed** → free immediately (on the
close event). `Stale`-at-close is a reliable proxy for "contributor's court,
abandoned," because awaiting-review PRs are exempt from Stale.
2. Assignee has **no open PR** and no comment within 14 days → scheduled job
unassigns + comments "freed up — comment `/take` to reclaim."
3. `/untake`.
- PR closed **without** `Stale` → no auto-free (deliberate close); the 14-day timer
handles it only if they then vanish.
- **Triggers:** daily `schedule` + `pull_request_target` `[closed]`.

---

## Component 5 — `stale-pr.yml` changes

- `days-before-pr-stale: 14` (down from 30) → applies `Stale`.
- `days-before-close: 7` → auto-close 7 days after `Stale` (was `-1`/never).
- **`remove-stale-when-updated: true`** (was `false`) — mandatory, so a push
clears `Stale` and an active contributor can't be auto-closed mid-fix.
- `exempt-pr-labels`: replace the **dead `Needs Review`** with **`Awaiting
Review`**; keep `Blocked`, `Needs Discussion`. (`Needs Issue Assignment` is
*not* exempt — gated PRs may go stale.)

---

## Cross-cutting: architecture & conventions

- **github-script** for `/take`/`/untake`; **Python in `scripts/`** for gate,
Awaiting Review, unassign — split into an API-client layer + **pure decision
functions**.
- **Unit tests** in `scripts/tests/` covering the pure logic
(awaiting_contributor, activity rules, gate decision, exemptions) and the daily
label reconciliation (via a fake client) with mocked API data.
- **Thin YAML:** checkout (base repo only, sparse `scripts/`) → run
`python3 -m scripts.issue_assignment.…` on the runner's **system Python** (the
code is stdlib-only, so no `setup-python` / pip step). Event data passed via env.
- **GitHub API:** the pre-authenticated **`gh` CLI only** — REST for writes,
several GraphQL queries for reads (linked issues, issue activity, the batched
open-PR scan). No `requests`, no PyGithub.
- All actions **SHA-pinned**; `permissions: {}` top-level + granular per-job;
`if: github.repository_owner == 'pandas-dev'`; `ubuntu-24.04`.
`pull_request_target` is safe here — we only read metadata and call the API,
never check out or execute PR code.

---

## Files

| File | Action |
|---|---|
| `.github/workflows/comment-commands.yml` | edit — add `/take`, `/untake` |
| `.github/workflows/pr-issue-gate.yml` | new |
| `.github/workflows/unassign-inactive.yml` | new — daily sweep + `Awaiting Review` reconcile + PR-close unassign |
| `.github/workflows/stale-pr.yml` | edit — thresholds, `remove-stale-when-updated`, exempt-label swap |
| `scripts/issue_assignment/…` | new — Python logic |
| `scripts/tests/test_issue_assignment.py` | new — unit tests |
| `doc/source/development/contributing.rst` | edit — document new flow |

---

## Docs (`contributing.rst`, ~lines 51–64)

"Leave a comment with your intention" must change: a comment no longer grants a
claim — **only `/take` does**. Document `/take`/`/untake`, the assignment gate,
Needs-Triage/Discussion blocking, and the 14-day auto-unassign.

---

## Rollout sequencing (warm-up, not phases)

1. Ship everything with gate `CLOSE_ENABLED = false` (warn-only).
2. Swap `exempt-pr-labels` to `Awaiting Review` **before** enabling stale
auto-close (else nothing protects awaiting-review PRs).
3. Let `Awaiting Review` labeling run and prove correct, **then** flip stale
auto-close on.
4. Then flip gate `CLOSE_ENABLED = true`.
5. Migration: warn-only (+ optional "PRs opened after cutoff") spares in-flight
PRs and old-style intent-commenters from retroactive closes.

---

## Tunable parameters (single constants)

`STALE_ASSIGNEE_DAYS = 14` · `days-before-pr-stale = 14` · `days-before-close = 7`
· gate `CLOSE_ENABLED = false`.
75 changes: 75 additions & 0 deletions .github/workflows/comment-commands.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,78 @@ jobs:
with:
previewer-server: "https://pandas.pydata.org/preview"
artifact-job: "Doc Build and Upload"

take:
runs-on: ubuntu-24.04
permissions:
issues: write
if: >-
github.repository_owner == 'pandas-dev' &&
github.event.issue.pull_request == null &&
startsWith(github.event.comment.body, '/take')
concurrency:
group: assign-${{ github.event.issue.number }}
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
if (context.payload.comment.body.trim() !== '/take') return;
const docsUrl = 'https://pandas.pydata.org/docs/development/contributing.html#issue-assignment-and-the-pull-request-lifecycle';
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const labels = issue.labels.map(label => label.name);
const assignees = issue.assignees.map(assignee => assignee.login);
const blocking = ['Needs Triage', 'Needs Discussion'].find(label => labels.includes(label));
const comment = body => github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body,
});
if (blocking) {
await comment(`Thanks for your interest, @${user}! This issue is still labeled \`${blocking}\`, which means a maintainer needs to review it before work starts — so it isn't open to claim just yet. Please hold off on a PR until that label is removed. See the [contributing guide](${docsUrl}) for more.`);
return;
}
if (assignees.includes(user)) {
await comment(`You already have this one, @${user} — it's assigned to you and you're all set to work on it.`);
return;
}
if (assignees.length > 0) {
await comment(`Thanks @${user}! This issue is currently assigned to @${assignees[0]}, who's already on it — so to avoid two people doing the same work, please pick a different available issue for now. If @${assignees[0]} becomes inactive, the issue is released automatically and you'll be able to \`/take\` it then. See the [contributing guide](${docsUrl}) for more.`);
Comment thread
mroeschke marked this conversation as resolved.
return;
}
await github.rest.issues.addAssignees({
owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, assignees: [user],
});
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: '+1',
});

untake:
runs-on: ubuntu-24.04
permissions:
issues: write
if: >-
github.repository_owner == 'pandas-dev' &&
github.event.issue.pull_request == null &&
startsWith(github.event.comment.body, '/untake')
concurrency:
group: assign-${{ github.event.issue.number }}
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
if (context.payload.comment.body.trim() !== '/untake') return;
const issue = context.payload.issue;
const user = context.payload.comment.user.login;
const assignees = issue.assignees.map(assignee => assignee.login);
if (!assignees.includes(user)) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number,
body: `@${user}, you're not currently assigned to this issue, so there's nothing to release. If you'd like to work on it, comment \`/take\` to claim it.`,
});
return;
}
await github.rest.issues.removeAssignees({
owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, assignees: [user],
});
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo, comment_id: context.payload.comment.id, content: '+1',
});
26 changes: 26 additions & 0 deletions .github/workflows/pr-issue-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: PR Issue Assignment Gate
on:
pull_request_target:
types: [opened, reopened]

permissions: {}

jobs:
gate:
if: github.repository_owner == 'pandas-dev'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: read
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: scripts
persist-credentials: false
- run: python3 -m scripts.issue_assignment.gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Warn-only until the gate has been observed in production; flip to
# 'true' to start closing flagged pull requests.
CLOSE_ENABLED: 'false'
11 changes: 6 additions & 5 deletions .github/workflows/stale-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ jobs:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-pr-message: "This pull request is stale because it has been open for thirty days with no activity. Please [update](https://pandas.pydata.org/pandas-docs/stable/development/contributing.html#updating-your-pull-request) and respond to this comment if you're still interested in working on this."
stale-pr-message: "This pull request has been automatically marked **stale** because it hasn't had any activity in **14 days**. If you're still working on it, just push an update or leave a comment and the label will clear itself. Otherwise it will be closed in **7 days** to keep the review queue manageable — and you can always reopen it later to continue. See the [contributing guide](https://pandas.pydata.org/docs/development/contributing.html#issue-assignment-and-the-pull-request-lifecycle) for how the pull request lifecycle works."
close-pr-message: "This pull request has been automatically closed after being stale for **7 days**. Thank you for the work you put into it! If you'd like to continue, you can reopen this PR — **nothing is lost.** If the linked issue has since been claimed by someone else, just leave a comment there to coordinate. See the [contributing guide](https://pandas.pydata.org/docs/development/contributing.html#issue-assignment-and-the-pull-request-lifecycle)."
stale-pr-label: "Stale"
exempt-pr-labels: "Needs Review,Blocked,Needs Discussion"
exempt-pr-labels: "Awaiting Review,Blocked,Needs Discussion"
days-before-issue-stale: -1
days-before-pr-stale: 30
days-before-close: -1
remove-stale-when-updated: false
days-before-pr-stale: 14
days-before-close: 7
remove-stale-when-updated: true
debug-only: false
31 changes: 31 additions & 0 deletions .github/workflows/unassign-inactive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Issue and PR maintenance
on:
schedule:
- cron: "0 1 * * *"
pull_request_target:
types: [closed]

permissions: {}

jobs:
maintenance:
if: github.repository_owner == 'pandas-dev'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
sparse-checkout: scripts
persist-credentials: false
- name: Unassign inactive or freed issues
run: python3 -m scripts.issue_assignment.unassign_inactive
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Reconcile Awaiting Review labels
if: github.event_name == 'schedule'
run: python3 -m scripts.issue_assignment.label_awaiting_review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Loading
Loading