const createCommitStatus = async (context, github, sha, state) => { const { workflow, runId } = context; const { owner, repo } = context.repo; const target_url = `https://github.com/${owner}/${repo}/actions/runs/${runId}?pr=${context.issue.number}`; await github.rest.repos.createCommitStatus({ owner, repo, sha, state, target_url, description: sha, context: workflow, }); }; const shouldAutoformat = (comment) => { return comment.body.trim() === "/autoformat"; }; const getPullInfo = async (context, github) => { const { owner, repo } = context.repo; const pull_number = context.issue.number; const pr = await github.rest.pulls.get({ owner, repo, pull_number }); const { sha: head_sha, ref: head_ref, repo: { full_name }, } = pr.data.head; const { sha: base_sha, ref: base_ref, repo: base_repo } = pr.data.base; return { repository: full_name, pull_number, head_sha, head_ref, base_sha, base_ref, base_repo: base_repo.full_name, author_association: pr.data.author_association, }; }; const createReaction = async (context, github) => { const { owner, repo } = context.repo; const { id: comment_id } = context.payload.comment; await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: "rocket", }); }; const createStatus = async (context, github, core) => { const { head_sha, head_ref, repository } = await getPullInfo(context, github); if (repository === "mlflow/mlflow" && head_ref === "master") { core.setFailed("Running autoformat bot against master branch of mlflow/mlflow is not allowed."); } await createCommitStatus(context, github, head_sha, "pending"); }; const updateStatus = async (context, github, sha, needs) => { const failed = Object.values(needs).some(({ result }) => result === "failure"); const state = failed ? "failure" : "success"; await createCommitStatus(context, github, sha, state); }; const fetchWorkflowRuns = async ({ context, github, head_sha }) => { const { owner, repo } = context.repo; const SLEEP_DURATION_MS = 5000; const MAX_RETRIES = 5; let prevRuns = []; for (let i = 0; i < MAX_RETRIES; i++) { console.log(`Attempt ${i + 1} to fetch workflow runs`); const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, { owner, repo, head_sha, status: "action_required", actor: "mlflow-app[bot]", }); // If the number of runs has not changed since the last attempt, // we can assume that all the workflow runs have been created. if (runs.length > 0 && runs.length === prevRuns.length) { return runs; } prevRuns = runs; await new Promise((resolve) => setTimeout(resolve, SLEEP_DURATION_MS)); } return prevRuns; }; const approveWorkflowRuns = async (context, github, head_sha) => { const { owner, repo } = context.repo; const workflowRuns = await fetchWorkflowRuns({ context, github, head_sha }); const approvePromises = workflowRuns.map((run) => github.rest.actions.approveWorkflowRun({ owner, repo, run_id: run.id, }) ); const results = await Promise.allSettled(approvePromises); for (const result of results) { if (result.status === "rejected") { console.error(`Failed to approve run: ${result.reason}`); } } }; const VALID_AUTHOR_ASSOCIATIONS = ["owner", "member", "collaborator"]; const isAllowedUser = ({ author_association, user }) => { return ( VALID_AUTHOR_ASSOCIATIONS.includes(author_association.toLowerCase()) || // Allow Copilot and mlflow-app bot to run this workflow (user && user.type.toLowerCase() === "bot" && ["copilot", "mlflow-app[bot]"].includes(user.login.toLowerCase())) ); }; const validatePermissions = async (context, github) => { const { comment } = context.payload; const { owner, repo } = context.repo; const pull_number = context.issue.number; // Check if commenter is owner/member/collaborator or an allowed bot if (!isAllowedUser({ author_association: comment.author_association, user: comment.user })) { const message = `This workflow can only be triggered by a repository owner, member, or collaborator. @${comment.user.login} (${comment.author_association}) does not have sufficient permissions.`; await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: `❌ **Autoformat failed**: ${message}`, }); throw new Error(message); } const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); const prAuthorAssociation = pr.author_association.toLowerCase(); // If PR author is not a trusted user, this is a community PR if (!isAllowedUser({ author_association: prAuthorAssociation, user: pr.user })) { // Community PR — require at least one approved review const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number, }); const hasApproval = reviews.some((review) => review.state === "APPROVED"); if (!hasApproval) { await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: `❌ **Autoformat failed**: This workflow requires an approved review before running on community PRs. Please approve the PR and comment \`/autoformat\` again.`, }); throw new Error("This workflow requires an approved review before running on community PRs."); } } }; const checkMaintainerAccess = async (context, github) => { const { owner, repo } = context.repo; const pull_number = context.issue.number; const { runId } = context; const pr = await github.rest.pulls.get({ owner, repo, pull_number }); // Skip maintainer access check for copilot bot PRs // Copilot bot creates PRs that are owned by the repository and don't need the same permission model if ( pr.data.user?.type?.toLowerCase() === "bot" && pr.data.user?.login?.toLowerCase() === "copilot" ) { console.log(`Skipping maintainer access check for copilot bot PR #${pull_number}`); return; } const isForkPR = pr.data.head.repo.full_name !== pr.data.base.repo.full_name; if (isForkPR && !pr.data.maintainer_can_modify) { const workflowRunUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`; await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: `❌ **Autoformat failed**: The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly. Please: 1. Check the "Allow edits and access to secrets by maintainers" checkbox on this pull request 2. Comment \`/autoformat\` again This permission is required for the autoformat bot to push changes to your branch. **Details:** [View workflow run](${workflowRunUrl})`, }); throw new Error( 'The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly.' ); } }; module.exports = { shouldAutoformat, getPullInfo, createReaction, createStatus, updateStatus, approveWorkflowRuns, checkMaintainerAccess, validatePermissions, };