diff --git a/src/stat/Stat.tsx b/src/stat/Stat.tsx index 8c1b382..eaa2c0a 100644 --- a/src/stat/Stat.tsx +++ b/src/stat/Stat.tsx @@ -8,7 +8,9 @@ export default function Stat({ org, repo, branch }: Props) { return ( { updateStat(stat, locData.loc) diff --git a/src/stat/injector.ts b/src/stat/injector.ts index 4e121c4..ccbc8c3 100644 --- a/src/stat/injector.ts +++ b/src/stat/injector.ts @@ -2,43 +2,67 @@ import { JSX, render } from "preact" import { LocData } from "./loader" import { openFallbackPage } from "./util" -function isInjected(root: Element) { - return root.querySelector("#github-loc") !== null +function isPublicRepository() { + const publicMeta = document.querySelector('meta[name="octolytics-dimension-repository_public"]') + const publicValue = publicMeta?.getAttribute("content") + + if (publicValue !== null) { + return publicValue === "true" + } + + const repoVisibility = document.evaluate( + '//*[@id="repo-title-component"]/span[2]', + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue + + return repoVisibility?.textContent !== "Private" } export function locateRoot(): Promise<[Element, boolean]> { return new Promise((resolve) => { - const root = document.evaluate( - '//h2[text()="About" and not(@class="heading-element")]', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue?.parentElement - - const repoVisibility = document.evaluate( - '//*[@id="repo-title-component"]/span[2]', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ).singleNodeValue - - if (root && !isInjected(root)) { - resolve([root, repoVisibility?.textContent !== "Private"]) + let observer: MutationObserver | undefined + + const tryLocate = () => { + const root = document.evaluate( + '//h2[normalize-space(.)="About" and not(@class="heading-element")]', + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue?.parentElement + + if (!root) { + return false + } + + observer?.disconnect() + resolve([root, isPublicRepository()]) + return true + } + + if (!tryLocate()) { + observer = new MutationObserver(tryLocate) + observer.observe(document.documentElement, { childList: true, subtree: true }) } }) } export function injectStat(root: Element, stat: JSX.Element) { - const div = document.createElement("div") - div.className = "mt-2" - div.id = "github-loc" - - if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) { - root.insertBefore(div, root.lastElementChild) - } else { - root.appendChild(div) + const existing = root.querySelector("#github-loc") + const div = existing ?? document.createElement("div") + + if (!existing) { + div.className = "mt-2" + div.id = "github-loc" + + if (root.lastElementChild?.firstElementChild?.textContent?.includes("Report")) { + root.insertBefore(div, root.lastElementChild) + } else { + root.appendChild(div) + } } render(stat, div) diff --git a/src/stat/loader.ts b/src/stat/loader.ts index 3e27bce..41817ef 100644 --- a/src/stat/loader.ts +++ b/src/stat/loader.ts @@ -1,3 +1,5 @@ +/// + import { now } from "./util" export interface LocData { @@ -6,24 +8,196 @@ export interface LocData { lastFetched: number } +interface GitHubTreeEntry { + path: string + sha: string + type: string +} + +interface GitHubTreeResponse { + tree: GitHubTreeEntry[] + truncated: boolean +} + +interface GitHubBlobResponse { + content: string + encoding: string +} + +interface GitHubRepositoryResponse { + default_branch: string +} + +const GITHUB_API = "https://api.github.com" +const BLOB_BATCH_SIZE = 10 + function makeKey(org: string, repo: string, branch: string) { return org + "/" + repo + "/" + branch } -async function sha1(str: string) { - const encoder = new TextEncoder() - const hash = await crypto.subtle.digest("SHA-1", encoder.encode(str)) +async function fetchGitHubJson(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || `GitHub API request failed (${response.status})`) + } + + return data as T +} + +function decodeBase64(value: string) { + const binary = atob(value.replace(/\s/g, "")) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +function countTextLines(bytes: Uint8Array) { + if (bytes.includes(0)) { + return 0 + } + + const text = new TextDecoder().decode(bytes) + + if (text.length === 0) { + return 0 + } + + const lines = text.split(/\r\n|\r|\n/) + return lines.length - (/(?:\r\n|\r|\n)$/.test(text) ? 1 : 0) +} + +function getFileExtension(path: string) { + const fileName = path.split("/").pop() || path + const dotIndex = fileName.lastIndexOf(".") + + return dotIndex > 0 ? fileName.slice(dotIndex).toLowerCase() : fileName +} + +function isIgnored(path: string, ignoredFiles: string[]) { + return ignoredFiles.some((ignored) => path.toLowerCase().endsWith(ignored.toLowerCase())) +} + +async function fetchLocalLoc( + org: string, + repo: string, + branch: string, + ignoredFiles: string[], +): Promise { + const accessToken = await chrome.storage.sync.get("accessToken") + + if (typeof accessToken.accessToken !== "string" || accessToken.accessToken.length === 0) { + throw new Error("A GitHub access token is required for private repositories") + } + + const token = accessToken.accessToken + const repository = branch + ? null + : await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent(repo)}`, + token, + ) + const ref = branch || repository!.default_branch + const tree = await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent( + repo, + )}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + token, + ) + + if (tree.truncated) { + throw new Error("The repository tree is too large to count in the browser") + } + + const files = tree.tree.filter( + (entry) => entry.type === "blob" && !isIgnored(entry.path, ignoredFiles), + ) + const locByLangs: { [lang: string]: number } = {} + + for (let index = 0; index < files.length; index += BLOB_BATCH_SIZE) { + const batch = files.slice(index, index + BLOB_BATCH_SIZE) + const lineCounts = await Promise.all( + batch.map(async (file) => { + const blob = await fetchGitHubJson( + `${GITHUB_API}/repos/${encodeURIComponent(org)}/${encodeURIComponent( + repo, + )}/git/blobs/${file.sha}`, + token, + ) + + if (blob.encoding !== "base64") { + return { extension: getFileExtension(file.path), lines: 0 } + } + + return { + extension: getFileExtension(file.path), + lines: countTextLines(decodeBase64(blob.content)), + } + }), + ) + + for (const { extension, lines } of lineCounts) { + locByLangs[extension] = (locByLangs[extension] || 0) + lines + } + } + + return { + loc: Object.values(locByLangs).reduce((total, lines) => total + lines, 0), + locByLangs, + lastFetched: 0, + } +} + +async function fetchPublicLoc(org: string, repo: string, branch: string, ignoredFiles: string[]) { + const encodedBranch = branch.split("/").map(encodeURIComponent).join("/") + const params = new URLSearchParams({ pretty: "false" }) + + if (ignoredFiles.length > 0) { + params.set("filter", ignoredFiles.map((ignored) => `!${ignored}$`).join(",")) + } + + const branchPath = encodedBranch ? `/${encodedBranch}` : "" + const configuredApiUrl = import.meta.env.VITE_API_URL + const apiBaseUrl = + typeof configuredApiUrl === "string" && configuredApiUrl.length > 0 + ? configuredApiUrl.replace(/\/$/, "") + : "https://ghloc.ifels.dev" + const headers = new Headers() + const authToken = import.meta.env.VITE_AUTH_TOKEN + + if (typeof authToken === "string" && authToken.length > 0) { + headers.set("Ghloc-Authorization", authToken) + } - return Array.from(new Uint8Array(hash)) - .map((v) => v.toString(16).padStart(2, "0")) - .join("") + const response = await fetch( + `${apiBaseUrl}/${encodeURIComponent(org)}/${encodeURIComponent(repo)}${branchPath}?${params}`, + { headers }, + ) + const data = await response.json() + + if (!response.ok || data.error) { + throw new Error(data.error || `LOC API request failed (${response.status})`) + } + + return data as LocData } export function loadLoc(org: string, repo: string, branch: string): Promise { return new Promise((resolve) => { const key = makeKey(org, repo, branch) - chrome.storage.local.get(key, (data) => { + chrome.storage.local.get(key, (data: { [key: string]: unknown }) => { const locData = data[key] as LocData if ( @@ -40,46 +214,19 @@ export function loadLoc(org: string, repo: string, branch: string): Promise { - let url = `https://ghloc-api.vercel.app/${org}/${repo}/${branch}` - - const accessToken = await chrome.storage.sync.get("accessToken") +export async function fetchLoc( + org: string, + repo: string, + branch: string, + isPublic: boolean, +): Promise { const ignoredFiles = await chrome.storage.sync.get("ignoredFiles") - - const headers = new Headers({ - "Ghloc-Authorization": import.meta.env.VITE_AUTH_TOKEN, - }) - - if (Array.isArray(ignoredFiles.ignoredFiles) && ignoredFiles.ignoredFiles.length > 0) { - url += "?match=" - - for (const ignored of ignoredFiles.ignoredFiles) { - url += "!" + ignored + "$," - } - - url = url.substring(0, url.length - 1) - } - - if (typeof accessToken.accessToken === "string" && accessToken.accessToken.length > 0) { - headers.append("Authorization", `Bearer ${accessToken.accessToken}`) - - url += url.includes("?match") ? "&" : "?" - url += "salt=" + (await sha1(accessToken.accessToken)) - } - - let data: LocData = await fetch(url, { headers }) - .then((res) => res.json()) - .then((data) => { - if (typeof data !== "object") { - throw new Error("Invalid response: " + JSON.stringify(data)) - } - - if (data.error) { - throw new Error(data.error) - } - - return data - }) + const ignoredList = Array.isArray(ignoredFiles.ignoredFiles) + ? (ignoredFiles.ignoredFiles as string[]) + : [] + const data = isPublic + ? await fetchPublicLoc(org, repo, branch, ignoredList) + : await fetchLocalLoc(org, repo, branch, ignoredList) data.lastFetched = now() chrome.storage.local.set({ [makeKey(org, repo, branch)]: data }) diff --git a/src/stat/util.ts b/src/stat/util.ts index 70204f7..58b1b7d 100644 --- a/src/stat/util.ts +++ b/src/stat/util.ts @@ -5,23 +5,22 @@ export function now(): number { return Math.floor(Date.now() / 1000) } +function getBranchFromSelector() { + const branchSelector = + document.querySelector("#ref-picker-repos-header-ref-selector") ?? + document.querySelector('[data-testid="anchor-button"][aria-label$=" branch"]') + const ariaLabel = branchSelector?.getAttribute("aria-label") + + return ariaLabel?.replace(/\s+branch$/i, "").trim() || branchSelector?.textContent?.trim() +} + export function getTarget() { const path = window.location.pathname.split("/") - let branch: string | undefined = path.slice(4).join("/") - - if (!branch) { - branch = document - .evaluate( - '//*[@id="ref-picker-repos-header-ref-selector"]/span/span[1]/div/div[2]/span', - document, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null, - ) - .singleNodeValue?.textContent?.trim() - } + const branchFromPath = + path[3] === "tree" || path[3] === "blob" ? path.slice(4).join("/") : undefined + const branch = getBranchFromSelector() || branchFromPath || "" - return [path[1], path[2], branch || "main"] + return [path[1], path[2], branch] } export function getFilter(): Promise {