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.
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/delete-branch.yml +37 -0
- package/.github/workflows/dependabot-auto-merge.yml +39 -0
- package/.github/workflows/labeler.yml +21 -0
- package/.github/workflows/security.yml +38 -0
- package/.github/workflows/version-bump.yml +35 -0
- package/README.md +146 -0
- package/agents/scrape-pr-reviews.md +86 -0
- package/llms.txt +80 -0
- package/package.json +23 -0
- package/pr-reviews/.gitkeep +0 -0
- package/scripts/resolve-pr-threads.ts +143 -0
- package/scripts/scrape-pr-reviews.ts +213 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
+
}
|