diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3496193..b9fc8f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: plugins: uses: rocketflare-dev/rocketflare/.github/workflows/plugin-ci.yml@main with: - plugin_subdirs: '["plugins/analytics", "plugins/web-knowledge", "plugins/connectors", "plugins/m365"]' + plugin_subdirs: '["plugins/analytics", "plugins/web-knowledge", "plugins/connectors", "plugins/m365", "plugins/issue-reports"]' # On a kit-released dispatch, prove against the ref that was just cut — used verbatim, with # the range NOT consulted. That is what makes the notification worth sending: resolving the # range again would pick the same versions as the last run and prove nothing new. Empty on diff --git a/README.md b/README.md index 2777a91..8974e64 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ sees what and its known gaps. They are also published at | [**web-knowledge**](plugins/web-knowledge/README.md) | `web_search` and `fetch_page` tools for agents and chat on each organisation's own key (Tavily, Brave, Exa, Serper, Firecrawl), Settings → Web search | kit 0.9.0 | | [**connectors**](plugins/connectors/README.md) | Organisation connections: admin consent once, then directory and calendar sync every 15 minutes; Settings → Connections, My calendar, CLI and the `connectors` setup skill | kit 0.13.0 and a provider | | [**m365**](plugins/m365/README.md) | Microsoft 365 as a `connectors` provider: Entra admin consent, app-only Graph delta sync of users, groups and calendars | kit 0.12.0 and `connectors` | +| [**issue-reports**](plugins/issue-reports/README.md) | Ideas & issues: anyone talks a bug or an idea through with an agent that asks until it is clear, checks for a duplicate, and logs it as a GitHub issue (or a comment) once they approve the draft | kit 0.21.0 and a GitHub token | ## Installing one diff --git a/plugins/issue-reports/README.md b/plugins/issue-reports/README.md new file mode 100644 index 0000000..241c37e --- /dev/null +++ b/plugins/issue-reports/README.md @@ -0,0 +1,99 @@ +# issue-reports + +> Ideas & issues: anyone on the team talks a bug or an idea through with an agent, which turns it +> into a GitHub issue a developer can pick up without asking anything. + +A person describes what they noticed in their own words. The agent asks one plain-language question +at a time until it understands what they actually need, searches the repository for an existing +issue, and shows a draft. Nothing reaches GitHub until they pick **Log it on GitHub**; anything else +they type at that point is a change it redrafts from. If they confirm a duplicate, it comments on +that issue instead of opening a new one. + +## What it adds + +| Area | What you get | +|---|---| +| UI | **Ideas & issues** (`/ideas-and-issues`, nav group "Feedback"): start a conversation, answer the agent, review the draft, and see the conversations you have shared | +| Agent | `issue-reports`, non-exclusive: each person's conversation is its own run, parked on their answers, so many can be in progress at once | +| Prompt | `issue-reports` — override it per organisation in Settings → Prompts, pick its model in Settings → agent models | +| API | `GET /api/issue-reports/status` → `{ configured, repo }`. Everything else is the kit's own `/api/agents/runs…` | +| GitHub | Four REST calls over `fetch`, with no Octokit: search issues, read one, open one, comment on one | +| Data | None. The agent run is the record: its input is what they first said, its interrupts are the conversation, its output is the issue it filed | + +## Requirements + +- Rocketflare kit **0.21.0** or later (a plugin agent's `interrupt`, `chatModel` and non-exclusive + runs). +- The kit's agents surface (`feature-agents`) and a chat model, through any of the kit's AI setups. +- A GitHub repository and a fine-grained token with **Issues: read and write** on that one + repository. + +## Install + +```bash +pnpm plugin add https://github.com/rocketflare-dev/rocketflare-plugins.git --subdir plugins/issue-reports +pnpm plugin add https://github.com/rocketflare-dev/rocketflare-plugins.git --subdir plugins/issue-reports --apply +pnpm install +``` + +The first command prints the plan and stops; `--apply` installs. There are no tables, so there is no +migration to generate. + +Then configure it, per environment. Both keys are declared as secrets, so the plugin needs no toml +edit: + +```bash +pnpm --filter @/web exec wrangler secret put ISSUE_REPORTS_GITHUB_REPO # owner/name; and --env staging +pnpm --filter @/web exec wrangler secret put ISSUE_REPORTS_GITHUB_TOKEN # and --env staging +``` + +The repository name is not sensitive. If you would rather keep it in the tomls, add +`ISSUE_REPORTS_GITHUB_REPO = "owner/name"` under `[vars]` in BOTH of them. Locally, add both keys to +`apps/web/.dev.vars.example` and set them in `apps/web/.dev.vars`. + +## First run + +1. Open **Ideas & issues** from the sidebar. If the repository or the token is missing, the page + says Ideas & issues is not set up and the form is disabled. +2. Describe a problem or an idea. A page can link here with `?from=` so the issue records + where the person was. +3. Answer the agent's questions. Usually two to five are enough. +4. Review the draft and pick **Log it on GitHub**, or type what to change. +5. The conversation shows the issue's number and link. + +## How it works + +- **The loop is code, not a model tool loop.** A parked run re-enters `run()` from the top when the + person answers, so every step before the park must replay exactly. Each round's decision is + `ctx.once('turn:')` and each question `ctx.interrupt({ key: 'ask:' })`. Both are keyed by + the round number, never by model text that could change on a replay. Filing is + `ctx.once('github:file')`, so a retried step never opens the issue twice. +- **What is filed**: the agent's Markdown summary (a bug gets steps / expected / actual; an + enhancement gets problem / proposal / acceptance criteria), who reported it and from which page, + then the whole conversation under `
`. If GitHub's 65 536-character limit forces a cut, + the start of the conversation goes first. +- Model calls are recorded in the kit's usage ledger as `agent:issue-reports`. + +## Permissions and data + +- Starting and answering a conversation is the kit's `create AgentRun`, which every role has for + its own runs; members see only their own conversations. +- The token is read from config at the moment of each call. It never appears in an error, a log + line, a response or anything handed to the model. +- Search results are marked to the model as coming from GitHub, not from the person. + +## Known gaps + +- A GitHub failure at the moment of filing fails the run. The conversation is kept, but the person + has to start again. +- No screenshots or attachments. +- In-app only: no Slack or Teams. +- No labels and no assignee. The issue names the reporter, and the team triages on GitHub. +- The page is recorded only when a link carries `?from=`; otherwise it is whatever the person says. +- One repository per deployment, not per organisation. + +## More + +- [Upgrade notes](docs/upgrades/), one per release. +- The kit's [plugins section](https://github.com/rocketflare-dev/rocketflare/blob/main/docs/CONCEPTS.md#16-plugins) + and [AI layer](https://github.com/rocketflare-dev/rocketflare/blob/main/docs/CONCEPTS.md#9-ai-layer). diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/agent/refine.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/agent/refine.ts new file mode 100644 index 0000000..d615a60 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/agent/refine.ts @@ -0,0 +1,384 @@ +/** + * The `issue-reports` agent: from "the calendar is annoying" to a GitHub issue somebody can act on. + * + * One ROUND is one model decision — ask the person something, search the repository's issues, or + * draft — and the loop below is plain code, not a model-driven tool loop, for one reason: a run + * that parks on a person RE-ENTERS `run()` from the top when they answer, so every step before the + * park must replay exactly. Each decision is recorded with `ctx.once('turn:')` and each question + * is asked with `ctx.interrupt({ key: 'ask:' })`, both keyed by the round number this code + * controls — never by model text, which could differ on a replay and ask the person twice. + * + * Nothing reaches GitHub until the person picks "Log it" on the review ask; anything else they + * type there is a change the agent redrafts from. Searches are read-only and happen whenever the + * agent wants to check for a duplicate. The filing itself is behind `ctx.once('github:file')`, so a + * retried step never opens the issue twice. + */ +import { choicePayloadSchema } from '@rocketflare/shared/ai/interrupts' +import { + ISSUE_REPORT_FILE_CHOICE, + ISSUE_REPORT_MAX_ROUNDS, + ISSUE_REPORT_QUESTION_MAX, + ISSUE_REPORTS_AGENT_KEY, + ISSUE_REPORTS_PROMPT_KEY, + type IssueReportDraft, + type IssueReportInput, + type IssueReportOutput, + issueReportDraftSchema, + issueReportInputSchema, + issueReportOutputSchema, +} from '@rocketflare/shared/plugins/issue-reports/index' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { users } from '@/db/schema/kit' +import type { AgentCtx, PluginAgent } from '@/plugins/api' +import { agentCtx, recordUsage } from '@/plugins/api' +import { type GithubClient, type GithubIssueRef, githubFor } from '../services/github' +import { buildIssueBody, type TranscriptEntry } from '../services/issue-body' + +/** What happened in one round, replayed from the run's own records on every re-entry. */ +export type Step = + | { kind: 'ask'; question: string; answer: string | null } + | { kind: 'search'; query: string; results: GithubIssueRef[] | null; error?: string } + | { kind: 'missing'; issueNumber: number } + | { kind: 'review'; draft: IssueReportDraft; changes: string } + +export function respondSchemaFor(lastRound: boolean) { + const draft = issueReportDraftSchema.describe( + 'The issue as you would file it. `body` is Markdown written for the developer who picks it up.' + ) + if (lastRound) return z.object({ action: z.literal('draft'), draft }) + return z + .object({ + action: z + .enum(['ask', 'search', 'draft']) + .describe( + 'ask = one question to the person; search = look for existing issues; draft = show them the issue' + ), + question: z + .string() + .trim() + .min(1) + .max(ISSUE_REPORT_QUESTION_MAX) + .optional() + .describe('action=ask: ONE short question, in plain language.'), + query: z + .string() + .trim() + .min(2) + .max(200) + .optional() + .describe( + 'action=search: a few keywords, as you would type them into GitHub issue search.' + ), + draft: draft.optional().describe('action=draft: the issue.'), + }) + .superRefine((value, issue) => { + const needed = { ask: 'question', search: 'query', draft: 'draft' } as const + const field = needed[value.action] + if (value[field] === undefined) { + issue.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `action "${value.action}" needs ${field}`, + }) + } + if (value.draft?.mode === 'comment' && !value.draft.existingIssueNumber) { + issue.addIssue({ + code: z.ZodIssueCode.custom, + path: ['draft', 'existingIssueNumber'], + message: 'a comment needs the existing issue number', + }) + } + }) +} + +type Respond = z.infer> + +const draftText = (draft: IssueReportDraft): string => + `${draft.mode === 'comment' ? `Comment on #${draft.existingIssueNumber}` : 'New issue'} (${draft.kind}): ${draft.title}\n\n${draft.body}` + +/** The conversation as the model reads it: strictly alternating, starting and ending with `user`. */ +export function buildMessages( + input: IssueReportInput, + reporterName: string, + steps: readonly Step[] +): { role: 'user' | 'assistant'; content: string }[] { + const where = input.pagePath ? ` (they were on the page ${input.pagePath})` : '' + const messages: { role: 'user' | 'assistant'; content: string }[] = [ + { role: 'user', content: `${reporterName} reported this${where}:\n\n${input.description}` }, + ] + for (const step of steps) { + switch (step.kind) { + case 'ask': + messages.push({ role: 'assistant', content: step.question }) + messages.push({ + role: 'user', + content: step.answer ?? '(They chose not to answer. Carry on without it.)', + }) + break + case 'search': { + messages.push({ + role: 'assistant', + content: `(Searching existing issues for "${step.query}".)`, + }) + const found = step.results + ? step.results.length + ? step.results.map(r => `#${r.number} [${r.state}] ${r.title}`).join('\n') + : 'No matching issues.' + : `The search failed (${step.error ?? 'unknown error'}). Do not search again; carry on.` + messages.push({ + role: 'user', + content: `[Search results — from GitHub, not from the person]\n${found}`, + }) + break + } + case 'missing': + messages.push({ role: 'assistant', content: `(Looking up #${step.issueNumber}.)` }) + messages.push({ + role: 'user', + content: `[From GitHub] Issue #${step.issueNumber} does not exist. Do not comment on it.`, + }) + break + case 'review': + messages.push({ role: 'assistant', content: `I drafted:\n\n${draftText(step.draft)}` }) + messages.push({ role: 'user', content: `Please change this: ${step.changes}` }) + break + } + } + return messages +} + +/** The conversation as it is filed: what the person said and what they were asked. */ +export function transcriptOf(input: IssueReportInput, steps: readonly Step[]): TranscriptEntry[] { + const out: TranscriptEntry[] = [{ speaker: 'reporter', text: input.description }] + for (const step of steps) { + if (step.kind === 'ask') { + out.push({ speaker: 'agent', text: step.question }) + out.push({ speaker: 'reporter', text: step.answer ?? '_(skipped)_' }) + } else if (step.kind === 'review') { + out.push({ speaker: 'agent', text: `Drafted: ${step.draft.title}` }) + out.push({ speaker: 'reporter', text: step.changes }) + } + } + return out +} + +async function reporterNameOf(ctx: AgentCtx): Promise { + if (!ctx.userId) return 'A teammate' + const [row] = await ctx.db + .select({ name: users.name, email: users.email }) + .from(users) + .where(eq(users.id, ctx.userId)) + .limit(1) + return row?.name?.trim() || row?.email?.split('@')[0] || 'A teammate' +} + +async function decide( + ctx: AgentCtx, + system: string, + messages: ReturnType, + lastRound: boolean +): Promise { + let usage: Parameters[1]['usage'] | null = null + const answer = await ctx.structured({ + system: lastRound + ? `${system}\n\nThis is your LAST turn: draft the issue now from what you have.` + : system, + messages, + tool: { + name: 'respond', + description: 'Your next move in this conversation.', + schema: respondSchemaFor(lastRound) as z.ZodType, + }, + onUsage: u => { + usage = u + }, + }) + if (usage) { + await recordUsage(ctx.db, { + tenantId: ctx.tenantId, + userId: ctx.userId, + feature: `agent:${ISSUE_REPORTS_AGENT_KEY}`, + provider: ctx.chatModel.provider, + model: ctx.chatModel.model, + usage, + }).catch(err => ctx.logger.warn({ err }, 'issue-reports: usage write failed')) + } + return answer +} + +async function search(github: GithubClient, query: string): Promise { + try { + return { kind: 'search', query, results: await github.searchIssues(query) } + } catch (err) { + return { + kind: 'search', + query, + results: null, + error: err instanceof Error ? err.message : 'unknown error', + } + } +} + +const discarded = (draft: IssueReportDraft | null): IssueReportOutput => ({ + outcome: 'discarded', + kind: draft?.kind ?? null, + title: draft?.title ?? null, + issueNumber: null, + issueUrl: null, +}) + +export async function runIssueReport( + ctx: AgentCtx, + /** Tests hand GitHub a recording `fetch`; the runtime never passes one. */ + deps: { fetch?: typeof fetch } = {} +): Promise { + const github = githubFor(ctx.config, deps.fetch) + if (!github) { + throw new Error( + 'Ideas & issues is not set up: ISSUE_REPORTS_GITHUB_REPO and ISSUE_REPORTS_GITHUB_TOKEN are both needed.' + ) + } + const input = ctx.input + const system = await ctx.prompt({ repo: github.repo }) + const reporterName = await reporterNameOf(ctx) + const steps: Step[] = [] + let lastDraft: IssueReportDraft | null = null + + for (let round = 0; round < ISSUE_REPORT_MAX_ROUNDS; round++) { + await ctx.checkCancelled() + const lastRound = round === ISSUE_REPORT_MAX_ROUNDS - 1 + const next = await ctx.once(`turn:${round}`, () => + decide(ctx, system, buildMessages(input, reporterName, steps), lastRound) + ) + + if (next.action === 'search' && 'query' in next && next.query) { + const query = next.query + const step = await ctx.once(`search:${round}`, () => search(github, query)) + const found = + step.kind === 'search' && step.results ? `${step.results.length} found` : 'failed' + await ctx.step(`search:${round}`, `Searched GitHub for “${query}”`, 'done', found) + steps.push(step) + continue + } + + if (next.action === 'ask' && 'question' in next && next.question) { + const answer = await ctx.interrupt({ + key: `ask:${round}`, + spec: { + kind: 'input', + title: 'A question about your report', + message: next.question, + multiline: true, + placeholder: 'Your answer', + }, + }) + const text = + answer.status === 'resolved' + ? (z.object({ text: z.string() }).safeParse(answer.payload).data?.text ?? null) + : null + steps.push({ kind: 'ask', question: next.question, answer: text }) + continue + } + + if (!next.draft) continue + let draft: IssueReportDraft = next.draft + if (draft.mode === 'comment' && draft.existingIssueNumber) { + const issueNumber = draft.existingIssueNumber + const existing = await ctx.once(`issue:${round}`, () => github.getIssue(issueNumber)) + if (!existing) { + steps.push({ kind: 'missing', issueNumber }) + continue + } + draft = { ...draft, title: existing.title } + } + lastDraft = draft + + const review = await ctx.interrupt({ + key: `review:${round}`, + spec: { + kind: 'choice', + title: + draft.mode === 'comment' + ? `Add this to #${draft.existingIssueNumber} — ${draft.title}?` + : `Log this ${draft.kind} on GitHub?`, + message: draftText(draft), + options: [ + { + value: ISSUE_REPORT_FILE_CHOICE, + label: + draft.mode === 'comment' + ? `Add it to #${draft.existingIssueNumber}` + : 'Log it on GitHub', + }, + ], + allowOther: true, + }, + }) + if (review.status !== 'resolved') return discarded(draft) + const choice = choicePayloadSchema.safeParse(review.payload) + if (!choice.success) return discarded(draft) + if (choice.data.value === ISSUE_REPORT_FILE_CHOICE) { + return await file(ctx, github, { input, steps, draft, reporterName }) + } + steps.push({ kind: 'review', draft, changes: choice.data.value }) + } + return discarded(lastDraft) +} + +async function file( + ctx: AgentCtx, + github: GithubClient, + report: { + input: IssueReportInput + steps: readonly Step[] + draft: IssueReportDraft + reporterName: string + } +): Promise { + const { draft } = report + const body = buildIssueBody({ + draft, + reporterName: report.reporterName, + pagePath: report.input.pagePath, + transcript: transcriptOf(report.input, report.steps), + }) + const filed = await ctx.once('github:file', async () => { + if (draft.mode === 'comment' && draft.existingIssueNumber) { + const comment = await github.commentOnIssue(draft.existingIssueNumber, body) + return { issueNumber: draft.existingIssueNumber, url: comment.url } + } + const issue = await github.createIssue({ title: draft.title, body }) + return { issueNumber: issue.number, url: issue.url } + }) + await ctx.step( + 'github:file', + draft.mode === 'comment' + ? `Commented on #${filed.issueNumber}` + : `Opened #${filed.issueNumber}`, + 'done', + filed.url + ) + return { + outcome: draft.mode === 'comment' ? 'commented' : 'created', + kind: draft.kind, + title: draft.title, + issueNumber: filed.issueNumber, + issueUrl: filed.url, + } +} + +export const issueReportsAgent: PluginAgent = { + meta: { + key: ISSUE_REPORTS_AGENT_KEY, + title: 'Ideas & issues', + description: + 'Talks through an idea for an improvement or a problem you found, asks until it is clear, checks for an existing issue, and logs it on GitHub once you approve the draft.', + inputSchema: issueReportInputSchema, + outputSchema: issueReportOutputSchema, + promptKey: ISSUE_REPORTS_PROMPT_KEY, + // One person's conversation: several people may be mid-way at once. + exclusive: false, + }, + run: ctx => runIssueReport(agentCtx(ctx)), +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/api/router.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/api/router.ts new file mode 100644 index 0000000..1183207 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/api/router.ts @@ -0,0 +1,27 @@ +/** + * `/api/issue-reports` — one read, for the page: can a conversation end in a GitHub issue here? + * + * GET /status create AgentRun — `{ configured, repo }` + * + * Everything else is the kit's own agent-run API (`/api/agents/runs…`): starting a conversation is + * `POST /api/agents/runs { agentKey: 'issue-reports' }`, answering is the interrupt route, and the + * list of your reports is `GET /api/agents/runs?agentKey=issue-reports` — members see their own. + * The token never leaves the server; only whether there is one does. + */ +import type { IssueReportsStatus } from '@rocketflare/shared/plugins/issue-reports/index' +import type { RequestCtx } from '@/plugins/api' +import { createRouter, requestCtx } from '@/plugins/api' +import { githubFor, issueReportsConfig } from '../services/github' + +export const issueReportsRouter = createRouter() + +issueReportsRouter.get('/status', c => { + const ctx: RequestCtx = requestCtx(c) + ctx.guard('create', 'AgentRun') + const configured = githubFor(ctx.config) !== null + const body: IssueReportsStatus = { + configured, + repo: configured ? (issueReportsConfig(ctx.config).ISSUE_REPORTS_GITHUB_REPO ?? null) : null, + } + return c.json(body) +}) diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/index.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/index.ts new file mode 100644 index 0000000..8bc9a9d --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/index.ts @@ -0,0 +1,26 @@ +/** + * `issue-reports` — the SERVER entry (D31): talk a bug or an idea through with an agent, then log + * it on GitHub. + * + * Read this file and you have the whole server half: one non-exclusive agent (each person's + * conversation is its own run, parked on their answers), its prompt, and a status route. No table, + * no job, no grants — starting and answering a run is the kit's `AgentRun` permission, which every + * role has for its own runs. The GitHub token is a secret read from config at the moment of use. + */ +import { + ISSUE_REPORTS_AGENT_KEY, + ISSUE_REPORTS_API_ROOT, + ISSUE_REPORTS_PROMPT_KEY, + issueReportsShared, +} from '@rocketflare/shared/plugins/issue-reports/index' +import type { ServerPlugin } from '@/plugins/api' +import { issueReportsAgent } from './agent/refine' +import { issueReportsRouter } from './api/router' +import { issueReportsPrompt } from './prompts' + +export const issueReportsServer = { + shared: issueReportsShared, + mounts: [[ISSUE_REPORTS_API_ROOT, issueReportsRouter]], + agents: { [ISSUE_REPORTS_AGENT_KEY]: issueReportsAgent }, + prompts: { [ISSUE_REPORTS_PROMPT_KEY]: issueReportsPrompt }, +} satisfies ServerPlugin diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/plugin.json b/plugins/issue-reports/apps/web/src/plugins/issue-reports/plugin.json new file mode 100644 index 0000000..7dc695b --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/plugin.json @@ -0,0 +1,45 @@ +{ + "id": "issue-reports", + "label": "Issue reports", + "version": "3.4.2", + "repo": "https://github.com/rocketflare-dev/rocketflare-plugins.git", + "subdir": "plugins/issue-reports", + "anchor": "apps/web/src/plugins/issue-reports/plugin.json", + "minKit": "0.21.0", + "uses": {}, + "paths": [ + "apps/web/src/plugins/issue-reports/**", + "packages/shared/src/plugins/issue-reports/**" + ], + "registries": [ + "apps/web/src/plugins/server.ts", + "apps/web/src/plugins/ui.ts", + "packages/shared/src/plugins/index.ts" + ], + "requires": { + "surfaces": ["feature-agents"], + "plugins": [] + }, + "dependencies": { + "apps/web": { + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "packages/shared": {}, + "apps/cli": {} + }, + "bindings": [], + "crons": [], + "apiPrefixes": [], + "vars": [ + { "key": "ISSUE_REPORTS_GITHUB_REPO", "example": "your-org/your-app", "secret": true }, + { "key": "ISSUE_REPORTS_GITHUB_TOKEN", "secret": true } + ], + "workerExports": [], + "skills": [], + "schema": { + "tables": [], + "rlsExcluded": [] + }, + "migrations": [] +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/prompts.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/prompts.ts new file mode 100644 index 0000000..6b0c99d --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/prompts.ts @@ -0,0 +1,40 @@ +/** + * The agent's system prompt — a registry entry, so an admin can override it per organisation in + * Settings → Prompts and point it at another model in Settings → agent models. + */ + +import type { PromptDefinition } from '@rocketflare/shared/ai/prompts' +import { ISSUE_REPORTS_PROMPT_KEY } from '@rocketflare/shared/plugins/issue-reports/index' + +const DEFAULT_TEXT = `You help the {{tenantName}} team turn something they noticed in {{appName}} — a bug, an +annoyance, or an idea for an improvement — into a GitHub issue in {{repo}} that a developer can +pick up without having to ask anything. + +You talk to the person who noticed it. They are not a developer. Work out what they actually +need, which is not always what they first ask for. + +Each turn, choose ONE move: +- ask: put ONE short, plain-language question to them. Ask about what is missing, never about + what they already told you. For a bug: what they did, what they expected, what happened, how + often, and which page or record. For an idea: the problem behind it, who it is for, how they + work around it today, and what "done" would look like. Offer examples to choose from when that + makes answering easier. +- search: look for an existing issue, with a few keywords. Search once you understand the request, + before drafting. If something looks like the same thing, ask them whether it is — and if it is, + draft a COMMENT on that issue instead of a new one. +- draft: once you could explain the request to a developer, draft it. Kind: bug, enhancement or + question. Title: short and specific. Body, in Markdown: a one-paragraph summary, then for a bug + "Steps to reproduce", "Expected", "Actual"; for an enhancement "Problem", "Proposal", + "Acceptance criteria" as a checklist. Only what they told you — never invent details. + +They see your draft with a button to log it. If they ask for changes, redraft with the changes. +Usually two to five questions are enough; do not interrogate them. Be warm and brief.` + +export const issueReportsPrompt: PromptDefinition = { + key: ISSUE_REPORTS_PROMPT_KEY, + title: 'Ideas & issues', + description: + 'System prompt for the `issue-reports` agent: asks the person questions until their bug or idea is clear, searches GitHub for an existing issue, and drafts the issue they approve.', + variables: ['appName', 'tenantName', 'repo'], + defaultText: DEFAULT_TEXT, +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/github.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/github.ts new file mode 100644 index 0000000..55289a7 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/github.ts @@ -0,0 +1,135 @@ +/** + * The four GitHub REST calls this plugin makes, and nothing else: search the repository's issues, + * read one, open one, comment on one. A plain module over `fetch` (injectable, so tests never leave + * the machine) — no Octokit, which would be a dependency for four requests. + * + * The token is read from config by the caller and passed in; it never appears in an error, a log + * line or anything the agent hands the model. + */ +import { + type IssueReportsConfig, + issueReportsConfigSchema, +} from '@rocketflare/shared/plugins/issue-reports/index' +import type { PluginConfig } from '@/plugins/api' + +const API = 'https://api.github.com' + +export interface GithubIssueRef { + number: number + title: string + state: 'open' | 'closed' + url: string +} + +export class GithubError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'GithubError' + } +} + +export interface GithubClient { + readonly repo: string + searchIssues(query: string, limit?: number): Promise + /** `null` for an issue that does not exist (or is a pull request). */ + getIssue(issueNumber: number): Promise + createIssue(input: { title: string; body: string }): Promise + commentOnIssue(issueNumber: number, body: string): Promise<{ url: string }> +} + +type Fetch = typeof fetch + +export function issueReportsConfig(config: PluginConfig): IssueReportsConfig { + return issueReportsConfigSchema.parse(config) +} + +/** The client, or `null` when the deployment has not named both a repository and a token. */ +export function githubFor(config: PluginConfig, fetchImpl: Fetch = fetch): GithubClient | null { + const { ISSUE_REPORTS_GITHUB_TOKEN: token, ISSUE_REPORTS_GITHUB_REPO: repo } = + issueReportsConfig(config) + if (!token || !repo) return null + return createGithubClient({ token, repo, fetch: fetchImpl }) +} + +interface RawIssue { + number: number + title: string + state: 'open' | 'closed' + html_url: string + pull_request?: unknown +} + +const toRef = (issue: RawIssue): GithubIssueRef => ({ + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.html_url, +}) + +export function createGithubClient(options: { + token: string + repo: string + fetch?: Fetch +}): GithubClient { + const doFetch = options.fetch ?? fetch + const { repo } = options + + async function call(path: string, init: { method?: string; body?: unknown } = {}): Promise { + const res = await doFetch(`${API}${path}`, { + method: init.method ?? 'GET', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${options.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + // GitHub refuses a request with no User-Agent. + 'User-Agent': 'rocketflare-issue-reports', + ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }) + if (!res.ok) { + // The status and GitHub's own message only: the response never echoes the token, and the + // request (which carries it) is not included. + const detail = await res + .json() + .then(body => (body as { message?: string }).message) + .catch(() => undefined) + throw new GithubError(`GitHub ${res.status}${detail ? `: ${detail}` : ''}`, res.status) + } + return (await res.json()) as T + } + + return { + repo, + async searchIssues(query, limit = 8) { + const q = `repo:${repo} is:issue ${query.replace(/\s+/g, ' ').trim()}` + const params = new URLSearchParams({ q, per_page: String(limit) }) + const body = await call<{ items: RawIssue[] }>(`/search/issues?${params}`) + return body.items.filter(item => !item.pull_request).map(toRef) + }, + async getIssue(issueNumber) { + try { + const issue = await call(`/repos/${repo}/issues/${issueNumber}`) + return issue.pull_request ? null : toRef(issue) + } catch (err) { + if (err instanceof GithubError && (err.status === 404 || err.status === 410)) return null + throw err + } + }, + async createIssue({ title, body }) { + return toRef( + await call(`/repos/${repo}/issues`, { method: 'POST', body: { title, body } }) + ) + }, + async commentOnIssue(issueNumber, body) { + const comment = await call<{ html_url: string }>( + `/repos/${repo}/issues/${issueNumber}/comments`, + { method: 'POST', body: { body } } + ) + return { url: comment.html_url } + }, + } +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/issue-body.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/issue-body.ts new file mode 100644 index 0000000..2585930 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/services/issue-body.ts @@ -0,0 +1,62 @@ +/** + * What lands on GitHub: the agent's summary first, then who reported it and from where, then the + * whole conversation folded away under `
`. Pure, so the exact text is unit-tested. + * + * No labels and no assignee — the team triages on GitHub. + */ +import type { IssueReportDraft } from '@rocketflare/shared/plugins/issue-reports/index' + +/** GitHub refuses a body over 65 536 characters; leave room for the summary and the frame. */ +export const GITHUB_BODY_MAX = 65_000 + +export interface TranscriptEntry { + speaker: 'reporter' | 'agent' + text: string +} + +export interface IssueBodyInput { + draft: IssueReportDraft + reporterName: string + pagePath?: string + transcript: readonly TranscriptEntry[] +} + +const TRUNCATED = '\n\n_…the start of the conversation was cut to fit GitHub’s size limit._' + +function quoteBlock(text: string): string { + return text + .trim() + .split('\n') + .map(line => `> ${line}`) + .join('\n') +} + +function renderTranscript(reporterName: string, transcript: readonly TranscriptEntry[]): string { + return transcript + .map(entry => { + const who = entry.speaker === 'reporter' ? reporterName : 'Assistant' + return `**${who}:**\n${quoteBlock(entry.text)}` + }) + .join('\n\n') +} + +export function buildIssueBody(input: IssueBodyInput): string { + const lead = + input.draft.mode === 'comment' ? `**Also reported by ${input.reporterName}.**\n\n` : '' + const context = [ + `**Reported by:** ${input.reporterName}, through Ideas & issues in the app`, + input.pagePath ? `**Page:** \`${input.pagePath}\`` : null, + ] + .filter(Boolean) + .join(' \n') + const head = `${lead}${input.draft.body.trim()}\n\n---\n\n${context}\n\n
\nFull conversation\n\n` + const tail = '\n\n
\n' + + let transcript = renderTranscript(input.reporterName, input.transcript) + const room = GITHUB_BODY_MAX - head.length - tail.length + if (transcript.length > room) { + // Keep the END: the latest answers are what the summary was drafted from. + transcript = TRUNCATED + transcript.slice(transcript.length - (room - TRUNCATED.length)) + } + return head + transcript + tail +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/api/status.test.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/api/status.test.ts new file mode 100644 index 0000000..e6d06f6 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/api/status.test.ts @@ -0,0 +1,48 @@ +/** + * `GET /api/issue-reports/status`: whether a conversation can end in an issue — the repository is + * named only when a token exists too, and the token itself never leaves the server. + */ +import { + createTestEnv, + createTestSession, + createTestTenantWithUser, + json, + request, + sessionCookieHeader, + setupTestDatabase, + type TestEnv, +} from '@testkit/integration' +import { describe, expect, it } from 'vitest' + +const db = setupTestDatabase() + +async function member() { + const { user, tenant } = await createTestTenantWithUser(db, 'member') + return sessionCookieHeader(await createTestSession(db, user.id, tenant.id)) +} + +const status = (headers: Record, env: TestEnv) => + request('/api/issue-reports/status', { headers }, { env }) + +describe('GET /api/issue-reports/status', () => { + it('configured with both; the token is never in the answer', async () => { + const env = createTestEnv({ + ISSUE_REPORTS_GITHUB_REPO: 'acme/app', + ISSUE_REPORTS_GITHUB_TOKEN: 'ghp_secret_value', + } as Partial) + const res = await status(await member(), env) + expect(res.status).toBe(200) + const text = await res.text() + expect(JSON.parse(text)).toEqual({ configured: true, repo: 'acme/app' }) + expect(text).not.toContain('ghp_secret_value') + }) + + it('a repository without a token is not configured, and names nothing', async () => { + const env = createTestEnv({ ISSUE_REPORTS_GITHUB_REPO: 'acme/app' } as Partial) + expect(await json(await status(await member(), env))).toEqual({ configured: false, repo: null }) + }) + + it('401 without a session', async () => { + expect((await status({}, createTestEnv())).status).toBe(401) + }) +}) diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/agent.test.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/agent.test.ts new file mode 100644 index 0000000..914a516 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/agent.test.ts @@ -0,0 +1,275 @@ +/** + * The `issue-reports` agent, driven the way the runtime drives it: `run()` is RE-ENTERED from the + * top every time the person answers, with `once` replaying what was recorded and `interrupt` + * answering what was asked. The fake context below is that contract and nothing more — a park is + * an exception, a resume is calling `runIssueReport` again over the same stores. + * + * What it proves: one model call per round however many times the run re-enters (so a resume never + * re-asks or re-bills), nothing reaches GitHub until the person picks "Log it", a requested change + * is redrafted, a duplicate becomes a comment, and declining files nothing. + */ +import type { IssueReportInput } from '@rocketflare/shared/plugins/issue-reports/index' +import { ISSUE_REPORT_FILE_CHOICE } from '@rocketflare/shared/plugins/issue-reports/index' +import { describe, expect, it } from 'vitest' +import type { AgentCtx, AgentInterruptAnswer, AgentInterruptAsk, PluginConfig } from '@/plugins/api' +import { runIssueReport } from '../../agent/refine' + +class Parked extends Error { + constructor(readonly key: string) { + super(`parked on ${key}`) + } +} + +interface FetchCall { + method: string + url: string + body: unknown +} + +function fakeGithub() { + const calls: FetchCall[] = [] + const fetchImpl = (async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const body = init?.body ? JSON.parse(String(init.body)) : undefined + calls.push({ method, url, body }) + const path = new URL(url).pathname + const reply = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + if (path === '/search/issues') { + return reply({ + items: [ + { + number: 12, + title: 'Calendar shows a moved order on the old day', + state: 'open', + html_url: 'https://github.com/acme/app/issues/12', + }, + ], + }) + } + if (method === 'GET' && path === '/repos/acme/app/issues/12') { + return reply({ + number: 12, + title: 'Calendar shows a moved order on the old day', + state: 'open', + html_url: 'https://github.com/acme/app/issues/12', + }) + } + if (method === 'GET' && path.startsWith('/repos/acme/app/issues/')) { + return reply({ message: 'Not Found' }, 404) + } + if (method === 'POST' && path === '/repos/acme/app/issues') { + return reply({ + number: 99, + title: (body as { title: string }).title, + state: 'open', + html_url: 'https://github.com/acme/app/issues/99', + }) + } + if (method === 'POST' && path === '/repos/acme/app/issues/12/comments') { + return reply({ html_url: 'https://github.com/acme/app/issues/12#issuecomment-1' }) + } + return reply({ message: 'unexpected' }, 500) + }) as typeof fetch + return { calls, fetch: fetchImpl, writes: () => calls.filter(c => c.method === 'POST') } +} + +const CONFIGURED = { + ISSUE_REPORTS_GITHUB_TOKEN: 'ghp_test', + ISSUE_REPORTS_GITHUB_REPO: 'acme/app', +} as unknown as PluginConfig + +const INPUT: IssueReportInput = { + description: 'The calendar is wrong after I move an order.', + pagePath: '/orders/calendar', +} + +const DRAFT = { + mode: 'new', + kind: 'bug', + title: 'Moved order still shows on its old day', + body: 'Summary.\n\n**Steps to reproduce**\n1. Move an order', +} + +/** A conversation: scripted model turns, and the person's answers keyed by the ask's key. */ +function conversation(script: unknown[], config: PluginConfig = CONFIGURED) { + const recorded = new Map() + const answers = new Map() + const asks: AgentInterruptAsk[] = [] + let modelCalls = 0 + const github = fakeGithub() + + const ctx = { + db: {}, + config, + env: {}, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} }, + tenantId: '00000000-0000-4000-8000-000000000001', + runId: '00000000-0000-4000-8000-000000000002', + userId: null, + input: INPUT, + tools: [], + prompt: async () => 'You help.', + step: async () => {}, + checkCancelled: async () => {}, + once: async (key: string, fn: () => Promise): Promise => { + // jsonb round trip, as the real `agent_run_effects` row gives it back + if (!recorded.has(key)) recorded.set(key, JSON.parse(JSON.stringify(await fn()))) + return recorded.get(key) as T + }, + structured: async (options: { + tool: { schema: { parse: (v: unknown) => unknown } } + onUsage?: (u: { inputTokens: number; outputTokens: number }) => void + }) => { + const turn = script[modelCalls] + modelCalls += 1 + if (turn === undefined) throw new Error('the script ran out') + options.onUsage?.({ inputTokens: 1, outputTokens: 1 }) + return options.tool.schema.parse(turn) + }, + toolLoop: async () => { + throw new Error('not used') + }, + decide: {}, + interrupt: async (ask: AgentInterruptAsk): Promise => { + if (!asks.some(a => a.key === ask.key)) asks.push(ask) + if (!answers.has(ask.key)) throw new Parked(ask.key) + const answer = answers.get(ask.key) + return answer === 'decline' + ? { interruptId: ask.key, status: 'cancelled', payload: null, resolvedByUserId: null } + : { interruptId: ask.key, status: 'resolved', payload: answer, resolvedByUserId: null } + }, + chatModel: { provider: 'anthropic', model: 'fake' }, + } as unknown as AgentCtx + + return { + github, + asks, + modelCalls: () => modelCalls, + answer: (key: string, payload: unknown) => answers.set(key, payload), + /** Run until it parks (→ the key it waits on) or finishes (→ its output). */ + run: async () => { + try { + return { output: await runIssueReport(ctx, { fetch: github.fetch }) } + } catch (err) { + if (err instanceof Parked) return { parkedOn: err.key } + throw err + } + }, + } +} + +describe('issue-reports agent', () => { + it('asks, searches, drafts, and files only once the person says so — one model call per round', async () => { + const c = conversation([ + { action: 'ask', question: 'Which day did you move it to, and did a refresh fix it?' }, + { action: 'search', query: 'calendar moved order' }, + { action: 'draft', draft: DRAFT }, + ]) + + expect(await c.run()).toEqual({ parkedOn: 'ask:0' }) + expect(c.asks[0]?.spec).toMatchObject({ + kind: 'input', + message: expect.stringContaining('Which day'), + }) + + c.answer('ask:0', { text: 'Tuesday to Thursday. Refreshing fixed it.' }) + expect(await c.run()).toEqual({ parkedOn: 'review:2' }) + expect(c.asks[1]?.spec).toMatchObject({ + kind: 'choice', + options: [{ value: ISSUE_REPORT_FILE_CHOICE, label: 'Log it on GitHub' }], + allowOther: true, + }) + // A search, but no write: nothing is filed before the person agrees. + expect(c.github.writes()).toEqual([]) + + c.answer('review:2', { value: ISSUE_REPORT_FILE_CHOICE }) + const done = await c.run() + expect(done).toEqual({ + output: { + outcome: 'created', + kind: 'bug', + title: DRAFT.title, + issueNumber: 99, + issueUrl: 'https://github.com/acme/app/issues/99', + }, + }) + // Three rounds, three model calls, across three entries into run(). + expect(c.modelCalls()).toBe(3) + + const [created] = c.github.writes() + expect(created?.url).toBe('https://api.github.com/repos/acme/app/issues') + const body = created?.body as { title: string; body: string; labels?: unknown } + expect(body.title).toBe(DRAFT.title) + expect(body.labels).toBeUndefined() + expect(body.body).toContain('**Steps to reproduce**') + expect(body.body).toContain('**Page:** `/orders/calendar`') + expect(body.body).toContain('Full conversation') + expect(body.body).toContain('> Tuesday to Thursday. Refreshing fixed it.') + + // A retried step after filing replays the recorded issue rather than opening a second. + await c.run() + expect(c.github.writes()).toHaveLength(1) + }) + + it('a requested change is redrafted and reviewed again', async () => { + const c = conversation([ + { action: 'draft', draft: DRAFT }, + { + action: 'draft', + draft: { ...DRAFT, title: 'Calendar keeps a moved order until refresh' }, + }, + ]) + expect(await c.run()).toEqual({ parkedOn: 'review:0' }) + c.answer('review:0', { value: 'Say it only happens until you refresh.' }) + expect(await c.run()).toEqual({ parkedOn: 'review:1' }) + c.answer('review:1', { value: ISSUE_REPORT_FILE_CHOICE }) + const done = await c.run() + expect(done.output?.title).toBe('Calendar keeps a moved order until refresh') + expect(c.github.writes()).toHaveLength(1) + }) + + it('a duplicate becomes a comment on the existing issue, titled from GitHub', async () => { + const c = conversation([ + { action: 'draft', draft: { ...DRAFT, mode: 'comment', existingIssueNumber: 12 } }, + ]) + expect(await c.run()).toEqual({ parkedOn: 'review:0' }) + expect(c.asks[0]?.spec).toMatchObject({ + title: 'Add this to #12 — Calendar shows a moved order on the old day?', + }) + c.answer('review:0', { value: ISSUE_REPORT_FILE_CHOICE }) + const done = await c.run() + expect(done.output).toMatchObject({ outcome: 'commented', issueNumber: 12 }) + const [comment] = c.github.writes() + expect(comment?.url).toBe('https://api.github.com/repos/acme/app/issues/12/comments') + expect((comment?.body as { body: string } | undefined)?.body).toMatch( + /^\*\*Also reported by A teammate\.\*\*/ + ) + }) + + it('a comment on an issue that does not exist goes back to the model, not to the person', async () => { + const c = conversation([ + { action: 'draft', draft: { ...DRAFT, mode: 'comment', existingIssueNumber: 404 } }, + { action: 'draft', draft: DRAFT }, + ]) + expect(await c.run()).toEqual({ parkedOn: 'review:1' }) + expect(c.asks).toHaveLength(1) + }) + + it('declining the draft files nothing', async () => { + const c = conversation([{ action: 'draft', draft: DRAFT }]) + await c.run() + c.answer('review:0', 'decline') + const done = await c.run() + expect(done.output).toMatchObject({ outcome: 'discarded', issueNumber: null }) + expect(c.github.writes()).toEqual([]) + }) + + it('refuses to start without a repository and a token', async () => { + const c = conversation([], {} as PluginConfig) + await expect(c.run()).rejects.toThrow(/not set up/) + }) +}) diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/issue-body.test.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/issue-body.test.ts new file mode 100644 index 0000000..13269f8 --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/tests/config/issue-body.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { buildIssueBody, GITHUB_BODY_MAX } from '../../services/issue-body' + +const draft = { + mode: 'new' as const, + kind: 'enhancement' as const, + title: 'Filter bookings by guide', + body: '**Problem**\nHard to see one guide’s week.', +} + +describe('buildIssueBody', () => { + it('summary, then who and where, then the whole conversation folded away', () => { + const body = buildIssueBody({ + draft, + reporterName: 'Sam', + pagePath: '/orders', + transcript: [ + { speaker: 'reporter', text: 'I want to filter by guide.' }, + { speaker: 'agent', text: 'Which page?\nThe list or the calendar?' }, + { speaker: 'reporter', text: 'Both.' }, + ], + }) + expect(body.startsWith('**Problem**')).toBe(true) + expect(body).toContain('**Reported by:** Sam, through Ideas & issues in the app') + expect(body).toContain('**Page:** `/orders`') + expect(body).toContain( + '**Assistant:**\n> Which page?\n> The list or the calendar?\n\n**Sam:**\n> Both.' + ) + expect(body.trimEnd().endsWith('
')).toBe(true) + }) + + it('a comment says who else reported it; no page line without a page', () => { + const body = buildIssueBody({ + draft: { ...draft, mode: 'comment', existingIssueNumber: 3 }, + reporterName: 'Sam', + transcript: [], + }) + expect(body.startsWith('**Also reported by Sam.**')).toBe(true) + expect(body).not.toContain('**Page:**') + }) + + it('a long conversation is cut from the start to fit GitHub, keeping the latest answers', () => { + const body = buildIssueBody({ + draft, + reporterName: 'Sam', + transcript: [ + { speaker: 'reporter', text: `FIRST ${'x'.repeat(70_000)}` }, + { speaker: 'reporter', text: 'LAST ANSWER' }, + ], + }) + expect(body.length).toBeLessThanOrEqual(GITHUB_BODY_MAX) + expect(body).toContain('LAST ANSWER') + expect(body).not.toContain('FIRST') + expect(body).toContain('cut to fit') + }) +}) diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/hooks/useIssueReports.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/hooks/useIssueReports.ts new file mode 100644 index 0000000..e64b61d --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/hooks/useIssueReports.ts @@ -0,0 +1,96 @@ +/** + * A report IS an agent run of `issue-reports`, so these hooks are thin wrappers over the kit's + * agent-run routes, parsed with the kit's own contracts. A conversation in progress is polled while + * the agent is thinking (queued / running) and left alone while it waits on the person. + */ +import { + type AgentRunWithEvents, + agentRunSchema, + agentRunWithEventsSchema, + createAgentRunResponseSchema, +} from '@rocketflare/shared/ai/agents' +import { paginatedResponse } from '@rocketflare/shared/plugins/api' +import { + ISSUE_REPORTS_AGENT_KEY, + ISSUE_REPORTS_API_ROOT, + type IssueReportInput, + issueReportsStatusSchema, +} from '@rocketflare/shared/plugins/issue-reports/index' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/plugins/api/ui' +import { issueReportsKeys } from '../query-keys' + +const RUNS = '/api/agents/runs' +const reportListSchema = paginatedResponse(agentRunSchema) + +/** While the agent is working the answer is seconds away; while it waits, nothing changes. */ +const POLL_MS = 1_500 + +export function useIssueReportsStatus() { + return useQuery({ + queryKey: issueReportsKeys.status, + queryFn: () => + api.get(`${ISSUE_REPORTS_API_ROOT}/status`, { schema: issueReportsStatusSchema }), + staleTime: 5 * 60_000, + }) +} + +export function useMyIssueReports(page = 1) { + return useQuery({ + queryKey: issueReportsKeys.list(page), + queryFn: () => + api.get(`${RUNS}?agentKey=${ISSUE_REPORTS_AGENT_KEY}&page=${page}&pageSize=20`, { + schema: reportListSchema, + }), + }) +} + +const isThinking = (run: AgentRunWithEvents | undefined) => + run?.status === 'queued' || run?.status === 'running' + +export function useIssueReport(runId: string) { + return useQuery({ + queryKey: issueReportsKeys.detail(runId), + queryFn: () => api.get(`${RUNS}/${runId}`, { schema: agentRunWithEventsSchema }), + refetchInterval: query => (isThinking(query.state.data) ? POLL_MS : false), + }) +} + +function useInvalidate() { + const queryClient = useQueryClient() + return () => queryClient.invalidateQueries({ queryKey: issueReportsKeys.all }) +} + +export function useStartIssueReport() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (input: IssueReportInput) => + api.post( + RUNS, + { agentKey: ISSUE_REPORTS_AGENT_KEY, input }, + { schema: createAgentRunResponseSchema } + ), + onSuccess: invalidate, + }) +} + +/** Answer the pending ask: `{ text }` for a question, `{ value }` for the draft review. */ +export function useAnswerIssueReport(runId: string) { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (answer: { interruptId: string; payload: { text: string } | { value: string } }) => + api.post(`${RUNS}/${runId}/interrupts/${answer.interruptId}`, { + status: 'resolved', + payload: answer.payload, + }), + onSuccess: invalidate, + }) +} + +export function useDiscardIssueReport(runId: string) { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: () => api.post(`${RUNS}/${runId}/cancel`), + onSuccess: invalidate, + }) +} diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/index.ts b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/index.ts new file mode 100644 index 0000000..763583d --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/index.ts @@ -0,0 +1,43 @@ +/** + * `issue-reports` — the UI entry (D31). Ships in the main bundle, so it wires and nothing else: + * two lazy pages and one nav item. The guard is the kit's own "may start an agent run", + * which every role has — anyone on the team can share an idea or flag a problem. + */ +import { LightBulbIcon } from '@heroicons/react/24/outline' +import { issueReportsShared } from '@rocketflare/shared/plugins/issue-reports/index' +import { lazy } from 'react' +import type { NavGuard, UiPlugin } from '@/plugins/api/ui-wiring' +import { issueReportsQueryKeys } from './query-keys' + +const ReportIssuePage = lazy(() => import('./pages/ReportIssuePage')) +const ReportConversationPage = lazy(() => import('./pages/ReportConversationPage')) + +export const ISSUE_REPORTS_GUARD: NavGuard = { action: 'create', subject: 'AgentRun' } + +export const issueReportsUi = { + shared: issueReportsShared, + routes: [ + { path: '/ideas-and-issues', Component: ReportIssuePage, guard: ISSUE_REPORTS_GUARD }, + { + path: '/ideas-and-issues/:runId', + Component: ReportConversationPage, + guard: ISSUE_REPORTS_GUARD, + }, + ], + // Its own group, after the kit's last one: it is about the app, not part of the work in it. + nav: [ + { + label: 'Feedback', + before: 'Platform', + items: [ + { + to: '/ideas-and-issues', + label: 'Ideas & issues', + icon: LightBulbIcon, + guard: ISSUE_REPORTS_GUARD, + }, + ], + }, + ], + queryKeys: issueReportsQueryKeys, +} satisfies UiPlugin diff --git a/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/pages/ReportConversationPage.tsx b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/pages/ReportConversationPage.tsx new file mode 100644 index 0000000..cb5421c --- /dev/null +++ b/plugins/issue-reports/apps/web/src/plugins/issue-reports/ui/pages/ReportConversationPage.tsx @@ -0,0 +1,293 @@ +/** + * `/ideas-and-issues/:runId` — the conversation. It is drawn entirely from the run: the opening + * description is its input, each question and its answer is an `ask:` interrupt, each draft and + * the person's verdict a `review:` one, searches are `step` events, and the logged issue is the + * output. Answering is the kit's interrupt route; Discard is the kit's cancel. + */ +import { ChatBubbleLeftEllipsisIcon } from '@heroicons/react/24/outline' +import { + type AgentRunWithEvents, + agentStepEventDataSchema, + isRunActive, +} from '@rocketflare/shared/ai/agents' +import { type AgentRunInterrupt, choicePayloadSchema } from '@rocketflare/shared/ai/interrupts' +import { + ISSUE_REPORT_FILE_CHOICE, + issueReportInputSchema, + issueReportOutputSchema, +} from '@rocketflare/shared/plugins/issue-reports/index' +import { type FormEvent, type KeyboardEvent, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { useParams } from 'react-router-dom' +import remarkGfm from 'remark-gfm' +import { z } from 'zod' +import { EmptyState, LoadingIndicator, PageHeader } from '@/plugins/api/ui' +import { + useAnswerIssueReport, + useDiscardIssueReport, + useIssueReport, +} from '../hooks/useIssueReports' + +type Item = + | { kind: 'reporter'; id: string; at: Date; text: string } + | { kind: 'agent'; id: string; at: Date; text: string } + | { kind: 'draft'; id: string; at: Date; title: string; body: string } + | { kind: 'note'; id: string; at: Date; text: string } + +const textPayload = z.object({ text: z.string() }) + +function answerText(interrupt: AgentRunInterrupt): string | null { + if (interrupt.status !== 'resolved') return null + if (interrupt.kind === 'input') return textPayload.safeParse(interrupt.payload).data?.text ?? null + const value = choicePayloadSchema.safeParse(interrupt.payload).data?.value + if (!value) return null + return value === ISSUE_REPORT_FILE_CHOICE ? 'Looks good — log it.' : value +} + +/** The run, flattened into what was said, in the order it was said. */ +export function conversationOf(run: AgentRunWithEvents): Item[] { + const items: Item[] = [] + const input = issueReportInputSchema.safeParse(run.input) + items.push({ + kind: 'reporter', + id: 'opening', + at: run.createdAt, + text: input.success ? input.data.description : '', + }) + for (const interrupt of run.interrupts) { + if (interrupt.spec.kind === 'input') { + items.push({ + kind: 'agent', + id: interrupt.id, + at: interrupt.createdAt, + text: interrupt.spec.message, + }) + } else if (interrupt.spec.kind === 'choice') { + const [, ...body] = interrupt.spec.message.split('\n\n') + items.push({ + kind: 'draft', + id: interrupt.id, + at: interrupt.createdAt, + title: interrupt.spec.title ?? 'Draft', + body: body.join('\n\n'), + }) + } + const answer = answerText(interrupt) + if (answer && interrupt.resolvedAt) { + items.push({ + kind: 'reporter', + id: `${interrupt.id}:answer`, + at: interrupt.resolvedAt, + text: answer, + }) + } else if (interrupt.status === 'cancelled' || interrupt.status === 'expired') { + items.push({ + kind: 'note', + id: `${interrupt.id}:skipped`, + at: interrupt.resolvedAt ?? interrupt.updatedAt, + text: interrupt.status === 'expired' ? 'Nobody answered in time.' : 'Skipped.', + }) + } + } + for (const event of run.events) { + if (event.type !== 'step') continue + const step = agentStepEventDataSchema.safeParse(event.data) + if (!step.success || step.data.status !== 'done' || !step.data.key.startsWith('search:')) + continue + items.push({ + kind: 'note', + id: event.id, + at: event.at, + text: `${step.data.label}${step.data.detail ? ` — ${step.data.detail}` : ''}`, + }) + } + return items.sort((a, b) => a.at.getTime() - b.at.getTime()) +} + +function Bubble({ item }: { item: Item }) { + if (item.kind === 'note') { + return

{item.text}

+ } + if (item.kind === 'draft') { + return ( +
+

{item.title}

+
+ + {item.body} + +
+
+ ) + } + const mine = item.kind === 'reporter' + return ( +
+
+ {item.text} +
+
+ ) +} + +function Composer({ + placeholder, + busy, + onSend, +}: { + placeholder: string + busy: boolean + onSend: (text: string) => void +}) { + const [text, setText] = useState('') + const send = (event?: FormEvent) => { + event?.preventDefault() + if (!text.trim() || busy) return + onSend(text.trim()) + setText('') + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) send(event) + } + return ( +
+