From 0e52e8282fff28d721e745159d341080762aa437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ahlert?= Date: Mon, 30 Mar 2026 16:05:47 -0300 Subject: [PATCH 1/3] ci: add stale PR lifecycle workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly cron job (Mondays 09:00 UTC) that enforces the PR lifecycle policy defined in #690: - 14 days after review with no author response: label pr/stale and convert to draft - 90 days with no activity: close with comment - Skip PRs with lifecycle/frozen, pr/do-not-merge, or from dependabot Closes #695 Signed-off-by: André Ahlert --- .github/workflows/stale-prs.yml | 167 ++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .github/workflows/stale-prs.yml diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml new file mode 100644 index 000000000..b1e7bc9fd --- /dev/null +++ b/.github/workflows/stale-prs.yml @@ -0,0 +1,167 @@ +# +# Stale PR lifecycle management +# Part of #690 (repo governance) and #695 (stale PR policy) +# +# Policy: +# - 14 days after review with no author response -> pr/stale + convert to draft +# - 90 days with no activity at all -> close with comment +# - PRs with lifecycle/frozen or pr/do-not-merge are always skipped + +name: Stale PR Lifecycle + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 09:00 UTC + workflow_dispatch: # Allow manual trigger + +permissions: + pull-requests: write + issues: write + +jobs: + stale-prs: + runs-on: ubuntu-latest + steps: + - name: Mark stale and close inactive PRs + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const STALE_DAYS = 14; + const CLOSE_DAYS = 90; + const SKIP_LABELS = ['lifecycle/frozen', 'pr/do-not-merge']; + const STALE_LABEL = 'pr/stale'; + + const now = new Date(); + + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'asc', + per_page: 100, + }); + + for (const pr of prs) { + const labels = pr.labels.map(l => l.name); + + // Skip protected PRs + if (SKIP_LABELS.some(skip => labels.includes(skip))) { + console.log(`#${pr.number}: skipped (protected label)`); + continue; + } + + // Skip dependabot PRs + if (pr.user.login === 'dependabot[bot]') { + console.log(`#${pr.number}: skipped (dependabot)`); + continue; + } + + const updatedAt = new Date(pr.updated_at); + const daysSinceUpdate = Math.floor((now - updatedAt) / (1000 * 60 * 60 * 24)); + + // 90+ days no activity -> close + if (daysSinceUpdate >= CLOSE_DAYS) { + const closeMsg = [ + `This PR has had no activity for ${daysSinceUpdate} days.`, + 'Closing to keep the PR list manageable.', + '', + 'Feel free to reopen when you are ready to continue.', + 'If the branch has conflicts, a fresh PR against `main` might be easier.', + ].join('\n'); + + console.log(`#${pr.number}: closing (${daysSinceUpdate} days inactive)`); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: closeMsg, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed', + }); + + continue; + } + + // 14+ days since last review with no author response -> stale + draft + if (daysSinceUpdate >= STALE_DAYS && !labels.includes(STALE_LABEL)) { + // Check if there's a review that the author hasn't responded to + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + + const hasUnaddressedReview = reviews.some(r => + r.user.login !== pr.user.login && + r.state !== 'APPROVED' && + r.state !== 'DISMISSED' + ); + + if (!hasUnaddressedReview) { + console.log(`#${pr.number}: skipped (no unaddressed review)`); + continue; + } + + console.log(`#${pr.number}: marking stale (${daysSinceUpdate} days, unaddressed review)`); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [STALE_LABEL], + }); + + // Convert to draft via GraphQL + try { + await github.graphql(` + mutation($id: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $id }) { + pullRequest { id } + } + } + `, { id: pr.node_id }); + } catch (e) { + console.log(`#${pr.number}: could not convert to draft (${e.message})`); + } + + const staleMsg = [ + `This PR has been inactive for ${daysSinceUpdate} days after receiving review feedback.`, + 'Converting to draft.', + '', + 'Please mark it as ready for review when you have addressed the comments.', + 'If no activity occurs within 90 days, it will be closed automatically.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: staleMsg, + }); + } + } From cf5d15bd7d06c1c34066d3a4769a8d44eba36c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ahlert?= Date: Mon, 30 Mar 2026 16:22:21 -0300 Subject: [PATCH 2/3] ci: add auto-labeling for new issues and PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New issues automatically get `status/needs-triage` - PRs get `area/*` labels based on changed files via actions/labeler - PRs get `pr/needs-rebase` when they have merge conflicts (auto-removed when resolved) Labeler config maps repo paths to area labels: core, ui, storage, streaming, hooks, tracking, integrations, visualization, website, ci, examples, typing. Closes #696 Signed-off-by: André Ahlert --- .github/labeler.yml | 77 +++++++++++++++++++++ .github/workflows/auto-label.yml | 111 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 .github/labeler.yml create mode 100644 .github/workflows/auto-label.yml diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..cf99246d8 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,77 @@ +# Auto-labeling config for actions/labeler +# Maps file path patterns to area/* labels for PRs +# Part of #696 + +"area/core": + - changed-files: + - any-glob-to-any-file: + - burr/core/** + - burr/lifecycle/** + - burr/system.py + +"area/ui": + - changed-files: + - any-glob-to-any-file: + - telemetry/ui/** + +"area/storage": + - changed-files: + - any-glob-to-any-file: + - burr/core/persistence.py + - burr/integrations/persisters/** + - burr/tracking/server/s3/** + +"area/streaming": + - changed-files: + - any-glob-to-any-file: + - burr/core/parallelism.py + +"area/hooks": + - changed-files: + - any-glob-to-any-file: + - burr/lifecycle/** + +"area/tracking": + - changed-files: + - any-glob-to-any-file: + - burr/tracking/** + - burr/telemetry.py + - burr/visibility/** + +"area/integrations": + - changed-files: + - any-glob-to-any-file: + - burr/integrations/** + +"area/visualization": + - changed-files: + - any-glob-to-any-file: + - burr/visibility/** + +"area/website": + - changed-files: + - any-glob-to-any-file: + - website/** + - docs/** + +"area/ci": + - changed-files: + - any-glob-to-any-file: + - .github/** + - scripts/** + - .pre-commit-config.yaml + - .rat-excludes + - pyproject.toml + - setup.cfg + +"area/examples": + - changed-files: + - any-glob-to-any-file: + - examples/** + - burr/examples/** + +"area/typing": + - changed-files: + - any-glob-to-any-file: + - burr/core/typing.py + - burr/integrations/pydantic.py diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 000000000..c6f85d2e5 --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,111 @@ +# +# Auto-labeling for new issues and PRs +# Part of #690 (repo governance) and #696 (auto-labeling) +# +# - New issues get status/needs-triage +# - PRs get area/* labels based on changed files +# - PRs get pr/needs-rebase when they have merge conflicts +# +# Security: this workflow only uses numeric IDs from context (issue_number, +# pull_request.number). No untrusted string input is interpolated. + +name: Auto-label + +on: + issues: + types: [opened] + pull_request_target: + types: [opened, synchronize] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + triage-issues: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - name: Add status/needs-triage to new issues + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['status/needs-triage'] + }); + + label-prs: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Apply area/* labels based on changed files + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: false + + check-mergeable: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Check for merge conflicts + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const LABEL = 'pr/needs-rebase'; + + // Wait for GitHub to compute mergeability + await new Promise(r => setTimeout(r, 5000)); + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + + const labels = pr.labels.map(l => l.name); + const hasLabel = labels.includes(LABEL); + + if (pr.mergeable === false && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [LABEL], + }); + console.log(`#${pr.number}: added ${LABEL}`); + } else if (pr.mergeable === true && hasLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: LABEL, + }); + console.log(`#${pr.number}: removed ${LABEL}`); + } else { + console.log(`#${pr.number}: no change (mergeable=${pr.mergeable}, hasLabel=${hasLabel})`); + } From 870d8ea1ee805acf1b8f410d7b71dce7ca1b2901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ahlert?= Date: Mon, 30 Mar 2026 16:26:30 -0300 Subject: [PATCH 3/3] ci: add issue assignment policy and stale assignment workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the assignment policy in CONTRIBUTING.rst: - Assignee = actively working - 14 days without activity: warning comment - 21 days total: unassign + add help wanted - lifecycle/frozen issues are exempt Adds weekly workflow (Wednesdays 09:00 UTC) to enforce it automatically. Closes #709 Signed-off-by: André Ahlert --- .github/workflows/stale-assignments.yml | 170 ++++++++++++++++++++++++ CONTRIBUTING.rst | 18 +++ 2 files changed, 188 insertions(+) create mode 100644 .github/workflows/stale-assignments.yml diff --git a/.github/workflows/stale-assignments.yml b/.github/workflows/stale-assignments.yml new file mode 100644 index 000000000..d418958e9 --- /dev/null +++ b/.github/workflows/stale-assignments.yml @@ -0,0 +1,170 @@ +# +# Stale issue assignment check +# Part of #690 (repo governance) and #709 (assignment policy) +# +# Policy (documented in CONTRIBUTING.rst): +# - 14 days without activity on an assigned issue -> comment asking for update +# - 21 days total -> unassign + add help wanted +# - Issues with lifecycle/frozen are exempt +# +# Security: only uses numeric IDs and login names from the GitHub API. +# No untrusted string input is interpolated into shell commands. + +name: Stale Assignments + +on: + schedule: + - cron: "0 9 * * 3" # Every Wednesday at 09:00 UTC + workflow_dispatch: + +permissions: + issues: write + +jobs: + check-assignments: + runs-on: ubuntu-latest + steps: + - name: Check for stale assignments + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const WARN_DAYS = 14; + const UNASSIGN_DAYS = 21; + const SKIP_LABELS = ['lifecycle/frozen']; + const now = new Date(); + + let page = 1; + let allIssues = []; + + // Paginate through all open issues + while (true) { + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + page: page, + }); + if (issues.length === 0) break; + allIssues = allIssues.concat(issues); + page++; + } + + for (const issue of allIssues) { + // Skip PRs (they show up in the issues API) + if (issue.pull_request) continue; + + // Skip unassigned + if (!issue.assignees || issue.assignees.length === 0) continue; + + const labels = issue.labels.map(l => l.name); + + // Skip protected issues + if (SKIP_LABELS.some(skip => labels.includes(skip))) { + console.log(`#${issue.number}: skipped (protected)`); + continue; + } + + const updatedAt = new Date(issue.updated_at); + const daysSinceUpdate = Math.floor((now - updatedAt) / (1000 * 60 * 60 * 24)); + const assigneeLogins = issue.assignees.map(a => a.login); + + // 21+ days -> unassign and add help wanted + if (daysSinceUpdate >= UNASSIGN_DAYS) { + const names = assigneeLogins.map(l => `@${l}`).join(', '); + + console.log(`#${issue.number}: unassigning ${names} (${daysSinceUpdate} days)`); + + const unassignMsg = [ + `Removing assignment from ${names} after ${daysSinceUpdate} days of inactivity`, + '(per the [assignment policy](../blob/main/CONTRIBUTING.rst#issue-assignment-policy)).', + '', + 'Feel free to comment if you want to pick this back up or if someone else wants to take it.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: unassignMsg, + }); + + await github.rest.issues.removeAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: assigneeLogins, + }); + + if (!labels.includes('help wanted')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['help wanted'], + }); + } + + continue; + } + + // 14+ days -> warn + if (daysSinceUpdate >= WARN_DAYS) { + // Check if we already warned (avoid spamming) + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 5, + }); + + const recentWarn = comments.some(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('assignment policy') && + (now - new Date(c.created_at)) / (1000 * 60 * 60 * 24) < 10 + ); + + if (recentWarn) { + console.log(`#${issue.number}: skipped (already warned recently)`); + continue; + } + + const names = assigneeLogins.map(l => `@${l}`).join(', '); + + console.log(`#${issue.number}: warning ${names} (${daysSinceUpdate} days)`); + + const warnMsg = [ + `${names}, this issue has been inactive for ${daysSinceUpdate} days.`, + 'Are you still working on it? Drop a comment to let us know.', + '', + 'If there is no update within 7 days, the assignment will be removed', + 'so someone else can pick it up', + '(per the [assignment policy](../blob/main/CONTRIBUTING.rst#issue-assignment-policy)).', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: warnMsg, + }); + } + } diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index db48538f0..1dd3f62cf 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -41,6 +41,24 @@ Please: #. Ensure all new features have tests #. Add documentation for new features +----------------------- +Issue assignment policy +----------------------- + +Assigning yourself to an issue signals that you are actively working on it. +This applies equally to maintainers, committers, and external contributors. + +- **Only assign yourself** if you have a PR open or are about to start coding. +- **14 days without visible activity** (PR, commit, or comment): a triager will + comment asking for a status update. +- **21 days total without response**: the assignee is removed and ``help wanted`` + is added so someone else can pick it up. +- **Re-assignment is welcome.** If you want to take over, comment on the issue. +- **Umbrella and tracking issues** marked with ``lifecycle/frozen`` are exempt. + +This is enforced by a weekly automated check. If you need more time, just drop a +comment on the issue to reset the clock. + --------------- Developer notes