Skip to content

Commit 2199a59

Browse files
authored
feat: add feedback loop (#76) (#85)
1 parent 5dcc52b commit 2199a59

16 files changed

Lines changed: 585 additions & 269 deletions

File tree

.github/scripts/validate.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,15 @@ import { readFileSync, existsSync } from 'fs';
44

55
const schema = z.object({
66
confidence: z.number().min(0).max(1).optional(),
7-
theme: z.string().optional(),
8-
themes: z.record(z.string(), z.record(z.string(), z.string())).optional(),
9-
labels: z.record(z.string(), z.string()).optional(),
10-
rules: z
7+
model: z.string().optional(),
8+
examples: z
119
.array(
1210
z.object({
13-
match: z.string(),
14-
add: z.array(z.string()),
11+
title: z.string(),
12+
labels: z.array(z.string()),
1513
}),
1614
)
1715
.optional(),
18-
reactions: z
19-
.object({
20-
start: z.string().optional(),
21-
complete: z.string().optional(),
22-
})
23-
.optional(),
24-
ignore: z
25-
.object({
26-
users: z.array(z.string()).optional(),
27-
labels: z.array(z.string()).optional(),
28-
})
29-
.optional(),
30-
duplicates: z
31-
.object({
32-
enabled: z.boolean().optional(),
33-
threshold: z.number().min(0).max(1).optional(),
34-
label: z.string().optional(),
35-
comment: z.boolean().optional(),
36-
close: z.boolean().optional(),
37-
})
38-
.optional(),
39-
autorespond: z
40-
.object({
41-
enabled: z.boolean().optional(),
42-
label: z.string().optional(),
43-
context: z.string().optional(),
44-
requirements: z.record(z.string(), z.array(z.string())).optional(),
45-
message: z.string().optional(),
46-
})
47-
.optional(),
4816
});
4917

5018
const configpath = '.github/tigent.yml';

.github/tigent.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ labels:
1919
help-wanted: muted
2020

2121
rules:
22-
- match: "crash|broken|not working"
22+
- match: 'crash|broken|not working'
2323
add: [bug, p1]
24-
- match: "security|vulnerability|cve"
24+
- match: 'security|vulnerability|cve'
2525
add: [security, p0]
26-
- match: "docs|readme|typo"
26+
- match: 'docs|readme|typo'
2727
add: [documentation]
28-
- match: "config|yaml|yml"
28+
- match: 'config|yaml|yml'
2929
add: [config]
30-
- match: "webhook|payload"
30+
- match: 'webhook|payload'
3131
add: [webhook]
3232

3333
reactions:

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pnpm prettier-fix && git add -u

app/api/webhook/feedback.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { Gh, Config } from './triage';
2+
import { classify, fetchlabels, addlabels } from './triage';
3+
import { createpr } from './learn';
4+
5+
const allowed = ['OWNER', 'MEMBER', 'COLLABORATOR'];
6+
7+
export async function handlecomment(gh: Gh, config: Config, payload: any) {
8+
const comment = payload.comment;
9+
const body: string = comment.body?.trim() || '';
10+
const association: string = comment.author_association || '';
11+
12+
if (!allowed.includes(association)) return;
13+
if (!body.toLowerCase().startsWith('@tigent')) return;
14+
15+
const command = body.slice(7).trim().toLowerCase();
16+
const issue = payload.issue;
17+
18+
if (command === 'why') {
19+
await handlewhy(gh, config, issue, comment.id);
20+
} else if (command.startsWith('wrong')) {
21+
const rest = body.slice(7).trim().slice(5).trim();
22+
const labels = parselabels(rest);
23+
if (labels.length > 0) {
24+
await handlewrong(gh, config, issue, comment.id, labels);
25+
}
26+
}
27+
}
28+
29+
async function handlewhy(
30+
gh: Gh,
31+
config: Config,
32+
issue: any,
33+
commentid: number,
34+
) {
35+
await reactcomment(gh, commentid);
36+
37+
const labels = await fetchlabels(gh);
38+
const result = await classify(config, labels, issue.title, issue.body || '');
39+
40+
const labelstr = result.labels.join(', ');
41+
const body = `**labels:** ${labelstr}\n**confidence:** ${result.confidence}\n\n${result.reasoning}`;
42+
43+
await gh.octokit.rest.issues.createComment({
44+
owner: gh.owner,
45+
repo: gh.repo,
46+
issue_number: issue.number,
47+
body,
48+
});
49+
}
50+
51+
async function handlewrong(
52+
gh: Gh,
53+
config: Config,
54+
issue: any,
55+
commentid: number,
56+
correctlabels: string[],
57+
) {
58+
await reactcomment(gh, commentid);
59+
60+
const [repolabels, current] = await Promise.all([
61+
fetchlabels(gh),
62+
gh.octokit.rest.issues.listLabelsOnIssue({
63+
owner: gh.owner,
64+
repo: gh.repo,
65+
issue_number: issue.number,
66+
}),
67+
]);
68+
69+
const result = await classify(
70+
config,
71+
repolabels,
72+
issue.title,
73+
issue.body || '',
74+
);
75+
76+
const ailabels = result.labels;
77+
const existing = current.data.map(l => l.name);
78+
79+
for (const label of ailabels) {
80+
if (existing.includes(label)) {
81+
await gh.octokit.rest.issues.removeLabel({
82+
owner: gh.owner,
83+
repo: gh.repo,
84+
issue_number: issue.number,
85+
name: label,
86+
});
87+
}
88+
}
89+
90+
const validcorrect = correctlabels.filter(l =>
91+
repolabels.some(x => x.name.toLowerCase() === l.toLowerCase()),
92+
);
93+
const matchedlabels = validcorrect.map(l => {
94+
const match = repolabels.find(
95+
x => x.name.toLowerCase() === l.toLowerCase(),
96+
);
97+
return match!.name;
98+
});
99+
100+
if (matchedlabels.length > 0) {
101+
await addlabels(gh, issue.number, matchedlabels);
102+
}
103+
104+
await createpr(gh, issue.number, issue.title, matchedlabels);
105+
}
106+
107+
function parselabels(input: string): string[] {
108+
const cleaned = input
109+
.replace(/^,/, '')
110+
.replace(/^should be/i, '')
111+
.trim();
112+
if (!cleaned) return [];
113+
return cleaned
114+
.split(',')
115+
.map(s => s.trim())
116+
.filter(Boolean);
117+
}
118+
119+
async function reactcomment(gh: Gh, commentid: number) {
120+
await gh.octokit.rest.reactions.createForIssueComment({
121+
owner: gh.owner,
122+
repo: gh.repo,
123+
comment_id: commentid,
124+
content: 'eyes',
125+
});
126+
}

app/api/webhook/learn.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Octokit } from 'octokit';
2+
import { parse, stringify } from 'yaml';
3+
import type { Gh, Config } from './triage';
4+
5+
const dancer = process.env.DANCER_PAT
6+
? new Octokit({ auth: process.env.DANCER_PAT })
7+
: null;
8+
9+
export async function createpr(
10+
gh: Gh,
11+
issue: number,
12+
title: string,
13+
labels: string[],
14+
) {
15+
if (!dancer) return;
16+
17+
const { data: repo } = await dancer.rest.repos.get({
18+
owner: gh.owner,
19+
repo: gh.repo,
20+
});
21+
const branch = `tigent/learn-${issue}`;
22+
const defaultbranch = repo.default_branch;
23+
24+
const { data: ref } = await dancer.rest.git.getRef({
25+
owner: gh.owner,
26+
repo: gh.repo,
27+
ref: `heads/${defaultbranch}`,
28+
});
29+
const sha = ref.object.sha;
30+
31+
await dancer.rest.git.createRef({
32+
owner: gh.owner,
33+
repo: gh.repo,
34+
ref: `refs/heads/${branch}`,
35+
sha,
36+
});
37+
38+
let config: Partial<Config> = {};
39+
let filesha: string | undefined;
40+
41+
try {
42+
const { data } = await dancer.rest.repos.getContent({
43+
owner: gh.owner,
44+
repo: gh.repo,
45+
path: '.github/tigent.yml',
46+
ref: defaultbranch,
47+
});
48+
if ('content' in data) {
49+
const content = Buffer.from(data.content, 'base64').toString();
50+
config = (parse(content) as Partial<Config>) || {};
51+
filesha = data.sha;
52+
}
53+
} catch {}
54+
55+
if (!config.examples) config.examples = [];
56+
config.examples.push({ title, labels });
57+
58+
const yaml = stringify(config);
59+
60+
await dancer.rest.repos.createOrUpdateFileContents({
61+
owner: gh.owner,
62+
repo: gh.repo,
63+
path: '.github/tigent.yml',
64+
message: `fix: add learning example from #${issue}`,
65+
content: Buffer.from(yaml).toString('base64'),
66+
branch,
67+
...(filesha ? { sha: filesha } : {}),
68+
});
69+
70+
await dancer.rest.pulls.create({
71+
owner: gh.owner,
72+
repo: gh.repo,
73+
title: `fix: learn from #${issue} correction`,
74+
body: `adds example to \`.github/tigent.yml\` from issue #${issue} correction.\n\n\`\`\`yaml\n- title: "${title}"\n labels: [${labels.join(', ')}]\n\`\`\``,
75+
head: branch,
76+
base: defaultbranch,
77+
});
78+
}

0 commit comments

Comments
 (0)