create-feedbax 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +502 -209
  3. package/dist/index.js +251 -68
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +1 -1
  10. package/template/CONNECTED.md +16 -2
  11. package/template/README.md +52 -13
  12. package/template/feedbax-vite.ts +79 -0
  13. package/template/migrations/0005_github.sql +4 -0
  14. package/template/migrations/0006_integration.sql +3 -0
  15. package/template/migrations/0007_ownership.sql +2 -0
  16. package/template/migrations/0008_integration_recovery.sql +5 -0
  17. package/template/migrations/0009_observed_history.sql +3 -0
  18. package/template/package.json +34 -33
  19. package/template/src/changelog-runtime.ts +2 -1
  20. package/template/src/cloudflare-workers.d.ts +3 -0
  21. package/template/src/comment-creation.test.tsx +53 -6
  22. package/template/src/components/connected-identity-provider.tsx +44 -17
  23. package/template/src/components/follow-post-button.tsx +2 -13
  24. package/template/src/components/post-history.tsx +101 -0
  25. package/template/src/components/profile-menu.tsx +7 -0
  26. package/template/src/configured-feedback.test.ts +82 -0
  27. package/template/src/configured-feedback.ts +61 -0
  28. package/template/src/connected-identity.test.ts +7 -3
  29. package/template/src/connected-identity.ts +6 -1
  30. package/template/src/connected-runtime.test.ts +5 -2
  31. package/template/src/feedback-cache-invalidation.ts +2 -0
  32. package/template/src/feedback-runtime.ts +61 -18
  33. package/template/src/feedbax/changelog/index.test.ts +35 -0
  34. package/template/src/feedbax/changelog/index.ts +5 -1
  35. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  36. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  37. package/template/src/feedbax/config/index.test.ts +45 -1
  38. package/template/src/feedbax/config/index.ts +112 -9
  39. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  40. package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
  41. package/template/src/feedbax/feedback/connected.ts +15 -5
  42. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  43. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  44. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  45. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  46. package/template/src/feedbax/feedback/github-request.ts +120 -0
  47. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  48. package/template/src/feedbax/feedback/image-feedback.ts +23 -10
  49. package/template/src/feedbax/feedback/images.ts +26 -0
  50. package/template/src/feedbax/feedback/index.ts +172 -16
  51. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  52. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  53. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  54. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  55. package/template/src/feedbax/feedback/ownership.ts +170 -0
  56. package/template/src/history-runtime.test.ts +80 -0
  57. package/template/src/history-runtime.ts +37 -0
  58. package/template/src/index.css +6 -0
  59. package/template/src/integration-runtime.test.ts +365 -0
  60. package/template/src/integration-runtime.ts +289 -0
  61. package/template/src/my-feedback-page.test.tsx +152 -0
  62. package/template/src/my-feedback-page.tsx +398 -0
  63. package/template/src/my-feedback-runtime.test.ts +93 -0
  64. package/template/src/my-feedback-runtime.ts +154 -0
  65. package/template/src/participation-runtime.ts +3 -3
  66. package/template/src/portal-comments.ts +11 -0
  67. package/template/src/portal-feedback-form.tsx +11 -3
  68. package/template/src/portal-feedback-submission.ts +9 -0
  69. package/template/src/portal-server-entry.ts +9 -0
  70. package/template/src/public-portal-error.tsx +1 -1
  71. package/template/src/public-post-detail.tsx +332 -256
  72. package/template/src/public-release-links.test.ts +120 -0
  73. package/template/src/public-release-links.ts +130 -0
  74. package/template/src/routeTree.gen.ts +21 -0
  75. package/template/src/routes/my-feedback.tsx +16 -0
  76. package/template/src/server.ts +46 -1
  77. package/template/src/submission-intent.ts +26 -0
  78. package/template/vite.config.ts +2 -0
@@ -0,0 +1,581 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { D1Database } from "@cloudflare/workers-types";
3
+ import type {
4
+ FeedbackStorage,
5
+ NewStoredPost,
6
+ StoredPost,
7
+ } from "./feedback-storage";
8
+ import {
9
+ BrowserCapabilityAuthorizationError,
10
+ PublicPostCursorError,
11
+ VoteEligibilityError,
12
+ type PostStatus,
13
+ type PostType,
14
+ } from "./index";
15
+ import {
16
+ createGitHubClient,
17
+ GitHubRateLimitError,
18
+ isRecord,
19
+ type GitHubAccessOptions,
20
+ } from "./github-request";
21
+
22
+ export interface GitHubFeedbackOptions extends GitHubAccessOptions {
23
+ database: D1Database;
24
+ votingEnabled?: boolean;
25
+ commentsEnabled?: boolean;
26
+ }
27
+
28
+ interface Mapping {
29
+ installation_id: string;
30
+ repository_id: number;
31
+ post_id: string;
32
+ slug: string;
33
+ external_id: string | null;
34
+ issue_number: number | null;
35
+ issue_id: number | null;
36
+ record: string;
37
+ state:
38
+ "reserved" | "creating" | "ready" | "uncertain" | "failed" | "withdrawn";
39
+ last_status: PostStatus;
40
+ vote_count: number;
41
+ projection_hash: string | null;
42
+ public_updated_at: number | null;
43
+ }
44
+
45
+ const statuses = {
46
+ "feedbax:new": "New",
47
+ "feedbax:reviewing": "Reviewing",
48
+ "feedbax:planned": "Planned",
49
+ "feedbax:in-progress": "In Progress",
50
+ "feedbax:shipped": "Shipped",
51
+ "feedbax:closed": "Closed",
52
+ } as const;
53
+ const start = "<!-- feedbax:public:v1 -->";
54
+ const end = "<!-- /feedbax:public -->";
55
+
56
+ export function createGitHubFeedbackStorage(
57
+ options: GitHubFeedbackOptions,
58
+ ): FeedbackStorage {
59
+ const { database, installationId, repository } = options;
60
+ const { request, authorize, scan: scanAll } = createGitHubClient(options);
61
+ const mapping = (field: "post_id" | "slug" | "external_id", value: string) =>
62
+ database
63
+ .prepare(
64
+ `SELECT * FROM github_posts WHERE installation_id=? AND repository_id=? AND ${field}=?`,
65
+ )
66
+ .bind(installationId, repository.id, value)
67
+ .first<Mapping>();
68
+ const scan = async () =>
69
+ (await scanAll()).filter((issue) => !issue.pull_request);
70
+ const complete = async (row: Mapping, issue: Record<string, unknown>) => {
71
+ if (
72
+ !Number.isSafeInteger(issue.number) ||
73
+ Number(issue.number) <= 0 ||
74
+ !Number.isSafeInteger(issue.id) ||
75
+ Number(issue.id) <= 0 ||
76
+ !isRecord(issue.user) ||
77
+ issue.user.id !== options.botUserId ||
78
+ issue.pull_request ||
79
+ !parseContent(issue.body, row.slug)
80
+ )
81
+ throw new Error("GitHub returned an invalid feedback issue.");
82
+ await database
83
+ .prepare(
84
+ "UPDATE github_posts SET state='ready',issue_number=?,issue_id=? WHERE installation_id=? AND repository_id=? AND post_id=? AND state IN ('creating','uncertain')",
85
+ )
86
+ .bind(issue.number, issue.id, installationId, repository.id, row.post_id)
87
+ .run();
88
+ return storedPost({
89
+ ...row,
90
+ state: "ready",
91
+ issue_number: Number(issue.number),
92
+ issue_id: Number(issue.id),
93
+ });
94
+ };
95
+ const recover = async (row: Mapping): Promise<StoredPost> => {
96
+ await authorize();
97
+ const candidates = (await scan()).filter(
98
+ (issue) =>
99
+ isRecord(issue.user) &&
100
+ issue.user.id === options.botUserId &&
101
+ parseContent(issue.body, row.slug),
102
+ );
103
+ if (candidates.length !== 1)
104
+ throw new Error(
105
+ "GitHub creation is pending reconciliation; no additional issue was created.",
106
+ );
107
+ return complete(row, candidates[0]!);
108
+ };
109
+ const issueFor = async (
110
+ row: Mapping,
111
+ ): Promise<Record<string, unknown> | undefined> => {
112
+ await authorize();
113
+ const response = await request(`/issues/${row.issue_number}`);
114
+ if (response.status === 404 || response.status === 410) return;
115
+ if (!response.ok)
116
+ throw new Error(`GitHub read failed (${response.status}).`);
117
+ const issue = (await response.json()) as Record<string, unknown>;
118
+ if (issue.id !== row.issue_id || issue.pull_request) return;
119
+ return issue;
120
+ };
121
+ const project = async (
122
+ row: Mapping,
123
+ issue: Record<string, unknown>,
124
+ remember = true,
125
+ ): Promise<StoredPost | undefined> => {
126
+ const content = parseContent(issue.body, row.slug);
127
+ if (!content) return;
128
+ const labels = Array.isArray(issue.labels)
129
+ ? issue.labels.map((label: unknown) =>
130
+ typeof label === "string"
131
+ ? label
132
+ : isRecord(label)
133
+ ? label.name
134
+ : undefined,
135
+ )
136
+ : [];
137
+ const matched = Object.entries(statuses).filter(([label]) =>
138
+ labels.includes(label),
139
+ );
140
+ const status = matched.length === 1 ? matched[0]![1] : row.last_status;
141
+ const stored = storedPost(row);
142
+ const post: StoredPost = {
143
+ ...stored,
144
+ ...content,
145
+ teamResponse: content.teamResponse,
146
+ releaseNotesUrl: content.releaseNotesUrl,
147
+ status,
148
+ published: labels.includes("feedbax:published"),
149
+ draftEditable:
150
+ matched.length === 1 &&
151
+ status === "New" &&
152
+ !labels.includes("feedbax:published"),
153
+ };
154
+ const hash = projectionHash(post);
155
+ const providerTime = Date.parse(String(issue.updated_at));
156
+ const updatedAt =
157
+ row.projection_hash === hash && row.public_updated_at !== null
158
+ ? row.public_updated_at
159
+ : Math.max(
160
+ row.public_updated_at ?? 0,
161
+ Number.isFinite(providerTime)
162
+ ? providerTime
163
+ : stored.updatedAt.getTime(),
164
+ );
165
+ if (remember) {
166
+ const active = await database
167
+ .prepare(
168
+ "UPDATE github_posts SET last_status=?,projection_hash=?,public_updated_at=? WHERE installation_id=? AND repository_id=? AND post_id=? AND state='ready' RETURNING post_id",
169
+ )
170
+ .bind(
171
+ status,
172
+ hash,
173
+ updatedAt,
174
+ installationId,
175
+ repository.id,
176
+ row.post_id,
177
+ )
178
+ .first();
179
+ if (!active) return;
180
+ }
181
+ return { ...post, updatedAt: new Date(updatedAt) };
182
+ };
183
+ const read = async (row: Mapping | null): Promise<StoredPost | undefined> => {
184
+ if (!row || row.state !== "ready") return;
185
+ const issue = await issueFor(row);
186
+ return issue ? project(row, issue) : undefined;
187
+ };
188
+ const listSnapshot = async () => {
189
+ await authorize();
190
+ const issues = await scan();
191
+ const byId = new Map(issues.map((issue) => [issue.id, issue]));
192
+ const rows = await database
193
+ .prepare(
194
+ "SELECT * FROM github_posts WHERE installation_id=? AND repository_id=? AND state='ready' AND issue_id IN (SELECT value FROM json_each(?))",
195
+ )
196
+ .bind(
197
+ installationId,
198
+ repository.id,
199
+ JSON.stringify(
200
+ issues.map((issue) => issue.id).filter(Number.isSafeInteger),
201
+ ),
202
+ )
203
+ .all<Mapping>();
204
+ const projected = await Promise.all(
205
+ rows.results.map(async (row) => {
206
+ const issue = byId.get(row.issue_id);
207
+ return issue ? project(row, issue, false) : undefined;
208
+ }),
209
+ );
210
+ const posts = projected.filter((post): post is StoredPost => Boolean(post));
211
+ if (posts.length) {
212
+ const changes = JSON.stringify(
213
+ posts.map((post) => ({
214
+ id: post.id,
215
+ status: post.status,
216
+ hash: projectionHash(post),
217
+ updatedAt: post.updatedAt.getTime(),
218
+ })),
219
+ );
220
+ const active = await database
221
+ .prepare(
222
+ "UPDATE github_posts AS p SET last_status=json_extract(v.value,'$.status'),projection_hash=json_extract(v.value,'$.hash'),public_updated_at=json_extract(v.value,'$.updatedAt') FROM json_each(?) AS v WHERE p.post_id=json_extract(v.value,'$.id') AND p.installation_id=? AND p.repository_id=? AND p.state='ready' RETURNING post_id",
223
+ )
224
+ .bind(changes, installationId, repository.id)
225
+ .all<{ post_id: string }>();
226
+ const visible = new Set(active.results.map((row) => row.post_id));
227
+ return posts.filter((post) => visible.has(post.id));
228
+ }
229
+ return posts;
230
+ };
231
+ const resume = async (row: Mapping): Promise<StoredPost> => {
232
+ if (row.state === "ready") return (await read(row)) ?? storedPost(row);
233
+ if (row.state === "withdrawn")
234
+ return { ...storedPost(row), published: false, status: "Closed" };
235
+ if (row.state === "failed")
236
+ throw new Error(
237
+ "GitHub creation was rejected; repair the original operation before retrying.",
238
+ );
239
+ if (row.state !== "reserved") return recover(row);
240
+ await authorize();
241
+ const claimed = await database
242
+ .prepare(
243
+ "UPDATE github_posts SET state='creating' WHERE installation_id=? AND repository_id=? AND post_id=? AND state='reserved' RETURNING *",
244
+ )
245
+ .bind(installationId, repository.id, row.post_id)
246
+ .first<Mapping>();
247
+ if (!claimed) return recover(row);
248
+ let failureState = "uncertain";
249
+ try {
250
+ const item = storedPost(row);
251
+ const response = await request("/issues", "POST", {
252
+ title: item.title,
253
+ body: serializeContent(item),
254
+ labels: ["feedbax:new"],
255
+ });
256
+ if (!response.ok) {
257
+ failureState =
258
+ response.status === 429 ||
259
+ (response.status === 403 &&
260
+ (response.headers.has("Retry-After") ||
261
+ response.headers.get("X-RateLimit-Remaining") === "0"))
262
+ ? "reserved"
263
+ : response.status >= 400 &&
264
+ response.status < 500 &&
265
+ response.status !== 408
266
+ ? "failed"
267
+ : "uncertain";
268
+ throw new Error(`GitHub creation failed (${response.status}).`);
269
+ }
270
+ const issue = (await response.json()) as Record<string, unknown>;
271
+ return await complete(row, issue);
272
+ } catch (error) {
273
+ if (error instanceof GitHubRateLimitError) failureState = "reserved";
274
+ await database
275
+ .prepare(
276
+ "UPDATE github_posts SET state=? WHERE installation_id=? AND repository_id=? AND post_id=? AND state='creating'",
277
+ )
278
+ .bind(failureState, installationId, repository.id, row.post_id)
279
+ .run();
280
+ throw error;
281
+ }
282
+ };
283
+ return {
284
+ async create(item) {
285
+ const row = await database
286
+ .prepare(
287
+ "INSERT INTO github_posts(installation_id,repository_id,post_id,slug,external_id,record,state) VALUES(?,?,?,?,?,?,'reserved') ON CONFLICT DO NOTHING RETURNING *",
288
+ )
289
+ .bind(
290
+ installationId,
291
+ repository.id,
292
+ randomUUID(),
293
+ item.slug,
294
+ item.externalId ?? null,
295
+ JSON.stringify(item),
296
+ )
297
+ .first<Mapping>();
298
+ if (row) return resume(row);
299
+ const existing = item.externalId
300
+ ? await mapping("external_id", item.externalId)
301
+ : null;
302
+ if (existing) return resume(existing);
303
+ throw new Error(
304
+ "GitHub Post slug is already reserved; retry the submission.",
305
+ );
306
+ },
307
+ find: async (id) => read(await mapping("post_id", id)),
308
+ findBySlug: async (slug) => {
309
+ const row = await mapping("slug", slug);
310
+ if (!row) return;
311
+ return (
312
+ (await read(row)) ?? {
313
+ ...storedPost(row),
314
+ status: "Closed",
315
+ published: false,
316
+ }
317
+ );
318
+ },
319
+ findPublicBySlug: async (slug) => {
320
+ const post = await read(await mapping("slug", slug));
321
+ return post?.published ? post : undefined;
322
+ },
323
+ findByExternalId: async (id) => {
324
+ const row = await mapping("external_id", id);
325
+ return row ? resume(row) : undefined;
326
+ },
327
+ async save(item) {
328
+ const row = await mapping("post_id", item.id);
329
+ const issue = row?.state === "ready" ? await issueFor(row) : undefined;
330
+ const current = row && issue ? await project(row, issue) : undefined;
331
+ if (
332
+ !row ||
333
+ !issue ||
334
+ !current ||
335
+ current.published ||
336
+ current.draftEditable === false ||
337
+ current.status !== "New"
338
+ )
339
+ throw new BrowserCapabilityAuthorizationError();
340
+ const body = String(issue.body);
341
+ const replacement =
342
+ body.slice(0, body.indexOf(start)) +
343
+ serializeContent({ ...item, slug: row.slug }, true) +
344
+ body.slice(body.indexOf(end) + end.length);
345
+ const response = await request(`/issues/${row.issue_number}`, "PATCH", {
346
+ body: replacement,
347
+ });
348
+ if (!response.ok)
349
+ throw new Error(`GitHub draft edit failed (${response.status}).`);
350
+ const saved = {
351
+ ...current,
352
+ title: item.title,
353
+ description: item.description,
354
+ type: item.type,
355
+ updatedAt: item.updatedAt,
356
+ };
357
+ await database
358
+ .prepare(
359
+ "UPDATE github_posts SET record=? WHERE installation_id=? AND repository_id=? AND post_id=? AND state='ready'",
360
+ )
361
+ .bind(JSON.stringify(saved), installationId, repository.id, row.post_id)
362
+ .run();
363
+ return saved;
364
+ },
365
+ async remove(id) {
366
+ const row = await mapping("post_id", id);
367
+ const current = await read(row);
368
+ if (
369
+ !row ||
370
+ !current ||
371
+ current.published ||
372
+ current.draftEditable === false ||
373
+ current.status !== "New"
374
+ )
375
+ throw new BrowserCapabilityAuthorizationError();
376
+ // The tombstone revokes local visibility even if GitHub cleanup is interrupted.
377
+ await database
378
+ .prepare(
379
+ "UPDATE github_posts SET state='withdrawn' WHERE installation_id=? AND repository_id=? AND post_id=?",
380
+ )
381
+ .bind(installationId, repository.id, id)
382
+ .run();
383
+ const response = await request(`/issues/${row.issue_number}`, "PATCH", {
384
+ state: "closed",
385
+ });
386
+ if (!response.ok)
387
+ throw new Error(
388
+ `GitHub withdrawal cleanup failed (${response.status}); public visibility remains revoked.`,
389
+ );
390
+ },
391
+ list: listSnapshot,
392
+ async listPublic(query) {
393
+ const matching = (await listSnapshot()).filter(
394
+ (post) =>
395
+ post.published &&
396
+ (!query.type || post.type === query.type) &&
397
+ (!query.status || post.status === query.status) &&
398
+ (!query.types?.length || query.types.includes(post.type)) &&
399
+ (!query.statuses?.length || query.statuses.includes(post.status)) &&
400
+ (!query.search ||
401
+ `${post.title}\n${post.description}`
402
+ .toLowerCase()
403
+ .includes(query.search.toLowerCase())),
404
+ );
405
+ matching.sort(
406
+ (a, b) =>
407
+ (query.sort === "new"
408
+ ? 0
409
+ : (b.voteCount ?? 0) - (a.voteCount ?? 0)) ||
410
+ b.createdAt.getTime() - a.createdAt.getTime() ||
411
+ b.id.localeCompare(a.id),
412
+ );
413
+ return paginate(matching, query.cursor);
414
+ },
415
+ async listPublicRoadmap(query) {
416
+ const matching = (await listSnapshot()).filter(
417
+ (post) => post.published && post.status === query.status,
418
+ );
419
+ matching.sort(
420
+ (a, b) =>
421
+ b.updatedAt.getTime() - a.updatedAt.getTime() ||
422
+ b.id.localeCompare(a.id),
423
+ );
424
+ return {
425
+ ...paginate(matching, query.cursor),
426
+ totalCount: matching.length,
427
+ };
428
+ },
429
+ async updateVoteCount(item, voteCount) {
430
+ const current = await read(await mapping("post_id", item.id));
431
+ if (!current?.published) throw new VoteEligibilityError();
432
+ const value = Math.max(0, Math.trunc(voteCount));
433
+ await database
434
+ .prepare(
435
+ "UPDATE github_posts SET vote_count=? WHERE installation_id=? AND repository_id=? AND post_id=? AND state='ready'",
436
+ )
437
+ .bind(value, installationId, repository.id, item.id)
438
+ .run();
439
+ return { ...current, voteCount: value };
440
+ },
441
+ };
442
+ }
443
+
444
+ function projectionHash(post: StoredPost) {
445
+ return createHash("sha256")
446
+ .update(
447
+ JSON.stringify([
448
+ post.title,
449
+ post.description,
450
+ post.type,
451
+ post.teamResponse ?? null,
452
+ post.releaseNotesUrl ?? null,
453
+ post.status,
454
+ post.published,
455
+ ]),
456
+ )
457
+ .digest("hex");
458
+ }
459
+
460
+ function paginate(items: StoredPost[], cursor?: string) {
461
+ const id = cursor
462
+ ? Buffer.from(cursor, "base64url").toString("utf8")
463
+ : undefined;
464
+ const index = id ? items.findIndex((post) => post.id === id) : -1;
465
+ if (id && index === -1) throw new PublicPostCursorError();
466
+ const page = items.slice(index + 1, index + 26);
467
+ return {
468
+ items: page,
469
+ ...(index + 1 + page.length < items.length && page.length
470
+ ? { nextCursor: Buffer.from(page.at(-1)!.id).toString("base64url") }
471
+ : {}),
472
+ };
473
+ }
474
+
475
+ function storedPost(row: Mapping): StoredPost {
476
+ const value = JSON.parse(row.record) as NewStoredPost;
477
+ return {
478
+ ...value,
479
+ id: row.post_id,
480
+ slug: row.slug,
481
+ createdAt: new Date(value.createdAt),
482
+ updatedAt: new Date(value.updatedAt),
483
+ status: row.last_status,
484
+ voteCount: row.vote_count,
485
+ };
486
+ }
487
+
488
+ function serializeContent(
489
+ item: Pick<
490
+ StoredPost,
491
+ | "slug"
492
+ | "title"
493
+ | "description"
494
+ | "type"
495
+ | "teamResponse"
496
+ | "releaseNotesUrl"
497
+ >,
498
+ preserveResponse = false,
499
+ ) {
500
+ return `${start}\n${JSON.stringify({
501
+ slug: item.slug,
502
+ title: item.title,
503
+ description: item.description,
504
+ type: item.type,
505
+ ...(preserveResponse && item.teamResponse
506
+ ? { teamResponse: item.teamResponse }
507
+ : {}),
508
+ ...(preserveResponse && safeLink(item.releaseNotesUrl)
509
+ ? { releaseNotesUrl: item.releaseNotesUrl }
510
+ : {}),
511
+ }).replaceAll("<", "\\u003c")}\n${end}`;
512
+ }
513
+
514
+ function parseContent(
515
+ body: unknown,
516
+ slug: string,
517
+ ):
518
+ | {
519
+ title: string;
520
+ description: string;
521
+ type: PostType;
522
+ teamResponse?: string;
523
+ releaseNotesUrl?: string;
524
+ }
525
+ | undefined {
526
+ if (
527
+ typeof body !== "string" ||
528
+ body.split(start).length !== 2 ||
529
+ body.split(end).length !== 2
530
+ )
531
+ return;
532
+ const from = body.indexOf(start) + start.length;
533
+ const to = body.indexOf(end);
534
+ if (to < from) return;
535
+ try {
536
+ const value: unknown = JSON.parse(body.slice(from, to));
537
+ if (
538
+ !isRecord(value) ||
539
+ value.slug !== slug ||
540
+ typeof value.title !== "string" ||
541
+ !value.title.trim() ||
542
+ value.title.length > 160 ||
543
+ typeof value.description !== "string" ||
544
+ value.description.length > 5000 ||
545
+ !["Feature Request", "Bug Report", "General Feedback"].includes(
546
+ String(value.type),
547
+ )
548
+ )
549
+ return;
550
+ return {
551
+ title: value.title,
552
+ description: value.description,
553
+ type: value.type as PostType,
554
+ ...(typeof value.teamResponse === "string" &&
555
+ value.teamResponse.length <= 5000
556
+ ? { teamResponse: value.teamResponse }
557
+ : {}),
558
+ ...(safeLink(value.releaseNotesUrl)
559
+ ? { releaseNotesUrl: String(value.releaseNotesUrl) }
560
+ : {}),
561
+ };
562
+ } catch {
563
+ return;
564
+ }
565
+ }
566
+
567
+ function safeLink(value: unknown): boolean {
568
+ if (
569
+ typeof value !== "string" ||
570
+ /[\\\s]/u.test(value) ||
571
+ Array.from(value).some((character) => character.charCodeAt(0) < 32)
572
+ )
573
+ return false;
574
+ if (value.startsWith("/") && !value.startsWith("//")) return true;
575
+ try {
576
+ const url = new URL(value);
577
+ return url.protocol === "https:" && !url.username && !url.password;
578
+ } catch {
579
+ return false;
580
+ }
581
+ }
@@ -0,0 +1,120 @@
1
+ export interface GitHubAccessOptions {
2
+ installationId: string;
3
+ repository: { id: number; owner: string; name: string };
4
+ botUserId: number;
5
+ getToken(): Promise<string>;
6
+ request?: typeof fetch;
7
+ sleep?: (milliseconds: number) => Promise<void>;
8
+ }
9
+
10
+ export class GitHubRateLimitError extends Error {
11
+ readonly status = 429;
12
+ constructor(readonly retryAfterMs: number) {
13
+ super("GitHub is rate limited; durable retry is required.");
14
+ this.name = "GitHubRateLimitError";
15
+ }
16
+ }
17
+
18
+ export function createGitHubClient(options: GitHubAccessOptions) {
19
+ if (
20
+ !options.installationId.trim() ||
21
+ options.installationId.length > 128 ||
22
+ !Number.isSafeInteger(options.repository.id) ||
23
+ options.repository.id <= 0 ||
24
+ !Number.isSafeInteger(options.botUserId) ||
25
+ options.botUserId <= 0 ||
26
+ !/^[A-Za-z0-9-]+$/.test(options.repository.owner) ||
27
+ !/^[A-Za-z0-9_.-]+$/.test(options.repository.name)
28
+ )
29
+ throw new Error(
30
+ "GitHub requires a verified Installation, repository and App bot identity.",
31
+ );
32
+ const path = `/repos/${options.repository.owner}/${options.repository.name}`;
33
+ const request = async (
34
+ suffix: string,
35
+ method = "GET",
36
+ body?: unknown,
37
+ ): Promise<Response> => {
38
+ for (let attempt = 0; attempt < 3; attempt++) {
39
+ const token = await options.getToken();
40
+ if (!token) throw new Error("GitHub installation access is unavailable.");
41
+ const response = await (options.request ?? fetch)(
42
+ `https://api.github.com${path}${suffix}`,
43
+ {
44
+ method,
45
+ redirect: "manual",
46
+ headers: {
47
+ Authorization: `Bearer ${token}`,
48
+ "User-Agent": "Feedbax",
49
+ Accept: "application/vnd.github+json",
50
+ "Content-Type": "application/json",
51
+ "X-GitHub-Api-Version": "2026-03-10",
52
+ },
53
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
54
+ },
55
+ );
56
+ const limited =
57
+ response.status === 429 ||
58
+ (response.status === 403 &&
59
+ (response.headers.has("Retry-After") ||
60
+ response.headers.get("X-RateLimit-Remaining") === "0"));
61
+ const retryable = limited || (method === "GET" && response.status >= 500);
62
+ if (!retryable || attempt === 2) return response;
63
+ const retryAfter = response.headers.get("Retry-After");
64
+ const reset = response.headers.get("X-RateLimit-Reset");
65
+ const delay =
66
+ retryAfter !== null
67
+ ? Number.isFinite(Number(retryAfter))
68
+ ? Number(retryAfter) * 1000
69
+ : Date.parse(retryAfter) - Date.now()
70
+ : limited && reset
71
+ ? Number(reset) * 1000 - Date.now()
72
+ : 250 * 2 ** attempt;
73
+ const wait = Number.isFinite(delay) ? Math.max(0, delay) : 1000;
74
+ if (wait > 2000) throw new GitHubRateLimitError(wait);
75
+ await response.body?.cancel();
76
+ await (
77
+ options.sleep ??
78
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
79
+ )(wait);
80
+ }
81
+ throw new Error("GitHub retry budget exhausted.");
82
+ };
83
+ return {
84
+ request,
85
+ async authorize() {
86
+ const response = await request("");
87
+ if (
88
+ !response.ok ||
89
+ ((await response.json()) as { id?: number }).id !==
90
+ options.repository.id
91
+ )
92
+ throw new Error(
93
+ "GitHub repository access is unavailable or no longer matches the Portal Binding.",
94
+ );
95
+ },
96
+ async scan(suffix = "/issues?state=all") {
97
+ const values: Record<string, unknown>[] = [];
98
+ for (let page = 1; page <= 10; page++) {
99
+ const response = await request(
100
+ `${suffix}${suffix.includes("?") ? "&" : "?"}per_page=100&page=${page}`,
101
+ );
102
+ if (!response.ok)
103
+ throw new Error(`GitHub scan failed (${response.status}).`);
104
+ const body: unknown = await response.json();
105
+ if (!Array.isArray(body) || body.some((value) => !isRecord(value)))
106
+ throw new Error("GitHub returned an invalid list.");
107
+ values.push(...body);
108
+ if (!response.headers.get("Link")?.includes('rel="next"'))
109
+ return values;
110
+ }
111
+ throw new Error(
112
+ "GitHub scan exceeded its bounded window; reconciliation is required.",
113
+ );
114
+ },
115
+ };
116
+ }
117
+
118
+ export function isRecord(value: unknown): value is Record<string, unknown> {
119
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
120
+ }