From 1a52067a1cb83c610ec1b199b6d1b5aa545086d9 Mon Sep 17 00:00:00 2001 From: Backoffice Date: Thu, 9 Apr 2026 09:10:22 +0000 Subject: [PATCH 1/5] fix(share): security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp minutes to 1–20 (default 5), times to 1–3 (default 1) - Serve HTML as application/octet-stream to prevent browser JS execution - Sanitize Content-Disposition filename (strip control chars and quotes) - Re-read store before decrement to narrow concurrent-request race window --- bin/share/index.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/bin/share/index.ts b/bin/share/index.ts index 7776c0e..c4ae924 100755 --- a/bin/share/index.ts +++ b/bin/share/index.ts @@ -28,8 +28,10 @@ import { SOCKET_PATH } from "../../src/rpc.js"; // ── Constants ───────────────────────────────────────────────────────────────── const DEFAULT_PORT = parseInt(process.env["SHARE_PORT"] ?? "3001"); -const DEFAULT_MINUTES = 10; +const DEFAULT_MINUTES = 5; +const MAX_MINUTES = 20; const DEFAULT_TIMES = 1; +const MAX_TIMES = 3; const DEFAULT_MAX_BYTES = 100 * 1024 * 1024; // 100 MB const CLEANUP_INTERVAL_MS = 60_000; @@ -51,7 +53,7 @@ const MIME: Record = { ".tar": "application/x-tar", ".gz": "application/gzip", // Data / web (non-sensitive formats only) - ".html": "text/html", + ".html": "application/octet-stream", // force download; prevent JS execution in browser ".csv": "text/csv", // Media ".mp4": "video/mp4", @@ -123,7 +125,7 @@ USAGE bun /app/bin/share rm Revoke a link by token prefix or file path ADD FLAGS - --minutes Link lifetime in minutes (default: ${String(DEFAULT_MINUTES)}, max: 60) + --minutes Link lifetime in minutes (default: ${String(DEFAULT_MINUTES)}, max: ${String(MAX_MINUTES)}) --times Max downloads before expiry (default: ${String(DEFAULT_TIMES)}) --delete-after Delete source file after final download --max-size Reject files larger than this (default: 100MB) @@ -234,21 +236,31 @@ function handleRequest(req: Request): Response { return new Response("File no longer available", { status: 410, headers: SECURE_HEADERS }); } - entry.usesRemaining -= 1; - const isLastDownload = entry.usesRemaining <= 0; + // Re-read store immediately before write to narrow race window on concurrent requests + const freshStore = readStore(); + const freshEntry = freshStore[token]; + if (!freshEntry || freshEntry.usesRemaining <= 0 || freshEntry.expiresAt <= Date.now()) { + return new Response("Link expired", { status: 410, headers: SECURE_HEADERS }); + } + freshEntry.usesRemaining -= 1; + const isLastDownload = freshEntry.usesRemaining <= 0; if (isLastDownload) { - delete store[token]; + delete freshStore[token]; } else { - store[token] = entry; + freshStore[token] = freshEntry; } - writeStore(store); + writeStore(freshStore); + // Reassign entry for deleteAfter logic below + Object.assign(entry, freshEntry); if (isLastDownload && entry.deleteAfter) { try { unlinkSync(entry.filePath); } catch { /* best effort */ } } - const fileName = entry.filePath.split("/").pop() ?? "file"; + const rawName = entry.filePath.split("/").pop() ?? "file"; + // Strip quotes and control chars to prevent Content-Disposition header injection + const fileName = rawName.replace(/[\x00-\x1f"\\]/g, "_"); const file = Bun.file(entry.filePath); return new Response(file, { @@ -310,13 +322,13 @@ async function cmdAdd(argv: string[]): Promise { process.exit(1); } - if (minutes < 1 || minutes > 60) { - console.error("Error: --minutes must be between 1 and 60"); + if (minutes < 1 || minutes > MAX_MINUTES) { + console.error(`Error: --minutes must be between 1 and ${String(MAX_MINUTES)}`); process.exit(1); } - if (times < 1) { - console.error("Error: --times must be at least 1"); + if (times < 1 || times > MAX_TIMES) { + console.error(`Error: --times must be between 1 and ${String(MAX_TIMES)}`); process.exit(1); } From 2cb86bebc4ca68a9bfe2ed3844605f1ba408ba03 Mon Sep 17 00:00:00 2001 From: Backoffice Date: Thu, 9 Apr 2026 09:11:23 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(share):=20move=20token=20store=20to=20/?= =?UTF-8?q?tmp=20=E2=80=94=20ephemeral=20across=20restarts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/share/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/share/store.ts b/bin/share/store.ts index 6a0db09..1f00869 100644 --- a/bin/share/store.ts +++ b/bin/share/store.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs"; -export const STORE_PATH = process.env["SHARE_STORE_PATH"] ?? "/data/sharing/store.json"; +export const STORE_PATH = process.env["SHARE_STORE_PATH"] ?? "/tmp/sharing/store.json"; export const ALLOWED_PREFIXES = ["/data/", "/tmp/", "/var/tmp/"]; export interface TokenEntry { From 4e486d5e102fa772d9795c68a1400c63e0bf0ac5 Mon Sep 17 00:00:00 2001 From: Backoffice Date: Thu, 9 Apr 2026 09:13:54 +0000 Subject: [PATCH 3/5] fix(share): SVG octet-stream, drop X-Uses-Remaining, fix stale help example, collapse double read --- bin/share/index.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/bin/share/index.ts b/bin/share/index.ts index c4ae924..701a1b4 100755 --- a/bin/share/index.ts +++ b/bin/share/index.ts @@ -47,7 +47,7 @@ const MIME: Record = { ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", - ".svg": "image/svg+xml", + ".svg": "application/octet-stream", // SVG can contain JS; force download // Archives ".zip": "application/zip", ".tar": "application/x-tar", @@ -141,7 +141,7 @@ EXAMPLES bun /app/bin/share add /tmp/report.pdf --minutes 10 # Share a file 3 times over 30 minutes, delete after last download - bun /app/bin/share add /tmp/data.zip --minutes 30 --times 3 --delete-after + bun /app/bin/share add /tmp/data.zip --minutes 20 --times 3 --delete-after # List active links bun /app/bin/share list @@ -212,6 +212,8 @@ function handleRequest(req: Request): Response { } const token = match[1]!; + + // Single read — used for all checks and the decrement write const store = readStore(); const entry = store[token]; @@ -236,23 +238,15 @@ function handleRequest(req: Request): Response { return new Response("File no longer available", { status: 410, headers: SECURE_HEADERS }); } - // Re-read store immediately before write to narrow race window on concurrent requests - const freshStore = readStore(); - const freshEntry = freshStore[token]; - if (!freshEntry || freshEntry.usesRemaining <= 0 || freshEntry.expiresAt <= Date.now()) { - return new Response("Link expired", { status: 410, headers: SECURE_HEADERS }); - } - freshEntry.usesRemaining -= 1; - const isLastDownload = freshEntry.usesRemaining <= 0; + entry.usesRemaining -= 1; + const isLastDownload = entry.usesRemaining <= 0; if (isLastDownload) { - delete freshStore[token]; + delete store[token]; } else { - freshStore[token] = freshEntry; + store[token] = entry; } - writeStore(freshStore); - // Reassign entry for deleteAfter logic below - Object.assign(entry, freshEntry); + writeStore(store); if (isLastDownload && entry.deleteAfter) { try { unlinkSync(entry.filePath); } catch { /* best effort */ } @@ -269,7 +263,6 @@ function handleRequest(req: Request): Response { ...SECURE_HEADERS, "Content-Type": mimeFor(entry.filePath), "Content-Disposition": `attachment; filename="${fileName}"`, - "X-Uses-Remaining": String(entry.usesRemaining), }, }); } From e2eff6b97de2ac77af90b8b7a87a027f05cc7068 Mon Sep 17 00:00:00 2001 From: Backoffice Date: Thu, 9 Apr 2026 09:14:02 +0000 Subject: [PATCH 4/5] feat: add watch-comments skill Adds a skill + script for polling GitHub repos for new comments on issues and PRs. Script (scripts/watch-comments.ts): - Takes as the first arg - Flags: --interval, --pr, --issue, --once, --state-dir - Checks all three GitHub comment streams per PR: issue comments, inline review comments, submitted review bodies - Persists seen comment IDs to state/-.json so it never re-reports old comments across runs - Prints new comments to stdout - Sends Telegram notifications if TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID are set in the environment Skill (SKILL.md): - Quick start commands for all watch modes - Background execution pattern with PID tracking - Log tailing and watcher health check - Telegram setup - State file management (reset, custom dir) - Options reference table --- .gitignore | 1 + skills/watch-comments/SKILL.md | 151 +++++++++ .../watch-comments/scripts/watch-comments.ts | 300 ++++++++++++++++++ skills/watch-comments/state/.gitkeep | 0 4 files changed, 452 insertions(+) create mode 100644 skills/watch-comments/SKILL.md create mode 100644 skills/watch-comments/scripts/watch-comments.ts create mode 100644 skills/watch-comments/state/.gitkeep diff --git a/.gitignore b/.gitignore index aa0e901..b04b289 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ coverage/ # Optional local tooling .vercel .netlify +skills/watch-comments/state/*.json diff --git a/skills/watch-comments/SKILL.md b/skills/watch-comments/SKILL.md new file mode 100644 index 0000000..663ebfd --- /dev/null +++ b/skills/watch-comments/SKILL.md @@ -0,0 +1,151 @@ +--- +name: watch-comments +description: > + Use this skill to watch a GitHub repo for new comments on issues and PRs. + Triggers include: "watch for comments in X repo", "let me know when someone + comments on PR #N", "keep an eye on issue #N", "start watching owner/repo", + "notify me of new comments", or any request to monitor GitHub activity. + Always use this skill — don't try to manually poll with gh commands. +--- + +# Watch Comments Skill + +Polls GitHub for new comments on issues and PRs, printing them to stdout and +optionally sending Telegram notifications. State is persisted between runs so +the same comment is never reported twice. + +--- + +## Quick Start + +```bash +# Watch all open issues + PRs in a repo (runs forever, polls every 60s) +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo + +# Watch a specific PR +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --pr 42 + +# Watch a specific issue +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --issue 7 + +# Run one poll and exit (useful for testing / ad-hoc checks) +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --once + +# Custom poll interval (e.g. every 2 minutes) +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --interval 120 +``` + +--- + +## Running in the Background + +To watch without blocking the session: + +```bash +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo \ + >> /data/skills/watch-comments/owner-repo.log 2>&1 & + +echo "Watcher PID: $!" +``` + +Save the PID so you can stop it later: +```bash +echo $! > /data/skills/watch-comments/owner-repo.pid +``` + +--- + +## Checking on a Running Watcher + +```bash +# Tail the log +tail -f /data/skills/watch-comments/owner-repo.log + +# Check if it's still running +PID=$(cat /data/skills/watch-comments/owner-repo.pid) +kill -0 $PID 2>/dev/null && echo "running" || echo "not running" +``` + +## Stopping a Watcher + +```bash +kill $(cat /data/skills/watch-comments/owner-repo.pid) +``` + +--- + +## Telegram Notifications + +If `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in the environment, +new comments are also sent as Telegram messages. + +```bash +# Set once (persists to env) +export TELEGRAM_BOT_TOKEN= +export TELEGRAM_CHAT_ID= +``` + +--- + +## What Gets Watched + +For each open issue or PR, the script checks all three GitHub comment streams: + +| Stream | API endpoint | Captured | +|---|---|---| +| Top-level thread | `issues/:n/comments` | Issue comments, PR conversation | +| Inline code comments | `pulls/:n/comments` | PR review line comments | +| Review submissions | `pulls/:n/reviews` | Submitted review bodies | + +--- + +## State Files + +Seen comment IDs are stored in: +``` +/data/skills/watch-comments/state/-.json +``` + +To reset (re-report all existing comments on next run): +```bash +rm /data/skills/watch-comments/state/owner-repo.json +``` + +To use a custom state directory (e.g. for multiple watchers of the same repo): +```bash +bun watch-comments.ts owner/repo --state-dir /tmp/my-state +``` + +--- + +## Interpreting Output + +Each new comment prints like: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📬 New comment on PR #42 in owner/repo + "Fix the login bug" + by @alice at 2025-01-15T14:32:00.000Z + https://github.com/owner/repo/pull/42#issuecomment-123456 + + Looks good to me, but could you add a test for the edge case? +``` + +--- + +## Options Reference + +| Flag | Default | Description | +|---|---|---| +| `--interval ` | `60` | Poll interval in seconds | +| `--pr ` | — | Watch one PR only | +| `--issue ` | — | Watch one issue only | +| `--once` | — | Single poll, then exit | +| `--state-dir ` | `/data/skills/watch-comments/state` | Where to persist seen IDs | diff --git a/skills/watch-comments/scripts/watch-comments.ts b/skills/watch-comments/scripts/watch-comments.ts new file mode 100644 index 0000000..9107e29 --- /dev/null +++ b/skills/watch-comments/scripts/watch-comments.ts @@ -0,0 +1,300 @@ +#!/usr/bin/env bun +/** + * watch-comments.ts + * Polls GitHub for new comments on issues and PRs in a repo. + * + * Usage: + * bun watch-comments.ts [options] + * + * Options: + * --interval Poll interval in seconds (default: 60) + * --pr Watch a specific PR only + * --issue Watch a specific issue only + * --once Run one poll then exit (useful for testing) + * --state-dir Where to store seen-IDs state + * (default: /data/skills/watch-comments/state) + * + * Env vars required: + * GITHUB_TOKEN GitHub personal access token + * SSL_CERT_FILE Path to CA bundle (e.g. /data/cacert.pem) + * + * Env vars optional: + * TELEGRAM_BOT_TOKEN If set, sends new comments via Telegram + * TELEGRAM_CHAT_ID Required if TELEGRAM_BOT_TOKEN is set + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +// ── Args ───────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +function argValue(flag: string): string | undefined { + const i = args.indexOf(flag); + return i !== -1 ? args[i + 1] : undefined; +} + +const repo = args[0]; +if (!repo || repo.startsWith("--")) { + console.error( + "Usage: bun watch-comments.ts [--interval 60] [--pr N] [--issue N] [--once]" + ); + process.exit(1); +} +if (!repo.includes("/")) { + console.error(`Invalid repo format "${repo}" — expected owner/repo`); + process.exit(1); +} + +const intervalSecs = parseInt(argValue("--interval") ?? "60", 10); +const watchPR = argValue("--pr") != null ? parseInt(argValue("--pr")!, 10) : null; +const watchIssue = argValue("--issue") != null ? parseInt(argValue("--issue")!, 10) : null; +const runOnce = args.includes("--once"); +const stateDir = argValue("--state-dir") ?? "/data/skills/watch-comments/state"; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { + console.error("GITHUB_TOKEN env var is required"); + process.exit(1); +} + +const SSL_CERT_FILE = process.env.SSL_CERT_FILE ?? "/data/cacert.pem"; +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; +const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; +const GH = "/data/gh"; + +// ── State ───────────────────────────────────────────────────────────────────── + +interface SeenCursors { + issueComments: number; + reviewComments: number; + reviews: number; +} + +interface State { + // key = "issue:" or "pr:" + seen: Record; +} + +const stateFile = join(stateDir, `${repo.replace("/", "-")}.json`); + +function loadState(): State { + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + if (!existsSync(stateFile)) return { seen: {} }; + try { + return JSON.parse(readFileSync(stateFile, "utf8")) as State; + } catch { + return { seen: {} }; + } +} + +function saveState(state: State) { + writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} + +// ── GitHub API ──────────────────────────────────────────────────────────────── + +async function ghApi(path: string): Promise { + const proc = Bun.spawn([GH, "api", path, "--paginate"], { + env: { ...process.env, GH_TOKEN: GITHUB_TOKEN!, SSL_CERT_FILE }, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) throw new Error(`gh api ${path} failed (${code}): ${err.trim()}`); + const cleaned = out.trim(); + try { + return JSON.parse(cleaned) as T; + } catch { + // --paginate outputs one JSON array per page, concatenated with newlines + const pages = cleaned.split(/\n(?=\[)/).map((p) => JSON.parse(p) as unknown[]); + return pages.flat() as unknown as T; + } +} + +interface GHIssue { + number: number; + title: string; + pull_request?: unknown; +} +interface GHComment { + id: number; + user: { login: string }; + body: string; + html_url: string; + created_at: string; +} +interface GHReview { + id: number; + user: { login: string }; + body: string; + html_url: string; + state: string; + submitted_at: string; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +function formatItem( + kind: "issue" | "pr", + number: number, + title: string, + type: string, + c: GHComment | GHReview +): string { + const when = "created_at" in c ? c.created_at : (c as GHReview).submitted_at; + const preview = + c.body.trim().slice(0, 300) + (c.body.length > 300 ? "…" : ""); + return [ + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, + `📬 New ${type} on ${kind.toUpperCase()} #${number} in ${repo}`, + ` "${title}"`, + ` by @${c.user.login} at ${new Date(when).toISOString()}`, + ` ${c.html_url}`, + ``, + ` ${preview}`, + ].join("\n"); +} + +async function sendTelegram(text: string) { + if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return; + const esc = text + .replace(/&/g, "&") + .replace(//g, ">"); + await fetch( + `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: esc, + parse_mode: "HTML", + disable_web_page_preview: true, + }), + } + ); +} + +// ── Poll one issue or PR ────────────────────────────────────────────────────── + +async function pollItem( + state: State, + kind: "issue" | "pr", + number: number, + title: string, + out: string[] +) { + const key = `${kind}:${number}`; + const seen: SeenCursors = state.seen[key] ?? { + issueComments: 0, + reviewComments: 0, + reviews: 0, + }; + + // (a) Top-level thread comments (both issues and PRs) + const issueComments = await ghApi( + `repos/${repo}/issues/${number}/comments` + ); + const newIC = issueComments.filter((c) => c.id > seen.issueComments); + for (const c of newIC) out.push(formatItem(kind, number, title, "comment", c)); + if (newIC.length) seen.issueComments = Math.max(...newIC.map((c) => c.id)); + + // (b) Inline review comments (PRs only) + if (kind === "pr") { + const reviewComments = await ghApi( + `repos/${repo}/pulls/${number}/comments` + ); + const newRC = reviewComments.filter((c) => c.id > seen.reviewComments); + for (const c of newRC) + out.push(formatItem(kind, number, title, "inline comment", c)); + if (newRC.length) + seen.reviewComments = Math.max(...newRC.map((c) => c.id)); + + // (c) Submitted review bodies (PRs only) + const reviews = await ghApi( + `repos/${repo}/pulls/${number}/reviews` + ); + const newRev = reviews.filter( + (r) => r.id > seen.reviews && r.body?.trim() + ); + for (const r of newRev) + out.push(formatItem(kind, number, title, `review (${r.state})`, r)); + if (newRev.length) seen.reviews = Math.max(...newRev.map((r) => r.id)); + } + + state.seen[key] = seen; +} + +// ── Main poll loop ──────────────────────────────────────────────────────────── + +async function poll() { + const state = loadState(); + const newComments: string[] = []; + const errors: string[] = []; + + if (watchPR !== null) { + try { + const pr = await ghApi<{ title: string }>( + `repos/${repo}/pulls/${watchPR}` + ); + await pollItem(state, "pr", watchPR, pr.title, newComments); + } catch (e) { + errors.push(`PR #${watchPR}: ${e}`); + } + } else if (watchIssue !== null) { + try { + const issue = await ghApi<{ title: string }>( + `repos/${repo}/issues/${watchIssue}` + ); + await pollItem(state, "issue", watchIssue, issue.title, newComments); + } catch (e) { + errors.push(`Issue #${watchIssue}: ${e}`); + } + } else { + // All open issues + PRs + const items = await ghApi( + `repos/${repo}/issues?state=open&per_page=100` + ); + for (const item of items) { + const kind = item.pull_request ? "pr" : "issue"; + try { + await pollItem(state, kind, item.number, item.title, newComments); + } catch (e) { + errors.push(`${kind} #${item.number}: ${e}`); + } + } + } + + saveState(state); + + for (const msg of newComments) { + console.log(msg); + await sendTelegram(msg); + } + + for (const e of errors) console.error(`[error] ${e}`); + + if (newComments.length === 0 && !runOnce) { + process.stdout.write( + `[${new Date().toISOString()}] No new comments. Next poll in ${intervalSecs}s\r` + ); + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +console.log(`👁 Watching ${repo} — polling every ${intervalSecs}s`); +if (watchPR !== null) console.log(` Scope: PR #${watchPR} only`); +else if (watchIssue !== null) console.log(` Scope: Issue #${watchIssue} only`); +else console.log(` Scope: all open issues + PRs`); +console.log(` State file: ${stateFile}`); +console.log(` Telegram: ${TELEGRAM_BOT_TOKEN ? "enabled" : "disabled"}`); +console.log(); + +await poll(); +if (!runOnce) setInterval(poll, intervalSecs * 1000); diff --git a/skills/watch-comments/state/.gitkeep b/skills/watch-comments/state/.gitkeep new file mode 100644 index 0000000..e69de29 From 859b989d24e72a5e3b0ab728963d31e098a5289d Mon Sep 17 00:00:00 2001 From: Backoffice Date: Thu, 9 Apr 2026 09:18:42 +0000 Subject: [PATCH 5/5] refactor: rename to gh-comments, add fetch/watch modes - Rename skill watch-comments -> gh-comments - Rename script watch-comments.ts -> gh-comments.ts - Default mode: fetch new comments once and exit - --watch flag: poll continuously (previous behaviour) - --all flag: ignore state, show all comments - Update SKILL.md to match new interface --- skills/gh-comments/SKILL.md | 118 +++++++ skills/gh-comments/scripts/gh-comments.ts | 255 +++++++++++++++ .../state/.gitkeep | 0 skills/watch-comments/SKILL.md | 151 --------- .../watch-comments/scripts/watch-comments.ts | 300 ------------------ 5 files changed, 373 insertions(+), 451 deletions(-) create mode 100644 skills/gh-comments/SKILL.md create mode 100644 skills/gh-comments/scripts/gh-comments.ts rename skills/{watch-comments => gh-comments}/state/.gitkeep (100%) delete mode 100644 skills/watch-comments/SKILL.md delete mode 100644 skills/watch-comments/scripts/watch-comments.ts diff --git a/skills/gh-comments/SKILL.md b/skills/gh-comments/SKILL.md new file mode 100644 index 0000000..2c2705e --- /dev/null +++ b/skills/gh-comments/SKILL.md @@ -0,0 +1,118 @@ +--- +name: gh-comments +description: > + Use this skill to fetch or watch GitHub comments on issues and PRs. + Triggers include: "get comments in X repo", "watch for comments in X repo", + "any new comments on PR #N?", "keep an eye on issue #N", "start watching + owner/repo", "notify me of new comments". Default mode fetches once and exits; + use --watch to poll continuously. +--- + +# gh-comments Skill + +Fetch new comments once, or watch continuously. State is persisted between runs +so the same comment is never reported twice. + +--- + +## Usage + +```bash +# Fetch new comments once and exit (default) +bun gh-comments.ts + +# Watch continuously, polling every 60s +bun gh-comments.ts --watch + +# Scope to a specific PR or issue +bun gh-comments.ts --pr 42 +bun gh-comments.ts --issue 7 + +# Custom poll interval (watch mode only) +bun gh-comments.ts --watch --interval 120 + +# Show all comments, ignoring state (re-fetch everything seen so far) +bun gh-comments.ts --all +``` + +Always set env vars: +```bash +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" bun gh-comments.ts ... +``` + +--- + +## Running in the Background (watch mode) + +```bash +SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ + bun /app/skills/gh-comments/scripts/gh-comments.ts --watch \ + >> /data/skills/gh-comments/-.log 2>&1 & + +echo $! > /data/skills/gh-comments/-.pid +echo "Watcher started" +``` + +Check on it: +```bash +tail -f /data/skills/gh-comments/-.log +kill -0 $(cat /data/skills/gh-comments/-.pid) && echo running || echo stopped +``` + +Stop it: +```bash +kill $(cat /data/skills/gh-comments/-.pid) +``` + +--- + +## Telegram Notifications + +Set once; persists to env: +```bash +export TELEGRAM_BOT_TOKEN= +export TELEGRAM_CHAT_ID= +``` + +When set, every new comment is also sent as a Telegram message. + +--- + +## What Gets Checked + +For each open issue or PR, all three GitHub comment streams are checked: + +| Stream | Covers | +|---|---| +| `issues/:n/comments` | Top-level thread (issues + PR conversation) | +| `pulls/:n/comments` | Inline review comments | +| `pulls/:n/reviews` | Submitted review bodies | + +--- + +## State Files + +Seen comment IDs are stored per-repo at: +``` +/data/skills/gh-comments/state/-.json +``` + +Reset (re-report all existing comments on next run): +```bash +rm /data/skills/gh-comments/state/-.json +``` + +Or use `--all` for a one-off full fetch without touching the state file. + +--- + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--watch` | — | Poll continuously instead of fetching once | +| `--pr ` | — | Scope to one PR | +| `--issue ` | — | Scope to one issue | +| `--interval ` | `60` | Seconds between polls (watch mode only) | +| `--all` | — | Ignore state; show all comments | +| `--state-dir ` | `/data/skills/gh-comments/state` | Custom state location | diff --git a/skills/gh-comments/scripts/gh-comments.ts b/skills/gh-comments/scripts/gh-comments.ts new file mode 100644 index 0000000..b694f35 --- /dev/null +++ b/skills/gh-comments/scripts/gh-comments.ts @@ -0,0 +1,255 @@ +#!/usr/bin/env bun +/** + * gh-comments + * Fetch or watch GitHub comments on issues and PRs in a repo. + * + * Usage: + * bun gh-comments.ts [options] + * + * Modes: + * (default) Fetch new comments once and exit + * --watch Poll continuously for new comments + * + * Options: + * --pr Scope to a specific PR + * --issue Scope to a specific issue + * --interval Poll interval in watch mode (default: 60) + * --state-dir Where to persist seen IDs + * (default: /data/skills/gh-comments/state) + * --all Include already-seen comments (ignores state) + * + * Env vars required: + * GITHUB_TOKEN GitHub personal access token + * SSL_CERT_FILE Path to CA bundle (e.g. /data/cacert.pem) + * + * Env vars optional: + * TELEGRAM_BOT_TOKEN If set, sends new comments via Telegram + * TELEGRAM_CHAT_ID Required if TELEGRAM_BOT_TOKEN is set + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +// ── Args ────────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); + +function flag(name: string): boolean { + return args.includes(name); +} +function opt(name: string): string | undefined { + const i = args.indexOf(name); + return i !== -1 ? args[i + 1] : undefined; +} + +const repo = args[0]; +if (!repo || repo.startsWith("--")) { + console.error( + "Usage: bun gh-comments.ts [--watch] [--pr N] [--issue N] [--interval 60] [--all]" + ); + process.exit(1); +} +if (!repo.includes("/")) { + console.error(`Invalid repo "${repo}" — expected owner/repo`); + process.exit(1); +} + +const watchMode = flag("--watch"); +const showAll = flag("--all"); +const intervalSec = parseInt(opt("--interval") ?? "60", 10); +const watchPR = opt("--pr") != null ? parseInt(opt("--pr")!, 10) : null; +const watchIssue = opt("--issue") != null ? parseInt(opt("--issue")!, 10) : null; +const stateDir = opt("--state-dir") ?? "/data/skills/gh-comments/state"; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +if (!GITHUB_TOKEN) { console.error("GITHUB_TOKEN env var is required"); process.exit(1); } + +const SSL_CERT_FILE = process.env.SSL_CERT_FILE ?? "/data/cacert.pem"; +const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; +const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; +const GH = "/data/gh"; + +// ── State ───────────────────────────────────────────────────────────────────── + +interface SeenCursors { + issueComments: number; + reviewComments: number; + reviews: number; +} +interface State { + seen: Record; +} + +const stateFile = join(stateDir, `${repo.replace("/", "-")}.json`); + +function loadState(): State { + if (showAll) return { seen: {} }; + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + if (!existsSync(stateFile)) return { seen: {} }; + try { return JSON.parse(readFileSync(stateFile, "utf8")) as State; } + catch { return { seen: {} }; } +} + +function saveState(state: State) { + if (showAll) return; // don't advance cursors when --all is used + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} + +// ── GitHub API ──────────────────────────────────────────────────────────────── + +async function ghApi(path: string): Promise { + const proc = Bun.spawn([GH, "api", path, "--paginate"], { + env: { ...process.env, GH_TOKEN: GITHUB_TOKEN!, SSL_CERT_FILE }, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + const err = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) throw new Error(`gh api ${path} failed (${code}): ${err.trim()}`); + const cleaned = out.trim(); + try { return JSON.parse(cleaned) as T; } + catch { + // --paginate outputs one array per page; join them + const pages = cleaned.split(/\n(?=\[)/).map((p) => JSON.parse(p) as unknown[]); + return pages.flat() as unknown as T; + } +} + +interface GHIssue { number: number; title: string; pull_request?: unknown } +interface GHComment { id: number; user: { login: string }; body: string; html_url: string; created_at: string } +interface GHReview { id: number; user: { login: string }; body: string; html_url: string; state: string; submitted_at: string } + +// ── Output ──────────────────────────────────────────────────────────────────── + +function fmt( + kind: "issue" | "pr", + number: number, + title: string, + type: string, + c: GHComment | GHReview +): string { + const when = "created_at" in c ? c.created_at : (c as GHReview).submitted_at; + const preview = c.body.trim().slice(0, 300) + (c.body.length > 300 ? "…" : ""); + return [ + `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, + `📬 New ${type} on ${kind.toUpperCase()} #${number} in ${repo}`, + ` "${title}"`, + ` by @${c.user.login} at ${new Date(when).toISOString()}`, + ` ${c.html_url}`, + ``, + ` ${preview}`, + ].join("\n"); +} + +async function sendTelegram(text: string) { + if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return; + const esc = text.replace(/&/g,"&").replace(//g,">"); + await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: TELEGRAM_CHAT_ID, + text: esc, + parse_mode: "HTML", + disable_web_page_preview: true, + }), + }); +} + +// ── Poll one item ───────────────────────────────────────────────────────────── + +async function pollItem( + state: State, + kind: "issue" | "pr", + number: number, + title: string, + out: string[] +) { + const key = `${kind}:${number}`; + const seen: SeenCursors = state.seen[key] ?? { issueComments: 0, reviewComments: 0, reviews: 0 }; + + // (a) Top-level thread comments + const issueComments = await ghApi(`repos/${repo}/issues/${number}/comments`); + const newIC = issueComments.filter((c) => c.id > seen.issueComments); + for (const c of newIC) out.push(fmt(kind, number, title, "comment", c)); + if (newIC.length) seen.issueComments = Math.max(...newIC.map((c) => c.id)); + + if (kind === "pr") { + // (b) Inline review comments + const reviewComments = await ghApi(`repos/${repo}/pulls/${number}/comments`); + const newRC = reviewComments.filter((c) => c.id > seen.reviewComments); + for (const c of newRC) out.push(fmt(kind, number, title, "inline comment", c)); + if (newRC.length) seen.reviewComments = Math.max(...newRC.map((c) => c.id)); + + // (c) Submitted review bodies + const reviews = await ghApi(`repos/${repo}/pulls/${number}/reviews`); + const newRev = reviews.filter((r) => r.id > seen.reviews && r.body?.trim()); + for (const r of newRev) out.push(fmt(kind, number, title, `review (${r.state})`, r)); + if (newRev.length) seen.reviews = Math.max(...newRev.map((r) => r.id)); + } + + state.seen[key] = seen; +} + +// ── Poll ────────────────────────────────────────────────────────────────────── + +async function poll(): Promise { + const state = loadState(); + const found: string[] = []; + const errors: string[] = []; + + if (watchPR !== null) { + try { + const pr = await ghApi<{ title: string }>(`repos/${repo}/pulls/${watchPR}`); + await pollItem(state, "pr", watchPR, pr.title, found); + } catch (e) { errors.push(`PR #${watchPR}: ${e}`); } + } else if (watchIssue !== null) { + try { + const issue = await ghApi<{ title: string }>(`repos/${repo}/issues/${watchIssue}`); + await pollItem(state, "issue", watchIssue, issue.title, found); + } catch (e) { errors.push(`Issue #${watchIssue}: ${e}`); } + } else { + const items = await ghApi(`repos/${repo}/issues?state=open&per_page=100`); + for (const item of items) { + const kind = item.pull_request ? "pr" : "issue"; + try { await pollItem(state, kind, item.number, item.title, found); } + catch (e) { errors.push(`${kind} #${item.number}: ${e}`); } + } + } + + saveState(state); + + for (const msg of found) { + console.log(msg); + await sendTelegram(msg); + } + for (const e of errors) console.error(`[error] ${e}`); + + return found.length; +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +if (watchMode) { + console.log(`👁 Watching ${repo} — polling every ${intervalSec}s`); + if (watchPR !== null) console.log(` Scope: PR #${watchPR} only`); + else if (watchIssue !== null) console.log(` Scope: Issue #${watchIssue} only`); + else console.log(` Scope: all open issues + PRs`); + console.log(` State: ${stateFile}`); + console.log(` Telegram: ${TELEGRAM_BOT_TOKEN ? "enabled" : "disabled"}`); + console.log(); + + await poll(); + setInterval(async () => { + const n = await poll(); + if (n === 0) process.stdout.write(`[${new Date().toISOString()}] No new comments. Next poll in ${intervalSec}s\r`); + }, intervalSec * 1000); +} else { + // Fetch mode: one poll, report count, exit + const n = await poll(); + if (n === 0) console.log("No new comments."); + else console.log(`\n${n} new comment${n === 1 ? "" : "s"}.`); + process.exit(0); +} diff --git a/skills/watch-comments/state/.gitkeep b/skills/gh-comments/state/.gitkeep similarity index 100% rename from skills/watch-comments/state/.gitkeep rename to skills/gh-comments/state/.gitkeep diff --git a/skills/watch-comments/SKILL.md b/skills/watch-comments/SKILL.md deleted file mode 100644 index 663ebfd..0000000 --- a/skills/watch-comments/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: watch-comments -description: > - Use this skill to watch a GitHub repo for new comments on issues and PRs. - Triggers include: "watch for comments in X repo", "let me know when someone - comments on PR #N", "keep an eye on issue #N", "start watching owner/repo", - "notify me of new comments", or any request to monitor GitHub activity. - Always use this skill — don't try to manually poll with gh commands. ---- - -# Watch Comments Skill - -Polls GitHub for new comments on issues and PRs, printing them to stdout and -optionally sending Telegram notifications. State is persisted between runs so -the same comment is never reported twice. - ---- - -## Quick Start - -```bash -# Watch all open issues + PRs in a repo (runs forever, polls every 60s) -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo - -# Watch a specific PR -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --pr 42 - -# Watch a specific issue -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --issue 7 - -# Run one poll and exit (useful for testing / ad-hoc checks) -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --once - -# Custom poll interval (e.g. every 2 minutes) -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo --interval 120 -``` - ---- - -## Running in the Background - -To watch without blocking the session: - -```bash -SSL_CERT_FILE=/data/cacert.pem GH_TOKEN="$GITHUB_TOKEN" \ - bun /app/skills/watch-comments/scripts/watch-comments.ts owner/repo \ - >> /data/skills/watch-comments/owner-repo.log 2>&1 & - -echo "Watcher PID: $!" -``` - -Save the PID so you can stop it later: -```bash -echo $! > /data/skills/watch-comments/owner-repo.pid -``` - ---- - -## Checking on a Running Watcher - -```bash -# Tail the log -tail -f /data/skills/watch-comments/owner-repo.log - -# Check if it's still running -PID=$(cat /data/skills/watch-comments/owner-repo.pid) -kill -0 $PID 2>/dev/null && echo "running" || echo "not running" -``` - -## Stopping a Watcher - -```bash -kill $(cat /data/skills/watch-comments/owner-repo.pid) -``` - ---- - -## Telegram Notifications - -If `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in the environment, -new comments are also sent as Telegram messages. - -```bash -# Set once (persists to env) -export TELEGRAM_BOT_TOKEN= -export TELEGRAM_CHAT_ID= -``` - ---- - -## What Gets Watched - -For each open issue or PR, the script checks all three GitHub comment streams: - -| Stream | API endpoint | Captured | -|---|---|---| -| Top-level thread | `issues/:n/comments` | Issue comments, PR conversation | -| Inline code comments | `pulls/:n/comments` | PR review line comments | -| Review submissions | `pulls/:n/reviews` | Submitted review bodies | - ---- - -## State Files - -Seen comment IDs are stored in: -``` -/data/skills/watch-comments/state/-.json -``` - -To reset (re-report all existing comments on next run): -```bash -rm /data/skills/watch-comments/state/owner-repo.json -``` - -To use a custom state directory (e.g. for multiple watchers of the same repo): -```bash -bun watch-comments.ts owner/repo --state-dir /tmp/my-state -``` - ---- - -## Interpreting Output - -Each new comment prints like: - -``` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📬 New comment on PR #42 in owner/repo - "Fix the login bug" - by @alice at 2025-01-15T14:32:00.000Z - https://github.com/owner/repo/pull/42#issuecomment-123456 - - Looks good to me, but could you add a test for the edge case? -``` - ---- - -## Options Reference - -| Flag | Default | Description | -|---|---|---| -| `--interval ` | `60` | Poll interval in seconds | -| `--pr ` | — | Watch one PR only | -| `--issue ` | — | Watch one issue only | -| `--once` | — | Single poll, then exit | -| `--state-dir ` | `/data/skills/watch-comments/state` | Where to persist seen IDs | diff --git a/skills/watch-comments/scripts/watch-comments.ts b/skills/watch-comments/scripts/watch-comments.ts deleted file mode 100644 index 9107e29..0000000 --- a/skills/watch-comments/scripts/watch-comments.ts +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env bun -/** - * watch-comments.ts - * Polls GitHub for new comments on issues and PRs in a repo. - * - * Usage: - * bun watch-comments.ts [options] - * - * Options: - * --interval Poll interval in seconds (default: 60) - * --pr Watch a specific PR only - * --issue Watch a specific issue only - * --once Run one poll then exit (useful for testing) - * --state-dir Where to store seen-IDs state - * (default: /data/skills/watch-comments/state) - * - * Env vars required: - * GITHUB_TOKEN GitHub personal access token - * SSL_CERT_FILE Path to CA bundle (e.g. /data/cacert.pem) - * - * Env vars optional: - * TELEGRAM_BOT_TOKEN If set, sends new comments via Telegram - * TELEGRAM_CHAT_ID Required if TELEGRAM_BOT_TOKEN is set - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; - -// ── Args ───────────────────────────────────────────────────────────────────── - -const args = process.argv.slice(2); - -function argValue(flag: string): string | undefined { - const i = args.indexOf(flag); - return i !== -1 ? args[i + 1] : undefined; -} - -const repo = args[0]; -if (!repo || repo.startsWith("--")) { - console.error( - "Usage: bun watch-comments.ts [--interval 60] [--pr N] [--issue N] [--once]" - ); - process.exit(1); -} -if (!repo.includes("/")) { - console.error(`Invalid repo format "${repo}" — expected owner/repo`); - process.exit(1); -} - -const intervalSecs = parseInt(argValue("--interval") ?? "60", 10); -const watchPR = argValue("--pr") != null ? parseInt(argValue("--pr")!, 10) : null; -const watchIssue = argValue("--issue") != null ? parseInt(argValue("--issue")!, 10) : null; -const runOnce = args.includes("--once"); -const stateDir = argValue("--state-dir") ?? "/data/skills/watch-comments/state"; - -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -if (!GITHUB_TOKEN) { - console.error("GITHUB_TOKEN env var is required"); - process.exit(1); -} - -const SSL_CERT_FILE = process.env.SSL_CERT_FILE ?? "/data/cacert.pem"; -const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; -const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID; -const GH = "/data/gh"; - -// ── State ───────────────────────────────────────────────────────────────────── - -interface SeenCursors { - issueComments: number; - reviewComments: number; - reviews: number; -} - -interface State { - // key = "issue:" or "pr:" - seen: Record; -} - -const stateFile = join(stateDir, `${repo.replace("/", "-")}.json`); - -function loadState(): State { - if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); - if (!existsSync(stateFile)) return { seen: {} }; - try { - return JSON.parse(readFileSync(stateFile, "utf8")) as State; - } catch { - return { seen: {} }; - } -} - -function saveState(state: State) { - writeFileSync(stateFile, JSON.stringify(state, null, 2)); -} - -// ── GitHub API ──────────────────────────────────────────────────────────────── - -async function ghApi(path: string): Promise { - const proc = Bun.spawn([GH, "api", path, "--paginate"], { - env: { ...process.env, GH_TOKEN: GITHUB_TOKEN!, SSL_CERT_FILE }, - stdout: "pipe", - stderr: "pipe", - }); - const out = await new Response(proc.stdout).text(); - const err = await new Response(proc.stderr).text(); - const code = await proc.exited; - if (code !== 0) throw new Error(`gh api ${path} failed (${code}): ${err.trim()}`); - const cleaned = out.trim(); - try { - return JSON.parse(cleaned) as T; - } catch { - // --paginate outputs one JSON array per page, concatenated with newlines - const pages = cleaned.split(/\n(?=\[)/).map((p) => JSON.parse(p) as unknown[]); - return pages.flat() as unknown as T; - } -} - -interface GHIssue { - number: number; - title: string; - pull_request?: unknown; -} -interface GHComment { - id: number; - user: { login: string }; - body: string; - html_url: string; - created_at: string; -} -interface GHReview { - id: number; - user: { login: string }; - body: string; - html_url: string; - state: string; - submitted_at: string; -} - -// ── Formatting ──────────────────────────────────────────────────────────────── - -function formatItem( - kind: "issue" | "pr", - number: number, - title: string, - type: string, - c: GHComment | GHReview -): string { - const when = "created_at" in c ? c.created_at : (c as GHReview).submitted_at; - const preview = - c.body.trim().slice(0, 300) + (c.body.length > 300 ? "…" : ""); - return [ - `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`, - `📬 New ${type} on ${kind.toUpperCase()} #${number} in ${repo}`, - ` "${title}"`, - ` by @${c.user.login} at ${new Date(when).toISOString()}`, - ` ${c.html_url}`, - ``, - ` ${preview}`, - ].join("\n"); -} - -async function sendTelegram(text: string) { - if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return; - const esc = text - .replace(/&/g, "&") - .replace(//g, ">"); - await fetch( - `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chat_id: TELEGRAM_CHAT_ID, - text: esc, - parse_mode: "HTML", - disable_web_page_preview: true, - }), - } - ); -} - -// ── Poll one issue or PR ────────────────────────────────────────────────────── - -async function pollItem( - state: State, - kind: "issue" | "pr", - number: number, - title: string, - out: string[] -) { - const key = `${kind}:${number}`; - const seen: SeenCursors = state.seen[key] ?? { - issueComments: 0, - reviewComments: 0, - reviews: 0, - }; - - // (a) Top-level thread comments (both issues and PRs) - const issueComments = await ghApi( - `repos/${repo}/issues/${number}/comments` - ); - const newIC = issueComments.filter((c) => c.id > seen.issueComments); - for (const c of newIC) out.push(formatItem(kind, number, title, "comment", c)); - if (newIC.length) seen.issueComments = Math.max(...newIC.map((c) => c.id)); - - // (b) Inline review comments (PRs only) - if (kind === "pr") { - const reviewComments = await ghApi( - `repos/${repo}/pulls/${number}/comments` - ); - const newRC = reviewComments.filter((c) => c.id > seen.reviewComments); - for (const c of newRC) - out.push(formatItem(kind, number, title, "inline comment", c)); - if (newRC.length) - seen.reviewComments = Math.max(...newRC.map((c) => c.id)); - - // (c) Submitted review bodies (PRs only) - const reviews = await ghApi( - `repos/${repo}/pulls/${number}/reviews` - ); - const newRev = reviews.filter( - (r) => r.id > seen.reviews && r.body?.trim() - ); - for (const r of newRev) - out.push(formatItem(kind, number, title, `review (${r.state})`, r)); - if (newRev.length) seen.reviews = Math.max(...newRev.map((r) => r.id)); - } - - state.seen[key] = seen; -} - -// ── Main poll loop ──────────────────────────────────────────────────────────── - -async function poll() { - const state = loadState(); - const newComments: string[] = []; - const errors: string[] = []; - - if (watchPR !== null) { - try { - const pr = await ghApi<{ title: string }>( - `repos/${repo}/pulls/${watchPR}` - ); - await pollItem(state, "pr", watchPR, pr.title, newComments); - } catch (e) { - errors.push(`PR #${watchPR}: ${e}`); - } - } else if (watchIssue !== null) { - try { - const issue = await ghApi<{ title: string }>( - `repos/${repo}/issues/${watchIssue}` - ); - await pollItem(state, "issue", watchIssue, issue.title, newComments); - } catch (e) { - errors.push(`Issue #${watchIssue}: ${e}`); - } - } else { - // All open issues + PRs - const items = await ghApi( - `repos/${repo}/issues?state=open&per_page=100` - ); - for (const item of items) { - const kind = item.pull_request ? "pr" : "issue"; - try { - await pollItem(state, kind, item.number, item.title, newComments); - } catch (e) { - errors.push(`${kind} #${item.number}: ${e}`); - } - } - } - - saveState(state); - - for (const msg of newComments) { - console.log(msg); - await sendTelegram(msg); - } - - for (const e of errors) console.error(`[error] ${e}`); - - if (newComments.length === 0 && !runOnce) { - process.stdout.write( - `[${new Date().toISOString()}] No new comments. Next poll in ${intervalSecs}s\r` - ); - } -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -console.log(`👁 Watching ${repo} — polling every ${intervalSecs}s`); -if (watchPR !== null) console.log(` Scope: PR #${watchPR} only`); -else if (watchIssue !== null) console.log(` Scope: Issue #${watchIssue} only`); -else console.log(` Scope: all open issues + PRs`); -console.log(` State file: ${stateFile}`); -console.log(` Telegram: ${TELEGRAM_BOT_TOKEN ? "enabled" : "disabled"}`); -console.log(); - -await poll(); -if (!runOnce) setInterval(poll, intervalSecs * 1000);