Skip to content

Commit 3f731e6

Browse files
Replace personal GitHub token with GitHub App for feedback submission (#3545)
* Replace personal GitHub token with GitHub App for feedback submission - Add @octokit/auth-app and @octokit/rest dependencies - Replace YUJONGLEE_GITHUB_TOKEN_REPO with GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID - Use Octokit with GitHub App authentication for creating issues and comments - Remove issueType parameter (only use product/desktop label) Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Route bug reports to GitHub Issues with type Bug, feature requests to GitHub Discussions - Add @octokit/graphql dependency for GitHub Discussions API - Add GITHUB_REPO_ID and GITHUB_DISCUSSION_CATEGORY_ID env vars - Bug reports: create GitHub Issue with type 'Bug' and product/desktop label - Feature requests: create GitHub Discussion via GraphQL API - Add getInstallationToken and createGitHubDiscussion functions Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Rename env vars to avoid GITHUB_ prefix restriction - GITHUB_APP_ID -> CHARLIE_APP_ID - GITHUB_APP_PRIVATE_KEY -> CHARLIE_APP_PRIVATE_KEY - GITHUB_APP_INSTALLATION_ID -> CHARLIE_APP_INSTALLATION_ID - GITHUB_REPO_ID -> CHAR_REPO_ID - GITHUB_DISCUSSION_CATEGORY_ID -> CHAR_DISCUSSION_CATEGORY_ID Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Remove issueType parameter from createGitHubIssue function GitHub Issues API does not support a type field, so removing it. Co-Authored-By: john@hyprnote.com <john@hyprnote.com> * Add back type field to createGitHubIssue function GitHub REST API supports type field for issues as of March 2025. See: https://github.blog/changelog/2025-03-18-github-issues-projects-rest-api-support-for-issue-types Co-Authored-By: john@hyprnote.com <john@hyprnote.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: john@hyprnote.com <john@hyprnote.com>
1 parent 18784c6 commit 3f731e6

File tree

4 files changed

+282
-69
lines changed

4 files changed

+282
-69
lines changed

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"@hono/zod-validator": "^0.7.6",
1414
"@hypr/api-client": "workspace:*",
1515
"@hypr/supabase": "workspace:*",
16+
"@octokit/auth-app": "^8.1.2",
17+
"@octokit/graphql": "^9.0.3",
18+
"@octokit/rest": "^22.0.1",
1619
"@posthog/ai": "^7.8.2",
1720
"@restatedev/restate-sdk-clients": "^1.10.2",
1821
"@scalar/hono-api-reference": "^0.5.184",

apps/api/src/env.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ export const env = createEnv({
3131
SLACK_SIGNING_SECRET: z.string().optional(),
3232
LOOPS_API_KEY: z.string().optional(),
3333
LOOPS_SLACK_CHANNEL_ID: z.string().optional(),
34-
YUJONGLEE_GITHUB_TOKEN_REPO: z.string().optional(),
34+
CHARLIE_APP_ID: z.string().optional(),
35+
CHARLIE_APP_PRIVATE_KEY: z.string().optional(),
36+
CHARLIE_APP_INSTALLATION_ID: z.string().optional(),
37+
CHAR_REPO_ID: z.string().optional(),
38+
CHAR_DISCUSSION_CATEGORY_ID: z.string().optional(),
3539
},
3640
runtimeEnv: Bun.env,
3741
emptyStringAsUndefined: true,

apps/api/src/routes/feedback.ts

Lines changed: 171 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { createAppAuth } from "@octokit/auth-app";
2+
import { graphql } from "@octokit/graphql";
3+
import { Octokit } from "@octokit/rest";
14
import { Hono } from "hono";
25
import { describeRoute } from "hono-openapi";
36
import { resolver, validator } from "hono-openapi/zod";
@@ -62,70 +65,149 @@ async function analyzeLogsWithAI(logs: string): Promise<string | null> {
6265
}
6366
}
6467

68+
function getGitHubClient(): Octokit | null {
69+
if (
70+
!env.CHARLIE_APP_ID ||
71+
!env.CHARLIE_APP_PRIVATE_KEY ||
72+
!env.CHARLIE_APP_INSTALLATION_ID
73+
) {
74+
return null;
75+
}
76+
77+
return new Octokit({
78+
authStrategy: createAppAuth,
79+
auth: {
80+
appId: env.CHARLIE_APP_ID,
81+
privateKey: env.CHARLIE_APP_PRIVATE_KEY.replace(/\\n/g, "\n"),
82+
installationId: env.CHARLIE_APP_INSTALLATION_ID,
83+
},
84+
});
85+
}
86+
6587
async function createGitHubIssue(
6688
title: string,
6789
body: string,
6890
labels: string[],
6991
issueType: string,
7092
): Promise<{ url: string; number: number } | { error: string }> {
71-
if (!env.YUJONGLEE_GITHUB_TOKEN_REPO) {
72-
return { error: "GitHub bot token not configured" };
93+
const octokit = getGitHubClient();
94+
if (!octokit) {
95+
return { error: "GitHub App credentials not configured" };
7396
}
7497

75-
const response = await fetch(
76-
"https://api.github.com/repos/fastrepl/hyprnote/issues",
77-
{
78-
method: "POST",
79-
headers: {
80-
Authorization: `Bearer ${env.YUJONGLEE_GITHUB_TOKEN_REPO}`,
81-
Accept: "application/vnd.github.v3+json",
82-
"Content-Type": "application/json",
83-
},
84-
body: JSON.stringify({
85-
title,
86-
body,
87-
labels,
88-
type: issueType,
89-
}),
90-
},
91-
);
92-
93-
if (!response.ok) {
94-
const errorText = await response.text();
95-
return { error: `GitHub API error: ${response.status} - ${errorText}` };
96-
}
98+
try {
99+
const response = await octokit.issues.create({
100+
owner: "fastrepl",
101+
repo: "hyprnote",
102+
title,
103+
body,
104+
labels,
105+
type: issueType,
106+
});
97107

98-
const data = (await response.json()) as {
99-
html_url?: string;
100-
number?: number;
101-
};
102-
if (!data.html_url || !data.number) {
103-
return { error: "GitHub API did not return issue URL" };
108+
return {
109+
url: response.data.html_url,
110+
number: response.data.number,
111+
};
112+
} catch (error) {
113+
const errorMessage =
114+
error instanceof Error ? error.message : "Unknown error";
115+
return { error: `GitHub API error: ${errorMessage}` };
104116
}
105-
106-
return { url: data.html_url, number: data.number };
107117
}
108118

109119
async function addCommentToIssue(
110120
issueNumber: number,
111121
comment: string,
112122
): Promise<void> {
113-
if (!env.YUJONGLEE_GITHUB_TOKEN_REPO) {
123+
const octokit = getGitHubClient();
124+
if (!octokit) {
114125
return;
115126
}
116127

117-
await fetch(
118-
`https://api.github.com/repos/fastrepl/hyprnote/issues/${issueNumber}/comments`,
119-
{
120-
method: "POST",
128+
try {
129+
await octokit.issues.createComment({
130+
owner: "fastrepl",
131+
repo: "hyprnote",
132+
issue_number: issueNumber,
133+
body: comment,
134+
});
135+
} catch {
136+
// Silently fail for comment creation
137+
}
138+
}
139+
140+
async function getInstallationToken(): Promise<string | null> {
141+
if (
142+
!env.CHARLIE_APP_ID ||
143+
!env.CHARLIE_APP_PRIVATE_KEY ||
144+
!env.CHARLIE_APP_INSTALLATION_ID
145+
) {
146+
return null;
147+
}
148+
149+
const auth = createAppAuth({
150+
appId: env.CHARLIE_APP_ID,
151+
privateKey: env.CHARLIE_APP_PRIVATE_KEY.replace(/\\n/g, "\n"),
152+
installationId: env.CHARLIE_APP_INSTALLATION_ID,
153+
});
154+
155+
const { token } = await auth({ type: "installation" });
156+
return token;
157+
}
158+
159+
async function createGitHubDiscussion(
160+
title: string,
161+
body: string,
162+
categoryId: string,
163+
): Promise<{ url: string } | { error: string }> {
164+
const token = await getInstallationToken();
165+
if (!token) {
166+
return { error: "GitHub App credentials not configured" };
167+
}
168+
169+
try {
170+
const graphqlWithAuth = graphql.defaults({
121171
headers: {
122-
Authorization: `Bearer ${env.YUJONGLEE_GITHUB_TOKEN_REPO}`,
123-
Accept: "application/vnd.github.v3+json",
124-
"Content-Type": "application/json",
172+
authorization: `token ${token}`,
125173
},
126-
body: JSON.stringify({ body: comment }),
127-
},
128-
);
174+
});
175+
176+
const result = await graphqlWithAuth<{
177+
createDiscussion: {
178+
discussion: {
179+
url: string;
180+
};
181+
};
182+
}>(
183+
`
184+
mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
185+
createDiscussion(input: {
186+
repositoryId: $repositoryId
187+
categoryId: $categoryId
188+
title: $title
189+
body: $body
190+
}) {
191+
discussion {
192+
url
193+
}
194+
}
195+
}
196+
`,
197+
{
198+
repositoryId: env.CHAR_REPO_ID,
199+
categoryId,
200+
title,
201+
body,
202+
},
203+
);
204+
205+
return { url: result.createDiscussion.discussion.url };
206+
} catch (error) {
207+
const errorMessage =
208+
error instanceof Error ? error.message : "Unknown error";
209+
return { error: `GitHub API error: ${errorMessage}` };
210+
}
129211
}
130212

131213
export const feedback = new Hono<AppBindings>();
@@ -178,39 +260,27 @@ feedback.post(
178260
`**Git Hash:** ${deviceInfo.gitHash}`,
179261
].join("\n");
180262

181-
const body =
182-
type === "bug"
183-
? `## Description
263+
if (type === "bug") {
264+
const body = `## Description
184265
${trimmedDescription}
185266
186267
## Device Information
187268
${deviceInfoSection}
188269
189270
---
190271
*This issue was submitted from the Hyprnote desktop app.*
191-
`
192-
: `## Feature Request
193-
${trimmedDescription}
194-
195-
## Submitted From
196-
${deviceInfoSection}
197-
198-
---
199-
*This feature request was submitted from the Hyprnote desktop app.*
200272
`;
201273

202-
const labels = ["product/desktop"];
203-
const issueType = type === "bug" ? "Bug" : "Feature";
274+
const labels = ["product/desktop"];
275+
const result = await createGitHubIssue(title, body, labels, "Bug");
204276

205-
const result = await createGitHubIssue(title, body, labels, issueType);
206-
207-
if ("error" in result) {
208-
return c.json({ success: false, error: result.error }, 500);
209-
}
277+
if ("error" in result) {
278+
return c.json({ success: false, error: result.error }, 500);
279+
}
210280

211-
if (logs) {
212-
const logSummary = await analyzeLogsWithAI(logs);
213-
const logComment = `## Log Analysis
281+
if (logs) {
282+
const logSummary = await analyzeLogsWithAI(logs);
283+
const logComment = `## Log Analysis
214284
215285
${logSummary?.trim() ? `### Summary\n\`\`\`\n${logSummary}\n\`\`\`` : "_No errors or warnings found._"}
216286
@@ -223,9 +293,42 @@ ${logs.slice(-10000)}
223293
224294
</details>`;
225295

226-
await addCommentToIssue(result.number, logComment);
227-
}
296+
await addCommentToIssue(result.number, logComment);
297+
}
298+
299+
return c.json({ success: true, issueUrl: result.url }, 200);
300+
} else {
301+
const body = `## Feature Request
302+
${trimmedDescription}
303+
304+
## Submitted From
305+
${deviceInfoSection}
306+
307+
---
308+
*This feature request was submitted from the Hyprnote desktop app.*
309+
`;
228310

229-
return c.json({ success: true, issueUrl: result.url }, 200);
311+
if (!env.CHAR_DISCUSSION_CATEGORY_ID) {
312+
return c.json(
313+
{
314+
success: false,
315+
error: "GitHub discussion category not configured",
316+
},
317+
500,
318+
);
319+
}
320+
321+
const result = await createGitHubDiscussion(
322+
title,
323+
body,
324+
env.CHAR_DISCUSSION_CATEGORY_ID,
325+
);
326+
327+
if ("error" in result) {
328+
return c.json({ success: false, error: result.error }, 500);
329+
}
330+
331+
return c.json({ success: true, issueUrl: result.url }, 200);
332+
}
230333
},
231334
);

0 commit comments

Comments
 (0)