Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules/
coverage/
dist/
.DS_Store
.env
16 changes: 16 additions & 0 deletions packages/gh-prs-ready-need-review/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# GitHub — fine-grained or classic PAT with repo read access.
# Fine-grained tokens need Pull requests + Issues (read), and Contents (read) for private repo search.
GITHUB_TOKEN=
# Repository to watch (owner/repo)
GITHUB_REPO=your-org/your-repo

# Gather Smart Object webhook — open PRs you authored or are assigned to
GATHER_WEBHOOK_URL=https://api.v2.gather.town/api/v2/hooks/spaces/.../objects/...
GATHER_WEBHOOK_SECRET=whsec_...

# Gather Smart Object webhook — open PRs that directly named you as reviewer, not yet approved (last 7 days)
GATHER_REVIEW_WEBHOOK_URL=https://api.v2.gather.town/api/v2/hooks/spaces/.../objects/...
GATHER_REVIEW_WEBHOOK_SECRET=whsec_...

# How often to poll all registered sources (default: 10 minutes)
POLL_INTERVAL_MS=600000
42 changes: 42 additions & 0 deletions packages/gh-prs-ready-need-review/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# @webhook-objects/gh-prs-ready-need-review
Comment thread
yaustar marked this conversation as resolved.

Polls GitHub for two PR metrics and mirrors each count to a Gather Smart Object `counter`:

1. Open, ready-for-review PRs you authored or are assigned to (last 14 days)
2. Open PRs that directly named you as reviewer, not yet approved (last 7 days)

Uses the GitHub REST search API (not the `gh` CLI). Configure via `.env` — see
`.env.example`.

## Setup

```sh
cp packages/gh-prs-ready-need-review/.env.example packages/gh-prs-ready-need-review/.env
# fill in GITHUB_TOKEN, GITHUB_REPO, and both Gather webhook URL/secret pairs
pnpm install --merge-git-branch-lockfiles
```

## Usage

```sh
# probe both Smart Objects (webhook.ping)
pnpm --filter @webhook-objects/gh-prs-ready-need-review ping

# clear legacy activity slots and reset counters (one-time migration)
pnpm --filter @webhook-objects/gh-prs-ready-need-review reset

# poll GitHub and push counter.set to both objects (runs until Ctrl+C)
pnpm --filter @webhook-objects/gh-prs-ready-need-review start
```

| env var | required | default | meaning |
| --- | --- | --- | --- |
| `GITHUB_TOKEN` | yes | — | PAT with repo read access |
| `GITHUB_REPO` | yes | — | `owner/repo` to watch |
| `GATHER_WEBHOOK_URL` | yes | — | counter object for open PRs |
| `GATHER_WEBHOOK_SECRET` | yes | — | `whsec_…` for that object |
| `GATHER_REVIEW_WEBHOOK_URL` | yes | — | counter object for review requests |
| `GATHER_REVIEW_WEBHOOK_SECRET` | yes | — | `whsec_…` for that object |
| `POLL_INTERVAL_MS` | no | `600000` | poll interval (min 10000) |

Events are signed and sent via [`@gathertown/webhook-object-sdk`](https://www.npmjs.com/package/@gathertown/webhook-object-sdk).
28 changes: 28 additions & 0 deletions packages/gh-prs-ready-need-review/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@webhook-objects/gh-prs-ready-need-review",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Poll GitHub PR metrics and mirror counts to Gather Smart Object counters",
"bin": {
"gh-prs-ready-need-review": "src/index.ts"
},
"scripts": {
"start": "tsx --env-file-if-exists=.env src/index.ts",
"ping": "tsx --env-file-if-exists=.env src/ping.ts",
"reset": "tsx --env-file-if-exists=.env src/reset.ts",
"test": "vitest run --coverage"
},
"licenses": [
{
"type": "Apache-2.0"
},
{
"type": "MIT"
}
],
"dependencies": {
"@gathertown/webhook-object-sdk": "^0.1.1",
"octokit": "^5.0.5"
}
}
18 changes: 18 additions & 0 deletions packages/gh-prs-ready-need-review/src/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from "vitest";
import { parseRepo } from "./config";

test("parseRepo accepts owner/repo", () => {
expect(parseRepo("acme/widget")).toEqual({ owner: "acme", name: "widget" });
});

test("parseRepo rejects invalid shapes", () => {
expect(() => parseRepo("not-a-repo")).toThrow(/owner\/repo/);
expect(() => parseRepo("a/b/c")).toThrow(/owner\/repo/);
});

test("parseRepo trims whitespace", () => {
expect(parseRepo(" acme/widget ")).toEqual({
owner: "acme",
name: "widget",
});
});
68 changes: 68 additions & 0 deletions packages/gh-prs-ready-need-review/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const WHSEC_PATTERN = /^whsec_.{20,}/;
const WEBHOOK_URL_PATTERN =
/^https:\/\/api\.v2\.(?:staging\.)?gather\.town\/api\/v2\/hooks\/spaces\/[0-9a-f-]{36}\/objects\/[0-9a-f-]{36}$/i;

function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}

export function parseRepo(value: string): { owner: string; name: string } {
const match = value.trim().match(/^([^/]+)\/([^/]+)$/);
if (!match) {
throw new Error(
`GITHUB_REPO must be owner/repo (got ${JSON.stringify(value)})`,
);
}
return { owner: match[1], name: match[2] };
}

function loadGatherWebhook(urlEnv: string, secretEnv: string) {
const secret = required(secretEnv);
if (!WHSEC_PATTERN.test(secret)) {
throw new Error(`${secretEnv} is missing or malformed (expected whsec_…)`);
}

const url = required(urlEnv);
if (!WEBHOOK_URL_PATTERN.test(url)) {
throw new Error(
`${urlEnv} is invalid — copy the full URL from the Smart Object setup (spaces/{uuid}/objects/{uuid})`,
);
}

return { url, secret };
}

export type Config = {
gather: {
openPrs: { url: string; secret: string };
reviewRequested: { url: string; secret: string };
};
github: { token: string; owner: string; repo: string };
pollIntervalMs: number;
};

export function loadConfig(): Config {
const githubRepo = parseRepo(required("GITHUB_REPO"));
const pollIntervalMs = Number(process.env.POLL_INTERVAL_MS ?? "600000");
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 10_000) {
throw new Error("POLL_INTERVAL_MS must be a number >= 10000");
}

return {
gather: {
openPrs: loadGatherWebhook("GATHER_WEBHOOK_URL", "GATHER_WEBHOOK_SECRET"),
reviewRequested: loadGatherWebhook(
"GATHER_REVIEW_WEBHOOK_URL",
"GATHER_REVIEW_WEBHOOK_SECRET",
),
},
github: {
token: required("GITHUB_TOKEN"),
owner: githubRepo.owner,
repo: githubRepo.name,
},
pollIntervalMs,
};
}
30 changes: 30 additions & 0 deletions packages/gh-prs-ready-need-review/src/github/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test, vi } from "vitest";
import {
cutoffSinceDate,
PR_CUTOFF_DAYS,
prCreatedSinceDate,
REVIEW_REQUEST_CUTOFF_DAYS,
reviewRequestSinceDate,
} from "./client";

test("cutoffSinceDate returns YYYY-MM-DD", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-01T12:00:00Z"));

expect(cutoffSinceDate(7)).toBe("2026-06-24");
expect(cutoffSinceDate(14)).toBe("2026-06-17");

vi.useRealTimers();
});

test("PR search cutoffs use expected day windows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-01T12:00:00Z"));

expect(prCreatedSinceDate()).toBe(cutoffSinceDate(PR_CUTOFF_DAYS));
expect(reviewRequestSinceDate()).toBe(
cutoffSinceDate(REVIEW_REQUEST_CUTOFF_DAYS),
);

vi.useRealTimers();
});
90 changes: 90 additions & 0 deletions packages/gh-prs-ready-need-review/src/github/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Octokit } from "octokit";

export const PR_CUTOFF_DAYS = 14;
export const REVIEW_REQUEST_CUTOFF_DAYS = 7;

export type GitHubPullRequest = {
id: number;
number: number;
title: string;
html_url: string;
};

function createOctokit(token: string): Octokit {
return new Octokit({ auth: token });
}

export function cutoffSinceDate(days: number): string {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return cutoff.toISOString().slice(0, 10);
}

/** YYYY-MM-DD for GitHub `created:>=` search qualifier */
export function prCreatedSinceDate(): string {
return cutoffSinceDate(PR_CUTOFF_DAYS);
}

/** YYYY-MM-DD — last week for review-requested PR search */
export function reviewRequestSinceDate(): string {
return cutoffSinceDate(REVIEW_REQUEST_CUTOFF_DAYS);
}

export async function fetchGitHubLogin(token: string): Promise<string> {
const octokit = createOctokit(token);
const { data: user } = await octokit.rest.users.getAuthenticated();
if (!user.login) {
throw new Error("GitHub /user response missing login");
}
return user.login;
}

export async function searchIssues(
token: string,
query: string,
{ perPage = 100 }: { perPage?: number } = {},
): Promise<GitHubPullRequest[]> {
const octokit = createOctokit(token);
const { data } = await octokit.rest.search.issuesAndPullRequests({
q: query,
per_page: perPage,
});
return (data.items ?? []) as GitHubPullRequest[];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Search capped at one page

Medium Severity

searchIssues calls GitHub issue search once with per_page 100 and returns only data.items, with no pagination. When a query matches more than 100 PRs, extra results are dropped, so listOpenPrsForUser and listOpenReviewRequestedPrsForUser can under-report counts pushed via counter.set.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0fd0edd. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine. If we have more than 100 items, then we've gone past the size of the inbox anyway.

}

/**
* Open, ready-for-review PRs you opened or are assigned to. Search API rejects
* OR on user qualifiers, so merge author + assignee results and dedupe.
*/
export async function listOpenPrsForUser(
token: string,
{ owner, repo, login }: { owner: string; repo: string; login: string },
): Promise<GitHubPullRequest[]> {
const createdSince = prCreatedSinceDate();
const base = `repo:${owner}/${repo} is:pr is:open draft:false created:>=${createdSince}`;
const [authored, assigned] = await Promise.all([
searchIssues(token, `${base} author:${login}`),
searchIssues(token, `${base} assignee:${login}`),
]);

const byId = new Map<number, GitHubPullRequest>();
for (const item of [...authored, ...assigned]) {
byId.set(item.id, item);
}

return [...byId.values()].sort((a, b) => a.number - b.number);
}

/**
* Open PRs that directly requested you as a reviewer (not via a team), not yet
* approved by anyone, created in the last week.
*/
export async function listOpenReviewRequestedPrsForUser(
token: string,
{ owner, repo, login }: { owner: string; repo: string; login: string },
): Promise<GitHubPullRequest[]> {
const since = reviewRequestSinceDate();
const query = `repo:${owner}/${repo} is:pr is:open user-review-requested:${login} -review:approved created:>=${since}`;
const items = await searchIssues(token, query);
return items.sort((a, b) => a.number - b.number);
}
42 changes: 42 additions & 0 deletions packages/gh-prs-ready-need-review/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env -S npx tsx
/**
* Poll GitHub for PR metrics and mirror counts to two Gather Smart Object
* counters — open PRs you authored/are assigned to, and unapproved review
* requests naming you directly.
*
* @module
*/
import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk";
import { loadConfig } from "./config";
import { fetchGitHubLogin } from "./github/client";
import { createPollEntries } from "./polls/registry";
import { runPollCycle } from "./runner";

const config = loadConfig();

const pollEntries = createPollEntries({
openPrs: createWebhookObjectClient(config.gather.openPrs),
reviewRequested: createWebhookObjectClient(config.gather.reviewRequested),
});

const login = await fetchGitHubLogin(config.github.token);
const ctx = { github: { ...config.github, login } };

async function tick() {
try {
await runPollCycle(ctx, pollEntries);
} catch (err) {
console.error("Poll cycle failed:", err);
}
}

console.log(
`Starting gh-prs-ready-need-review as @${login} (poll every ${config.pollIntervalMs / 1000}s)`,
);

const loop = async () => {
await tick();
setTimeout(loop, config.pollIntervalMs);
};

loop();
19 changes: 19 additions & 0 deletions packages/gh-prs-ready-need-review/src/ping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createWebhookObjectClient } from "@gathertown/webhook-object-sdk";
import { loadConfig } from "./config";

const config = loadConfig();

const targets = [
{ name: "open PRs (authored/assigned)", ...config.gather.openPrs },
{
name: "unapproved direct review-requested PRs",
...config.gather.reviewRequested,
},
];

for (const { name, url, secret } of targets) {
const client = createWebhookObjectClient({ url, secret });
const result = await client.ping();
console.log(`\n${name}:`);
console.log(JSON.stringify(result, null, 2));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { listOpenPrsForUser, prCreatedSinceDate } from "../github/client";
import type { Poll, PollContext, PollResult } from "./types";

/** Count open, ready-for-review PRs you opened or are assigned to. */
export const githubPrReviewCountPoll: Poll = async (
ctx: PollContext,
): Promise<PollResult> => {
const { owner, repo, token, login } = ctx.github;

const prs = await listOpenPrsForUser(token, { owner, repo, login });

console.log(
`[github-pr-review-count] ${prs.length} ready-for-review PR(s) since ${prCreatedSinceDate()} authored by or assigned to @${login}:`,
);
if (prs.length === 0) {
console.log(" (none)");
} else {
for (const pr of prs) {
console.log(` #${pr.number} ${pr.title}`);
console.log(` ${pr.html_url}`);
}
}

return {
pollId: "github-pr-review-count",
label: "GitHub PR reviews",
value: prs.length,
};
};
Loading