From 7e7239c2a90e651bc6c8dce6021b264c4b263d15 Mon Sep 17 00:00:00 2001 From: Darrien Rushing Date: Thu, 5 Nov 2026 16:10:02 -0600 Subject: [PATCH] Add a read-only GitHub preview to project pages A project page now shows, for each repo with a GitHub remote, the open pull requests, open issues and latest commits on the default branch, so progress across several repos can be seen in one place. The preview is kept apart from gitlib's own issues so the two cannot be confused: it is a separate "On GitHub" section, rows are named owner/repo#N and link out to GitHub, and nothing in it can be edited or assigned. It only issues GET requests, never touches planning.db, and keeps its data in a five-minute in-memory cache (Refresh bypasses it). Repos with no GitHub remote or that GitHub cannot show are reported individually. The repo-level Pull requests and Issues tabs now share the same fetch code. Local issue labels on the Projects pages say "gitlib issues". Co-Authored-By: Claude Sonnet 5.5 --- README.md | 15 + .../src/components/project-github-preview.tsx | 280 ++++++++++++++++++ apps/web/src/routes/projects.$id.tsx | 18 +- apps/web/src/routes/projects.index.tsx | 2 +- apps/web/src/server/github-lists.server.ts | 123 ++++++++ apps/web/src/server/issues.ts | 50 +--- apps/web/src/server/project-github.server.ts | 104 +++++++ apps/web/src/server/project-github.ts | 63 ++++ apps/web/src/server/pulls.ts | 42 +-- apps/web/test/project-github.test.ts | 229 ++++++++++++++ 10 files changed, 835 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/components/project-github-preview.tsx create mode 100644 apps/web/src/server/github-lists.server.ts create mode 100644 apps/web/src/server/project-github.server.ts create mode 100644 apps/web/src/server/project-github.ts create mode 100644 apps/web/test/project-github.test.ts diff --git a/README.md b/README.md index 490aa87..2a25fcb 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,21 @@ tabs are separate and stay read-only GitHub views). stay readable and are labelled "repository not found"; new issues can only be filed against repos gitlib currently sees. +### Projects + +The **Projects** page (`/projects`) groups repositories and gitlib issues under a name, so one project can span +several repos. A project page has two parts that are kept apart on purpose: + +- **Repositories** and **Gitlib issues** are gitlib's own data, stored in `planning.db`. You can add, remove and + edit them, and nothing in them is ever sent to GitHub. +- **On GitHub** is a read-only preview: for each repo in the project that has a GitHub remote it shows the open + pull requests, open issues and the latest commits on the default branch. Every row links out to GitHub and is + named `owner/repo#12`, so it cannot be mistaken for a gitlib issue (`repo#3`). Gitlib never writes to GitHub + here, never saves what it reads, and never reads it back into planning: the only copy is an in-memory cache that + lasts about five minutes (the **Refresh** button skips it) and is gone when the server restarts. A repo with no + GitHub remote, or one GitHub cannot show (not found, rate limited), is reported on its own line. Set + `GITHUB_TOKEN` to read private repos and avoid the 60-requests-an-hour limit for anonymous use. + Planning data lives in its own file, `~/.git-library/planning.db`, not in `gitlib.db`. `gitlib.db` is rebuilt by rescanning and its tables are shared with the original Python version; planning data cannot be rebuilt, so it has its own versioned schema (append-only migrations tracked in `PRAGMA diff --git a/apps/web/src/components/project-github-preview.tsx b/apps/web/src/components/project-github-preview.tsx new file mode 100644 index 0000000..831c6f1 --- /dev/null +++ b/apps/web/src/components/project-github-preview.tsx @@ -0,0 +1,280 @@ +import { ExternalLink, RefreshCw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { PROVIDERS } from "#/components/provider-banner"; +import { Button } from "#/components/ui/button"; +import { formatAgo } from "#/lib/format"; +import { getProjectGitHub, type GitHubList, type ProjectGitHubRepo, type ProjectGitHubView } from "#/server/project-github"; + +/** Items listed per kind before the rest are left to GitHub. */ +const SHOWN = 5; + +const GitHubMark = PROVIDERS.github.icon; + +/** "7", "50+" when GitHub has more than gitlib fetched, "–" when the lookup failed. */ +function countOf(list: GitHubList): string { + if (list.error !== null) return "–"; + return `${list.items.length}${list.hasMore ? "+" : ""}`; +} + +function sumOf(repos: readonly ProjectGitHubRepo[], pick: (repo: ProjectGitHubRepo) => GitHubList): string { + const lists = repos.map(pick).filter((list) => list.error === null); + if (lists.length === 0) return "–"; + const total = lists.reduce((sum, list) => sum + list.items.length, 0); + return `${total}${lists.some((list) => list.hasMore) ? "+" : ""}`; +} + +/** + * What GitHub currently holds for the project's repos. It is a separate block with its own heading and + * wording, every row links out to GitHub, and nothing here can be edited, assigned or filed: gitlib's own + * issues (above) are the only ones this app owns. + */ +export function ProjectGitHubPreview({ projectId, repoPaths }: { projectId: number; repoPaths: readonly string[] }) { + const [state, setState] = useState< + { status: "loading" } | { status: "error"; message: string } | { status: "done"; view: ProjectGitHubView } + >({ status: "loading" }); + // Bumped by Refresh. Starts at 0 so that opening the page may use GitHub data cached a few minutes ago. + const [refreshes, setRefreshes] = useState(0); + const repoKey = repoPaths.join("\n"); + + useEffect(() => { + if (repoPaths.length === 0) return; + let cancelled = false; + setState((previous) => (previous.status === "done" ? previous : { status: "loading" })); + getProjectGitHub({ data: { id: projectId, refresh: refreshes > 0 } }) + .then((view) => !cancelled && setState({ status: "done", view })) + .catch((caught: unknown) => { + if (!cancelled) { + setState({ status: "error", message: caught instanceof Error ? caught.message : "Could not load GitHub." }); + } + }); + return () => { + cancelled = true; + }; + // `repoKey` stands in for `repoPaths`, which is a new array on every render. + }, [projectId, repoKey, refreshes]); + + const loading = state.status === "loading"; + + return ( +
+
+
+

+ On GitHub + Read-only preview +

+

+ Pulled live from GitHub for the repositories above and kept in memory for a few minutes. Gitlib does not save, edit + or sync any of it, and the issues you file in gitlib are never sent here. +

+
+ {repoPaths.length > 0 ? ( + + ) : null} +
+ + {repoPaths.length === 0 ? ( +

Add a repository to this project to see what is open on GitHub.

+ ) : state.status === "loading" ? ( +

Loading from GitHub…

+ ) : state.status === "error" ? ( +

+ {state.message} +

+ ) : ( + + )} +
+ ); +} + +function Loaded({ view }: { view: ProjectGitHubView }) { + const linked = view.repos.filter((repo) => repo.slug !== null); + const unlinked = view.repos.filter((repo) => repo.slug === null); + + return ( + <> + {linked.length > 0 ? ( +

+ {sumOf(linked, (repo) => repo.pulls)} open PRs ·{" "} + {sumOf(linked, (repo) => repo.issues)} open issues on GitHub, across{" "} + {linked.length} {linked.length === 1 ? "repository" : "repositories"} +

+ ) : null} + + {!view.hasToken ? ( +

+ No GitHub token is set, so private repositories are hidden and GitHub allows only a few lookups an hour. Set{" "} + GITHUB_TOKEN to lift that. +

+ ) : null} + + {linked.length > 0 ? ( +
    + {linked.map((repo) => ( + + ))} +
+ ) : null} + + {unlinked.length > 0 ? ( +
    + {unlinked.map((repo) => ( +
  • + {repo.relativePath}{" "} + {repo.error ? `· ${repo.error}` : "· no GitHub remote, so nothing to show."} +
  • + ))} +
+ ) : null} + +

Checked {formatAgo(view.fetchedAt) || "just now"}.

+ + ); +} + +function RepoActivity({ repo }: { repo: ProjectGitHubRepo }) { + const slug = repo.slug ?? repo.relativePath; + const latest = repo.commits.items[0]; + // GitHub refusing a whole repo (not found, rate limited) fails all three lists the same way; say it once. + const sharedError = + repo.pulls.error !== null && repo.pulls.error === repo.issues.error && repo.issues.error === repo.commits.error + ? repo.pulls.error + : null; + return ( +
  • +
    + + {slug} + + {countOf(repo.pulls)} PRs · {countOf(repo.issues)} issues + {latest?.committedAt ? ` · last commit ${formatAgo(latest.committedAt)}` : ""} + + + {sharedError !== null ? ( +

    + {sharedError} +

    + ) : ( +
    +
    ({ + key: pull.number, + url: pull.url, + title: pull.title, + ref: `${slug}#${pull.number}`, + meta: `${pull.author ? `${pull.author} · ` : ""}updated ${formatAgo(pull.updatedAt)}`, + badge: pull.draft ? "Draft" : null, + })} + /> +
    ({ + key: issue.number, + url: issue.url, + title: issue.title, + ref: `${slug}#${issue.number}`, + meta: `${issue.author ? `${issue.author} · ` : ""}updated ${formatAgo(issue.updatedAt)}`, + badge: null, + })} + /> +
    ({ + key: commit.sha, + url: commit.url, + title: commit.message, + ref: `${slug}@${commit.sha}`, + meta: `${commit.author ? `${commit.author} · ` : ""}${commit.committedAt ? formatAgo(commit.committedAt) : ""}`, + badge: null, + })} + /> +
    + )} +
    +
  • + ); +} + +interface Row { + key: string | number; + url: string; + title: string; + /** The GitHub name for the item, always including `owner/repo`, so it cannot pass for a gitlib `repo#3`. */ + ref: string; + meta: string; + badge: string | null; +} + +function Section({ + title, + list, + slug, + moreUrl, + empty, + render, +}: { + title: string; + list: GitHubList; + slug: string; + moreUrl: string; + empty: string; + render: (item: T) => Row; +}) { + const rows = list.items.slice(0, SHOWN).map(render); + const hidden = list.items.length - rows.length; + return ( +
    +

    {title}

    + {list.error !== null ? ( +

    + {list.error} +

    + ) : rows.length === 0 ? ( +

    {empty}

    + ) : ( +
      + {rows.map((row) => ( +
    • + + {row.title} + + + {row.badge ? {row.badge} : null} +

      + {row.ref} · {row.meta} +

      +
    • + ))} +
    + )} + {list.error === null && (hidden > 0 || list.hasMore) ? ( +

    + + {hidden > 0 ? `${hidden}${list.hasMore ? "+" : ""} more on GitHub` : "See all on GitHub"} + +

    + ) : null} +
    + ); +} diff --git a/apps/web/src/routes/projects.$id.tsx b/apps/web/src/routes/projects.$id.tsx index d3dbc1a..3ccf36a 100644 --- a/apps/web/src/routes/projects.$id.tsx +++ b/apps/web/src/routes/projects.$id.tsx @@ -2,6 +2,7 @@ import { Link, createFileRoute, notFound, useRouter } from "@tanstack/react-rout import { ArrowLeft, CircleCheck, CircleDot, Pencil, Trash2, X } from "lucide-react"; import { useState } from "react"; import { DeleteProjectDialog } from "#/components/delete-project-dialog"; +import { ProjectGitHubPreview } from "#/components/project-github-preview"; import { ProjectNameDialog } from "#/components/project-name-dialog"; import { Button } from "#/components/ui/button"; import { SearchSelect } from "#/components/ui/search-select"; @@ -153,13 +154,16 @@ function ProjectDetailPage() {
    -

    - Issues {project.issues.length} -

    +
    +

    + Gitlib issues {project.issues.length} +

    +

    Your own planning issues. They live in gitlib only and are never sent to GitHub.

    +
    {project.unassignedIssues.length > 0 ? ( {project.issues.length === 0 ? ( -

    No issues in this project yet.

    +

    No gitlib issues in this project yet.

    ) : (
      {project.issues.map((issue) => { @@ -225,6 +229,8 @@ function ProjectDetailPage() {
    )}
    + + repo.relativePath)} /> ); } diff --git a/apps/web/src/routes/projects.index.tsx b/apps/web/src/routes/projects.index.tsx index d0dc5a1..626f16e 100644 --- a/apps/web/src/routes/projects.index.tsx +++ b/apps/web/src/routes/projects.index.tsx @@ -52,7 +52,7 @@ function Projects() { {project.name}

    - {count(project.repoCount, "repository", "repositories")} · {count(project.issueCount, "issue", "issues")} + {count(project.repoCount, "repository", "repositories")} · {count(project.issueCount, "gitlib issue", "gitlib issues")} {project.issueCount > 0 ? ` (${project.openIssueCount} open)` : ""}

    diff --git a/apps/web/src/server/github-lists.server.ts b/apps/web/src/server/github-lists.server.ts new file mode 100644 index 0000000..e037348 --- /dev/null +++ b/apps/web/src/server/github-lists.server.ts @@ -0,0 +1,123 @@ +import { githubGet, type GitHubSource } from "./github.server"; +import type { IssueView } from "./issues"; +import type { PullRequestView } from "./pulls"; +import type { CommitView, GitHubList } from "./project-github"; + +export const LIST_PAGE_SIZE = 50; +export const COMMIT_COUNT = 5; + +const failed = (error: string): GitHubList => ({ items: [], hasMore: false, error }); + +interface GitHubPull { + number: number; + title: string; + html_url: string; + created_at: string; + updated_at: string; + draft?: boolean; + user: { login: string } | null; + labels: { name?: string; color?: string }[]; + head: { ref: string; label?: string; repo: { full_name: string } | null }; + base: { ref: string; repo: { full_name: string } | null }; +} + +interface GitHubIssue { + number: number; + title: string; + html_url: string; + created_at: string; + updated_at: string; + comments: number; + user: { login: string } | null; + labels: (string | { name?: string; color?: string })[]; + /** Present when the "issue" is really a pull request. */ + pull_request?: unknown; +} + +interface GitHubCommit { + sha: string; + html_url: string; + commit: { message: string; author: { name?: string; date?: string } | null; committer: { date?: string } | null }; + author: { login: string } | null; +} + +/** Open pull requests, newest activity first. Throws on a network failure; a GitHub error comes back in the list. */ +export async function fetchOpenPulls(source: GitHubSource): Promise> { + const result = await githubGet(source, "/pulls", { + state: "open", + sort: "updated", + direction: "desc", + per_page: String(LIST_PAGE_SIZE), + }); + if (result.error !== null) return failed(result.error); + + const items = result.data.map( + (item): PullRequestView => ({ + number: item.number, + title: item.title, + url: item.html_url, + author: item.user?.login ?? null, + createdAt: item.created_at, + updatedAt: item.updated_at, + draft: item.draft === true, + head: item.head.repo?.full_name === item.base.repo?.full_name ? item.head.ref : (item.head.label ?? item.head.ref), + base: item.base.ref, + labels: item.labels.flatMap((label) => (label.name ? [{ name: label.name, color: label.color ?? "888888" }] : [])), + }), + ); + return { items, hasMore: result.data.length === LIST_PAGE_SIZE, error: null }; +} + +/** Open issues (pull requests excluded), newest activity first. Throws on a network failure. */ +export async function fetchOpenIssues(source: GitHubSource): Promise> { + // The issues endpoint also lists pull requests, so a page is fetched and they are filtered out below. + const result = await githubGet(source, "/issues", { + state: "open", + sort: "updated", + direction: "desc", + per_page: String(LIST_PAGE_SIZE), + }); + if (result.error !== null) return failed(result.error); + + const items = result.data + .filter((item) => !item.pull_request) + .map( + (item): IssueView => ({ + number: item.number, + title: item.title, + url: item.html_url, + author: item.user?.login ?? null, + createdAt: item.created_at, + updatedAt: item.updated_at, + comments: item.comments, + labels: item.labels.flatMap((label) => + typeof label === "string" + ? [{ name: label, color: "888888" }] + : label.name + ? [{ name: label.name, color: label.color ?? "888888" }] + : [], + ), + }), + ); + return { items, hasMore: result.data.length === LIST_PAGE_SIZE, error: null }; +} + +/** The latest commits on the default branch. Throws on a network failure. */ +export async function fetchRecentCommits(source: GitHubSource): Promise> { + const result = await githubGet(source, "/commits", { per_page: String(COMMIT_COUNT) }); + if (result.error !== null) { + // GitHub answers 409 for a repository with no commits yet, which is an empty list rather than a failure. + return result.status === 409 ? { items: [], hasMore: false, error: null } : failed(result.error); + } + + const items = result.data.map( + (item): CommitView => ({ + sha: item.sha.slice(0, 7), + message: item.commit.message.split("\n", 1)[0] ?? "", + url: item.html_url, + author: item.author?.login ?? item.commit.author?.name ?? null, + committedAt: item.commit.committer?.date ?? item.commit.author?.date ?? "", + }), + ); + return { items, hasMore: false, error: null }; +} diff --git a/apps/web/src/server/issues.ts b/apps/web/src/server/issues.ts index 156772f..351e77c 100644 --- a/apps/web/src/server/issues.ts +++ b/apps/web/src/server/issues.ts @@ -1,6 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; import type { GitHubRepoRef } from "@gitlib/core"; -import { githubGet, githubToken, groupReposByGitHubSlug, resolveGitHubSource } from "./github.server"; +import { githubToken, groupReposByGitHubSlug, resolveGitHubSource } from "./github.server"; +import { fetchOpenIssues } from "./github-lists.server"; import type { Provider } from "./provider"; export interface IssueView { @@ -28,21 +29,6 @@ export interface IssuesView { error: string | null; } -const PAGE_SIZE = 50; - -interface GitHubIssue { - number: number; - title: string; - html_url: string; - created_at: string; - updated_at: string; - comments: number; - user: { login: string } | null; - labels: (string | { name?: string; color?: string })[]; - /** Present when the "issue" is really a pull request. */ - pull_request?: unknown; -} - type IssuesSource = Pick; const NO_SOURCE: IssuesSource = { provider: null, slug: null, issuesUrl: null }; @@ -68,37 +54,9 @@ export const getIssues = createServerFn({ method: "GET" }) if (!source) return { ...view, ...NO_ISSUES, error: null }; view = { provider: source.provider, slug: source.slug, issuesUrl: `https://github.com/${source.slug}/issues` }; - // The issues endpoint also lists pull requests, so over-fetch a page and filter them out below. - const result = await githubGet(source, "/issues", { - state: "open", - sort: "updated", - direction: "desc", - per_page: String(PAGE_SIZE), - }); + const result = await fetchOpenIssues(source); if (result.error !== null) return failure(view, result.error); - - const items = result.data; - const issues = items - .filter((item) => !item.pull_request) - .map( - (item): IssueView => ({ - number: item.number, - title: item.title, - url: item.html_url, - author: item.user?.login ?? null, - createdAt: item.created_at, - updatedAt: item.updated_at, - comments: item.comments, - labels: item.labels.flatMap((label) => - typeof label === "string" - ? [{ name: label, color: "888888" }] - : label.name - ? [{ name: label.name, color: label.color ?? "888888" }] - : [], - ), - }), - ); - return { ...view, issues, hasMore: items.length === PAGE_SIZE, error: null }; + return { ...view, issues: result.items, hasMore: result.hasMore, error: null }; } catch (caught) { return failure(view, caught instanceof Error ? caught.message : "Could not load issues."); } diff --git a/apps/web/src/server/project-github.server.ts b/apps/web/src/server/project-github.server.ts new file mode 100644 index 0000000..c2e4fab --- /dev/null +++ b/apps/web/src/server/project-github.server.ts @@ -0,0 +1,104 @@ +import { resolveGitHubSource, githubToken, type GitHubSource } from "./github.server"; +import { fetchOpenIssues, fetchOpenPulls, fetchRecentCommits } from "./github-lists.server"; +import type { GitHubList, ProjectGitHubRepo, ProjectGitHubView } from "./project-github"; + +/** + * A read-only look at what GitHub holds for the repos in a project. Nothing here writes to GitHub, and + * nothing is saved: the only copy is the in-memory cache below, which a restart empties. Gitlib's own + * planning data is never read or changed, so the two cannot drift or be mistaken for one another. + */ + +export const GITHUB_CACHE_TTL_MS = 5 * 60_000; +/** Repos read at once. Each one is three requests, and an unauthenticated client only gets 60 an hour. */ +const CONCURRENCY = 4; + +type Activity = Pick; + +const cache = new Map }>(); + +/** Empties the in-memory cache, so the next look asks GitHub again. */ +export function clearProjectGitHubCache(): void { + cache.clear(); +} + +async function settle(read: Promise>, fallback: string): Promise> { + try { + return await read; + } catch (caught) { + return { items: [], hasMore: false, error: caught instanceof Error ? caught.message : fallback }; + } +} + +async function readActivity(source: GitHubSource): Promise { + const [pulls, issues, commits] = await Promise.all([ + settle(fetchOpenPulls(source), "Could not load pull requests."), + settle(fetchOpenIssues(source), "Could not load issues."), + settle(fetchRecentCommits(source), "Could not load commits."), + ]); + return { pulls, issues, commits }; +} + +const failedEntirely = ({ pulls, issues, commits }: Activity) => + pulls.error !== null && issues.error !== null && commits.error !== null; + +function activityOf(source: GitHubSource, refresh: boolean): Promise { + const key = source.slug.toLowerCase(); + const cached = cache.get(key); + if (!refresh && cached && Date.now() - cached.at <= GITHUB_CACHE_TTL_MS) return cached.activity; + + const entry = { at: Date.now(), activity: readActivity(source) }; + cache.set(key, entry); + // A lookup that failed outright is worth retrying next time rather than keeping for minutes. + void entry.activity.then((activity) => { + if (failedEntirely(activity) && cache.get(key) === entry) cache.delete(key); + }); + return entry.activity; +} + +const noList = (): GitHubList => ({ items: [], hasMore: false, error: null }); + +/** Runs `work` over `items` with at most `limit` in flight, keeping the results in order. */ +async function inPool(items: readonly T[], limit: number, work: (item: T) => Promise): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await work(items[index] as T); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +/** + * Open pull requests, open issues and recent commits from GitHub for each repo path, in the order given. + * Only repos gitlib knows are read (`resolveGitHubSource` refuses any other), and a repo without a GitHub + * remote, or one that cannot be read, is reported on its own without affecting the rest. + */ +export async function readProjectGitHub( + repoPaths: readonly string[], + { refresh = false }: { refresh?: boolean } = {}, +): Promise { + const repos = await inPool(repoPaths, CONCURRENCY, async (relativePath): Promise => { + const empty = { relativePath, slug: null, url: null, pulls: noList(), issues: noList(), commits: noList() }; + let resolved: ReturnType; + try { + resolved = resolveGitHubSource(relativePath); + } catch { + return { ...empty, error: "Gitlib no longer finds this repository." }; + } + if (resolved.error) return { ...empty, error: resolved.error }; + if (!resolved.source) return { ...empty, error: null }; + + const { source } = resolved; + return { + ...empty, + slug: source.slug, + url: `https://github.com/${source.slug}`, + error: null, + ...(await activityOf(source, refresh)), + }; + }); + return { repos, fetchedAt: new Date().toISOString(), hasToken: githubToken() !== undefined }; +} diff --git a/apps/web/src/server/project-github.ts b/apps/web/src/server/project-github.ts new file mode 100644 index 0000000..fa835c3 --- /dev/null +++ b/apps/web/src/server/project-github.ts @@ -0,0 +1,63 @@ +import { createServerFn } from "@tanstack/react-start"; +import { getApp, loadRepositories } from "./gitlib.server"; +import type { IssueView } from "./issues"; +import type { PullRequestView } from "./pulls"; +import { readProjectGitHub } from "./project-github.server"; + +/** One list read from GitHub: what came back, whether there is more, or why nothing did. */ +export interface GitHubList { + items: T[]; + /** Whether GitHub has more than were fetched. */ + hasMore: boolean; + error: string | null; +} + +export interface CommitView { + /** Abbreviated hash. */ + sha: string; + /** First line of the commit message. */ + message: string; + url: string; + /** GitHub login when it knows one, else the name written into the commit. */ + author: string | null; + committedAt: string; +} + +/** What GitHub holds for one repo in a project. Read-only: gitlib never writes to GitHub or keeps this. */ +export interface ProjectGitHubRepo { + relativePath: string; + /** `owner/repo`, or null when the repo has no GitHub remote or could not be resolved. */ + slug: string | null; + /** The repo's page on GitHub. */ + url: string | null; + /** Why this repo could not be looked up at all; each list below also carries its own error. */ + error: string | null; + pulls: GitHubList; + issues: GitHubList; + commits: GitHubList; +} + +export interface ProjectGitHubView { + /** One entry per repo in the project, in the project's order. */ + repos: ProjectGitHubRepo[]; + /** When gitlib asked for this (a cached repo may be a few minutes older). */ + fetchedAt: string; + /** Without a token GitHub allows few requests and hides private repos. */ + hasToken: boolean; +} + +/** + * A project's repos as GitHub sees them: open pull requests, open issues and recent commits. It is a + * preview only; it never touches the planning database, so GitHub is not a source of truth for gitlib. + */ +export const getProjectGitHub = createServerFn({ method: "GET" }) + .validator((input: { id: number; refresh?: boolean }) => { + if (!Number.isInteger(input?.id) || input.id <= 0) throw new Error("Invalid project request."); + return { id: input.id, refresh: input.refresh === true }; + }) + .handler(async ({ data }): Promise => { + loadRepositories(); + const project = getApp().planning.getProject(data.id); + if (!project) throw new Error("Gitlib doesn't have that project."); + return readProjectGitHub(project.repoPaths, { refresh: data.refresh }); + }); diff --git a/apps/web/src/server/pulls.ts b/apps/web/src/server/pulls.ts index 05c917d..abe7281 100644 --- a/apps/web/src/server/pulls.ts +++ b/apps/web/src/server/pulls.ts @@ -1,5 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; -import { githubGet, resolveGitHubSource } from "./github.server"; +import { resolveGitHubSource } from "./github.server"; +import { fetchOpenPulls } from "./github-lists.server"; import type { Provider } from "./provider"; export interface PullRequestView { @@ -31,21 +32,6 @@ export interface PullRequestsView { error: string | null; } -const PAGE_SIZE = 50; - -interface GitHubPull { - number: number; - title: string; - html_url: string; - created_at: string; - updated_at: string; - draft?: boolean; - user: { login: string } | null; - labels: { name?: string; color?: string }[]; - head: { ref: string; label?: string; repo: { full_name: string } | null }; - base: { ref: string; repo: { full_name: string } | null }; -} - type PullsSource = Pick; const NO_SOURCE: PullsSource = { provider: null, slug: null, pullsUrl: null }; @@ -71,29 +57,9 @@ export const getPullRequests = createServerFn({ method: "GET" }) if (!source) return { ...view, ...NO_PULLS, error: null }; view = { provider: source.provider, slug: source.slug, pullsUrl: `https://github.com/${source.slug}/pulls` }; - const result = await githubGet(source, "/pulls", { - state: "open", - sort: "updated", - direction: "desc", - per_page: String(PAGE_SIZE), - }); + const result = await fetchOpenPulls(source); if (result.error !== null) return failure(view, result.error); - - const pulls = result.data.map( - (item): PullRequestView => ({ - number: item.number, - title: item.title, - url: item.html_url, - author: item.user?.login ?? null, - createdAt: item.created_at, - updatedAt: item.updated_at, - draft: item.draft === true, - head: item.head.repo?.full_name === item.base.repo?.full_name ? item.head.ref : (item.head.label ?? item.head.ref), - base: item.base.ref, - labels: item.labels.flatMap((label) => (label.name ? [{ name: label.name, color: label.color ?? "888888" }] : [])), - }), - ); - return { ...view, pulls, hasMore: result.data.length === PAGE_SIZE, error: null }; + return { ...view, pulls: result.items, hasMore: result.hasMore, error: null }; } catch (caught) { return failure(view, caught instanceof Error ? caught.message : "Could not load pull requests."); } diff --git a/apps/web/test/project-github.test.ts b/apps/web/test/project-github.test.ts new file mode 100644 index 0000000..e18ddb2 --- /dev/null +++ b/apps/web/test/project-github.test.ts @@ -0,0 +1,229 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** Shaped like real responses for `GET /repos/{owner}/{repo}/pulls`, `/issues` and `/commits`. */ +const pull = (number: number, extra: Record = {}) => ({ + number, + title: `Pull ${number}`, + html_url: `https://github.com/me/app/pull/${number}`, + created_at: "2026-11-01T00:00:00Z", + updated_at: "2026-11-02T00:00:00Z", + user: { login: "octo" }, + labels: [{ name: "bug", color: "d73a4a" }], + head: { ref: "feature", repo: { full_name: "me/app" } }, + base: { ref: "main", repo: { full_name: "me/app" } }, + ...extra, +}); +const issue = (number: number, extra: Record = {}) => ({ + number, + title: `Issue ${number}`, + html_url: `https://github.com/me/app/issues/${number}`, + created_at: "2026-10-01T00:00:00Z", + updated_at: "2026-10-02T00:00:00Z", + comments: 2, + user: { login: "octo" }, + labels: ["help wanted"], + ...extra, +}); +const commit = (sha: string, message: string) => ({ + sha, + html_url: `https://github.com/me/app/commit/${sha}`, + commit: { message, author: { name: "Octo Cat", date: "2026-11-03T00:00:00Z" }, committer: { date: "2026-11-03T01:00:00Z" } }, + author: null, +}); + +/** A GitHub that knows `me/app` and `me/api`; `control` makes parts of it fail. */ +function fakeGitHub() { + const control = { failIssuesFor: "" as string, rateLimited: false, emptyRepo: "" as string }; + const requests: { method: string; path: string }[] = []; + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + requests.push({ method: init?.method ?? "GET", path: url.pathname }); + if (control.rateLimited) return json({ message: "rate limited" }, 403); + const match = /^\/repos\/me\/([^/]+)\/(pulls|issues|commits)$/.exec(url.pathname); + if (!match) return json({ message: "Not Found" }, 404); + const [, repo, kind] = match; + if (kind === "issues" && control.failIssuesFor === repo) return json({ message: "boom" }, 500); + if (kind === "commits" && control.emptyRepo === repo) return json({ message: "Git Repository is empty." }, 409); + switch (kind) { + case "pulls": + return json([pull(7, { draft: true }), pull(6, { head: { ref: "patch", label: "fork:patch", repo: { full_name: "fork/app" } } })]); + case "issues": + // GitHub lists pull requests among issues; one is mixed in here. + return json([issue(3), issue(7, { pull_request: {} }), issue(2)]); + default: + return json([commit("abcdef1234567890", "Fix the thing\n\nLonger explanation."), commit("1234567abcdef", "Earlier")]); + } + }); + return { control, requests, fetchMock }; +} + +let base: string; + +function gitClone(name: string, remote?: string) { + const dir = path.join(base, "git", name); + fs.mkdirSync(dir, { recursive: true }); + execFileSync("git", ["init", "-q", dir]); + if (remote) execFileSync("git", ["-C", dir, "remote", "add", "origin", remote]); +} + +beforeEach(() => { + base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gitlib-project-github-"))); + gitClone("app", "https://github.com/me/app.git"); + gitClone("api", "git@github.com:me/api.git"); + gitClone("local-only"); + fs.mkdirSync(path.join(base, "home")); + fs.writeFileSync(path.join(base, "home", "config.toml"), `roots = ["${path.join(base, "git")}"]\n\n[search]\nenabled = false\n`); + + vi.stubEnv("GITLIB_HOME", path.join(base, "home")); + vi.stubEnv("GITLIB_GITHUB_TOKEN", "ghp_test"); + vi.stubEnv("GITHUB_TOKEN", ""); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + fs.rmSync(base, { recursive: true, force: true }); +}); + +/** A fresh server over the scanned clones, with GitHub faked. */ +async function start(github: ReturnType) { + vi.stubGlobal("fetch", github.fetchMock); + delete (globalThis as Record)["__gitlib"]; + vi.resetModules(); + const { loadRepositories, getApp } = await import("../src/server/gitlib.server"); + loadRepositories(); + const server = await import("../src/server/project-github.server"); + server.clearProjectGitHubCache(); + return { app: getApp(), ...server }; +} + +describe("a project's GitHub preview", () => { + it("reads open pull requests, open issues and recent commits for each repo, in the order given", async () => { + const github = fakeGitHub(); + const { readProjectGitHub } = await start(github); + + const view = await readProjectGitHub(["app", "api"]); + + expect(view.hasToken).toBe(true); + expect(view.repos.map((repo) => [repo.relativePath, repo.slug])).toEqual([ + ["app", "me/app"], + ["api", "me/api"], + ]); + const [app] = view.repos; + expect(app?.url).toBe("https://github.com/me/app"); + expect(app?.pulls).toMatchObject({ hasMore: false, error: null }); + expect(app?.pulls.items.map((p) => [p.number, p.draft, p.head])).toEqual([ + [7, true, "feature"], + [6, false, "fork:patch"], + ]); + // The pull request that GitHub also lists as an issue is left out. + expect(app?.issues.items.map((i) => i.number)).toEqual([3, 2]); + expect(app?.commits.items[0]).toEqual({ + sha: "abcdef1", + message: "Fix the thing", + url: "https://github.com/me/app/commit/abcdef1234567890", + author: "Octo Cat", + committedAt: "2026-11-03T01:00:00Z", + }); + }); + + it("only reads: every request is a GET to api.github.com", async () => { + const github = fakeGitHub(); + const { readProjectGitHub } = await start(github); + await readProjectGitHub(["app", "api"]); + expect(github.requests).toHaveLength(6); + expect(new Set(github.requests.map((request) => request.method))).toEqual(new Set(["GET"])); + for (const [url] of github.fetchMock.mock.calls) expect(new URL(String(url)).origin).toBe("https://api.github.com"); + }); + + it("leaves gitlib's planning data exactly as it was", async () => { + const github = fakeGitHub(); + const { app, readProjectGitHub } = await start(github); + const project = app.createProject("Platform"); + app.addRepoToProject(project.id, "app"); + const local = app.createLocalIssue("app", { title: "Mine", body: "Only in gitlib" }); + const before = { projects: app.listProjects(), issues: app.listLocalIssues() }; + + await readProjectGitHub(["app"]); + + expect(app.listProjects()).toEqual(before.projects); + expect(app.listLocalIssues()).toEqual(before.issues); + // GitHub issue #3 and gitlib's own issue #1 on the same repo stay two unrelated things. + expect(app.listLocalIssues()).toHaveLength(1); + expect(app.listLocalIssues()[0]).toMatchObject({ id: local.id, number: 1, title: "Mine" }); + }); + + it("reports a repo with no GitHub remote or no longer known on its own, without asking GitHub", async () => { + const github = fakeGitHub(); + const { readProjectGitHub } = await start(github); + + const view = await readProjectGitHub(["local-only", "vanished", "app"]); + + expect(view.repos[0]).toMatchObject({ relativePath: "local-only", slug: null, url: null, error: null }); + expect(view.repos[1]).toMatchObject({ relativePath: "vanished", slug: null, error: "Gitlib no longer finds this repository." }); + expect(view.repos[2]).toMatchObject({ slug: "me/app", error: null }); + expect(github.requests.every((request) => request.path.startsWith("/repos/me/app/"))).toBe(true); + }); + + it("keeps one failing list from spoiling the rest", async () => { + const github = fakeGitHub(); + github.control.failIssuesFor = "api"; + const { readProjectGitHub } = await start(github); + + const [app, api] = (await readProjectGitHub(["app", "api"])).repos; + + expect(app?.issues.error).toBeNull(); + expect(api?.issues).toEqual({ items: [], hasMore: false, error: "GitHub responded with 500." }); + expect(api?.pulls.items).toHaveLength(2); + expect(api?.commits.items).toHaveLength(2); + }); + + it("treats an empty repository as having no commits rather than as an error", async () => { + const github = fakeGitHub(); + github.control.emptyRepo = "app"; + const { readProjectGitHub } = await start(github); + const [app] = (await readProjectGitHub(["app"])).repos; + expect(app?.commits).toEqual({ items: [], hasMore: false, error: null }); + }); + + it("reuses what it read for a few minutes, and reads again on refresh", async () => { + const github = fakeGitHub(); + const { readProjectGitHub } = await start(github); + + await readProjectGitHub(["app"]); + await readProjectGitHub(["app"]); + expect(github.requests).toHaveLength(3); + + await readProjectGitHub(["app"], { refresh: true }); + expect(github.requests).toHaveLength(6); + }); + + it("does not keep a lookup that failed outright", async () => { + const github = fakeGitHub(); + github.control.rateLimited = true; + const { readProjectGitHub } = await start(github); + + const [limited] = (await readProjectGitHub(["app"])).repos; + expect(limited?.pulls.error).toMatch(/rate limit/i); + + github.control.rateLimited = false; + const [recovered] = (await readProjectGitHub(["app"])).repos; + expect(recovered?.pulls.items).toHaveLength(2); + }); + + it("asks GitHub about a repository once when two paths share it", async () => { + const github = fakeGitHub(); + gitClone("app-copy", "https://github.com/me/app.git"); + const { readProjectGitHub } = await start(github); + + const view = await readProjectGitHub(["app", "app-copy"]); + + expect(view.repos.map((repo) => repo.slug)).toEqual(["me/app", "me/app"]); + expect(github.requests).toHaveLength(3); + }); +});