| # |
| # 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: Update build status workflow |
| |
| on: |
| schedule: |
| - cron: "*/15 * * * *" |
| |
| jobs: |
| update: |
| name: Update build status |
| runs-on: ubuntu-latest |
| permissions: |
| actions: read |
| checks: write |
| steps: |
| - name: "Update build status" |
| uses: actions/github-script@v6 |
| with: |
| github-token: ${{ secrets.GITHUB_TOKEN }} |
| script: | |
| const SEARCH_WINDOW_DAYS = 7; |
| const BATCH_SIZE = 10; |
| |
| function getSearchSince() { |
| const windowStart = new Date(Date.now() - SEARCH_WINDOW_DAYS * 24 * 60 * 60 * 1000); |
| return windowStart.toISOString().replace(/\.\d{3}Z$/, 'Z'); |
| } |
| |
| async function searchActivePullRequests() { |
| const since = getSearchSince(); |
| const query = |
| `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open updated:>=${since}`; |
| const items = []; |
| let page = 1; |
| |
| console.log(`Searching active PRs with query: ${query}`); |
| |
| while (true) { |
| const response = await github.request('GET /search/issues', { |
| q: query, |
| page, |
| per_page: 100, |
| sort: 'updated', |
| order: 'desc' |
| }); |
| const pageItems = response.data.items || []; |
| items.push(...pageItems); |
| console.log(`Fetched ${pageItems.length} PRs from search page ${page}`); |
| |
| if (pageItems.length < 100) { |
| break; |
| } |
| page += 1; |
| } |
| |
| return items; |
| } |
| |
| function shouldSkipPatch(checkRun, workflowRun) { |
| const sameStatus = checkRun.status === workflowRun.status; |
| const sameConclusion = |
| workflowRun.status !== 'completed' || checkRun.conclusion === workflowRun.conclusion; |
| const sameDetailsUrl = checkRun.details_url === workflowRun.details_url; |
| |
| return sameStatus && sameConclusion && sameDetailsUrl; |
| } |
| |
| async function processPullRequest(searchItem) { |
| const prNumber = searchItem.number; |
| |
| try { |
| const pr = ( |
| await github.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| pull_number: prNumber |
| }) |
| ).data; |
| |
| console.log(`PR #${pr.number} SHA: ${pr.head.sha}`); |
| console.log(` Mergeable status: ${pr.mergeable_state}`); |
| |
| const checkRuns = await github.request( |
| 'GET /repos/{owner}/{repo}/commits/{ref}/check-runs', |
| { |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| ref: pr.head.sha |
| } |
| ); |
| |
| const buildCheckRun = checkRuns.data.check_runs.find( |
| (checkRun) => |
| checkRun.name === 'Build' && checkRun.conclusion !== 'action_required' |
| ); |
| |
| if (!buildCheckRun) { |
| console.log(` Skip PR #${pr.number}: no eligible Build check run`); |
| return; |
| } |
| |
| if (!buildCheckRun.output || !buildCheckRun.output.text) { |
| console.log(` Skip PR #${pr.number}: Build check run ${buildCheckRun.id} has no run metadata`); |
| return; |
| } |
| |
| let workflowRunParams; |
| try { |
| workflowRunParams = JSON.parse(buildCheckRun.output.text); |
| } catch (error) { |
| console.error(` Skip PR #${pr.number}: invalid JSON in check run ${buildCheckRun.id}`, error); |
| return; |
| } |
| |
| let workflowRun; |
| try { |
| workflowRun = ( |
| await github.request( |
| 'GET /repos/{owner}/{repo}/actions/runs/{run_id}', |
| workflowRunParams |
| ) |
| ).data; |
| } catch (error) { |
| console.error(` Skip PR #${pr.number}: workflow run lookup failed`, error); |
| return; |
| } |
| |
| if (shouldSkipPatch(buildCheckRun, workflowRun)) { |
| console.log( |
| ` Skip PR #${pr.number}: Build check run ${buildCheckRun.id} already matches ${workflowRun.status}/${workflowRun.conclusion}` |
| ); |
| return; |
| } |
| |
| const patchParams = { |
| owner: context.repo.owner, |
| repo: context.repo.repo, |
| check_run_id: buildCheckRun.id, |
| output: buildCheckRun.output, |
| status: workflowRun.status, |
| details_url: workflowRun.details_url |
| }; |
| |
| if (workflowRun.status === 'completed') { |
| patchParams.conclusion = workflowRun.conclusion; |
| console.log( |
| ` Patch PR #${pr.number} check run ${buildCheckRun.id}: ${buildCheckRun.status}/${buildCheckRun.conclusion} -> ${workflowRun.status}/${workflowRun.conclusion}` |
| ); |
| } else { |
| console.log( |
| ` Patch PR #${pr.number} check run ${buildCheckRun.id}: ${buildCheckRun.status} -> ${workflowRun.status}` |
| ); |
| } |
| |
| await github.request( |
| 'PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}', |
| patchParams |
| ); |
| } catch (error) { |
| console.error(`Failed to sync PR #${prNumber}`, error); |
| } |
| } |
| |
| async function processInBatches(items) { |
| for (let index = 0; index < items.length; index += BATCH_SIZE) { |
| const batch = items.slice(index, index + BATCH_SIZE); |
| console.log( |
| `Processing batch ${Math.floor(index / BATCH_SIZE) + 1} with ${batch.length} PRs` |
| ); |
| await Promise.all(batch.map((item) => processPullRequest(item))); |
| } |
| } |
| |
| try { |
| const activePullRequests = await searchActivePullRequests(); |
| console.log(`Found ${activePullRequests.length} active PRs to evaluate`); |
| |
| if (activePullRequests.length === 0) { |
| console.log('No active PRs matched the search window'); |
| return; |
| } |
| |
| await processInBatches(activePullRequests); |
| } catch (error) { |
| console.error('Update build status workflow failed', error); |
| throw error; |
| } |