File size: 12,045 Bytes
95b671e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | 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 };
|