From babb4c25860d7c01bfa957a3a9c7124d240fffb8 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 9 Feb 2026 08:34:13 +0000 Subject: [PATCH 1/3] feat: add feedback loop for labeling corrections --- .github/scripts/validate.ts | 94 +++++++------------- app/api/webhook/feedback.ts | 130 +++++++++++++++++++++++++++ app/api/webhook/learn.ts | 76 ++++++++++++++++ app/api/webhook/route.ts | 171 +++--------------------------------- app/api/webhook/triage.ts | 163 ++++++++++++++++++++++++++++++++++ app/docs/config.ts | 1 + app/docs/config/page.tsx | 33 ++++++- app/docs/feedback/page.tsx | 70 +++++++++++++++ 8 files changed, 512 insertions(+), 226 deletions(-) create mode 100644 app/api/webhook/feedback.ts create mode 100644 app/api/webhook/learn.ts create mode 100644 app/api/webhook/triage.ts create mode 100644 app/docs/feedback/page.tsx diff --git a/.github/scripts/validate.ts b/.github/scripts/validate.ts index 010a6af..39baa9b 100644 --- a/.github/scripts/validate.ts +++ b/.github/scripts/validate.ts @@ -1,78 +1,46 @@ -import { parse } from 'yaml'; -import { z } from 'zod'; -import { readFileSync, existsSync } from 'fs'; +import { parse } from "yaml" +import { z } from "zod" +import { readFileSync, existsSync } from "fs" const schema = z.object({ - confidence: z.number().min(0).max(1).optional(), - theme: z.string().optional(), - themes: z.record(z.string(), z.record(z.string(), z.string())).optional(), - labels: z.record(z.string(), z.string()).optional(), - rules: z - .array( - z.object({ - match: z.string(), - add: z.array(z.string()), - }), - ) - .optional(), - reactions: z - .object({ - start: z.string().optional(), - complete: z.string().optional(), - }) - .optional(), - ignore: z - .object({ - users: z.array(z.string()).optional(), - labels: z.array(z.string()).optional(), - }) - .optional(), - duplicates: z - .object({ - enabled: z.boolean().optional(), - threshold: z.number().min(0).max(1).optional(), - label: z.string().optional(), - comment: z.boolean().optional(), - close: z.boolean().optional(), - }) - .optional(), - autorespond: z - .object({ - enabled: z.boolean().optional(), - label: z.string().optional(), - context: z.string().optional(), - requirements: z.record(z.string(), z.array(z.string())).optional(), - message: z.string().optional(), - }) - .optional(), -}); - -const configpath = '.github/tigent.yml'; + confidence: z.number().min(0).max(1).optional(), + model: z.string().optional(), + examples: z + .array( + z.object({ + title: z.string(), + labels: z.array(z.string()), + }), + ) + .optional(), +}) + +const configpath = ".github/tigent.yml" if (!existsSync(configpath)) { - console.log('no config file found, skipping validation'); - process.exit(0); + console.log("no config file found, skipping validation") + process.exit(0) } -const content = readFileSync(configpath, 'utf-8'); +const content = readFileSync(configpath, "utf-8") -let parsed: unknown; +let parsed: unknown try { - parsed = parse(content); + parsed = parse(content) } catch (err) { - console.error('invalid yaml syntax'); - console.error(err); - process.exit(1); + console.error("invalid yaml syntax") + console.error(err) + process.exit(1) } -const result = schema.safeParse(parsed); +const result = schema.safeParse(parsed) if (!result.success) { - console.error('config validation failed:'); - for (const issue of result.error.issues) { - console.error(` - ${issue.path.join('.')}: ${issue.message}`); - } - process.exit(1); + console.error("config validation failed:") + for (const issue of result.error.issues) { + console.error(` - ${issue.path.join(".")}: ${issue.message}`) + } + process.exit(1) } -console.log('config is valid'); +console.log("config is valid") diff --git a/app/api/webhook/feedback.ts b/app/api/webhook/feedback.ts new file mode 100644 index 0000000..9918749 --- /dev/null +++ b/app/api/webhook/feedback.ts @@ -0,0 +1,130 @@ +import type { Gh, Config } from "./triage" +import { classify, fetchlabels, addlabels } from "./triage" +import { createpr } from "./learn" + +const allowed = ["OWNER", "MEMBER", "COLLABORATOR"] + +export async function handlecomment( + gh: Gh, + config: Config, + payload: any, +) { + const comment = payload.comment + const body: string = comment.body?.trim() || "" + const association: string = comment.author_association || "" + + if (!allowed.includes(association)) return + if (!body.toLowerCase().startsWith("@tigent")) return + + const command = body.slice(7).trim().toLowerCase() + const issue = payload.issue + + if (command === "why") { + await handlewhy(gh, config, issue, comment.id) + } else if (command.startsWith("wrong")) { + const rest = body.slice(7).trim().slice(5).trim() + const labels = parselabels(rest) + if (labels.length > 0) { + await handlewrong(gh, config, issue, comment.id, labels) + } + } +} + +async function handlewhy( + gh: Gh, + config: Config, + issue: any, + commentid: number, +) { + await reactcomment(gh, commentid) + + const labels = await fetchlabels(gh) + const result = await classify( + config, + labels, + issue.title, + issue.body || "", + ) + + const labelstr = result.labels.join(", ") + const body = `**labels:** ${labelstr}\n**confidence:** ${result.confidence}\n\n${result.reasoning}` + + await gh.octokit.rest.issues.createComment({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + body, + }) +} + +async function handlewrong( + gh: Gh, + config: Config, + issue: any, + commentid: number, + correctlabels: string[], +) { + await reactcomment(gh, commentid) + + const [repolabels, current] = await Promise.all([ + fetchlabels(gh), + gh.octokit.rest.issues.listLabelsOnIssue({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + }), + ]) + + const result = await classify( + config, + repolabels, + issue.title, + issue.body || "", + ) + + const ailabels = result.labels + const existing = current.data.map((l) => l.name) + + for (const label of ailabels) { + if (existing.includes(label)) { + await gh.octokit.rest.issues.removeLabel({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + name: label, + }) + } + } + + const validcorrect = correctlabels.filter((l) => + repolabels.some((x) => x.name.toLowerCase() === l.toLowerCase()), + ) + const matchedlabels = validcorrect.map((l) => { + const match = repolabels.find((x) => x.name.toLowerCase() === l.toLowerCase()) + return match!.name + }) + + if (matchedlabels.length > 0) { + await addlabels(gh, issue.number, matchedlabels) + } + + await createpr(gh, issue.number, issue.title, matchedlabels) +} + +function parselabels(input: string): string[] { + const cleaned = input.replace(/^,/, "").replace(/^should be/i, "").trim() + if (!cleaned) return [] + return cleaned + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +} + +async function reactcomment(gh: Gh, commentid: number) { + await gh.octokit.rest.reactions.createForIssueComment({ + owner: gh.owner, + repo: gh.repo, + comment_id: commentid, + content: "eyes", + }) +} diff --git a/app/api/webhook/learn.ts b/app/api/webhook/learn.ts new file mode 100644 index 0000000..bf912f0 --- /dev/null +++ b/app/api/webhook/learn.ts @@ -0,0 +1,76 @@ +import { Octokit } from "octokit" +import { parse, stringify } from "yaml" +import type { Gh, Config } from "./triage" + +const dancer = process.env.DANCER_PAT ? new Octokit({ auth: process.env.DANCER_PAT }) : null + +export async function createpr( + gh: Gh, + issue: number, + title: string, + labels: string[], +) { + if (!dancer) return + + const { data: repo } = await dancer.rest.repos.get({ + owner: gh.owner, + repo: gh.repo, + }) + const branch = `tigent/learn-${issue}` + const defaultbranch = repo.default_branch + + const { data: ref } = await dancer.rest.git.getRef({ + owner: gh.owner, + repo: gh.repo, + ref: `heads/${defaultbranch}`, + }) + const sha = ref.object.sha + + await dancer.rest.git.createRef({ + owner: gh.owner, + repo: gh.repo, + ref: `refs/heads/${branch}`, + sha, + }) + + let config: Partial = {} + let filesha: string | undefined + + try { + const { data } = await dancer.rest.repos.getContent({ + owner: gh.owner, + repo: gh.repo, + path: ".github/tigent.yml", + ref: defaultbranch, + }) + if ("content" in data) { + const content = Buffer.from(data.content, "base64").toString() + config = (parse(content) as Partial) || {} + filesha = data.sha + } + } catch {} + + if (!config.examples) config.examples = [] + config.examples.push({ title, labels }) + + const yaml = stringify(config) + + await dancer.rest.repos.createOrUpdateFileContents({ + owner: gh.owner, + repo: gh.repo, + path: ".github/tigent.yml", + message: `fix: add learning example from #${issue}`, + content: Buffer.from(yaml).toString("base64"), + branch, + ...(filesha ? { sha: filesha } : {}), + }) + + await dancer.rest.pulls.create({ + owner: gh.owner, + repo: gh.repo, + title: `fix: learn from #${issue} correction`, + body: `adds example to \`.github/tigent.yml\` from issue #${issue} correction.\n\n\`\`\`yaml\n- title: "${title}"\n labels: [${labels.join(", ")}]\n\`\`\``, + head: branch, + base: defaultbranch, + }) +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index d0be39e..f0e0de8 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,8 +1,7 @@ import { App } from "octokit" -import { generateObject } from "ai" -import { z } from "zod" -import { parse } from "yaml" -import type { Octokit } from "octokit" +import { getconfig } from "./triage" +import { triageissue, triagepr } from "./triage" +import { handlecomment } from "./feedback" const app = new App({ appId: process.env.GITHUB_APP_ID!, @@ -26,6 +25,14 @@ app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => { await triagepr(gh, config, payload.pull_request.number) }) +app.webhooks.on("issue_comment.created", async ({ octokit, payload }) => { + const owner = payload.repository.owner.login + const repo = payload.repository.name + const gh = { octokit, owner, repo } + const config = await getconfig(gh) + await handlecomment(gh, config, payload) +}) + export async function POST(req: Request) { const body = await req.text() try { @@ -38,159 +45,3 @@ export async function POST(req: Request) { } catch {} return new Response("ok") } - -interface Gh { - octokit: Octokit - owner: string - repo: string -} - -interface Config { - confidence: number - model: string -} - -const defaultconfig: Config = { - confidence: 0.6, - model: "openai/gpt-5-nano", -} - -async function getconfig(gh: Gh): Promise { - try { - const { data } = await gh.octokit.rest.repos.getContent({ - owner: gh.owner, - repo: gh.repo, - path: ".github/tigent.yml", - mediaType: { format: "raw" }, - }) - const yaml = data as unknown as string - const parsed = parse(yaml) as Partial - return { ...defaultconfig, ...parsed } - } catch { - return defaultconfig - } -} - -interface Label { - name: string - description: string -} - -async function fetchlabels(gh: Gh): Promise { - const { data } = await gh.octokit.rest.issues.listLabelsForRepo({ - owner: gh.owner, - repo: gh.repo, - per_page: 100, - }) - return data.map((l) => ({ name: l.name, description: l.description || "" })) -} - -async function addlabels(gh: Gh, issue: number, labels: string[]) { - if (labels.length === 0) return - await gh.octokit.rest.issues.addLabels({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue, - labels, - }) -} - -async function react(gh: Gh, issue: number) { - await gh.octokit.rest.reactions.createForIssue({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue, - content: "eyes", - }) -} - -const schema = z.object({ - labels: z.array(z.string()), - confidence: z.number().min(0).max(1), - reasoning: z.string(), -}) - -async function triageissue(gh: Gh, config: Config, number: number) { - await react(gh, number) - - const [issue, labels] = await Promise.all([ - gh.octokit.rest.issues.get({ owner: gh.owner, repo: gh.repo, issue_number: number }), - fetchlabels(gh), - ]) - - const labellist = labels - .map((l) => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) - .join("\n") - - const { object } = await generateObject({ - model: config.model, - schema, - system: `you are a github issue classifier. assign labels based on the issue content. - -available labels: -${labellist} - -rules: -- only use labels from the list above -- pick labels that match the issue content -- use label descriptions to understand what each label means -- be conservative, only add labels you are confident about`, - prompt: `title: ${issue.data.title} - -body: -${issue.data.body || "no description"}`, - }) - - const valid = object.labels.filter((l) => labels.some((x) => x.name === l)) - if (object.confidence >= config.confidence && valid.length > 0) { - await addlabels(gh, number, valid) - } -} - -async function triagepr(gh: Gh, config: Config, number: number) { - await react(gh, number) - - const [pr, files, labels] = await Promise.all([ - gh.octokit.rest.pulls.get({ owner: gh.owner, repo: gh.repo, pull_number: number }), - gh.octokit.rest.pulls.listFiles({ - owner: gh.owner, - repo: gh.repo, - pull_number: number, - per_page: 100, - }), - fetchlabels(gh), - ]) - - const labellist = labels - .map((l) => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) - .join("\n") - - const filelist = files.data.map((f) => f.filename).join("\n") - - const { object } = await generateObject({ - model: config.model, - schema, - system: `you are a github pull request classifier. assign labels based on the pr content. - -available labels: -${labellist} - -rules: -- only use labels from the list above -- pick labels that match the pr content and changed files -- use label descriptions to understand what each label means -- be conservative, only add labels you are confident about`, - prompt: `title: ${pr.data.title} - -body: -${pr.data.body || "no description"} - -changed files: -${filelist}`, - }) - - const valid = object.labels.filter((l) => labels.some((x) => x.name === l)) - if (object.confidence >= config.confidence && valid.length > 0) { - await addlabels(gh, number, valid) - } -} diff --git a/app/api/webhook/triage.ts b/app/api/webhook/triage.ts new file mode 100644 index 0000000..d337ec0 --- /dev/null +++ b/app/api/webhook/triage.ts @@ -0,0 +1,163 @@ +import { generateObject } from "ai" +import { z } from "zod" +import { parse } from "yaml" +import type { Octokit } from "octokit" + +export interface Gh { + octokit: Octokit + owner: string + repo: string +} + +export interface Example { + title: string + labels: string[] +} + +export interface Config { + confidence: number + model: string + examples: Example[] +} + +export interface Label { + name: string + description: string +} + +export const defaultconfig: Config = { + confidence: 0.6, + model: "openai/gpt-5-nano", + examples: [], +} + +export async function getconfig(gh: Gh): Promise { + try { + const { data } = await gh.octokit.rest.repos.getContent({ + owner: gh.owner, + repo: gh.repo, + path: ".github/tigent.yml", + mediaType: { format: "raw" }, + }) + const yaml = data as unknown as string + const parsed = parse(yaml) as Partial + return { ...defaultconfig, ...parsed } + } catch { + return defaultconfig + } +} + +export async function fetchlabels(gh: Gh): Promise { + const { data } = await gh.octokit.rest.issues.listLabelsForRepo({ + owner: gh.owner, + repo: gh.repo, + per_page: 100, + }) + return data.map((l) => ({ name: l.name, description: l.description || "" })) +} + +export async function addlabels(gh: Gh, issue: number, labels: string[]) { + if (labels.length === 0) return + await gh.octokit.rest.issues.addLabels({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue, + labels, + }) +} + +export async function react(gh: Gh, issue: number) { + await gh.octokit.rest.reactions.createForIssue({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue, + content: "eyes", + }) +} + +export const schema = z.object({ + labels: z.array(z.string()), + confidence: z.number().min(0).max(1), + reasoning: z.string(), +}) + +function examplesblock(examples: Example[]): string { + if (examples.length === 0) return "" + const lines = examples.map( + (e) => `- "${e.title}" → ${e.labels.join(", ")}`, + ) + return `\nexamples from past corrections:\n${lines.join("\n")}\n` +} + +export async function classify( + config: Config, + labels: Label[], + title: string, + body: string, + extra?: string, +) { + const labellist = labels + .map((l) => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) + .join("\n") + + const examples = examplesblock(config.examples) + + const system = `you are a github issue classifier. assign labels based on the content. + +available labels: +${labellist} +${examples} +rules: +- only use labels from the list above +- pick labels that match the content +- use label descriptions to understand what each label means +- be conservative, only add labels you are confident about` + + const prompt = `title: ${title} + +body: +${body || "no description"}${extra ? `\n\n${extra}` : ""}` + + const { object } = await generateObject({ model: config.model, schema, system, prompt }) + const valid = object.labels.filter((l) => labels.some((x) => x.name === l)) + return { labels: valid, confidence: object.confidence, reasoning: object.reasoning } +} + +export async function triageissue(gh: Gh, config: Config, number: number) { + await react(gh, number) + + const [issue, labels] = await Promise.all([ + gh.octokit.rest.issues.get({ owner: gh.owner, repo: gh.repo, issue_number: number }), + fetchlabels(gh), + ]) + + const result = await classify(config, labels, issue.data.title, issue.data.body || "") + + if (result.confidence >= config.confidence && result.labels.length > 0) { + await addlabels(gh, number, result.labels) + } +} + +export async function triagepr(gh: Gh, config: Config, number: number) { + await react(gh, number) + + const [pr, files, labels] = await Promise.all([ + gh.octokit.rest.pulls.get({ owner: gh.owner, repo: gh.repo, pull_number: number }), + gh.octokit.rest.pulls.listFiles({ + owner: gh.owner, + repo: gh.repo, + pull_number: number, + per_page: 100, + }), + fetchlabels(gh), + ]) + + const filelist = files.data.map((f) => f.filename).join("\n") + const extra = `changed files:\n${filelist}` + + const result = await classify(config, labels, pr.data.title, pr.data.body || "", extra) + + if (result.confidence >= config.confidence && result.labels.length > 0) { + await addlabels(gh, number, result.labels) + } +} diff --git a/app/docs/config.ts b/app/docs/config.ts index 721b524..1b33fcd 100644 --- a/app/docs/config.ts +++ b/app/docs/config.ts @@ -14,6 +14,7 @@ export const navigation = [ items: [ { title: 'Configuration', href: '/docs/config' }, { title: 'Labels', href: '/docs/labels' }, + { title: 'Feedback', href: '/docs/feedback' }, ], }, ]; diff --git a/app/docs/config/page.tsx b/app/docs/config/page.tsx index 47f0f21..10403e9 100644 --- a/app/docs/config/page.tsx +++ b/app/docs/config/page.tsx @@ -56,9 +56,36 @@ export default function Config() { -
-

A minimal config file:

- {`confidence: 0.7`} +
+
+
+

+ examples +

+

+ Few-shot examples from past corrections. These are injected into + the AI prompt to improve future classifications. Managed + automatically by the feedback loop. +

+ {`examples: + - title: "app crashes on startup" + labels: [bug, p1] + - title: "add dark mode support" + labels: [feature]`} +
+
+
+ +
+

A complete config file:

+ {`confidence: 0.7 + +examples: + - title: "app crashes on startup" + labels: [bug, p1]`}
diff --git a/app/docs/feedback/page.tsx b/app/docs/feedback/page.tsx new file mode 100644 index 0000000..a253279 --- /dev/null +++ b/app/docs/feedback/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from "next" +import { Header, Section, Code, Codeinline, Prevnext } from "../components" + +export const metadata: Metadata = { + title: "Feedback", + description: "Teach Tigent by correcting its labeling decisions.", +} + +export default function Feedback() { + return ( +
+
+ +
+

+ Comment on any issue or PR to interact with Tigent. Only repository + owners, members, and collaborators can use these commands. +

+ +
+
+

+ @tigent why +

+

+ Re-classifies the issue and posts the reasoning as a comment. +

+ @tigent why +
+ +
+

+ @tigent wrong +

+

+ Removes the AI-assigned labels and applies the correct ones. + Creates a PR to save the correction as a learning example. +

+ @tigent wrong, should be bug, p1 +
+
+
+ +
+

+ When you use the wrong command, Tigent creates a PR that adds the + correction to your config file as an example. Future classifications + use these examples to make better decisions. +

+ {`confidence: 0.7 +examples: + - title: "app crashes on startup" + labels: [bug, p1]`} +
+ +
+

+ Only users with OWNER, MEMBER, or COLLABORATOR association can use + feedback commands. Comments from other users are ignored. +

+
+ + +
+ ) +} From 784fd32870dd1a596536b24f4a2433f3c1aad17d Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 9 Feb 2026 08:35:50 +0000 Subject: [PATCH 2/3] chore: run prettier --- .github/scripts/validate.ts | 62 ++++----- app/api/webhook/feedback.ts | 226 ++++++++++++++++----------------- app/api/webhook/learn.ts | 126 ++++++++++--------- app/api/webhook/route.ts | 78 ++++++------ app/api/webhook/triage.ts | 244 ++++++++++++++++++++---------------- app/docs/config/page.tsx | 3 +- app/docs/feedback/page.tsx | 112 ++++++++--------- 7 files changed, 437 insertions(+), 414 deletions(-) diff --git a/.github/scripts/validate.ts b/.github/scripts/validate.ts index 39baa9b..3620e8d 100644 --- a/.github/scripts/validate.ts +++ b/.github/scripts/validate.ts @@ -1,46 +1,46 @@ -import { parse } from "yaml" -import { z } from "zod" -import { readFileSync, existsSync } from "fs" +import { parse } from 'yaml'; +import { z } from 'zod'; +import { readFileSync, existsSync } from 'fs'; const schema = z.object({ - confidence: z.number().min(0).max(1).optional(), - model: z.string().optional(), - examples: z - .array( - z.object({ - title: z.string(), - labels: z.array(z.string()), - }), - ) - .optional(), -}) - -const configpath = ".github/tigent.yml" + confidence: z.number().min(0).max(1).optional(), + model: z.string().optional(), + examples: z + .array( + z.object({ + title: z.string(), + labels: z.array(z.string()), + }), + ) + .optional(), +}); + +const configpath = '.github/tigent.yml'; if (!existsSync(configpath)) { - console.log("no config file found, skipping validation") - process.exit(0) + console.log('no config file found, skipping validation'); + process.exit(0); } -const content = readFileSync(configpath, "utf-8") +const content = readFileSync(configpath, 'utf-8'); -let parsed: unknown +let parsed: unknown; try { - parsed = parse(content) + parsed = parse(content); } catch (err) { - console.error("invalid yaml syntax") - console.error(err) - process.exit(1) + console.error('invalid yaml syntax'); + console.error(err); + process.exit(1); } -const result = schema.safeParse(parsed) +const result = schema.safeParse(parsed); if (!result.success) { - console.error("config validation failed:") - for (const issue of result.error.issues) { - console.error(` - ${issue.path.join(".")}: ${issue.message}`) - } - process.exit(1) + console.error('config validation failed:'); + for (const issue of result.error.issues) { + console.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + process.exit(1); } -console.log("config is valid") +console.log('config is valid'); diff --git a/app/api/webhook/feedback.ts b/app/api/webhook/feedback.ts index 9918749..d7e43f6 100644 --- a/app/api/webhook/feedback.ts +++ b/app/api/webhook/feedback.ts @@ -1,130 +1,126 @@ -import type { Gh, Config } from "./triage" -import { classify, fetchlabels, addlabels } from "./triage" -import { createpr } from "./learn" - -const allowed = ["OWNER", "MEMBER", "COLLABORATOR"] - -export async function handlecomment( - gh: Gh, - config: Config, - payload: any, -) { - const comment = payload.comment - const body: string = comment.body?.trim() || "" - const association: string = comment.author_association || "" - - if (!allowed.includes(association)) return - if (!body.toLowerCase().startsWith("@tigent")) return - - const command = body.slice(7).trim().toLowerCase() - const issue = payload.issue - - if (command === "why") { - await handlewhy(gh, config, issue, comment.id) - } else if (command.startsWith("wrong")) { - const rest = body.slice(7).trim().slice(5).trim() - const labels = parselabels(rest) - if (labels.length > 0) { - await handlewrong(gh, config, issue, comment.id, labels) - } - } +import type { Gh, Config } from './triage'; +import { classify, fetchlabels, addlabels } from './triage'; +import { createpr } from './learn'; + +const allowed = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +export async function handlecomment(gh: Gh, config: Config, payload: any) { + const comment = payload.comment; + const body: string = comment.body?.trim() || ''; + const association: string = comment.author_association || ''; + + if (!allowed.includes(association)) return; + if (!body.toLowerCase().startsWith('@tigent')) return; + + const command = body.slice(7).trim().toLowerCase(); + const issue = payload.issue; + + if (command === 'why') { + await handlewhy(gh, config, issue, comment.id); + } else if (command.startsWith('wrong')) { + const rest = body.slice(7).trim().slice(5).trim(); + const labels = parselabels(rest); + if (labels.length > 0) { + await handlewrong(gh, config, issue, comment.id, labels); + } + } } async function handlewhy( - gh: Gh, - config: Config, - issue: any, - commentid: number, + gh: Gh, + config: Config, + issue: any, + commentid: number, ) { - await reactcomment(gh, commentid) - - const labels = await fetchlabels(gh) - const result = await classify( - config, - labels, - issue.title, - issue.body || "", - ) - - const labelstr = result.labels.join(", ") - const body = `**labels:** ${labelstr}\n**confidence:** ${result.confidence}\n\n${result.reasoning}` - - await gh.octokit.rest.issues.createComment({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue.number, - body, - }) + await reactcomment(gh, commentid); + + const labels = await fetchlabels(gh); + const result = await classify(config, labels, issue.title, issue.body || ''); + + const labelstr = result.labels.join(', '); + const body = `**labels:** ${labelstr}\n**confidence:** ${result.confidence}\n\n${result.reasoning}`; + + await gh.octokit.rest.issues.createComment({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + body, + }); } async function handlewrong( - gh: Gh, - config: Config, - issue: any, - commentid: number, - correctlabels: string[], + gh: Gh, + config: Config, + issue: any, + commentid: number, + correctlabels: string[], ) { - await reactcomment(gh, commentid) - - const [repolabels, current] = await Promise.all([ - fetchlabels(gh), - gh.octokit.rest.issues.listLabelsOnIssue({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue.number, - }), - ]) - - const result = await classify( - config, - repolabels, - issue.title, - issue.body || "", - ) - - const ailabels = result.labels - const existing = current.data.map((l) => l.name) - - for (const label of ailabels) { - if (existing.includes(label)) { - await gh.octokit.rest.issues.removeLabel({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue.number, - name: label, - }) - } - } - - const validcorrect = correctlabels.filter((l) => - repolabels.some((x) => x.name.toLowerCase() === l.toLowerCase()), - ) - const matchedlabels = validcorrect.map((l) => { - const match = repolabels.find((x) => x.name.toLowerCase() === l.toLowerCase()) - return match!.name - }) - - if (matchedlabels.length > 0) { - await addlabels(gh, issue.number, matchedlabels) - } - - await createpr(gh, issue.number, issue.title, matchedlabels) + await reactcomment(gh, commentid); + + const [repolabels, current] = await Promise.all([ + fetchlabels(gh), + gh.octokit.rest.issues.listLabelsOnIssue({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + }), + ]); + + const result = await classify( + config, + repolabels, + issue.title, + issue.body || '', + ); + + const ailabels = result.labels; + const existing = current.data.map(l => l.name); + + for (const label of ailabels) { + if (existing.includes(label)) { + await gh.octokit.rest.issues.removeLabel({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue.number, + name: label, + }); + } + } + + const validcorrect = correctlabels.filter(l => + repolabels.some(x => x.name.toLowerCase() === l.toLowerCase()), + ); + const matchedlabels = validcorrect.map(l => { + const match = repolabels.find( + x => x.name.toLowerCase() === l.toLowerCase(), + ); + return match!.name; + }); + + if (matchedlabels.length > 0) { + await addlabels(gh, issue.number, matchedlabels); + } + + await createpr(gh, issue.number, issue.title, matchedlabels); } function parselabels(input: string): string[] { - const cleaned = input.replace(/^,/, "").replace(/^should be/i, "").trim() - if (!cleaned) return [] - return cleaned - .split(",") - .map((s) => s.trim()) - .filter(Boolean) + const cleaned = input + .replace(/^,/, '') + .replace(/^should be/i, '') + .trim(); + if (!cleaned) return []; + return cleaned + .split(',') + .map(s => s.trim()) + .filter(Boolean); } async function reactcomment(gh: Gh, commentid: number) { - await gh.octokit.rest.reactions.createForIssueComment({ - owner: gh.owner, - repo: gh.repo, - comment_id: commentid, - content: "eyes", - }) + await gh.octokit.rest.reactions.createForIssueComment({ + owner: gh.owner, + repo: gh.repo, + comment_id: commentid, + content: 'eyes', + }); } diff --git a/app/api/webhook/learn.ts b/app/api/webhook/learn.ts index bf912f0..0e79d6a 100644 --- a/app/api/webhook/learn.ts +++ b/app/api/webhook/learn.ts @@ -1,76 +1,78 @@ -import { Octokit } from "octokit" -import { parse, stringify } from "yaml" -import type { Gh, Config } from "./triage" +import { Octokit } from 'octokit'; +import { parse, stringify } from 'yaml'; +import type { Gh, Config } from './triage'; -const dancer = process.env.DANCER_PAT ? new Octokit({ auth: process.env.DANCER_PAT }) : null +const dancer = process.env.DANCER_PAT + ? new Octokit({ auth: process.env.DANCER_PAT }) + : null; export async function createpr( - gh: Gh, - issue: number, - title: string, - labels: string[], + gh: Gh, + issue: number, + title: string, + labels: string[], ) { - if (!dancer) return + if (!dancer) return; - const { data: repo } = await dancer.rest.repos.get({ - owner: gh.owner, - repo: gh.repo, - }) - const branch = `tigent/learn-${issue}` - const defaultbranch = repo.default_branch + const { data: repo } = await dancer.rest.repos.get({ + owner: gh.owner, + repo: gh.repo, + }); + const branch = `tigent/learn-${issue}`; + const defaultbranch = repo.default_branch; - const { data: ref } = await dancer.rest.git.getRef({ - owner: gh.owner, - repo: gh.repo, - ref: `heads/${defaultbranch}`, - }) - const sha = ref.object.sha + const { data: ref } = await dancer.rest.git.getRef({ + owner: gh.owner, + repo: gh.repo, + ref: `heads/${defaultbranch}`, + }); + const sha = ref.object.sha; - await dancer.rest.git.createRef({ - owner: gh.owner, - repo: gh.repo, - ref: `refs/heads/${branch}`, - sha, - }) + await dancer.rest.git.createRef({ + owner: gh.owner, + repo: gh.repo, + ref: `refs/heads/${branch}`, + sha, + }); - let config: Partial = {} - let filesha: string | undefined + let config: Partial = {}; + let filesha: string | undefined; - try { - const { data } = await dancer.rest.repos.getContent({ - owner: gh.owner, - repo: gh.repo, - path: ".github/tigent.yml", - ref: defaultbranch, - }) - if ("content" in data) { - const content = Buffer.from(data.content, "base64").toString() - config = (parse(content) as Partial) || {} - filesha = data.sha - } - } catch {} + try { + const { data } = await dancer.rest.repos.getContent({ + owner: gh.owner, + repo: gh.repo, + path: '.github/tigent.yml', + ref: defaultbranch, + }); + if ('content' in data) { + const content = Buffer.from(data.content, 'base64').toString(); + config = (parse(content) as Partial) || {}; + filesha = data.sha; + } + } catch {} - if (!config.examples) config.examples = [] - config.examples.push({ title, labels }) + if (!config.examples) config.examples = []; + config.examples.push({ title, labels }); - const yaml = stringify(config) + const yaml = stringify(config); - await dancer.rest.repos.createOrUpdateFileContents({ - owner: gh.owner, - repo: gh.repo, - path: ".github/tigent.yml", - message: `fix: add learning example from #${issue}`, - content: Buffer.from(yaml).toString("base64"), - branch, - ...(filesha ? { sha: filesha } : {}), - }) + await dancer.rest.repos.createOrUpdateFileContents({ + owner: gh.owner, + repo: gh.repo, + path: '.github/tigent.yml', + message: `fix: add learning example from #${issue}`, + content: Buffer.from(yaml).toString('base64'), + branch, + ...(filesha ? { sha: filesha } : {}), + }); - await dancer.rest.pulls.create({ - owner: gh.owner, - repo: gh.repo, - title: `fix: learn from #${issue} correction`, - body: `adds example to \`.github/tigent.yml\` from issue #${issue} correction.\n\n\`\`\`yaml\n- title: "${title}"\n labels: [${labels.join(", ")}]\n\`\`\``, - head: branch, - base: defaultbranch, - }) + await dancer.rest.pulls.create({ + owner: gh.owner, + repo: gh.repo, + title: `fix: learn from #${issue} correction`, + body: `adds example to \`.github/tigent.yml\` from issue #${issue} correction.\n\n\`\`\`yaml\n- title: "${title}"\n labels: [${labels.join(', ')}]\n\`\`\``, + head: branch, + base: defaultbranch, + }); } diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index f0e0de8..b599934 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,47 +1,47 @@ -import { App } from "octokit" -import { getconfig } from "./triage" -import { triageissue, triagepr } from "./triage" -import { handlecomment } from "./feedback" +import { App } from 'octokit'; +import { getconfig } from './triage'; +import { triageissue, triagepr } from './triage'; +import { handlecomment } from './feedback'; const app = new App({ - appId: process.env.GITHUB_APP_ID!, - privateKey: process.env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, "\n"), - webhooks: { secret: process.env.GITHUB_WEBHOOK_SECRET! }, -}) + appId: process.env.GITHUB_APP_ID!, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, '\n'), + webhooks: { secret: process.env.GITHUB_WEBHOOK_SECRET! }, +}); -app.webhooks.on("issues.opened", async ({ octokit, payload }) => { - const owner = payload.repository.owner.login - const repo = payload.repository.name - const gh = { octokit, owner, repo } - const config = await getconfig(gh) - await triageissue(gh, config, payload.issue.number) -}) +app.webhooks.on('issues.opened', async ({ octokit, payload }) => { + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const gh = { octokit, owner, repo }; + const config = await getconfig(gh); + await triageissue(gh, config, payload.issue.number); +}); -app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => { - const owner = payload.repository.owner.login - const repo = payload.repository.name - const gh = { octokit, owner, repo } - const config = await getconfig(gh) - await triagepr(gh, config, payload.pull_request.number) -}) +app.webhooks.on('pull_request.opened', async ({ octokit, payload }) => { + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const gh = { octokit, owner, repo }; + const config = await getconfig(gh); + await triagepr(gh, config, payload.pull_request.number); +}); -app.webhooks.on("issue_comment.created", async ({ octokit, payload }) => { - const owner = payload.repository.owner.login - const repo = payload.repository.name - const gh = { octokit, owner, repo } - const config = await getconfig(gh) - await handlecomment(gh, config, payload) -}) +app.webhooks.on('issue_comment.created', async ({ octokit, payload }) => { + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const gh = { octokit, owner, repo }; + const config = await getconfig(gh); + await handlecomment(gh, config, payload); +}); export async function POST(req: Request) { - const body = await req.text() - try { - await app.webhooks.verifyAndReceive({ - id: req.headers.get("x-github-delivery") || "", - name: req.headers.get("x-github-event") as any, - payload: body, - signature: req.headers.get("x-hub-signature-256") || "", - }) - } catch {} - return new Response("ok") + const body = await req.text(); + try { + await app.webhooks.verifyAndReceive({ + id: req.headers.get('x-github-delivery') || '', + name: req.headers.get('x-github-event') as any, + payload: body, + signature: req.headers.get('x-hub-signature-256') || '', + }); + } catch {} + return new Response('ok'); } diff --git a/app/api/webhook/triage.ts b/app/api/webhook/triage.ts index d337ec0..3302b63 100644 --- a/app/api/webhook/triage.ts +++ b/app/api/webhook/triage.ts @@ -1,108 +1,106 @@ -import { generateObject } from "ai" -import { z } from "zod" -import { parse } from "yaml" -import type { Octokit } from "octokit" +import { generateObject } from 'ai'; +import { z } from 'zod'; +import { parse } from 'yaml'; +import type { Octokit } from 'octokit'; export interface Gh { - octokit: Octokit - owner: string - repo: string + octokit: Octokit; + owner: string; + repo: string; } export interface Example { - title: string - labels: string[] + title: string; + labels: string[]; } export interface Config { - confidence: number - model: string - examples: Example[] + confidence: number; + model: string; + examples: Example[]; } export interface Label { - name: string - description: string + name: string; + description: string; } export const defaultconfig: Config = { - confidence: 0.6, - model: "openai/gpt-5-nano", - examples: [], -} + confidence: 0.6, + model: 'openai/gpt-5-nano', + examples: [], +}; export async function getconfig(gh: Gh): Promise { - try { - const { data } = await gh.octokit.rest.repos.getContent({ - owner: gh.owner, - repo: gh.repo, - path: ".github/tigent.yml", - mediaType: { format: "raw" }, - }) - const yaml = data as unknown as string - const parsed = parse(yaml) as Partial - return { ...defaultconfig, ...parsed } - } catch { - return defaultconfig - } + try { + const { data } = await gh.octokit.rest.repos.getContent({ + owner: gh.owner, + repo: gh.repo, + path: '.github/tigent.yml', + mediaType: { format: 'raw' }, + }); + const yaml = data as unknown as string; + const parsed = parse(yaml) as Partial; + return { ...defaultconfig, ...parsed }; + } catch { + return defaultconfig; + } } export async function fetchlabels(gh: Gh): Promise { - const { data } = await gh.octokit.rest.issues.listLabelsForRepo({ - owner: gh.owner, - repo: gh.repo, - per_page: 100, - }) - return data.map((l) => ({ name: l.name, description: l.description || "" })) + const { data } = await gh.octokit.rest.issues.listLabelsForRepo({ + owner: gh.owner, + repo: gh.repo, + per_page: 100, + }); + return data.map(l => ({ name: l.name, description: l.description || '' })); } export async function addlabels(gh: Gh, issue: number, labels: string[]) { - if (labels.length === 0) return - await gh.octokit.rest.issues.addLabels({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue, - labels, - }) + if (labels.length === 0) return; + await gh.octokit.rest.issues.addLabels({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue, + labels, + }); } export async function react(gh: Gh, issue: number) { - await gh.octokit.rest.reactions.createForIssue({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue, - content: "eyes", - }) + await gh.octokit.rest.reactions.createForIssue({ + owner: gh.owner, + repo: gh.repo, + issue_number: issue, + content: 'eyes', + }); } export const schema = z.object({ - labels: z.array(z.string()), - confidence: z.number().min(0).max(1), - reasoning: z.string(), -}) + labels: z.array(z.string()), + confidence: z.number().min(0).max(1), + reasoning: z.string(), +}); function examplesblock(examples: Example[]): string { - if (examples.length === 0) return "" - const lines = examples.map( - (e) => `- "${e.title}" → ${e.labels.join(", ")}`, - ) - return `\nexamples from past corrections:\n${lines.join("\n")}\n` + if (examples.length === 0) return ''; + const lines = examples.map(e => `- "${e.title}" → ${e.labels.join(', ')}`); + return `\nexamples from past corrections:\n${lines.join('\n')}\n`; } export async function classify( - config: Config, - labels: Label[], - title: string, - body: string, - extra?: string, + config: Config, + labels: Label[], + title: string, + body: string, + extra?: string, ) { - const labellist = labels - .map((l) => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) - .join("\n") + const labellist = labels + .map(l => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) + .join('\n'); - const examples = examplesblock(config.examples) + const examples = examplesblock(config.examples); - const system = `you are a github issue classifier. assign labels based on the content. + const system = `you are a github issue classifier. assign labels based on the content. available labels: ${labellist} @@ -111,53 +109,81 @@ rules: - only use labels from the list above - pick labels that match the content - use label descriptions to understand what each label means -- be conservative, only add labels you are confident about` +- be conservative, only add labels you are confident about`; - const prompt = `title: ${title} + const prompt = `title: ${title} body: -${body || "no description"}${extra ? `\n\n${extra}` : ""}` - - const { object } = await generateObject({ model: config.model, schema, system, prompt }) - const valid = object.labels.filter((l) => labels.some((x) => x.name === l)) - return { labels: valid, confidence: object.confidence, reasoning: object.reasoning } +${body || 'no description'}${extra ? `\n\n${extra}` : ''}`; + + const { object } = await generateObject({ + model: config.model, + schema, + system, + prompt, + }); + const valid = object.labels.filter(l => labels.some(x => x.name === l)); + return { + labels: valid, + confidence: object.confidence, + reasoning: object.reasoning, + }; } export async function triageissue(gh: Gh, config: Config, number: number) { - await react(gh, number) - - const [issue, labels] = await Promise.all([ - gh.octokit.rest.issues.get({ owner: gh.owner, repo: gh.repo, issue_number: number }), - fetchlabels(gh), - ]) - - const result = await classify(config, labels, issue.data.title, issue.data.body || "") - - if (result.confidence >= config.confidence && result.labels.length > 0) { - await addlabels(gh, number, result.labels) - } + await react(gh, number); + + const [issue, labels] = await Promise.all([ + gh.octokit.rest.issues.get({ + owner: gh.owner, + repo: gh.repo, + issue_number: number, + }), + fetchlabels(gh), + ]); + + const result = await classify( + config, + labels, + issue.data.title, + issue.data.body || '', + ); + + if (result.confidence >= config.confidence && result.labels.length > 0) { + await addlabels(gh, number, result.labels); + } } export async function triagepr(gh: Gh, config: Config, number: number) { - await react(gh, number) - - const [pr, files, labels] = await Promise.all([ - gh.octokit.rest.pulls.get({ owner: gh.owner, repo: gh.repo, pull_number: number }), - gh.octokit.rest.pulls.listFiles({ - owner: gh.owner, - repo: gh.repo, - pull_number: number, - per_page: 100, - }), - fetchlabels(gh), - ]) - - const filelist = files.data.map((f) => f.filename).join("\n") - const extra = `changed files:\n${filelist}` - - const result = await classify(config, labels, pr.data.title, pr.data.body || "", extra) - - if (result.confidence >= config.confidence && result.labels.length > 0) { - await addlabels(gh, number, result.labels) - } + await react(gh, number); + + const [pr, files, labels] = await Promise.all([ + gh.octokit.rest.pulls.get({ + owner: gh.owner, + repo: gh.repo, + pull_number: number, + }), + gh.octokit.rest.pulls.listFiles({ + owner: gh.owner, + repo: gh.repo, + pull_number: number, + per_page: 100, + }), + fetchlabels(gh), + ]); + + const filelist = files.data.map(f => f.filename).join('\n'); + const extra = `changed files:\n${filelist}`; + + const result = await classify( + config, + labels, + pr.data.title, + pr.data.body || '', + extra, + ); + + if (result.confidence >= config.confidence && result.labels.length > 0) { + await addlabels(gh, number, result.labels); + } } diff --git a/app/docs/config/page.tsx b/app/docs/config/page.tsx index 10403e9..a4a5bad 100644 --- a/app/docs/config/page.tsx +++ b/app/docs/config/page.tsx @@ -48,8 +48,7 @@ export default function Config() { model

- AI model to use for classification. Default: - openai/gpt-5-nano + AI model to use for classification. Default: openai/gpt-5-nano

model: openai/gpt-5-nano diff --git a/app/docs/feedback/page.tsx b/app/docs/feedback/page.tsx index a253279..8294964 100644 --- a/app/docs/feedback/page.tsx +++ b/app/docs/feedback/page.tsx @@ -1,70 +1,70 @@ -import type { Metadata } from "next" -import { Header, Section, Code, Codeinline, Prevnext } from "../components" +import type { Metadata } from 'next'; +import { Header, Section, Code, Codeinline, Prevnext } from '../components'; export const metadata: Metadata = { - title: "Feedback", - description: "Teach Tigent by correcting its labeling decisions.", -} + title: 'Feedback', + description: 'Teach Tigent by correcting its labeling decisions.', +}; export default function Feedback() { - return ( -
-
+ return ( +
+
-
-

- Comment on any issue or PR to interact with Tigent. Only repository - owners, members, and collaborators can use these commands. -

+
+

+ Comment on any issue or PR to interact with Tigent. Only repository + owners, members, and collaborators can use these commands. +

-
-
-

- @tigent why -

-

- Re-classifies the issue and posts the reasoning as a comment. -

- @tigent why -
+
+
+

+ @tigent why +

+

+ Re-classifies the issue and posts the reasoning as a comment. +

+ @tigent why +
-
-

- @tigent wrong -

-

- Removes the AI-assigned labels and applies the correct ones. - Creates a PR to save the correction as a learning example. -

- @tigent wrong, should be bug, p1 -
-
-
+
+

+ @tigent wrong +

+

+ Removes the AI-assigned labels and applies the correct ones. + Creates a PR to save the correction as a learning example. +

+ @tigent wrong, should be bug, p1 +
+ +
-
-

- When you use the wrong command, Tigent creates a PR that adds the - correction to your config file as an example. Future classifications - use these examples to make better decisions. -

- {`confidence: 0.7 +
+

+ When you use the wrong command, Tigent creates a PR that adds the + correction to your config file as an example. Future classifications + use these examples to make better decisions. +

+ {`confidence: 0.7 examples: - title: "app crashes on startup" labels: [bug, p1]`} -
+
-
-

- Only users with OWNER, MEMBER, or COLLABORATOR association can use - feedback commands. Comments from other users are ignored. -

-
+
+

+ Only users with OWNER, MEMBER, or COLLABORATOR association can use + feedback commands. Comments from other users are ignored. +

+
- -
- ) + +
+ ); } From 0416e1cde51da9e94d6e1a28ee6a6cdac2ad542f Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 9 Feb 2026 08:37:06 +0000 Subject: [PATCH 3/3] chore: add prettier pre-commit hook --- .github/tigent.yml | 10 +++++----- .husky/pre-commit | 1 + app/globals.css | 2 +- biome.json | 38 +++++++++++++++++++------------------- package.json | 4 +++- pnpm-lock.yaml | 10 ++++++++++ postcss.config.mjs | 6 +++--- tsconfig.json | 10 ++-------- 8 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 .husky/pre-commit diff --git a/.github/tigent.yml b/.github/tigent.yml index a5e2d1e..9763f4e 100644 --- a/.github/tigent.yml +++ b/.github/tigent.yml @@ -19,15 +19,15 @@ labels: help-wanted: muted rules: - - match: "crash|broken|not working" + - match: 'crash|broken|not working' add: [bug, p1] - - match: "security|vulnerability|cve" + - match: 'security|vulnerability|cve' add: [security, p0] - - match: "docs|readme|typo" + - match: 'docs|readme|typo' add: [documentation] - - match: "config|yaml|yml" + - match: 'config|yaml|yml' add: [config] - - match: "webhook|payload" + - match: 'webhook|payload' add: [webhook] reactions: diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..6add08e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm prettier-fix && git add -u diff --git a/app/globals.css b/app/globals.css index 67f740b..a785b29 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,4 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @theme { --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; diff --git a/biome.json b/biome.json index e0f786f..1037d77 100644 --- a/biome.json +++ b/biome.json @@ -1,21 +1,21 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "css": { - "parser": { - "cssModules": true - } - }, - "files": { - "ignore": ["*.css"] - } + "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "css": { + "parser": { + "cssModules": true + } + }, + "files": { + "ignore": ["*.css"] + } } diff --git a/package.json b/package.json index 0c8a4f6..fba890c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "biome lint .", "format": "biome format --write .", - "prettier-fix": "prettier --write \"**/*.{js,ts,tsx,md,mdx,svelte}\"" + "prettier-fix": "prettier --write \"**/*.{js,ts,tsx,md,mdx,svelte}\"", + "prepare": "husky" }, "lint-staged": { "*": [ @@ -19,6 +20,7 @@ "@biomejs/biome": "^2.3.12", "@types/node": "^25.0.10", "@types/react": "^19.2.9", + "husky": "^9.1.7", "prettier": "^3.5.3" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdac403..d0d2d04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@types/react': specifier: ^19.2.9 version: 19.2.9 + husky: + specifier: ^9.1.7 + version: 9.1.7 prettier: specifier: ^3.5.3 version: 3.8.1 @@ -635,6 +638,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1317,6 +1325,8 @@ snapshots: graceful-fs@4.2.11: {} + husky@9.1.7: {} + jiti@2.6.1: {} json-schema@0.4.0: {} diff --git a/postcss.config.mjs b/postcss.config.mjs index e627111..a34a3d5 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ export default { plugins: { - "@tailwindcss/postcss": {} - } -} + '@tailwindcss/postcss': {}, + }, +}; diff --git a/tsconfig.json b/tsconfig.json index c9c3943..9dce394 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,11 +6,7 @@ "@/*": ["./*"] }, // Environment setup & latest features - "lib": [ - "ESNext", - "DOM", - "DOM.Iterable" - ], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", @@ -46,7 +42,5 @@ "**/*.ts", "**/*.tsx" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] }