lib/agent-api.ts
Rama main · réplica de lectura del repositorio original.
"use client";
import type { IssuePr } from "@/lib/issue-pr";
import { fetchClientRead } from "./client-read";
import type { RepoProviderId } from "@/lib/repo-providers";
import type { ReviewThreadState } from "@/lib/pr-review-threads";
import type { PrTimelineEvent } from "@/lib/pr-timeline";
import type { CommitAuthor } from "@/lib/commit-authors";
import type {
ReviewCommentReaction,
ReviewReactionContent,
} from "@/lib/pr-review-reactions";
import { trackEvent } from "./analytics";
import type { PullRequestReadiness } from "./pr-readiness";
import type { IssueStatus } from "./issue-constants";
/**
* Pull request client fetchers (MIN-138, MIN-143): the PR list, its detail,
* review gestures, comments and the issue ↔ PR link.
*/
/**
* API error which keeps the `code` business of the route (e.g. `lineNotInDiff`) in
* more of the message: some callers must distinguish the case, not only
* display it. One `Error` remains — callers who only read `.message` are
* unchanged.
*/
export class ApiError extends Error {
code?: string;
status?: number;
retryAt?: number;
/**
* Structured detail that the raw message does not carry — today the
* `modelLimit` of a refusal `modelAbovePlan` (model, multiplier, ceiling,
* plan), which the screen needs to tell which one and how far.
*/
details?: Record<string, unknown>;
constructor(
message: string,
code?: string,
details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
this.code = code;
this.details = details;
}
}
function retryAfterDeadline(value: string | null): number | undefined {
if (!value) return undefined;
const seconds = Number(value);
const deadline = Number.isFinite(seconds) ? Date.now() + Math.max(0, seconds) * 1000 : Date.parse(value);
return Number.isFinite(deadline) ? deadline : undefined;
}
async function parseJson<T>(response: Response): Promise<T> {
const text = await response.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
const payload = data as {
error?: string;
code?: string;
modelLimit?: Record<string, unknown>;
} | null;
const message = payload?.error || text.trim() || "Request failed";
const error = new ApiError(message, payload?.code, payload?.modelLimit);
error.status = response.status;
error.retryAt = retryAfterDeadline(response.headers.get("retry-after"));
throw error;
}
return data as T;
}
// The issue's pull request lives in a pure module, so the card face can read
// it without this API client.
export { isPrWorthShowing, type IssuePr } from "@/lib/issue-pr";
export async function fetchIssuePullRequestApi(
issueId: string,
): Promise<{ pullRequest: IssuePr | null }> {
return parseJson(await fetchClientRead(`/api/issues/${issueId}/pull-request`));
}
export interface PullRequestRef {
number: number;
url: string;
state: string;
draft?: boolean;
merged?: boolean;
title?: string;
body?: string | null;
head?: string;
/** Provider-qualified head name used by the default merge commit title. */
headLabel?: string;
/** `true` only when the forge confirms that the head branch lives in the base repository. */
headFromBaseRepository?: boolean;
base?: string;
/** Head SHA — the EXACT diff served. Compared to the SHA that was reread by the last
* Numo review to see if relaunch would have anything new to read. */
headSha?: string;
/** How many commits the PR carries, according to the forge. GitHub itself counts it;
GitLab does not serve it and leaves the field absent (the caller then falls back to
the length of the commit list, as long as it is not truncated). */
commitCount?: number;
/** Author and opening date: `body` opens the conversation thread as a comment. */
user?: { login: string; avatar_url: string | null } | null;
createdAt?: string;
/** Last forge edit of the body — the "(edited)" marker reads it. */
updatedAt?: string;
/** `null`/absent = UNKNOWN fusionability (forges calculate it using
asynchronous), never display as “blocked” — MIN-138. */
mergeable?: boolean | null;
/** `clean` · `blocked` (repository requires approvals/checks) · `dirty` (conflict)
· `unstable` (checks not required in failure) · `unknown`. */
mergeableState?: string | null;
/** Provider refusal normalized to one actionable reason. */
mergeabilityReason?: import("./pr-readiness").MergeabilityReason | null;
mergeFlowActive?: boolean;
reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
/** Direct reviewer requests that are still pending at the forge. */
requestedReviewers?: Array<{ login: string; avatar_url: string | null }>;
/** Fully rendered GitLab defaults for the merge dialog. */
defaultMergeCommitMessage?: string | null;
defaultSquashCommitMessage?: string | null;
}
/** Normalized status of a CI check (MIN-138) — mirror of `lib/server/agent/checks-core.ts`. */
export type CheckState = "pending" | "success" | "failure" | "neutral";
export interface PullRequestCheck {
name: string;
state: CheckState;
/** Pending but not started yet (waiting for a runner or a resource). */
queued?: boolean;
url: string | null;
/** The integration that produced the check (“GitHub Actions”, “Vercel”…),
when the check name does not already have it. */
appName: string | null;
/** Its logo at the forge. `null` → the UI falls back to the forge icon. */
appAvatarUrl: string | null;
/** The result in one line, as the forge formulates it. */
description: string | null;
durationMs: number | null;
startedAt: string | null;
completedAt: string | null;
required: boolean | null;
rerunRef: { kind: "github_check_suite" | "gitlab_pipeline"; id: number } | null;
}
export interface ChecksSummary {
checks: PullRequestCheck[];
/** Stable branch or pull-request preview advertised by a successful check. */
deploymentUrl?: string | null;
/** Aggregate: failure wins, then “in progress”. `null` = no check. */
state: CheckState | null;
/** Non-blocking checks (successful or neutral) — the `n` of “n/m passed”. */
passing: number;
total: number;
startedAt: string | null;
completedAt: string | null;
}
/** Merge methods offered by the forge of the linked repository. */
export type MergeMethod = "merge" | "squash" | "rebase";
export interface PullRequestReviewSummary {
approvals: number;
changesRequested: number;
requiredApprovals?: number | null;
}
/**
* Response from the GET of the PR of a run. Two nuances of “no checks”:
* `checks: null` = on n'a pas pu lire (`checksError` dit pourquoi — `forbidden`
* = GitHub permission that the installation did not accept), `checks.total === 0`
* = the repository has no CI. Same thing for `reviews: null`.
*/
/**
* What the current user can do on this PR (MIN-144). On GitHub
* like on GitLab, a human gesture starts from HIS Git account: without account
* connected, or without rights to the deposit, minddy says it instead of offering a button
* who would lie about the author.
*/
export interface PrViewer {
provider: RepoProviderId;
/** Does the provider have the means to authorize an account (self-host without env)? */
configured: boolean;
connected: boolean;
/** An account is connected but the forge REFUSES its token: to be reauthorized.
Absent on a tab that remained open since a version before this field. */
expired?: boolean;
login: string | null;
/** Avatar of the connected forge account that will author human actions. */
avatarUrl?: string | null;
/** `write` = merge/resolve, `read` = reviewer/comment, `none` = nothing. */
capability: "write" | "read" | "none";
/** The account under which Numo writes at the forge – enough to recognize SES
messages in the thread (MIN-162). `null` on the GitLab side, where nothing
distinguishes (MIN-146), and on a tab that has remained open since a version
before this field. */
numoLogin?: string | null;
/** The viewer only reads the linking project (Reader role, MIN-693): every
PR action is refused whatever the forge account may do. Absent on older
responses. */
projectReadOnly?: boolean;
}
export interface AgentRunPrResponse {
pr: PullRequestRef | null;
files: PullRequestFile[];
provider?: RepoProviderId;
checks?: ChecksSummary | null;
checksError?: "forbidden" | "unknown" | null;
/** The preview a check advertises; the deployment card comes from
`fetchPullRequestDeploymentApi`. */
deploymentUrl?: string | null;
reviews?: PullRequestReviewSummary | null;
viewer?: PrViewer;
mergeMethods?: MergeMethod[];
mergePolicy?: import("./pr-readiness").RepositoryMergePolicy | null;
readiness?: import("./pr-readiness").PullRequestReadiness | null;
reviewThreads?: import("./pr-review-threads").ReviewThreadState[] | null;
}
export interface PullRequestFile {
filename: string;
status: string;
additions: number;
deletions: number;
patch?: string;
/** Path BEFORE the PR if the file has been renamed — it addresses the base version. */
previous_filename?: string;
}
export async function fetchPullRequestApi(
prId: string,
signal?: AbortSignal,
): Promise<AgentRunPrResponse> {
return parseJson(await fetchClientRead(`/api/pull-requests/${prId}`, { signal, cache: "no-store" }));
}
/** The environment built from a pull request's live head (MIN-889). */
export interface PullRequestDeploymentResponse {
/** The head the deployment was resolved for. */
headSha: string | null;
deploymentUrl: string | null;
/** Lifecycle of the head environment as the forge reports it. */
deploymentStatus: "success" | "in_progress" | null;
/** Created date of the deployment in flight — the card ticks from it. */
deploymentStartedAt: string | null;
/** Time the environment took to settle, when the forge dates it. */
deploymentDurationMs: number | null;
}
/**
* Read apart from the detail: resolving it walks the forge's deployments one
* reference at a time, which used to hold the whole detail back.
*/
export async function fetchPullRequestDeploymentApi(
prId: string,
signal?: AbortSignal,
): Promise<PullRequestDeploymentResponse> {
return parseJson(await fetchClientRead(`/api/pull-requests/${prId}/deployment`, { signal, cache: "no-store" }));
}
/**
* What a board needs to offer the merge gestures of a row (auto-merge state,
* whether the repository allows it, whether the viewer may write). The server
* gates each gesture again when it runs.
*/
export interface PullRequestMergeFlow {
/** Auto-merge or a merge queue entry is set on the pull request. */
active: boolean;
autoMergeAllowed: boolean | null;
canWrite: boolean;
}
export interface PullRequestReadinessBatchResponse {
readiness: Record<string, PullRequestReadiness>;
/** Absent from responses of a server older than this field. */
mergeFlow?: Record<string, PullRequestMergeFlow>;
unavailablePrIds: string[];
retryStatus?: 403 | 429;
/** Client-anchored transport deadline, never content freshness or write authority. */
retryAt?: number;
}
export async function fetchPullRequestReadinessBatchApi(
prIds: readonly string[],
): Promise<PullRequestReadinessBatchResponse> {
const params = new URLSearchParams();
for (const prId of prIds) params.append("pr", prId);
const response = await fetchClientRead(`/api/pull-requests/readiness?${params}`);
const data = await parseJson<PullRequestReadinessBatchResponse>(response);
return data.retryStatus === 403 || data.retryStatus === 429
? { ...data, retryAt: retryAfterDeadline(response.headers.get("retry-after")) ?? Date.now() + 60_000 }
: data;
}
/** Basis of routes for a pull request (MIN-143), indexed by the PR. */
export type PrEndpoint = string;
export function prEndpoint(prId: string): PrEndpoint {
return `/api/pull-requests/${prId}`;
}
/**
* Base version of a PR file (plain text at merge base) — the source of the
* context unfolding in diff view. `path` = base side path.
*/
export async function fetchPrFileSourceApi(
endpoint: PrEndpoint,
path: string,
): Promise<{ content: string }> {
return parseJson(
await fetchClientRead(`${endpoint}/file?path=${encodeURIComponent(path)}`),
);
}
/**
* Byte proxy URL of a diff file — the source of the view's `<img>`
* side by side images (MIN-66). `path` = the path as the diff names it
* (the server itself deduces the base version of a renamed file).
*/
export function prFileRawUrl(
endpoint: PrEndpoint,
path: string,
side: "base" | "head",
): string {
return `${endpoint}/file/raw?path=${encodeURIComponent(path)}&side=${side}`;
}
export async function actOnPullRequestApi(
prId: string,
action: "merge" | "close" | "reopen" | "ready_for_review" | "convert_to_draft",
options: {
method?: MergeMethod;
commitTitle?: string;
commitMessage?: string;
} = {},
): Promise<{ ok: true; pr_state: PullRequestListItem["pr_state"] }> {
trackEvent("pr_review_submitted", { verdict: action });
return parseJson(
await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...options }),
}),
);
}
/**
* "Generate then merge" (MIN-548): the generation runs in the background
* and the merge fires the moment it lands. Optimistic by design — the
* answer only says the job started; the broadcast carries the outcome.
*/
export async function mergeWithNumoApi(
prId: string,
method: MergeMethod | null,
): Promise<{ ok: true; started: true }> {
return parseJson(
await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "merge_with_numo", method }),
}),
);
}
export async function maintainPullRequestApi(
prId: string,
action:
| "update_branch"
| "rerun_check"
| "update_title"
| "update_body"
| "enable_auto_merge"
| "disable_auto_merge",
payload: {
title?: string;
body?: string;
rerunRef?: PullRequestCheck["rerunRef"];
} = {},
): Promise<{ ok: true; title?: string }> {
return parseJson(
await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...payload }),
}),
);
}
/** Add an issue to a PR and synchronize its status with the current PR state. */
export async function linkPullRequestIssueApi(
prId: string,
issueId: string,
prState: string,
): Promise<{
ok: true;
issue: { id: string; number: number; title: string };
status: string | null;
}> {
trackEvent("pr_issue_linked", { pr_state: prState });
return parseJson(
await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "link_issue", issueId }),
}),
);
}
export async function unlinkPullRequestIssueApi(
prId: string,
issueId: string,
): Promise<{ ok: true }> {
return parseJson(await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unlink_issue", issueId }),
}));
}
/** Verdict of a review submitted from minddy (MIN-138). */
export type ReviewVerdict = "approve" | "request_changes" | "comment";
/**
* Submits a review on PR (MIN-138).
*
* `published: "comment"` in return = the forge refused to publish the verdict —
* An App cannot approve its own pull request. The verdict is recorded
* minddy side anyway; the caller must tell the user.
*/
export async function submitPullRequestReviewApi(
prId: string,
input: {
verdict: ReviewVerdict;
message: string;
},
): Promise<{
ok: true;
/** `comment` = the forge folded the verdict up, being unable to carry it
* (self-review). */
published: "review" | "comment";
}> {
trackEvent("pr_review_submitted", { verdict: input.verdict });
return parseJson(
await fetch(prEndpoint(prId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "review", ...input }),
}),
);
}
/** Ask a repository member to review the pull request. */
export async function requestPullRequestReviewerApi(prId: string, login: string): Promise<{ ok: true }> {
return parseJson(await fetch(`${prEndpoint(prId)}/review-requests`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ login }),
}));
}
// ── Global Pull Requests page (MIN-66, expanded by MIN-143) ─────────────────
/** List filtering state — served by the SERVER (`?state=`). */
export type PullRequestStateFilter = "open" | "merged" | "closed" | "completed" | "all";
export interface PullRequestListItem {
/** The item IS the PR. */
prId: string;
/** Authenticated viewer authored this PR at the forge. */
createdByMe?: boolean;
/** The viewer has a pending review request at the forge. */
reviewRequestedForMe?: boolean;
pr_number: number;
pr_url: string | null;
pr_state: "draft" | "open" | "merged" | "closed";
/** Linked Repository Provider — controls PR/MR vocabulary and links (MIN-69). */
provider: RepoProviderId;
title: string | null;
/** Who opened it at the forge. */
author: { login: string; avatar_url: string | null } | null;
head_branch: string | null;
created_at: string;
updated_at: string;
issue: { id: string; number: number; title: string } | null;
issues?: Array<{ id: string; number: number; title: string; project_id: string; project_key: string; status?: IssueStatus }>;
project: {
id: string;
key: string;
name: string;
icon_url: string | null;
orb_seed: string | null;
} | null;
}
export interface PullRequestComment {
id: number;
body: string;
user: { login: string; avatar_url: string | null } | null;
created_at: string;
/** Last edit AT THE FORGE — the "(edited)" marker reads `updated_at` vs
`created_at`. `null` when the forge never carried it. */
updated_at?: string | null;
html_url: string;
}
export interface PullRequestListResponse {
pullRequests: PullRequestListItem[];
/** There are more beyond `limit` — the “see more” button on the page. */
hasMore: boolean;
/** The pagination of a forge has been cut: the list is not exhaustive. */
truncated: boolean;
/** Linked deposits visible by this account, all projects combined. 0 = there is
* nothing to show here until a repository is linked somewhere. */
repoCount: number;
/** Is there ONE pull request, all states combined? An empty list on the
* “open” filter does not say the same thing as an account that does not have any
* never had — only the second deserves a home screen. */
anyPr: boolean;
}
/**
* `pin` PINS a PR in the answer even if it falls off the page — a
* deep-link to an old PR should not depend on the depth of the scrolling.
*/
export async function fetchAllPullRequestsApi(input: {
state: PullRequestStateFilter;
limit: number;
offset?: number;
pin?: { pr?: string | null };
}, signal?: AbortSignal): Promise<PullRequestListResponse> {
const params = new URLSearchParams({
state: input.state,
limit: String(input.limit),
});
if (input.offset != null) params.set("offset", String(input.offset));
if (input.pin?.pr) params.set("pr", input.pin.pr);
return parseJson(await fetchClientRead(`/api/pull-requests?${params}`, { signal }));
}
/** Lightweight open-PR count used by the persistent app shell. */
export async function fetchOpenPullRequestCountApi(): Promise<{ count: number }> {
return parseJson(await fetchClientRead("/api/pull-requests/count"));
}
/**
* The conversation thread: messages, PR ACTIVITY (MIN-159) and reactions,
* served together — it all goes into one thread ordered by date.
*
* `reactions` ALSO carries those of the body of the PR, under `PR_BODY_COMMENT_ID`:
* the message that opens the thread is reacted like the others.
*
* `timeline` is best-effort on the server side: an empty list means “the forge
* did not know how to give it” as much as “nothing happened”. The thread then makes
* his messages, as before.
*/
export async function fetchPullRequestCommentsApi(prId: string, signal?: AbortSignal): Promise<{
comments: PullRequestComment[];
timeline: PrTimelineEvent[];
reactions: ReviewCommentReaction[];
}> {
return parseJson(await fetchClientRead(`${prEndpoint(prId)}/comments`, { signal }));
}
/**
* A PR commit. TWO authors, and they do not say the same thing:
* `author` is the ACCOUNT of the forge (avatar + login), when it was able to attach
* the commit email; `authorName` is the name written IN the commit, which git has
* always. On the GitLab side, whose API does not serve any account on this endpoint, it
* There is only ever the second - hence the fallback of the rendering on it.
*/
export interface PullRequestCommit {
sha: string;
/** FULL message: first line = title, the rest = body. */
message: string;
author: { login: string; avatar_url: string | null } | null;
authorName: string | null;
/** Author email — the deduplication key with co-signers. */
authorEmail: string | null;
authoredAt: string | null;
url: string | null;
/** Signature verified. `null` = UNKNOWN (GitLab doesn't serve it), not "no". */
verified: boolean | null;
/** First parent — the "before" side of this commit's diff. */
parentSha: string | null;
/** Lines added/removed BY THIS COMMIT. `null` = the forge did not know the
give (GraphQL failed, or MR too long on the GitLab side): the indicator
+/− then disappears, but the commit diff remains open. */
additions: number | null;
deletions: number | null;
/**
* ALL its authors, principal at the head (MIN-159). A co-signed commit
* (`Co-authored-by:`) has several — the common case as soon as an agent has held
* the keyboard — and the screen stacks avatars, like the forge.
*
* OPTIONAL although the road always fills it: a tab remained open
* during a deployment keeps in cache the response of the previous version, which
* did not have this field. The guy says it, so that every reading keeps it —
* otherwise the entire page falls on a `.length` of `undefined`.
*/
authors?: CommitAuthor[];
}
/**
* The commits that make up the PR, from oldest to newest on the forge side —
* the Commits tab then displays them from newest to oldest.
* `truncated`: PR has more than Minddy lists at once.
*/
export async function fetchPullRequestCommitsApi(
prId: string,
signal?: AbortSignal,
): Promise<{ commits: PullRequestCommit[]; truncated: boolean }> {
return parseJson(await fetchClientRead(`${prEndpoint(prId)}/commits`, { signal }));
}
/** An account of the forge, as the commentary composer suggests. */
export interface RepoMember {
login: string;
avatar_url: string | null;
name: string | null;
}
/**
* The forge accounts mentionable on this PR (MIN-162). Never in
* server side error: at worst an empty list, and the composer does not offer any
* simply none.
*/
export async function fetchPullRequestMembersApi(
endpoint: PrEndpoint,
): Promise<{ members: RepoMember[] }> {
return parseJson(await fetchClientRead(`${endpoint}/members`));
}
/** Base of ONE commit routes: its diff, and — like a PR — its files. */
export function prCommitEndpoint(prId: string, sha: string): PrEndpoint {
return `${prEndpoint(prId)}/commits/${sha}`;
}
export interface PrCommitDiff {
files: PullRequestFile[];
additions: number;
deletions: number;
url: string | null;
parentSha: string | null;
message: string;
author: { login: string; avatar_url: string | null } | null;
authorName: string | null;
authoredAt: string | null;
provider?: RepoProviderId;
}
/**
* The diff of ONE PR commit, against its parent. Serves the view “what this
* commit change” — same files/patches as the PR diff, so the same
* rendering component, except that context unfolding resolves the problem there.
* PARENT du commit (cf. `prCommitEndpoint`).
*/
export async function fetchPrCommitDiffApi(
prId: string,
sha: string,
): Promise<PrCommitDiff> {
return parseJson(await fetchClientRead(prCommitEndpoint(prId, sha)));
}
/**
* Post or remove a reaction on a message in the conversation thread, or on the
* PR body (`commentId: PR_BODY_COMMENT_ID`). `on` has the DESIRED state, not
* a switch — same contract as on the review side.
*/
export async function setPrCommentReactionApi(
endpoint: PrEndpoint,
input: { commentId: number; content: ReviewReactionContent; on: boolean },
): Promise<{ ok: true; on: boolean }> {
return parseJson(
await fetch(`${endpoint}/comments/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment_id: input.commentId,
content: input.content,
on: input.on,
}),
}),
);
}
/**
* Review comment: anchored to a line in the diff, unlike
* `PullRequestComment` which lives in the flat thread of the conversation.
*
* `line` non-zero does NOT mean “the line is in the diff”: GitHub
* keeps as long as he knows how to find the line in his head, even if the diff has
* moved elsewhere. It is the resolution in the rendered hunks that decides
* l'affichage inline — voir `pr-diff`.
*/
export interface PullRequestReviewComment {
id: number;
body: string;
path: string;
line: number | null;
original_line: number | null;
side: "LEFT" | "RIGHT";
/** First line of a MULTI-LINE remark (MIN-181) — `line` is then
the last. `null` for a single-line remark, and always
`null` on the GitLab side, where a note is anchored on a line. */
start_line: number | null;
/** First line at the original commit, retained when GitHub marks the thread outdated. */
original_start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
/** Root of the thread, or null if this comment IS the root. */
in_reply_to_id: number | null;
/** Review that carries this comment (MIN-159) — the conversation thread ranges
the points of a review UNDER it. `null` on the GitLab side, without review purpose. */
review_id: number | null;
diff_hunk: string;
user: { login: string; avatar_url: string | null } | null;
created_at: string;
html_url: string;
}
/**
* Review comments AND condition of their sons (MIN-139), served together: one
* thread goes with both. `threads` can be empty while comments
* exist — the forge was unable to tell the state, and the UI then does not offer
* “Resolve” button rather than announcing “open” blindly.
*/
export async function fetchPrReviewCommentsApi(endpoint: PrEndpoint, signal?: AbortSignal): Promise<{
comments: PullRequestReviewComment[];
threads: ReviewThreadState[];
reactions: ReviewCommentReaction[];
}> {
return parseJson(await fetchClientRead(`${endpoint}/review-comments`, { signal }));
}
/**
* Post or remove a reaction to a review comment. `on` carries the state
* WANTED, not a switch: two competing clicks converge instead of
* cancels itself, and a resend after network failure does not undo what had succeeded.
*/
export async function setPrReviewCommentReactionApi(
endpoint: PrEndpoint,
input: { commentId: number; content: ReviewReactionContent; on: boolean },
): Promise<{ ok: true; on: boolean }> {
return parseJson(
await fetch(`${endpoint}/review-comments/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
comment_id: input.commentId,
content: input.content,
on: input.on,
}),
}),
);
}
/**
* Answers, from a ticket, a comment mirrored from its pull request: the answer
* is posted on the forge under the caller's git account (MIN-802).
*/
export async function postForgeReplyApi(
issueId: string,
commentId: string,
body: string,
): Promise<unknown> {
return parseJson(
await fetch(`/api/issues/${issueId}/comments/${commentId}/forge-reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
}),
);
}
/** Resolves/reopens a review thread (`threadId` comes from `threads` above). */
export async function setPrReviewThreadResolvedApi(
endpoint: PrEndpoint,
input: { threadId: string; resolved: boolean },
): Promise<{ ok: true; resolved: boolean }> {
return parseJson(
await fetch(`${endpoint}/review-comments`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId,
resolved: input.resolved,
}),
}),
);
}
/**
* Post a comment on a line in the diff — or on a RANGE of lines, in
* passing `startLine` (`line` is then the LAST line referred to, like GitHub
* waiting for it). Share immediately on GitHub. Raise a code `ApiError`
* `lineNotInDiff` if GitHub refuses to anchor the line.
*/
export async function postPrReviewCommentApi(
endpoint: PrEndpoint,
input: {
body: string;
path: string;
line: number;
side: "LEFT" | "RIGHT";
startLine?: number;
startSide?: "LEFT" | "RIGHT";
},
): Promise<{ comment: PullRequestReviewComment }> {
const { startLine, startSide, ...rest } = input;
return parseJson(
await fetch(`${endpoint}/review-comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...rest,
...(startLine != null
? { start_line: startLine, start_side: startSide }
: {}),
}),
}),
);
}
/** Reply in a review thread (`inReplyTo` = any thread id). */
export async function replyPrReviewCommentApi(
endpoint: PrEndpoint,
input: { body: string; inReplyTo: number },
): Promise<{ comment: PullRequestReviewComment }> {
return parseJson(
await fetch(`${endpoint}/review-comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: input.body, in_reply_to: input.inReplyTo }),
}),
);
}
/**
* Post a message in the PR thread.
*
* `review` accompanies the response when the message MENTIONED `@numo`
* (MIN-162): the pass is already open on the server side when it returns, and
* it is this field that allows the screen to show it RIGHT AWAY. Without him, he
* would only discover it at the next fortuitous refresh.
*/
export async function postPullRequestCommentApi(
prId: string,
body: string,
): Promise<{
comment: PullRequestComment;
review?: {
conversationId: string;
turnId: string;
} | null;
}> {
return parseJson(
await fetch(`${prEndpoint(prId)}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
}),
);
}
/**
* A PREVIOUS version of a thread comment — the snapshot taken when the
* message was edited (by minddy, or by its webhook echo when the forge sends
* the old body). Served oldest-first.
*/
export interface PullRequestCommentEdit {
body: string;
edited_by: string | null;
created_at: string;
}
/**
* Rewrites a thread comment on the forge, under the connected account.
* The server snapshots the PREVIOUS body first, so "previous versions" can
* list it afterwards.
*/
export async function updatePullRequestCommentApi(
endpoint: PrEndpoint,
input: { commentId: number; body: string },
): Promise<{ comment: PullRequestComment }> {
return parseJson(
await fetch(`${endpoint}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ commentId: input.commentId, body: input.body }),
}),
);
}
/** Previous versions of one comment, oldest-first. */
export async function fetchPullRequestCommentEditsApi(
endpoint: PrEndpoint,
commentId: number,
signal?: AbortSignal,
): Promise<{ edits: PullRequestCommentEdit[] }> {
return parseJson(
await fetchClientRead(`${endpoint}/comment-edits?commentId=${commentId}`, { signal }),
);
}