Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/contracts/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,12 @@ export type MergeWaiting = typeof MergeWaiting.Type;
/** The most people's reviews a `MonitorMark` remembers; older ones fall off the front. */
export const MONITOR_MARK_REVIEWS_MAX = 32;

/** The most people's comments a `MonitorMark` remembers; twice what a badge lists. */
export const MONITOR_MARK_COMMENTS_MAX = 64;

/** The most comment wakes a `MonitorMark` remembers: a day's worth at the cap. */
export const MONITOR_MARK_COMMENT_WAKES_MAX = 32;

/**
* What a PR's monitors last saw of it, kept with the binding so the first look after a restart
* compares against it: without it, that look is only a baseline, and a CI settle, a bot round or a
Expand All @@ -447,6 +453,26 @@ export const MonitorMark = Schema.Struct({
humanReviews: Schema.optionalKey(
Schema.Array(Schema.String).check(Schema.isMaxLength(MONITOR_MARK_REVIEWS_MAX)),
),
/**
* People's comments handled after `commentsFloor`, by id and when each was posted: delivered to
* the thread, or passed over as a bot's, a Gram thread's own or an untrusted author's.
*/
comments: Schema.optionalKey(
// A plain id is how an earlier build kept one; it has no time, so it reads as old.
Schema.Array(
Schema.Union([Schema.String, Schema.Struct({ id: Schema.String, at: Schema.String })]),
).check(Schema.isMaxLength(MONITOR_MARK_COMMENTS_MAX)),
),
/**
* What is old: a comment at or before it never wakes the thread, whatever `comments` forgot. It
* stays a little behind the newest handled, so one GitHub lists late with an earlier time still
* counts, and every comment handled after it is in `comments`.
*/
commentsFloor: Schema.optionalKey(Schema.Struct({ at: Schema.String, id: Schema.String })),
/** When comments last woke the thread, epoch ms, oldest first: the rate limit survives a restart. */
commentWakes: Schema.optionalKey(
Schema.Array(Schema.Number).check(Schema.isMaxLength(MONITOR_MARK_COMMENT_WAKES_MAX)),
),
});
export type MonitorMark = typeof MonitorMark.Type;

Expand Down
27 changes: 27 additions & 0 deletions packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export type PrState = typeof PrState.Type;
/** The most failing check names a badge carries. */
export const MAX_FAILING_CHECK_NAMES = 5;

/** The most people's comments a badge lists: the last ten issue comments and twenty reviews read. */
export const MAX_PERSON_COMMENTS = 30;

/**
* A comment a person (no configured review bot, no GitHub App account) left on a PR: an issue
* comment, or a review that only commented. By id alone; what it says is read when it is new.
*/
export const PersonComment = Schema.Struct({
id: Schema.String,
author: Schema.String,
/** When it was posted (a review's `submittedAt`), ISO 8601; what orders it after another. */
at: Schema.String,
});
export type PersonComment = typeof PersonComment.Type;

/** The PR badge payload (mirrors Rust `PrStatus`). */
/**
* The `PrStale.reason` for a lookup `gh` refused because nobody is signed in. Named here so the UI
Expand Down Expand Up @@ -101,6 +116,18 @@ export const PrStatus = Schema.Struct({
* elsewhere with no review monitor on it, or a badge from the REST fallback.
*/
reviewUnread: Schema.optionalKey(Schema.Literal(true)),
/**
* People's comments and comment-only reviews among those read with the reviews, oldest first;
* absent when there are none or the reviews weren't read.
*/
personComments: Schema.optionalKey(
Schema.Array(PersonComment).check(Schema.isMaxLength(MAX_PERSON_COMMENTS)),
),
/**
* From when `personComments` is whole, ISO 8601: a listing that came back full may have left
* older comments out. Absent when nothing was cut.
*/
personCommentsFrom: Schema.optionalKey(Schema.String),
/** Set while the badge is carried over failed refreshes; cleared by the next successful one. */
stale: Schema.optionalKey(PrStale),
});
Expand Down
1 change: 1 addition & 0 deletions packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from "./pr.ts";
export * from "./pr-actions.ts";
export * from "./pr-cache.ts";
export * from "./pr-checkout.ts";
export * from "./pr-comments.ts";
export * from "./pr-people.ts";
export * from "./proc.ts";
export * from "./pr-create.ts";
Expand Down
75 changes: 75 additions & 0 deletions packages/git/src/pr-comments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { canWrite, parseFetchedComment, personCommentsQuery } from "./pr-comments.ts";

describe("personCommentsQuery", () => {
it("names each node by a typed variable, never by its id", () => {
const query = personCommentsQuery(2);
expect(query.startsWith("query($i0:ID!,$i1:ID!){")).toBe(true);
expect(query).toContain("c0:node(id:$i0)");
expect(query).toContain("c1:node(id:$i1)");
expect(query).toContain("viewerDidAuthor");
});
});

describe("parseFetchedComment", () => {
it("reads an issue comment", () => {
expect(
parseFetchedComment({
__typename: "IssueComment",
id: "IC_1",
body: "Please rename it.",
url: "https://github.com/acme/web/pull/12#issuecomment-1",
createdAt: "2026-11-02T10:00:00Z",
viewerDidAuthor: false,
author: { login: "alice", __typename: "User" },
}),
).toEqual({
id: "IC_1",
kind: "comment",
author: "alice",
authorIsBot: false,
viewerDidAuthor: false,
body: "Please rename it.",
url: "https://github.com/acme/web/pull/12#issuecomment-1",
createdAt: "2026-11-02T10:00:00Z",
inline: [],
});
});

it("reads a review with its inline comments, and a bot author as one", () => {
const review = parseFetchedComment({
__typename: "PullRequestReview",
id: "PRR_1",
state: "COMMENTED",
body: "",
url: "u",
createdAt: "t",
viewerDidAuthor: true,
author: { login: "ci-helper", __typename: "Bot" },
comments: {
nodes: [{ id: "C1", body: "Here.", path: "a.ts", line: null, originalLine: 4, url: "u1" }],
},
});
expect(review).toMatchObject({
kind: "review",
state: "COMMENTED",
authorIsBot: true,
viewerDidAuthor: true,
inline: [{ id: "C1", path: "a.ts", line: 4, body: "Here." }],
});
});

it("answers nothing for a node that is gone or of another type", () => {
expect(parseFetchedComment(null)).toBeUndefined();
expect(parseFetchedComment({ __typename: "Commit", id: "x" })).toBeUndefined();
});
});

describe("canWrite", () => {
it("lets the owner, admins and writers through, and nobody else", () => {
expect(canWrite("admin")).toBe(true);
expect(canWrite("write")).toBe(true);
expect(canWrite("read")).toBe(false);
expect(canWrite("none")).toBe(false);
});
});
212 changes: 212 additions & 0 deletions packages/git/src/pr-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// People's comments on a PR, over `gh`: what a comment the badge listed by id says, and whether its
// author may write to the repository. Reads only, run in the thread's checkout so `gh` picks up its
// host and the user's own login; no token of Gram's own.

import * as Effect from "effect/Effect";
import { ghApi } from "./exec.ts";
import { makeTtlCache } from "./pr-cache.ts";
import type { Proc } from "./proc.ts";
import { ReviewActionError } from "./review-actions.ts";

type Json = Record<string, unknown>;

const isJson = (value: unknown): value is Json =>
typeof value === "object" && value !== null && !Array.isArray(value);
const arr = (value: unknown): Array<Json> => (Array.isArray(value) ? value.filter(isJson) : []);
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const at = (value: unknown, path: ReadonlyArray<string>): unknown => {
let current = value;
for (const key of path) current = isJson(current) ? current[key] : undefined;
return current;
};

/** The most comments one lookup reads: as many as a badge lists. */
export const PERSON_COMMENTS_PER_LOOKUP = 30;
/** The most inline comments read of one review. */
const INLINE_PER_REVIEW = 20;

/** One inline comment of a comment-only review. */
export interface InlineComment {
readonly id: string;
readonly path: string;
readonly line?: number | undefined;
readonly body: string;
readonly url: string;
}

/** A person's issue comment or comment-only review, as GitHub has it now. */
export interface FetchedComment {
readonly id: string;
readonly kind: "comment" | "review";
readonly author: string;
/** The author is a GitHub App or other bot account (`__typename` Bot). */
readonly authorIsBot: boolean;
/** Posted with the login `gh` is signed in as: the user's, which the user's agents post with too. */
readonly viewerDidAuthor: boolean;
/** A review's state, e.g. COMMENTED; absent for an issue comment. */
readonly state?: string | undefined;
readonly body: string;
readonly url: string;
readonly createdAt: string;
/** A review's inline comments; empty for an issue comment. */
readonly inline: ReadonlyArray<InlineComment>;
}

const AUTHOR = "author{login __typename}";
const NODE_FIELDS =
`__typename ... on IssueComment{id body url createdAt viewerDidAuthor ${AUTHOR}} ` +
`... on PullRequestReview{id state body url createdAt viewerDidAuthor ${AUTHOR} ` +
`comments(first:${INLINE_PER_REVIEW}){nodes{id body path line originalLine url}}}`;

/**
* One document for `count` nodes by id: positional aliases and typed variables, so no id is ever
* interpolated into the query.
*/
export const personCommentsQuery = (count: number): string => {
const vars = Array.from({ length: count }, (_, i) => `$i${i}:ID!`);
const body = Array.from({ length: count }, (_, i) => `c${i}:node(id:$i${i}){${NODE_FIELDS}}`);
return `query(${vars.join(",")}){${body.join(" ")}}`;
};

const positiveInt = (value: unknown) =>
typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;

/** A node the query answered, or `undefined` for one that is gone or of another type. */
export const parseFetchedComment = (node: unknown): FetchedComment | undefined => {
if (!isJson(node)) return undefined;
const type = node["__typename"];
if (type !== "IssueComment" && type !== "PullRequestReview") return undefined;
const id = str(node["id"]);
if (id === "") return undefined;
return {
id,
kind: type === "IssueComment" ? "comment" : "review",
author: str(at(node, ["author", "login"])),
authorIsBot: at(node, ["author", "__typename"]) === "Bot",
viewerDidAuthor: node["viewerDidAuthor"] === true,
...(type === "PullRequestReview" ? { state: str(node["state"]) } : {}),
body: str(node["body"]),
url: str(node["url"]),
createdAt: str(node["createdAt"]),
inline: arr(at(node, ["comments", "nodes"])).map((c) => {
const line = positiveInt(c["line"]) ?? positiveInt(c["originalLine"]);
return {
id: str(c["id"]),
path: str(c["path"]),
...(line === undefined ? {} : { line }),
body: str(c["body"]),
url: str(c["url"]),
};
}),
};
};

const NODE_ID = /^[A-Za-z0-9_=-]+$/;

/**
* What the comments named by `ids` say now, in one `gh api graphql` call. One that was deleted is
* simply missing from the answer.
*/
export const fetchPersonComments = (
workTree: string,
ids: ReadonlyArray<string>,
): Effect.Effect<ReadonlyArray<FetchedComment>, ReviewActionError, Proc> =>
Effect.gen(function* () {
const wanted = [...new Set(ids)].filter((id) => NODE_ID.test(id));
if (wanted.length === 0) return [];
if (wanted.length > PERSON_COMMENTS_PER_LOOKUP)
return yield* new ReviewActionError({
message: `at most ${PERSON_COMMENTS_PER_LOOKUP} comments are read at once`,
});
const args = ["graphql", "-f", `query=${personCommentsQuery(wanted.length)}`];
wanted.forEach((id, i) => args.push("-f", `i${i}=${id}`));
const out = yield* ghApi(workTree, args, { timeoutMs: 20_000 }).pipe(
Effect.mapError((e) => new ReviewActionError({ message: e.message })),
);
let body: unknown;
try {
body = JSON.parse(out.stdout.trim());
} catch {
return yield* new ReviewActionError({
message: out.stderr.trim() || `gh exited with code ${out.status}`,
});
}
const data = at(body, ["data"]);
// A node id GitHub can't resolve is an error beside partial data, which still answers the rest.
if (!isJson(data)) {
const errors = at(body, ["errors"]);
const first = Array.isArray(errors) && isJson(errors[0]) ? str(errors[0]["message"]) : "";
return yield* new ReviewActionError({ message: first || "GitHub returned no data" });
}
return wanted.flatMap((_, i) => parseFetchedComment(data[`c${i}`]) ?? []);
});

/** A collaborator's legacy base role on a repository, as GitHub's permission endpoint names it. */
export type RepoPermission = "admin" | "write" | "read" | "none";

/** Whether `permission` lets its holder push: the repository's owner and admins, and writers. */
export const canWrite = (permission: RepoPermission): boolean =>
permission === "admin" || permission === "write";

/** How long a permission answer is reused: a removed collaborator is trusted at most this long. */
export const PERMISSION_TTL_MS = 10 * 60_000;

const OWNER_REPO = /^[\w.-]+\/[\w.-]+$/;
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;

const parsePermission = (text: string): RepoPermission | undefined => {
const value = text.trim();
return value === "admin" || value === "write" || value === "read" || value === "none"
? value
: undefined;
};

const uncachedPermission = ({
workTree,
repo,
login,
}: {
readonly workTree: string;
readonly repo: string;
readonly login: string;
}): Effect.Effect<RepoPermission, ReviewActionError, Proc> =>
Effect.gen(function* () {
// Nothing else is ever a login: a name outside GitHub's alphabet has no permission to ask about.
if (!OWNER_REPO.test(repo) || !LOGIN.test(login)) return "none";
const out = yield* ghApi(
workTree,
[`repos/${repo}/collaborators/${login}/permission`, "--jq", ".permission"],
{ timeoutMs: 15_000 },
).pipe(Effect.mapError((e) => new ReviewActionError({ message: e.message })));
if (out.status === 0) {
const permission = parsePermission(out.stdout);
if (permission !== undefined) return permission;
return yield* new ReviewActionError({ message: "GitHub named no permission" });
}
// Not a user GitHub knows: no permission at all. Anything else is unknown, and fails.
if (/HTTP 404/.test(out.stderr)) return "none";
return yield* new ReviewActionError({
message: out.stderr.trim() || `gh exited with code ${out.status}`,
});
});

const permissionCache = makeTtlCache({
key: ({ repo, login }: { readonly repo: string; readonly login: string }) =>
`${repo.toLowerCase()}|${login.toLowerCase()}`,
fetch: uncachedPermission,
ttlMs: PERMISSION_TTL_MS,
});

/**
* `login`'s permission on `repo`, from `gh api repos/{repo}/collaborators/{login}/permission`.
* Answers are kept for `PERMISSION_TTL_MS`; a failure is never kept, so it is asked again.
*/
export const collaboratorPermission = (
workTree: string,
repo: string,
login: string,
): Effect.Effect<RepoPermission, ReviewActionError, Proc> =>
permissionCache.get({ workTree, repo, login });

/** Drops every kept permission answer; for tests. */
export const clearPermissionCache = (): void => permissionCache.clear();
Loading
Loading