Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"@hono/zod-validator": "^0.7.6",
"@hypr/api-client": "workspace:*",
"@hypr/supabase": "workspace:*",
"@octokit/auth-app": "^8.1.2",
"@octokit/graphql": "^9.0.3",
"@octokit/rest": "^22.0.1",
"@posthog/ai": "^7.8.2",
"@restatedev/restate-sdk-clients": "^1.10.2",
"@scalar/hono-api-reference": "^0.5.184",
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export const env = createEnv({
SLACK_SIGNING_SECRET: z.string().optional(),
LOOPS_API_KEY: z.string().optional(),
LOOPS_SLACK_CHANNEL_ID: z.string().optional(),
YUJONGLEE_GITHUB_TOKEN_REPO: z.string().optional(),
CHARLIE_APP_ID: z.string().optional(),
CHARLIE_APP_PRIVATE_KEY: z.string().optional(),
CHARLIE_APP_INSTALLATION_ID: z.string().optional(),
CHAR_REPO_ID: z.string().optional(),
CHAR_DISCUSSION_CATEGORY_ID: z.string().optional(),
},
runtimeEnv: Bun.env,
emptyStringAsUndefined: true,
Expand Down
239 changes: 171 additions & 68 deletions apps/api/src/routes/feedback.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createAppAuth } from "@octokit/auth-app";
import { graphql } from "@octokit/graphql";
import { Octokit } from "@octokit/rest";
import { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
Expand Down Expand Up @@ -62,70 +65,149 @@ async function analyzeLogsWithAI(logs: string): Promise<string | null> {
}
}

function getGitHubClient(): Octokit | null {
if (
!env.CHARLIE_APP_ID ||
!env.CHARLIE_APP_PRIVATE_KEY ||
!env.CHARLIE_APP_INSTALLATION_ID
) {
return null;
}

return new Octokit({
authStrategy: createAppAuth,
auth: {
appId: env.CHARLIE_APP_ID,
privateKey: env.CHARLIE_APP_PRIVATE_KEY.replace(/\\n/g, "\n"),
installationId: env.CHARLIE_APP_INSTALLATION_ID,
},
});
}

async function createGitHubIssue(
title: string,
body: string,
labels: string[],
issueType: string,
): Promise<{ url: string; number: number } | { error: string }> {
if (!env.YUJONGLEE_GITHUB_TOKEN_REPO) {
return { error: "GitHub bot token not configured" };
const octokit = getGitHubClient();
if (!octokit) {
return { error: "GitHub App credentials not configured" };
}

const response = await fetch(
"https://api.github.com/repos/fastrepl/hyprnote/issues",
{
method: "POST",
headers: {
Authorization: `Bearer ${env.YUJONGLEE_GITHUB_TOKEN_REPO}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
body,
labels,
type: issueType,
}),
},
);

if (!response.ok) {
const errorText = await response.text();
return { error: `GitHub API error: ${response.status} - ${errorText}` };
}
try {
const response = await octokit.issues.create({
owner: "fastrepl",
repo: "hyprnote",
title,
body,
labels,
type: issueType,
});

const data = (await response.json()) as {
html_url?: string;
number?: number;
};
if (!data.html_url || !data.number) {
return { error: "GitHub API did not return issue URL" };
return {
url: response.data.html_url,
number: response.data.number,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { error: `GitHub API error: ${errorMessage}` };
}

return { url: data.html_url, number: data.number };
}

async function addCommentToIssue(
issueNumber: number,
comment: string,
): Promise<void> {
if (!env.YUJONGLEE_GITHUB_TOKEN_REPO) {
const octokit = getGitHubClient();
if (!octokit) {
return;
}

await fetch(
`https://api.github.com/repos/fastrepl/hyprnote/issues/${issueNumber}/comments`,
{
method: "POST",
try {
await octokit.issues.createComment({
owner: "fastrepl",
repo: "hyprnote",
issue_number: issueNumber,
body: comment,
});
} catch {
// Silently fail for comment creation
}
}

async function getInstallationToken(): Promise<string | null> {
if (
!env.CHARLIE_APP_ID ||
!env.CHARLIE_APP_PRIVATE_KEY ||
!env.CHARLIE_APP_INSTALLATION_ID
) {
return null;
}

const auth = createAppAuth({
appId: env.CHARLIE_APP_ID,
privateKey: env.CHARLIE_APP_PRIVATE_KEY.replace(/\\n/g, "\n"),
installationId: env.CHARLIE_APP_INSTALLATION_ID,
});

const { token } = await auth({ type: "installation" });
return token;
}

async function createGitHubDiscussion(
title: string,
body: string,
categoryId: string,
): Promise<{ url: string } | { error: string }> {
const token = await getInstallationToken();
if (!token) {
return { error: "GitHub App credentials not configured" };
}

try {
const graphqlWithAuth = graphql.defaults({
headers: {
Authorization: `Bearer ${env.YUJONGLEE_GITHUB_TOKEN_REPO}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
authorization: `token ${token}`,
},
body: JSON.stringify({ body: comment }),
},
);
});

const result = await graphqlWithAuth<{
createDiscussion: {
discussion: {
url: string;
};
};
}>(
`
mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
createDiscussion(input: {
repositoryId: $repositoryId
categoryId: $categoryId
title: $title
body: $body
}) {
discussion {
url
}
}
}
`,
{
repositoryId: env.CHAR_REPO_ID,
categoryId,
title,
body,
},
);

return { url: result.createDiscussion.discussion.url };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return { error: `GitHub API error: ${errorMessage}` };
}
}

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

const body =
type === "bug"
? `## Description
if (type === "bug") {
const body = `## Description
${trimmedDescription}

## Device Information
${deviceInfoSection}

---
*This issue was submitted from the Hyprnote desktop app.*
`
: `## Feature Request
${trimmedDescription}

## Submitted From
${deviceInfoSection}

---
*This feature request was submitted from the Hyprnote desktop app.*
`;

const labels = ["product/desktop"];
const issueType = type === "bug" ? "Bug" : "Feature";
const labels = ["product/desktop"];
const result = await createGitHubIssue(title, body, labels, "Bug");

const result = await createGitHubIssue(title, body, labels, issueType);

if ("error" in result) {
return c.json({ success: false, error: result.error }, 500);
}
if ("error" in result) {
return c.json({ success: false, error: result.error }, 500);
}

if (logs) {
const logSummary = await analyzeLogsWithAI(logs);
const logComment = `## Log Analysis
if (logs) {
const logSummary = await analyzeLogsWithAI(logs);
const logComment = `## Log Analysis

${logSummary?.trim() ? `### Summary\n\`\`\`\n${logSummary}\n\`\`\`` : "_No errors or warnings found._"}

Expand All @@ -223,9 +293,42 @@ ${logs.slice(-10000)}

</details>`;

await addCommentToIssue(result.number, logComment);
}
await addCommentToIssue(result.number, logComment);
}

return c.json({ success: true, issueUrl: result.url }, 200);
} else {
const body = `## Feature Request
${trimmedDescription}

## Submitted From
${deviceInfoSection}

---
*This feature request was submitted from the Hyprnote desktop app.*
`;

return c.json({ success: true, issueUrl: result.url }, 200);
if (!env.CHAR_DISCUSSION_CATEGORY_ID) {
return c.json(
{
success: false,
error: "GitHub discussion category not configured",
},
500,
);
}

const result = await createGitHubDiscussion(
title,
body,
env.CHAR_DISCUSSION_CATEGORY_ID,
);

if ("error" in result) {
return c.json({ success: false, error: result.error }, 500);
}

return c.json({ success: true, issueUrl: result.url }, 200);
}
},
);
Loading