Skip to content

Commit 22aaaf1

Browse files
committed
feat(feedback): add structured feedback reporting
Add privacy-aware /feedback submissions with redacted session context, GitHub fallback handling, and worker support for structured payloads. Also keep the welcome/update refinements and Dependabot config changes currently present on the branch.
1 parent 87bad36 commit 22aaaf1

9 files changed

Lines changed: 1400 additions & 36 deletions

File tree

.github/dependabot.yml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,23 @@ updates:
88
- package-ecosystem: "uv"
99
directory: "/"
1010
schedule:
11-
interval: "daily"
11+
interval: "weekly"
12+
day: "monday"
13+
# Cap concurrent bot PRs so the queue stays reviewable.
14+
open-pull-requests-limit: 3
15+
# Consolidate routine bumps into a single PR; isolate majors so
16+
# breaking changes get their own review.
17+
groups:
18+
minor-and-patch:
19+
update-types:
20+
- "minor"
21+
- "patch"
22+
ignore:
23+
# ruff is deliberately pinned `<0.15` in pyproject.toml; 0.15's formatter
24+
# reflow fails `make check`. Don't let Dependabot widen the ceiling.
25+
- dependency-name: "ruff"
26+
versions: [">=0.15"]
27+
# click 8.4.x regresses pyright (`click.Option` typed partially unknown,
28+
# ~95 errors in `make check`). Hold until the type regression is resolved.
29+
- dependency-name: "click"
30+
versions: [">=8.4"]

examples/feedback-worker/src/index.ts

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,34 @@ type RecentError = {
2020
};
2121

2222
type FeedbackPayload = {
23+
schema_version?: number;
2324
session_id?: string;
2425
type?: string;
2526
content?: string;
2627
version?: string;
2728
os?: string;
2829
model?: string;
2930
recent_errors?: RecentError[];
31+
session?: Record<string, unknown>;
32+
client?: Record<string, unknown>;
33+
repo?: Record<string, unknown>;
34+
context?: {
35+
recent_errors?: RecentError[];
36+
last_messages?: unknown[];
37+
tool_calls?: unknown[];
38+
subagents?: unknown[];
39+
};
40+
privacy?: Record<string, unknown>;
41+
};
42+
43+
type GitHubIssue = {
44+
number?: number;
45+
html_url?: string;
3046
};
3147

3248
const MAX_CONTENT_LENGTH = 10_000;
3349
const MAX_RECENT_ERRORS = 10;
50+
const MAX_CONTEXT_ITEMS = 20;
3451

3552
export default {
3653
async fetch(request: Request, env: Env): Promise<Response> {
@@ -50,15 +67,17 @@ export default {
5067

5168
let payload: FeedbackPayload;
5269
try {
53-
payload = await request.json<FeedbackPayload>();
70+
payload = (await request.json()) as FeedbackPayload;
5471
} catch {
5572
return jsonResponse({ error: "invalid_json" }, 400);
5673
}
5774

5875
const content = (payload.content || "").trim();
59-
const recentErrors = Array.isArray(payload.recent_errors)
60-
? payload.recent_errors.slice(0, MAX_RECENT_ERRORS)
61-
: [];
76+
const recentErrors = Array.isArray(payload.context?.recent_errors)
77+
? payload.context.recent_errors.slice(0, MAX_RECENT_ERRORS)
78+
: Array.isArray(payload.recent_errors)
79+
? payload.recent_errors.slice(0, MAX_RECENT_ERRORS)
80+
: [];
6281
if (!content && recentErrors.length === 0) {
6382
return jsonResponse({ error: "empty_feedback" }, 400);
6483
}
@@ -74,15 +93,25 @@ export default {
7493
os: trim(payload.os, 128),
7594
model: trim(payload.model, 128),
7695
recent_errors: recentErrors.map(sanitizeRecentError),
96+
session: sanitizeRecord(payload.session),
97+
client: sanitizeRecord(payload.client),
98+
repo: sanitizeRecord(payload.repo, 24, 20_000),
99+
context: {
100+
recent_errors: recentErrors.map(sanitizeRecentError),
101+
last_messages: sanitizeArray(payload.context?.last_messages),
102+
tool_calls: sanitizeArray(payload.context?.tool_calls),
103+
subagents: sanitizeArray(payload.context?.subagents),
104+
},
105+
privacy: sanitizeRecord(payload.privacy),
77106
};
78107

79108
const title = githubTitle(sanitizedPayload);
80109
const body = githubBody(sanitizedPayload, request);
81110

82-
await createGithubIssue(env, title, body);
111+
const issue = await createGithubIssue(env, sanitizedPayload, title, body);
83112
await sendSupportEmail(env, title, body);
84113

85-
return corsResponse(null, 204);
114+
return jsonResponse({ number: issue.number, html_url: issue.html_url }, 201);
86115
},
87116
};
88117

@@ -103,6 +132,33 @@ function sanitizeRecentError(error: RecentError): RecentError {
103132
};
104133
}
105134

135+
function sanitizeValue(value: unknown, maxStringLength = 2_000): unknown {
136+
if (typeof value === "string") return value.slice(0, maxStringLength);
137+
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
138+
if (Array.isArray(value)) return value.slice(0, MAX_CONTEXT_ITEMS).map((item) => sanitizeValue(item));
139+
if (typeof value === "object" && value !== null) return sanitizeRecord(value as Record<string, unknown>);
140+
return undefined;
141+
}
142+
143+
function sanitizeRecord(
144+
value: unknown,
145+
maxKeys = 20,
146+
maxStringLength = 2_000,
147+
): Record<string, unknown> | undefined {
148+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
149+
const out: Record<string, unknown> = {};
150+
for (const [key, raw] of Object.entries(value).slice(0, maxKeys)) {
151+
out[key.slice(0, 80)] = sanitizeValue(raw, maxStringLength);
152+
}
153+
return out;
154+
}
155+
156+
function sanitizeArray(value: unknown): unknown[] | undefined {
157+
return Array.isArray(value)
158+
? value.slice(0, MAX_CONTEXT_ITEMS).map((item) => sanitizeValue(item))
159+
: undefined;
160+
}
161+
106162
function githubTitle(payload: FeedbackPayload): string {
107163
const prefix = payload.type === "error" ? "Error report" : "Feedback";
108164
const version = payload.version ? ` ${payload.version}` : "";
@@ -127,6 +183,9 @@ function githubBody(payload: FeedbackPayload, request: Request): string {
127183
`- CF ray: ${request.headers.get("cf-ray") || "unknown"}`,
128184
];
129185

186+
appendJsonSection(lines, "Privacy", payload.privacy);
187+
appendJsonSection(lines, "Repository", payload.repo);
188+
130189
if (payload.recent_errors?.length) {
131190
lines.push("", "## Recent errors", "");
132191
for (const error of payload.recent_errors) {
@@ -138,11 +197,30 @@ function githubBody(payload: FeedbackPayload, request: Request): string {
138197
}
139198
}
140199

200+
appendJsonSection(lines, "Recent visible messages", payload.context?.last_messages);
201+
appendJsonSection(lines, "Tool calls", payload.context?.tool_calls);
202+
appendJsonSection(lines, "Subagents", payload.context?.subagents);
203+
141204
return lines.join("\n");
142205
}
143206

144-
async function createGithubIssue(env: Env, title: string, body: string): Promise<void> {
145-
const labels = splitCsv(env.GITHUB_LABELS || "feedback,pythinker-cli");
207+
function appendJsonSection(lines: string[], title: string, value: unknown): void {
208+
if (value === undefined || value === null) return;
209+
if (Array.isArray(value) && value.length === 0) return;
210+
if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) return;
211+
lines.push("", `## ${title}`, "", "```json", JSON.stringify(value, null, 2), "```");
212+
}
213+
214+
async function createGithubIssue(
215+
env: Env,
216+
payload: FeedbackPayload,
217+
title: string,
218+
body: string,
219+
): Promise<GitHubIssue> {
220+
const labels = unique([
221+
...splitCsv(env.GITHUB_LABELS || "feedback,pythinker-cli"),
222+
`feedback:${payload.type || "feedback"}`,
223+
]);
146224
const assignees = splitCsv(env.GITHUB_ASSIGNEES || "");
147225
const response = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
148226
method: "POST",
@@ -159,6 +237,8 @@ async function createGithubIssue(env: Env, title: string, body: string): Promise
159237
if (!response.ok) {
160238
throw new Error(`GitHub issue creation failed: ${response.status}`);
161239
}
240+
const issue = (await response.json()) as GitHubIssue;
241+
return { number: issue.number, html_url: issue.html_url };
162242
}
163243

164244
async function sendSupportEmail(env: Env, subject: string, body: string): Promise<void> {
@@ -229,6 +309,10 @@ function splitCsv(value: string): string[] {
229309
.filter(Boolean);
230310
}
231311

312+
function unique(values: string[]): string[] {
313+
return Array.from(new Set(values));
314+
}
315+
232316
function corsResponse(body: BodyInit | null, status: number): Response {
233317
return new Response(body, { status, headers: corsHeaders() });
234318
}

0 commit comments

Comments
 (0)