Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/scripts/check-permissions.js
Original file line number Diff line number Diff line change
@@ -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';
}
};
125 changes: 125 additions & 0 deletions .github/scripts/create-ci-pr.js
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
isNewPR = true; // Treat as new to ensure labels are added
isNewPR = false; // This is an existing PR being reused, not a new one

When reusing an existing CI test PR without the 'ci-test' label but with the correct title, the isNewPR flag is incorrectly set to true, causing the comment to say "CI test created" instead of "CI test updated".

View Details

Analysis

Incorrect isNewPR flag causes misleading message for reused CI test PRs

What fails: When reusing an existing CI test PR that lacks the 'ci-test' label but has a title starting with '[CI Test]', the isNewPR flag is incorrectly set to true at line 54 of .github/scripts/create-ci-pr.js. This causes the user comment at line 75 to say "✅ CI test created" instead of "✅ CI test updated", which is misleading since the PR is being reused, not newly created.

How to reproduce:

  1. Trigger /run-ci on an external PR that creates a new CI test PR (branch: ci-test/{prNumber})
  2. The initial comment will correctly say "✅ CI test created"
  3. Manually remove the 'ci-test' label from the CI test PR
  4. Trigger /run-ci again on the same external PR
  5. The code will find the existing CI test PR without the label (line 31), verify it has the correct title (line 50), and reuse it (line 52)
  6. The isNewPR flag is set to true (line 54), causing the comment to incorrectly say "✅ CI test created" again

Expected: The message should say "✅ CI test updated" because the code path represents reusing an existing PR (line 52: ciPR = existingPR), not creating a new one. The labels are added automatically via the label-adding logic (lines 60-70), so setting isNewPR = true is unnecessary and incorrect.

Fix: Changed line 54 from isNewPR = true to isNewPR = false, with an updated comment clarifying that this code path represents an existing PR being reused. The label-adding logic continues to work correctly in all scenarios.

}
// 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);
};
17 changes: 17 additions & 0 deletions .github/scripts/get-pr-details.js
Original file line number Diff line number Diff line change
@@ -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
};
};
9 changes: 9 additions & 0 deletions .github/scripts/post-unauthorized-comment.js
Original file line number Diff line number Diff line change
@@ -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');
};
107 changes: 9 additions & 98 deletions .github/workflows/trigger-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,34 +24,17 @@ 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'
uses: actions/github-script@v7
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'
Expand All @@ -60,94 +43,22 @@ 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'
uses: actions/checkout@v4
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);