85aa3ac0aa
* feat(github): add /github page for PRs and issues Dedicated page under /github lists pull requests and issues across the authenticated user's repos with preset filters, custom GitHub search queries, per-repo narrowing, saved queries, and one-click task launching for any PR or issue. Performance: - Cache user PR/issue search responses for 30s with singleflight to coalesce concurrent misses, so preset switching and pagination avoid upstream round trips. - Replace the per-row PR status fetch (N+1) with a batch endpoint POST /api/v1/github/prs/statuses that fans out concurrently on the backend with a semaphore and caches per-PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback on PR cache + status batching - Harden searchCacheKey against separator collisions from user-controllable customQuery (length-prefix encoding). - Drop repositories.loaded hydration when inner workspace fetch fails so the client refetches instead of trusting a partial snapshot. - Guard useGitHubSearch with a monotonic request counter to drop stale responses when filter+page changes overlap. - Fix usePRStatuses teardown: depend only on composed key so a new array identity per render doesn't cancel the in-flight batch. - Correct godocs (duplicate, outdated state:open claim). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address additional review feedback - Restore state:open for issue watcher: buildIssueSearchQuery no longer injects it as a default, so existing watches were starting to surface closed issues. Inject state:open in buildIssueFilter so only the watcher path is affected; the /github page presets already set their own state qualifiers. - countCheckResults now excludes in-progress checks from the denominator so a `1/3` badge reflects pass rate on completed checks, not a mix of running and failed. The pending state is still carried via ChecksState. - usePRStatuses retries after a transient fetch error (only mark the key as completed once the fetch resolves). This also fixes missing badges under React Strict Mode's intentional double-mount, since the first effect's cleanup fires before the response lands and the guard stays unset for the second mount to retry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(github): add tests for my-github hooks Covers use-committed-query, buildParams from use-github-search, use-saved-presets readStorage validation, use-known-repos recordForKey accumulator semantics, and use-pr-statuses batch fetch lifecycle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback on search cache and repo accumulator - Clamp perPage before composing the search cache key so identical pages (e.g. perPage=150 and =100) share a cache entry instead of creating duplicates keyed on the raw value. - Add resetKnownReposStore and call it on /github unmount so the module-level repo accumulator doesn't carry across navigations. - Rename use-pr-statuses test to match its assertion (it verifies stale statuses are cleared when the key changes and fetch fails). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): distinguish blocked from dirty in PR mergeable badge GitHub's blocked state (required reviews/checks/branch protection) is not a merge conflict, so don't label it "Conflicts". Surface it as "Blocked" with the pending tone; keep "Conflicts" for dirty. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address remaining /github code review comments - Route PR status + batch through handleSearchError so ErrNoClient surfaces as 503 instead of 500 (matches the search endpoints). - Sort workflow steps by position in the quick-task-launcher default pick, matching the convention in issue-watch-dialog. - Make PresetItem keyboard-accessible: role=button, tabIndex, and Enter/Space handling on the wrapping div (it contains a nested delete button, so it can't itself be a button). - Extract useRepoOptions out of useGitHubPageState to bring the hook back under the 100-line complexity limit. - Honor ctx.Done() when acquiring the PR-status batch semaphore so a cancelled HTTP request releases queued goroutines instead of running each upstream fetch to completion. - Clarify parsePaginationQuery doc: the upper bound (100) is enforced by clampSearchPage in the client layer, not here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): address /github review follow-ups - Merge duplicate @/lib/utils imports in pr-list. - Use rel="noopener noreferrer" on external PR/issue links (codebase convention) and add cursor-pointer per the UI guidelines. - Rewrite onOpenSaveDialog as an explicit if-block so the callback returns void rather than boolean | void. - Use PaginationEllipsis in its own PaginationItem so the ellipsis and the adjacent page link are separate list items instead of visually fused into a single cell. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(web): derive QuickTaskLauncher dialog state from props Replaces the useEffect+setState anti-pattern with useMemo-derived state driven directly by the payload prop. Drops the eslint suppression that was needed for react-hooks/set-state-in-effect. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
9.5 KiB
TypeScript
309 lines
9.5 KiB
TypeScript
import { fetchJson, type ApiRequestOptions } from "../client";
|
|
import type {
|
|
GitHubStatusResponse,
|
|
GitHubOrg,
|
|
GitHubRepoInfo,
|
|
GitHubPR,
|
|
TaskPRsResponse,
|
|
TaskPR,
|
|
PRFeedback,
|
|
PRWatchesResponse,
|
|
ReviewWatch,
|
|
ReviewWatchesResponse,
|
|
CreateReviewWatchRequest,
|
|
UpdateReviewWatchRequest,
|
|
TriggerReviewResponse,
|
|
PRStatsResponse,
|
|
IssueWatch,
|
|
IssueWatchesResponse,
|
|
CreateIssueWatchRequest,
|
|
UpdateIssueWatchRequest,
|
|
TriggerIssueResponse,
|
|
SearchPRsResponse,
|
|
SearchIssuesResponse,
|
|
GitHubPRStatus,
|
|
} from "@/lib/types/github";
|
|
|
|
// Status
|
|
export async function fetchGitHubStatus(options?: ApiRequestOptions) {
|
|
return fetchJson<GitHubStatusResponse>("/api/v1/github/status", options);
|
|
}
|
|
|
|
// Task PR associations
|
|
export async function listTaskPRs(taskIds: string[], options?: ApiRequestOptions) {
|
|
const query = new URLSearchParams();
|
|
query.set("task_ids", taskIds.join(","));
|
|
return fetchJson<TaskPRsResponse>(`/api/v1/github/task-prs?${query.toString()}`, options);
|
|
}
|
|
|
|
export async function listWorkspaceTaskPRs(workspaceId: string, options?: ApiRequestOptions) {
|
|
return fetchJson<TaskPRsResponse>(
|
|
`/api/v1/github/task-prs?workspace_id=${encodeURIComponent(workspaceId)}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function getTaskPR(taskId: string, options?: ApiRequestOptions) {
|
|
return fetchJson<TaskPR>(`/api/v1/github/task-prs/${taskId}`, options);
|
|
}
|
|
|
|
// PR feedback (live from GitHub)
|
|
export async function getPRFeedback(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<PRFeedback>(`/api/v1/github/prs/${owner}/${repo}/${number}`, options);
|
|
}
|
|
|
|
// Lightweight PR status (review + checks + mergeable), skips comments.
|
|
export async function getPRStatus(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<GitHubPRStatus>(`/api/v1/github/prs/${owner}/${repo}/${number}/status`, options);
|
|
}
|
|
|
|
export type PRStatusRef = { owner: string; repo: string; number: number };
|
|
|
|
// Batch variant of getPRStatus: one round-trip for a whole list page. The
|
|
// backend fans out concurrently and caches per-PR, so repeat calls for the
|
|
// same page are cheap. Keys in the returned map are "<owner>/<repo>#<number>".
|
|
export async function getPRStatusesBatch(refs: PRStatusRef[], options?: ApiRequestOptions) {
|
|
return fetchJson<{ statuses: Record<string, GitHubPRStatus> }>(`/api/v1/github/prs/statuses`, {
|
|
...options,
|
|
init: {
|
|
method: "POST",
|
|
body: JSON.stringify({ refs }),
|
|
...(options?.init ?? {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Submit PR review
|
|
export async function submitPRReview(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
event: "APPROVE" | "COMMENT" | "REQUEST_CHANGES",
|
|
body?: string,
|
|
) {
|
|
return fetchJson<{ submitted: boolean }>(
|
|
`/api/v1/github/prs/${owner}/${repo}/${number}/reviews`,
|
|
{
|
|
init: {
|
|
method: "POST",
|
|
body: JSON.stringify({ event, body: body ?? "" }),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// PR watches
|
|
export async function listPRWatches(options?: ApiRequestOptions) {
|
|
return fetchJson<PRWatchesResponse>("/api/v1/github/watches/pr", options);
|
|
}
|
|
|
|
export async function deletePRWatch(id: string, options?: ApiRequestOptions) {
|
|
return fetchJson<{ success: boolean }>(`/api/v1/github/watches/pr/${id}`, {
|
|
...options,
|
|
init: { method: "DELETE", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
// Review watches
|
|
export async function listReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
|
|
const query = new URLSearchParams({ workspace_id: workspaceId });
|
|
return fetchJson<ReviewWatchesResponse>(
|
|
`/api/v1/github/watches/review?${query.toString()}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function createReviewWatch(
|
|
payload: CreateReviewWatchRequest,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<ReviewWatch>("/api/v1/github/watches/review", {
|
|
...options,
|
|
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function updateReviewWatch(
|
|
id: string,
|
|
payload: UpdateReviewWatchRequest,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<ReviewWatch>(`/api/v1/github/watches/review/${id}`, {
|
|
...options,
|
|
init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function deleteReviewWatch(id: string, options?: ApiRequestOptions) {
|
|
return fetchJson<{ success: boolean }>(`/api/v1/github/watches/review/${id}`, {
|
|
...options,
|
|
init: { method: "DELETE", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function triggerReviewWatch(id: string, options?: ApiRequestOptions) {
|
|
return fetchJson<TriggerReviewResponse>(`/api/v1/github/watches/review/${id}/trigger`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function triggerAllReviewWatches(workspaceId: string, options?: ApiRequestOptions) {
|
|
const query = new URLSearchParams({ workspace_id: workspaceId });
|
|
return fetchJson<TriggerReviewResponse>(
|
|
`/api/v1/github/watches/review/trigger-all?${query.toString()}`,
|
|
{
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
},
|
|
);
|
|
}
|
|
|
|
// Orgs & repo search
|
|
export async function listUserOrgs(options?: ApiRequestOptions) {
|
|
return fetchJson<{ orgs: GitHubOrg[] }>("/api/v1/github/orgs", options);
|
|
}
|
|
|
|
export async function searchOrgRepos(org: string, query?: string, options?: ApiRequestOptions) {
|
|
const params = new URLSearchParams({ org });
|
|
if (query) params.set("q", query);
|
|
return fetchJson<{ repos: GitHubRepoInfo[] }>(
|
|
`/api/v1/github/repos/search?${params.toString()}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
// PR info (lightweight)
|
|
export async function fetchPRInfo(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<GitHubPR>(
|
|
`/api/v1/github/prs/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/info`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
// Remote repo branches
|
|
export async function fetchRepoBranches(owner: string, repo: string, options?: ApiRequestOptions) {
|
|
return fetchJson<{ branches: { name: string }[] }>(
|
|
`/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
// Stats
|
|
export async function fetchGitHubStats(
|
|
params?: { workspace_id?: string; start_date?: string; end_date?: string },
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
const query = new URLSearchParams();
|
|
if (params?.workspace_id) query.set("workspace_id", params.workspace_id);
|
|
if (params?.start_date) query.set("start_date", params.start_date);
|
|
if (params?.end_date) query.set("end_date", params.end_date);
|
|
const suffix = query.toString();
|
|
return fetchJson<PRStatsResponse>(`/api/v1/github/stats${suffix ? `?${suffix}` : ""}`, options);
|
|
}
|
|
|
|
// Issue watches
|
|
export async function listIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
|
|
const query = new URLSearchParams({ workspace_id: workspaceId });
|
|
return fetchJson<IssueWatchesResponse>(
|
|
`/api/v1/github/watches/issue?${query.toString()}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function createIssueWatch(
|
|
payload: CreateIssueWatchRequest,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<IssueWatch>("/api/v1/github/watches/issue", {
|
|
...options,
|
|
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function updateIssueWatch(
|
|
id: string,
|
|
payload: UpdateIssueWatchRequest,
|
|
options?: ApiRequestOptions,
|
|
) {
|
|
return fetchJson<IssueWatch>(`/api/v1/github/watches/issue/${id}`, {
|
|
...options,
|
|
init: { method: "PUT", body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function deleteIssueWatch(id: string, options?: ApiRequestOptions) {
|
|
return fetchJson<{ deleted: boolean }>(`/api/v1/github/watches/issue/${id}`, {
|
|
...options,
|
|
init: { method: "DELETE", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function triggerIssueWatch(id: string, options?: ApiRequestOptions) {
|
|
return fetchJson<TriggerIssueResponse>(`/api/v1/github/watches/issue/${id}/trigger`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export async function triggerAllIssueWatches(workspaceId: string, options?: ApiRequestOptions) {
|
|
const query = new URLSearchParams({ workspace_id: workspaceId });
|
|
return fetchJson<TriggerIssueResponse>(
|
|
`/api/v1/github/watches/issue/trigger-all?${query.toString()}`,
|
|
{
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
},
|
|
);
|
|
}
|
|
|
|
// User PR / issue search (for the /github page).
|
|
// Pass `query` to use a verbatim GitHub search string, or `filter` to append to
|
|
// the default (type:pr state:open / type:issue state:open).
|
|
type SearchParams = {
|
|
query?: string;
|
|
filter?: string;
|
|
page?: number;
|
|
perPage?: number;
|
|
};
|
|
|
|
function buildSearchQuery(params: SearchParams) {
|
|
const search = new URLSearchParams();
|
|
if (params.query) search.set("query", params.query);
|
|
if (params.filter) search.set("filter", params.filter);
|
|
if (params.page && params.page > 1) search.set("page", String(params.page));
|
|
if (params.perPage) search.set("per_page", String(params.perPage));
|
|
return search.toString();
|
|
}
|
|
|
|
export async function searchUserPRs(params: SearchParams, options?: ApiRequestOptions) {
|
|
const suffix = buildSearchQuery(params);
|
|
return fetchJson<SearchPRsResponse>(
|
|
`/api/v1/github/user/prs${suffix ? `?${suffix}` : ""}`,
|
|
options,
|
|
);
|
|
}
|
|
|
|
export async function searchUserIssues(params: SearchParams, options?: ApiRequestOptions) {
|
|
const suffix = buildSearchQuery(params);
|
|
return fetchJson<SearchIssuesResponse>(
|
|
`/api/v1/github/user/issues${suffix ? `?${suffix}` : ""}`,
|
|
options,
|
|
);
|
|
}
|