From fcc621b9537686e220b980bbd1477f8ef9307ee8 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 7 Apr 2025 01:08:52 -0600 Subject: [PATCH 01/18] initial port --- apps/roam/src/components/Export.tsx | 147 ++- apps/roam/src/components/ExportGithub.tsx | 217 ++-- apps/roam/src/components/GitHubSync.tsx | 1038 +++++++++++++++++ .../components/GitHubSyncCommentsQuery.tsx | 109 ++ .../components/settings/GeneralSettings.tsx | 16 +- .../roam/src/components/settings/Settings.tsx | 2 +- apps/roam/src/index.ts | 2 + apps/roam/src/utils/discourseConfigRef.ts | 7 + apps/roam/src/utils/handleTitleAdditions.ts | 38 + .../utils/initializeObserversAndListeners.ts | 3 + 10 files changed, 1462 insertions(+), 117 deletions(-) create mode 100644 apps/roam/src/components/GitHubSync.tsx create mode 100644 apps/roam/src/components/GitHubSyncCommentsQuery.tsx create mode 100644 apps/roam/src/utils/handleTitleAdditions.ts diff --git a/apps/roam/src/components/Export.tsx b/apps/roam/src/components/Export.tsx index 6218cef42..8414c3bdd 100644 --- a/apps/roam/src/components/Export.tsx +++ b/apps/roam/src/components/Export.tsx @@ -82,23 +82,26 @@ const ExportProgress = ({ id }: { id: string }) => { ); }; +const EXPORT_DESTINATIONS = [ + { id: "local", label: "Download Locally", active: true }, + { id: "app", label: "Store in Roam", active: false }, + { id: "github", label: "Send to GitHub", active: true }, +]; + export type ExportDialogProps = { results?: Result[]; title?: string; columns?: Column[]; isExportDiscourseGraph?: boolean; initialPanel?: "sendTo" | "export"; + initialExportDestination?: (typeof EXPORT_DESTINATIONS)[number]["id"]; + onClose?: () => void; }; type ExportDialogComponent = ( props: RoamOverlayProps, ) => JSX.Element; -const EXPORT_DESTINATIONS = [ - { id: "local", label: "Download Locally", active: true }, - { id: "app", label: "Store in Roam", active: false }, - { id: "github", label: "Send to GitHub", active: true }, -]; const SEND_TO_DESTINATIONS = ["page", "graph"]; const exportDestinationById = Object.fromEntries( @@ -113,6 +116,7 @@ const ExportDialog: ExportDialogComponent = ({ title = "Share Data", isExportDiscourseGraph = false, initialPanel, + initialExportDestination, }) => { const [selectedRepo, setSelectedRepo] = useState( getSetting("selected-repo", ""), @@ -143,7 +147,11 @@ const ExportDialog: ExportDialogComponent = ({ exportTypes[0].name, ); const [activeExportDestination, setActiveExportDestination] = - useState(EXPORT_DESTINATIONS[0].id); + useState( + initialExportDestination + ? exportDestinationById[initialExportDestination].id + : EXPORT_DESTINATIONS[0].id, + ); const firstColumnKey = columns?.[0]?.key || "text"; const currentPageUid = getCurrentPageUid(); @@ -177,9 +185,15 @@ const ExportDialog: ExportDialogComponent = ({ content: string; setError: (error: string) => void; }): Promise<{ status: number }> => { - const base64Content = btoa(content); + const gitHubAccessToken = localStorageGet("github-oauth"); + const selectedRepo = localStorageGet("github-repo"); + + const encoder = new TextEncoder(); + const uint8Array = encoder.encode(content); + const base64Content = btoa(String.fromCharCode(...uint8Array)); try { + // https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents const response = await apiPut({ domain: "https://api.github.com", path: `repos/${selectedRepo}/contents/${filename}`, @@ -192,7 +206,6 @@ const ExportDialog: ExportDialogComponent = ({ }, }); if (response.status === 401) { - setGitHubAccessToken(null); setError("Authentication failed. Please log in again."); setSetting("oauth-github", ""); return { status: 401 }; @@ -209,6 +222,74 @@ const ExportDialog: ExportDialogComponent = ({ } }; + const writeFileToIssue = async ({ + title, + body, + setError, + pageUid, + }: { + title: string; + body: string; + setError: (error: string) => void; + pageUid: string; + }): Promise<{ status: number }> => { + const gitHubAccessToken = localStorageGet("github-oauth"); + const selectedRepo = localStorageGet("github-repo"); + try { + // https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue + const response = await apiPost({ + domain: "https://api.github.com", + path: `repos/${selectedRepo}/issues`, + headers: { + Authorization: `token ${gitHubAccessToken}`, + }, + data: { + title, + body, + // milestone, + // labels, + // assignees + }, + }); + if (response.status === 401) { + setError("Authentication failed. Please log in again."); + localStorageSet("github-oauth", ""); + return { status: 401 }; + } + + if (response.status === 201) { + const props = getBlockProps(pageUid); + const newProps = { + ...props, + ["github-sync"]: { + issue: { + id: response.id, + number: response.number, + html_url: response.html_url, + state: response.state, + labels: response.labels, + createdAt: response.created_at, + updatedAt: response.updated_at, + repo: selectedRepo, + }, + }, + }; + window.roamAlphaAPI.updateBlock({ + block: { + uid: pageUid, + props: newProps, + }, + }); + } + + return { status: response.status }; + } catch (error) { + const e = error as Error; + setError("Failed to create issue"); + return { status: 500 }; + } + }; + const handleSetSelectedPage = (title: string) => { setSelectedPageTitle(title); setSelectedPageUid(getPageUidByPageTitle(title)); @@ -521,15 +602,12 @@ const ExportDialog: ExportDialogComponent = ({ onItemSelect={(et) => setActiveExportDestination(et)} /> - + {activeExportDestination === "github" && ( + + )} @@ -620,17 +698,40 @@ const ExportDialog: ExportDialogComponent = ({ if (activeExportDestination === "github") { const { title, content } = files[0]; + const githubDestination = + localStorageGet("github-destination"); try { - const { status } = await writeFileToRepo({ - filename: title, - content, - setError, - }); + let status; + if (githubDestination === "File") { + status = ( + await writeFileToRepo({ + filename: title, + content, + setError, + }) + ).status; + } + if (githubDestination === "Issue") { + const pageUid = + typeof results === "function" ? "" : results[0].uid; // TODO handle multiple results + if (!pageUid) { + setError("No page UID found."); + return; + } + status = ( + await writeFileToIssue({ + title: title.replace(/\.[^/.]+$/, ""), // remove extension + body: content, + setError, + pageUid, + }) + ).status; + } if (status === 201) { // TODO: remove toast by prolonging ExportProgress renderToast({ id: "export-success", - content: "Upload Success", + content: `Upload Success to ${githubDestination}`, intent: "success", }); onClose(); diff --git a/apps/roam/src/components/ExportGithub.tsx b/apps/roam/src/components/ExportGithub.tsx index b72b0181b..f0ad7f55f 100644 --- a/apps/roam/src/components/ExportGithub.tsx +++ b/apps/roam/src/components/ExportGithub.tsx @@ -1,4 +1,4 @@ -import { Button } from "@blueprintjs/core"; +import { Button, Label } from "@blueprintjs/core"; import nanoid from "nanoid"; import React, { useCallback, @@ -14,7 +14,7 @@ import { getNodeEnv } from "roamjs-components/util/env"; import getExtensionApi from "roamjs-components/util/extensionApiContext"; import { setSetting } from "~/utils/extensionSettings"; -type UserReposResponse = { +export type UserReposResponse = { data: [ { name: string; @@ -23,29 +23,49 @@ type UserReposResponse = { ]; status: number; }; -type UserRepos = UserReposResponse["data"]; -const initialRepos: UserRepos = [{ name: "", full_name: "" }]; +export type UserRepos = UserReposResponse["data"]; +export const initialRepos: UserRepos = [{ name: "", full_name: "" }]; +export type GitHubDestination = (typeof GITHUB_DESTINATIONS)[number]; +const GITHUB_DESTINATIONS = ["Issue", "File"] as const; -const WINDOW_WIDTH = 600; -const WINDOW_HEIGHT = 525; -const WINDOW_LEFT = window.screenX + (window.innerWidth - WINDOW_WIDTH) / 2; -const WINDOW_TOP = window.screenY + (window.innerHeight - WINDOW_HEIGHT) / 2; +export const WINDOW_WIDTH = 600; +export const WINDOW_HEIGHT = 525; +export const WINDOW_LEFT = + window.screenX + (window.innerWidth - WINDOW_WIDTH) / 2; +export const WINDOW_TOP = + window.screenY + (window.innerHeight - WINDOW_HEIGHT) / 2; + +// const isDev = getNodeEnv() === "development"; +const isDev = false; + +export const fetchInstallationStatus = async () => { + try { + // https://docs.github.com/en/rest/apps/installations?apiVersion=2022-11-28#list-app-installations-accessible-to-the-user-access-token + const res = await apiGet<{ installations: { app_id: number }[] }>({ + domain: "https://api.github.com", + path: "user/installations", + headers: { + Authorization: `token ${localStorageGet("github-oauth")}`, + }, + }); + const installations = res.installations; + console.log(installations); + const APP_ID = isDev ? 882491 : 312167; // TODO - pull from process.env.GITHUB_APP_ID + const isAppInstalled = installations.some( + (installation) => installation.app_id === APP_ID, + ); + return isAppInstalled; + } catch (error) { + const e = error as Error; + return false; + } +}; export const ExportGithub = ({ - isVisible, - selectedRepo, - setSelectedRepo, setError, - gitHubAccessToken, - setGitHubAccessToken, setCanSendToGitHub, }: { - isVisible: boolean; - selectedRepo: string; - setSelectedRepo: (selectedRepo: string) => void; setError: (error: string) => void; - gitHubAccessToken: string | null; - setGitHubAccessToken: (gitHubAccessToken: string | null) => void; setCanSendToGitHub: (canSendToGitHub: boolean) => void; }) => { const authWindow = useRef(null); @@ -54,13 +74,31 @@ export const ExportGithub = ({ const [clickedInstall, setClickedInstall] = useState(false); const [repos, setRepos] = useState(initialRepos); const [state, setState] = useState(""); + const [gitHubAccessToken, _setGitHubAccessToken] = useState( + localStorageGet("github-oauth"), + ); + const [githubDestination, _setGithubDestination] = + useState( + (localStorageGet("github-destination") as GitHubDestination) || "File", + ); + const [selectedRepo, _setSelectedRepo] = useState( + localStorageGet("github-repo"), + ); const showGitHubLogin = isGitHubAppInstalled && !gitHubAccessToken; - const repoSelectEnabled = isGitHubAppInstalled && gitHubAccessToken; + const repoAndDestinationSelectEnabled = + isGitHubAppInstalled && gitHubAccessToken; - const isDev = useMemo(() => getNodeEnv() === "development", []); - const setRepo = (repo: string) => { - setSelectedRepo(repo); - setSetting("selected-repo", repo); + const setGitHubAccessToken = (token: string) => { + setSetting("github-oauth", token); + _setGitHubAccessToken(token); + }; + const setGithubDestination = (destination: GitHubDestination) => { + setSetting("github-destination", destination); + _setGithubDestination(destination); + }; + const setSelectedRepo = (repo: string) => { + setSetting("github-repo", repo); + _setSelectedRepo(repo); }; const handleReceivedAccessToken = (token: string) => { @@ -72,30 +110,15 @@ export const ExportGithub = ({ const fetchAndSetInstallation = useCallback(async (token: string) => { try { - const res = await apiGet<{ installations: { app_id: number }[] }>({ - domain: "https://api.github.com", - path: "user/installations", - headers: { - Authorization: `token ${token}`, - }, - }); - - const installations = res.installations; - const APP_ID = isDev ? 882491 : 312167; // TODO - pull from process.env.GITHUB_APP_ID - const isAppInstalled = installations.some( - (installation) => installation.app_id === APP_ID, - ); - + const isAppInstalled = await fetchInstallationStatus(); setIsGitHubAppInstalled(isAppInstalled); - return isAppInstalled; } catch (error) { const e = error as Error; if (e.message === "Bad credentials") { - setGitHubAccessToken(null); + setGitHubAccessToken(""); setSetting("oauth-github", ""); } - return false; } }, []); @@ -115,16 +138,11 @@ export const ExportGithub = ({ } }; - if (isVisible) { - window.addEventListener("message", handleGitHubAuthMessage); - } - + window.addEventListener("message", handleGitHubAuthMessage); return () => { - if (isVisible) { - window.removeEventListener("message", handleGitHubAuthMessage); - } + window.removeEventListener("message", handleGitHubAuthMessage); }; - }, [isVisible]); + }, []); // check for installation useEffect(() => { @@ -136,6 +154,7 @@ export const ExportGithub = ({ if (!gitHubAccessToken || !isGitHubAppInstalled) return; const fetchAndSetRepos = async () => { try { + // https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user const res = await apiGet({ domain: "https://api.github.com", path: "user/repos?per_page=100&type=owner", @@ -159,42 +178,43 @@ export const ExportGithub = ({ } }, [gitHubAccessToken, isGitHubAppInstalled, selectedRepo]); - if (!isVisible) return null; return (
-
- {!isGitHubAppInstalled && ( -
+ {(!isGitHubAppInstalled || clickedInstall) && ( +
+ {!isGitHubAppInstalled && ( +
+ )} {showGitHubLogin && (
); diff --git a/apps/roam/src/components/GitHubSync.tsx b/apps/roam/src/components/GitHubSync.tsx new file mode 100644 index 000000000..f71b5e4a6 --- /dev/null +++ b/apps/roam/src/components/GitHubSync.tsx @@ -0,0 +1,1038 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import matchDiscourseNode from "~/utils/matchDiscourseNode"; +import { OnloadArgs, PullBlock, RoamBasicNode } from "roamjs-components/types"; +import { Button, Card, Classes, Dialog, Tag } from "@blueprintjs/core"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import createBlock from "roamjs-components/writes/createBlock"; +import runQuery from "~/utils/runQuery"; +import { render as renderToast } from "roamjs-components/components/Toast"; +import { createConfigObserver } from "roamjs-components/components/ConfigPage"; +import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel"; +import { render as exportRender } from "~/components/Export"; +import getBlockProps from "~/utils/getBlockProps"; +import localStorageGet from "roamjs-components/util/localStorageGet"; +import apiGet from "roamjs-components/util/apiGet"; +import { handleTitleAdditions } from "~/utils/handleTitleAdditions"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; +import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; +import getUids from "roamjs-components/dom/getUids"; +import createBlockObserver from "roamjs-components/dom/createBlockObserver"; +import ReactDOM from "react-dom"; +import getUidsFromButton from "roamjs-components/dom/getUidsFromButton"; +import getPageUidByBlockUid from "roamjs-components/queries/getPageUidByBlockUid"; +import apiPost from "roamjs-components/util/apiPost"; +import renderOverlay from "roamjs-components/util/renderOverlay"; +import { + GitHubDestination, + WINDOW_HEIGHT, + WINDOW_LEFT, + WINDOW_TOP, + WINDOW_WIDTH, + fetchInstallationStatus, +} from "~/components/ExportGithub"; +import localStorageSet from "roamjs-components/util/localStorageSet"; +import { getNodeEnv } from "roamjs-components/util/env"; +import nanoid from "nanoid"; +import { + CustomField, + Field, +} from "roamjs-components/components/ConfigPanels/types"; +import CustomPanel from "roamjs-components/components/ConfigPanels/CustomPanel"; +import getShallowTreeByParentUid from "roamjs-components/queries/getShallowTreeByParentUid"; +import CommentsQuery from "./GitHubSyncCommentsQuery"; +import getSubTree from "roamjs-components/util/getSubTree"; +import isFlagEnabled from "~/utils/isFlagEnabled"; + +const CommentUidCache = new Set(); +const CommentContainerUidCache = new Set(); + +type GitHubIssuePage = { + "github-sync": { + issue: GitHubIssue; + }; +}; +type GitHubIssue = { + number: number; + createdAt: string; + updatedAt: string; + labels: string[]; + id: number; + html_url: string; + state: string; + repo: string; +}; +type GitHubCommentBlock = { + "github-sync": { + comment: GitHubComment; + }; +}; +type GitHubComment = { + id: number; + body: string; + html_url: string; + user: { + login: string; + }; + created_at: string; + updated_at: string; +}; +type GitHubCommentResponse = GitHubComment & { status: number }; +type GitHubCommentsResponse = { + data: GitHubComment[]; + status: number; +}; + +const CONFIG_PAGE = "roam/js/github-sync"; +export const SETTING = "GitHub Sync"; +let enabled = false; + +// Utils +const getPageGitHubPropsDetails = (pageUid: string) => { + const blockProps = getBlockProps(pageUid) as GitHubIssuePage; + const issueNumber = blockProps?.["github-sync"]?.["issue"]?.["number"]; + const issueRepo = blockProps?.["github-sync"]?.["issue"]?.["repo"]; + return { issueNumber, issueRepo }; +}; +const getRoamCommentsContainerUid = async ({ + pageUid, + extensionAPI, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + const pageTitle = getPageTitleByPageUid(pageUid); + const configUid = getPageUidByPageTitle(CONFIG_PAGE); + const configTree = getBasicTreeByParentUid(configUid); + const queryNode = getSubTree({ + tree: configTree, + key: "Comments Block", + }); + if (!queryNode) { + renderToast({ + id: "github-issue-comments", + content: `Comments Block query not set. Set it in ${CONFIG_PAGE}`, + }); + return; + } + const results = await runQuery({ + extensionAPI, + parentUid: queryNode.uid, + inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, + }); + + return results.results[0]?.uid; +}; +export const insertNewCommentsFromGitHub = async ({ + pageUid, + extensionAPI, +}: { + pageUid: string; + extensionAPI: OnloadArgs["extensionAPI"]; +}) => { + const getCommentsOnPage = (pageUid: string) => { + const query = `[:find + (pull ?node [:block/string :block/uid :block/props]) + :where + [?p :block/uid "${pageUid}"] + [?node :block/page ?p] + [?node :block/props ?props] + ]`; + const results = window.roamAlphaAPI.q(query); + return results + .filter((r: any) => r[0].props["github-sync"]) + .map((r: any) => { + const node = r[0]; + return { + id: node.props["github-sync"]["comment"]["id"], + uid: node.uid, + string: node.string, + }; + }); + }; + + const { issueNumber, issueRepo } = getPageGitHubPropsDetails(pageUid); + if (!issueNumber) { + renderToast({ + id: "github-issue-comments", + content: "No Issue Number Found. Please send to GitHub first.", + }); + return; + } + + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + }); + + const gitHubAccessToken = localStorageGet("github-oauth"); + if (!gitHubAccessToken) { + renderToast({ + id: "github-issue-auth", + content: + "GitHub Authorization not found. Please re-authorize in the details window at the top of the page.", + }); + return; + } + + try { + // https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#list-issue-comments + const response = await apiGet({ + domain: "https://api.github.com", + path: `repos/${issueRepo}/issues/${issueNumber}/comments`, + headers: { + Authorization: `token ${gitHubAccessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 200) { + const commentsOnGithub = response.data.map((c) => ({ + id: c.id, + body: c.body, + html_url: c.html_url, + user: { + login: c.user.login, + }, + created_at: c.created_at, + updated_at: c.updated_at, + })); + + const commentsOnPage = getCommentsOnPage(pageUid); + const commentsOnPageIds = new Set(commentsOnPage.map((gc) => gc.id)); + const newComments = commentsOnGithub.filter( + (comment) => !commentsOnPageIds.has(comment.id), + ); + + if (newComments.length === 0) { + renderToast({ + id: "github-issue-comments", + content: "No new comments found.", + }); + return; + } + + if (!commentsContainerUid) { + renderToast({ + id: "github-issue-comments", + content: "Comments Block not found. Please create one.", + }); + return; + } + + renderOverlay({ + Overlay: NewCommentsConfirmationDialog, + props: { + comments: newComments, + commentsContainerUid, + }, + }); + } + } catch (error) { + const e = error as Error; + const message = e.message; + renderToast({ + intent: "danger", + id: "github-issue-comments", + content: `Failed to add comments: ${ + message === "Bad credentials" + ? `${message}. Please re-authorize in the details window at the top of the page.` + : message + }`, + }); + } +}; +export const isGitHubSyncPage = (pageTitle: string) => { + if (!enabled) return; + const gitHubNodeResult = window.roamAlphaAPI.data.fast.q(`[:find + (pull ?node [:block/string]) + :where + [?roamjsgithub-sync :node/title "roam/js/github-sync"] + [?node :block/page ?roamjsgithub-sync] + [?p :block/children ?node] + (or [?p :block/string ?p-String] + [?p :node/title ?p-String]) + [(clojure.string/includes? ?p-String "Node Select")] + ]`) as [PullBlock][]; + const nodeText = gitHubNodeResult[0]?.[0]?.[":block/string"] || ""; + if (!nodeText) return; + + const discourseNodes = getDiscourseNodes(); + const selectedNode = discourseNodes.find((node) => node.text === nodeText); + const isPageTypeOfNode = matchDiscourseNode({ + format: selectedNode?.format || "", + specification: selectedNode?.specification || [], + text: selectedNode?.text || "", + title: pageTitle, + }); + return isPageTypeOfNode; +}; + +export const renderGitHubSyncPage = async ({ + title, + h1, + onloadArgs, +}: { + title: string; + h1: HTMLHeadingElement; + onloadArgs: OnloadArgs; +}) => { + const extensionAPI = onloadArgs.extensionAPI; + const pageUid = getPageUidByPageTitle(title); + + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + }); + const commentHeaderEl = document.querySelector( + `.rm-block__input[id$="${commentsContainerUid}"]`, + ); + handleTitleAdditions(h1, ); + + // Initial render for existing comments / comment container + if (commentHeaderEl && commentsContainerUid) { + const commentNodes = getShallowTreeByParentUid(commentsContainerUid); + commentNodes.map((comment) => { + const uid = comment.uid; + CommentUidCache.add(uid); + + const commentDiv = document.querySelector(`[id*="${uid}"]`); + if (commentDiv && !commentDiv.hasAttribute("github-sync-comment")) { + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + commentDiv.append(containerDiv); + ReactDOM.render(, containerDiv); + } + }); + + if (!commentHeaderEl.hasAttribute("github-sync-comment-container")) { + CommentContainerUidCache.add(commentsContainerUid); + commentHeaderEl.setAttribute("github-sync-comment-container", "true"); + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + commentHeaderEl.appendChild(containerDiv); + ReactDOM.render( + , + containerDiv, + ); + } + } +}; + +const formatComment = (c: GitHubComment) => { + const roamCreatedDate = window.roamAlphaAPI.util.dateToPageTitle( + new Date(c.created_at), + ); + const commentHeader = `${c.user.login} on [[${roamCreatedDate}]]`; + const commentBody = c.body.trim(); + return { + header: commentHeader, + body: commentBody, + props: { + "github-sync": { + comment: c, + }, + }, + }; +}; + +// Components +const CommentsComponent = ({ blockUid }: { blockUid: string }) => { + const [loading, setLoading] = useState(false); + const url = useMemo(() => { + const props = getBlockProps(blockUid) as GitHubCommentBlock; + const commentProps = props?.["github-sync"]?.["comment"]; + return commentProps?.html_url; + }, [blockUid, loading]); + + return ( + <> + + + + + + ); }; From 54edfb6073b97ba592fa1444eb75b4b74ef67e39 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 16 Jun 2025 17:11:12 -0600 Subject: [PATCH 16/18] ah, targetOrigin --- .../app/auth/ClientCallbackHandler.tsx | 33 +------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/apps/website/app/auth/ClientCallbackHandler.tsx b/apps/website/app/auth/ClientCallbackHandler.tsx index a84641752..6f001b9a3 100644 --- a/apps/website/app/auth/ClientCallbackHandler.tsx +++ b/apps/website/app/auth/ClientCallbackHandler.tsx @@ -24,7 +24,7 @@ const Page = ({ accessToken, state, error }: Props) => { if (hasValidOpener && accessToken) { window.opener.postMessage(accessToken, "*"); setMessage("Success! You may close this page."); - // setTimeout(() => window.close(), 2000); + setTimeout(() => window.close(), 2000); return; } @@ -81,37 +81,6 @@ const Page = ({ accessToken, state, error }: Props) => { return (
{message}
-
- - - - - -
); }; From 1eab9480e8b4a14b7399c2122d50c5910b7319ad Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 16 Jun 2025 17:22:30 -0600 Subject: [PATCH 17/18] works in preview as well --- apps/roam/src/components/ExportGithub.tsx | 1 + apps/roam/src/components/GitHubSync.tsx | 19 ++++++------------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/roam/src/components/ExportGithub.tsx b/apps/roam/src/components/ExportGithub.tsx index 89ad87eed..f22b8d9b7 100644 --- a/apps/roam/src/components/ExportGithub.tsx +++ b/apps/roam/src/components/ExportGithub.tsx @@ -46,6 +46,7 @@ export const WINDOW_TOP = window.screenY + (window.innerHeight - WINDOW_HEIGHT) / 2; const isDev = getNodeEnv() === "development"; +// const isDev = false; const APP_ID = isDev ? GH_APP_ID_DEV : GH_APP_ID_PROD; const CLIENT_ID = isDev ? GH_CLIENT_ID_DEV : GH_CLIENT_ID_PROD; const API_URL = isDev ? API_URL_DEV : API_URL_PROD; diff --git a/apps/roam/src/components/GitHubSync.tsx b/apps/roam/src/components/GitHubSync.tsx index bf6b89f1f..6fcf37182 100644 --- a/apps/roam/src/components/GitHubSync.tsx +++ b/apps/roam/src/components/GitHubSync.tsx @@ -97,6 +97,7 @@ export const SETTING = "GitHub Sync"; let enabled = false; const isDev = getNodeEnv() === "development"; +// const isDev = false; const APP_ID = isDev ? GH_APP_ID_DEV : GH_APP_ID_PROD; const CLIENT_ID = isDev ? GH_CLIENT_ID_DEV : GH_CLIENT_ID_PROD; const API_URL = isDev ? API_URL_DEV : API_URL_PROD; @@ -743,7 +744,10 @@ const IssueDetailsDialog = ({ pageUid }: { pageUid: string }) => { const state = `github_${otp}_${key}`; setState(state); const handleGitHubAuthMessage = (event: MessageEvent) => { - const targetOrigin = new URL(API_URL).origin; + // TODO: add this back before publishing + // const targetOrigin = new URL(API_URL).origin; + const targetOrigin = + "https://discourse-graph-git-roam-github-sync-discourse-graphs.vercel.app"; if (event.data && event.origin === targetOrigin) { setGitHubAccessToken(event.data); setClickedInstall(false); @@ -878,21 +882,10 @@ const IssueDetailsDialog = ({ pageUid }: { pageUid: string }) => { )} {repoSelectEnabled && ( - + Authorized )} -

GitHub Sync Settings

- { - window.roamAlphaAPI.ui.mainWindow.openPage({ - page: { title: CONFIG_PAGE }, - }); - setIsOpen(false); - }} - > - {`[[${CONFIG_PAGE}]]`} -
From 1fad51301bb47157bb2d4812298f7abb591b728f Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 20 Jul 2025 22:08:46 -0600 Subject: [PATCH 18/18] Enhance error handling in GitHub components by integrating sendErrorEmail utility. --- apps/roam/src/components/ExportGithub.tsx | 29 ++ apps/roam/src/components/GitHubSync.tsx | 361 ++++++++++++------ .../components/GitHubSyncCommentsQuery.tsx | 51 ++- 3 files changed, 317 insertions(+), 124 deletions(-) diff --git a/apps/roam/src/components/ExportGithub.tsx b/apps/roam/src/components/ExportGithub.tsx index f22b8d9b7..dff1d2b36 100644 --- a/apps/roam/src/components/ExportGithub.tsx +++ b/apps/roam/src/components/ExportGithub.tsx @@ -23,6 +23,7 @@ import { API_URL_DEV, API_URL_PROD, } from "~/constants"; +import sendErrorEmail from "~/utils/sendErrorEmail"; export type UserReposResponse = { data: [ @@ -71,6 +72,15 @@ export const fetchInstallationStatus = async (token: string) => { return isAppInstalled; } catch (error) { const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "Export GitHub - Installation Status Check Failed", + context: { + token: token ? "present" : "missing", + }, + }).catch(() => {}); + return false; } }; @@ -127,6 +137,14 @@ export const ExportGithub = ({ } catch (error) { const e = error as Error; + await sendErrorEmail({ + error: e, + type: "Export GitHub - Installation Status Check Failed", + context: { + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + }, + }).catch(() => {}); + if (e.message === "Bad credentials") { setGitHubAccessToken(""); setSetting("oauth-github", ""); @@ -177,6 +195,17 @@ export const ExportGithub = ({ setError(""); setRepos(res.data); } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "Export GitHub - Repository Fetch Failed", + context: { + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + isGitHubAppInstalled, + }, + }).catch(() => {}); + setError("Failed to fetch repositories"); } }; diff --git a/apps/roam/src/components/GitHubSync.tsx b/apps/roam/src/components/GitHubSync.tsx index 6fcf37182..8e7c4cd07 100644 --- a/apps/roam/src/components/GitHubSync.tsx +++ b/apps/roam/src/components/GitHubSync.tsx @@ -52,6 +52,7 @@ import { API_URL_PROD, } from "~/constants"; import { getNodeEnv } from "roamjs-components/util/env"; +import sendErrorEmail from "~/utils/sendErrorEmail"; const CommentUidCache = new Set(); const CommentContainerUidCache = new Set(); @@ -119,19 +120,38 @@ const getRoamCommentsContainerUid = async ({ extensionAPI: OnloadArgs["extensionAPI"]; matchingNode?: DiscourseNode; }) => { - const pageTitle = getPageTitleByPageUid(pageUid); + try { + const pageTitle = getPageTitleByPageUid(pageUid); - if (!matchingNode?.githubSync?.commentsQueryUid || !matchingNode) { - return; - } + if (!matchingNode?.githubSync?.commentsQueryUid || !matchingNode) { + return; + } - const results = await runQuery({ - extensionAPI, - parentUid: matchingNode.githubSync?.commentsQueryUid, - inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, - }); + const results = await runQuery({ + extensionAPI, + parentUid: matchingNode.githubSync?.commentsQueryUid, + inputs: { NODETEXT: pageTitle, NODEUID: pageUid }, + }); + + return results.results[0]?.uid; + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Comments Query Execution Failed", + context: { + pageUid, + pageTitle: getPageTitleByPageUid(pageUid), + matchingNode: matchingNode?.text || "unknown", + commentsQueryUid: + matchingNode?.githubSync?.commentsQueryUid || "missing", + }, + }).catch(() => {}); - return results.results[0]?.uid; + console.error("Failed to get comments container UID:", e); + return undefined; + } }; export const insertNewCommentsFromGitHub = async ({ pageUid, @@ -244,6 +264,18 @@ export const insertNewCommentsFromGitHub = async ({ } catch (error) { const e = error as Error; const message = e.message; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Fetch Comments Failed", + context: { + pageUid, + issueNumber, + issueRepo, + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + }, + }).catch(() => {}); + renderToast({ intent: "danger", id: "github-issue-comments", @@ -283,52 +315,68 @@ export const renderGitHubSyncPage = async ({ onloadArgs: OnloadArgs; matchingNode: DiscourseNode; }) => { - const extensionAPI = onloadArgs.extensionAPI; - const pageUid = getPageUidByPageTitle(title); + try { + const extensionAPI = onloadArgs.extensionAPI; + const pageUid = getPageUidByPageTitle(title); - const commentsContainerUid = await getRoamCommentsContainerUid({ - pageUid, - extensionAPI, - matchingNode, - }); - const commentHeaderEl = document.querySelector( - `.rm-block__input[id$="${commentsContainerUid}"]`, - ); - handleTitleAdditions(h1, ); + const commentsContainerUid = await getRoamCommentsContainerUid({ + pageUid, + extensionAPI, + matchingNode, + }); + const commentHeaderEl = document.querySelector( + `.rm-block__input[id$="${commentsContainerUid}"]`, + ); + handleTitleAdditions(h1, ); - // Initial render for existing comments / comment container - if (commentHeaderEl && commentsContainerUid) { - const commentNodes = getShallowTreeByParentUid(commentsContainerUid); - commentNodes.map((comment) => { - const uid = comment.uid; - CommentUidCache.add(uid); + // Initial render for existing comments / comment container + if (commentHeaderEl && commentsContainerUid) { + const commentNodes = getShallowTreeByParentUid(commentsContainerUid); + commentNodes.map((comment) => { + const uid = comment.uid; + CommentUidCache.add(uid); - const commentDiv = document.querySelector(`[id*="${uid}"]`); - if (commentDiv && !commentDiv.hasAttribute("github-sync-comment")) { + const commentDiv = document.querySelector(`[id*="${uid}"]`); + if (commentDiv && !commentDiv.hasAttribute("github-sync-comment")) { + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + commentDiv.append(containerDiv); + ReactDOM.render(, containerDiv); + } + }); + + if (!commentHeaderEl.hasAttribute("github-sync-comment-container")) { + CommentContainerUidCache.add(commentsContainerUid); + commentHeaderEl.setAttribute("github-sync-comment-container", "true"); const containerDiv = document.createElement("div"); containerDiv.className = "inline-block ml-2"; containerDiv.onmousedown = (e) => e.stopPropagation(); - commentDiv.append(containerDiv); - ReactDOM.render(, containerDiv); + commentHeaderEl.appendChild(containerDiv); + ReactDOM.render( + , + containerDiv, + ); } - }); - - if (!commentHeaderEl.hasAttribute("github-sync-comment-container")) { - CommentContainerUidCache.add(commentsContainerUid); - commentHeaderEl.setAttribute("github-sync-comment-container", "true"); - const containerDiv = document.createElement("div"); - containerDiv.className = "inline-block ml-2"; - containerDiv.onmousedown = (e) => e.stopPropagation(); - commentHeaderEl.appendChild(containerDiv); - ReactDOM.render( - , - containerDiv, - ); } + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Page Rendering Failed", + context: { + title, + pageUid: getPageUidByPageTitle(title), + matchingNode: matchingNode?.text || "unknown", + }, + }).catch(() => {}); + + console.error("Failed to render GitHub sync page:", e); } }; @@ -460,6 +508,20 @@ const CommentsComponent = ({ blockUid }: { blockUid: string }) => { } catch (error) { const e = error as Error; const message = e.message; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Create Comment Failed", + context: { + blockUid: triggerUid, + pageUid, + issueNumber, + issueRepo, + commentLength: comment.length, + gitHubAccessToken: gitHubAccessToken ? "present" : "missing", + }, + }).catch(() => {}); + renderToast({ intent: "danger", id: "github-issue-comments", @@ -535,13 +597,35 @@ const CommentsContainerComponent = ({ loading={loadingComments} onClick={async () => { setLoadingComments(true); - const pageUid = getPageUidByBlockUid(commentsContainerUid); - await insertNewCommentsFromGitHub({ - pageUid, - extensionAPI, - matchingNode, - }); - setLoadingComments(false); + try { + const pageUid = getPageUidByBlockUid(commentsContainerUid); + await insertNewCommentsFromGitHub({ + pageUid, + extensionAPI, + matchingNode, + }); + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Comment Sync Failed", + context: { + commentsContainerUid, + pageUid: getPageUidByBlockUid(commentsContainerUid), + matchingNode: matchingNode?.text || "unknown", + errorMessage: e.message, + }, + }).catch(() => {}); + + renderToast({ + intent: "danger", + id: "github-comment-sync", + content: `Failed to sync comments: ${e.message}`, + }); + } finally { + setLoadingComments(false); + } }} />
@@ -663,35 +747,54 @@ const NewCommentsConfirmationDialog = ({ icon="add" intent="primary" onClick={async () => { - await Promise.all( - comments.map(async (c) => { - const roamCreatedDate = - window.roamAlphaAPI.util.dateToPageTitle( - new Date(c.created_at), - ); - const commentHeader = `${c.user.login} on [[${roamCreatedDate}]]`; - const commentBody = c.body.trim(); - await createBlock({ - node: { - text: commentHeader, - children: [{ text: commentBody }], - props: { - "github-sync": { - comment: c, + try { + await Promise.all( + comments.map(async (c) => { + const roamCreatedDate = + window.roamAlphaAPI.util.dateToPageTitle( + new Date(c.created_at), + ); + const commentHeader = `${c.user.login} on [[${roamCreatedDate}]]`; + const commentBody = c.body.trim(); + await createBlock({ + node: { + text: commentHeader, + children: [{ text: commentBody }], + props: { + "github-sync": { + comment: c, + }, }, }, - }, - parentUid: commentsContainerUid, - order: "last", - }); - }), - ); - renderToast({ - intent: "success", - id: "github-issue-comments", - content: "GitHub Comments Added", - }); - setIsOpen(false); + parentUid: commentsContainerUid, + order: "last", + }); + }), + ); + renderToast({ + intent: "success", + id: "github-issue-comments", + content: "GitHub Comments Added", + }); + setIsOpen(false); + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Bulk Comment Creation Failed", + context: { + commentsContainerUid, + commentCount: comments.length, + }, + }).catch(() => {}); + + renderToast({ + intent: "danger", + id: "github-issue-comments", + content: `Failed to add comments: ${e.message}`, + }); + } }} /> @@ -731,6 +834,17 @@ const IssueDetailsDialog = ({ pageUid }: { pageUid: string }) => { setIsGitHubAppInstalled(isAppInstalled); } catch (error) { const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Installation Status Check Failed", + context: { + pageUid, + gitHubAccessToken: token ? "present" : "missing", + errorMessage: e.message, + }, + }).catch(() => {}); + if (e.message === "Bad credentials") { setGitHubAccessToken(""); } @@ -903,40 +1017,55 @@ const initializeGitHubSync = async (onloadArgs: OnloadArgs) => { if (flag && !enabled) { const commentObserver = createBlockObserver({ onBlockLoad: (b) => { - const { blockUid } = getUids(b); - if (CommentContainerUidCache.has(blockUid)) { - if (b.hasAttribute("github-sync-comment-container")) return; - - // TODO: move this to renderGitHubSyncPage so we can pass in the matching node - const title = getPageTitleByBlockUid(blockUid); - const matchingNode = isGitHubSyncPage(title); - if (!matchingNode) return; - - b.setAttribute("github-sync-comment-container", "true"); - const containerDiv = document.createElement("div"); - containerDiv.className = "inline-block ml-2"; - containerDiv.onmousedown = (e) => e.stopPropagation(); - b.append(containerDiv); - ReactDOM.render( - , - containerDiv, - ); - } - if (CommentUidCache.has(blockUid)) { - if (b.hasAttribute("github-sync-comment")) return; - b.setAttribute("github-sync-comment", "true"); - const containerDiv = document.createElement("div"); - containerDiv.className = "inline-block ml-2"; - containerDiv.onmousedown = (e) => e.stopPropagation(); - b.append(containerDiv); - ReactDOM.render( - , - containerDiv, - ); + try { + const { blockUid } = getUids(b); + if (CommentContainerUidCache.has(blockUid)) { + if (b.hasAttribute("github-sync-comment-container")) return; + + // TODO: move this to renderGitHubSyncPage so we can pass in the matching node + const title = getPageTitleByBlockUid(blockUid); + const matchingNode = isGitHubSyncPage(title); + if (!matchingNode) return; + + b.setAttribute("github-sync-comment-container", "true"); + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + b.append(containerDiv); + ReactDOM.render( + , + containerDiv, + ); + } + if (CommentUidCache.has(blockUid)) { + if (b.hasAttribute("github-sync-comment")) return; + b.setAttribute("github-sync-comment", "true"); + const containerDiv = document.createElement("div"); + containerDiv.className = "inline-block ml-2"; + containerDiv.onmousedown = (e) => e.stopPropagation(); + b.append(containerDiv); + ReactDOM.render( + , + containerDiv, + ); + } + } catch (error) { + const e = error as Error; + + sendErrorEmail({ + error: e, + type: "GitHub Sync - Block Observer Failed", + context: { + blockUid: getUids(b).blockUid, + errorMessage: e.message, + }, + }).catch(() => {}); + + console.error("GitHub Sync Block Observer Error:", e); } }, }); diff --git a/apps/roam/src/components/GitHubSyncCommentsQuery.tsx b/apps/roam/src/components/GitHubSyncCommentsQuery.tsx index 7c69712c4..c57b67ac2 100644 --- a/apps/roam/src/components/GitHubSyncCommentsQuery.tsx +++ b/apps/roam/src/components/GitHubSyncCommentsQuery.tsx @@ -5,6 +5,7 @@ import type { OnloadArgs } from "roamjs-components/types/native"; import QueryBuilder from "~/components/QueryBuilder"; import parseQuery, { DEFAULT_RETURN_NODE } from "~/utils/parseQuery"; import createBlock from "roamjs-components/writes/createBlock"; +import sendErrorEmail from "~/utils/sendErrorEmail"; const CommentsQuery = ({ parentUid, @@ -85,18 +86,52 @@ const CommentsQuery = ({ ]; const createInitialQueryblocks = async () => { - for (const block of initialQueryConditionBlocks) { - await createBlock({ - parentUid: initialQueryArgs.conditionsNodesUid, - order: "last", - node: block, - }); + try { + for (const block of initialQueryConditionBlocks) { + await createBlock({ + parentUid: initialQueryArgs.conditionsNodesUid, + order: "last", + node: block, + }); + } + setShowQuery(true); + } catch (error) { + const e = error as Error; + + await sendErrorEmail({ + error: e, + type: "GitHub Sync - Query Block Creation Failed", + context: { + parentUid, + conditionsNodesUid: initialQueryArgs.conditionsNodesUid, + blockCount: initialQueryConditionBlocks.length, + }, + }).catch(() => {}); + + console.error("Failed to create initial query blocks:", e); + // Still show the query even if block creation fails + setShowQuery(true); } - setShowQuery(true); }; useEffect(() => { - if (!showQuery) createInitialQueryblocks(); + if (!showQuery) { + createInitialQueryblocks().catch((error) => { + const e = error as Error; + + sendErrorEmail({ + error: e, + type: "GitHub Sync - Query Initialization Failed", + context: { + parentUid, + showQuery, + errorMessage: e.message, + }, + }).catch(() => {}); + + console.error("Failed to initialize query:", e); + }); + } }, [parentUid, initialQueryArgs, showQuery]); return (