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,390 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { CommentStorage, StoredComment } from "./comment-storage";
3
+ import type { FeedbackStorage } from "./feedback-storage";
4
+ import type { GitHubFeedbackOptions } from "./github-feedback";
5
+ import {
6
+ createGitHubClient,
7
+ GitHubRateLimitError,
8
+ isRecord,
9
+ } from "./github-request";
10
+
11
+ interface CommentMapping {
12
+ comment_id: string;
13
+ post_id: string;
14
+ discussion_id: string;
15
+ remote_id: number | null;
16
+ record: string;
17
+ state: "creating" | "ready" | "uncertain" | "deleted";
18
+ }
19
+ const start = "<!-- feedbax:comment:v1 -->";
20
+ const end = "<!-- /feedbax:comment -->";
21
+
22
+ export function createGitHubCommentStorage(
23
+ options: GitHubFeedbackOptions,
24
+ posts: FeedbackStorage,
25
+ ): CommentStorage {
26
+ const { database, installationId, repository } = options;
27
+ const { request, authorize, scan } = createGitHubClient(options);
28
+ const rowFor = (id: string) =>
29
+ database
30
+ .prepare(
31
+ "SELECT * FROM github_comments WHERE installation_id=? AND repository_id=? AND comment_id=?",
32
+ )
33
+ .bind(installationId, repository.id, id)
34
+ .first<CommentMapping>();
35
+ const postFor = async (id: string, requirePublished = true) => {
36
+ const post = await posts.find(id);
37
+ const row = await database
38
+ .prepare(
39
+ "SELECT issue_number FROM github_posts WHERE installation_id=? AND repository_id=? AND post_id=? AND state='ready'",
40
+ )
41
+ .bind(installationId, repository.id, id)
42
+ .first<{ issue_number: number }>();
43
+ if (!post || (requirePublished && !post.published) || !row)
44
+ throw new Error("Post is unavailable.");
45
+ return row;
46
+ };
47
+ const project = (
48
+ row: CommentMapping,
49
+ value: Record<string, unknown>,
50
+ ): StoredComment | undefined => {
51
+ if (
52
+ !isRecord(value.user) ||
53
+ value.user.id !== options.botUserId ||
54
+ typeof value.body !== "string" ||
55
+ (row.remote_id !== null && value.id !== row.remote_id) ||
56
+ !Number.isSafeInteger(value.id)
57
+ )
58
+ return;
59
+ const body = value.body;
60
+ if (
61
+ body.split(start).length !== 2 ||
62
+ body.split(end).length !== 2 ||
63
+ body.indexOf(end) < body.indexOf(start)
64
+ )
65
+ return;
66
+ try {
67
+ const content: unknown = JSON.parse(
68
+ body.slice(body.indexOf(start) + start.length, body.indexOf(end)),
69
+ );
70
+ const createdAt = new Date(String(value.created_at));
71
+ const original = JSON.parse(row.record) as { displayName: string };
72
+ if (
73
+ !isRecord(content) ||
74
+ content.id !== row.comment_id ||
75
+ content.discussionId !== row.discussion_id ||
76
+ typeof content.body !== "string" ||
77
+ !content.body.trim() ||
78
+ content.body.length > 5000 ||
79
+ content.displayName !== original.displayName ||
80
+ !Number.isFinite(createdAt.getTime())
81
+ )
82
+ return;
83
+ return {
84
+ id: row.comment_id,
85
+ postId: row.post_id,
86
+ discussionId: row.discussion_id,
87
+ body: content.body,
88
+ author: { kind: "participant", displayName: original.displayName },
89
+ createdAt,
90
+ resolved: false,
91
+ scope: "page",
92
+ };
93
+ } catch {
94
+ return;
95
+ }
96
+ };
97
+ const encode = (row: CommentMapping, body: string) =>
98
+ `${start}\n${JSON.stringify({
99
+ id: row.comment_id,
100
+ discussionId: row.discussion_id,
101
+ body,
102
+ displayName: (JSON.parse(row.record) as { displayName: string })
103
+ .displayName,
104
+ }).replaceAll("<", "\\u003c")}\n${end}`;
105
+ const complete = async (
106
+ row: CommentMapping,
107
+ value: Record<string, unknown>,
108
+ ) => {
109
+ const comment = project(row, value);
110
+ if (!comment) throw new Error("GitHub returned an invalid portal Comment.");
111
+ const result = await database
112
+ .prepare(
113
+ "UPDATE github_comments SET remote_id=?,state='ready' WHERE installation_id=? AND repository_id=? AND comment_id=? AND state IN('creating','uncertain')",
114
+ )
115
+ .bind(value.id, installationId, repository.id, row.comment_id)
116
+ .run();
117
+ if (result.meta.changes === 0) {
118
+ const current = await rowFor(row.comment_id);
119
+ if (current?.state !== "ready" || current.remote_id !== value.id)
120
+ throw new Error("Comment is unavailable.");
121
+ }
122
+ return comment;
123
+ };
124
+ const list = async (postId: string) => {
125
+ const post = await postFor(postId);
126
+ const values = await scan(`/issues/${post.issue_number}/comments`);
127
+ const rows = await database
128
+ .prepare(
129
+ "SELECT * FROM github_comments WHERE installation_id=? AND repository_id=? AND post_id=? AND state!='deleted'",
130
+ )
131
+ .bind(installationId, repository.id, postId)
132
+ .all<CommentMapping>();
133
+ const result: StoredComment[] = [];
134
+ const remoteOrder = new Map<string, number>();
135
+ for (const row of rows.results) {
136
+ const matching = values.filter((value) => project(row, value));
137
+ if (matching.length !== 1) continue;
138
+ const value = matching[0]!;
139
+ remoteOrder.set(row.comment_id, Number(value.id));
140
+ result.push(
141
+ row.state === "ready"
142
+ ? project(row, value)!
143
+ : await complete(row, value),
144
+ );
145
+ }
146
+ result.sort(
147
+ (a, b) =>
148
+ a.createdAt.getTime() - b.createdAt.getTime() ||
149
+ remoteOrder.get(a.id)! - remoteOrder.get(b.id)!,
150
+ );
151
+ return result;
152
+ };
153
+ const create = async (input: {
154
+ submissionKey?: string;
155
+ recoveryCommentId?: string;
156
+ postId: string;
157
+ discussionId?: string;
158
+ body: string;
159
+ displayName: string;
160
+ }) => {
161
+ const post = await postFor(input.postId);
162
+ if (
163
+ input.submissionKey !== undefined &&
164
+ !/^[A-Za-z0-9_-]{43}$/.test(input.submissionKey)
165
+ )
166
+ throw new Error("Comment intent is invalid.");
167
+ const digest = input.submissionKey
168
+ ? createHash("sha256")
169
+ .update(
170
+ JSON.stringify([
171
+ "feedbax-comment-intent-v1",
172
+ installationId,
173
+ repository.id,
174
+ input.postId,
175
+ input.discussionId ?? null,
176
+ input.submissionKey,
177
+ ]),
178
+ )
179
+ .digest("hex")
180
+ : undefined;
181
+ const id = digest
182
+ ? `${digest.slice(0, 8)}-${digest.slice(8, 12)}-8${digest.slice(13, 16)}-8${digest.slice(17, 20)}-${digest.slice(20, 32)}`
183
+ : randomUUID();
184
+ if (input.recoveryCommentId && input.recoveryCommentId !== id)
185
+ throw new Error("Images belong to another Comment intent.");
186
+ let row: CommentMapping = {
187
+ comment_id: id,
188
+ post_id: input.postId,
189
+ discussion_id: input.discussionId ?? id,
190
+ remote_id: null,
191
+ record: JSON.stringify({
192
+ displayName: input.displayName,
193
+ body: input.body,
194
+ }),
195
+ state: "creating",
196
+ };
197
+ const claimed = await database
198
+ .prepare(
199
+ "INSERT INTO github_comments(installation_id,repository_id,comment_id,post_id,discussion_id,record,state) VALUES(?,?,?,?,?,?,'creating') ON CONFLICT DO NOTHING RETURNING comment_id",
200
+ )
201
+ .bind(
202
+ installationId,
203
+ repository.id,
204
+ id,
205
+ row.post_id,
206
+ row.discussion_id,
207
+ row.record,
208
+ )
209
+ .first();
210
+ if (!claimed) {
211
+ const original = await rowFor(id);
212
+ if (!original || original.state === "deleted")
213
+ throw new Error("Comment is unavailable.");
214
+ if (original.state === "ready") {
215
+ const response = await request(
216
+ `/issues/comments/${original.remote_id}`,
217
+ );
218
+ if (!response.ok) throw new Error("Comment is unavailable.");
219
+ const comment = project(
220
+ original,
221
+ (await response.json()) as Record<string, unknown>,
222
+ );
223
+ if (!comment) throw new Error("Comment is unavailable.");
224
+ return { ...comment, recoverable: true };
225
+ }
226
+ const retryAt = (JSON.parse(original.record) as { retryAt?: number })
227
+ .retryAt;
228
+ if (retryAt === undefined) {
229
+ const candidates = (
230
+ await scan(`/issues/${post.issue_number}/comments`)
231
+ ).filter((value) => project(original, value));
232
+ if (candidates.length !== 1)
233
+ throw new Error(
234
+ "Comment creation is pending reconciliation; do not start another intent.",
235
+ );
236
+ return {
237
+ ...(await complete(original, candidates[0]!)),
238
+ recoverable: true,
239
+ };
240
+ }
241
+ if (retryAt > Date.now())
242
+ throw new GitHubRateLimitError(retryAt - Date.now());
243
+ const resumed = await database
244
+ .prepare(
245
+ "UPDATE github_comments SET state='creating',record=json_remove(record,'$.retryAt') WHERE installation_id=? AND repository_id=? AND comment_id=? AND state='uncertain' AND json_extract(record,'$.retryAt') <= ? RETURNING *",
246
+ )
247
+ .bind(installationId, repository.id, id, Date.now())
248
+ .first<CommentMapping>();
249
+ if (!resumed) throw new Error("Comment creation is already in progress.");
250
+ row = resumed;
251
+ }
252
+ try {
253
+ const response = await request(
254
+ `/issues/${post.issue_number}/comments`,
255
+ "POST",
256
+ {
257
+ body: encode(row, (JSON.parse(row.record) as { body: string }).body),
258
+ },
259
+ );
260
+ if (
261
+ response.status === 429 ||
262
+ (response.status === 403 &&
263
+ (response.headers.has("Retry-After") ||
264
+ response.headers.get("X-RateLimit-Remaining") === "0"))
265
+ ) {
266
+ const after = response.headers.get("Retry-After");
267
+ const reset = response.headers.get("X-RateLimit-Reset");
268
+ const delay =
269
+ after !== null
270
+ ? Number.isFinite(Number(after))
271
+ ? Number(after) * 1000
272
+ : Date.parse(after) - Date.now()
273
+ : reset
274
+ ? Number(reset) * 1000 - Date.now()
275
+ : 1000;
276
+ throw new GitHubRateLimitError(
277
+ Number.isFinite(delay) ? Math.max(0, delay) : 1000,
278
+ );
279
+ }
280
+ if (!response.ok)
281
+ throw new Error(`GitHub Comment creation failed (${response.status}).`);
282
+ return {
283
+ ...(await complete(
284
+ row,
285
+ (await response.json()) as Record<string, unknown>,
286
+ )),
287
+ recoverable: Boolean(input.submissionKey),
288
+ };
289
+ } catch (error) {
290
+ await database
291
+ .prepare(
292
+ "UPDATE github_comments SET state='uncertain',record=CASE WHEN ? IS NULL THEN record ELSE json_set(record,'$.retryAt',?) END WHERE installation_id=? AND repository_id=? AND comment_id=? AND state='creating'",
293
+ )
294
+ .bind(
295
+ error instanceof GitHubRateLimitError
296
+ ? Date.now() + error.retryAfterMs
297
+ : null,
298
+ error instanceof GitHubRateLimitError
299
+ ? Date.now() + error.retryAfterMs
300
+ : null,
301
+ installationId,
302
+ repository.id,
303
+ id,
304
+ )
305
+ .run();
306
+ throw error;
307
+ }
308
+ };
309
+ return {
310
+ supportsIntentRecovery: true,
311
+ createPageComment: create,
312
+ createDiscussionReply: create,
313
+ async listPageComments(postId, cursor) {
314
+ const comments = await list(postId);
315
+ let offset = 0;
316
+ if (cursor) {
317
+ try {
318
+ const parsed = JSON.parse(
319
+ Buffer.from(cursor, "base64url").toString("utf8"),
320
+ );
321
+ if (
322
+ parsed.post !== postId ||
323
+ !Number.isSafeInteger(parsed.offset) ||
324
+ parsed.offset < 0
325
+ )
326
+ throw new Error();
327
+ offset = parsed.offset;
328
+ } catch {
329
+ throw new Error("Invalid GitHub Comment cursor.");
330
+ }
331
+ }
332
+ return {
333
+ items: comments.slice(offset, offset + 50),
334
+ ...(offset + 50 < comments.length
335
+ ? {
336
+ nextCursor: Buffer.from(
337
+ JSON.stringify({ post: postId, offset: offset + 50 }),
338
+ ).toString("base64url"),
339
+ }
340
+ : {}),
341
+ };
342
+ },
343
+ async updateComment(id, body) {
344
+ const row = await rowFor(id);
345
+ if (!row || row.state !== "ready")
346
+ throw new Error("Comment is unavailable.");
347
+ await postFor(row.post_id);
348
+ const response = await request(`/issues/comments/${row.remote_id}`);
349
+ if (!response.ok) throw new Error("Comment is unavailable.");
350
+ const value = (await response.json()) as Record<string, unknown>;
351
+ if (!project(row, value)) throw new Error("Comment is unavailable.");
352
+ const previous = String(value.body);
353
+ const replacement =
354
+ previous.slice(0, previous.indexOf(start)) +
355
+ encode(row, body) +
356
+ previous.slice(previous.indexOf(end) + end.length);
357
+ const updated = await request(
358
+ `/issues/comments/${row.remote_id}`,
359
+ "PATCH",
360
+ { body: replacement },
361
+ );
362
+ if (!updated.ok)
363
+ throw new Error(`GitHub Comment edit failed (${updated.status}).`);
364
+ const projected = project(
365
+ row,
366
+ (await updated.json()) as Record<string, unknown>,
367
+ );
368
+ if (!projected)
369
+ throw new Error("GitHub returned an invalid portal Comment.");
370
+ return projected;
371
+ },
372
+ async deleteComment(id) {
373
+ const row = await rowFor(id);
374
+ if (!row || !row.remote_id) throw new Error("Comment is unavailable.");
375
+ await authorize();
376
+ await database
377
+ .prepare(
378
+ "UPDATE github_comments SET state='deleted' WHERE installation_id=? AND repository_id=? AND comment_id=?",
379
+ )
380
+ .bind(installationId, repository.id, id)
381
+ .run();
382
+ const response = await request(
383
+ `/issues/comments/${row.remote_id}`,
384
+ "DELETE",
385
+ );
386
+ if (!response.ok && response.status !== 404)
387
+ throw new Error(`GitHub Comment cleanup failed (${response.status}).`);
388
+ },
389
+ };
390
+ }