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 ? ( + + ) : null} + + {unlinked.length > 0 ? ( + + ) : 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); + }); +});