Skip to content

Commit 45ef125

Browse files
committed
Add GitHub action to have AI give an initial triage comment to new GitHub issues.
1 parent 6afa062 commit 45ef125

File tree

1 file changed

+152
-0
lines changed

1 file changed

+152
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
2+
name: AI auto-reply to new issues
3+
4+
on:
5+
issues:
6+
types: [opened]
7+
8+
permissions:
9+
issues: write
10+
contents: read
11+
12+
env:
13+
# ---- Behavior toggles ----
14+
REQUIRED_LABEL: "" # e.g., "needs-triage" to only reply when present
15+
BLOCK_LABELS: "security,vulnerability,no-ai,do-not-reply"
16+
DRY_RUN: "false"
17+
MODELS_MODEL: "openai/gpt-5"
18+
19+
jobs:
20+
ai_autoreply:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Decide whether to reply
27+
id: gate
28+
uses: actions/github-script@v7
29+
with:
30+
script: |
31+
const core = require('@actions/core');
32+
const issue = context.payload.issue;
33+
const owner = context.repo.owner;
34+
const repo = context.repo.repo;
35+
36+
// PR safety: only issues
37+
if (issue.pull_request) { core.setOutput('should','false'); return; }
38+
39+
// Skip issues from maintainers (owners/members/collaborators)
40+
const assoc = issue.author_association || 'NONE';
41+
if (['OWNER','MEMBER','COLLABORATOR'].includes(assoc)) {
42+
core.setOutput('should', 'false'); return;
43+
}
44+
45+
// Label checks
46+
const labels = (issue.labels || []).map(l => (typeof l === 'string' ? l : l.name).toLowerCase());
47+
const required = (process.env.REQUIRED_LABEL || '').trim().toLowerCase();
48+
if (required && !labels.includes(required)) {
49+
core.setOutput('should','false'); return;
50+
}
51+
const block = (process.env.BLOCK_LABELS || '').split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);
52+
if (labels.some(l => block.includes(l))) {
53+
core.setOutput('should','false'); return;
54+
}
55+
56+
// Skip if maintainer already commented or we already AI-replied
57+
const comments = await github.rest.issues.listComments({ owner, repo, issue_number: issue.number, per_page: 50 });
58+
if (comments.data.find(c => ['OWNER','MEMBER','COLLABORATOR'].includes(c.author_association))) {
59+
core.setOutput('should','false'); return;
60+
}
61+
const priorAI = comments.data.find(c =>
62+
(c.user?.login === 'github-actions[bot]' || c.user?.type === 'Bot') &&
63+
(c.body || '').includes('<!-- AI-autoreply -->')
64+
);
65+
if (priorAI || labels.includes('AI-replied')) {
66+
core.setOutput('should','false'); return;
67+
}
68+
69+
core.setOutput('should','true');
70+
71+
- name: Generate AI reply (GitHub Models)
72+
id: ai
73+
if: steps.gate.outputs.should == 'true'
74+
env:
75+
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
76+
GH_MODELS_MODEL: ${{ env.MODELS_MODEL }}
77+
run: |
78+
set -euo pipefail
79+
80+
ISSUE_URL="${{ github.event.issue.html_url }}"
81+
ISSUE_TITLE="${{ github.event.issue.title }}"
82+
ISSUE_BODY="${{ github.event.issue.body || '' }}"
83+
REPO="${{ github.repository }}"
84+
85+
# System & user prompts tuned for triage; keep it short and safe.
86+
SYSTEM_PROMPT=$'You are a cautious, concise triage assistant for '"$REPO"$'.\\
87+
Goals: Greet the user; summarize in 1-2 sentences; \\
88+
use communication guidelines defined in: https://raw.githubusercontent.com/AzureAD/microsoft-authentication-library-for-objc/dev/.clinerules/06-Customer-communication-guidelines.md'
89+
90+
USER_PROMPT=$'New issue at: '"$ISSUE_URL"$'\\nTitle: '"$ISSUE_TITLE"$'\\n\\nBody:\\n'"$ISSUE_BODY"$
91+
92+
REQ=$(jq -n --arg m "$GH_MODELS_MODEL" \
93+
--arg sys "$SYSTEM_PROMPT" \
94+
--arg user "$USER_PROMPT" \
95+
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$user}], temperature:0.4}')
96+
97+
# Call GitHub Models Chat Completions API
98+
RESP=$(curl -sSL -X POST \
99+
-H "Accept: application/vnd.github+json" \
100+
-H "Authorization: Bearer $GH_MODELS_TOKEN" \
101+
-H "X-GitHub-Api-Version: 2022-11-28" \
102+
-H "Content-Type: application/json" \
103+
https://models.github.ai/inference/chat/completions \
104+
-d "$REQ")
105+
106+
# Extract text
107+
AI=$(printf '%s' "$RESP" | jq -r '.choices[0].message.content // ""')
108+
109+
# Set multi-line output for next step
110+
{
111+
echo "text<<'EOF'"
112+
printf '%s\n' "$AI"
113+
echo "EOF"
114+
} >> "$GITHUB_OUTPUT"
115+
116+
- name: Post reply
117+
if: steps.gate.outputs.should == 'true'
118+
uses: actions/github-script@v7
119+
env:
120+
DRY_RUN: ${{ env.DRY_RUN }}
121+
with:
122+
script: |
123+
const core = require('@actions/core');
124+
const body = core.getInput('text') || `${{ steps.ai.outputs.text }}` || '';
125+
const finalBody = `${body.trim()}
126+
127+
<!-- AI-autoreply -->
128+
_This comment was generated by an automated assistant to help with initial triage. A maintainer will follow up as needed._`.trim();
129+
130+
if ((process.env.DRY_RUN || '').toLowerCase() === 'true') {
131+
core.info('DRY_RUN=true — would have posted:\n' + finalBody);
132+
return;
133+
}
134+
135+
await github.rest.issues.createComment({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
issue_number: context.payload.issue.number,
139+
body: finalBody
140+
});
141+
142+
// Try to add a label to prevent duplicate replies
143+
try {
144+
await github.rest.issues.addLabels({
145+
owner: context.repo.owner,
146+
repo: context.repo.repo,
147+
issue_number: context.payload.issue.number,
148+
labels: ['AI-replied']
149+
});
150+
} catch (e) {
151+
core.warning('Could not add AI-replied label (create it first).');
152+
}

0 commit comments

Comments
 (0)