Files
kandev/apps/web/lib/api/domains/github-api.ts
T
João Salavisa b8a09bc5ea fix(github): pick allowed merge method so squash-only repos stop 405ing (#999)
* fix(github): pick allowed merge method so squash-only repos stop 405ing

The merge button sent an empty merge_method to GitHub, which defaults to
"merge" regardless of repo settings — squash-only / rebase-only repos
returned 405 "Merge commits are not allowed on this repository". The
backend now resolves an empty method to the first allowed one via a new
GetRepoMergeMethods endpoint (5-min cache), and the frontend renders a
GitHub-style split button so users can pick among the allowed methods.

* fix(github): address PR #999 review feedback

- pr-merge-button: extract repo-methods fetch to useRepoMergeMethods
  hook (Claude bot); hide button while methods are loading so the
  primary label doesn't flicker (Claude bot); drop e.preventDefault on
  dropdown items so Radix closes the menu on selection (Greptile P1).
- gh_client / pat_client: treat missing allow_* fields as false instead
  of true so a permission-gated response can't pick a disallowed merge
  method (cubic P2).
- service.MergePR: only overwrite mergeMethod when pickDefaultMergeMethod
  returns non-empty, so a degenerate all-false config falls through to
  GitHub's default with the real error (Claude bot).

* fix(github): don't hide merge button on lookup failure

Round-2 PR feedback (Claude, Greptile, CodeRabbit, cubic): the
`if (!methods) return null` guard added in the previous round to
avoid label flicker turned a transient repo-methods lookup failure
into a permanent UI lockout — the button would stay hidden until
unmount/remount.

- pr-merge-button: revert the null-guard; treat null as "use backend
  resolver", render the plain primary button without a dropdown.
- use-repo-merge-methods: add a 5-min frontend TTL to match the
  backend cache so admin merge-strategy flips are visible without a
  page reload. Catch block now triggers a rerender, and co-waiting
  consumers attach both fulfilled/rejected handlers to avoid silent
  rejection swallowing.

* test(github): cover useRepoMergeMethods + silence rejection warning

Round-3 PR feedback (Claude):

- Swallow rejections in the originating fetch chain instead of
  rethrowing, so the first consumer's promise never escapes the hook
  as an unhandled rejection. Coalesced consumers already re-render to
  null, which is the same state a fresh "not loaded" call would show.
- Add use-repo-merge-methods.test.ts covering: null-on-missing-params,
  loading then resolution, cache hit on remount, concurrent-fetch
  coalescing, retry-on-failure (no cache poisoning, no unhandled
  rejection), and TTL-expiry refetch via fake timers.

* test(github): cover httpGetRepoMergeMethods error branches

Round-4 PR feedback (Claude): the new endpoint had only happy-path
coverage. Add tests matching httpMergePR's existing shape:

- nil client → 503 (ErrNoClient)
- GitHubAPIError{404} → 404
- generic upstream error → 500

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:22:31 +01:00

419 lines
13 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,
GitHubActionPresets,
UpdateGitHubActionPresetsRequest,
CleanupTasksResponse,
MergeMethod,
RepoMergeMethods,
} from "@/lib/types/github";
// Status
export async function fetchGitHubStatus(options?: ApiRequestOptions) {
return fetchJson<GitHubStatusResponse>("/api/v1/github/status", options);
}
// Token configuration
export async function configureGitHubToken(token: string) {
return fetchJson<{ configured: boolean }>("/api/v1/github/token", {
init: {
method: "POST",
body: JSON.stringify({ token }),
},
});
}
export async function clearGitHubToken() {
return fetchJson<{ cleared: boolean }>("/api/v1/github/token", {
init: { method: "DELETE" },
});
}
// 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);
}
export async function createTaskPR(
data: { task_id: string; repository_id?: string; pr_url: string },
options?: ApiRequestOptions,
) {
return fetchJson<TaskPR>(`/api/v1/github/task-prs`, {
...options,
init: {
...(options?.init ?? {}),
method: "POST",
body: JSON.stringify(data),
},
});
}
// 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 ?? "" }),
},
},
);
}
// Merge a pull request. Omit mergeMethod to let the backend pick the first
// method the repo allows (avoids GitHub's "default to merge commit" 405 on
// squash-only / rebase-only repos).
export async function mergePR(
owner: string,
repo: string,
number: number,
mergeMethod?: MergeMethod,
) {
return fetchJson<{ merged: boolean }>(`/api/v1/github/prs/${owner}/${repo}/${number}/merge`, {
init: {
method: "PUT",
body: JSON.stringify({ merge_method: mergeMethod ?? "" }),
},
});
}
// Fetch the merge methods a repository allows (allow_merge_commit /
// allow_squash_merge / allow_rebase_merge). Used by the merge button to
// hide disallowed options and avoid 405s.
export async function getRepoMergeMethods(
owner: string,
repo: string,
options?: ApiRequestOptions,
) {
return fetchJson<RepoMergeMethods>(
`/api/v1/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/merge-methods`,
options,
);
}
// 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
// Omit workspaceId to fetch every watch across all workspaces.
export async function listReviewWatches(workspaceId?: string, options?: ApiRequestOptions) {
const path = workspaceId
? `/api/v1/github/watches/review?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/v1/github/watches/review`;
return fetchJson<ReviewWatchesResponse>(path, 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
// Omit workspaceId to fetch every watch across all workspaces.
export async function listIssueWatches(workspaceId?: string, options?: ApiRequestOptions) {
const path = workspaceId
? `/api/v1/github/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}`
: `/api/v1/github/watches/issue`;
return fetchJson<IssueWatchesResponse>(path, 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 ?? {}) },
},
);
}
// Manual cleanup sweeps. The poller runs these every 5min per watch, but a
// user with a pile of legacy merged-PR tasks (created before the cleanup
// policy was in place) can invoke them on demand from the settings page.
export async function cleanupMergedReviewTasks(options?: ApiRequestOptions) {
return fetchJson<CleanupTasksResponse>("/api/v1/github/cleanup/review-tasks", {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function cleanupClosedIssueTasks(options?: ApiRequestOptions) {
return fetchJson<CleanupTasksResponse>("/api/v1/github/cleanup/issue-tasks", {
...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,
);
}
// Action presets (quick-launch prompts on the /github page).
export async function fetchGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<GitHubActionPresets>(
`/api/v1/github/action-presets?${query.toString()}`,
options,
);
}
export async function updateGitHubActionPresets(
payload: UpdateGitHubActionPresetsRequest,
options?: ApiRequestOptions,
) {
return fetchJson<GitHubActionPresets>("/api/v1/github/action-presets", {
...options,
init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(payload) },
});
}
export async function resetGitHubActionPresets(workspaceId: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ workspace_id: workspaceId });
return fetchJson<GitHubActionPresets>(`/api/v1/github/action-presets/reset?${query.toString()}`, {
...options,
init: { ...(options?.init ?? {}), method: "POST" },
});
}