-
Notifications
You must be signed in to change notification settings - Fork 1
feat: gh-prs-ready-need-review — GitHub PR counters to Gather #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d9a5ac8
1a6a6dc
665ee48
664a83b
ab0ff5e
f97bf51
0fd0edd
8ec3e8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| node_modules/ | ||
| coverage/ | ||
| dist/ | ||
| .DS_Store | ||
| .env |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # @webhook-objects/gh-prs-ready-need-review | ||
|
|
||
| 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). | ||
| 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" | ||
| } | ||
| } |
| 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", | ||
| }); | ||
| }); |
| 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, | ||
| }; | ||
| } |
| 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(); | ||
| }); |
| 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[]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Search capped at one pageMedium Severity
Reviewed by Cursor Bugbot for commit 0fd0edd. Configure here.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| 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(); |
| 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, | ||
| }; | ||
| }; |


Uh oh!
There was an error while loading. Please reload this page.