Skip to content

Commit b8aeb63

Browse files
feat(ci): Add workflow to label ai generated pull requests (#10804)
feat(ci): Add workflow to label ai generated pull requests Co-authored-by: bruce.bujon <bruce.bujon@datadoghq.com>
1 parent 3745249 commit b8aeb63

File tree

2 files changed

+125
-4
lines changed

2 files changed

+125
-4
lines changed

.github/workflows/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ _Recovery:_ Manually verify the guideline compliance.
3030

3131
### check-pull-request-labels [🔗](check-pull-request-labels.yaml)
3232

33-
_Trigger:_ When creating or updating a pull request.
33+
_Trigger:_ When creating or updating a pull request, or when new commits are pushed to it.
34+
35+
_Actions:_
3436

35-
_Action:_ Check the pull request did not introduce unexpected label.
37+
* Detect AI-generated pull requests then apply the `tag: ai generated` label.
38+
* Check the pull request did not introduce unexpected labels.
3639

3740
_Recovery:_ Update the pull request or add a comment to trigger the action again.
3841

.github/workflows/check-pull-request-labels.yaml

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Validate PR Label Format
22
on:
33
pull_request:
4-
types: [opened, edited, ready_for_review, labeled]
4+
types: [opened, edited, ready_for_review, labeled, synchronize]
55

66
concurrency:
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

Comments
 (0)