diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts index 741a482..f167068 100644 --- a/build/esbuild-build.ts +++ b/build/esbuild-build.ts @@ -1,13 +1,15 @@ -import * as dotenv from "dotenv"; +import { config } from "dotenv"; import esbuild from "esbuild"; import { invertColors } from "./plugins/invert-colors"; import { pwaManifest } from "./plugins/pwa-manifest"; +import { execSync } from "child_process"; +config(); + const typescriptEntries = ["src/home/home.ts", "src/progressive-web-app.ts"]; const cssEntries = ["static/style/style.css"]; const entries = [...typescriptEntries, ...cssEntries, "static/manifest.json", "static/favicon.svg", "static/icon-512x512.png"]; export const esBuildContext: esbuild.BuildOptions = { - define: createEnvDefines(["SUPABASE_URL", "SUPABASE_ANON_KEY"]), plugins: [invertColors, pwaManifest], sourcemap: true, entryPoints: entries, @@ -23,6 +25,10 @@ export const esBuildContext: esbuild.BuildOptions = { ".json": "file", }, outdir: "static/dist", + define: createEnvDefines(["SUPABASE_URL", "SUPABASE_ANON_KEY"], { + SUPABASE_STORAGE_KEY: generateSupabaseStorageKey(), + commitHash: execSync(`git rev-parse --short HEAD`).toString().trim(), + }), }; esbuild @@ -30,16 +36,43 @@ esbuild .then(() => console.log("\tesbuild complete")) .catch(console.error); -function createEnvDefines(variableNames: string[]): Record { +function createEnvDefines(environmentVariables: string[], generatedAtBuild: Record): Record { const defines: Record = {}; - dotenv.config(); - for (const name of variableNames) { + for (const name of environmentVariables) { const envVar = process.env[name]; if (envVar !== undefined) { - defines[`process.env.${name}`] = JSON.stringify(envVar); + defines[name] = JSON.stringify(envVar); } else { throw new Error(`Missing environment variable: ${name}`); } } + for (const key in generatedAtBuild) { + if (Object.prototype.hasOwnProperty.call(generatedAtBuild, key)) { + defines[key] = JSON.stringify(generatedAtBuild[key]); + } + } return defines; } + +export function generateSupabaseStorageKey(): string | null { + const SUPABASE_URL = process.env.SUPABASE_URL; + if (!SUPABASE_URL) { + console.error("SUPABASE_URL environment variable is not set"); + return null; + } + + const urlParts = SUPABASE_URL.split("."); + if (urlParts.length === 0) { + console.error("Invalid SUPABASE_URL environment variable"); + return null; + } + + const domain = urlParts[0]; + const lastSlashIndex = domain.lastIndexOf("/"); + if (lastSlashIndex === -1) { + console.error("Invalid SUPABASE_URL format"); + return null; + } + + return domain.substring(lastSlashIndex + 1); +} diff --git a/cypress/e2e/devpool.cy.ts b/cypress/e2e/devpool.cy.ts index 13cc329..e367ed7 100644 --- a/cypress/e2e/devpool.cy.ts +++ b/cypress/e2e/devpool.cy.ts @@ -50,7 +50,6 @@ describe("DevPool", () => { }).as("getIssues"); cy.visit("/"); cy.get('div[id="issues-container"]').children().should("have.length", 1); - cy.get("#issues-container > :nth-child(1)").should("have.class", "new-task"); // needed to make sure data is written to the local storage cy.wait(3000); @@ -65,7 +64,6 @@ describe("DevPool", () => { }).as("getIssues"); cy.visit("/"); cy.get('div[id="issues-container"]').children().should("have.length", 1); - cy.get("#issues-container > :nth-child(1)").should("not.have.class", "new-task"); // needed to make sure data is written to the local storage cy.wait(3000); @@ -139,6 +137,7 @@ describe("DevPool", () => { statusCode: 200, }); // Simulate login token + // cSpell: ignore wfzpewmlyiozupulbuur window.localStorage.setItem("sb-wfzpewmlyiozupulbuur-auth-token", JSON.stringify(loginToken)); }).as("githubLogin"); cy.visit("/"); diff --git a/src/home/authentication.ts b/src/home/authentication.ts index bdac7ab..1a263ce 100644 --- a/src/home/authentication.ts +++ b/src/home/authentication.ts @@ -5,7 +5,7 @@ import { displayGitHubUserInformation } from "./rendering/display-github-user-in import { renderGitHubLoginButton } from "./rendering/render-github-login-button"; export async function authentication() { - const accessToken = getGitHubAccessToken(); + const accessToken = await getGitHubAccessToken(); if (!accessToken) { renderGitHubLoginButton(); } diff --git a/src/home/fetch-github/fetch-and-display-previews.ts b/src/home/fetch-github/fetch-and-display-previews.ts index 8d88e94..47f6df6 100644 --- a/src/home/fetch-github/fetch-and-display-previews.ts +++ b/src/home/fetch-github/fetch-and-display-previews.ts @@ -1,3 +1,4 @@ +import { getGitHubAccessToken } from "../getters/get-github-access-token"; import { getImageFromCache } from "../getters/get-indexed-db"; import { getLocalStore } from "../getters/get-local-store"; import { GITHUB_TASKS_STORAGE_KEY, TaskStorageItems } from "../github-types"; @@ -16,11 +17,26 @@ export type Options = { export async function fetchAndDisplayPreviewsFromCache(sorting?: Sorting, options = { ordering: "normal" }) { let _cachedTasks = getLocalStore(GITHUB_TASKS_STORAGE_KEY) as TaskStorageItems; + const _accessToken = await getGitHubAccessToken(); + + // Refresh the storage if there is no logged-in object in cachedTasks but there is one now. + if (_cachedTasks && !_cachedTasks.loggedIn && _accessToken) { + localStorage.removeItem(GITHUB_TASKS_STORAGE_KEY); + return fetchAndDisplayPreviewsFromNetwork(sorting, options); + } + + // If previously logged in but not anymore, clear cache and fetch from network. + if (_cachedTasks && _cachedTasks.loggedIn && !_accessToken) { + localStorage.removeItem(GITHUB_TASKS_STORAGE_KEY); + return fetchAndDisplayPreviewsFromNetwork(sorting, options); + } + // makes sure tasks have a timestamp to know how old the cache is, or refresh if older than 15 minutes if (!_cachedTasks || !_cachedTasks.timestamp || _cachedTasks.timestamp + 60 * 1000 * 15 <= Date.now()) { _cachedTasks = { timestamp: Date.now(), tasks: [], + loggedIn: _accessToken !== null, }; } diff --git a/src/home/fetch-github/fetch-avatar.ts b/src/home/fetch-github/fetch-avatar.ts index 591f706..98b7bb2 100644 --- a/src/home/fetch-github/fetch-avatar.ts +++ b/src/home/fetch-github/fetch-avatar.ts @@ -19,7 +19,7 @@ export async function fetchAvatar(orgName: string) { } // If not in IndexedDB, fetch from network - const octokit = new Octokit({ auth: getGitHubAccessToken() }); + const octokit = new Octokit({ auth: await getGitHubAccessToken() }); try { const { data: { avatar_url: avatarUrl }, diff --git a/src/home/fetch-github/fetch-issues-full.ts b/src/home/fetch-github/fetch-issues-full.ts index f257f9a..b10df1b 100644 --- a/src/home/fetch-github/fetch-issues-full.ts +++ b/src/home/fetch-github/fetch-issues-full.ts @@ -8,7 +8,7 @@ import { TaskMaybeFull, TaskWithFull } from "./preview-to-full-mapping"; export const organizationImageCache = new Map(); export async function fetchIssuesFull(taskPreviews: TaskMaybeFull[]): Promise { - const octokit = new Octokit({ auth: getGitHubAccessToken() }); + const octokit = new Octokit({ auth: await getGitHubAccessToken() }); const urlPattern = /https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/issues\/(?\d+)/; const fullTaskPromises = taskPreviews.map(async (task) => { diff --git a/src/home/fetch-github/fetch-issues-preview.ts b/src/home/fetch-github/fetch-issues-preview.ts index 6702755..14ec673 100644 --- a/src/home/fetch-github/fetch-issues-preview.ts +++ b/src/home/fetch-github/fetch-issues-preview.ts @@ -1,18 +1,81 @@ import { Octokit } from "@octokit/rest"; -import { getGitHubAccessToken } from "../getters/get-github-access-token"; +import { getGitHubAccessToken, getGitHubUserName } from "../getters/get-github-access-token"; import { GitHubIssue } from "../github-types"; import { taskManager } from "../home"; import { displayPopupMessage } from "../rendering/display-popup-modal"; import { TaskNoFull } from "./preview-to-full-mapping"; +async function checkPrivateRepoAccess(): Promise { + const octokit = new Octokit({ auth: await getGitHubAccessToken() }); + const username = getGitHubUserName(); + + if (username) { + try { + const response = await octokit.repos.checkCollaborator({ + owner: "ubiquity", + repo: "devpool-directory-private", + username, + }); + + if (response.status === 204) { + // If the response is successful, it means the user has access to the private repository + return true; + } + return false; + } catch (error) { + if (error.status === 404) { + // If the status is 404, it means the user is not a collaborator, hence no access + return false; + } else { + // Handle other errors if needed + console.error("Error checking repository access:", error); + throw error; + } + } + } + + return false; +} + export async function fetchIssuePreviews(): Promise { - const octokit = new Octokit({ auth: getGitHubAccessToken() }); + const octokit = new Octokit({ auth: await getGitHubAccessToken() }); let freshIssues: GitHubIssue[] = []; + let hasPrivateRepoAccess = false; // Flag to track access to the private repository + try { - const response = await octokit.paginate("GET /repos/ubiquity/devpool-directory/issues", { state: "open" }); + // Check if the user has access to the private repository + hasPrivateRepoAccess = await checkPrivateRepoAccess(); + + // Fetch issues from public repository + const { data: publicResponse } = await octokit.issues.listForRepo({ + owner: "ubiquity", + repo: "devpool-directory", + state: "open", + }); + + const publicIssues = publicResponse.filter((issue: GitHubIssue) => !issue.pull_request); - freshIssues = response.filter((issue: GitHubIssue) => !issue.pull_request); + // Fetch issues from the private repository only if the user has access + if (hasPrivateRepoAccess) { + const { data: privateResponse } = await octokit.issues.listForRepo({ + owner: "ubiquity", + repo: "devpool-directory-private", + state: "open", + }); + const privateIssues = privateResponse.filter((issue: GitHubIssue) => !issue.pull_request); + + // Mark private issues + const privateIssuesWithFlag = privateIssues.map((issue) => { + return issue; + }); + + // Combine public and private issues + freshIssues = [...privateIssuesWithFlag, ...publicIssues]; + } else { + // If user doesn't have access, only load issues from the public repository + freshIssues = publicIssues; + } } catch (error) { if (403 === error.status) { console.error(`GitHub API rate limit exceeded.`); diff --git a/src/home/getters/get-github-access-token.ts b/src/home/getters/get-github-access-token.ts index c0bdec5..4e19f00 100644 --- a/src/home/getters/get-github-access-token.ts +++ b/src/home/getters/get-github-access-token.ts @@ -1,12 +1,15 @@ +declare const SUPABASE_STORAGE_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts +import { checkSupabaseSession } from "../rendering/render-github-login-button"; import { getLocalStore } from "./get-local-store"; -export function getGitHubAccessToken(): string | null { - const oauthToken = getLocalStore("sb-wfzpewmlyiozupulbuur-auth-token") as OAuthToken | null; +export async function getGitHubAccessToken(): Promise { + // better to use official function, looking up localstorage has flaws + const oauthToken = await checkSupabaseSession(); const expiresAt = oauthToken?.expires_at; if (expiresAt) { if (expiresAt < Date.now() / 1000) { - localStorage.removeItem("sb-wfzpewmlyiozupulbuur-auth-token"); + localStorage.removeItem(`sb-${SUPABASE_STORAGE_KEY}-auth-token`); return null; } } @@ -19,6 +22,17 @@ export function getGitHubAccessToken(): string | null { return null; } +export function getGitHubUserName(): string | null { + const oauthToken = getLocalStore(`sb-${SUPABASE_STORAGE_KEY}-auth-token`) as OAuthToken | null; + + const username = oauthToken?.user?.user_metadata?.user_name; + if (username) { + return username; + } + + return null; +} + export interface OAuthToken { provider_token: string; access_token: string; diff --git a/src/home/getters/get-github-user.ts b/src/home/getters/get-github-user.ts index 064c4b3..8d6a1a7 100644 --- a/src/home/getters/get-github-user.ts +++ b/src/home/getters/get-github-user.ts @@ -2,6 +2,7 @@ import { Octokit } from "@octokit/rest"; import { GitHubUser, GitHubUserResponse } from "../github-types"; import { OAuthToken } from "./get-github-access-token"; import { getLocalStore } from "./get-local-store"; +declare const SUPABASE_STORAGE_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts export async function getGitHubUser(): Promise { const activeSessionToken = await getSessionToken(); @@ -13,7 +14,7 @@ export async function getGitHubUser(): Promise { } async function getSessionToken(): Promise { - const cachedSessionToken = getLocalStore("sb-wfzpewmlyiozupulbuur-auth-token") as OAuthToken | null; + const cachedSessionToken = getLocalStore(`sb-${SUPABASE_STORAGE_KEY}-auth-token`) as OAuthToken | null; if (cachedSessionToken) { return cachedSessionToken.provider_token; } diff --git a/src/home/github-types.ts b/src/home/github-types.ts index 0489145..89d34e9 100644 --- a/src/home/github-types.ts +++ b/src/home/github-types.ts @@ -1,122 +1,6 @@ +import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { TaskNoState } from "./fetch-github/preview-to-full-mapping"; -export interface GitHubUser { - avatar_url: string; - bio: string; - blog: string; - company: string; - created_at: string; - email: string | null; - events_url: string; - followers_url: string; - followers: number; - following_url: string; - following: number; - gists_url: string; - gravatar_id: string; - hireable: boolean | null; - html_url: string; - id: number; - location: string; - login: string; - name: string; - node_id: string; - organizations_url: string; - public_gists: number; - public_repos: number; - received_events_url: string; - repos_url: string; - site_admin: boolean; - starred_url: string; - subscriptions_url: string; - twitter_username: string; - type: string; - updated_at: string; - url: string; -} -export interface GitHubUserResponse { - status: number; - url: string; - headers: { - "cache-control": string; - "content-type": string; - etag: string; - "last-modified": string; - "x-accepted-oauth-scopes": string; - "x-github-media-type": string; - "x-github-request-id": string; - "x-oauth-scopes": string; - "x-ratelimit-limit": string; - "x-ratelimit-remaining": string; - "x-ratelimit-reset": string; - "x-ratelimit-resource": string; - "x-ratelimit-used": string; - }; - data: GitHubUser; -} - -interface GitHubLabel { - id: number; - node_id: string; - url: string; - name: string; - description: string; - color: string; - default: boolean; -} - -interface GitHubMilestone { - url: string; - html_url: string; - labels_url: string; - id: number; - node_id: string; - number: number; - state: string; - title: string; - description: string; - creator: GitHubUser; - open_issues: number; - closed_issues: number; - created_at: string; - updated_at: string; - closed_at: string | null; - due_on: string | null; -} - -export interface GitHubIssue { - id: number; - node_id: string; - url: string; - repository_url: string; - labels_url: string; - comments_url: string; - events_url: string; - html_url: string; - number: number; - state: string; - title: string; - body: string; - user: GitHubUser; - labels: GitHubLabel[]; - assignee: GitHubUser | null; - assignees: GitHubUser[]; - milestone: GitHubMilestone | null; - locked: boolean; - active_lock_reason: string | null; - comments: number; - pull_request?: { - url: string; - html_url: string; - diff_url: string; - patch_url: string; - }; - closed_at: string | null; - created_at: string; - updated_at: string; - closed_by?: GitHubUser; -} - export interface AvatarCache { [organization: string]: string | null; } @@ -126,4 +10,9 @@ export const GITHUB_TASKS_STORAGE_KEY = "gitHubTasks"; export type TaskStorageItems = { timestamp: number; tasks: TaskNoState[]; + loggedIn: boolean; }; + +export type GitHubUserResponse = RestEndpointMethodTypes["users"]["getByUsername"]["response"]; +export type GitHubUser = GitHubUserResponse["data"]; +export type GitHubIssue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; diff --git a/src/home/home.ts b/src/home/home.ts index 0f67510..541a1df 100644 --- a/src/home/home.ts +++ b/src/home/home.ts @@ -25,7 +25,7 @@ void (async function home() { const fullTasks = await fetchIssuesFull(previews); taskManager.syncTasks(fullTasks); console.trace({ fullTasks }); - taskManager.writeToStorage(); + await taskManager.writeToStorage(); return fullTasks; } catch (error) { console.error(error); diff --git a/src/home/rendering/render-github-issues.ts b/src/home/rendering/render-github-issues.ts index d5ef76f..2d585a5 100644 --- a/src/home/rendering/render-github-issues.ts +++ b/src/home/rendering/render-github-issues.ts @@ -38,6 +38,10 @@ function everyNewIssue({ taskPreview, container }: { taskPreview: TaskMaybeFull; issueElement.classList.add("issue-element-inner"); const urlPattern = /https:\/\/github\.com\/([^/]+)\/([^/]+)\//; + if (!taskPreview.preview.body) { + console.warn(`No body found for issue ${taskPreview.preview.id}.`); + return; + } const match = taskPreview.preview.body.match(urlPattern); const organizationName = match?.[1]; @@ -105,6 +109,22 @@ function parseAndGenerateLabels(task: TaskMaybeFull) { const { labels, otherLabels } = task.preview.labels.reduce( (acc, label) => { + // check if label is a single string + if (typeof label === "string") { + return { + labels: [], + otherLabels: [], + }; + } + + // check if label.name exists + if (!label.name) { + return { + labels: [], + otherLabels: [], + }; + } + const match = label.name.match(/^(Pricing|Time|Priority): /); if (match) { const name = label.name.replace(match[0], ""); @@ -149,6 +169,7 @@ export function viewIssueDetails(full: GitHubIssue) { // Update the title and body for the new issue titleHeader.textContent = full.title; titleAnchor.href = full.html_url; + if (!full.body) return; previewBodyInner.innerHTML = marked(full.body) as string; // Show the preview diff --git a/src/home/rendering/render-github-login-button.ts b/src/home/rendering/render-github-login-button.ts index 5c72444..a8b5f8f 100644 --- a/src/home/rendering/render-github-login-button.ts +++ b/src/home/rendering/render-github-login-button.ts @@ -1,19 +1,30 @@ import { createClient } from "@supabase/supabase-js"; import { toolbar } from "../ready-toolbar"; -const supabaseUrl = process.env.SUPABASE_URL; -if (!supabaseUrl) throw new Error("SUPABASE_URL not found"); -const supabaseAnonKey = process.env.SUPABASE_ANON_KEY; -if (!supabaseAnonKey) throw new Error("SUPABASE_ANON_KEY not found"); +declare const SUPABASE_URL: string; // @DEV: passed in at build time check build/esbuild-build.ts +declare const SUPABASE_ANON_KEY: string; // @DEV: passed in at build time check build/esbuild-build.ts -const supabase = createClient(supabaseUrl, supabaseAnonKey); +const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); export function getSupabase() { return supabase; } +export async function checkSupabaseSession() { + const { + data: { session }, + } = await supabase.auth.getSession(); + + return session; +} + async function gitHubLoginButtonHandler() { - const { error } = await supabase.auth.signInWithOAuth({ provider: "github" }); + const { error } = await supabase.auth.signInWithOAuth({ + provider: "github", + options: { + scopes: "repo", + }, + }); if (error) { console.error("Error logging in:", error); } diff --git a/src/home/task-manager.ts b/src/home/task-manager.ts index 50db817..59bde85 100644 --- a/src/home/task-manager.ts +++ b/src/home/task-manager.ts @@ -1,4 +1,5 @@ import { TaskMaybeFull } from "./fetch-github/preview-to-full-mapping"; +import { getGitHubAccessToken } from "./getters/get-github-access-token"; import { setLocalStore } from "./getters/get-local-store"; import { GITHUB_TASKS_STORAGE_KEY } from "./github-types"; @@ -50,7 +51,8 @@ export class TaskManager { return this._container; } - public writeToStorage() { - setLocalStore(GITHUB_TASKS_STORAGE_KEY, { timestamp: Date.now(), tasks: this._tasks }); + public async writeToStorage() { + const _accessToken = await getGitHubAccessToken(); + setLocalStore(GITHUB_TASKS_STORAGE_KEY, { timestamp: Date.now(), tasks: this._tasks, loggedIn: _accessToken !== null }); } }