Unverified Commit 5ec9d257 authored by Wolfgang Walther's avatar Wolfgang Walther Committed by GitHub
Browse files

Revert "workflows/labels: manage stale & merge conflict labels" (#419574)

parents 1795b087 c366efa6
Loading
Loading
Loading
Loading
+151 −218
Original line number Diff line number Diff line
@@ -17,12 +17,18 @@ on:
      NIXPKGS_CI_APP_PRIVATE_KEY:
        required: true
  workflow_dispatch:
    inputs:
      updatedWithin:
        description: 'Updated within [hours]'
        type: number
        required: false
        default: 0 # everything since last run

concurrency:
  # This explicitly avoids using `run_id` for the concurrency key to make sure that only
  # *one* scheduled run can run at a time.
  # *one* non-PR run can run at a time.
  group: labels-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
  # PR-triggered runs will be cancelled, but scheduled runs will be queued.
  # PR- and manually-triggered runs will be cancelled, but scheduled runs will be queued.
  cancel-in-progress: ${{ github.event_name != 'schedule' }}

# This is used as fallback without app only.
@@ -63,6 +69,8 @@ jobs:

      - name: Labels from API data and Eval results
        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
        env:
          UPDATED_WITHIN: ${{ inputs.updatedWithin }}
        with:
          github-token: ${{ steps.app-token.outputs.token || github.token }}
          script: |
@@ -93,9 +101,6 @@ jobs:
            github.hook.wrap('request', async (request, options) => {
              // Requests to the /rate_limit endpoint do not count against the rate limit.
              if (options.url == '/rate_limit') return request(options)
              // Search requests are in a different resource group, which allows 30 requests / minute.
              // We do less than a handful each run, so not implementing throttling for now.
              if (options.url.startsWith('/search/')) return request(options)
              stats.requests++
              if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
                return writeLimits.schedule(request.bind(null, options))
@@ -123,36 +128,70 @@ jobs:
            const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
            process.on('uncaughtException', () => clearInterval(reservoirUpdater))

            async function handle(item) {
            if (process.env.UPDATED_WITHIN && !/^\d+$/.test(process.env.UPDATED_WITHIN))
              throw new Error('Please enter "updated within" as integer in hours.')

            const cutoff = new Date(await (async () => {
              // Always run for Pull Request triggers, no cutoff since there will be a single
              // response only anyway. 0 is the Unix epoch, so always smaller.
              if (context.payload.pull_request?.number) return 0

              // Manually triggered via UI when updatedWithin is set. Will fallthrough to the last
              // option if the updatedWithin parameter is set to 0, which is the default.
              const updatedWithin = Number.parseInt(process.env.UPDATED_WITHIN, 10)
              if (updatedWithin) return new Date().getTime() - updatedWithin * 60 * 60 * 1000

              // Normally a scheduled run, but could be workflow_dispatch, see above. Go back as far
              // as the last successful run of this workflow to make sure we are not leaving anyone
              // behind on GHA failures.
              // Defaults to go back 1 hour on the first run.
              return (await github.rest.actions.listWorkflowRuns({
                ...context.repo,
                workflow_id: 'labels.yml',
                event: 'schedule',
                status: 'success',
                exclude_pull_requests: true
              })).data.workflow_runs[0]?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000
            })())
            core.info('cutoff timestamp: ' + cutoff.toISOString())

            // To simplify this action's logic we fetch the pull_request data again below, even if
            // we are already in a pull_request event's context and would have the data readily
            // available. We do this by filtering the list of pull requests with head and base
            // branch - there can only be a single open Pull Request for any such combination.
            const prEventCondition = !context.payload.pull_request ? undefined : {
              // "label" is in the format of `user:branch` or `org:branch`
              head: context.payload.pull_request.head.label,
              base: context.payload.pull_request.base.ref
            }

            const prs = await github.paginate(
              github.rest.pulls.list,
              {
                ...context.repo,
                state: 'open',
                sort: 'updated',
                direction: 'desc',
                ...prEventCondition
              },
              (response, done) => response.data.map(async (pull_request) => {
                try {
                  const log = (k,v,skip) => {
                  core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
                    core.info(`PR #${pull_request.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
                    return skip
                  }

                log('Last updated at', item.updated_at)
                  if (log('Last updated at', pull_request.updated_at, new Date(pull_request.updated_at) < cutoff))
                    return done()
                  stats.prs++
                log('URL', item.html_url)

                const pull_number = item.number
                const issue_number = item.number

                // The search result is of a format that works for both issues and pull requests and thus
                // does not have all fields of a full pull_request response. Notably, it is missing `head.sha`,
                // which we need to fetch the workflow run below. When triggered via pull_request event,
                // this field is already available.
                // This API request is also important for the merge-conflict label, because it triggers the
                // creation of a new test merge commit. This is needed to actually determine the state of a PR.
                const pull_request = item.head ? item : (await github.rest.pulls.get({
                  ...context.repo,
                  pull_number
                })).data
                  log('URL', pull_request.html_url)

                  const run_id = (await github.rest.actions.listWorkflowRuns({
                    ...context.repo,
                    workflow_id: 'pr.yml',
                    event: 'pull_request_target',
                  status: 'success',
                    // For PR events, the workflow run is still in progress with this job itself.
                    status: prEventCondition ? 'in_progress' : 'success',
                    exclude_pull_requests: true,
                    head_sha: pull_request.head.sha
                  })).data.workflow_runs[0]?.id ??
@@ -167,12 +206,12 @@ jobs:
                      head_sha: pull_request.head.sha
                    })).data.workflow_runs[0]?.id

                // Newer PRs might not have run Eval to completion, yet.
                // Older PRs might not have an eval.yml workflow, yet.
                // In either case we continue without fetching an artifact on a best-effort basis.
                log('Last eval run', run_id ?? '<n/a>')
                  // Newer PRs might not have run Eval to completion, yet. We can skip them, because this
                  // job will be run as part of that Eval run anyway.
                  if (log('Last eval run', run_id ?? '<pending>', !run_id))
                    return;

                const artifact = run_id && (await github.rest.actions.listWorkflowRunArtifacts({
                  const artifact = (await github.rest.actions.listWorkflowRunArtifacts({
                    ...context.repo,
                    run_id,
                    name: 'comparison'
@@ -181,10 +220,10 @@ jobs:
                  // Instead of checking the boolean artifact.expired, we will give us a minute to
                  // actually download the artifact in the next step and avoid that race condition.
                  // Older PRs, where the workflow run was already eval.yml, but the artifact was not
                // called "comparison", yet, will skip the download.
                const expired = !artifact || new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)
                log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
                if (!expired) {
                  // called "comparison", yet, will be skipped as well.
                  const expired = new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)
                  if (log('Artifact expires at', artifact?.expires_at ?? '<not found>', expired))
                    return;
                  stats.artifacts++

                  await artifactClient.downloadArtifact(artifact.id, {
@@ -193,17 +232,16 @@ jobs:
                      repositoryOwner: context.repo.owner,
                      token: core.getInput('github-token')
                    },
                    path: path.resolve(pull_number.toString()),
                    path: path.resolve(pull_request.number.toString()),
                    expectedHash: artifact.digest
                  })
                }

                  // Create a map (Label -> Boolean) of all currently set labels.
                  // Each label is set to True and can be disabled later.
                  const before = Object.fromEntries(
                    (await github.paginate(github.rest.issues.listLabelsOnIssue, {
                      ...context.repo,
                    issue_number
                      issue_number: pull_request.number
                    }))
                    .map(({ name }) => [name, true])
                  )
@@ -211,64 +249,22 @@ jobs:
                  const approvals = new Set(
                    (await github.paginate(github.rest.pulls.listReviews, {
                      ...context.repo,
                    pull_number
                      pull_number: pull_request.number
                    }))
                    .filter(review => review.state == 'APPROVED')
                    .map(review => review.user?.id)
                  )

                const latest_event_at = new Date(
                  (await github.paginate(
                    github.rest.issues.listEventsForTimeline,
                    {
                      ...context.repo,
                      issue_number,
                      per_page: 100
                    }
                  ))
                  // We also ignore base_ref_force_pushed, which will not happen in nixpkgs, but
                  // is very useful for testing in forks.
                  .findLast(({ event }) => !['labeled', 'unlabeled', 'base_ref_force_pushed'].includes(event))
                  ?.created_at ?? item.created_at
                )
                const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))

                // Manage most of the labels, without eval results
                const after = Object.assign(
                  {},
                  before,
                  {
                    // We intentionally don't use the mergeable or mergeable_state attributes.
                    // Those have an intermediate state while the test merge commit is created.
                    // This doesn't work well for us, because we might have just triggered another
                    // test merge commit creation by request the pull request via API at the start
                    // of this function.
                    // The attribute merge_commit_sha keeps the old value of null or the hash *until*
                    // the new test merge commit has either successfully been created or failed so.
                    // This essentially means we are updating the merge conflict label in two steps:
                    // On the first pass of the day, we just fetch the pull request, which triggers
                    // the creation. At this stage, the label is likely not updated, yet.
                    // The second pass will then read the result from the first pass and set the label.
                    '2.status: merge conflict': !pull_request.merge_commit_sha,
                    '2.status: stale': !before['1.severity: security'] && latest_event_at < stale_at,
                    '12.approvals: 1': approvals.size == 1,
                    '12.approvals: 2': approvals.size == 2,
                    '12.approvals: 3+': approvals.size >= 3,
                    '12.first-time contribution':
                      [ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association),
                  }
                )

                // Manage labels based on eval results
                if (!expired) {
                  const maintainers = new Set(Object.keys(
                    JSON.parse(await readFile(`${pull_number}/maintainers.json`, 'utf-8'))
                    JSON.parse(await readFile(`${pull_request.number}/maintainers.json`, 'utf-8'))
                  ).map(m => Number.parseInt(m, 10)))

                  const evalLabels = JSON.parse(await readFile(`${pull_number}/changed-paths.json`, 'utf-8')).labels
                  const evalLabels = JSON.parse(await readFile(`${pull_request.number}/changed-paths.json`, 'utf-8')).labels

                  Object.assign(
                    after,
                  // Manage the labels
                  const after = Object.assign(
                    {},
                    before,
                    // Ignore `evalLabels` if it's an array.
                    // This can happen for older eval runs, before we switched to objects.
                    // The old eval labels would have been set by the eval run,
@@ -276,10 +272,14 @@ jobs:
                    // TODO: Simplify once old eval results have expired (~2025-10)
                    (Array.isArray(evalLabels) ? undefined : evalLabels),
                    {
                      '12.approvals: 1': approvals.size == 1,
                      '12.approvals: 2': approvals.size == 2,
                      '12.approvals: 3+': approvals.size >= 3,
                      '12.approved-by: package-maintainer': Array.from(maintainers).some(m => approvals.has(m)),
                      '12.first-time contribution':
                        [ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association),
                    }
                  )
                }

                  // No need for an API request, if all labels are the same.
                  const hasChanges = Object.keys(after).some(name => (before[name] ?? false) != after[name])
@@ -293,87 +293,20 @@ jobs:

                  await github.rest.issues.setLabels({
                    ...context.repo,
                  issue_number,
                    issue_number: pull_request.number,
                    labels
                  })
                } catch (cause) {
                throw new Error(`Labeling #${item.number} failed.`, { cause })
              }
            }

            if (context.payload.pull_request) {
              await handle(context.payload.pull_request)
            } else {
              const workflowData = (await github.rest.actions.listWorkflowRuns({
                ...context.repo,
                workflow_id: 'labels.yml',
                event: 'schedule',
                status: 'success',
                exclude_pull_requests: true,
                per_page: 1
              })).data

              // Go back as far as the last successful run of this workflow to make sure
              // we are not leaving anyone behind on GHA failures.
              // Defaults to go back 1 hour on the first run.
              const cutoff = new Date(workflowData.workflow_runs[0]?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000)
              core.info('cutoff timestamp: ' + cutoff.toISOString())

              const updatedItems = await github.paginate(
                github.rest.search.issuesAndPullRequests,
                {
                  q: [
                    `repo:"${process.env.GITHUB_REPOSITORY}"`,
                    'type:pr',
                    'is:open',
                    `updated:>=${cutoff.toISOString()}`
                  ].join(' AND '),
                  // TODO: Remove in 2025-10, when it becomes the default.
                  advanced_search: true
                }
              )

              const allOptions = {
                q: [
                  `repo:"${process.env.GITHUB_REPOSITORY}"`,
                  'type:pr',
                  'is:open'
                ].join(' AND '),
                sort: 'created',
                direction: 'asc',
                // TODO: Remove in 2025-10, when it becomes the default.
                advanced_search: true
                  throw new Error(`Labeling PR #${pull_request.number} failed.`, { cause })
                }
              })
            );

              const { total_count: total_pulls } = (await github.rest.search.issuesAndPullRequests({
                ...allOptions,
                per_page: 1
              })).data
              const { total_count: total_runs } = workflowData
              const allItems = (await github.rest.search.issuesAndPullRequests({
                ...allOptions,
                per_page: 100,
                // We iterate through pages of 100 items across scheduled runs. With currently ~7000 open PRs and
                // up to 6*24=144 scheduled runs per day, we hit every PR twice each day.
                // We might not hit every PR on one iteration, because the pages will shift slightly when
                // PRs are closed or merged. We assume this to be OK on the bigger scale, because a PR which was
                // missed once, would have to move through the whole page to be missed again. This is very unlikely,
                // so it should certainly be hit on the next iteration.
                // TODO: Evaluate after a while, whether the above holds still true and potentially implement
                // an overlap between runs.
                page: total_runs % Math.ceil(total_pulls / 100)
              })).data.items

              // Some items might be in both search results, so filtering out duplicates as well.
              const items = [].concat(updatedItems, allItems)
                .filter((thisItem, idx, arr) => idx == arr.findIndex(firstItem => firstItem.number == thisItem.number))

              ;(await Promise.allSettled(items.map(handle)))
            (await Promise.allSettled(prs.flat()))
              .filter(({ status }) => status == 'rejected')
              .map(({ reason }) => core.setFailed(`${reason.message}\n${reason.cause.stack}`))

            core.notice(`Processed ${stats.prs} PRs, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`)
            }
            clearInterval(reservoirUpdater)

      - name: Log current API rate limits