11name : Validate PR Label Format
22on :
33 pull_request :
4- types : [opened, edited, ready_for_review, labeled]
4+ types : [opened, edited, ready_for_review, labeled, synchronize ]
55
66concurrency :
77 group : ${{ github.workflow }}-${{ github.ref }}
@@ -15,8 +15,114 @@ jobs:
1515 pull-requests : write
1616 runs-on : ubuntu-latest
1717 steps :
18+ - name : Flag AI-generated pull requests
19+ id : flag_ai_generated
20+ uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # 8.0.0
21+ with :
22+ github-token : ${{ secrets.GITHUB_TOKEN }}
23+ script : |
24+ // Skip draft pull requests
25+ if (context.payload.pull_request.draft) {
26+ return
27+ }
28+ const prNumber = context.payload.pull_request.number
29+ const owner = context.repo.owner
30+ const repo = context.repo.repo
31+ const aiGeneratedLabel = 'tag: ai generated'
32+ let isAiGenerated = false
33+ let labelsStale = false
34+
35+ /*
36+ * Check for 'Bits AI' label and remove it.
37+ */
38+ const bitsAiLabel = 'Bits AI'
39+ const prLabels = context.payload.pull_request.labels.map(l => l.name)
40+ if (prLabels.includes(bitsAiLabel)) {
41+ isAiGenerated = true
42+ // Remove label from the PR
43+ try {
44+ await github.rest.issues.removeLabel({
45+ owner, repo,
46+ issue_number: prNumber,
47+ name: bitsAiLabel
48+ })
49+ } catch (e) {
50+ core.warning(`Could not remove '${bitsAiLabel}' label from PR: ${e.message}`)
51+ }
52+ labelsStale = true
53+ // Delete label from the repository
54+ try {
55+ await github.rest.issues.deleteLabel({ owner, repo, name: bitsAiLabel })
56+ } catch (e) {
57+ core.warning(`Could not delete '${bitsAiLabel}' label from repo: ${e.message}`)
58+ }
59+ }
60+
61+ /*
62+ * Inspect commits for AI authorship signals.
63+ */
64+ if (context.payload.pull_request.labels.some(l => l.name === aiGeneratedLabel)) {
65+ core.info(`PR #${prNumber} is already labeled as AI-generated, skipping commit scan.`)
66+ core.setOutput('labels_stale', String(labelsStale))
67+ return
68+ }
69+ const aiRegex = /\b(anthropic|chatgpt|codex|copilot|cursor|openai)\b/i
70+ const commits = await github.paginate(github.rest.pulls.listCommits, {
71+ owner, repo,
72+ pull_number: prNumber,
73+ per_page: 100
74+ })
75+ for (const { commit } of commits) {
76+ const authorName = commit.author?.name ?? ''
77+ const authorEmail = commit.author?.email ?? ''
78+ const committerName = commit.committer?.name ?? ''
79+ const committerEmail = commit.committer?.email ?? ''
80+ // Extract Co-authored-by trailer lines from commit message
81+ const coAuthors = (commit.message ?? '').split('\n')
82+ .filter(line => /^co-authored-by:/i.test(line.trim()))
83+ const fieldsToCheck = [authorName, authorEmail]
84+ // Skip GitHub's generic noreply for committer
85+ if (committerEmail !== 'noreply@github.com') {
86+ fieldsToCheck.push(committerName, committerEmail)
87+ }
88+ fieldsToCheck.push(...coAuthors)
89+ if (fieldsToCheck.some(field => aiRegex.test(field))) {
90+ isAiGenerated = true
91+ break
92+ }
93+ }
94+
95+ /*
96+ * Add 'tag: ai generated' label if AI-generated.
97+ */
98+ if (isAiGenerated) {
99+ // Re-fetch labels only if they were modified above (Bits AI removal)
100+ let currentLabels
101+ if (labelsStale) {
102+ const { data: currentPr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber })
103+ currentLabels = currentPr.labels.map(l => l.name)
104+ } else {
105+ currentLabels = context.payload.pull_request.labels.map(l => l.name)
106+ }
107+ if (!currentLabels.includes(aiGeneratedLabel)) {
108+ try {
109+ await github.rest.issues.addLabels({
110+ owner, repo,
111+ issue_number: prNumber,
112+ labels: [aiGeneratedLabel]
113+ })
114+ core.info(`Added '${aiGeneratedLabel}' label to PR #${prNumber}`)
115+ } catch (e) {
116+ core.setFailed(`Could not add '${aiGeneratedLabel}' label to PR #${prNumber}: ${e.message}`)
117+ }
118+ }
119+ }
120+ core.setOutput('labels_stale', String(labelsStale))
121+
18122 - name : Check pull request labels
19123 uses : actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # 8.0.0
124+ env :
125+ LABELS_STALE : ${{ steps.flag_ai_generated.outputs.labels_stale }}
20126 with :
21127 github-token : ${{ secrets.GITHUB_TOKEN }}
22128 script : |
@@ -35,8 +141,20 @@ jobs:
35141 'performance:', // To refactor to 'ci: ' in the future
36142 'run-tests:' // Unused since GitLab migration
37143 ]
144+ // Re-fetch labels only if the previous step modified them (ex: "Bits AI" removal)
145+ let prLabels
146+ if (process.env.LABELS_STALE === 'true') {
147+ const { data: currentPr } = await github.rest.pulls.get({
148+ owner: context.repo.owner,
149+ repo: context.repo.repo,
150+ pull_number: context.payload.pull_request.number
151+ })
152+ prLabels = currentPr.labels
153+ } else {
154+ prLabels = context.payload.pull_request.labels
155+ }
38156 // Look for invalid labels
39- const invalidLabels = context.payload.pull_request.labels
157+ const invalidLabels = prLabels
40158 .map(label => label.name)
41159 .filter(label => validCategories.every(prefix => !label.startsWith(prefix)))
42160 const hasInvalidLabels = invalidLabels.length > 0
0 commit comments