-
Notifications
You must be signed in to change notification settings - Fork 359
feat(ci): label pull requests by reading effort #3949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ae5fc43
feat(ci): label pull requests by review state and reading effort
Astro-Han 719459a
feat(ci): label pull requests by reading effort
Astro-Han d2ac383
fix(ci): register the effort label workflow in the trigger allowlist
Astro-Han 43770a5
refactor(ci): fold the effort dry run into the workflow
Astro-Han c32ef9b
test(ci): lock the pull_request_target checkout to the base commit
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # 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: PR effort label | ||
|
|
||
| # Labels a pull request when it first asks for review, then re-checks the whole | ||
| # open set once a day. A push changes the diff and so can change the tier, but | ||
| # subscribing to every push would run this hundreds of times a day to correct a | ||
| # label nobody is misled by in the meantime; the daily sweep absorbs that drift | ||
| # and doubles as the recovery path for any event this misses. | ||
| on: | ||
| pull_request_target: | ||
| types: [opened, reopened, ready_for_review] | ||
| branches: [main] | ||
| schedule: | ||
| - cron: "23 4 * * *" | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: Log the tier each open pull request would get without writing labels. | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| concurrency: | ||
| group: pr-effort-label-${{ github.event.pull_request.number || github.workflow }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} | ||
|
|
||
| jobs: | ||
| label: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| steps: | ||
| # pull_request_target runs with a writable token, so the ref is pinned to | ||
| # the trusted default-branch commit rather than left to the event's | ||
| # default: pull_request_review and friends resolve to refs/pull/N/merge, | ||
| # and this job imports the checked-out script. | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| ref: ${{ github.sha }} | ||
| persist-credentials: false | ||
|
|
||
| - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| env: | ||
| DRY_RUN: ${{ inputs.dry_run || false }} | ||
| with: | ||
| script: | | ||
| const path = require("node:path") | ||
| const { pathToFileURL } = require("node:url") | ||
|
|
||
| const { planLabels } = await import( | ||
| pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, "scripts/pr-effort.mjs")).href | ||
| ) | ||
|
|
||
| const { owner, repo } = context.repo | ||
|
|
||
| // Reviewing a tier boundary or an exclusion means seeing it against | ||
| // real pull requests, which is this same sweep minus the writes. | ||
| const dryRun = process.env.DRY_RUN === "true" | ||
|
|
||
| const targets = context.payload.pull_request | ||
| ? [context.payload.pull_request.number] | ||
| : ( | ||
| await github.paginate(github.rest.pulls.list, { | ||
| owner, | ||
| repo, | ||
| state: "open", | ||
| per_page: 100, | ||
| }) | ||
| ).map((pull) => pull.number) | ||
|
|
||
| for (const pull_number of targets) { | ||
| const [files, current] = await Promise.all([ | ||
| github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 }), | ||
| github.paginate(github.rest.issues.listLabelsOnIssue, { | ||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| per_page: 100, | ||
| }), | ||
| ]) | ||
|
|
||
| const plan = planLabels( | ||
| files, | ||
| current.map((label) => label.name), | ||
| ) | ||
|
|
||
| if (dryRun) { | ||
| core.info( | ||
| `#${pull_number}: ${plan.label} (${plan.lines} readable lines)` + | ||
| ` +[${plan.addLabels.join(", ")}] -[${plan.removeLabels.join(", ")}]`, | ||
| ) | ||
| continue | ||
| } | ||
|
|
||
| if (plan.addLabels.length > 0) { | ||
| await github.rest.issues.addLabels({ | ||
|
Astro-Han marked this conversation as resolved.
|
||
| owner, | ||
| repo, | ||
| issue_number: pull_number, | ||
| labels: plan.addLabels, | ||
| }) | ||
| } | ||
|
|
||
| for (const name of plan.removeLabels) { | ||
| try { | ||
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name }) | ||
| } catch (error) { | ||
| // Another run may have removed it first; anything else is real. | ||
| if (error.status !== 404) throw error | ||
| } | ||
| } | ||
|
|
||
| core.info(`#${pull_number}: ${plan.label} (${plan.lines} readable lines)`) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /* | ||
| * 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. | ||
| */ | ||
|
|
||
| // How much reading a pull request asks for. Review state is deliberately not | ||
| // labelled here: GitHub already indexes review, check and draft state, so a | ||
| // search qualifier answers "whose move is it" without a workflow keeping a | ||
| // copy fresh. Pull request search has no size qualifier, which leaves reading | ||
| // effort as the one axis a query cannot express. | ||
|
|
||
| const EFFORT_LABELS = ['effort/XS', 'effort/S', 'effort/M', 'effort/L', 'effort/XL']; | ||
|
|
||
| // Counted changes should track what a human actually reads. Lockfiles, | ||
| // regenerated artifacts and binaries are verified by their own contracts, so | ||
| // letting their line counts reach the tiers would inflate every pull request | ||
| // that happens to touch one. A Rust dependency bump alone rewrites thousands | ||
| // of Cargo.lock lines that nobody reviews line by line. | ||
| const UNREAD_PATTERNS = [ | ||
| /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|uv\.lock)$/, | ||
| /(^|\/)THIRD_PARTY_(NOTICES|LICENSES)[^/]*$/, | ||
| /\.generated\.[cm]?[jt]sx?$/, | ||
| /\.snapshot\.json$/, | ||
| /\.min\.(js|css)$/, | ||
| /\.(png|jpe?g|gif|ico|webp|woff2?|ttf|sqlite|zip|gz|pdf)$/, | ||
| ]; | ||
|
|
||
| // Tier boundaries are inclusive upper bounds on readable lines. Test code is | ||
| // not discounted anywhere here; it is reviewed too. | ||
| const EFFORT_TIERS = [ | ||
| { label: 'effort/XS', maxLines: 10 }, | ||
| { label: 'effort/S', maxLines: 100 }, | ||
| { label: 'effort/M', maxLines: 500 }, | ||
| { label: 'effort/L', maxLines: 1000 }, | ||
| { label: 'effort/XL', maxLines: Number.POSITIVE_INFINITY }, | ||
| ]; | ||
|
|
||
| function isUnreadPath(path) { | ||
| const normalized = String(path).replace(/\\/g, '/'); | ||
| return UNREAD_PATTERNS.some((pattern) => pattern.test(normalized)); | ||
| } | ||
|
|
||
| /** | ||
| * @param {Array<{filename: string, additions?: number, deletions?: number}>} files | ||
| */ | ||
| function countReadableLines(files = []) { | ||
| return files.reduce((total, file) => { | ||
| if (isUnreadPath(file.filename)) return total; | ||
| return total + (file.additions ?? 0) + (file.deletions ?? 0); | ||
| }, 0); | ||
| } | ||
|
|
||
| /** | ||
| * @param {Array<object>} files | ||
| */ | ||
| function classifyEffort(files = []) { | ||
| const lines = countReadableLines(files); | ||
| const tier = EFFORT_TIERS.find((candidate) => lines <= candidate.maxLines); | ||
| return { label: tier.label, lines }; | ||
| } | ||
|
|
||
| /** | ||
| * @param {Array<object>} files | ||
| * @param {string[]} currentLabels | ||
| */ | ||
| export function planLabels(files = [], currentLabels = []) { | ||
| const { label, lines } = classifyEffort(files); | ||
| const current = new Set(currentLabels); | ||
|
|
||
| return { | ||
| label, | ||
| lines, | ||
| addLabels: current.has(label) ? [] : [label], | ||
| removeLabels: EFFORT_LABELS.filter((name) => name !== label && current.has(name)), | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.