From 8a51d8ecf47b0411f8437c6551ec2a898113f8f9 Mon Sep 17 00:00:00 2001 From: dancer Date: Mon, 9 Mar 2026 18:46:29 +0000 Subject: [PATCH 1/9] feat: improve triage memory and operator console --- .github/tigent.yml | 101 +++-- app/api/dashboard/logs/route.ts | 6 +- app/api/webhook/feedback.ts | 435 ++++++++++++++----- app/api/webhook/learn.ts | 202 ++++++--- app/api/webhook/route.ts | 23 +- app/api/webhook/triage.ts | 255 +++++++---- app/dashboard/[owner]/[repo]/config/page.tsx | 49 ++- app/dashboard/[owner]/[repo]/page.tsx | 83 +++- app/dashboard/components/activity.tsx | 433 ++++++++++-------- app/dashboard/components/config.tsx | 152 ++++++- app/dashboard/components/empty.tsx | 54 ++- app/dashboard/components/header.tsx | 43 +- app/dashboard/components/memory.tsx | 86 ++++ app/dashboard/components/mobilenav.tsx | 78 ++-- app/dashboard/components/shell.tsx | 10 +- app/dashboard/components/sidebar.tsx | 78 +++- app/dashboard/components/stats.tsx | 45 +- app/dashboard/page.tsx | 6 +- app/docs/breadcrumb.tsx | 24 +- app/docs/components.tsx | 158 ++++--- app/docs/config/page.tsx | 157 ++++--- app/docs/feedback/page.tsx | 112 +++-- app/docs/installation/page.tsx | 98 +++-- app/docs/labels/page.tsx | 128 +++--- app/docs/layout.tsx | 26 +- app/docs/menu.tsx | 118 +++++ app/docs/page.tsx | 144 +++--- app/docs/quickstart/page.tsx | 77 ++-- app/lib/config.ts | 64 +++ app/lib/github.ts | 23 +- app/lib/logging.ts | 221 +++++++++- app/lib/memory.ts | 129 ++++++ app/lib/model.ts | 41 ++ app/lib/policy.ts | 28 ++ app/lib/repos.ts | 16 + app/lib/scope.ts | 28 ++ eval/cases/10372.json | 22 + eval/cases/11382.json | 23 + eval/cases/12657.json | 32 ++ eval/cases/12670.json | 22 + eval/cases/12879.json | 22 + eval/cases/12965.json | 28 ++ eval/cases/12982.json | 22 + eval/cases/12999.json | 22 + eval/cases/13005.json | 22 + eval/cases/13007.json | 22 + eval/cases/13017.json | 34 ++ eval/cases/13021.json | 29 ++ eval/cases/13023.json | 22 + eval/cases/13040.json | 28 ++ eval/cases/13055.json | 29 ++ eval/cases/13057.json | 34 ++ eval/cases/13069.json | 22 + eval/cases/13075.json | 34 ++ eval/cases/13080.json | 22 + eval/cases/13090.json | 23 + eval/cases/13109.json | 22 + eval/cases/13114.json | 22 + eval/cases/165.json | 27 ++ eval/cases/166.json | 29 ++ eval/cases/168.json | 25 ++ eval/kinds.ts | 108 +++++ eval/labels.json | 272 ++++++++++++ eval/load.ts | 136 ++++++ eval/mock.ts | 111 +++++ eval/readme.md | 32 ++ eval/run.ts | 345 +++++++++++++++ handbook/setup.md | 13 + package.json | 4 +- pnpm-lock.yaml | 335 ++++++++++++++ 70 files changed, 4656 insertions(+), 1070 deletions(-) create mode 100644 app/dashboard/components/memory.tsx create mode 100644 app/docs/menu.tsx create mode 100644 app/lib/config.ts create mode 100644 app/lib/memory.ts create mode 100644 app/lib/model.ts create mode 100644 app/lib/policy.ts create mode 100644 app/lib/repos.ts create mode 100644 app/lib/scope.ts create mode 100644 eval/cases/10372.json create mode 100644 eval/cases/11382.json create mode 100644 eval/cases/12657.json create mode 100644 eval/cases/12670.json create mode 100644 eval/cases/12879.json create mode 100644 eval/cases/12965.json create mode 100644 eval/cases/12982.json create mode 100644 eval/cases/12999.json create mode 100644 eval/cases/13005.json create mode 100644 eval/cases/13007.json create mode 100644 eval/cases/13017.json create mode 100644 eval/cases/13021.json create mode 100644 eval/cases/13023.json create mode 100644 eval/cases/13040.json create mode 100644 eval/cases/13055.json create mode 100644 eval/cases/13057.json create mode 100644 eval/cases/13069.json create mode 100644 eval/cases/13075.json create mode 100644 eval/cases/13080.json create mode 100644 eval/cases/13090.json create mode 100644 eval/cases/13109.json create mode 100644 eval/cases/13114.json create mode 100644 eval/cases/165.json create mode 100644 eval/cases/166.json create mode 100644 eval/cases/168.json create mode 100644 eval/kinds.ts create mode 100644 eval/labels.json create mode 100644 eval/load.ts create mode 100644 eval/mock.ts create mode 100644 eval/readme.md create mode 100644 eval/run.ts diff --git a/.github/tigent.yml b/.github/tigent.yml index 229d16e..016be54 100644 --- a/.github/tigent.yml +++ b/.github/tigent.yml @@ -1,60 +1,67 @@ +blocklist: + - major + - minor + - backport + - pull request welcome + - good first issue + - type:batch + - type:epic + - wontfix + prompt: | you are the labeling agent for the vercel ai sdk repository. your job is to read every new issue and pr and apply the correct labels. always apply labels, never skip. - the ai sdk is a monorepo with packages for core ai functionality, ui hooks, provider integrations, mcp, gateway, and more. most issues come from users who need help, not from actual bugs. + the ai sdk is a monorepo. most issues come from users who need help, not confirmed sdk defects. + + when in doubt, add support. if someone says "doesn't work" or "how do i" but has not shown a clear sdk defect, prefer support over bug. - when in doubt, add support. if someone says "doesn't work" or "how do i", that's support not bug. only use bug when there's a clear defect or regression with evidence. + use only labels that exist in this repository. choose the smallest correct set. - every issue should get at least one area label and one type label. + most issues and prs should get one area label and one type label. provider issues must get ai/provider plus at least one matching provider/* label. area labels: - ai/core — generateText, generateObject, streamText, streamObject, tool calling, structured output, steps, middleware. - ai/ui — useChat, useCompletion, useAssistant, UIMessage, react hooks, frontend streaming. - ai/ui-vue — vue.js ui bindings. - ai/rsc — react server components, createStreamableUI, createStreamableValue. - ai/provider — provider interface, registry, model specs, shared utilities. - ai/mcp — model context protocol, @ai-sdk/mcp, mcp tools. - ai/gateway — @ai-sdk/gateway, provider routing, oidc. - ai/telemetry — opentelemetry, tracing, spans. - ai/codemod — codemods, migration scripts. - expo — react native, expo, metro bundler. - tools-registry — tool packages, shared tool definitions. - codex — codex functionality. - - provider labels (use alongside ai/provider): - provider/openai, provider/anthropic, provider/google, provider/google-vertex, provider/azure, provider/amazon-bedrock, provider/xai, provider/mistral, provider/cohere, provider/groq, provider/deepseek, provider/fireworks, provider/togetherai, provider/perplexity, provider/replicate, provider/huggingface, provider/cerebras, provider/deepinfra, provider/baseten, provider/fal, provider/luma, provider/black-forest-labs, provider/gateway, provider/vercel. - provider/openai-compatible — providers using the openai-compatible base. - provider/community — community-maintained providers. - audio/speech providers: provider/elevenlabs, provider/lmnt, provider/hume, provider/deepgram, provider/assemblyai, provider/gladia, provider/revai. + ai/core - core sdk apis such as generatetext, streamtext, generateobject, streamobject, tool calling, structured output, output, steps, and middleware. + ai/ui - usechat, usecompletion, useassistant, uimessage, react ui hooks, and frontend streaming. + ai/rsc - @ai-sdk/rsc, createstreamableui, and createstreamablevalue. + ai/mcp - @ai-sdk/mcp and model context protocol integrations. + ai/provider - provider packages, provider registry, provider utils, model specs, shared provider infrastructure, and provider-facing gateway work. - type labels: - bug — confirmed defects, regressions, crashes, incorrect behavior. - feature — new capabilities that don't exist yet. - documentation — docs, typos, missing guides, broken links. - maintenance — dependency updates, refactoring, ci/cd, tooling, tests. - support — questions, help requests, "how do i", confusion, setup issues. this is the most common label for user-filed issues. - deprecation — marking apis or patterns as deprecated. - never assign wontfix. + provider labels: + provider/openai, provider/anthropic, provider/google, provider/google-vertex, provider/azure, provider/amazon-bedrock, provider/xai, provider/mistral, provider/cohere, provider/groq, provider/deepseek, provider/fireworks, provider/togetherai, provider/perplexity, provider/replicate, provider/huggingface, provider/cerebras, provider/deepinfra, provider/baseten, provider/fal, provider/luma, provider/black-forest-labs, provider/gateway, provider/vercel, provider/assemblyai, provider/deepgram, provider/elevenlabs, provider/gladia, provider/hume, provider/lmnt, provider/revai, provider/openai-compatible, provider/community. + use exactly the provider labels that match the issue or pr. if the issue is about the openai-compatible base rather than a specific provider, use provider/openai-compatible. if it is about a community-maintained provider, use provider/community. + use provider/openai for the official openai provider, the responses api, chat completions api, official openai docs, or @ai-sdk/openai. + use provider/openai-compatible only for the openai-compatible package, adapters built on that package, or third-party compatible backends such as litellm. + if the report is about official openai behavior but references openai-compatible internals because that code path powers the implementation, still use provider/openai. - triage labels: - reproduction needed — bug report without repro steps or code. - reproduction provided — bug report with code, repo link, or clear steps. - good first issue — small well-scoped tasks for new contributors. - pull request welcome — tasks where community prs are appreciated. - external — issue caused by something outside the ai sdk. - resumability — resumable streams and recovery. + type labels: + support - questions, help requests, setup issues, confusion, "how do i", and expected behavior checks. this is the most common label. + bug - clear defects, regressions, crashes, or behavior that does not match the documented sdk behavior. + feature - new capabilities or feature requests that do not exist yet. + documentation - docs improvements, missing guides, typos, broken links, and docs-only prs. + maintenance - ci, internal docs, automations, tooling, tests, refactors, and dependency work. + deprecation - only for pull requests that introduce a deprecation. - version labels (prs only): - major — breaking changes. minor — new features. backport — should be backported. + bug and support are mutually exclusive. - workflow labels: - type:batch — coordinated changes across packages. - type:epic — large tracked initiatives. + triage labels: + reproduction needed - bug reports that do not include a runnable reproduction. + reproduction provided - only when the issue or pr includes a real code snippet, repo link, or runnable example that demonstrates the problem. prose steps alone are not enough. patterns: - user says "error" or "doesn't work" but is asking how to use something → support, not bug. - provider-specific issues get ai/provider + the specific provider label. - issues about streaming, generateText, tool calling → ai/core. - issues about useChat, useCompletion, hooks → ai/ui. - docs-only prs → documentation. - dependency updates → maintenance. + provider-specific issues get ai/provider plus the matching provider/* label. + if a report mentions a provider package name, provider option namespace, or provider model id like google-vertex:* or openai/*, add ai/provider plus the matching provider/* label even when the main area label is ai/core or ai/ui. + gateway model ids like google/* inside ai gateway flows still need provider/gateway and the matching upstream provider label. + issues about generatetext, streamtext, generateobject, output.object, tool loops, maxsteps, structured output, experimental_output, or tool calling should usually include ai/core unless the report is clearly about ai/ui or ai/rsc. + issues about @ai-sdk/gateway belong under ai/provider with provider/gateway. + issues about generatetext, streamtext, tool calling, structured output, steps, or middleware usually belong to ai/core. + issues about usechat, usecompletion, useassistant, or ui streaming usually belong to ai/ui. + issues about @ai-sdk/mcp or mcp tools belong to ai/mcp. + issues about dynamictooluipart, mcp app rendering, tool ui parts, or frontend rendering of mcp tool results belong to ai/ui and ai/mcp together. + mcp protocol mismatches, unsupported protocol version errors, and mcp connection failures are bug or support cases, not feature requests. + issues about @ai-sdk/rsc belong to ai/rsc. + if the author is asking whether existing behavior is expected, asks "am i missing something", or asks "is there a reason" about current support, prefer support over feature. + if a title sounds like a feature request but the body is mainly asking whether current support should already exist or whether behavior is expected, prefer support over feature. + questions about accepted file types, supported inputs, current provider capabilities, or whether a provider should already support something are support unless the issue clearly asks to build a brand new capability. + automated provider model change issues belong to maintenance with ai/provider and the matching provider/* label. do not label them as feature just because they list new models. + if the report is mostly user confusion or usage help, prefer support over bug. + if you apply reproduction needed or reproduction provided, only do so on bug reports. diff --git a/app/api/dashboard/logs/route.ts b/app/api/dashboard/logs/route.ts index 9a61898..eb7085b 100644 --- a/app/api/dashboard/logs/route.ts +++ b/app/api/dashboard/logs/route.ts @@ -10,6 +10,10 @@ export async function GET(req: NextRequest) { const repo = req.nextUrl.searchParams.get('repo'); if (!repo) return NextResponse.json([]); - const logs = await readlogs(repo); + const limit = Number.parseInt( + req.nextUrl.searchParams.get('limit') || '100', + 10, + ); + const logs = await readlogs(repo, 0, Number.isNaN(limit) ? 100 : limit); return NextResponse.json(logs); } diff --git a/app/api/webhook/feedback.ts b/app/api/webhook/feedback.ts index 6d64716..5e94921 100644 --- a/app/api/webhook/feedback.ts +++ b/app/api/webhook/feedback.ts @@ -1,13 +1,21 @@ import { generateText, Output } from 'ai'; import { z } from 'zod'; -import type { Gh, Config, Label } from './triage'; -import { classify, fetchlabels, addlabels } from './triage'; +import type { Config } from '@/app/lib/config'; +import { readcontext } from '@/app/lib/logging'; +import { writelog } from '@/app/lib/logging'; +import { writememory } from '@/app/lib/memory'; +import { call } from '@/app/lib/model'; +import { filterlabels } from '@/app/lib/policy'; import { createpr } from './learn'; +import type { Gh, Label } from './triage'; +import { addlabels, classify, fetchlabels, summarizepr } from './triage'; interface Issue { number: number; title: string; body: string | null; + html_url: string; + pull_request?: { url?: string }; } interface Payload { @@ -15,168 +23,373 @@ interface Payload { id: number; body?: string; author_association?: string; + user?: { login?: string } | null; }; issue: Issue; } -const intentschema = z.object({ - intent: z.enum(['why', 'wrong', 'learn', 'unsupported']), - labels: z.array(z.string()), +interface Tools { + react: (gh: Gh, comment: number, content: 'eyes' | '-1') => Promise; + reply: (gh: Gh, issue: number, body: string) => Promise; + learn: typeof createpr; + memory: typeof writememory; + log: typeof writelog; +} + +const schema = z.object({ + explain: z.boolean(), + learn: z.boolean(), + add: z.array(z.string()), + remove: z.array(z.string()), + block: z.array(z.string()), + clarify: z.string(), reply: z.string(), }); -async function parseintent( +function canon(labels: Label[]) { + return new Map(labels.map(label => [label.name.toLowerCase(), label.name])); +} + +function match(names: string[], labels: Label[]) { + const items = canon(labels); + return [...new Set(names)] + .map(name => items.get(name.toLowerCase())) + .filter(Boolean) as string[]; +} + +function detail(names: string[], labels: Label[], reason: string) { + const items = new Map(labels.map(label => [label.name, label.color])); + return names.map(name => ({ + name, + reason, + color: items.get(name) || '', + })); +} + +function nextlabels(current: string[], add: string[], remove: string[]) { + const removed = new Set(remove.map(name => name.toLowerCase())); + const kept = current.filter(name => !removed.has(name.toLowerCase())); + return [...new Set([...kept, ...add])]; +} + +export async function planmessage( config: Config, - comment: string, - available: string[], + issue: { + kind: 'issue' | 'pr'; + title: string; + body: string | null; + context?: string; + }, + message: string, + available: Label[], + current: string[], ) { + const list = available + .map(label => + label.description + ? `- ${label.name}: ${label.description}` + : `- ${label.name}`, + ) + .join('\n'); const { output } = await generateText({ - model: config.model, - output: Output.object({ schema: intentschema }), - system: `you parse commands directed at a github issue labeling bot called tigent. + ...call(config.model), + output: Output.object({ schema }), + system: `you plan actions for tigent, a github labeling agent. -classify the intent: -- "why": user wants to know why labels were assigned or wants the bot to explain its reasoning. -- "wrong": user is correcting the labels. extract the correct label names into the labels array. just fix the labels, do not update rules. -- "learn": user is correcting labels AND explicitly asking to update the rules or make a pr. look for phrases like "update your rules", "learn this", "make a pr", "fix your prompt", "remember this". extract the correct label names into the labels array. -- "unsupported": user is asking the bot to do something it cannot do (close, delete, assign, merge, etc). write a short one-sentence reply explaining what tigent can do instead. +capabilities: +- explain why labels were chosen +- add labels +- remove labels +- update the repo blocklist in tigent.yml +- open a learning pr to improve future behavior +- ask for clarification +- reply naturally when a request is unsupported -available labels: ${available.join(', ')} +available labels: +${list} +current labels: ${current.join(', ') || '(none)'} +repo blocklist: ${config.blocklist.join(', ') || '(none)'} rules: -- for "why" intent, labels array should be empty, reply should be empty. -- for "wrong" intent, extract every label the user mentions. reply should be empty. -- for "learn" intent, extract every label the user mentions. reply should be empty. -- for "unsupported" intent, labels array should be empty. reply should be brief and lowercase. -- only include labels that exist in the available labels list. -- default to "wrong" not "learn" unless the user explicitly asks to update rules or learn.`, - prompt: comment, +- only include labels that exist in the available labels list +- use the issue or pr context to understand what the maintainer is referring to +- only add labels that the maintainer directly requests or clearly identifies as the correct outcome +- when a maintainer directly asks to add a specific label, include it in add even if the repo blocklist will stop tigent from applying it later +- only remove labels that the maintainer directly asks to remove or clearly says are wrong +- if the maintainer only says something is wrong without naming the right labels, ask for clarification instead of inferring the fix from issue context alone +- put labels in block when the maintainer says tigent should never add them, should leave them to humans, or should add them manually +- when the maintainer says tigent should leave a currently applied label to humans, also remove that label +- set learn to true when the maintainer asks tigent to learn, remember, update its config, update its rules, or make a pr +- set learn to true when the maintainer is defining lasting blocklist or labeling guidance +- set explain to true when the maintainer asks why or asks for reasoning +- use clarify when the maintainer says labels are wrong but the desired action is not clear enough to execute safely +- use reply for unsupported requests or brief status text +- keep reply lowercase and short`, + prompt: `kind: ${issue.kind} + +title: ${issue.title} + +body: +${issue.body || 'no description'}${issue.context ? `\n\nextra context:\n${issue.context}` : ''} + +maintainer message: +${message}`, }); return output!; } -export async function handlecomment(gh: Gh, config: Config, payload: Payload) { - const comment = payload.comment; - const body: string = comment.body?.trim() || ''; - const association: string = comment.author_association || ''; +async function explain( + gh: Gh, + config: Config, + issue: Issue, + labels: Label[], + context?: string, +) { + const result = await classify( + gh, + config, + labels, + issue.title, + issue.body || '', + context, + ); + const names = result.labels.map(label => label.name).join(', ') || '(none)'; + const rows = result.labels.map( + label => `- **${label.name}**: ${label.reason}`, + ); + if (result.blocked.length > 0) { + rows.push( + ...result.blocked.map(item => `- **${item.name}**: ${item.reason}`), + ); + } + if (result.memories.length > 0) { + rows.push( + ...result.memories.map( + item => `- **memory** ${item.title}: ${item.summary}`, + ), + ); + } + return `**labels:** ${names}\n\n${rows.join('\n')}`; +} - if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return; +function blockedreply(items: string[]) { + if (items.length === 1) { + return `i didn't add ${items[0]} because it is in this repo's blocklist.`; + } - const match = body.match(/^@tigent\s+(.+)/is); - if (!match) return; + return `i didn't add ${items.join(', ')} because they are in this repo's blocklist.`; +} + +async function reactcomment(gh: Gh, comment: number, content: 'eyes' | '-1') { + await gh.octokit.rest.reactions.createForIssueComment({ + owner: gh.owner, + repo: gh.repo, + comment_id: comment, + content, + }); +} - await reactcomment(gh, comment.id); +async function context(gh: Gh, config: Config, issue: Issue) { + if (!issue.pull_request) return ''; - const repolabels = await fetchlabels(gh); - const available = repolabels.map(l => l.name); - const message = match[1]!.trim(); - const issue = payload.issue; + const repo = `${gh.owner}/${gh.repo}`; + const stored = await readcontext(repo, issue.number, 'pr'); + if (stored) return stored; - const intent = await parseintent(config, message, available); + const summary = await summarizepr(gh, config, issue.number); + return summary.context; +} - if (intent.intent === 'why') { - await handlewhy(gh, config, issue, repolabels); - } else if (intent.intent === 'wrong' && intent.labels.length > 0) { - await handlewrong(gh, config, issue, intent.labels, repolabels, false); - } else if (intent.intent === 'learn' && intent.labels.length > 0) { - await handlewrong(gh, config, issue, intent.labels, repolabels, true); - } else if (intent.intent === 'unsupported' && intent.reply) { +const defaults: Tools = { + react: reactcomment, + reply: async (gh, issue, body) => { await gh.octokit.rest.issues.createComment({ owner: gh.owner, repo: gh.repo, - issue_number: issue.number, - body: intent.reply, + issue_number: issue, + body, }); - } -} + }, + learn: createpr, + memory: writememory, + log: writelog, +}; -async function handlewhy( +export async function handlecomment( gh: Gh, config: Config, - issue: Issue, - labels: Label[], + payload: Payload, + tools: Partial = {}, ) { - const result = await classify(config, labels, issue.title, issue.body || ''); - const labelstr = result.labels.map(l => l.name).join(', '); - const reasons = result.labels - .map(l => `- **${l.name}**: ${l.reason}`) - .join('\n'); - const body = `**labels:** ${labelstr}\n\n${reasons}`; + const live = { ...defaults, ...tools }; + const comment = payload.comment; + const body = comment.body?.trim() || ''; + const association = comment.author_association || ''; + if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return; + + const matchtext = body.match(/^@tigent\s+(.+)/is); + if (!matchtext) return; - await gh.octokit.rest.issues.createComment({ + const labels = await fetchlabels(gh); + const issue = payload.issue; + const url = issue.html_url; + const kind = issue.pull_request ? 'pr' : 'issue'; + const current = await gh.octokit.rest.issues.listLabelsOnIssue({ owner: gh.owner, repo: gh.repo, issue_number: issue.number, - body, }); -} + const existing = current.data.map(label => label.name); + const message = matchtext[1]!.trim(); + const extra = await context(gh, config, issue); + const actions = await planmessage( + config, + { + kind, + title: issue.title, + body: issue.body || '', + context: extra, + }, + message, + labels, + existing, + ); -async function handlewrong( - gh: Gh, - config: Config, - issue: Issue, - correctlabels: string[], - repolabels: Label[], - learn: boolean, -) { - const [result, current] = await Promise.all([ - classify(config, repolabels, issue.title, issue.body || ''), - gh.octokit.rest.issues.listLabelsOnIssue({ - owner: gh.owner, - repo: gh.repo, - issue_number: issue.number, - }), - ]); - - const ailabels = result.labels.map(l => l.name); - const existing = current.data.map(l => l.name); - - const lowercorrect = correctlabels.map(l => l.toLowerCase()); - for (const label of ailabels) { - if ( - existing.includes(label) && - !lowercorrect.includes(label.toLowerCase()) - ) { + const add = match(actions.add, labels); + const remove = match(actions.remove, labels); + const block = match(actions.block, labels); + const repo = `${gh.owner}/${gh.repo}`; + const parts: string[] = []; + const policy = filterlabels(config.blocklist, add); + const blocked = policy.blocked; + const allowed = policy.allowed; + + if (actions.clarify) { + await live.react(gh, comment.id, 'eyes'); + await live.reply(gh, issue.number, actions.clarify); + await live.log(repo, { + type: 'feedback', + action: 'clarify', + number: issue.number, + title: issue.title, + labels: [], + rejected: [], + blocked: [], + memories: [], + summary: actions.clarify, + author: comment.user?.login || '', + url, + message, + model: config.model, + }); + return; + } + + if (actions.explain) { + parts.push(await explain(gh, config, issue, labels, extra)); + } + + if (remove.length > 0) { + for (const name of remove) { + if (!existing.some(item => item.toLowerCase() === name.toLowerCase())) + continue; await gh.octokit.rest.issues.removeLabel({ owner: gh.owner, repo: gh.repo, issue_number: issue.number, - name: label, + name, }); } + parts.push(`removed: ${remove.join(', ')}`); } - 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 (allowed.length > 0) { + await addlabels(gh, issue.number, allowed); + parts.push(`added: ${allowed.join(', ')}`); + } - if (matchedlabels.length > 0) { - await addlabels(gh, issue.number, matchedlabels); + if (blocked.length > 0) { + parts.push(blockedreply(blocked.map(item => item.name))); } - if (learn) { - await createpr( + const target = nextlabels(existing, allowed, remove); + let learning = null as null | Awaited>; + + if (actions.learn || block.length > 0) { + learning = await live.learn( gh, - issue.number, - issue.title, - matchedlabels, - ailabels, + { + kind, + number: issue.number, + title: issue.title, + body: issue.body || '', + message, + correct: target, + labels: existing, + block, + }, config, ); + parts.push( + learning.verified + ? `opened learning pr: ${learning.url}` + : `opened draft learning pr: ${learning.url}`, + ); } -} -async function reactcomment(gh: Gh, commentid: number) { - await gh.octokit.rest.reactions.createForIssueComment({ - owner: gh.owner, - repo: gh.repo, - comment_id: commentid, - content: 'eyes', + if (actions.reply) parts.push(actions.reply); + if (parts.length === 0) { + parts.push( + 'tell me which labels to add, remove, explain, or keep out of tigent.', + ); + } + + const reply = parts.join('\n\n'); + await live.react(gh, comment.id, blocked.length > 0 ? '-1' : 'eyes'); + await live.reply(gh, issue.number, reply); + + if ( + allowed.length > 0 || + remove.length > 0 || + block.length > 0 || + actions.learn + ) { + await live.memory(repo, { + kind, + number: issue.number, + title: issue.title, + body: issue.body || '', + message, + summary: reply, + labels: existing, + correct: target, + source: + block.length > 0 ? 'blocklist' : actions.learn ? 'learn' : 'correction', + author: comment.user?.login || '', + verified: true, + }); + } + + await live.log(repo, { + type: 'feedback', + action: + block.length > 0 + ? 'blocklist' + : actions.learn + ? 'learn' + : actions.explain && allowed.length === 0 && remove.length === 0 + ? 'explain' + : 'relabel', + number: issue.number, + title: issue.title, + labels: detail(allowed, labels, 'requested by maintainer'), + rejected: [], + blocked, + memories: [], + summary: reply, + author: comment.user?.login || '', + url, + message, + model: config.model, }); } diff --git a/app/api/webhook/learn.ts b/app/api/webhook/learn.ts index 2fd4c4a..69d4a7c 100644 --- a/app/api/webhook/learn.ts +++ b/app/api/webhook/learn.ts @@ -1,53 +1,123 @@ import { generateText } from 'ai'; -import { parse, stringify } from 'yaml'; -import type { Gh, Config } from './triage'; - -async function updatedprompt( - current: string, - context: string, - model: string, -): Promise { +import { renderconfig } from '@/app/lib/config'; +import type { Config } from '@/app/lib/config'; +import { call } from '@/app/lib/model'; +import type { Gh } from './triage'; +import { classify, fetchlabels } from './triage'; + +export interface Learn { + kind: 'issue' | 'pr'; + number: number; + title: string; + body: string; + message: string; + correct: string[]; + labels: string[]; + block: string[]; +} + +function unique(items: string[]) { + return [...new Set(items.map(item => item.trim()).filter(Boolean))]; +} + +function merge(config: Config, learn: Learn): Config { + return { + ...config, + blocklist: unique([...config.blocklist, ...learn.block]), + }; +} + +function context(learn: Learn) { + return [ + `kind: ${learn.kind}`, + `title: ${learn.title}`, + `maintainer message: ${learn.message || '(none)'}`, + `ai labels: ${learn.labels.join(', ') || '(none)'}`, + `correct labels: ${learn.correct.join(', ') || '(none)'}`, + `blocked labels: ${learn.block.join(', ') || '(none)'}`, + ].join('\n'); +} + +async function updateprompt(current: string, info: string, model: string) { const { text } = await generateText({ - model, - system: `you maintain a ruleset for a github issue labeling bot. given the current rules and a correction, output the updated rules. + ...call(model), + system: `you maintain a ruleset for a github labeling bot. given the current rules and maintainer guidance, output the updated rules. instructions: -- if an existing rule contradicts the correction, update that rule in place -- if no existing rule covers this case, append a new line at the end -- do not reference specific issue titles or numbers -- focus on general patterns, not one-off cases -- keep every existing rule that is not contradicted -- preserve the exact formatting, line breaks, and structure -- output only the full updated rules, nothing else`, - prompt: `current rules:\n${current || '(none)'}\n\ncorrection:\n${context}`, +- keep formatting simple and compact +- preserve any valid rule that is still useful +- update contradictory rules in place +- add new generalized rules when needed +- do not mention specific issue or pr numbers +- do not mention one-off examples +- do not restate blocklist rules +- output only the full updated rules`, + prompt: `current rules:\n${current || '(none)'}\n\nmaintainer guidance:\n${info}`, }); return text.trim(); } -export async function createpr( - gh: Gh, - issue: number, - title: string, - correctlabels: string[], - ailabels: string[], - config: Config, -) { - const context = `issue: "${title}"\nai assigned: ${ailabels.join(', ') || '(none)'}\ncorrect labels: ${correctlabels.join(', ')}`; - const newprompt = await updatedprompt(config.prompt, context, config.model); +function same(left: string[], right: string[]) { + const one = [...new Set(left.map(item => item.toLowerCase()))].sort(); + const two = [...new Set(right.map(item => item.toLowerCase()))].sort(); + return ( + one.length === two.length && one.every((item, index) => item === two[index]) + ); +} + +async function verify(gh: Gh, config: Config, learn: Learn) { + if (learn.correct.length === 0) { + return { ok: true, labels: [] as string[] }; + } + const labels = await fetchlabels(gh); + const result = await classify( + gh, + config, + labels, + learn.title, + learn.body, + `maintainer guidance:\n${learn.message}`, + ); + const picked = result.labels.map(label => label.name); + return { + ok: same(picked, learn.correct), + labels: picked, + }; +} + +export async function createpr(gh: Gh, learn: Learn, config: Config) { + let next = merge(config, learn); + let attempts = 0; + let verified = learn.correct.length === 0; + let picked: string[] = []; + + if (learn.correct.length > 0 || (learn.message && learn.block.length === 0)) { + let prompt = next.prompt; + let info = context(learn); + + while (attempts < 3) { + attempts += 1; + prompt = await updateprompt(prompt, info, next.model); + next = { ...next, prompt }; + const result = await verify(gh, next, learn); + verified = result.ok; + picked = result.labels; + if (verified) break; + info = `${context(learn)}\n\nverification failed. expected labels: ${learn.correct.join(', ') || '(none)'}\nactual labels: ${picked.join(', ') || '(none)'}`; + } + } const { data: repo } = await gh.octokit.rest.repos.get({ owner: gh.owner, repo: gh.repo, }); - const branch = `tigent/learn-${issue}`; - const defaultbranch = repo.default_branch; - + const branch = `tigent/learn-${learn.number}`; + const base = repo.default_branch; const { data: ref } = await gh.octokit.rest.git.getRef({ owner: gh.owner, repo: gh.repo, - ref: `heads/${defaultbranch}`, + ref: `heads/${base}`, }); - const sha = ref.object.sha; try { await gh.octokit.rest.git.deleteRef({ @@ -61,54 +131,64 @@ export async function createpr( owner: gh.owner, repo: gh.repo, ref: `refs/heads/${branch}`, - sha, + sha: ref.object.sha, }); - let fileconfig: Partial = {}; - let filesha: string | undefined; - + let sha: string | undefined; try { const { data } = await gh.octokit.rest.repos.getContent({ owner: gh.owner, repo: gh.repo, path: '.github/tigent.yml', - ref: defaultbranch, + ref: base, }); - if ('content' in data) { - const content = Buffer.from(data.content, 'base64').toString(); - fileconfig = (parse(content) as Partial) || {}; - filesha = data.sha; - } + if ('sha' in data) sha = data.sha; } catch {} - fileconfig.prompt = newprompt; - delete (fileconfig as any).prompt; - - const rest = Object.keys(fileconfig).length > 0 ? stringify(fileconfig) : ''; - const promptlines = newprompt - .split('\n') - .map(l => (l ? ` ${l}` : '')) - .join('\n'); - const yaml = rest - ? `${rest}\nprompt: |\n${promptlines}\n` - : `prompt: |\n${promptlines}\n`; - + const yaml = renderconfig(next); await gh.octokit.rest.repos.createOrUpdateFileContents({ owner: gh.owner, repo: gh.repo, path: '.github/tigent.yml', - message: `fix: update prompt from #${issue}`, + message: `fix: update tigent config from #${learn.number}`, content: Buffer.from(yaml).toString('base64'), branch, - ...(filesha ? { sha: filesha } : {}), + ...(sha ? { sha } : {}), }); - await gh.octokit.rest.pulls.create({ + const body = [ + `updates \\.github/tigent.yml\\ from #${learn.number}.`, + '', + `verification: ${verified ? 'passed' : 'failed'}`, + `attempts: ${attempts}`, + learn.correct.length > 0 + ? `expected labels: ${learn.correct.join(', ')}` + : 'expected labels: (none)', + picked.length > 0 + ? `verified labels: ${picked.join(', ')}` + : 'verified labels: (none)', + learn.block.length > 0 + ? `blocklist additions: ${learn.block.join(', ')}` + : '', + learn.message ? `maintainer message: ${learn.message}` : '', + ] + .filter(Boolean) + .join('\n'); + + const pr = await gh.octokit.rest.pulls.create({ owner: gh.owner, repo: gh.repo, - title: `fix: learn from #${issue} correction`, - body: `updates prompt in \`.github/tigent.yml\` from issue #${issue} correction.\n\n**correction:** ai assigned ${ailabels.join(', ') || '(none)'}, correct labels are ${correctlabels.join(', ')}.`, + title: `fix: learn from #${learn.number}`, + body, head: branch, - base: defaultbranch, + base, + draft: !verified, }); + + return { + verified, + attempts, + picked, + url: pr.data.html_url, + }; } diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index c6c7e9b..1dd1038 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -1,27 +1,28 @@ -import { App } from 'octokit'; -import { getconfig, triageissue, triagepr, react } from './triage'; -import { handlecomment } from './feedback'; +import { getapp } from '@/app/lib/github'; import { writelog } from '@/app/lib/logging'; +import { allowed } from '@/app/lib/scope'; +import { handlecomment } from './feedback'; +import { getconfig, react, triageissue, triagepr } from './triage'; -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! }, -}); +const app = getapp(); app.webhooks.on('issues.opened', async ({ octokit, payload }) => { const owner = payload.repository.owner.login; const repo = payload.repository.name; + if (!allowed(owner, repo)) return; const gh = { octokit, owner, repo }; try { const config = await getconfig(gh); const result = await triageissue(gh, config, payload.issue.number); await writelog(`${owner}/${repo}`, { type: 'issue', + action: 'triage', number: payload.issue.number, title: result.title, labels: result.labels, rejected: result.rejected, + blocked: result.blocked, + memories: result.memories, confidence: result.confidence, summary: result.summary, model: config.model, @@ -39,22 +40,27 @@ app.webhooks.on('issues.opened', async ({ octokit, payload }) => { app.webhooks.on('pull_request.opened', async ({ octokit, payload }) => { const owner = payload.repository.owner.login; const repo = payload.repository.name; + if (!allowed(owner, repo)) return; const gh = { octokit, owner, repo }; try { const config = await getconfig(gh); const result = await triagepr(gh, config, payload.pull_request.number); await writelog(`${owner}/${repo}`, { type: 'pr', + action: 'triage', number: payload.pull_request.number, title: result.title, labels: result.labels, rejected: result.rejected, + blocked: result.blocked, + memories: result.memories, confidence: result.confidence, summary: result.summary, model: config.model, duration: result.duration, author: result.author, url: result.url, + context: result.context, skipped: result.skipped, available: result.available, }); @@ -66,6 +72,7 @@ app.webhooks.on('pull_request.opened', async ({ octokit, payload }) => { app.webhooks.on('issue_comment.created', async ({ octokit, payload }) => { const owner = payload.repository.owner.login; const repo = payload.repository.name; + if (!allowed(owner, repo)) return; const gh = { octokit, owner, repo }; try { const config = await getconfig(gh); diff --git a/app/api/webhook/triage.ts b/app/api/webhook/triage.ts index 2a8e03d..8595190 100644 --- a/app/api/webhook/triage.ts +++ b/app/api/webhook/triage.ts @@ -1,7 +1,11 @@ import { generateText, Output } from 'ai'; -import { z } from 'zod'; -import { parse } from 'yaml'; import type { Octokit } from 'octokit'; +import { z } from 'zod'; +import { parseconfig } from '@/app/lib/config'; +import type { Config } from '@/app/lib/config'; +import { matchmemory } from '@/app/lib/memory'; +import { call } from '@/app/lib/model'; +import { filterlabels } from '@/app/lib/policy'; export interface Gh { octokit: Octokit; @@ -9,21 +13,47 @@ export interface Gh { repo: string; } -export interface Config { - model: string; - prompt: string; -} - export interface Label { name: string; description: string; color: string; } -export const defaultconfig: Config = { - model: 'google/gemini-2.5-flash', - prompt: '', -}; +export const schema = z.object({ + labels: z.array( + z.object({ + name: z.string(), + reason: z.string(), + }), + ), + rejected: z.array( + z.object({ + name: z.string(), + reason: z.string(), + }), + ), + confidence: z.enum(['high', 'medium', 'low']), + summary: z.string(), +}); + +function canon(labels: Label[]) { + return new Map(labels.map(label => [label.name.toLowerCase(), label])); +} + +function memoryblock(items: Awaited>) { + if (items.length === 0) return ''; + return `\nrelevant repo memory:\n${items + .map( + item => + `- ${item.title}\n labels: ${item.correct.join(', ') || '(none)'}\n source: ${item.source}\n note: ${item.summary}`, + ) + .join('\n')}`; +} + +function policyblock(config: Config) { + if (config.blocklist.length === 0) return ''; + return `\nrepo blocklist:\n- ${config.blocklist.join('\n- ')}`; +} export async function getconfig(gh: Gh): Promise { try { @@ -33,11 +63,9 @@ export async function getconfig(gh: Gh): Promise { path: '.github/tigent.yml', mediaType: { format: 'raw' }, }); - const yaml = data as unknown as string; - const parsed = parse(yaml) as Partial; - return { ...defaultconfig, ...parsed }; + return parseconfig(data as unknown as string); } catch { - return defaultconfig; + return parseconfig(); } } @@ -47,10 +75,10 @@ export async function fetchlabels(gh: Gh): Promise { repo: gh.repo, per_page: 100, }); - return data.map(l => ({ - name: l.name, - description: l.description || '', - color: l.color, + return data.map(label => ({ + name: label.name, + description: label.description || '', + color: label.color, })); } @@ -64,6 +92,44 @@ export async function addlabels(gh: Gh, issue: number, labels: string[]) { }); } +export async function summarizepr(gh: Gh, config: Config, number: number) { + const [pr, files] = 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, + }), + ]); + + const diff = files.data + .map(file => { + const patch = file.patch ? `\n${file.patch}` : ''; + return `${file.filename} (+${file.additions} -${file.deletions})${patch}`; + }) + .join('\n\n'); + + const { text } = await generateText({ + ...call(config.model), + system: + 'summarize a pull request diff into 3-8 bullet points. focus on what changed and why, not line-by-line details. mention which packages or areas were modified. be concise.', + prompt: `title: ${pr.data.title}\n\nbody:\n${pr.data.body || 'no description'}\n\ndiff:\n${diff}`, + }); + + return { + title: pr.data.title, + body: pr.data.body || '', + url: pr.data.html_url, + author: pr.data.user?.login || '', + context: `pr summary:\n${text}`, + }; +} + export async function react(gh: Gh, issue: number, content: string = 'eyes') { await gh.octokit.rest.reactions.createForIssue({ owner: gh.owner, @@ -81,52 +147,46 @@ export async function react(gh: Gh, issue: number, content: string = 'eyes') { }); } -export const schema = z.object({ - labels: z.array( - z.object({ - name: z.string(), - reason: z.string(), - }), - ), - rejected: z.array( - z.object({ - name: z.string(), - reason: z.string(), - }), - ), - confidence: z.enum(['high', 'medium', 'low']), - summary: z.string(), -}); - -export type ClassifyResult = z.infer; - export async function classify( + gh: Gh, config: Config, labels: Label[], title: string, body: string, extra?: string, + overrides?: { memories?: Awaited> }, ) { - const labellist = labels - .map(l => (l.description ? `- ${l.name}: ${l.description}` : `- ${l.name}`)) + const items = + overrides?.memories || + (await matchmemory( + `${gh.owner}/${gh.repo}`, + [title, body, extra || ''].join('\n'), + )); + const list = labels + .map(label => + label.description + ? `- ${label.name}: ${label.description}` + : `- ${label.name}`, + ) .join('\n'); const system = `${config.prompt || 'you are a github issue classifier. assign labels based on the content.'} available labels: -${labellist} +${list}${policyblock(config)}${memoryblock(items)} 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 +- never choose labels that appear in the repo blocklist +- relevant repo memory represents past maintainer corrections and should be treated as precedent respond with: - labels: array of { name, reason } for each label you apply. reason should be one sentence explaining why. - rejected: array of { name, reason } for 2-4 labels you considered but rejected. reason should explain why it was close but not right. - confidence: "high", "medium", or "low" based on how sure you are about the labels. -- summary: one sentence summarizing what this issue/pr is about.`; +- summary: one sentence summarizing what this issue or pr is about.`; const prompt = `title: ${title} @@ -134,24 +194,49 @@ body: ${body || 'no description'}${extra ? `\n\n${extra}` : ''}`; const { output } = await generateText({ - model: config.model, + ...call(config.model), output: Output.object({ schema }), system, prompt, }); - const colormap = new Map(labels.map(l => [l.name, l.color])); - const valid = output!.labels - .filter(l => labels.some(x => x.name === l.name)) - .map(l => ({ ...l, color: colormap.get(l.name) || '' })); - const rejected = output!.rejected.map(l => ({ - ...l, - color: colormap.get(l.name) || '', - })); + + const names = canon(labels); + const picked = (output?.labels || []).flatMap(item => { + const match = names.get(item.name.toLowerCase()); + if (!match) return []; + return [ + { + name: match.name, + reason: item.reason, + color: match.color, + }, + ]; + }); + + const rejected = (output?.rejected || []) + .map(item => { + const match = names.get(item.name.toLowerCase()); + return { + name: match?.name || item.name, + reason: item.reason, + color: match?.color || '', + }; + }) + .filter(item => item.name); + + const policy = filterlabels( + config.blocklist, + picked.map(item => item.name), + ); + const allowed = new Set(policy.allowed.map(name => name.toLowerCase())); + return { - labels: valid, + labels: picked.filter(item => allowed.has(item.name.toLowerCase())), rejected, - confidence: output!.confidence, - summary: output!.summary, + blocked: policy.blocked, + memories: items, + confidence: output?.confidence, + summary: output?.summary || '', }; } @@ -169,19 +254,22 @@ export async function triageissue(gh: Gh, config: Config, number: number) { ]); const result = await classify( + gh, config, labels, issue.data.title, issue.data.body || '', ); - const labelnames = result.labels.map(l => l.name); - const skipped = labelnames.length === 0; - if (!skipped) await addlabels(gh, number, labelnames); + const names = result.labels.map(label => label.name); + const skipped = names.length === 0; + if (!skipped) await addlabels(gh, number, names); return { labels: result.labels, rejected: result.rejected, + blocked: result.blocked, + memories: result.memories, confidence: result.confidence, summary: result.summary, title: issue.data.title, @@ -197,56 +285,35 @@ export async function triagepr(gh: Gh, config: Config, number: number) { const start = Date.now(); 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, - }), + const [labels, summary] = await Promise.all([ fetchlabels(gh), + summarizepr(gh, config, number), ]); - const diff = files.data - .map(f => { - const patch = f.patch ? `\n${f.patch}` : ''; - return `${f.filename} (+${f.additions} -${f.deletions})${patch}`; - }) - .join('\n\n'); - - const { text: summary } = await generateText({ - model: config.model, - system: `summarize a pull request diff into 3-8 bullet points. focus on what changed and why, not line-by-line details. mention which packages or areas were modified. be concise.`, - prompt: `title: ${pr.data.title}\n\nbody:\n${pr.data.body || 'no description'}\n\ndiff:\n${diff}`, - }); - - const extra = `pr summary:\n${summary}`; - const result = await classify( + gh, config, labels, - pr.data.title, - pr.data.body || '', - extra, + summary.title, + summary.body, + summary.context, ); - const labelnames = result.labels.map(l => l.name); - const skipped = labelnames.length === 0; - if (!skipped) await addlabels(gh, number, labelnames); + const names = result.labels.map(label => label.name); + const skipped = names.length === 0; + if (!skipped) await addlabels(gh, number, names); return { labels: result.labels, rejected: result.rejected, + blocked: result.blocked, + memories: result.memories, confidence: result.confidence, summary: result.summary, - title: pr.data.title, - author: pr.data.user?.login || '', - url: pr.data.html_url, + title: summary.title, + author: summary.author, + url: summary.url, + context: summary.context, duration: Date.now() - start, skipped, available: labels.length, diff --git a/app/dashboard/[owner]/[repo]/config/page.tsx b/app/dashboard/[owner]/[repo]/config/page.tsx index 1a8f5cb..7861500 100644 --- a/app/dashboard/[owner]/[repo]/config/page.tsx +++ b/app/dashboard/[owner]/[repo]/config/page.tsx @@ -1,22 +1,7 @@ +import { readconfig } from '@/app/lib/repos'; import { getsession } from '@/app/lib/session'; -import { useroctokit } from '@/app/lib/github'; import { Config } from '../../../components/config'; -async function fetchconfig(token: string, owner: string, repo: string) { - const octokit = useroctokit(token); - try { - const { data } = await octokit.rest.repos.getContent({ - owner, - repo, - path: '.github/tigent.yml', - mediaType: { format: 'raw' }, - }); - return data as unknown as string; - } catch { - return null; - } -} - export default async function Page({ params, }: { @@ -25,18 +10,34 @@ export default async function Page({ const { owner, repo } = await params; const session = await getsession(); const content = session.token - ? await fetchconfig(session.token, owner, repo) + ? await readconfig(session.token, owner, repo) : null; return ( -
-
-

Configuration

-

- {owner}/{repo} -

+
+
+
+
+
+
+

+ {owner} / {repo} +

+

+ Inspect the live triage rules +

+

+ Blocked labels, model selection, and prompt guidance live here + so the operator console can stay focused on recent work. +

+
+
+
+
+
+ +
-
); } diff --git a/app/dashboard/[owner]/[repo]/page.tsx b/app/dashboard/[owner]/[repo]/page.tsx index 5acc6ad..df9bb83 100644 --- a/app/dashboard/[owner]/[repo]/page.tsx +++ b/app/dashboard/[owner]/[repo]/page.tsx @@ -1,5 +1,12 @@ -import { readlogs } from '@/app/lib/logging'; +import Link from 'next/link'; +import { parseconfig } from '@/app/lib/config'; +import { counts, readlogs } from '@/app/lib/logging'; +import { readmemory } from '@/app/lib/memory'; +import { label } from '@/app/lib/model'; +import { readconfig } from '@/app/lib/repos'; +import { getsession } from '@/app/lib/session'; import { Activity } from '../../components/activity'; +import { Memory } from '../../components/memory'; export default async function Page({ params, @@ -8,15 +15,77 @@ export default async function Page({ }) { const { owner, repo } = await params; const full = `${owner}/${repo}`; - const logs = await readlogs(full); + const session = await getsession(); + const [logs, stats, items, content] = await Promise.all([ + readlogs(full), + counts(full), + readmemory(full), + session.token ? readconfig(session.token, owner, repo) : null, + ]); + const rules = parseconfig(content || ''); return ( -
-
-

Dashboard

-

{full}

+
+
+
+
+
+
+

+ {owner} / {repo} +

+

+ Review triage without losing the thread +

+

+ Keep the thread visible, keep memory close, and move config + off the console. +

+

+ Recent activity stays scrollable, memory stays visible, and + configuration lives on its own page so the console stays + focused on operational work. +

+
+
+
+
+
+

+ model +

+

+ {label(rules.model)} +

+
+
+

+ blocklist +

+

+ {rules.blocklist.length} labels +

+
+
+ + view config + + +
+
+
+
+
+ +
+
+ +
+
-
); } diff --git a/app/dashboard/components/activity.tsx b/app/dashboard/components/activity.tsx index e16d6d2..1c8945d 100644 --- a/app/dashboard/components/activity.tsx +++ b/app/dashboard/components/activity.tsx @@ -1,11 +1,12 @@ 'use client'; import { useState } from 'react'; -import type { LogEntry } from '@/app/lib/logging'; +import type { Counts, LogEntry } from '@/app/lib/logging'; +import { label } from '@/app/lib/model'; import { Stats } from './stats'; -function timeago(ts: number) { - const diff = Date.now() - ts; +function timeago(timestamp: number) { + const diff = Date.now() - timestamp; const mins = Math.floor(diff / 60000); if (mins < 1) return 'now'; if (mins < 60) return `${mins}m`; @@ -15,221 +16,285 @@ function timeago(ts: number) { return `${days}d`; } -function ms(val: number) { - if (!val) return ''; - if (val < 1000) return `${val}ms`; - return `${(val / 1000).toFixed(1)}s`; +function ms(value?: number) { + if (!value) return ''; + if (value < 1000) return `${value}ms`; + return `${(value / 1000).toFixed(1)}s`; } -function modelname(model: string) { +function modelname(model?: string) { if (!model) return ''; - const parts = model.split('/'); - return parts[parts.length - 1] || model; + return label(model); } function islight(hex: string) { - const r = Number.parseInt(hex.slice(0, 2), 16); - const g = Number.parseInt(hex.slice(2, 4), 16); - const b = Number.parseInt(hex.slice(4, 6), 16); - return r * 0.299 + g * 0.587 + b * 0.114 > 150; + const red = Number.parseInt(hex.slice(0, 2), 16); + const green = Number.parseInt(hex.slice(2, 4), 16); + const blue = Number.parseInt(hex.slice(4, 6), 16); + return red * 0.299 + green * 0.587 + blue * 0.114 > 150; } -function labeldata(log: LogEntry): { name: string; color: string }[] { - if (!log.labels) return []; - if (typeof log.labels[0] === 'string') - return (log.labels as unknown as string[]).map(n => ({ - name: n, - color: '', - })); - return log.labels.map(l => ({ name: l.name, color: l.color || '' })); +function badge(name: string, color?: string) { + if (!color) { + return ( + + {name} + + ); + } + return ( + + {name} + + ); } -export function Activity({ logs }: { logs: LogEntry[] }) { - const [expanded, setExpanded] = useState(null); +function tone(log: LogEntry) { + if (log.type === 'feedback') return 'feedback'; + if (log.blocked.length > 0 && log.labels.length === 0) return 'blocked'; + if (log.skipped) return 'skip'; + return log.type; +} + +export function Activity({ + logs, + counts, +}: { + logs: LogEntry[]; + counts: Counts; +}) { + const [open, setopen] = useState(null); return ( -
- - -
-

Recent Activity

- {logs.length === 0 ? ( -
-

No activity yet

+
+
+
+
+

Recent Activity

+

+ the console stays fixed while this feed scrolls +

- ) : ( -
- {logs.map(log => { - const key = `${log.number}-${log.timestamp}`; - const open = expanded === key; - const ld = labeldata(log); - return ( - - ); - })} -
- )} + {log.rejected.length > 0 ? ( +
+

+ Considered +

+ {log.rejected.map(label => ( +
+ + {label.name} + + + {label.reason} + +
+ ))} +
+ ) : null} + + {log.memories.length > 0 ? ( +
+

+ Memory +

+ {log.memories.map(memory => ( +
+ + {memory.title} + + + {memory.summary} + +
+ ))} +
+ ) : null} +
+ )} + + ); + })} +
+ )} +
+ +
+
); diff --git a/app/dashboard/components/config.tsx b/app/dashboard/components/config.tsx index ece88e7..e53f049 100644 --- a/app/dashboard/components/config.tsx +++ b/app/dashboard/components/config.tsx @@ -1,3 +1,6 @@ +import { parseconfig } from '@/app/lib/config'; +import { label } from '@/app/lib/model'; + function indent(line: string) { const trimmed = line.replace(/^ +/, ''); const spaces = line.length - trimmed.length; @@ -5,31 +8,142 @@ function indent(line: string) { } export function Config({ content }: { content: string | null }) { + const config = parseconfig(content || ''); const lines = content ? content.split('\n') : []; + const prompt = config.prompt.split('\n').filter(Boolean); + const cards = [ + { + label: 'Model', + value: label(config.model), + detail: 'Classification runtime', + }, + { + label: 'Blocklist', + value: + config.blocklist.length === 0 + ? 'None' + : `${config.blocklist.length} labels`, + detail: 'Hard enforcement in code', + }, + { + label: 'Prompt', + value: prompt.length === 0 ? 'Defaults' : `${prompt.length} lines`, + detail: 'Repo guidance', + }, + ]; return ( -
- {lines.length > 0 ? ( -
- {lines.map((line, i) => { - if (line.trim() === '') return
; - const { text, pad } = indent(line); - return ( -

- {text} +

+
+
+ {cards.map(item => ( +
+

+ {item.label} +

+

+ {item.value} +

+

{item.detail}

+
+ ))} +
+
+
+
+

Blocklist

+

+ These labels are never applied by Tigent +

+
+
+ {config.blocklist.length === 0 ? ( +
+ No blocked labels configured +
+ ) : ( +
+ {config.blocklist.map(item => ( + + {item} + + ))} +
+ )} +
+
+
+
+

Prompt Preview

+

+ Keep blocklist rules out of the prompt and focus it on + classification guidance

- ); - })} +
+
+ {prompt.length === 0 ? ( +
+ Using default prompt behavior +
+ ) : ( +
+
+ {prompt.map((line, index) => ( +

+ {line} +

+ ))} +
+
+ )} +
+
- ) : ( -
-

Using defaults

+
+
+
+

Raw tigent.yml

+

+ The exact config file currently loaded from the repository +

- )} + {lines.length > 0 ? ( +
+
+ {lines.map((line, index) => { + const key = `${index}:${line}`; + if (line.trim() === '') + return
; + const { text, pad } = indent(line); + return ( +

+ {text} +

+ ); + })} +
+
+ ) : ( +
+
+

Using defaults

+
+
+ )} +
); } diff --git a/app/dashboard/components/empty.tsx b/app/dashboard/components/empty.tsx index b34811e..942ab82 100644 --- a/app/dashboard/components/empty.tsx +++ b/app/dashboard/components/empty.tsx @@ -1,25 +1,39 @@ export function Empty({ hasrepos }: { hasrepos: boolean }) { return ( -
- -

- {hasrepos ? 'Select a repository' : 'No repositories yet'} -

- {!hasrepos && ( - - Install - - )} +
+
+
+ +
+

+ Operator Console +

+

+ {hasrepos + ? 'Pick a repository to review' + : 'No repositories connected yet'} +

+

+ {hasrepos + ? 'Select a repo from the left rail to open activity, memory, and config in the same workspace.' + : 'Install Tigent on a repository to start triage, memory, and operator review from this dashboard.'} +

+ {!hasrepos ? ( + + Install Tigent + + ) : null} +
); } diff --git a/app/dashboard/components/header.tsx b/app/dashboard/components/header.tsx index bbd0f53..e638fc0 100644 --- a/app/dashboard/components/header.tsx +++ b/app/dashboard/components/header.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { usePathname } from 'next/navigation'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; interface User { username: string; @@ -22,7 +22,7 @@ export function Iconbar({ const parts = pathname.split('/').filter(Boolean); const repo = parts.length >= 3 ? `${parts[1]}/${parts[2]}` : null; - const onconfig = pathname.endsWith('/config'); + const ondashboard = pathname.startsWith('/dashboard'); useEffect(() => { const handler = (e: MouseEvent) => { @@ -36,6 +36,7 @@ export function Iconbar({ return (