From ba72899c179b041d834b8cf15216578475ab4b40 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 22:19:34 +0530 Subject: [PATCH 1/9] [CI] Enforce AI disclosure in pull requests --- .github/workflows/ai_disclosure.yml | 55 +++++ .github/workflows/ai_disclosure/check.js | 260 +++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 .github/workflows/ai_disclosure.yml create mode 100644 .github/workflows/ai_disclosure/check.js diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml new file mode 100644 index 000000000000..b760e6ea5889 --- /dev/null +++ b/.github/workflows/ai_disclosure.yml @@ -0,0 +1,55 @@ +# 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 + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.number }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + # Always run the script from main, never from the PR head. + - uses: actions/checkout@v7 + with: + repository: apache/arrow + ref: main + persist-credentials: false + + - name: Check AI disclosure + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/ai_disclosure/check.js`); + await script({github, context, core}); diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js new file mode 100644 index 000000000000..cf3b22471d49 --- /dev/null +++ b/.github/workflows/ai_disclosure/check.js @@ -0,0 +1,260 @@ +// 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 COMMENT_MARKER = ""; + +const SECTIONS = [ + { + heading: "PR code and description written by:", + options: ["Human", "AI"] + }, + { + heading: "Reviewed before submission by:", + options: ["Human", "AI", "Not reviewed"] + } +]; + +const INCOMPLETE_COMMENT = `${COMMENT_MARKER} + +The AI disclosure in this pull request is missing or incomplete. + +Please complete both disclosure sections in the pull request description by selecting the applicable checkboxes. Once the disclosure is complete, you can mark the pull request as ready for review. + +If the disclosure is not present, add the following to the pull request description: + +**PR code and description written by:** + +- [ ] Human +- [ ] AI + +**Reviewed before submission by:** + +- [ ] Human +- [ ] AI +- [ ] Not reviewed + +Once you have completed the disclosure, please manually mark this pull request as **Ready for review** +This pull request has been converted to Draft until the disclosure is completed.`; + +const AI_ONLY_COMMENT = `${COMMENT_MARKER} + +This pull request has been automatically closed because both its authorship and review disclosures indicate AI-only involvement. + +Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review. + +If this classification is incorrect, please reopen the pull request and correct the disclosure.`; + +const RESOLVED_COMMENT = `${COMMENT_MARKER} + +The AI disclosure is now complete. No further action is required.`; + +function parseDisclosure(body) { + const lines = body.split(/\r?\n/); + // Keep the sections ordered so unrelated checkboxes cannot be misclassified. + const sections = lines.flatMap((line, lineNumber) => + SECTIONS + .filter(({heading}) => line.trim() === `**${heading}**`) + .map((section) => ({...section, line: lineNumber})) + ); + + if ( + sections.length !== SECTIONS.length || + sections.some((section, index) => section.heading !== SECTIONS[index].heading) + ) { + return {status: "incomplete"}; + } + + const selected = sections.map((section, index) => { + const nextSection = sections[index + 1]; + let endLine = nextSection?.line ?? lines.length; + + // The final section ends at the next Markdown heading, if there is one. + if (!nextSection) { + const headingLine = lines.findIndex( + (line, lineNumber) => + lineNumber > section.line && + /^\s*\*\*[^*].+?\*\*\s*$/.test(line) + ); + if (headingLine !== -1) { + endLine = headingLine; + } + } + + const selections = lines + .slice(section.line + 1, endLine) + .filter((line) => /^\s*-\s+\[[ xX]\]\s*.+$/.test(line)) + .map((line) => { + const [, checked, option] = line.match( + /^\s*-\s+\[([ xX])\]\s*(.*?)\s*$/ + ); + return {checked: checked.toLowerCase() === "x", option}; + }); + const foundOptions = selections.map(({option}) => option); + + // Every declared option must appear once, with at least one selected. + if ( + foundOptions.length !== section.options.length || + section.options.some((option) => !foundOptions.includes(option)) || + new Set(foundOptions).size !== foundOptions.length || + !selections.some(({checked}) => checked) + ) { + return null; + } + + return selections + .filter(({checked}) => checked) + .map(({option}) => option); + }); + + if (selected.some((section) => section === null)) { + return {status: "incomplete"}; + } + + const [authorship, review] = selected; + + if ( + authorship.length === 1 && + authorship[0] === "AI" && + review.length === 1 && + review[0] === "AI" + ) { + return {status: "ai-only", authorship, review}; + } + + return {status: "complete", authorship, review}; +} + +async function findDisclosureComment(github, context, pullRequestNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + per_page: 100 + }); + return comments.find((comment) => + comment.user.type === "Bot" && comment.body.includes(COMMENT_MARKER) + ); +} + +async function updateDisclosureComment( + github, + context, + pullRequestNumber, + body +) { + const existing = await findDisclosureComment( + github, + context, + pullRequestNumber + ); + const params = { + owner: context.repo.owner, + repo: context.repo.repo, + body + }; + await (existing + ? github.rest.issues.updateComment({...params, comment_id: existing.id}) + : github.rest.issues.createComment({ + ...params, + issue_number: pullRequestNumber + })); +} + +async function checkDisclosure({github, context, core}) { + const pullRequest = context.payload.pull_request; + const pullRequestNumber = pullRequest.number; + + if (pullRequest.state !== "open") { + core.info(`Skipping #${pullRequestNumber}: pull request is not open.`); + return; + } + + if (pullRequest.draft) { + core.info(`Skipping #${pullRequestNumber}: pull request is a draft.`); + return; + } + + if (pullRequest.user.type === "Bot") { + core.info(`Skipping #${pullRequestNumber}: author is a bot.`); + return; + } + + const result = parseDisclosure(pullRequest.body || ""); + + core.info(`Disclosure status for #${pullRequestNumber}: ${result.status}.`); + + if (result.status === "incomplete") { + await updateDisclosureComment( + github, + context, + pullRequestNumber, + INCOMPLETE_COMMENT + ); + + await github.graphql( + `mutation($pullRequestId: ID!) { + convertPullRequestToDraft( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { + isDraft + } + } + }`, + {pullRequestId: pullRequest.node_id} + ); + return; + } + + if (result.status === "ai-only") { + await updateDisclosureComment( + github, + context, + pullRequestNumber, + AI_ONLY_COMMENT + ); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequestNumber, + state: "closed" + }); + + core.info(`Closed #${pullRequestNumber}: AI-only authorship and review.`); + return; + } + + // Update an existing bot comment, but don't create one for valid disclosures. + const existing = await findDisclosureComment( + github, + context, + pullRequestNumber + ); + + if (existing && existing.body !== RESOLVED_COMMENT) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: RESOLVED_COMMENT + }); + } +} + +module.exports = checkDisclosure; +module.exports.parseDisclosure = parseDisclosure; From 01b31bb9c7995912fe7f70594241cc36f9ad01d7 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 22:27:56 +0530 Subject: [PATCH 2/9] [CI] Fix AI disclosure workflow checkout --- .github/workflows/ai_disclosure.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml index b760e6ea5889..567d4b8c3768 100644 --- a/.github/workflows/ai_disclosure.yml +++ b/.github/workflows/ai_disclosure.yml @@ -42,8 +42,8 @@ jobs: # Always run the script from main, never from the PR head. - uses: actions/checkout@v7 with: - repository: apache/arrow - ref: main + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.base.ref }} persist-credentials: false - name: Check AI disclosure From 585299d61194160fc6fab13fba6fa7c9e5b63279 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 22:40:05 +0530 Subject: [PATCH 3/9] Fix bugs --- .github/workflows/ai_disclosure/check.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js index cf3b22471d49..72b4248174f9 100644 --- a/.github/workflows/ai_disclosure/check.js +++ b/.github/workflows/ai_disclosure/check.js @@ -52,7 +52,7 @@ This pull request has been converted to Draft until the disclosure is completed. const AI_ONLY_COMMENT = `${COMMENT_MARKER} -This pull request has been automatically closed because both its authorship and review disclosures indicate AI-only involvement. +This pull request has been automatically closed because its authorship indicates AI involvement without human review. Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review. @@ -62,6 +62,12 @@ const RESOLVED_COMMENT = `${COMMENT_MARKER} The AI disclosure is now complete. No further action is required.`; +const READY_WITHOUT_DISCLOSURE_COMMENT = `${COMMENT_MARKER} + +This pull request was marked Ready for review, but the AI disclosure is still missing or incomplete. + +Please add and complete both AI disclosure sections in the pull request description before marking it Ready for review again.`; + function parseDisclosure(body) { const lines = body.split(/\r?\n/); // Keep the sections ordered so unrelated checkboxes cannot be misclassified. @@ -130,7 +136,7 @@ function parseDisclosure(body) { authorship.length === 1 && authorship[0] === "AI" && review.length === 1 && - review[0] === "AI" + ["AI", "Not reviewed"].includes(review[0]) ) { return {status: "ai-only", authorship, review}; } @@ -154,7 +160,8 @@ async function updateDisclosureComment( github, context, pullRequestNumber, - body + body, + createNew = false ) { const existing = await findDisclosureComment( github, @@ -166,7 +173,7 @@ async function updateDisclosureComment( repo: context.repo.repo, body }; - await (existing + await (existing && !createNew ? github.rest.issues.updateComment({...params, comment_id: existing.id}) : github.rest.issues.createComment({ ...params, @@ -202,7 +209,10 @@ async function checkDisclosure({github, context, core}) { github, context, pullRequestNumber, - INCOMPLETE_COMMENT + context.payload.action === "ready_for_review" + ? READY_WITHOUT_DISCLOSURE_COMMENT + : INCOMPLETE_COMMENT, + context.payload.action === "ready_for_review" ); await github.graphql( From 00726832d43f7e78b42520fc44d5f3d060a1cc26 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Sun, 1 Nov 2026 22:47:45 +0530 Subject: [PATCH 4/9] New comment when converted to draft --- .github/workflows/ai_disclosure/check.js | 27 +++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js index 72b4248174f9..5e4158e7efbc 100644 --- a/.github/workflows/ai_disclosure/check.js +++ b/.github/workflows/ai_disclosure/check.js @@ -184,13 +184,14 @@ async function updateDisclosureComment( async function checkDisclosure({github, context, core}) { const pullRequest = context.payload.pull_request; const pullRequestNumber = pullRequest.number; + const isReadyForReview = context.payload.action === "ready_for_review"; if (pullRequest.state !== "open") { core.info(`Skipping #${pullRequestNumber}: pull request is not open.`); return; } - if (pullRequest.draft) { + if (pullRequest.draft && !isReadyForReview) { core.info(`Skipping #${pullRequestNumber}: pull request is a draft.`); return; } @@ -205,15 +206,21 @@ async function checkDisclosure({github, context, core}) { core.info(`Disclosure status for #${pullRequestNumber}: ${result.status}.`); if (result.status === "incomplete") { - await updateDisclosureComment( - github, - context, - pullRequestNumber, - context.payload.action === "ready_for_review" - ? READY_WITHOUT_DISCLOSURE_COMMENT - : INCOMPLETE_COMMENT, - context.payload.action === "ready_for_review" - ); + if (isReadyForReview) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + body: READY_WITHOUT_DISCLOSURE_COMMENT + }); + } else { + await updateDisclosureComment( + github, + context, + pullRequestNumber, + INCOMPLETE_COMMENT + ); + } await github.graphql( `mutation($pullRequestId: ID!) { From 627e45aa66044317797d556dc3533089b025cb6e Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Mon, 2 Nov 2026 22:47:51 +0530 Subject: [PATCH 5/9] Simplify the code --- .github/workflows/ai_disclosure.yml | 2 +- .github/workflows/ai_disclosure/check.js | 180 +++++++++++------------ 2 files changed, 88 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml index 567d4b8c3768..2f8ea43716e7 100644 --- a/.github/workflows/ai_disclosure.yml +++ b/.github/workflows/ai_disclosure.yml @@ -30,7 +30,7 @@ concurrency: cancel-in-progress: true permissions: - contents: write + contents: read pull-requests: write issues: write diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js index 5e4158e7efbc..976db2ce3326 100644 --- a/.github/workflows/ai_disclosure/check.js +++ b/.github/workflows/ai_disclosure/check.js @@ -17,16 +17,8 @@ const COMMENT_MARKER = ""; -const SECTIONS = [ - { - heading: "PR code and description written by:", - options: ["Human", "AI"] - }, - { - heading: "Reviewed before submission by:", - options: ["Human", "AI", "Not reviewed"] - } -]; +const AUTHORSHIP_HEADING = "**PR code and description written by:**"; +const REVIEW_HEADING = "**Reviewed before submission by:**"; const INCOMPLETE_COMMENT = `${COMMENT_MARKER} @@ -47,7 +39,7 @@ If the disclosure is not present, add the following to the pull request descript - [ ] AI - [ ] Not reviewed -Once you have completed the disclosure, please manually mark this pull request as **Ready for review** +Once you have completed the disclosure, please manually mark this pull request as **Ready for review**. This pull request has been converted to Draft until the disclosure is completed.`; const AI_ONLY_COMMENT = `${COMMENT_MARKER} @@ -62,76 +54,81 @@ const RESOLVED_COMMENT = `${COMMENT_MARKER} The AI disclosure is now complete. No further action is required.`; -const READY_WITHOUT_DISCLOSURE_COMMENT = `${COMMENT_MARKER} +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) { + continue; + } + + const option = match[2]; + const checked = match[1].toLowerCase() === "x"; + + if (!options.includes(option) || found.has(option)) { + return null; + } + + found.add(option); + + if (checked) { + selected.push(option); + } + } -This pull request was marked Ready for review, but the AI disclosure is still missing or incomplete. + if (found.size !== options.length || selected.length === 0) { + return null; + } -Please add and complete both AI disclosure sections in the pull request description before marking it Ready for review again.`; + return selected; +} function parseDisclosure(body) { const lines = body.split(/\r?\n/); - // Keep the sections ordered so unrelated checkboxes cannot be misclassified. - const sections = lines.flatMap((line, lineNumber) => - SECTIONS - .filter(({heading}) => line.trim() === `**${heading}**`) - .map((section) => ({...section, line: lineNumber})) - ); + const normalizedLines = lines.map((line) => line.trim()); + + const authorshipStart = normalizedLines.indexOf(AUTHORSHIP_HEADING); + const reviewStart = normalizedLines.indexOf(REVIEW_HEADING); if ( - sections.length !== SECTIONS.length || - sections.some((section, index) => section.heading !== SECTIONS[index].heading) + authorshipStart === -1 || + reviewStart <= authorshipStart || + normalizedLines.lastIndexOf(AUTHORSHIP_HEADING) !== authorshipStart || + normalizedLines.lastIndexOf(REVIEW_HEADING) !== reviewStart ) { return {status: "incomplete"}; } - const selected = sections.map((section, index) => { - const nextSection = sections[index + 1]; - let endLine = nextSection?.line ?? lines.length; - - // The final section ends at the next Markdown heading, if there is one. - if (!nextSection) { - const headingLine = lines.findIndex( - (line, lineNumber) => - lineNumber > section.line && - /^\s*\*\*[^*].+?\*\*\s*$/.test(line) - ); - if (headingLine !== -1) { - endLine = headingLine; - } - } + const reviewEnd = normalizedLines.findIndex( + (line, index) => + index > reviewStart && /^\*\*[^*].+?\*\*$/.test(line) + ); - const selections = lines - .slice(section.line + 1, endLine) - .filter((line) => /^\s*-\s+\[[ xX]\]\s*.+$/.test(line)) - .map((line) => { - const [, checked, option] = line.match( - /^\s*-\s+\[([ xX])\]\s*(.*?)\s*$/ - ); - return {checked: checked.toLowerCase() === "x", option}; - }); - const foundOptions = selections.map(({option}) => option); - - // Every declared option must appear once, with at least one selected. - if ( - foundOptions.length !== section.options.length || - section.options.some((option) => !foundOptions.includes(option)) || - new Set(foundOptions).size !== foundOptions.length || - !selections.some(({checked}) => checked) - ) { - return null; - } + const authorship = parseSection( + lines, + authorshipStart, + reviewStart, + ["Human", "AI"] + ); - return selections - .filter(({checked}) => checked) - .map(({option}) => option); - }); + const review = parseSection( + lines, + reviewStart, + reviewEnd === -1 ? lines.length : reviewEnd, + ["Human", "AI", "Not reviewed"] + ); - if (selected.some((section) => section === null)) { + if ( + !authorship || + !review || + (review.includes("Not reviewed") && review.length > 1) + ) { return {status: "incomplete"}; } - const [authorship, review] = selected; - if ( authorship.length === 1 && authorship[0] === "AI" && @@ -160,25 +157,31 @@ async function updateDisclosureComment( github, context, pullRequestNumber, - body, - createNew = false + body ) { const existing = await findDisclosureComment( github, context, pullRequestNumber ); - const params = { - owner: context.repo.owner, - repo: context.repo.repo, - body - }; - await (existing && !createNew - ? github.rest.issues.updateComment({...params, comment_id: existing.id}) - : github.rest.issues.createComment({ - ...params, - issue_number: pullRequestNumber - })); + + if (existing) { + if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequestNumber, + body + }); + } } async function checkDisclosure({github, context, core}) { @@ -206,21 +209,12 @@ async function checkDisclosure({github, context, core}) { core.info(`Disclosure status for #${pullRequestNumber}: ${result.status}.`); if (result.status === "incomplete") { - if (isReadyForReview) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNumber, - body: READY_WITHOUT_DISCLOSURE_COMMENT - }); - } else { - await updateDisclosureComment( - github, - context, - pullRequestNumber, - INCOMPLETE_COMMENT - ); - } + await updateDisclosureComment( + github, + context, + pullRequestNumber, + INCOMPLETE_COMMENT + ); await github.graphql( `mutation($pullRequestId: ID!) { @@ -234,6 +228,7 @@ async function checkDisclosure({github, context, core}) { }`, {pullRequestId: pullRequest.node_id} ); + return; } @@ -256,7 +251,6 @@ async function checkDisclosure({github, context, core}) { return; } - // Update an existing bot comment, but don't create one for valid disclosures. const existing = await findDisclosureComment( github, context, From 75b979041495889471da4a98c058fb3ec811330e Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Tue, 3 Nov 2026 23:41:33 +0530 Subject: [PATCH 6/9] Add md file for comment and simplify code --- .github/workflows/ai_disclosure.yml | 4 +- .github/workflows/ai_disclosure/check.js | 244 +++++++----------- .github/workflows/ai_disclosure/incomplete.md | 43 +++ 3 files changed, 133 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/ai_disclosure/incomplete.md diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml index 2f8ea43716e7..230ce152f2c2 100644 --- a/.github/workflows/ai_disclosure.yml +++ b/.github/workflows/ai_disclosure.yml @@ -30,7 +30,7 @@ concurrency: cancel-in-progress: true permissions: - contents: read + contents: write pull-requests: write issues: write @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v7 with: repository: ${{ github.repository }} - ref: ${{ github.event.pull_request.base.ref }} + ref: ${{ github.event.repository.default_branch }} persist-credentials: false - name: Check AI disclosure diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js index 976db2ce3326..6d7ab4ac098d 100644 --- a/.github/workflows/ai_disclosure/check.js +++ b/.github/workflows/ai_disclosure/check.js @@ -15,44 +15,10 @@ // specific language governing permissions and limitations // under the License. -const COMMENT_MARKER = ""; +const fs = require("fs"); -const AUTHORSHIP_HEADING = "**PR code and description written by:**"; -const REVIEW_HEADING = "**Reviewed before submission by:**"; - -const INCOMPLETE_COMMENT = `${COMMENT_MARKER} - -The AI disclosure in this pull request is missing or incomplete. - -Please complete both disclosure sections in the pull request description by selecting the applicable checkboxes. Once the disclosure is complete, you can mark the pull request as ready for review. - -If the disclosure is not present, add the following to the pull request description: - -**PR code and description written by:** - -- [ ] Human -- [ ] AI - -**Reviewed before submission by:** - -- [ ] Human -- [ ] AI -- [ ] Not reviewed - -Once you have completed the disclosure, please manually mark this pull request as **Ready for review**. -This pull request has been converted to Draft until the disclosure is completed.`; - -const AI_ONLY_COMMENT = `${COMMENT_MARKER} - -This pull request has been automatically closed because its authorship indicates AI involvement without human review. - -Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review. - -If this classification is incorrect, please reopen the pull request and correct the disclosure.`; - -const RESOLVED_COMMENT = `${COMMENT_MARKER} - -The AI disclosure is now complete. No further action is required.`; +const AUTHORSHIP_HEADING = "PR code and description written by:"; +const REVIEW_HEADING = "Reviewed before submission by:"; function parseSection(lines, start, end, options) { const found = new Set(); @@ -62,56 +28,62 @@ function parseSection(lines, start, end, options) { const match = line.match(/^\s*-\s+\[([ xX])\]\s*(.*?)\s*$/); if (!match) { + if (/^\s*-\s+\[/.test(line)) return null; continue; } const option = match[2]; - const checked = match[1].toLowerCase() === "x"; - if (!options.includes(option) || found.has(option)) { - return null; - } + if (!options.includes(option) || found.has(option)) return null; found.add(option); - if (checked) { + if (match[1].toLowerCase() === "x") { selected.push(option); } } - if (found.size !== options.length || selected.length === 0) { + if ( + found.size !== options.length || + selected.length === 0 || + (selected.includes("Not reviewed") && selected.length > 1) + ) { return null; } return selected; } +function normalizeHeading(line) { + return line.trim().replace(/^\*\*(.*?)\*\*$/, "$1"); +} + +function isHeading(line) { + const trimmed = line.trim(); + return /^#{1,6}\s/.test(trimmed) || /^\*\*.+\*\*$/.test(trimmed); +} + function parseDisclosure(body) { const lines = body.split(/\r?\n/); - const normalizedLines = lines.map((line) => line.trim()); - - const authorshipStart = normalizedLines.indexOf(AUTHORSHIP_HEADING); - const reviewStart = normalizedLines.indexOf(REVIEW_HEADING); + const headings = lines.map(normalizeHeading); + const authorshipStart = headings.indexOf(AUTHORSHIP_HEADING); + const reviewStart = headings.indexOf(REVIEW_HEADING); if ( authorshipStart === -1 || reviewStart <= authorshipStart || - normalizedLines.lastIndexOf(AUTHORSHIP_HEADING) !== authorshipStart || - normalizedLines.lastIndexOf(REVIEW_HEADING) !== reviewStart + headings.lastIndexOf(AUTHORSHIP_HEADING) !== authorshipStart || + headings.lastIndexOf(REVIEW_HEADING) !== reviewStart ) { return {status: "incomplete"}; } - const reviewEnd = normalizedLines.findIndex( - (line, index) => - index > reviewStart && /^\*\*[^*].+?\*\*$/.test(line) + const reviewEnd = lines.findIndex( + (line, index) => index > reviewStart && isHeading(line) ); const authorship = parseSection( - lines, - authorshipStart, - reviewStart, - ["Human", "AI"] + lines, authorshipStart, reviewStart, ["Human", "AI"] ); const review = parseSection( @@ -121,86 +93,70 @@ function parseDisclosure(body) { ["Human", "AI", "Not reviewed"] ); - if ( - !authorship || - !review || - (review.includes("Not reviewed") && review.length > 1) - ) { + if (!authorship || !review) { return {status: "incomplete"}; } - if ( - authorship.length === 1 && - authorship[0] === "AI" && - review.length === 1 && - ["AI", "Not reviewed"].includes(review[0]) - ) { - return {status: "ai-only", authorship, review}; + if (review.includes("Human")) { + return {status: "complete"}; } - return {status: "complete", authorship, review}; + if (authorship.length === 1 && authorship[0] === "AI") { + return {status: "ai-unreviewed"}; + } + + return {status: "needs-human-review"}; } -async function findDisclosureComment(github, context, pullRequestNumber) { - const comments = await github.paginate(github.rest.issues.listComments, { +/** + * 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, - per_page: 100 + body }); - return comments.find((comment) => - comment.user.type === "Bot" && comment.body.includes(COMMENT_MARKER) - ); } -async function updateDisclosureComment( - github, - context, - pullRequestNumber, - body -) { - const existing = await findDisclosureComment( - github, - context, - pullRequestNumber +/** + * Converts the pull request to Draft unless it is already a draft. + * + * @param {Object} github + * @param {Object} context + */ +async function convertToDraft(github, context) { + if (context.payload.pull_request.draft) return; + + await github.graphql( + ` + mutation ($pullRequestId: ID!) { + convertPullRequestToDraft( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { + id + } + } + } + `, + {pullRequestId: context.payload.pull_request.node_id} ); - - if (existing) { - if (existing.body !== body) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); - } - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNumber, - body - }); - } } -async function checkDisclosure({github, context, core}) { +module.exports = async ({github, context, core}) => { const pullRequest = context.payload.pull_request; const pullRequestNumber = pullRequest.number; - const isReadyForReview = context.payload.action === "ready_for_review"; - if (pullRequest.state !== "open") { - core.info(`Skipping #${pullRequestNumber}: pull request is not open.`); - return; - } - - if (pullRequest.draft && !isReadyForReview) { - core.info(`Skipping #${pullRequestNumber}: pull request is a draft.`); - return; - } - - if (pullRequest.user.type === "Bot") { - core.info(`Skipping #${pullRequestNumber}: author is a bot.`); + if ( + pullRequest.state !== "open" || + (pullRequest.draft && context.payload.action !== "ready_for_review") || + pullRequest.user?.type === "Bot" + ) { + core.info(`Skipping #${pullRequestNumber}: not an eligible pull request.`); return; } @@ -208,36 +164,17 @@ async function checkDisclosure({github, context, core}) { core.info(`Disclosure status for #${pullRequestNumber}: ${result.status}.`); - if (result.status === "incomplete") { - await updateDisclosureComment( - github, - context, - pullRequestNumber, - INCOMPLETE_COMMENT - ); - - await github.graphql( - `mutation($pullRequestId: ID!) { - convertPullRequestToDraft( - input: {pullRequestId: $pullRequestId} - ) { - pullRequest { - isDraft - } - } - }`, - {pullRequestId: pullRequest.node_id} - ); - + if (result.status === "complete") { return; } - if (result.status === "ai-only") { - await updateDisclosureComment( + if (result.status === "ai-unreviewed") { + await createDisclosureComment( github, context, pullRequestNumber, - AI_ONLY_COMMENT + `This pull request has been automatically closed because its authorship is AI-only and it has no human review. + Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review.` ); await github.rest.pulls.update({ @@ -247,25 +184,20 @@ async function checkDisclosure({github, context, core}) { state: "closed" }); - core.info(`Closed #${pullRequestNumber}: AI-only authorship and review.`); + core.info(`Closed #${pullRequestNumber}: AI-only authorship without human review.`); return; } - const existing = await findDisclosureComment( + await createDisclosureComment( github, context, - pullRequestNumber + pullRequestNumber, + result.status === "incomplete" + ? fs.readFileSync(`${__dirname}/incomplete.md`, "utf8") + : `This pull request has been converted to Draft because it has not been reviewed by a human. Please review your contribution + yourself before marking this pull request as ready for review. + \nOnce you have completed your review, update the review disclosure section and mark this pull request as Ready for review.` ); - if (existing && existing.body !== RESOLVED_COMMENT) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: RESOLVED_COMMENT - }); - } -} - -module.exports = checkDisclosure; -module.exports.parseDisclosure = parseDisclosure; + await convertToDraft(github, context); +}; diff --git a/.github/workflows/ai_disclosure/incomplete.md b/.github/workflows/ai_disclosure/incomplete.md new file mode 100644 index 000000000000..d39b1c065c82 --- /dev/null +++ b/.github/workflows/ai_disclosure/incomplete.md @@ -0,0 +1,43 @@ + + + + +The AI disclosure in this pull request is missing or incomplete. + +Please complete both disclosure sections in the pull request description by selecting the applicable checkboxes. + +If the disclosure is not present, add the following to your pull request description: + +```markdown +**PR code and description written by:** + +- [ ] Human +- [ ] AI + +**Reviewed before submission by:** + +- [ ] Human +- [ ] AI +- [ ] Not reviewed +``` + +Once you have completed the disclosure, please manually mark this pull request as **Ready for review**. + +This pull request has been converted to Draft until the disclosure is completed. From 1b388bfcf85ffa10150900618c5a9eaf98cffafd Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Tue, 10 Nov 2026 15:06:39 +0530 Subject: [PATCH 7/9] Remove pull_request_target --- .github/workflows/ai_disclosure.yml | 39 ++-- .github/workflows/ai_disclosure/check.js | 203 ---------------- .github/workflows/ai_disclosure/incomplete.md | 43 ---- .github/workflows/dev_pr/ai-check.js | 221 ++++++++++++++++++ .github/workflows/pr-trigger.yml | 36 +++ 5 files changed, 276 insertions(+), 266 deletions(-) delete mode 100644 .github/workflows/ai_disclosure/check.js delete mode 100644 .github/workflows/ai_disclosure/incomplete.md create mode 100644 .github/workflows/dev_pr/ai-check.js create mode 100644 .github/workflows/pr-trigger.yml diff --git a/.github/workflows/ai_disclosure.yml b/.github/workflows/ai_disclosure.yml index 230ce152f2c2..7808f03765cf 100644 --- a/.github/workflows/ai_disclosure.yml +++ b/.github/workflows/ai_disclosure.yml @@ -15,19 +15,14 @@ # specific language governing permissions and limitations # under the License. -name: AI Disclosure +name: AI disclosure enforcement on: - pull_request_target: + workflow_run: + workflows: + - PR trigger types: - - opened - - edited - - reopened - - ready_for_review - -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.number }} - cancel-in-progress: true + - completed permissions: contents: write @@ -35,21 +30,25 @@ permissions: issues: write jobs: - check: - name: Check + enforce: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest + steps: - # Always run the script from main, never from the PR head. - - uses: actions/checkout@v7 + # Check out trusted code from the default branch, not the PR branch. + - name: Checkout repository + uses: actions/checkout@v4 with: - repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - name: Check AI disclosure - uses: actions/github-script@v9 + uses: actions/github-script@v7 with: - github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const script = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/ai_disclosure/check.js`); - await script({github, context, core}); + const checkDisclosure = require( + "./.github/workflows/dev_pr/ai-check.js" + ); + + await checkDisclosure({github, context, core}); diff --git a/.github/workflows/ai_disclosure/check.js b/.github/workflows/ai_disclosure/check.js deleted file mode 100644 index 6d7ab4ac098d..000000000000 --- a/.github/workflows/ai_disclosure/check.js +++ /dev/null @@ -1,203 +0,0 @@ -// 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 fs = require("fs"); - -const AUTHORSHIP_HEADING = "PR code and description written by:"; -const REVIEW_HEADING = "Reviewed before submission by:"; - -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) { - if (/^\s*-\s+\[/.test(line)) return null; - continue; - } - - const option = match[2]; - - 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 === 0 || - (selected.includes("Not reviewed") && selected.length > 1) - ) { - return null; - } - - return selected; -} - -function normalizeHeading(line) { - return line.trim().replace(/^\*\*(.*?)\*\*$/, "$1"); -} - -function isHeading(line) { - const trimmed = line.trim(); - return /^#{1,6}\s/.test(trimmed) || /^\*\*.+\*\*$/.test(trimmed); -} - -function parseDisclosure(body) { - const lines = body.split(/\r?\n/); - const headings = lines.map(normalizeHeading); - const authorshipStart = headings.indexOf(AUTHORSHIP_HEADING); - const reviewStart = headings.indexOf(REVIEW_HEADING); - - if ( - authorshipStart === -1 || - reviewStart <= authorshipStart || - headings.lastIndexOf(AUTHORSHIP_HEADING) !== authorshipStart || - headings.lastIndexOf(REVIEW_HEADING) !== reviewStart - ) { - return {status: "incomplete"}; - } - - const reviewEnd = lines.findIndex( - (line, index) => index > reviewStart && isHeading(line) - ); - - 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 {status: "incomplete"}; - } - - if (review.includes("Human")) { - return {status: "complete"}; - } - - if (authorship.length === 1 && authorship[0] === "AI") { - return {status: "ai-unreviewed"}; - } - - return {status: "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 - }); -} - -/** - * Converts the pull request to Draft unless it is already a draft. - * - * @param {Object} github - * @param {Object} context - */ -async function convertToDraft(github, context) { - if (context.payload.pull_request.draft) return; - - await github.graphql( - ` - mutation ($pullRequestId: ID!) { - convertPullRequestToDraft( - input: {pullRequestId: $pullRequestId} - ) { - pullRequest { - id - } - } - } - `, - {pullRequestId: context.payload.pull_request.node_id} - ); -} - -module.exports = async ({github, context, core}) => { - const pullRequest = context.payload.pull_request; - const pullRequestNumber = pullRequest.number; - - if ( - pullRequest.state !== "open" || - (pullRequest.draft && context.payload.action !== "ready_for_review") || - pullRequest.user?.type === "Bot" - ) { - core.info(`Skipping #${pullRequestNumber}: not an eligible pull request.`); - return; - } - - const result = parseDisclosure(pullRequest.body || ""); - - core.info(`Disclosure status for #${pullRequestNumber}: ${result.status}.`); - - if (result.status === "complete") { - return; - } - - if (result.status === "ai-unreviewed") { - await createDisclosureComment( - github, - context, - pullRequestNumber, - `This pull request has been automatically closed because its authorship is AI-only and it has no human review. - Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review.` - ); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - state: "closed" - }); - - core.info(`Closed #${pullRequestNumber}: AI-only authorship without human review.`); - return; - } - - await createDisclosureComment( - github, - context, - pullRequestNumber, - result.status === "incomplete" - ? fs.readFileSync(`${__dirname}/incomplete.md`, "utf8") - : `This pull request has been converted to Draft because it has not been reviewed by a human. Please review your contribution - yourself before marking this pull request as ready for review. - \nOnce you have completed your review, update the review disclosure section and mark this pull request as Ready for review.` - ); - - await convertToDraft(github, context); -}; diff --git a/.github/workflows/ai_disclosure/incomplete.md b/.github/workflows/ai_disclosure/incomplete.md deleted file mode 100644 index d39b1c065c82..000000000000 --- a/.github/workflows/ai_disclosure/incomplete.md +++ /dev/null @@ -1,43 +0,0 @@ - - - - -The AI disclosure in this pull request is missing or incomplete. - -Please complete both disclosure sections in the pull request description by selecting the applicable checkboxes. - -If the disclosure is not present, add the following to your pull request description: - -```markdown -**PR code and description written by:** - -- [ ] Human -- [ ] AI - -**Reviewed before submission by:** - -- [ ] Human -- [ ] AI -- [ ] Not reviewed -``` - -Once you have completed the disclosure, please manually mark this pull request as **Ready for review**. - -This pull request has been converted to Draft until the disclosure is completed. diff --git a/.github/workflows/dev_pr/ai-check.js b/.github/workflows/dev_pr/ai-check.js new file mode 100644 index 000000000000..f4e2b7284144 --- /dev/null +++ b/.github/workflows/dev_pr/ai-check.js @@ -0,0 +1,221 @@ +// 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"; + +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 + }); +} + +/** + * Converts the pull request to Draft unless it is already a draft. + */ +async function convertToDraft(github, pullRequest) { + if (pullRequest.draft) return; + + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: {pullRequestId: $id}) { + pullRequest { + id + } + } + }`, + {id: pullRequest.node_id} + ); +} + +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; + } + + // workflow_run may not contain associated PR details for fork PRs. + // Fall back to looking up PRs by their source repository and branch. + const candidates = run.pull_requests?.length + ? run.pull_requests + : 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 closed PRs, 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 messages = { + incomplete: INCOMPLETE_COMMENT, + + "ai-only": `This pull request has been automatically closed because its authorship is AI-only and it has no human review. + +Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review.`, + + "needs-human-review": `This pull request has been converted to Draft because it has not been reviewed by a human. + +Please review your contribution, update the review disclosure section, and mark the pull request as Ready for review.` + }; + + await createDisclosureComment( + github, context, pr.number, messages[status] + ); + + // 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 { + await convertToDraft(github, pr); + } + } +}; + +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." From 93165c4f0c4ee726d1154ffc54290aacc51e0896 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Tue, 10 Nov 2026 15:55:49 +0530 Subject: [PATCH 8/9] Update Messages --- .github/workflows/dev_pr/ai-check.js | 54 ++++++++++++++-------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/dev_pr/ai-check.js b/.github/workflows/dev_pr/ai-check.js index f4e2b7284144..ff227145bafe 100644 --- a/.github/workflows/dev_pr/ai-check.js +++ b/.github/workflows/dev_pr/ai-check.js @@ -23,6 +23,15 @@ const INCOMPLETE_COMMENT = "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 = []; @@ -148,20 +157,17 @@ module.exports = async ({github, context, core}) => { return; } - // workflow_run may not contain associated PR details for fork PRs. - // Fall back to looking up PRs by their source repository and branch. - const candidates = run.pull_requests?.length - ? run.pull_requests - : 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 - } - ); + // 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({ @@ -170,7 +176,7 @@ module.exports = async ({github, context, core}) => { pull_number: candidate.number }); - // Ignore closed PRs, outdated runs, and bot-authored PRs. + // Ignore outdated runs and bot-authored PRs. if ( pr.state !== "open" || pr.head.sha !== run.head_sha || @@ -187,20 +193,14 @@ module.exports = async ({github, context, core}) => { // A valid disclosure requires no action. if (status === "complete") continue; - const messages = { - incomplete: INCOMPLETE_COMMENT, - - "ai-only": `This pull request has been automatically closed because its authorship is AI-only and it has no human review. - -Contributions involving AI assistance are welcome. However, we require human involvement in the contribution and its review.`, - - "needs-human-review": `This pull request has been converted to Draft because it has not been reviewed by a human. - -Please review your contribution, update the review disclosure section, and mark the pull request as Ready for review.` - }; + const comment = { + incomplete: INCOMPLETE_COMMENT, + "ai-only": AI_ONLY_COMMENT, + "needs-human-review": NEEDS_HUMAN_REVIEW_COMMENT + }[status]; await createDisclosureComment( - github, context, pr.number, messages[status] + github, context, pr.number, comment ); // Close missing/incomplete disclosures and AI-only contributions From 1acb09e9c59a4e2f98a7bd6ffdd40c81354df848 Mon Sep 17 00:00:00 2001 From: Aaditya Srinivasan Date: Tue, 10 Nov 2026 16:05:39 +0530 Subject: [PATCH 9/9] Use convertPullRequestToDraft --- .github/workflows/dev_pr/ai-check.js | 31 ++++++++++------------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/.github/workflows/dev_pr/ai-check.js b/.github/workflows/dev_pr/ai-check.js index ff227145bafe..c2e4fd3d5ff5 100644 --- a/.github/workflows/dev_pr/ai-check.js +++ b/.github/workflows/dev_pr/ai-check.js @@ -124,24 +124,6 @@ async function createDisclosureComment( }); } -/** - * Converts the pull request to Draft unless it is already a draft. - */ -async function convertToDraft(github, pullRequest) { - if (pullRequest.draft) return; - - await github.graphql( - `mutation($id: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $id}) { - pullRequest { - id - } - } - }`, - {id: pullRequest.node_id} - ); -} - module.exports = async ({github, context, core}) => { const run = context.payload.workflow_run; const head = run.head_repository; @@ -212,8 +194,17 @@ module.exports = async ({github, context, core}) => { pull_number: pr.number, state: "closed" }); - } else { - await convertToDraft(github, pr); + } else if (!pr.draft) { + await github.graphql( + `mutation($id: ID!) { + convertPullRequestToDraft(input: {pullRequestId: $id}) { + pullRequest { + id + } + } + }`, + {id: pr.node_id} + ); } } };