diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml new file mode 100644 index 000000000000..7808f03765cf --- /dev/null +++ b/.github/workflows/ai_disclosure.yml @@ -0,0 +1,54 @@ +# 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: AI disclosure enforcement + +on: + workflow_run: + workflows: + - PR trigger + types: + - completed + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + enforce: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + + steps: + # Check out trusted code from the default branch, not the PR branch. + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Check AI disclosure + uses: actions/github-script@v7 + with: + script: | + const checkDisclosure = require( + "./.github/workflows/dev_pr/ai-check.js" + ); + + await checkDisclosure({github, context, core}); diff --git a/.github/workflows/dev_pr/ai-check.js b/.github/workflows/dev_pr/ai-check.js new file mode 100644 index 000000000000..c2e4fd3d5ff5 --- /dev/null +++ b/.github/workflows/dev_pr/ai-check.js @@ -0,0 +1,212 @@ +// 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. + +const AUTHORSHIP_HEADING = "PR code and description written by:"; +const REVIEW_HEADING = "Reviewed before submission by:"; + +const INCOMPLETE_COMMENT = + "This pull request does not follow the Apache Arrow PR template and is therefore being closed.\n\n" + + "If you wish to contribute, please open a new pull request using the [Apache Arrow PR template](https://github.com/apache/arrow/blob/main/.github/pull_request_template.md) " + + "and complete all required sections"; + +const AI_ONLY_COMMENT = + "This pull request has been automatically closed because its authorship is AI-only and it has no human review.\n\n" + + "Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review."; + +const NEEDS_HUMAN_REVIEW_COMMENT = + "This pull request has been converted to Draft because it has not been reviewed by a human.\n\n" + + "Please review your contribution, update the review disclosure section, " + + "and mark the pull request as Ready for review."; + +function parseSection(lines, start, end, options) { + const found = new Set(); + const selected = []; + + for (const line of lines.slice(start + 1, end)) { + const match = line.match(/^\s*-\s+\[([ xX])\]\s*(.*?)\s*$/); + + if (!match) { + // Reject malformed checkbox entries. + if (/^\s*-\s+\[/.test(line)) return null; + continue; + } + + const option = match[2]; + + // Each expected option must appear exactly once. + if (!options.includes(option) || found.has(option)) return null; + + found.add(option); + if (match[1].toLowerCase() === "x") selected.push(option); + } + + if ( + found.size !== options.length || + !selected.length || + (selected.includes("Not reviewed") && selected.length > 1) + ) { + return null; + } + + return selected; +} + +function parseDisclosure(body) { + const lines = body.split(/\r?\n/); + const headings = lines.map(line => + line.trim().replace(/^\*\*(.*?)\*\*$/, "$1") + ); + + const authorshipStart = headings.indexOf(AUTHORSHIP_HEADING); + const reviewStart = headings.indexOf(REVIEW_HEADING); + + // Both headings must exist exactly once and in the correct order. + if ( + authorshipStart === -1 || + reviewStart <= authorshipStart || + headings.lastIndexOf(AUTHORSHIP_HEADING) !== authorshipStart || + headings.lastIndexOf(REVIEW_HEADING) !== reviewStart + ) { + return "incomplete"; + } + + // Find where the review section ends. + const reviewEnd = lines.findIndex( + (line, index) => + index > reviewStart && + (/^#{1,6}\s/.test(line.trim()) || /^\*\*.+\*\*$/.test(line.trim())) + ); + + const authorship = parseSection( + lines, authorshipStart, reviewStart, ["Human", "AI"] + ); + + const review = parseSection( + lines, + reviewStart, + reviewEnd === -1 ? lines.length : reviewEnd, + ["Human", "AI", "Not reviewed"] + ); + + if (!authorship || !review) return "incomplete"; + if (review.includes("Human")) return "complete"; + + return authorship.length === 1 && authorship[0] === "AI" + ? "ai-only" + : "needs-human-review"; +} + +/** + * Adds a comment to the pull request. + */ +async function createDisclosureComment( + github, context, pullRequestNumber, body +) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + body + }); +} + +module.exports = async ({github, context, core}) => { + const run = context.payload.workflow_run; + const head = run.head_repository; + + if ( + run.event !== "pull_request" || + !head?.owner?.login || + !head.full_name || + !run.head_branch || + !run.head_sha + ) { + core.info("Unable to identify an associated pull request."); + return; + } + + // Find the PR using the contributor's fork and branch. + const candidates = await github.paginate( + github.rest.pulls.list, + { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + head: `${head.owner.login}:${run.head_branch}`, + per_page: 100 + } + ); + + for (const candidate of candidates) { + const {data: pr} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: candidate.number + }); + + // Ignore outdated runs and bot-authored PRs. + if ( + pr.state !== "open" || + pr.head.sha !== run.head_sha || + pr.head.repo?.full_name.toLowerCase() !== head.full_name.toLowerCase() || + pr.user?.type === "Bot" + ) { + continue; + } + + const status = parseDisclosure(pr.body || ""); + + core.info(`Disclosure status for #${pr.number}: ${status}.`); + + // A valid disclosure requires no action. + if (status === "complete") continue; + + const comment = { + incomplete: INCOMPLETE_COMMENT, + "ai-only": AI_ONLY_COMMENT, + "needs-human-review": NEEDS_HUMAN_REVIEW_COMMENT + }[status]; + + await createDisclosureComment( + github, context, pr.number, comment + ); + + // Close missing/incomplete disclosures and AI-only contributions + // without human review. Other contributions must receive human review. + if (status === "incomplete" || status === "ai-only") { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: "closed" + }); + } else if (!pr.draft) { + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: {pullRequestId: $id}) { + pullRequest { + id + } + } + }`, + {id: pr.node_id} + ); + } + } +}; + +module.exports.parseDisclosure = parseDisclosure; diff --git a/.github/workflows/pr-trigger.yml b/.github/workflows/pr-trigger.yml new file mode 100644 index 000000000000..1a1983083253 --- /dev/null +++ b/.github/workflows/pr-trigger.yml @@ -0,0 +1,36 @@ +# 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: PR trigger + +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +permissions: {} + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Trigger PR validation + run: echo "PR update detected."