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
103 changes: 103 additions & 0 deletions packages/gatekeeper-github/__tests__/github-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GitHubApi,
type GitHubIssueResponse,
} from "../src/github-api";
import {
assertIssueSearchResultsInRepo,
buildIssueSearchQuery,
} from "../src/github-search";

function issueAt(htmlUrl: string): Pick<GitHubIssueResponse, "html_url"> {
return { html_url: htmlUrl };
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe("assertIssueSearchResultsInRepo", () => {
it("accepts exact repository path segments case-insensitively", () => {
expect(() => assertIssueSearchResultsInRepo("Cloudflare", "Workerd", [
issueAt("https://github.com/cloudflare/workerd/issues/1"),
])).not.toThrow();
});

it("rejects results from another repository", () => {
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
issueAt("https://github.com/cloudflare/quiche/issues/1"),
])).toThrow("outside the connected repository");
});

it("does not accept repository names that only share a prefix", () => {
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
issueAt("https://github.com/cloudflare/workerd-private/issues/1"),
])).toThrow("outside the connected repository");
});

it("rejects pull requests returned by an injected search expression", () => {
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
issueAt("https://github.com/cloudflare/workerd/pull/1"),
])).toThrow("non-issue result");
});

it("rejects malformed and non-GitHub result URLs", () => {
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
issueAt("not a URL"),
])).toThrow("outside the connected repository");
expect(() => assertIssueSearchResultsInRepo("cloudflare", "workerd", [
issueAt("https://example.com/cloudflare/workerd/issues/1"),
])).toThrow("outside the connected repository");
});
});

describe("buildIssueSearchQuery", () => {
it("builds a benign literal phrase search with structured filters", () => {
expect(buildIssueSearchQuery("cloudflare", "workerd", {
text: "durable objects",
state: "open",
labels: ["bug"],
author: "jasnell",
})).toBe(
'"durable objects" repo:cloudflare/workerd is:issue state:open label:"bug" author:"jasnell"',
);
});

it("quotes every caller-controlled query fragment", () => {
expect(buildIssueSearchQuery("cloudflare", "workerd", {
text: "repo:cloudflare/quiche OR scheduler",
author: "jasnell OR repo:cloudflare/quiche",
assignee: "octocat OR repo:cloudflare/quiche",
})).toBe(
'"repo:cloudflare/quiche OR scheduler" repo:cloudflare/workerd is:issue '
+ 'author:"jasnell OR repo:cloudflare/quiche" assignee:"octocat OR repo:cloudflare/quiche"',
);
});

it("escapes quotes inside plain search text", () => {
expect(buildIssueSearchQuery("cloudflare", "workerd", {
text: 'bug" OR repo:cloudflare/quiche OR "',
})).toBe('"bug\\" OR repo:cloudflare/quiche OR \\"" repo:cloudflare/workerd is:issue');
});
});

describe("GitHubApi.searchIssuesConditional", () => {
it("enables GitHub advanced search parsing", async () => {
let requestUrl: URL | undefined;
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
requestUrl = new URL(String(input));
return new Response(JSON.stringify({ items: [] }), {
headers: { "content-type": "application/json" },
});
}));

const api = new GitHubApi(async () => "test-token");
await api.searchIssuesConditional(
"repo:cloudflare/quiche OR repo:cloudflare/workerd is:issue",
1,
100,
);

expect(requestUrl?.searchParams.get("advanced_search")).toBe("true");
});
});
2 changes: 2 additions & 0 deletions packages/gatekeeper-github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"deploy": "pnpm run build:configurator && wrangler deploy",
"build": "pnpm run build:configurator && tsc",
"types:check": "pnpm run build:configurator && tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist src/generated"
},
"dependencies": {
Expand All @@ -20,6 +21,7 @@
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wrangler": "^4.119.0"
}
}
3 changes: 1 addition & 2 deletions packages/gatekeeper-github/src/github-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "cloudflare:workers";

export type GitHubOAuthGrant = {
accessToken: string;
scopes: string[];
Expand Down Expand Up @@ -596,6 +594,7 @@ export class GitHubApi {
"/search/issues",
{
q: query,
advanced_search: true,
page,
per_page: perPage,
sort,
Expand Down
40 changes: 40 additions & 0 deletions packages/gatekeeper-github/src/github-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { GitHubIssueResponse } from "./github-api";
import type { GitHubIssueSearch } from "./types";

export function buildIssueSearchQuery(owner: string, repo: string, query: GitHubIssueSearch): string {
const parts = [query.text ? JSON.stringify(query.text) : "", `repo:${owner}/${repo}`, "is:issue"];
if (query.state && query.state !== "all") parts.push(`state:${query.state}`);
for (const label of query.labels ?? []) {
parts.push(`label:${JSON.stringify(label)}`);
}
if (query.author) parts.push(`author:${JSON.stringify(query.author)}`);
if (query.assignee) parts.push(`assignee:${JSON.stringify(query.assignee)}`);
return parts.filter(Boolean).join(" ");
}

export function assertIssueSearchResultsInRepo(
owner: string,
repo: string,
results: readonly Pick<GitHubIssueResponse, "html_url">[],
): void {
const expectedOwner = owner.toLowerCase();
const expectedRepo = repo.toLowerCase();

for (const result of results) {
let url: URL | undefined;
try {
url = new URL(result.html_url);
} catch {
// Handled by the scope check below.
}

const [resultOwner, resultRepo, resultKind] = url?.pathname.split("/").filter(Boolean) ?? [];
if (url?.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com"
|| resultOwner?.toLowerCase() !== expectedOwner || resultRepo?.toLowerCase() !== expectedRepo) {
throw new Error("GitHub returned an issue outside the connected repository.");
}
if (resultKind !== "issues") {
throw new Error("GitHub returned a non-issue result for an issue search.");
}
}
}
43 changes: 26 additions & 17 deletions packages/gatekeeper-github/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type GitHubPullRequestResponse,
type GitHubPullRequestReviewCommentResponse,
} from "./github-api";
import { assertIssueSearchResultsInRepo, buildIssueSearchQuery } from "./github-search";
import GITHUB_LOGO_SVG from "./github-logo.svg";
import type {
GitHubActor,
Expand Down Expand Up @@ -111,6 +112,11 @@ type Cached<T> = {
generation: number;
};

type CachedIssueSearchResult = {
html_url: string;
summary: GitHubIssueSummary;
};

type GitHubDiscussionCommentEntry = Extract<GitHubDiscussionEntry, { kind: "comment" }>;

type StoredCommentCacheState = {
Expand Down Expand Up @@ -697,17 +703,6 @@ function pullComparator(
};
}

function buildIssueSearchQuery(owner: string, repo: string, query: GitHubIssueSearch): string {
const parts = [query.text, `repo:${owner}/${repo}`, "is:issue"];
if (query.state && query.state !== "all") parts.push(`state:${query.state}`);
for (const label of query.labels ?? []) {
parts.push(`label:${JSON.stringify(label)}`);
}
if (query.author) parts.push(`author:${query.author}`);
if (query.assignee) parts.push(`assignee:${query.assignee}`);
return parts.filter(Boolean).join(" ");
}

function parseDiffSide(side?: "LEFT" | "RIGHT" | null): "old" | "new" {
return side === "LEFT" ? "old" : "new";
}
Expand Down Expand Up @@ -2563,28 +2558,42 @@ export class GitHubGatekeeperImpl extends DurableObject<Env, GitHubGatekeeperImp
const owner = this.ctx.props.owner;
const repo = this.ctx.props.repo;
const searchQuery = buildIssueSearchQuery(owner, repo, query);
const assertSearchScope = (results: readonly Pick<GitHubIssueResponse, "html_url">[]) => {
try {
assertIssueSearchResultsInRepo(owner, repo, results);
} catch (error) {
logger.warn("GitHub issue search scope validation failed", {
event: "issue.search.scope.validation.failed", error,
});
throw error;
}
};
return new StreamingCursor<GitHubIssueSummary>({
fetchPage: async (page, perPage) => {
const cacheKey = this.#cacheKey("search-issues", stableKey(query), `p${page}`);
return await this.#loadCachedWithEtag<GitHubIssueSummary[]>(cacheKey, LIST_CACHE_TTL_MS, async etag => {
const cacheKey = this.#cacheKey("search-issues-scoped-v1", stableKey(query), `p${page}`);
const results = await this.#loadCachedWithEtag<CachedIssueSearchResult[]>(cacheKey, LIST_CACHE_TTL_MS, async etag => {
const raw = await this.#withApi(api =>
api.searchIssuesConditional(searchQuery, page, perPage, remoteSort, remoteDirection, { ifNoneMatch: etag })
);
if (raw.status === 304) {
return raw;
}

assertSearchScope(raw.data.items);
return {
status: 200,
headers: raw.headers,
data: raw.data.items
.filter(item => !item.pull_request)
.map(item => normalizeIssueSummary(owner, repo, item)),
data: raw.data.items.map(item => ({
html_url: item.html_url,
summary: normalizeIssueSummary(owner, repo, item),
})),
};
});
assertSearchScope(results);
return results.map(item => item.summary);
},
overlay: item => this.#overlayIssueLike(item, "issue", item.id),
filter: () => true, // Remote search results are already filtered by GitHub's search API.
filter: () => true, // Search scope was validated before results entered the cursor.
comparator: compare,
injectedItems: provisionals,
pageSize,
Expand Down
4 changes: 2 additions & 2 deletions packages/gatekeeper-github/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export interface GitHubRepo {
/**
* Searches issues in this repository.
*
* The search is limited to this repository. `query.text` is a plain-text search
* string; the remaining fields are structured filters.
* The search is limited to this repository. `query.text` is matched as a literal phrase;
* search qualifiers in it are not interpreted. The remaining fields are structured filters.
*/
searchIssues(query: GitHubIssueSearch): Promise<Cursor<GitHubIssueSummary>>;

Expand Down
2 changes: 1 addition & 1 deletion packages/gatekeeper-github/storage-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Implemented TTL cache families:
- `cache:issue:<realId>` -> `GitHubIssueDetails`
- `cache:pull:<realId>` -> `GitHubPullRequestDetails`
- `cache:list-issues:<encodedQuery>` -> `GitHubIssueSummary[]`
- `cache:search-issues:<encodedQuery>` -> `GitHubIssueSummary[]`
- `cache:search-issues-scoped-v1:<encodedQuery>` -> validated source URLs and `GitHubIssueSummary` values
- `cache:list-pulls:<encodedQuery>` -> `GitHubPullRequestSummary[]`
- `cache:search-pulls:<encodedQuery>` -> `GitHubPullRequestSummary[]`
- `cache:discussion-reviews:<realId>:p<page>` -> `GitHubDiscussionEntry[]` review-summary pages for pull discussions
Expand Down
8 changes: 8 additions & 0 deletions packages/gatekeeper-github/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["__tests__/*.test.ts"],
environment: "node",
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading