diff --git a/.github/scripts/check-permissions.js b/.github/scripts/check-permissions.js new file mode 100644 index 000000000..0417990d5 --- /dev/null +++ b/.github/scripts/check-permissions.js @@ -0,0 +1,16 @@ +module.exports = async ({ github, context }) => { + try { + const permission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + + const hasPermission = ['admin', 'write'].includes(permission.data.permission); + console.log(`User ${context.actor} has permission: ${permission.data.permission}`); + return hasPermission ? 'true' : 'false'; + } catch (error) { + console.error('Error checking permissions:', error); + return 'false'; + } +}; diff --git a/.github/scripts/create-ci-pr.js b/.github/scripts/create-ci-pr.js new file mode 100644 index 000000000..5555de5ad --- /dev/null +++ b/.github/scripts/create-ci-pr.js @@ -0,0 +1,125 @@ +module.exports = async ({ github, context, core, exec }, prDetails) => { + const ciBranchName = `ci-test/${prDetails.number}`; + + // Add remote for the external fork if it's from a fork + if (prDetails.head_repo_full_name !== `${context.repo.owner}/${context.repo.repo}`) { + await exec.exec('git', ['remote', 'add', 'external', `https://github.com/${prDetails.head_repo_full_name}.git`]); + await exec.exec('git', ['fetch', 'external', prDetails.head_ref]); + await exec.exec('git', ['checkout', '-b', ciBranchName, `external/${prDetails.head_ref}`]); + } else { + await exec.exec('git', ['fetch', 'origin', prDetails.head_ref]); + await exec.exec('git', ['checkout', '-b', ciBranchName, `origin/${prDetails.head_ref}`]); + } + + // Push the branch to origin (force push to update if exists) + await exec.exec('git', ['push', '-f', 'origin', ciBranchName]); + + // Check if a CI PR already exists for this original PR + const existingPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${ciBranchName}` + }); + + let ciPR; + let isNewPR = false; + + if (existingPRs.data.length > 0) { + // Filter to find the CI test PR (should be labeled with 'ci-test') + const ciTestPR = existingPRs.data.find(pr => + pr.labels.some(label => label.name === 'ci-test') + ); + + if (ciTestPR) { + // Update existing PR + ciPR = ciTestPR; + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: ciPR.number, + body: `🤖 **Automated CI Test PR** + +This is an automated PR created to run CI tests for PR #${prDetails.number} by @${prDetails.user}. + +**Original PR:** #${prDetails.number} +**Last triggered by:** @${context.actor} +**Source branch:** \`${prDetails.head_ref}\` +**Source SHA:** \`${prDetails.head_sha}\` + +⚠️ **This PR will be automatically closed once CI completes.** Do not merge this PR. + +--- +_This PR was last updated in response to the \`/run-ci\` command in #${prDetails.number}_` + }); + } else { + // Existing PR found but it's not labeled as a CI test PR + // Verify it's actually a CI test PR by checking the title + const existingPR = existingPRs.data[0]; + if (existingPR.title.startsWith('[CI Test]')) { + // Use the existing PR and add the labels + ciPR = existingPR; + isNewPR = true; // Treat as new to ensure labels are added + } + // If it's not a CI test PR, ciPR remains undefined and a new PR will be created + } + } + + if (!ciPR) { + // Create a new draft PR + const newPR = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[CI Test] ${prDetails.title}`, + head: ciBranchName, + base: prDetails.base_ref, + body: `🤖 **Automated CI Test PR** + +This is an automated PR created to run CI tests for PR #${prDetails.number} by @${prDetails.user}. + +**Original PR:** #${prDetails.number} +**Triggered by:** @${context.actor} +**Source branch:** \`${prDetails.head_ref}\` +**Source SHA:** \`${prDetails.head_sha}\` + +⚠️ **This PR will be automatically closed once CI completes.** Do not merge this PR. + +--- +_This PR was created in response to the \`/run-ci\` command in #${prDetails.number}_`, + draft: true + }); + ciPR = newPR.data; + isNewPR = true; + } + + // Ensure labels are present on the CI PR (handles both new and existing PRs) + // Labels can be strings or objects with 'name' property depending on the API response + const currentLabels = ciPR.labels?.map(l => typeof l === 'string' ? l : l.name) || []; + const requiredLabels = ['ci-test', 'automated']; + const missingLabels = requiredLabels.filter(label => !currentLabels.includes(label)); + + if (missingLabels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ciPR.number, + labels: missingLabels + }); + } + + // Comment on the original PR + const prAction = isNewPR ? 'created' : 'updated'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `✅ CI test ${prAction} by @${context.actor}! + +CI is now running in draft PR #${ciPR.number}. You can monitor the progress there. + +Once the tests complete, you can review the results and the draft PR will be automatically closed.` + }); + + core.setOutput('ci_pr_number', ciPR.number); + core.setOutput('ci_branch_name', ciBranchName); +}; diff --git a/.github/scripts/get-pr-details.js b/.github/scripts/get-pr-details.js new file mode 100644 index 000000000..88a0ce5ca --- /dev/null +++ b/.github/scripts/get-pr-details.js @@ -0,0 +1,17 @@ +module.exports = async ({ github, context }) => { + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + return { + head_ref: pr.data.head.ref, + head_sha: pr.data.head.sha, + head_repo_full_name: pr.data.head.repo.full_name, + base_ref: pr.data.base.ref, + title: pr.data.title, + number: pr.data.number, + user: pr.data.user.login + }; +}; diff --git a/.github/scripts/post-unauthorized-comment.js b/.github/scripts/post-unauthorized-comment.js new file mode 100644 index 000000000..5e0394c16 --- /dev/null +++ b/.github/scripts/post-unauthorized-comment.js @@ -0,0 +1,9 @@ +module.exports = async ({ github, context, core }) => { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '❌ Only repository admins and maintainers can trigger CI runs. You have insufficient permissions.' + }); + core.setFailed('Insufficient permissions to trigger CI'); +}; diff --git a/.github/workflows/trigger-ci.yml b/.github/workflows/trigger-ci.yml index ab00f45c9..6afce8c78 100644 --- a/.github/workflows/trigger-ci.yml +++ b/.github/workflows/trigger-ci.yml @@ -24,20 +24,8 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} result-encoding: string script: | - try { - const permission = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.actor - }); - - const hasPermission = ['admin', 'write'].includes(permission.data.permission); - console.log(`User ${context.actor} has permission: ${permission.data.permission}`); - return hasPermission ? 'true' : 'false'; - } catch (error) { - console.error('Error checking permissions:', error); - return 'false'; - } + const script = require('./.github/scripts/check-permissions.js'); + return await script({ github, context }); - name: Exit if unauthorized if: steps.check-permissions.outputs.result != 'true' @@ -45,13 +33,8 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: '❌ Only repository admins and maintainers can trigger CI runs. You have insufficient permissions.' - }); - core.setFailed('Insufficient permissions to trigger CI'); + const script = require('./.github/scripts/post-unauthorized-comment.js'); + await script({ github, context, core }); - name: Get PR details if: steps.check-permissions.outputs.result == 'true' @@ -60,21 +43,8 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number - }); - - return { - head_ref: pr.data.head.ref, - head_sha: pr.data.head.sha, - head_repo_full_name: pr.data.head.repo.full_name, - base_ref: pr.data.base.ref, - title: pr.data.title, - number: pr.data.number, - user: pr.data.user.login - }; + const script = require('./.github/scripts/get-pr-details.js'); + return await script({ github, context }); - name: Checkout repo if: steps.check-permissions.outputs.result == 'true' @@ -82,72 +52,13 @@ jobs: with: fetch-depth: 0 - - name: Create CI branch and PR + - name: Create or update CI branch and PR if: steps.check-permissions.outputs.result == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | + const script = require('./.github/scripts/create-ci-pr.js'); const prDetails = ${{ steps.pr-details.outputs.result }}; - const timestamp = new Date().getTime(); - const ciBranchName = `ci-test/${prDetails.number}-${timestamp}`; - - // Add remote for the external fork if it's from a fork - if (prDetails.head_repo_full_name !== `${context.repo.owner}/${context.repo.repo}`) { - await exec.exec('git', ['remote', 'add', 'external', `https://github.com/${prDetails.head_repo_full_name}.git`]); - await exec.exec('git', ['fetch', 'external', prDetails.head_ref]); - await exec.exec('git', ['checkout', '-b', ciBranchName, `external/${prDetails.head_ref}`]); - } else { - await exec.exec('git', ['fetch', 'origin', prDetails.head_ref]); - await exec.exec('git', ['checkout', '-b', ciBranchName, `origin/${prDetails.head_ref}`]); - } - - // Push the new branch to origin - await exec.exec('git', ['push', 'origin', ciBranchName]); - - // Create a draft PR - const newPR = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `[CI Test] ${prDetails.title}`, - head: ciBranchName, - base: prDetails.base_ref, - body: `🤖 **Automated CI Test PR** - -This is an automated PR created to run CI tests for PR #${prDetails.number} by @${prDetails.user}. - -**Original PR:** #${prDetails.number} -**Triggered by:** @${context.actor} -**Source branch:** \`${prDetails.head_ref}\` -**Source SHA:** \`${prDetails.head_sha}\` - -⚠️ **This PR will be automatically closed once CI completes.** Do not merge this PR. - ---- -_This PR was created in response to the \`/run-ci\` command in #${prDetails.number}_`, - draft: true - }); - - // Comment on the original PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `✅ CI test triggered by @${context.actor}! - -CI is now running in draft PR #${newPR.data.number}. You can monitor the progress there. - -Once the tests complete, you can review the results and the draft PR will be automatically closed.` - }); - - // Add label to the new PR - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: newPR.data.number, - labels: ['ci-test', 'automated'] - }); - - core.setOutput('ci_pr_number', newPR.data.number); - core.setOutput('ci_branch_name', ciBranchName); + await script({ github, context, core, exec }, prDetails);