pr-prism 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, dev]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ typecheck:
11
+ name: Type Check
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup pnpm
18
+ uses: pnpm/action-setup@v4
19
+ with:
20
+ version: 9.15.0
21
+
22
+ - name: Setup Node.js
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: "22"
26
+ cache: "pnpm"
27
+
28
+ - name: Install dependencies
29
+ run: pnpm install --frozen-lockfile
30
+
31
+ - name: Run TypeScript type check
32
+ run: pnpm run typecheck
@@ -0,0 +1,37 @@
1
+ name: Auto Delete Merged Branch
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+
7
+ jobs:
8
+ delete-branch:
9
+ if: >
10
+ github.event.pull_request.merged == true &&
11
+ github.event.pull_request.head.repo.full_name == github.repository
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: write
15
+
16
+ steps:
17
+ - name: Delete merged branch
18
+ env:
19
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20
+ BRANCH_NAME: ${{ github.event.pull_request.head.ref }}
21
+ REPO: ${{ github.repository }}
22
+ run: |
23
+ PROTECTED_BRANCHES="main master dev develop staging production release"
24
+
25
+ for protected in $PROTECTED_BRANCHES; do
26
+ if [ "$BRANCH_NAME" = "$protected" ]; then
27
+ echo "ā­ļø Skipping protected branch: $BRANCH_NAME"
28
+ exit 0
29
+ fi
30
+ done
31
+
32
+ ENCODED_BRANCH=$(printf '%s' "$BRANCH_NAME" | jq -sRr @uri)
33
+
34
+ echo "šŸ—‘ļø Deleting branch: $BRANCH_NAME"
35
+ gh api -X DELETE "repos/$REPO/git/refs/heads/$ENCODED_BRANCH" \
36
+ && echo "āœ… Branch '$BRANCH_NAME' deleted successfully" \
37
+ || echo "āš ļø Branch '$BRANCH_NAME' may have already been deleted"
@@ -0,0 +1,39 @@
1
+ name: Dependabot Auto-Merge
2
+
3
+ on:
4
+ pull_request_target:
5
+ types: [opened, synchronize, reopened]
6
+
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ auto-merge:
13
+ if: github.event.pull_request.user.login == 'dependabot[bot]'
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Fetch Dependabot metadata
18
+ id: metadata
19
+ uses: dependabot/fetch-metadata@v2
20
+ with:
21
+ github-token: ${{ secrets.GITHUB_TOKEN }}
22
+
23
+ - name: Auto-approve minor and patch updates
24
+ if: >
25
+ steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
26
+ steps.metadata.outputs.update-type == 'version-update:semver-patch'
27
+ env:
28
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29
+ PR_URL: ${{ github.event.pull_request.html_url }}
30
+ run: gh pr review "$PR_URL" --approve
31
+
32
+ - name: Auto-merge minor and patch updates
33
+ if: >
34
+ steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
35
+ steps.metadata.outputs.update-type == 'version-update:semver-patch'
36
+ env:
37
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38
+ PR_URL: ${{ github.event.pull_request.html_url }}
39
+ run: gh pr merge "$PR_URL" --squash --auto
@@ -0,0 +1,21 @@
1
+ name: Label PRs
2
+
3
+ on:
4
+ pull_request_target:
5
+ types: [opened, synchronize]
6
+
7
+ jobs:
8
+ label:
9
+ name: Auto-label
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write
14
+ steps:
15
+ - name: Checkout
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Label PR
19
+ uses: actions/labeler@v5
20
+ with:
21
+ configuration-path: .github/labeler.yml
@@ -0,0 +1,38 @@
1
+ name: Security Audit
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ - cron: '0 9 * * 1'
10
+
11
+ jobs:
12
+ audit:
13
+ name: Dependency Audit
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Setup pnpm
20
+ uses: pnpm/action-setup@v4
21
+ with:
22
+ version: 9.15.0
23
+
24
+ - name: Setup Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: "22"
28
+ cache: "pnpm"
29
+
30
+ - name: Install dependencies
31
+ run: pnpm install --frozen-lockfile
32
+
33
+ - name: Run pnpm audit
34
+ run: pnpm audit --audit-level=high
35
+ continue-on-error: true
36
+
37
+ - name: Run pnpm audit (strict - critical only)
38
+ run: pnpm audit --audit-level=critical
@@ -0,0 +1,35 @@
1
+ name: Version Bump
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ bump:
9
+ if: "!startsWith(github.event.head_commit.message, 'chore: bump version')"
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+ with:
17
+ token: ${{ secrets.GITHUB_TOKEN }}
18
+
19
+ - name: Setup Node.js
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: "22"
23
+
24
+ - name: Configure git
25
+ run: |
26
+ git config user.name "github-actions[bot]"
27
+ git config user.email "github-actions[bot]@users.noreply.github.com"
28
+
29
+ - name: Bump patch version and push
30
+ run: |
31
+ npm version patch --no-git-tag-version
32
+ VERSION=$(node -p "require('./package.json').version")
33
+ git add package.json
34
+ git commit -m "chore: bump version to v${VERSION}"
35
+ git push
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # pr-prism
2
+
3
+ > Filter the noise. Focus on what matters.
4
+
5
+ **pr-prism** is a stateful GitHub PR review scraper built for AI agent workflows. It fetches review comments directly via the GitHub GraphQL API, filters out bots and noise, emits only what's actionable — once per comment, forever cached — and can resolve handled threads and tag agents for re-review when fixes are pushed.
6
+
7
+ ---
8
+
9
+ ## The Problem
10
+
11
+ Running an AI agent in a PR-fix loop has one brutal problem: **statefulness**.
12
+
13
+ Every re-run, the agent re-reads the same resolved comments, outdated threads, and bot spam — wasting tokens, inflating context, and confusing what actually needs fixing right now.
14
+
15
+ ## The Solution
16
+
17
+ pr-prism solves this with a two-command workflow:
18
+
19
+ 1. **`pr-review`** — scrapes new comments, caches IDs so re-runs only show what's new
20
+ 2. **`pr-resolve`** — resolves handled threads via GraphQL and tags AI agents for re-review
21
+
22
+ ---
23
+
24
+ ## Features
25
+
26
+ | Feature | What it does |
27
+ |---|---|
28
+ | **ID cache** | `pr-reviews/.scraped-ids.json` — re-runs emit only new comments |
29
+ | **Resolved threads** | Silently skipped via GraphQL `isResolved` |
30
+ | **Outdated threads** | Flagged with `āš ļø OUTDATED` so agents don't chase dead feedback |
31
+ | **Bot filter** | Authors ending in `[bot]` or matching `KNOWN_BOTS` are skipped |
32
+ | **Suggested changes** | ` ```suggestion ` blocks rendered as clean `diff` (REMOVE / ADD) |
33
+ | **File path** | Each inline comment prefixed with `šŸ“„ File: path/to/file.ts` |
34
+ | **Noise domains** | Social sharing links (Twitter, Reddit, LinkedIn, CodeAnt) stripped |
35
+ | **Thread resolution** | `pr-resolve` closes threads via `resolveReviewThread` mutation |
36
+ | **Agent tagging** | `--tag-agents` posts a configurable @mention comment after resolving |
37
+
38
+ ---
39
+
40
+ ## Requirements
41
+
42
+ - Node.js ≄ 20
43
+ - [pnpm](https://pnpm.io) (or npm / yarn)
44
+ - [gh CLI](https://cli.github.com) authenticated: `gh auth login`
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ git clone https://github.com/YosefHayim/pr-prism
52
+ cd pr-prism
53
+ pnpm install
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Usage
59
+
60
+ ### `pr-review` — scrape review comments
61
+
62
+ ```bash
63
+ pnpm run pr-review # detect repo, list open PRs, interactive select
64
+ pnpm run pr-review -- 42 # process PR #42 directly
65
+ pnpm run pr-review -- <url> # process by full GitHub PR URL
66
+ ```
67
+
68
+ ### `pr-resolve` — resolve threads + tag agents
69
+
70
+ ```bash
71
+ pnpm run pr-resolve # resolve threads from latest .threads-*.json
72
+ pnpm run pr-resolve -- 42 # explicit PR number
73
+ pnpm run pr-resolve -- 42 --dry-run # preview what would be resolved
74
+ pnpm run pr-resolve -- 42 --tag-agents # resolve + post @mention comment
75
+ pnpm run pr-resolve -- 42 --tag-agents --comment "Fixed in abc123"
76
+ pnpm run pr-resolve -- 42 --unresolve # re-open threads if needed
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Output
82
+
83
+ | File | Description |
84
+ |---|---|
85
+ | `pr-reviews/new-<timestamp>.md` | New actionable comments since last run |
86
+ | `pr-reviews/.scraped-ids.json` | Persistent ID cache — **commit this file** |
87
+ | `pr-reviews/.threads-<pr>.json` | Thread IDs consumed by `pr-resolve` |
88
+
89
+ ---
90
+
91
+ ## The AI Agent Loop
92
+
93
+ ```
94
+ 1. pnpm run pr-review -- <PR number>
95
+ → pr-reviews/new-<timestamp>.md (new comments only)
96
+ → pr-reviews/.threads-<pr>.json (thread IDs for resolve)
97
+
98
+ 2. Agent reads the markdown, implements fixes, commits, pushes
99
+
100
+ 3. pnpm run pr-resolve -- <PR number> --tag-agents
101
+ → Resolves all handled threads via GitHub GraphQL
102
+ → Posts: "All feedback addressed. @cubic-dev-ai please re-review."
103
+
104
+ 4. Repeat from step 1 — only new reviewer replies appear
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Config (`.pr-prism.json`)
110
+
111
+ Create `.pr-prism.json` in your repo root to customise agent mentions without editing source:
112
+
113
+ ```json
114
+ {
115
+ "agentMentions": ["cubic-dev-ai", "coderabbitai"]
116
+ }
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Extending the bot list
122
+
123
+ Edit `KNOWN_BOTS` at the top of `scripts/scrape-pr-reviews.ts`:
124
+
125
+ ```ts
126
+ const KNOWN_BOTS = ["github-actions", "dependabot", "coderabbitai", "changeset-bot", "codeantai"];
127
+ ```
128
+
129
+ ## Adding noise domains
130
+
131
+ Edit `NOISE_DOMAINS` in the same file:
132
+
133
+ ```ts
134
+ const NOISE_DOMAINS = [
135
+ "twitter.com/intent", "x.com/intent",
136
+ "reddit.com/submit",
137
+ "linkedin.com/sharing",
138
+ "app.codeant.ai", "codeant.ai/feedback",
139
+ ];
140
+ ```
141
+
142
+ ---
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: scrape-pr-reviews
3
+ description: >
4
+ Fetches open PRs from the current repo via gh CLI, lets you pick one interactively,
5
+ and writes only new/unresolved human review comments to a Markdown file.
6
+ After fixes, pr-resolve closes handled threads and optionally tags AI agents for re-review.
7
+ Requires gh CLI authenticated (gh auth login).
8
+ tools: ["Bash", "Read"]
9
+ model: haiku
10
+ ---
11
+
12
+ ## Purpose
13
+
14
+ Auto-detects the GitHub repo from the local git remote, lists open PRs, and
15
+ processes the selected PR into a clean Markdown file. Re-runs skip already-seen
16
+ comment IDs so each file contains only what's new since the last run.
17
+ After fixes are pushed, `pr-resolve` closes handled threads via the GitHub GraphQL
18
+ API and can post a comment tagging AI agents for re-review.
19
+
20
+ ## Prerequisites
21
+
22
+ - `gh` CLI installed and authenticated: `gh auth login`
23
+ - `pnpm install` (prompts is a devDependency)
24
+
25
+ ## Commands
26
+
27
+ ```bash
28
+ # Scrape new review comments
29
+ pnpm run pr-review # list open PRs → interactive select
30
+ pnpm run pr-review -- 42 # process PR #42 directly
31
+ pnpm run pr-review -- https://github.com/owner/repo/pull/42
32
+
33
+ # Resolve handled threads + tag agents
34
+ pnpm run pr-resolve -- 42 # resolve all threads from last scrape
35
+ pnpm run pr-resolve -- 42 --dry-run # preview without mutating
36
+ pnpm run pr-resolve -- 42 --tag-agents # resolve + post @mention comment
37
+ pnpm run pr-resolve -- 42 --tag-agents --comment "Fixed in abc123"
38
+ pnpm run pr-resolve -- 42 --unresolve # re-open threads if needed
39
+ ```
40
+
41
+ ## Output files
42
+
43
+ | File | Description |
44
+ |------|-------------|
45
+ | `pr-reviews/new-<timestamp>.md` | New comments only since last run |
46
+ | `pr-reviews/.scraped-ids.json` | Persistent ID cache — commit this file |
47
+ | `pr-reviews/.threads-<pr>.json` | Thread IDs for `pr-resolve` — auto-generated |
48
+
49
+ ## What gets filtered (pr-review)
50
+
51
+ | Case | Detection | Behaviour |
52
+ |------|-----------|-----------|
53
+ | Already-seen comment | ID in `.scraped-ids.json` | Silently skipped |
54
+ | Resolved thread | GraphQL `isResolved: true` | Skipped + ID cached |
55
+ | Bot comment | Author ends in `[bot]` or matches `KNOWN_BOTS` | Skipped + ID cached |
56
+
57
+ ## What gets annotated (pr-review)
58
+
59
+ | Case | Detection | Output annotation |
60
+ |------|-----------|-------------------|
61
+ | Outdated thread | GraphQL `isOutdated: true` | `### āš ļø OUTDATED / SUPERSEDED` |
62
+ | Suggested change | ` ```suggestion ` block in body | ` ```diff ` with `+` lines |
63
+ | File context | Thread `path` field | `### šŸ“„ File: \`path/to/file.ts\`` |
64
+ | Thread ID | First comment of each thread | `<!-- thread-id: PRRT_xxx -->` |
65
+
66
+ ## Iterative agent workflow
67
+
68
+ ```
69
+ 1. pnpm run pr-review -- <PR number>
70
+ 2. Agent reads pr-reviews/new-<timestamp>.md
71
+ 3. Agent implements fixes, commits, pushes
72
+ 4. pnpm run pr-resolve -- <PR number> --tag-agents
73
+ → Resolves handled threads via GraphQL
74
+ → Posts comment tagging AI agents for re-review
75
+ 5. Repeat from step 1 — only new reviewer replies appear
76
+ ```
77
+
78
+ ## Config (.pr-prism.json in repo root)
79
+
80
+ ```json
81
+ {
82
+ "agentMentions": ["cubic-dev-ai", "coderabbitai"]
83
+ }
84
+ ```
85
+
86
+ Overrides `DEFAULT_AGENT_MENTIONS` in `resolve-pr-threads.ts` without editing source.
package/llms.txt ADDED
@@ -0,0 +1,80 @@
1
+ # pr-prism
2
+
3
+ > Filter the noise. Focus on what matters.
4
+
5
+ Stateful GitHub PR review scraper for AI agent workflows. Fetches review comments via the GitHub GraphQL API, filters bots and noise, emits only new/unresolved comments — once per ID, forever cached. After fixes are pushed, resolves threads and tags AI agents for re-review.
6
+
7
+ ## Commands
8
+
9
+ - `pnpm run pr-review` — detect repo, list open PRs, interactive select
10
+ - `pnpm run pr-review -- 42` — process PR #42 directly (non-interactive)
11
+ - `pnpm run pr-review -- <url>` — process by full GitHub PR URL
12
+ - `pnpm run pr-resolve` — resolve threads from latest sidecar (auto-runs pr-review if missing)
13
+ - `pnpm run pr-resolve -- 42` — explicit PR number
14
+ - `pnpm run pr-resolve -- 42 --dry-run` — preview without mutating
15
+ - `pnpm run pr-resolve -- 42 --tag-agents` — resolve + post @mention comment
16
+ - `pnpm run pr-resolve -- 42 --tag-agents --comment "Fixed in abc123"` — custom message
17
+ - `pnpm run pr-resolve -- 42 --unresolve` — re-open resolved threads
18
+
19
+ ## Output Files
20
+
21
+ - `pr-reviews/new-<timestamp>.md` — new actionable comments since last run
22
+ - `pr-reviews/.scraped-ids.json` — persistent ID cache (commit this file)
23
+ - `pr-reviews/.threads-<pr>.json` — thread IDs consumed by pr-resolve
24
+
25
+ ## Comment Format (in output markdown)
26
+
27
+ Each comment header includes visible IDs for LLM reference:
28
+
29
+ ```
30
+ ## šŸ’¬ **author** Ā· thread `PRRT_xxx` Ā· `#databaseId` ← first comment of an inline thread
31
+ ## šŸ’¬ **author** Ā· `#databaseId` ← subsequent comments / general comments
32
+ ```
33
+
34
+ ## What Gets Filtered
35
+
36
+ - Already-seen comment IDs (in `.scraped-ids.json`) — silently skipped
37
+ - Resolved threads (`isResolved: true`) — skipped and cached
38
+ - Bot authors (ending in `[bot]` or matching `KNOWN_BOTS`) — skipped and cached
39
+ - Outdated threads (`isOutdated: true`) — shown with `### āš ļø OUTDATED / SUPERSEDED`
40
+ - Social sharing links (Twitter, Reddit, LinkedIn, CodeAnt) — stripped from body
41
+
42
+ ## Config (`.pr-prism.json` in repo root)
43
+
44
+ Create this file to customise behaviour without editing source:
45
+
46
+ ```json
47
+ {
48
+ "agentMentions": ["cubic-dev-ai", "coderabbitai"]
49
+ }
50
+ ```
51
+
52
+ `agentMentions` — list of GitHub handles (without `@`) tagged in the post-resolve comment. Defaults to `["cubic-dev-ai"]`.
53
+
54
+ ## The AI Agent Loop
55
+
56
+ ```
57
+ 1. pnpm run pr-review -- <PR number>
58
+ → pr-reviews/new-<timestamp>.md (new comments only)
59
+ → pr-reviews/.threads-<pr>.json (thread IDs for resolve)
60
+
61
+ 2. Agent reads markdown, implements fixes, commits, pushes
62
+
63
+ 3. pnpm run pr-resolve -- <PR number> --tag-agents
64
+ → Resolves all handled threads via GitHub GraphQL
65
+ → Posts: "All feedback addressed. @cubic-dev-ai please re-review."
66
+
67
+ 4. Repeat — only new reviewer replies appear
68
+ ```
69
+
70
+ ## Requirements
71
+
72
+ - Node.js ≄ 20
73
+ - gh CLI authenticated (`gh auth login`)
74
+
75
+ ## Source
76
+
77
+ - [README](./README.md) — full documentation
78
+ - [scripts/scrape-pr-reviews.ts](./scripts/scrape-pr-reviews.ts) — scraper
79
+ - [scripts/resolve-pr-threads.ts](./scripts/resolve-pr-threads.ts) — resolver
80
+ - [agents/scrape-pr-reviews.md](./agents/scrape-pr-reviews.md) — agent skill reference
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "pr-prism",
3
+ "version": "1.0.5",
4
+ "description": "Stateful GitHub PR review scraper for AI agents — filter noise, cache seen comments, deliver signal",
5
+ "type": "module",
6
+ "scripts": {
7
+ "pr-review": "tsx scripts/scrape-pr-reviews.ts",
8
+ "pr-resolve": "tsx scripts/resolve-pr-threads.ts",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "devDependencies": {
12
+ "@types/node": "^20.10.6",
13
+ "@types/prompts": "^2.4.9",
14
+ "prompts": "^2.4.2",
15
+ "tsx": "^4.7.0",
16
+ "typescript": "^5.3.3"
17
+ },
18
+ "engines": {
19
+ "node": ">=20.0.0"
20
+ },
21
+ "packageManager": "pnpm@9.15.0",
22
+ "license": "MIT"
23
+ }
File without changes
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env tsx
2
+ /*
3
+ * Resolve (or unresolve) PR review threads and optionally tag AI agents.
4
+ * Reads the sidecar .threads-<prNumber>.json written by pr-review.
5
+ *
6
+ * USAGE
7
+ * pnpm run pr-resolve — latest .threads-*.json
8
+ * pnpm run pr-resolve -- 42 — explicit PR number
9
+ * pnpm run pr-resolve -- 42 --dry-run — preview without mutating
10
+ * pnpm run pr-resolve -- 42 --tag-agents — resolve + post @mention comment
11
+ * pnpm run pr-resolve -- 42 --tag-agents --comment "Fixed in abc123"
12
+ * pnpm run pr-resolve -- 42 --unresolve — re-open resolved threads
13
+ *
14
+ * CONFIG (.pr-prism.json in repo root)
15
+ * { "agentMentions": ["cubic-dev-ai", "coderabbitai"] }
16
+ */
17
+
18
+ import { execSync, spawnSync } from "node:child_process";
19
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+
23
+ const OUT_DIR = "pr-reviews";
24
+ const CONFIG_FILE = ".pr-prism.json";
25
+ const DEFAULT_AGENT_MENTIONS = ["cubic-dev-ai"];
26
+
27
+ interface ThreadsSidecar { prNumber: number; owner: string; repo: string; threadIds: string[]; }
28
+ interface PrPrismConfig { agentMentions?: string[]; }
29
+
30
+ function run(cmd: string): string {
31
+ return execSync(cmd, { encoding: "utf-8", stdio: "pipe" }).trim();
32
+ }
33
+
34
+ function loadConfig(): PrPrismConfig {
35
+ if (!existsSync(CONFIG_FILE)) return {};
36
+ try { return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as PrPrismConfig; } catch { return {}; }
37
+ }
38
+
39
+ function runScrape(prNumber: number | null): void {
40
+ const args = ["scripts/scrape-pr-reviews.ts"];
41
+ if (prNumber !== null) args.push(String(prNumber));
42
+ const result = spawnSync(join("node_modules", ".bin", "tsx"), args, { stdio: "inherit" });
43
+ if (result.status !== 0) process.exit(result.status ?? 1);
44
+ }
45
+
46
+ function findSidecar(prNumber: number | null): string | null {
47
+ if (!existsSync(OUT_DIR)) return null;
48
+ if (prNumber !== null) {
49
+ const f = join(OUT_DIR, `.threads-${prNumber}.json`);
50
+ return existsSync(f) ? f : null;
51
+ }
52
+ const files = readdirSync(OUT_DIR)
53
+ .filter((f) => f.startsWith(".threads-") && f.endsWith(".json"))
54
+ .sort()
55
+ .reverse();
56
+ return files.length > 0 ? join(OUT_DIR, files[0]) : null;
57
+ }
58
+
59
+ function mutateThread(threadId: string, unresolve: boolean): boolean {
60
+ const mutation = unresolve
61
+ ? `mutation($id:ID!){unresolveReviewThread(input:{threadId:$id}){thread{isResolved}}}`
62
+ : `mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}`;
63
+ const reqFile = join(tmpdir(), ".pr-resolve-req.json");
64
+ writeFileSync(reqFile, JSON.stringify({ query: mutation, variables: { id: threadId } }), "utf-8");
65
+ try {
66
+ const result = JSON.parse(run(`gh api https://api.github.com/graphql --input ${reqFile}`));
67
+ const key = unresolve ? "unresolveReviewThread" : "resolveReviewThread";
68
+ return result.data?.[key]?.thread != null;
69
+ } catch {
70
+ return false;
71
+ } finally {
72
+ unlinkSync(reqFile);
73
+ }
74
+ }
75
+
76
+ function postComment(prNumber: number, owner: string, repo: string, message: string): void {
77
+ const bodyFile = join(tmpdir(), ".pr-comment-body.txt");
78
+ writeFileSync(bodyFile, message, "utf-8");
79
+ try {
80
+ run(`gh pr comment ${prNumber} --repo ${owner}/${repo} --body-file ${bodyFile}`);
81
+ } finally {
82
+ unlinkSync(bodyFile);
83
+ }
84
+ }
85
+
86
+ async function main(): Promise<void> {
87
+ const args = process.argv.slice(2);
88
+ const isDryRun = args.includes("--dry-run");
89
+ const isUnresolve = args.includes("--unresolve");
90
+ const shouldTag = args.includes("--tag-agents");
91
+ const commentIdx = args.indexOf("--comment");
92
+ const customComment = commentIdx !== -1 ? args[commentIdx + 1] : null;
93
+ const prArg = args.find((a) => /^\d+$/.test(a));
94
+ const prNumber = prArg != null ? parseInt(prArg, 10) : null;
95
+
96
+ let sidecarPath = findSidecar(prNumber);
97
+ if (!sidecarPath) {
98
+ console.log("\n⚔ No sidecar found — running pr-review to generate it…\n");
99
+ runScrape(prNumber);
100
+ sidecarPath = findSidecar(prNumber);
101
+ if (!sidecarPath) {
102
+ console.log("ā„¹ļø pr-review ran but found no inline review threads to resolve.");
103
+ process.exit(0);
104
+ }
105
+ }
106
+
107
+ const sidecar = JSON.parse(readFileSync(sidecarPath, "utf-8")) as ThreadsSidecar;
108
+ const { threadIds, owner, repo } = sidecar;
109
+ const resolvedPr = sidecar.prNumber;
110
+ const action = isUnresolve ? "Unresolve" : "Resolve";
111
+
112
+ console.log(`\n${action} ${threadIds.length} thread(s) in ${owner}/${repo} #${resolvedPr}${isDryRun ? " [DRY RUN]" : ""}\n`);
113
+
114
+ let ok = 0;
115
+ let failed = 0;
116
+
117
+ for (const id of threadIds) {
118
+ if (isDryRun) {
119
+ console.log(` [dry-run] would ${action.toLowerCase()} ${id}`);
120
+ ok++;
121
+ continue;
122
+ }
123
+ if (mutateThread(id, isUnresolve)) {
124
+ console.log(` āœ… ${id}`);
125
+ ok++;
126
+ } else {
127
+ console.log(` āš ļø ${id} — already ${action.toLowerCase()}d or failed`);
128
+ failed++;
129
+ }
130
+ }
131
+
132
+ console.log(`\n${ok} ${action.toLowerCase()}d${failed > 0 ? `, ${failed} skipped` : ""}`);
133
+
134
+ if (shouldTag && !isDryRun) {
135
+ const config = loadConfig();
136
+ const mentions = (config.agentMentions ?? DEFAULT_AGENT_MENTIONS).map((a) => `@${a}`).join(" ");
137
+ const message = customComment ?? `All feedback addressed. ${mentions} please re-review.`;
138
+ postComment(resolvedPr, owner, repo, message);
139
+ console.log(`\nšŸ’¬ Posted: "${message}"`);
140
+ }
141
+ }
142
+
143
+ main().catch((err: unknown) => { console.error((err as Error).message); process.exit(1); });
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env tsx
2
+ /*
3
+ * PR review scraper — auto-detects repo, lists open PRs, processes selected PR.
4
+ *
5
+ * REQUIRES: gh CLI authenticated (gh auth login)
6
+ * FEATURES: ID cache Ā· resolved/outdated skip Ā· bot filter Ā· suggested diff Ā· file path
7
+ *
8
+ * USAGE
9
+ * pnpm run pr-review — detect repo, list open PRs, interactive select
10
+ * pnpm run pr-review -- 42 — detect repo, process PR #42 directly
11
+ * pnpm run pr-review -- <url> — process by full GitHub PR URL
12
+ *
13
+ * OUTPUT
14
+ * pr-reviews/new-<timestamp>.md — new comments only since last run
15
+ * pr-reviews/.scraped-ids.json — persistent ID cache (commit this file)
16
+ * pr-reviews/.threads-<prNumber>.json — thread IDs for pr-resolve
17
+ */
18
+
19
+ import { execSync } from "node:child_process";
20
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import prompts from "prompts";
24
+
25
+ const KNOWN_BOTS = ["github-actions", "dependabot", "coderabbitai", "changeset-bot", "codeantai"];
26
+ const OUT_DIR = "pr-reviews";
27
+ const CACHE_FILE = join(OUT_DIR, ".scraped-ids.json");
28
+
29
+ const GRAPHQL_QUERY = `
30
+ query($owner: String!, $repo: String!, $prNumber: Int!) {
31
+ repository(owner: $owner, name: $repo) {
32
+ pullRequest(number: $prNumber) {
33
+ reviewThreads(first: 100) {
34
+ nodes {
35
+ id isResolved isOutdated
36
+ comments(first: 20) {
37
+ nodes { databaseId author { login } body path }
38
+ }
39
+ }
40
+ }
41
+ reviews(first: 50) {
42
+ nodes { databaseId author { login } body state }
43
+ }
44
+ comments(first: 100) {
45
+ nodes { databaseId author { login } body }
46
+ }
47
+ }
48
+ }
49
+ }`.trim();
50
+
51
+ interface GhComment { databaseId: number; author: { login: string }; body: string; path?: string; state?: string; }
52
+ interface ReviewThread { id: string; isResolved: boolean; isOutdated: boolean; comments: { nodes: GhComment[] }; }
53
+ interface ThreadsSidecar { prNumber: number; owner: string; repo: string; threadIds: string[]; }
54
+ interface PrPayload { data: { repository: { pullRequest: { reviewThreads: { nodes: ReviewThread[] }; reviews: { nodes: GhComment[] }; comments: { nodes: GhComment[] }; }; }; }; }
55
+ interface PrListItem { number: number; title: string; author: { login: string }; }
56
+ interface IdCache { seen: string[]; }
57
+
58
+ function run(cmd: string): string {
59
+ return execSync(cmd, { encoding: "utf-8", stdio: "pipe" }).trim();
60
+ }
61
+
62
+ function detectRepo(): { owner: string; repo: string } {
63
+ try {
64
+ const remote = run("git remote get-url origin");
65
+ const m = remote.match(/github\.com[:/]([^/]+)\/([^/\s.]+)/);
66
+ if (!m) throw new Error();
67
+ return { owner: m[1], repo: m[2].replace(/\.git$/, "") };
68
+ } catch {
69
+ console.error("āŒ Could not detect GitHub repo. Run from a GitHub repo or pass a URL.");
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ function parsePrUrl(url: string): { owner: string; repo: string; prNumber: number } {
75
+ const m = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
76
+ if (!m) { console.error(`āŒ Invalid GitHub PR URL: ${url}`); process.exit(1); }
77
+ return { owner: m[1], repo: m[2], prNumber: parseInt(m[3], 10) };
78
+ }
79
+
80
+ function listOpenPrs(owner: string, repo: string): PrListItem[] {
81
+ const out = run(`gh pr list --repo ${owner}/${repo} --state open --json number,title,author --limit 50`);
82
+ return JSON.parse(out) as PrListItem[];
83
+ }
84
+
85
+ async function selectPr(prs: PrListItem[]): Promise<number> {
86
+ if (prs.length === 0) { console.log("No open PRs found."); process.exit(0); }
87
+ const { value } = await prompts({
88
+ type: "select",
89
+ name: "value",
90
+ message: "Select a PR:",
91
+ choices: prs.map((p) => ({ title: `#${p.number} ${p.title} (${p.author.login})`, value: p.number })),
92
+ });
93
+ if (value === undefined) process.exit(0);
94
+ return value as number;
95
+ }
96
+
97
+ function fetchPr(owner: string, repo: string, prNumber: number): PrPayload {
98
+ const reqFile = join(tmpdir(), ".pr-review-req.json");
99
+ writeFileSync(reqFile, JSON.stringify({ query: GRAPHQL_QUERY, variables: { owner, repo, prNumber } }), "utf-8");
100
+ try {
101
+ return JSON.parse(run(`gh api https://api.github.com/graphql --input ${reqFile}`)) as PrPayload;
102
+ } catch (err) {
103
+ console.error("āŒ gh API failed. Is gh authenticated? Run: gh auth login");
104
+ console.error((err as Error).message);
105
+ process.exit(1);
106
+ } finally {
107
+ unlinkSync(reqFile);
108
+ }
109
+ }
110
+
111
+ function loadCache(): Set<string> {
112
+ if (!existsSync(CACHE_FILE)) return new Set();
113
+ try { return new Set((JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as IdCache).seen); } catch { return new Set(); }
114
+ }
115
+ function saveCache(seen: Set<string>): void { writeFileSync(CACHE_FILE, JSON.stringify({ seen: [...seen] }, null, 2), "utf-8"); }
116
+ function isBot(login: string): boolean { const l = login.toLowerCase(); return l.endsWith("[bot]") || KNOWN_BOTS.some((b) => l.includes(b)); }
117
+
118
+ const NOISE_DOMAINS = [
119
+ "twitter.com/intent", "x.com/intent",
120
+ "reddit.com/submit",
121
+ "linkedin.com/sharing",
122
+ "app.codeant.ai", "codeant.ai/feedback",
123
+ ];
124
+
125
+ function stripNoise(body: string): string {
126
+ return body
127
+ .replace(/<a\s[^>]*href=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/a>/gi, (match, url: string) =>
128
+ NOISE_DOMAINS.some((d) => url.includes(d)) ? "" : match,
129
+ )
130
+ .replace(/\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, (match, _text, url: string) =>
131
+ NOISE_DOMAINS.some((d) => url.includes(d)) ? "" : match,
132
+ )
133
+ .replace(/^[\sĀ·|—\-]+$/gm, "")
134
+ .replace(/\n{3,}/g, "\n\n")
135
+ .trim();
136
+ }
137
+
138
+ function renderSuggestions(body: string): string {
139
+ return body.replace(/```suggestion\n([\s\S]*?)```/g, (_, code: string) => {
140
+ const lines = code.trimEnd().split("\n").map((l: string) => `+ ${l}`).join("\n");
141
+ return `\n**SUGGESTED CHANGE:**\n\`\`\`diff\n${lines}\n\`\`\`\n`;
142
+ });
143
+ }
144
+
145
+ function appendComment(out: string, c: GhComment, prefix: string, threadId?: string): string {
146
+ const body = renderSuggestions(stripNoise(c.body)).trim();
147
+ if (!body) return out;
148
+ const meta = threadId
149
+ ? `thread \`${threadId}\` Ā· \`#${c.databaseId}\``
150
+ : `\`#${c.databaseId}\``;
151
+ return out + prefix + `## šŸ’¬ **${c.author.login}** Ā· ${meta}\n\n${body}\n\n---\n\n`;
152
+ }
153
+
154
+ async function main(): Promise<void> {
155
+ const arg = process.argv[2];
156
+ let owner: string, repo: string, prNumber: number;
157
+
158
+ if (arg?.startsWith("http")) {
159
+ ({ owner, repo, prNumber } = parsePrUrl(arg));
160
+ } else if (arg && /^\d+$/.test(arg)) {
161
+ ({ owner, repo } = detectRepo());
162
+ prNumber = parseInt(arg, 10);
163
+ } else {
164
+ ({ owner, repo } = detectRepo());
165
+ const prs = listOpenPrs(owner, repo);
166
+ console.log(`\nFound ${prs.length} open PR(s) in ${owner}/${repo}\n`);
167
+ prNumber = await selectPr(prs);
168
+ }
169
+
170
+ console.log(`\nFetching PR #${prNumber} from ${owner}/${repo}…`);
171
+ const pr = fetchPr(owner, repo, prNumber).data.repository.pullRequest;
172
+
173
+ mkdirSync(OUT_DIR, { recursive: true });
174
+ const cache = loadCache();
175
+ let output = `# PR Review — ${owner}/${repo} #${prNumber}\n\n`;
176
+ let count = 0;
177
+ const emittedThreadIds: string[] = [];
178
+
179
+ for (const thread of pr.reviewThreads.nodes) {
180
+ let firstInThread = true;
181
+ for (const c of thread.comments.nodes) {
182
+ const key = String(c.databaseId);
183
+ if (thread.isResolved || isBot(c.author.login)) { cache.add(key); continue; }
184
+ if (cache.has(key)) continue;
185
+ const filePrefix = c.path ? `### šŸ“„ File: \`${c.path}\`\n\n` : "";
186
+ const outdatedPrefix = thread.isOutdated ? `### āš ļø OUTDATED / SUPERSEDED\n\n` : "";
187
+ output = appendComment(output, c, filePrefix + outdatedPrefix, firstInThread ? thread.id : undefined);
188
+ cache.add(key); count++;
189
+ if (firstInThread) { emittedThreadIds.push(thread.id); firstInThread = false; }
190
+ }
191
+ }
192
+
193
+ for (const c of [...pr.reviews.nodes, ...pr.comments.nodes]) {
194
+ const key = String(c.databaseId);
195
+ if (isBot(c.author.login) || !c.body.trim()) { cache.add(key); continue; }
196
+ if (cache.has(key)) continue;
197
+ output = appendComment(output, c, "");
198
+ cache.add(key); count++;
199
+ }
200
+
201
+ saveCache(cache);
202
+ const outFile = join(OUT_DIR, `new-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
203
+ writeFileSync(outFile, output, "utf-8");
204
+
205
+ if (emittedThreadIds.length > 0) {
206
+ const sidecar: ThreadsSidecar = { prNumber, owner, repo, threadIds: emittedThreadIds };
207
+ writeFileSync(join(OUT_DIR, `.threads-${prNumber}.json`), JSON.stringify(sidecar, null, 2), "utf-8");
208
+ }
209
+
210
+ console.log(count > 0 ? `\nāœ… ${count} new comment(s) → ${outFile}` : `\nāœ… No new comments since last run. → ${outFile}`);
211
+ }
212
+
213
+ main().catch((err: unknown) => { console.error((err as Error).message); process.exit(1); });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true
12
+ },
13
+ "include": ["scripts/**/*"],
14
+ "exclude": ["node_modules"]
15
+ }