mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication. - List with search, quick filters and paging; a tab per pull request and a New Pull Request tab. - Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen. - Timeline editor: comments, review threads, reactions and suggested changes. - Review in the diff viewer and, for the checked-out branch, in the editor (review mode). - New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands. - CSP: img-src allows https://avatars.githubusercontent.com for avatars. Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane. Co-authored-by: imacross <30753807+imacross@users.noreply.github.com>
445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
/**
|
|
* Pure rules of the pull request data, ported from IntelliJ: the list search value and
|
|
* its query (`GHPRListSearchValue`, `GHPRSearchQuery`), reviewer states
|
|
* (`GHReviewersUtils`), the unread rule (`GHPRPersistentInteractionState`), the merge box
|
|
* state (`GHPRMergeabilityStateBuilder`), permissions (`GHPRSecurityService`) and the
|
|
* merge commit messages (`GHPRReviewFlowViewModelImpl`).
|
|
*/
|
|
import type {
|
|
CodeReviewCIJob,
|
|
CodeReviewCIJobState,
|
|
GHActor,
|
|
GHCommitCheckSuiteConclusion,
|
|
GHCommitStatusContextState,
|
|
GHPRMergeabilityState,
|
|
GHPRReviewerState,
|
|
GHPullRequest,
|
|
GHPullRequestMergeabilityData,
|
|
GHPullRequestMergeStateStatus,
|
|
GHPullRequestRequestedReviewer,
|
|
GHPullRequestReview,
|
|
GHPullRequestShort,
|
|
GHPullRequestState,
|
|
GHRepository,
|
|
GHRepositoryCoordinates,
|
|
GHRepositoryPermissionLevel,
|
|
GHTeam,
|
|
GHUser,
|
|
} from "../api/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List search (`GHPRListSearchValue`)
|
|
|
|
export type PrSearchState = "OPEN" | "CLOSED" | "MERGED";
|
|
export type PrSearchReviewState =
|
|
| "NO_REVIEW"
|
|
| "REQUIRED"
|
|
| "APPROVED"
|
|
| "CHANGES_REQUESTED"
|
|
| "REVIEWED_BY_ME"
|
|
| "NOT_REVIEWED_BY_ME"
|
|
| "AWAITING_REVIEW";
|
|
export type PrSearchSort =
|
|
| "NEWEST"
|
|
| "OLDEST"
|
|
| "MOST_COMMENTED"
|
|
| "LEAST_COMMENTED"
|
|
| "RECENTLY_UPDATED"
|
|
| "LEAST_RECENTLY_UPDATED";
|
|
|
|
/** The list filter: free text plus the chips (author / assignee are logins). Unset = absent. */
|
|
export interface PrSearchValue {
|
|
searchQuery?: string;
|
|
state?: PrSearchState;
|
|
assignee?: string;
|
|
reviewState?: PrSearchReviewState;
|
|
author?: string;
|
|
label?: string;
|
|
sort?: PrSearchSort;
|
|
}
|
|
|
|
/** `GHPRListSearchValue.DEFAULT`: the Quick Filters' "Open" (`EMPTY` is `{}`). */
|
|
export const DEFAULT_SEARCH: PrSearchValue = { state: "OPEN" };
|
|
|
|
const SEARCH_KEYS = ["searchQuery", "state", "assignee", "reviewState", "author", "label", "sort"] as const;
|
|
|
|
const STATE_TERMS: Record<PrSearchState, string> = { OPEN: "is:open", CLOSED: "is:closed", MERGED: "is:merged" };
|
|
const REVIEW_TERMS: Record<PrSearchReviewState, string> = {
|
|
NO_REVIEW: "review:none",
|
|
REQUIRED: "review:required",
|
|
APPROVED: "review:approved",
|
|
CHANGES_REQUESTED: "review:changes-requested",
|
|
REVIEWED_BY_ME: "reviewed-by:@me",
|
|
NOT_REVIEWED_BY_ME: "-reviewed-by:@me",
|
|
AWAITING_REVIEW: "review-requested:@me",
|
|
};
|
|
const SORT_TERMS: Record<PrSearchSort, string> = {
|
|
NEWEST: "sort:created-desc",
|
|
OLDEST: "sort:created-asc",
|
|
MOST_COMMENTED: "sort:comments-desc",
|
|
LEAST_COMMENTED: "sort:comments-asc",
|
|
RECENTLY_UPDATED: "sort:updated-desc",
|
|
LEAST_RECENTLY_UPDATED: "sort:updated-asc",
|
|
};
|
|
|
|
/** How many filters are set (the text counts as one), as the Quick Filters badge and "Clear {n} Filters". */
|
|
export function filterCount(v: PrSearchValue): number {
|
|
return SEARCH_KEYS.filter((k) => v[k] !== undefined).length;
|
|
}
|
|
|
|
export function sameSearch(a: PrSearchValue, b: PrSearchValue): boolean {
|
|
return SEARCH_KEYS.every((k) => a[k] === b[k]);
|
|
}
|
|
|
|
/** A search value with only known, non-empty fields (from storage or a patch). */
|
|
export function normalizeSearch(v: unknown): PrSearchValue | null {
|
|
if (!v || typeof v !== "object") return null;
|
|
const r = v as Record<string, unknown>;
|
|
const str = (x: unknown) => (typeof x === "string" && x !== "" ? x : undefined);
|
|
const oneOf = <T extends string>(x: unknown, all: Record<T, unknown>) =>
|
|
typeof x === "string" && x in all ? (x as T) : undefined;
|
|
const out: PrSearchValue = {
|
|
searchQuery: str(r.searchQuery),
|
|
state: oneOf(r.state, STATE_TERMS),
|
|
assignee: str(r.assignee),
|
|
reviewState: oneOf(r.reviewState, REVIEW_TERMS),
|
|
author: str(r.author),
|
|
label: str(r.label),
|
|
sort: oneOf(r.sort, SORT_TERMS),
|
|
};
|
|
for (const k of SEARCH_KEYS) if (out[k] === undefined) delete out[k];
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The filter's search terms (`GHPRListSearchValue.toQuery`), or null when nothing is set.
|
|
* The typed text is grouped in parentheses: GitHub's advanced search gives `OR` the lowest
|
|
* precedence, so `a OR b` would otherwise escape the repository and the other filters.
|
|
*/
|
|
export function searchTerms(v: PrSearchValue): string | null {
|
|
const terms: string[] = [];
|
|
const text = v.searchQuery?.trim();
|
|
if (text) terms.push(`(${text})`);
|
|
if (v.state) terms.push(STATE_TERMS[v.state]);
|
|
if (v.assignee !== undefined) terms.push(`assignee:${v.assignee}`);
|
|
if (v.reviewState) terms.push(REVIEW_TERMS[v.reviewState]);
|
|
if (v.author !== undefined) terms.push(`author:${v.author}`);
|
|
if (v.label !== undefined) terms.push(`label:"${v.label}"`);
|
|
if (v.sort) terms.push(SORT_TERMS[v.sort]);
|
|
return terms.length ? terms.join(" ") : null;
|
|
}
|
|
|
|
/** The `issueSearch` query of the list: `type:pr repo:owner/name` and the filter's terms. */
|
|
export function listSearchQuery(repo: GHRepositoryCoordinates, v: PrSearchValue): string {
|
|
const terms = searchTerms(v);
|
|
return [`type:pr repo:${repo.owner}/${repo.name}`, terms].filter(Boolean).join(" ");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reviewers (`GHReviewersUtils`)
|
|
|
|
export interface ReviewerWithState {
|
|
reviewer: GHPullRequestRequestedReviewer;
|
|
state: GHPRReviewerState;
|
|
}
|
|
|
|
export const isTeam = (r: GHPullRequestRequestedReviewer | GHActor | null | undefined): r is GHTeam =>
|
|
r?.__typename === "Team";
|
|
|
|
/** Login of a user / actor, `org/slug` of a team. */
|
|
export function reviewerKey(r: GHPullRequestRequestedReviewer): string {
|
|
return isTeam(r) ? r.combinedSlug : r.login;
|
|
}
|
|
|
|
/** A reviewer's effective state from their reviews, newest first; null for no submitted review. */
|
|
export function reviewerStateOf(reviews: readonly GHPullRequestReview[]): GHPRReviewerState | null {
|
|
let commented = false;
|
|
const newestFirst = [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
for (const review of newestFirst) {
|
|
switch (review.state) {
|
|
case "APPROVED":
|
|
return "APPROVED";
|
|
case "CHANGES_REQUESTED":
|
|
return "CHANGES_REQUESTED";
|
|
case "DISMISSED":
|
|
return "COMMENTED";
|
|
case "COMMENTED":
|
|
commented = true;
|
|
break;
|
|
case "PENDING":
|
|
break;
|
|
}
|
|
}
|
|
return commented ? "COMMENTED" : null;
|
|
}
|
|
|
|
/** Placeholder author of reviews whose user was deleted (IntelliJ's ghost user). */
|
|
export const GHOST_USER: GHUser = {
|
|
__typename: "User",
|
|
id: "",
|
|
login: "ghost",
|
|
url: "https://github.com/ghost",
|
|
avatarUrl: "https://avatars.githubusercontent.com/u/10137?v=4",
|
|
name: "Deleted user",
|
|
};
|
|
|
|
/**
|
|
* Every reviewer of `pr` with their state, as GitHub shows them: reviewers by their
|
|
* reviews (the author's own excluded), then the pending requests, which make a reviewer
|
|
* who already reviewed RE_REQUESTED and one who did not NEEDS_REVIEW.
|
|
*/
|
|
export function reviewersStates(
|
|
pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">,
|
|
): ReviewerWithState[] {
|
|
const result = new Map<string, ReviewerWithState>();
|
|
const byReviewer = new Map<string, { reviewer: GHPullRequestRequestedReviewer; reviews: GHPullRequestReview[] }>();
|
|
for (const review of pr.reviews.nodes) {
|
|
const reviewer = review.author ?? GHOST_USER;
|
|
const key = reviewerKey(reviewer);
|
|
const group = byReviewer.get(key) ?? { reviewer, reviews: [] };
|
|
group.reviews.push(review);
|
|
byReviewer.set(key, group);
|
|
}
|
|
const authorKey = pr.author ? pr.author.login : null;
|
|
for (const [key, { reviewer, reviews }] of byReviewer) {
|
|
if (key === authorKey) continue;
|
|
const state = reviewerStateOf(reviews);
|
|
if (state) result.set(key, { reviewer, state });
|
|
}
|
|
for (const { requestedReviewer } of pr.reviewRequests.nodes) {
|
|
if (!requestedReviewer) continue;
|
|
const key = reviewerKey(requestedReviewer);
|
|
result.set(key, { reviewer: requestedReviewer, state: result.has(key) ? "RE_REQUESTED" : "NEEDS_REVIEW" });
|
|
}
|
|
return [...result.values()];
|
|
}
|
|
|
|
const REVIEWER_ORDER: Record<GHPRReviewerState, number> = {
|
|
APPROVED: 0,
|
|
CHANGES_REQUESTED: 1,
|
|
COMMENTED: 2,
|
|
RE_REQUESTED: 3,
|
|
NEEDS_REVIEW: 4,
|
|
};
|
|
|
|
/** `reviewersStates` ordered like the list row's avatars: approved → changes requested → commented → re-requested → needs review. */
|
|
export function sortedReviewersStates(pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">) {
|
|
return reviewersStates(pr).sort((a, b) => REVIEWER_ORDER[a.state] - REVIEWER_ORDER[b.state]);
|
|
}
|
|
|
|
/** Review threads that are neither resolved nor outdated (the list row's comment counter). */
|
|
export function unresolvedThreadsCount(pr: Pick<GHPullRequestShort, "reviewThreads">): number {
|
|
return pr.reviewThreads.nodes.filter((t) => !t.isResolved && !t.isOutdated).length;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unread (`GHPRPersistentInteractionState`)
|
|
|
|
/** Registry `github.last.seen.state.margin.millis`. */
|
|
export const LAST_SEEN_MARGIN_MS = 5000;
|
|
/** Registry `github.clear.last.seen.state.days`. */
|
|
export const CLEAR_LAST_SEEN_AFTER_DAYS = 14;
|
|
|
|
/**
|
|
* Whether `pr` counts as seen by `user`: always, unless the user wrote it or their review
|
|
* is requested; then when it was last seen (ms, stored locally) no earlier than its update.
|
|
*/
|
|
export function isSeen(
|
|
pr: Pick<GHPullRequestShort, "author" | "reviewRequests" | "updatedAt">,
|
|
user: Pick<GHUser, "id">,
|
|
lastSeen: number | null | undefined,
|
|
): boolean {
|
|
const involved =
|
|
pr.author?.id === user.id || pr.reviewRequests.nodes.some((r) => r.requestedReviewer?.id === user.id);
|
|
if (!involved) return true;
|
|
return lastSeen != null && lastSeen + LAST_SEEN_MARGIN_MS >= Date.parse(pr.updatedAt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pull request state
|
|
|
|
/** `GHUIUtil.getPullRequestStateIcon`. */
|
|
export function pullRequestStateIcon(state: GHPullRequestState, isDraft: boolean): string {
|
|
if (state === "MERGED") return "github-pr-merged";
|
|
if (state === "CLOSED") return "github-pr-closed";
|
|
return isDraft ? "github-pr-draft" : "github-pr-open";
|
|
}
|
|
|
|
/** The state tag's text, or null for an open pull request (draft wins over open). */
|
|
export function stateTagText(state: GHPullRequestState, isDraft: boolean): "DRAFT" | "CLOSED" | "MERGED" | null {
|
|
if (state === "MERGED") return "MERGED";
|
|
if (state === "CLOSED") return "CLOSED";
|
|
return isDraft ? "DRAFT" : null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mergeability (`GHPRMergeabilityStateBuilder`)
|
|
|
|
const CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
|
|
const ADMIN_CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["BLOCKED", "BEHIND"]);
|
|
|
|
function contextJobState(s: GHCommitStatusContextState): CodeReviewCIJobState {
|
|
if (s === "PENDING") return "PENDING";
|
|
if (s === "SUCCESS") return "SUCCESS";
|
|
return "FAILED";
|
|
}
|
|
|
|
function checkRunJobState(c: GHCommitCheckSuiteConclusion | null): CodeReviewCIJobState {
|
|
if (c === null) return "PENDING";
|
|
if (c === "SKIPPED") return "SKIPPED";
|
|
if (c === "SUCCESS") return "SUCCESS";
|
|
return "FAILED";
|
|
}
|
|
|
|
export function buildMergeabilityState(
|
|
data: GHPullRequestMergeabilityData,
|
|
currentUserIsAdmin: boolean,
|
|
): GHPRMergeabilityState {
|
|
const rule = data.baseRef?.refUpdateRule ?? null;
|
|
const canOverrideAsAdmin = rule ? currentUserIsAdmin : false;
|
|
const requiredContexts = rule ? rule.requiredStatusCheckContexts.filter((c): c is string => c !== null) : [];
|
|
const isRestricted = rule ? !rule.viewerCanPush : false;
|
|
const requiredReviews = rule?.requiredApprovingReviewCount ?? 0;
|
|
|
|
const hasConflicts = data.mergeable === "MERGEABLE" ? false : data.mergeable === "CONFLICTING" ? true : null;
|
|
const lastCommit = data.commits.nodes.at(-1)?.commit;
|
|
const contexts = lastCommit?.status?.contexts ?? [];
|
|
const ciJobs: CodeReviewCIJob[] = [
|
|
...contexts.map((c) => ({
|
|
name: c.context,
|
|
status: contextJobState(c.state),
|
|
isRequired: c.isRequired,
|
|
detailsUrl: c.targetUrl,
|
|
})),
|
|
...(lastCommit?.checkSuites?.nodes ?? [])
|
|
.flatMap((s) => s.checkRuns?.nodes ?? [])
|
|
.map((r) => ({
|
|
name: r.name,
|
|
status: checkRunJobState(r.conclusion),
|
|
isRequired: r.isRequired,
|
|
detailsUrl: r.detailsUrl ?? r.url,
|
|
})),
|
|
];
|
|
const status = data.mergeStateStatus;
|
|
const canBeMerged = CAN_MERGE.has(status) || (ADMIN_CAN_MERGE.has(status) && canOverrideAsAdmin);
|
|
let checksState: GHPRMergeabilityState["checksState"] = null;
|
|
if (status === "BEHIND") checksState = "BLOCKING_BEHIND";
|
|
else if (status === "BLOCKED") {
|
|
const passed = new Set(contexts.filter((c) => c.state === "SUCCESS").map((c) => c.context));
|
|
if (requiredContexts.length && !requiredContexts.every((c) => passed.has(c))) checksState = "BLOCKING_FAILING";
|
|
}
|
|
const requiredApprovingReviewsCount =
|
|
status === "BLOCKED" && !isRestricted && checksState !== "BLOCKING_FAILING" ? requiredReviews : 0;
|
|
return {
|
|
hasConflicts,
|
|
ciJobs,
|
|
canBeMerged,
|
|
canBeRebased: data.canBeRebased,
|
|
isRestricted,
|
|
requiredApprovingReviewsCount,
|
|
checksState,
|
|
mergeable: data.mergeable,
|
|
mergeStateStatus: status,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Permissions (`GHPRSecurityService`)
|
|
|
|
const PERMISSION_ORDER: GHRepositoryPermissionLevel[] = ["READ", "TRIAGE", "WRITE", "MAINTAIN", "ADMIN"];
|
|
|
|
/** Whether `permission` is at least `level` (an unknown permission has none). */
|
|
export function hasPermission(
|
|
permission: GHRepositoryPermissionLevel | null | undefined,
|
|
level: GHRepositoryPermissionLevel,
|
|
): boolean {
|
|
return (permission ? PERMISSION_ORDER.indexOf(permission) : -1) >= PERMISSION_ORDER.indexOf(level);
|
|
}
|
|
|
|
/**
|
|
* Which pull request actions the user may run (IntelliJ's review flow enablement): managing
|
|
* (request reviewers, close, reopen, post a draft) needs triage access or authorship; merging
|
|
* needs write access, a method the repository allows, a mergeable state and an unrestricted
|
|
* base branch, and `mergeForbidden` (the project setting) off.
|
|
*/
|
|
export function pullRequestPermissions(
|
|
pr: Pick<GHPullRequest, "viewerDidAuthor" | "state">,
|
|
repo: Pick<GHRepository, "viewerPermission" | "mergeCommitAllowed" | "squashMergeAllowed" | "rebaseMergeAllowed">,
|
|
mergeability: GHPRMergeabilityState | null,
|
|
mergeForbidden = false,
|
|
) {
|
|
const canManage = hasPermission(repo.viewerPermission, "TRIAGE") || pr.viewerDidAuthor;
|
|
const canWrite = hasPermission(repo.viewerPermission, "WRITE") && !mergeForbidden;
|
|
const open = pr.state === "OPEN";
|
|
const mergeable = open && canWrite && !!mergeability && !mergeability.isRestricted;
|
|
return {
|
|
canManage,
|
|
canMerge: mergeable && repo.mergeCommitAllowed && mergeability!.canBeMerged,
|
|
canSquashMerge: mergeable && repo.squashMergeAllowed && mergeability!.canBeMerged,
|
|
canRebaseMerge: mergeable && repo.rebaseMergeAllowed && mergeability!.canBeRebased,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Merge commit messages
|
|
|
|
/** The merge dialog's message: subject `Merge pull request #n`, body the title, or `* headline` per commit when squashing. */
|
|
export function mergeCommitMessage(
|
|
pr: Pick<GHPullRequest, "number" | "title">,
|
|
squashCommitHeadlines?: readonly string[],
|
|
): string {
|
|
const subject = `Merge pull request #${pr.number}`;
|
|
const body = squashCommitHeadlines ? `* ${squashCommitHeadlines.join("\n\n* ")}` : pr.title;
|
|
return `${subject}\n\n${body}`;
|
|
}
|
|
|
|
/** Splits a commit message into its subject (first line) and body (`splitCommitMessage`). */
|
|
export function splitCommitMessage(message: string): { title: string; body: string } {
|
|
const i = message.indexOf("\n");
|
|
if (i < 0) return { title: message.trim(), body: "" };
|
|
return { title: message.slice(0, i).trim(), body: message.slice(i + 1).trim() };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repositories
|
|
|
|
/** `GHUIUtil.getRepositoryDisplayName`: `name`, or `owner/name` when the owners differ (or always). */
|
|
export function repositoryDisplayName(
|
|
all: readonly GHRepositoryCoordinates[],
|
|
repo: GHRepositoryCoordinates,
|
|
alwaysShowOwner = false,
|
|
): string {
|
|
const showOwner = alwaysShowOwner || (all.length > 1 && all.some((r) => r.owner !== all[0]!.owner));
|
|
return showOwner ? `${repo.owner}/${repo.name}` : repo.name;
|
|
}
|
|
|
|
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
|
|
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
|
|
const m =
|
|
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
|
|
url.trim(),
|
|
);
|
|
return m ? { owner: m[1]!, name: m[2]! } : null;
|
|
}
|
|
|
|
/**
|
|
* The remote of `repo` among `remotes`, by the URL's path: gh also resolves a remote behind
|
|
* an SSH host alias (`git@work-github:o/r.git`), which `parseGitHubRemote` can't tell is GitHub.
|
|
*/
|
|
export function remoteOfRepository(
|
|
remotes: ReadonlyArray<{ name: string; url: string }>,
|
|
repo: GHRepositoryCoordinates,
|
|
): { name: string; url: string } | null {
|
|
const path = `${repo.owner}/${repo.name}`.toLowerCase();
|
|
return (
|
|
remotes.find((r) => {
|
|
const url = r.url
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\/$/, "")
|
|
.replace(/\.git$/, "");
|
|
return url.endsWith(`/${path}`) || url.endsWith(`:${path}`);
|
|
}) ?? null
|
|
);
|
|
}
|