Download superlog/apps/api/src/feedback.ts from smlflg/Experimente: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/feedback.ts
- Command line
-
hf download hf://smlflg/Experimente/superlog/apps/api/src/feedback.ts
-
curl -L -o feedback.ts https://huggingface.co/smlflg/Experimente/resolve/main/superlog/apps/api/src/feedback.ts
12 kB
| import { db, schema } from "@superlog/db"; | |
| import { and, eq } from "drizzle-orm"; | |
| import type { Hono } from "hono"; | |
| import { HTTPException } from "hono/http-exception"; | |
| import { auth } from "./auth.js"; | |
| import { logger } from "./logger.js"; | |
| const log = logger.child({ scope: "feedback" }); | |
| const MAX_BODY_LEN = 8_000; | |
| const FEEDBACK_KINDS = new Set<schema.FeedbackKind>(["incident", "issue", "pr"]); | |
| // Public + authed routes share one mount because the PR-link page must be | |
| // reachable without a session (external GitHub PR participants). | |
| // | |
| // /api/feedback (authed) is for the in-product dialog on incident/issue | |
| // drawers β the session middleware in index.ts wraps it. /feedback/pr/... | |
| // is mounted before the session middleware so it's open to anonymous | |
| // submitters. The github.ts webhook handler inserts feedback rows directly | |
| // via the helper below; same for the Slack interactivity handler. | |
| // | |
| // Every insert path funnels through `recordFeedback` so the Slack notifier | |
| // and admin-inbox bookkeeping stay in one place. | |
| // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. | |
| export function mountFeedbackPublic(app: Hono<any>): void { | |
| // Anonymous PR-link submissions. We accept these without a session because | |
| // the link is included in PR descriptions on customer repos, and many | |
| // commenters won't have Superlog accounts. Rate-limiting is intentionally | |
| // out-of-scope for v1 β the form is one click deep, the admin inbox is | |
| // staff-only, and we can lock it down if it gets abused. | |
| app.post("/feedback/pr/:owner/:repo/:number", async (c) => { | |
| const owner = c.req.param("owner"); | |
| const repo = c.req.param("repo"); | |
| const number = Number(c.req.param("number")); | |
| if (!owner || !repo || !Number.isFinite(number) || number <= 0) { | |
| throw new HTTPException(400, { message: "invalid pr ref" }); | |
| } | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| body?: unknown; | |
| githubLogin?: unknown; | |
| }; | |
| const text = typeof body.body === "string" ? body.body.trim() : ""; | |
| if (!text) throw new HTTPException(400, { message: "body required" }); | |
| if (text.length > MAX_BODY_LEN) { | |
| throw new HTTPException(400, { message: "body too long" }); | |
| } | |
| const githubLogin = typeof body.githubLogin === "string" ? body.githubLogin.trim() : ""; | |
| // Look up the agent PR row if we know about it β best-effort, the | |
| // feedback is recorded either way. ref_id is the agent_pull_requests.id | |
| // when we have it, otherwise we fall back to "owner/repo#number" so the | |
| // admin still has something to click. | |
| const repoFullName = `${owner}/${repo}`; | |
| const agentPr = await db.query.agentPullRequests.findFirst({ | |
| where: and( | |
| eq(schema.agentPullRequests.repoFullName, repoFullName), | |
| eq(schema.agentPullRequests.prNumber, number), | |
| ), | |
| }); | |
| // Try to grab the session cookie if the user happens to be signed in β | |
| // that lets us attribute the feedback to a known Superlog user even | |
| // though the route is public. Best-effort. | |
| let authorUserId: string | null = null; | |
| try { | |
| const session = await auth.api.getSession({ headers: c.req.raw.headers }); | |
| authorUserId = session?.user.id ?? null; | |
| } catch { | |
| authorUserId = null; | |
| } | |
| await recordFeedback({ | |
| kind: "pr", | |
| refId: agentPr?.id ?? `${repoFullName}#${number}`, | |
| refRepo: repoFullName, | |
| source: "pr_link", | |
| body: text, | |
| authorUserId, | |
| authorExternal: githubLogin ? { githubLogin } : null, | |
| orgId: null, | |
| projectId: null, | |
| }); | |
| return c.json({ ok: true }); | |
| }); | |
| } | |
| // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance. | |
| export function mountFeedbackAuthed(app: Hono<any>): void { | |
| // In-product dialog. The dialog is rendered on incident and issue | |
| // drawers, so kind is always 'incident' or 'issue'. We trust the caller | |
| // to pass the correct ref_id (incident or issue UUID); we don't gate on | |
| // whether the user can actually see that incident β feedback is an | |
| // unauth'd-by-design signal and the admin inbox is the only thing that | |
| // reads it. | |
| app.post("/api/feedback", async (c) => { | |
| const userId = c.var.userId as string; | |
| const body = (await c.req.json().catch(() => ({}))) as { | |
| kind?: unknown; | |
| refId?: unknown; | |
| body?: unknown; | |
| projectId?: unknown; | |
| }; | |
| const kind = typeof body.kind === "string" ? body.kind : ""; | |
| if (!FEEDBACK_KINDS.has(kind as schema.FeedbackKind) || kind === "pr") { | |
| // PR feedback comes in via /feedback/pr/* (anonymous) or the github | |
| // webhook; the in-product dialog only fires on incident/issue pages. | |
| throw new HTTPException(400, { message: "kind must be 'incident' or 'issue'" }); | |
| } | |
| const refId = typeof body.refId === "string" ? body.refId : ""; | |
| if (!refId) throw new HTTPException(400, { message: "refId required" }); | |
| const text = typeof body.body === "string" ? body.body.trim() : ""; | |
| if (!text) throw new HTTPException(400, { message: "body required" }); | |
| if (text.length > MAX_BODY_LEN) { | |
| throw new HTTPException(400, { message: "body too long" }); | |
| } | |
| // Resolve projectId via lookup before we trust it. A garbage string | |
| // (non-UUID syntax, or a stale ID whose project was deleted) would | |
| // otherwise hit the feedback.project_id FK and 500 on insert. We | |
| // wrap the lookup in try/catch because postgres throws a `22P02` | |
| // syntax error rather than returning empty for malformed UUIDs. | |
| const requestedProjectId = typeof body.projectId === "string" ? body.projectId : null; | |
| let projectId: string | null = null; | |
| let orgId: string | null = null; | |
| if (requestedProjectId) { | |
| const project = await db.query.projects | |
| .findFirst({ where: eq(schema.projects.id, requestedProjectId) }) | |
| .catch(() => null); | |
| if (project) { | |
| projectId = project.id; | |
| orgId = project.orgId; | |
| } | |
| } | |
| await recordFeedback({ | |
| kind: kind as "incident" | "issue", | |
| refId, | |
| refRepo: null, | |
| source: "dialog", | |
| body: text, | |
| authorUserId: userId, | |
| authorExternal: null, | |
| orgId, | |
| projectId, | |
| }); | |
| return c.json({ ok: true }); | |
| }); | |
| // Staff triage reads the same `feedback` table directly. | |
| } | |
| // Shared insert + notify. Exported so the github webhook handler and Slack | |
| // view_submission handler can call it directly without re-implementing the | |
| // shape contract. | |
| export async function recordFeedback(input: { | |
| kind: schema.FeedbackKind; | |
| refId: string; | |
| refRepo: string | null; | |
| source: schema.FeedbackSource; | |
| body: string; | |
| authorUserId: string | null; | |
| authorExternal: schema.FeedbackAuthorExternal | null; | |
| orgId: string | null; | |
| projectId: string | null; | |
| }): Promise<void> { | |
| const [row] = await db | |
| .insert(schema.feedback) | |
| .values({ | |
| kind: input.kind, | |
| refId: input.refId, | |
| refRepo: input.refRepo, | |
| source: input.source, | |
| body: input.body, | |
| authorUserId: input.authorUserId, | |
| authorExternal: input.authorExternal, | |
| orgId: input.orgId, | |
| projectId: input.projectId, | |
| }) | |
| .returning(); | |
| log.info( | |
| { | |
| feedback_id: row?.id, | |
| kind: input.kind, | |
| source: input.source, | |
| ref_id: input.refId, | |
| author_user_id: input.authorUserId, | |
| }, | |
| "feedback recorded", | |
| ); | |
| if (row) { | |
| void notifyFeedbackSlack(row).catch((err) => { | |
| log.warn({ err, feedback_id: row.id }, "feedback slack notify failed"); | |
| }); | |
| } | |
| } | |
| // Posts a one-liner to FEEDBACK_SLACK_WEBHOOK so the team hears about new | |
| // feedback in real time. Best-effort β failures are logged and swallowed | |
| // so they never block the insert path. Configure FEEDBACK_SLACK_WEBHOOK to | |
| // a Slack incoming webhook URL (Slack app β Incoming Webhooks β pick a | |
| // channel). Leave unset to disable. | |
| async function notifyFeedbackSlack(row: schema.Feedback): Promise<void> { | |
| const webhookUrl = process.env.FEEDBACK_SLACK_WEBHOOK; | |
| if (!webhookUrl) return; | |
| // Optional staff tooling deep link. Unset -> no deep link. | |
| const adminOrigin = process.env.ADMIN_WEB_ORIGIN; | |
| const link = adminOrigin ? `${adminOrigin}/feedback?id=${row.id}` : null; | |
| const preview = row.body.length > 280 ? `${row.body.slice(0, 277)}β¦` : row.body; | |
| let authorEmail: string | null = null; | |
| if (row.authorUserId) { | |
| const user = await db.query.users.findFirst({ | |
| where: eq(schema.users.id, row.authorUserId), | |
| }); | |
| authorEmail = user?.email ?? null; | |
| } | |
| const author = authorEmail | |
| ? authorEmail | |
| : row.authorExternal?.githubLogin | |
| ? `@${row.authorExternal.githubLogin} (github)` | |
| : row.authorExternal?.slackUserId | |
| ? `<@${row.authorExternal.slackUserId}> (slack)` | |
| : "anonymous"; | |
| // For PR feedback the refId is either an agent_pull_requests UUID (when | |
| // we know the PR β happens for PR-comment webhooks and for /feedback/pr/* | |
| // POSTs that match an agent PR row) or the fallback "owner/repo#number" | |
| // string from feedback.ts:79 (when the link was hit for a PR we don't | |
| // track). The UUID case needs the repo name prepended; the fallback case | |
| // already includes it, so re-prepending makes "owner/repo#β¦" appear twice. | |
| // Detect UUIDs by length (36) + presence of dashes. | |
| const refDesc = | |
| row.kind === "pr" | |
| ? row.refId.includes("#") | |
| ? `PR ${row.refId}` | |
| : `PR ${row.refRepo ?? ""} (${row.refId.slice(0, 8)}β¦)` | |
| : `${row.kind} ${row.refId.slice(0, 8)}β¦`; | |
| const text = `:speech_balloon: *New feedback* on ${refDesc} via _${row.source}_ from ${author}\n>${preview}${ | |
| link ? `\n<${link}|Open in admin>` : "" | |
| }`; | |
| try { | |
| const res = await fetch(webhookUrl, { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ text }), | |
| }); | |
| if (!res.ok) { | |
| log.warn({ status: res.status }, "feedback slack webhook returned non-2xx"); | |
| } | |
| } catch (err) { | |
| log.warn({ err }, "feedback slack webhook fetch failed"); | |
| } | |
| } | |
| // Used by github.ts to decide whether a PR comment counts as feedback. | |
| // Greptile and other bot accounts have type='Bot'; their reviews are | |
| // review-bot output, not user feedback. The author_association check | |
| // keeps us from accepting drive-by comments from random GitHub users | |
| // who happened to wander in β feedback should come from people who | |
| // have a real relationship with the PR. | |
| // | |
| // Possible author_associations: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, | |
| // FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE. | |
| const FEEDBACK_ASSOCIATIONS = new Set([ | |
| "OWNER", | |
| "MEMBER", | |
| "COLLABORATOR", | |
| "CONTRIBUTOR", | |
| "FIRST_TIME_CONTRIBUTOR", | |
| ]); | |
| export function isFeedbackEligibleCommenter(opts: { | |
| userType?: string | null; | |
| authorAssociation?: string | null; | |
| }): boolean { | |
| if (opts.userType === "Bot") return false; | |
| if (!opts.authorAssociation) return false; | |
| return FEEDBACK_ASSOCIATIONS.has(opts.authorAssociation); | |
| } | |
| // Used by the dropped FEEDBACK_SLACK_WEBHOOK notifier β exported so tests | |
| // can confirm the env var is honored without invoking fetch. | |
| export function feedbackSlackWebhookConfigured(): boolean { | |
| return !!process.env.FEEDBACK_SLACK_WEBHOOK; | |
| } | |
| // Marker used by the github webhook to filter out our own footer so we | |
| // don't surface our own link as feedback when GitHub echoes it back. | |
| // The footer string itself is rendered by `renderFeedbackFooter` in | |
| // apps/worker/src/github-app.ts (worker can't import from the api app), | |
| // so any change to the URL path there needs to be mirrored here. The | |
| // path `/feedback/pr/` is wired to a router pattern and is unlikely to | |
| // move; matching on it (rather than the whole footer text) keeps this | |
| // resilient to copy edits in the footer. | |
| export const FEEDBACK_PR_FOOTER_MARKER = "/feedback/pr/"; | |
| // Used by the admin inbox query β also exported to keep the | |
| // authorAssociation list in one place. Tests import this set directly. | |
| export { FEEDBACK_ASSOCIATIONS }; | |