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.
- package/README.md +17 -5
- package/dist/cli.js +502 -209
- package/dist/index.js +251 -68
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +1 -1
- package/template/CONNECTED.md +16 -2
- package/template/README.md +52 -13
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +34 -33
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +3 -0
- package/template/src/comment-creation.test.tsx +53 -6
- package/template/src/components/connected-identity-provider.tsx +44 -17
- package/template/src/components/follow-post-button.tsx +2 -13
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +7 -0
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +7 -3
- package/template/src/connected-identity.ts +6 -1
- package/template/src/connected-runtime.test.ts +5 -2
- package/template/src/feedback-cache-invalidation.ts +2 -0
- package/template/src/feedback-runtime.ts +61 -18
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +45 -1
- package/template/src/feedbax/config/index.ts +112 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +41 -12
- package/template/src/feedbax/feedback/connected.ts +15 -5
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/image-feedback.ts +23 -10
- package/template/src/feedbax/feedback/images.ts +26 -0
- package/template/src/feedbax/feedback/index.ts +172 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/index.css +6 -0
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/participation-runtime.ts +3 -3
- package/template/src/portal-comments.ts +11 -0
- package/template/src/portal-feedback-form.tsx +11 -3
- package/template/src/portal-feedback-submission.ts +9 -0
- package/template/src/portal-server-entry.ts +9 -0
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail.tsx +332 -256
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/routeTree.gen.ts +21 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/server.ts +46 -1
- package/template/src/submission-intent.ts +26 -0
- 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
|
+
}
|