diff --git a/.github/workflows/dogfood-comment-writer.yml b/.github/workflows/dogfood-comment-writer.yml new file mode 100644 index 000000000..abbbdcd0d --- /dev/null +++ b/.github/workflows/dogfood-comment-writer.yml @@ -0,0 +1,158 @@ +name: Add Dogfooding Comment Writer + +on: + workflow_run: + workflows: ["Add Dogfooding Comment"] + types: [completed] + +permissions: + actions: read + issues: write + pull-requests: read + +concurrency: + group: dogfood-comment-${{ github.event.workflow_run.head_repository.id || 'unknown-repo' }}-${{ github.event.workflow_run.head_branch || 'unknown-branch' }} + cancel-in-progress: true + +jobs: + add-dogfood-comment: + if: ${{ github.repository_owner == 'CommunityToolkit' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Download comment result artifact + id: download-result + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dogfood-comment + path: ${{ runner.temp }}/dogfood-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Add dogfooding comment + if: ${{ steps.download-result.outcome == 'success' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const workflowRun = context.payload.workflow_run; + const resultPath = path.join(process.env.RUNNER_TEMP, 'dogfood-comment', 'result.json'); + const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); + + function fail(message) { + throw new Error(`Invalid dogfood comment artifact: ${message}`); + } + + if (result.schema_version !== 'dogfood-comment/v1') fail('unexpected schema_version'); + if (result.event !== 'pull_request') fail('unexpected event'); + if (!Number.isInteger(result.pr_number) || result.pr_number < 1) fail('invalid pr_number'); + if (!/^[0-9a-f]{40}$/i.test(String(result.head_sha || ''))) fail('invalid head_sha'); + if (String(result.run_id || '') !== String(workflowRun.id)) fail('run_id did not match workflow_run'); + if (result.head_sha !== workflowRun.head_sha) fail('head_sha did not match workflow_run'); + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: result.pr_number, + }); + + if (pr.state !== 'open') { + core.info(`Skipping non-open PR #${result.pr_number}.`); + return; + } + + const expectedBaseRepository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase(); + const runHeadRepository = String(workflowRun.head_repository?.full_name || ''); + const runHeadRepositoryParts = runHeadRepository.split('/'); + const runHeadRef = String(workflowRun.head_branch || ''); + if (String(pr.base?.repo?.full_name || '').toLowerCase() !== expectedBaseRepository) { + fail(`PR #${result.pr_number} does not target this repository`); + } + if (pr.head.sha !== workflowRun.head_sha) { + core.warning(`Skipping stale comment for PR #${result.pr_number}: artifact head ${result.head_sha}, current head ${pr.head.sha}`); + return; + } + if ( + runHeadRepositoryParts.length !== 2 || + !runHeadRepositoryParts[0] || + !runHeadRepositoryParts[1] || + !runHeadRef || + String(pr.head?.repo?.full_name || '').toLowerCase() !== runHeadRepository.toLowerCase() || + String(pr.head?.ref || '') !== runHeadRef + ) { + fail(`PR #${result.pr_number} head did not match workflow_run`); + } + + const workflowRunPullRequests = Array.isArray(workflowRun.pull_requests) ? workflowRun.pull_requests : []; + if (workflowRunPullRequests.length > 0) { + if (!workflowRunPullRequests.some((pullRequest) => pullRequest.number === result.pr_number)) { + fail(`PR #${result.pr_number} was not present in workflow_run.pull_requests`); + } + } else { + const candidatePullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${runHeadRepositoryParts[0]}:${runHeadRef}`, + per_page: 100, + }); + const trustedMatches = candidatePullRequests.filter((candidate) => + candidate.head?.sha === workflowRun.head_sha && + String(candidate.head?.ref || '') === runHeadRef && + String(candidate.head?.repo?.full_name || '').toLowerCase() === runHeadRepository.toLowerCase() && + String(candidate.base?.repo?.full_name || '').toLowerCase() === expectedBaseRepository + ); + if (trustedMatches.length !== 1 || trustedMatches[0].number !== result.pr_number) { + fail(`PR #${result.pr_number} could not be uniquely associated with workflow_run`); + } + } + + const prNumber = pr.number; + const bashScript = 'https://raw.githubusercontent.com/CommunityToolkit/Aspire/main/eng/scripts/dogfood-pr.sh'; + const psScript = 'https://raw.githubusercontent.com/CommunityToolkit/Aspire/main/eng/scripts/dogfood-pr.ps1'; + const dogfoodMarker = ''; + const comment = `${dogfoodMarker} + 🚀 **Dogfood this PR with:** + + > **⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.** + + \`\`\`bash + curl -fsSL ${bashScript} | bash -s -- ${prNumber} + \`\`\` + Or + - Run remotely in PowerShell: + \`\`\`powershell + iex "& { $(irm ${psScript}) } ${prNumber}" + \`\`\``; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && String(comment.body || '').includes(dogfoodMarker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + comment_id: existingComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment, + }); + return; + } + + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment, + }); + + - name: Note missing artifact + if: ${{ steps.download-result.outcome != 'success' }} + run: echo "No dogfood-comment artifact was available; nothing to synchronize." diff --git a/.github/workflows/dogfood-comment.yml b/.github/workflows/dogfood-comment.yml index 9c5760f42..eae5b9526 100644 --- a/.github/workflows/dogfood-comment.yml +++ b/.github/workflows/dogfood-comment.yml @@ -1,13 +1,10 @@ name: Add Dogfooding Comment on: - # Use pull_request_target to run in the context of the base branch - # This allows commenting on PRs from forks - pull_request_target: + pull_request: types: [opened, reopened, synchronize] branches: - 'main' - # Allow manual triggering workflow_dispatch: inputs: pr_number: @@ -16,19 +13,46 @@ on: type: number jobs: - add-dogfood-comment: - # Only run on the CommunityToolkit org to avoid running on forks - if: ${{ github.repository_owner == 'CommunityToolkit' }} + prepare-dogfood-comment: + if: ${{ github.repository_owner == 'CommunityToolkit' && github.event_name == 'pull_request' }} runs-on: ubuntu-latest permissions: - pull-requests: write + pull-requests: read + steps: + - name: Write comment result artifact + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + mkdir -p "$RUNNER_TEMP/dogfood-comment" + jq -n \ + --arg schema_version "dogfood-comment/v1" \ + --arg event "pull_request" \ + --arg head_sha "$PR_HEAD_SHA" \ + --argjson pr_number "$PR_NUMBER" \ + --arg run_id "$GITHUB_RUN_ID" \ + '{schema_version:$schema_version,event:$event,head_sha:$head_sha,pr_number:$pr_number,run_id:$run_id}' \ + > "$RUNNER_TEMP/dogfood-comment/result.json" + + - name: Upload comment result artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dogfood-comment + path: ${{ runner.temp }}/dogfood-comment/result.json + if-no-files-found: error + retention-days: 3 + + add-dogfood-comment-manually: + if: ${{ github.repository_owner == 'CommunityToolkit' && github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + permissions: + issues: write steps: - name: Add dogfooding comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - // Get PR number from either the PR event or manual input - const prNumber = context.payload.number || context.payload.inputs.pr_number; + const prNumber = context.payload.inputs.pr_number; const bashScript = 'https://raw.githubusercontent.com/CommunityToolkit/Aspire/main/eng/scripts/dogfood-pr.sh'; const psScript = 'https://raw.githubusercontent.com/CommunityToolkit/Aspire/main/eng/scripts/dogfood-pr.ps1'; diff --git a/.github/workflows/integration-codeowner-writer.yml b/.github/workflows/integration-codeowner-writer.yml new file mode 100644 index 000000000..a4e4a06f8 --- /dev/null +++ b/.github/workflows/integration-codeowner-writer.yml @@ -0,0 +1,156 @@ +name: Integration code owner prompt writer + +on: + workflow_run: + workflows: ["Integration code owner automation"] + types: [completed] + +permissions: + actions: read + issues: write + pull-requests: read + +concurrency: + group: integration-codeowner-prompt-${{ github.event.workflow_run.head_repository.id || 'unknown-repo' }}-${{ github.event.workflow_run.head_branch || 'unknown-branch' }} + cancel-in-progress: true + +jobs: + prompt-for-codeowner: + if: ${{ github.repository_owner == 'CommunityToolkit' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Download prompt result artifact + id: download-result + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: integration-codeowner-prompt + path: ${{ runner.temp }}/integration-codeowner-prompt + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Prompt for code owner interest + if: ${{ steps.download-result.outcome == 'success' }} + uses: actions/github-script@v9.0.0 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const workflowRun = context.payload.workflow_run; + const resultPath = path.join(process.env.RUNNER_TEMP, 'integration-codeowner-prompt', 'result.json'); + const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); + + function fail(message) { + throw new Error(`Invalid integration code owner prompt artifact: ${message}`); + } + + if (result.schema_version !== 'integration-codeowner-prompt/v1') fail('unexpected schema_version'); + if (result.event !== 'pull_request') fail('unexpected event'); + if (!Number.isInteger(result.pr_number) || result.pr_number < 1) fail('invalid pr_number'); + if (typeof result.is_new_integration !== 'boolean') fail('invalid is_new_integration'); + if (!/^[0-9a-f]{40}$/i.test(String(result.head_sha || ''))) fail('invalid head_sha'); + if (String(result.run_id || '') !== String(workflowRun.id)) fail('run_id did not match workflow_run'); + if (result.head_sha !== workflowRun.head_sha) fail('head_sha did not match workflow_run'); + + if (!result.is_new_integration) { + core.info('Skipping PR because it does not add a new integration.'); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: result.pr_number, + }); + + if (pr.state !== 'open' || pr.draft) { + core.info(`Skipping non-open or draft PR #${result.pr_number}.`); + return; + } + + const expectedBaseRepository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase(); + const runHeadRepository = String(workflowRun.head_repository?.full_name || ''); + const runHeadRepositoryParts = runHeadRepository.split('/'); + const runHeadRef = String(workflowRun.head_branch || ''); + if (String(pr.base?.repo?.full_name || '').toLowerCase() !== expectedBaseRepository) { + fail(`PR #${result.pr_number} does not target this repository`); + } + if (pr.head.sha !== workflowRun.head_sha) { + core.warning(`Skipping stale prompt for PR #${result.pr_number}: artifact head ${result.head_sha}, current head ${pr.head.sha}`); + return; + } + if ( + runHeadRepositoryParts.length !== 2 || + !runHeadRepositoryParts[0] || + !runHeadRepositoryParts[1] || + !runHeadRef || + String(pr.head?.repo?.full_name || '').toLowerCase() !== runHeadRepository.toLowerCase() || + String(pr.head?.ref || '') !== runHeadRef + ) { + fail(`PR #${result.pr_number} head did not match workflow_run`); + } + + const workflowRunPullRequests = Array.isArray(workflowRun.pull_requests) ? workflowRun.pull_requests : []; + if (workflowRunPullRequests.length > 0) { + if (!workflowRunPullRequests.some((pullRequest) => pullRequest.number === result.pr_number)) { + fail(`PR #${result.pr_number} was not present in workflow_run.pull_requests`); + } + } else { + const candidatePullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${runHeadRepositoryParts[0]}:${runHeadRef}`, + per_page: 100, + }); + const trustedMatches = candidatePullRequests.filter((candidate) => + candidate.head?.sha === workflowRun.head_sha && + String(candidate.head?.ref || '') === runHeadRef && + String(candidate.head?.repo?.full_name || '').toLowerCase() === runHeadRepository.toLowerCase() && + String(candidate.base?.repo?.full_name || '').toLowerCase() === expectedBaseRepository + ); + if (trustedMatches.length !== 1 || trustedMatches[0].number !== result.pr_number) { + fail(`PR #${result.pr_number} could not be uniquely associated with workflow_run`); + } + } + + const marker = ''; + const body = `${marker} + Thanks for opening a new integration PR, @${pr.user.login}. + + If you want to become the code owner for this integration, reply with: + - \`/codeowner-accept\` + - \`/codeowner-reject\` + + Only the PR author should use these commands.`; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + const existingComment = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && String(comment.body || '').includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + + - name: Note missing artifact + if: ${{ steps.download-result.outcome != 'success' }} + run: echo "No integration-codeowner-prompt artifact was available; nothing to synchronize." diff --git a/.github/workflows/integration-codeowner.yml b/.github/workflows/integration-codeowner.yml index ca0dc84eb..7006068dd 100644 --- a/.github/workflows/integration-codeowner.yml +++ b/.github/workflows/integration-codeowner.yml @@ -1,18 +1,17 @@ name: Integration code owner automation on: - pull_request_target: + pull_request: types: [opened, ready_for_review, reopened] issue_comment: types: [created] jobs: - prompt-for-codeowner: - if: ${{ github.repository_owner == 'CommunityToolkit' && github.event_name == 'pull_request_target' && !github.event.pull_request.draft }} + prepare-codeowner-prompt: + if: ${{ github.repository_owner == 'CommunityToolkit' && github.event_name == 'pull_request' && !github.event.pull_request.draft }} runs-on: ubuntu-latest permissions: pull-requests: read - issues: write steps: - name: Detect new integration paths id: metadata @@ -41,50 +40,30 @@ jobs: core.setOutput('example_folders_json', JSON.stringify(exampleFolders)); core.setOutput('is_new_integration', packageIds.length > 0 && exampleFolders.length > 0 ? 'true' : 'false'); - - name: Prompt for code owner interest - if: ${{ steps.metadata.outputs.is_new_integration == 'true' }} - uses: actions/github-script@v9.0.0 + - name: Write prompt result artifact + env: + IS_NEW_INTEGRATION: ${{ steps.metadata.outputs.is_new_integration }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + mkdir -p "$RUNNER_TEMP/integration-codeowner-prompt" + jq -n \ + --arg schema_version "integration-codeowner-prompt/v1" \ + --arg event "pull_request" \ + --argjson is_new_integration "$IS_NEW_INTEGRATION" \ + --arg head_sha "$PR_HEAD_SHA" \ + --argjson pr_number "$PR_NUMBER" \ + --arg run_id "$GITHUB_RUN_ID" \ + '{schema_version:$schema_version,event:$event,is_new_integration:$is_new_integration,head_sha:$head_sha,pr_number:$pr_number,run_id:$run_id}' \ + > "$RUNNER_TEMP/integration-codeowner-prompt/result.json" + + - name: Upload prompt result artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - script: | - const marker = ''; - const prNumber = context.payload.pull_request.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - const author = context.payload.pull_request.user.login; - const body = `${marker} - Thanks for opening a new integration PR, @${author}. - - If you want to become the code owner for this integration, reply with: - - \`/codeowner-accept\` - - \`/codeowner-reject\` - - Only the PR author should use these commands.`; - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: prNumber, - per_page: 100, - }); - - const existingComment = comments.find((comment) => comment.body.includes(marker)); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existingComment.id, - body, - }); - return; - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - }); + name: integration-codeowner-prompt + path: ${{ runner.temp }}/integration-codeowner-prompt/result.json + if-no-files-found: error + retention-days: 3 handle-codeowner-response: if: ${{ github.repository_owner == 'CommunityToolkit' && github.event_name == 'issue_comment' && github.event.issue.pull_request && (startsWith(github.event.comment.body, '/codeowner-accept') || startsWith(github.event.comment.body, '/codeowner-reject')) }}