pr-docent 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +175 -2
- package/bin/pr-docent.mjs +91 -0
- package/host/native.mjs +35 -0
- package/host/pr-docent-host.mjs +119 -0
- package/package.json +36 -4
- package/scripts/check-tour.mjs +55 -0
- package/scripts/tour-prompt.md +98 -0
- package/src/diff.js +114 -0
- package/src/guidelines.js +102 -0
- package/src/tour.js +172 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 꼬리찾은도마뱀
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PR Docent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
GitHub 풀 리퀘스트의 **Files changed** 화면 위에서 동작하는 리뷰 투어 Chrome 확장입니다. 수천 줄짜리 PR을 챕터로 나누고, **읽을 순서대로** 해당 파일과 줄로 이동하며 하이라이트하고, 설명을 보여 주거나 음성으로 읽어 줍니다.
|
|
4
|
+
|
|
5
|
+
> 상태: 1단계 프로토타입. 투어는 미리 만든 JSON을 불러와 재생합니다. 투어 자동 생성(2단계)은 아직 없습니다.
|
|
6
|
+
|
|
7
|
+
## 기능
|
|
8
|
+
|
|
9
|
+
- 챕터 목록, 진행률, 이전·재생·다음, **집중 단계만 골라 건너뛰기**, 단축키
|
|
10
|
+
- 단계마다 파일과 줄 범위로 스크롤하고 하이라이트. 나머지 코드는 흐리게 처리합니다.
|
|
11
|
+
- 음성이 읽는 문장에 맞춰 해당 줄을 진하게 가리킵니다(`note`를 문장별 배열로 쓴 경우).
|
|
12
|
+
- 중요도 표시: **집중**(꼼꼼히 볼 곳), **훑기**, **생략 가능**
|
|
13
|
+
- 브라우저 내장 음성 합성으로 설명 읽기(속도 조절). 네트워크 요청이나 비용이 없습니다.
|
|
14
|
+
- **투어가 다루지 않은 파일** 목록: 투어만 믿고 놓치는 파일이 없게 합니다.
|
|
15
|
+
- 접힌 대용량 diff("Load Diff")는 자동으로 펼칩니다.
|
|
16
|
+
- 패널이 열려 있는 동안 diff가 패널에 가리지 않도록 페이지 폭을 줄입니다.
|
|
17
|
+
- PR마다 마지막 위치를 기억합니다.
|
|
18
|
+
- 화면 언어는 영어(기본)와 한국어를 지원합니다. 툴바의 PR Docent 아이콘을 눌러 자동(브라우저 언어), English, 한국어 중에서 고릅니다. 로컬에서 만드는 투어도 고른 언어로 작성되고, 음성은 투어 문장의 언어에 맞춰 읽습니다.
|
|
19
|
+
|
|
20
|
+
## 단축키
|
|
21
|
+
|
|
22
|
+
GitHub의 diff 화면은 화살표 키와 한 글자 단축키를 이미 쓰므로, PR Docent는 Alt(Mac은 Option) 조합만 씁니다. 코멘트를 입력하는 중에는 동작하지 않습니다.
|
|
23
|
+
|
|
24
|
+
| 단축키 | 동작 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `Alt` + `→` / `←` | 다음 / 이전 단계 |
|
|
27
|
+
| `Alt` + `↓` / `↑` | 다음 / 이전 **집중** 단계로 건너뛰기 |
|
|
28
|
+
| `Alt` + `P` | 재생 / 일시정지 |
|
|
29
|
+
|
|
30
|
+
## 설치 (개발용)
|
|
31
|
+
|
|
32
|
+
1. `chrome://extensions`에서 **개발자 모드**를 켭니다.
|
|
33
|
+
2. **압축해제된 확장 프로그램을 로드합니다**를 누르고 이 폴더를 선택합니다.
|
|
34
|
+
3. PR의 Files changed 탭을 열면 오른쪽 아래에 **▶ PR Docent** 버튼이 나타납니다.
|
|
35
|
+
|
|
36
|
+
## 투어 자동 생성 (GitHub Actions)
|
|
37
|
+
|
|
38
|
+
PR이 열리거나 커밋이 추가될 때마다 Claude Code가 diff를 읽고 투어를 만들어 PR 코멘트로 올립니다. 확장은 Files changed 탭에서 그 코멘트를 찾아 투어를 자동으로 불러옵니다. 비공개 저장소에서도 로그인한 사용자 권한으로 읽으므로 별도 토큰이 필요 없습니다.
|
|
39
|
+
|
|
40
|
+
1. Claude 구독 계정으로 OAuth 토큰을 만듭니다. 생성에는 그 구독의 사용량이 쓰입니다.
|
|
41
|
+
```bash
|
|
42
|
+
claude setup-token
|
|
43
|
+
```
|
|
44
|
+
2. 투어를 만들 저장소에 secret으로 등록합니다.
|
|
45
|
+
```bash
|
|
46
|
+
gh secret set CLAUDE_CODE_OAUTH_TOKEN -R <owner>/<repo>
|
|
47
|
+
```
|
|
48
|
+
3. 그 저장소에 `.github/workflows/pr-docent.yml`을 추가합니다.
|
|
49
|
+
```yaml
|
|
50
|
+
name: PR Docent tour
|
|
51
|
+
on:
|
|
52
|
+
pull_request:
|
|
53
|
+
types: [opened, synchronize, reopened, ready_for_review]
|
|
54
|
+
concurrency:
|
|
55
|
+
group: pr-docent-tour-${{ github.event.pull_request.number }}
|
|
56
|
+
cancel-in-progress: true
|
|
57
|
+
jobs:
|
|
58
|
+
tour:
|
|
59
|
+
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
|
|
60
|
+
uses: thomas783/pr-docent/.github/workflows/generate-tour.yml@main
|
|
61
|
+
secrets:
|
|
62
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
63
|
+
permissions:
|
|
64
|
+
contents: read
|
|
65
|
+
pull-requests: write
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
동작 방식
|
|
69
|
+
- Claude는 투어 파일 하나만 쓸 수 있고, 저장소의 다른 파일은 읽기만 합니다.
|
|
70
|
+
- `scripts/check-tour.mjs`가 투어를 검사합니다. 형식, 모든 줄 번호가 실제 diff 화면에 보이는지, 문장별 줄이 단계 범위 안에 있는지, 바뀐 파일을 모두 다루거나 건너뛴 이유를 적었는지 확인합니다. Claude는 검사를 통과할 때까지 고치고, 통과하지 못하면 코멘트를 올리지 않습니다.
|
|
71
|
+
- 코멘트는 PR마다 하나이며, 새 커밋이 올라오면 같은 코멘트를 갱신합니다.
|
|
72
|
+
- 입력값 `model`(기본 `claude-opus-5-5`)과 `language`(기본 `한국어`)로 모델과 언어를 바꿀 수 있습니다.
|
|
73
|
+
- 포크에서 온 PR에는 secret이 전달되지 않으므로 실행하지 않습니다.
|
|
74
|
+
|
|
75
|
+
## 투어 로컬 생성 (본인 Claude Code)
|
|
76
|
+
|
|
77
|
+
저장소 설정 없이, 패널의 **로컬 Claude Code로 투어 생성** 버튼으로 지금 보고 있는 PR의 투어를 만듭니다. 본인 컴퓨터의 Claude Code와 구독을 쓰므로 API 키나 추가 비용이 없고, 설정 권한이 없는 저장소의 PR에서도 됩니다.
|
|
78
|
+
|
|
79
|
+
준비 (컴퓨터마다 한 번, macOS와 Linux)
|
|
80
|
+
1. [Claude Code](https://claude.com/claude-code)를 설치하고 로그인합니다.
|
|
81
|
+
2. 로컬 도우미를 Chrome에 등록합니다. Node.js 18 이상이 필요합니다.
|
|
82
|
+
```bash
|
|
83
|
+
npx pr-docent install
|
|
84
|
+
```
|
|
85
|
+
`~/.pr-docent/`에 도우미 파일과 실행 스크립트를 복사하고, Chrome의 `NativeMessagingHosts` 폴더에 `dev.prdocent.host.json`을 만듭니다. 이 확장(ID `hleefogoiedjahnmjgmmgonckaeicppc`)만 연결할 수 있으며, 다른 ID는 `--extension-id <ID>`로 추가합니다. 이 저장소에서는 `npm run install-host`도 같습니다.
|
|
86
|
+
3. `chrome://extensions`에서 확장을 새로고침합니다.
|
|
87
|
+
|
|
88
|
+
동작 방식
|
|
89
|
+
- 확장이 로그인 세션으로 PR의 diff와 바뀐 파일을 가져와 로컬 도우미(`host/pr-docent-host.mjs`)에 넘깁니다.
|
|
90
|
+
- 도우미가 임시 폴더에서 `claude -p`를 실행합니다. GitHub Actions와 같은 프롬프트와 검사기를 쓰고, Claude는 투어 파일 하나만 쓸 수 있습니다.
|
|
91
|
+
- 검사를 통과한 투어를 이 PR에 저장하고 바로 재생합니다. 생성에는 보통 2~3분이 걸립니다.
|
|
92
|
+
- 제거: `npx pr-docent uninstall`
|
|
93
|
+
|
|
94
|
+
## 투어 생성 규칙 바꾸기 (`.github/pr-docent.md`)
|
|
95
|
+
|
|
96
|
+
Actions 생성과 로컬 생성 모두 기본적으로 짧은 투어를 만듭니다. 단계 수는 바뀐 줄 25줄당 1개에 3을 더한 값까지(최대 20)이고, 단계마다 문장 3개, 문장마다 80자까지입니다. 주석만 바뀐 곳, 임포트, 문서처럼 사소한 변경은 단계 대신 "건너뛴 파일"에 넣습니다. 검사기가 이 기준을 확인하고, 넘으면 Claude가 다시 줄입니다.
|
|
97
|
+
|
|
98
|
+
저장소에 `.github/pr-docent.md`를 두면 그 저장소의 규칙을 더할 수 있습니다. 앞부분 `---` 블록은 숫자 기준을 바꾸고, 본문은 생성 지시문 뒤에 붙어 기본 규칙보다 우선합니다.
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
---
|
|
102
|
+
maxSteps: 6 # 단계 수 상한 (기본: 3 + 바뀐 줄 25줄당 1, 최대 20)
|
|
103
|
+
sentencesPerStep: 2 # 단계당 문장 수 (기본 3)
|
|
104
|
+
sentenceChars: 100 # 문장 길이 (기본 80자)
|
|
105
|
+
pointChars: 120 # 리뷰 포인트 길이 (기본 120자)
|
|
106
|
+
summaryChars: 200 # PR 요약 길이 (기본 200자)
|
|
107
|
+
chapterSummaryChars: 60
|
|
108
|
+
---
|
|
109
|
+
- 도메인 용어는 원장, 안분, 파생 라인으로 씁니다.
|
|
110
|
+
- Flyway 마이그레이션 파일은 항상 집중 단계로 다룹니다.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
PR의 head 커밋에 있는 파일을 읽으므로, 규칙을 바꾸는 PR에서는 바뀐 규칙으로 생성됩니다.
|
|
114
|
+
|
|
115
|
+
## 투어 불러오기
|
|
116
|
+
|
|
117
|
+
- PR 코멘트에 자동 생성된 투어가 있으면 그 투어를 불러옵니다. 직접 불러온 투어가 있으면 그쪽이 우선합니다.
|
|
118
|
+
- `tours/<owner>--<repo>--<number>.json` 파일이 있으면 해당 PR에서 자동으로 불러옵니다.
|
|
119
|
+
- 예제: [`tours/thomas783--pr-docent--1.json`](tours/thomas783--pr-docent--1.json) — 이 레포의 [PR #1](https://github.com/thomas783/pr-docent/pull/1/changes)(단축키와 집중 단계 건너뛰기)을 리뷰하는 투어입니다. 확장을 설치한 뒤 이 PR을 열면, PR Docent로 PR Docent 자신의 변경을 리뷰해 볼 수 있습니다.
|
|
120
|
+
- 직접 만든 투어는 같은 폴더에 두면 자동으로 불러오고, 커밋되지 않습니다(`.gitignore` 참고). 공개 예제만 커밋합니다.
|
|
121
|
+
- 그 밖의 PR은 패널의 **투어 JSON 불러오기**로 파일을 선택합니다. 불러온 투어는 그 PR에 저장됩니다.
|
|
122
|
+
|
|
123
|
+
## 투어 형식 (version 1)
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"version": 1,
|
|
128
|
+
"pr": { "owner": "your-org", "repo": "your-repo", "number": 42 },
|
|
129
|
+
"title": "투어 제목",
|
|
130
|
+
"summary": "PR 요약",
|
|
131
|
+
"chapters": [
|
|
132
|
+
{
|
|
133
|
+
"title": "1. 도메인 모델",
|
|
134
|
+
"summary": "챕터 설명",
|
|
135
|
+
"steps": [
|
|
136
|
+
{ "file": "src/Foo.kt", "lines": [10, 24], "side": "right", "level": "focus", "note": "이 단계에서 볼 것" }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
"skipped": [{ "file": "gradlew", "reason": "생성된 파일" }]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| 필드 | 설명 |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `lines` | `[시작, 끝]` 줄 번호. 생략하면 파일 전체로 이동합니다. |
|
|
147
|
+
| `side` | `right`(변경 후, 기본값) 또는 `left`(변경 전, 삭제된 줄) |
|
|
148
|
+
| `level` | `focus`, `skim`(기본값), `skip` |
|
|
149
|
+
| `note` | 설명 문자열. 또는 `[{ "text": "...", "lines": [시작, 끝] }, ...]` 배열로 쓰면 음성이 각 문장을 읽는 동안 그 줄을 진하게 가리킵니다. |
|
|
150
|
+
| `point` | 리뷰어가 확인할 점(선택). 패널에 강조 상자로 보이고, 설명 다음에 따로 읽어 줍니다. |
|
|
151
|
+
| `skipped` | 일부러 건너뛴 파일과 이유. "다루지 않은 파일" 목록에서 빠집니다. |
|
|
152
|
+
|
|
153
|
+
## GitHub 화면 연동 방식
|
|
154
|
+
|
|
155
|
+
GitHub의 2026년 Files changed 화면 기준입니다. 화면 구조가 바뀌면 `src/github.js`만 고치면 됩니다.
|
|
156
|
+
|
|
157
|
+
- 파일 요소 ID: `diff-` + 파일 경로의 SHA-256
|
|
158
|
+
- 줄 셀: `data-line-anchor="diff-<해시>R<줄>"`(삭제 쪽은 `L`)
|
|
159
|
+
- URL 해시를 바꿔도 스크롤되지 않으므로 직접 스크롤합니다.
|
|
160
|
+
- 하이라이트는 요소에 클래스를 붙이지 않고 줄 앵커를 가리키는 CSS 규칙으로 적용합니다. GitHub(React)가 요소를 다시 그려도 유지됩니다.
|
|
161
|
+
- 화면 밖 파일과 접힌 파일은 그 파일로 스크롤한 뒤 내용이 그려질 때까지 기다리고, 필요하면 "Load Diff"를 누릅니다.
|
|
162
|
+
|
|
163
|
+
## 개발
|
|
164
|
+
|
|
165
|
+
- 빌드 단계가 없습니다. 콘텐츠 스크립트(`src/loader.js`)가 ES 모듈(`src/main.js`)을 불러옵니다.
|
|
166
|
+
- 테스트: `npm test` (Node 내장 테스트 러너). 투어 검증, diff 대조, 재생 상태, 단축키, PR 주소 해석, GitHub 파일 ID 해시, 줄 선택자를 다룹니다.
|
|
167
|
+
- npm 게시: `package.json`의 버전을 올려 머지한 뒤 `git tag npm-v<버전> && git push origin npm-v<버전>`. GitHub Actions(`publish.yml`)가 trusted publishing으로 게시하므로 npm 토큰이나 2FA 입력이 필요 없습니다.
|
|
168
|
+
- 투어 검사: `node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]` (`gh pr diff <number> > pr.diff`)
|
|
169
|
+
|
|
170
|
+
## 라이선스
|
|
171
|
+
|
|
172
|
+
MIT
|
|
173
|
+
|
|
174
|
+
## 다음 단계
|
|
175
|
+
|
|
176
|
+
1. **다듬기:** 구조 변화 다이어그램, 테스트 커버리지 표시, 비공개 레포 처리
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PR Docent's local generator setup.
|
|
3
|
+
//
|
|
4
|
+
// npx pr-docent install [--extension-id <id>]... register with Chrome (once per computer)
|
|
5
|
+
// npx pr-docent uninstall remove the registration and files
|
|
6
|
+
//
|
|
7
|
+
// Install copies the generator into ~/.pr-docent/app, so it keeps working after npx clears its
|
|
8
|
+
// cache, and writes a wrapper that pins PATH, Node and Claude Code: Chrome starts native hosts
|
|
9
|
+
// with a bare environment.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { chmodSync, cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir, platform } from "node:os";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { EXTENSION_IDS, HOST_NAME } from "../host/native.mjs";
|
|
17
|
+
|
|
18
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
// What the host needs at run time; everything else in the package is for the extension or tests.
|
|
20
|
+
const RUNTIME_FILES = ["host/native.mjs", "host/pr-docent-host.mjs", "src/diff.js", "src/guidelines.js", "src/tour.js", "scripts/check-tour.mjs", "scripts/tour-prompt.md"];
|
|
21
|
+
const HOSTS_DIRS = {
|
|
22
|
+
darwin: join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts"),
|
|
23
|
+
linux: join(homedir(), ".config", "google-chrome", "NativeMessagingHosts"),
|
|
24
|
+
};
|
|
25
|
+
const CONFIG_DIR = join(homedir(), ".pr-docent");
|
|
26
|
+
|
|
27
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
28
|
+
const hostsDir = HOSTS_DIRS[platform()];
|
|
29
|
+
const hostManifest = hostsDir && join(hostsDir, `${HOST_NAME}.json`);
|
|
30
|
+
|
|
31
|
+
if (command === "install") install(args);
|
|
32
|
+
else if (command === "uninstall") uninstall();
|
|
33
|
+
else {
|
|
34
|
+
console.log("usage: pr-docent install [--extension-id <id>]... | pr-docent uninstall");
|
|
35
|
+
process.exit(command === "help" ? 0 : 2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function install(args) {
|
|
39
|
+
if (!hostsDir) fail(`Not supported on ${platform()} yet (macOS and Linux only).`);
|
|
40
|
+
let claude;
|
|
41
|
+
try {
|
|
42
|
+
claude = execFileSync("which", ["claude"], { encoding: "utf8" }).trim();
|
|
43
|
+
} catch {
|
|
44
|
+
fail("Claude Code (claude) was not found on PATH. Install it and log in first: https://claude.com/claude-code");
|
|
45
|
+
}
|
|
46
|
+
const extraIds = args.flatMap((arg, i) => (arg === "--extension-id" && args[i + 1] ? [args[i + 1]] : []));
|
|
47
|
+
const extensionIds = [...new Set([...EXTENSION_IDS, ...extraIds])];
|
|
48
|
+
|
|
49
|
+
const app = join(CONFIG_DIR, "app");
|
|
50
|
+
rmSync(app, { recursive: true, force: true });
|
|
51
|
+
for (const file of RUNTIME_FILES) {
|
|
52
|
+
mkdirSync(dirname(join(app, file)), { recursive: true });
|
|
53
|
+
cpSync(join(PACKAGE_ROOT, file), join(app, file));
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(join(app, "package.json"), `${JSON.stringify({ type: "module" })}\n`); // src/*.js are ES modules
|
|
56
|
+
writeFileSync(join(CONFIG_DIR, "config.json"), `${JSON.stringify({ claude }, null, 2)}\n`);
|
|
57
|
+
|
|
58
|
+
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
59
|
+
const wrapper = join(CONFIG_DIR, "host.sh");
|
|
60
|
+
writeFileSync(wrapper, `#!/bin/sh\nexport PATH=${quote(process.env.PATH)}\nexec ${quote(process.execPath)} ${quote(join(app, "host", "pr-docent-host.mjs"))}\n`);
|
|
61
|
+
chmodSync(wrapper, 0o755);
|
|
62
|
+
|
|
63
|
+
mkdirSync(hostsDir, { recursive: true });
|
|
64
|
+
const manifest = {
|
|
65
|
+
name: HOST_NAME,
|
|
66
|
+
description: "PR Docent local tour generator",
|
|
67
|
+
path: wrapper,
|
|
68
|
+
type: "stdio",
|
|
69
|
+
allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`),
|
|
70
|
+
};
|
|
71
|
+
writeFileSync(hostManifest, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
72
|
+
|
|
73
|
+
console.log(`Installed the PR Docent local generator.
|
|
74
|
+
Chrome host manifest: ${hostManifest}
|
|
75
|
+
Generator: ${app}
|
|
76
|
+
Allowed extensions: ${extensionIds.join(", ")}
|
|
77
|
+
Claude Code: ${claude}
|
|
78
|
+
Reload the extension in chrome://extensions, then click "Create a tour with local Claude Code"
|
|
79
|
+
in the panel ("로컬 Claude Code로 투어 생성" in Korean).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uninstall() {
|
|
83
|
+
rmSync(CONFIG_DIR, { recursive: true, force: true });
|
|
84
|
+
if (hostManifest) rmSync(hostManifest, { force: true });
|
|
85
|
+
console.log(`Removed ${CONFIG_DIR}${hostManifest ? ` and ${hostManifest}` : ""}.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fail(message) {
|
|
89
|
+
console.error(message);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
package/host/native.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Pieces of the local generator that don't touch the process or the file system.
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const HOST_NAME = "dev.prdocent.host";
|
|
6
|
+
// Extensions the host accepts by default: the unpacked install, whose ID manifest.json's key fixes.
|
|
7
|
+
export const EXTENSION_IDS = ["hleefogoiedjahnmjgmmgonckaeicppc"];
|
|
8
|
+
|
|
9
|
+
/** Chrome native messaging frames each message as a 32-bit little-endian length plus UTF-8 JSON. */
|
|
10
|
+
export function encodeMessage(value) {
|
|
11
|
+
const json = Buffer.from(JSON.stringify(value), "utf8");
|
|
12
|
+
const header = Buffer.alloc(4);
|
|
13
|
+
header.writeUInt32LE(json.length, 0);
|
|
14
|
+
return Buffer.concat([header, json]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Returns a stdin "data" handler that calls [onMessage] for every complete frame. */
|
|
18
|
+
export function createDecoder(onMessage) {
|
|
19
|
+
let pending = Buffer.alloc(0);
|
|
20
|
+
return (chunk) => {
|
|
21
|
+
pending = Buffer.concat([pending, chunk]);
|
|
22
|
+
while (pending.length >= 4) {
|
|
23
|
+
const length = pending.readUInt32LE(0);
|
|
24
|
+
if (pending.length < 4 + length) break;
|
|
25
|
+
onMessage(JSON.parse(pending.subarray(4, 4 + length).toString("utf8")));
|
|
26
|
+
pending = pending.subarray(4 + length);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The ID Chrome gives an extension whose manifest carries this public [key]. */
|
|
32
|
+
export function extensionIdFromKey(key) {
|
|
33
|
+
const hex = createHash("sha256").update(Buffer.from(key, "base64")).digest("hex").slice(0, 32);
|
|
34
|
+
return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join("");
|
|
35
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Local generator: Chrome starts this through native messaging when the panel asks for a tour.
|
|
2
|
+
// It writes the PR's diff and changed files to a temporary folder, runs the user's own Claude
|
|
3
|
+
// Code (their subscription, no API key) with the same prompt and checker as the GitHub Actions
|
|
4
|
+
// workflow, and sends the checked tour back. Installed by `pr-docent install` (bin/pr-docent.mjs).
|
|
5
|
+
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
|
|
12
|
+
import { buildPrompt, checkBrevity, parseGuidelines } from "../src/guidelines.js";
|
|
13
|
+
import { parseTour } from "../src/tour.js";
|
|
14
|
+
import { createDecoder, encodeMessage } from "./native.mjs";
|
|
15
|
+
|
|
16
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const CONFIG = readConfig();
|
|
18
|
+
const TIMEOUT_MS = 15 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
let child = null;
|
|
21
|
+
const send = (message) => process.stdout.write(encodeMessage(message));
|
|
22
|
+
|
|
23
|
+
process.stdin.on(
|
|
24
|
+
"data",
|
|
25
|
+
createDecoder((message) => {
|
|
26
|
+
if (message.type !== "generate") return;
|
|
27
|
+
generate(message)
|
|
28
|
+
.then((tour) => send({ type: "done", tour }))
|
|
29
|
+
.catch((error) => send({ type: "error", message: error.message }))
|
|
30
|
+
.finally(() => process.exit(0));
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
// Chrome closes stdin when the extension disconnects (panel closed, tab gone): stop Claude too.
|
|
34
|
+
process.stdin.on("end", () => {
|
|
35
|
+
child?.kill();
|
|
36
|
+
process.exit(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function generate({ pr, title, body, headSha, diff, files, guidelines = "", model, language }) {
|
|
40
|
+
// Real path: on macOS the temp folder is a symlink, and the write rule must match exactly.
|
|
41
|
+
const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
|
|
42
|
+
try {
|
|
43
|
+
const repo = join(work, "repo");
|
|
44
|
+
const out = join(work, "out");
|
|
45
|
+
mkdirSync(repo);
|
|
46
|
+
mkdirSync(out);
|
|
47
|
+
for (const [path, content] of Object.entries(files)) {
|
|
48
|
+
const target = resolve(repo, path);
|
|
49
|
+
if (!target.startsWith(repo + sep)) continue; // never write outside the folder
|
|
50
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
51
|
+
writeFileSync(target, content);
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(join(out, "pr.diff"), diff);
|
|
54
|
+
writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
|
|
55
|
+
if (guidelines) writeFileSync(join(out, "guidelines.md"), guidelines);
|
|
56
|
+
const { limits, errors: guidelineErrors } = parseGuidelines(guidelines);
|
|
57
|
+
if (guidelineErrors.length) throw new Error(`.github/pr-docent.md: ${guidelineErrors.join(" / ")}`);
|
|
58
|
+
const changedLines = changedLineCount(diff);
|
|
59
|
+
|
|
60
|
+
const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
61
|
+
const prompt = buildPrompt(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
|
|
62
|
+
pr: prRef,
|
|
63
|
+
out,
|
|
64
|
+
tools: ROOT,
|
|
65
|
+
language: language || "한국어",
|
|
66
|
+
changedLines,
|
|
67
|
+
guidelinesText: guidelines,
|
|
68
|
+
});
|
|
69
|
+
await runClaude(prompt, repo, [
|
|
70
|
+
"--model", model || "claude-opus-5-5",
|
|
71
|
+
"--permission-mode", "dontAsk",
|
|
72
|
+
"--allowedTools", `Read,Glob,Grep,Edit(/${out}/tour.json),Bash(node ${ROOT}/scripts/check-tour.mjs:*)`,
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = JSON.parse(readFileSync(join(out, "tour.json"), "utf8"));
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("Claude did not write a tour file.");
|
|
80
|
+
}
|
|
81
|
+
const { tour, errors } = parseTour(raw);
|
|
82
|
+
const problems = errors.length
|
|
83
|
+
? errors
|
|
84
|
+
: [...checkTour(tour, visibleLines(diff)), ...checkBrevity(tour, { changedLines, limits })];
|
|
85
|
+
if (problems.length) throw new Error(`The tour did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
|
|
86
|
+
return raw;
|
|
87
|
+
} finally {
|
|
88
|
+
rmSync(work, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Written by the installer; without it, use whatever claude is on PATH. */
|
|
93
|
+
function readConfig() {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(join(homedir(), ".pr-docent", "config.json"), "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return { claude: "claude" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runClaude(prompt, cwd, args) {
|
|
102
|
+
return new Promise((resolvePromise, reject) => {
|
|
103
|
+
const started = Date.now();
|
|
104
|
+
let output = "";
|
|
105
|
+
child = spawn(CONFIG.claude, ["-p", prompt, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
106
|
+
const keep = (chunk) => (output = (output + chunk).slice(-2000));
|
|
107
|
+
child.stdout.on("data", keep);
|
|
108
|
+
child.stderr.on("data", keep);
|
|
109
|
+
const progress = setInterval(() => send({ type: "progress", seconds: Math.round((Date.now() - started) / 1000) }), 5000);
|
|
110
|
+
const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
|
|
111
|
+
child.on("error", (error) => reject(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`)));
|
|
112
|
+
child.on("close", (code) => {
|
|
113
|
+
clearInterval(progress);
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
if (code === 0) resolvePromise();
|
|
116
|
+
else reject(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-docent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Guided, narrated review tours on top of GitHub pull request diffs. This package installs the local tour generator for the PR Docent Chrome extension.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/thomas783/pr-docent.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/thomas783/pr-docent#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"github",
|
|
14
|
+
"pull-request",
|
|
15
|
+
"code-review",
|
|
16
|
+
"chrome-extension",
|
|
17
|
+
"claude"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"pr-docent": "bin/pr-docent.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"host/",
|
|
28
|
+
"src/diff.js",
|
|
29
|
+
"src/guidelines.js",
|
|
30
|
+
"src/tour.js",
|
|
31
|
+
"scripts/check-tour.mjs",
|
|
32
|
+
"scripts/tour-prompt.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test tests/*.test.mjs",
|
|
36
|
+
"install-host": "node bin/pr-docent.mjs install"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validates a tour against a pull request diff.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]
|
|
5
|
+
//
|
|
6
|
+
// Prints every problem and exits 1, or prints a one-line summary and exits 0.
|
|
7
|
+
// The diff must use 3 lines of context (`gh pr diff` or `git diff -U3`), as GitHub does.
|
|
8
|
+
// --guidelines points at a copy of the repository's .github/pr-docent.md; a missing file means
|
|
9
|
+
// the default limits.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
|
|
13
|
+
import { checkBrevity, parseGuidelines } from "../src/guidelines.js";
|
|
14
|
+
import { flattenSteps, parseTour } from "../src/tour.js";
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const flag = args.indexOf("--guidelines");
|
|
18
|
+
const guidelinesPath = flag >= 0 ? args.splice(flag, 2)[1] : null;
|
|
19
|
+
const [tourPath, diffPath, expectedPr] = args;
|
|
20
|
+
if (!tourPath || !diffPath) {
|
|
21
|
+
console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fail = (errors) => {
|
|
26
|
+
console.error(`Tour has ${errors.length} problem(s):`);
|
|
27
|
+
for (const error of errors) console.error(`- ${error}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(readFileSync(tourPath, "utf8"));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
fail([`${tourPath} is not valid JSON: ${error.message}`]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { tour, errors } = parseTour(raw);
|
|
39
|
+
if (errors.length) fail(errors);
|
|
40
|
+
|
|
41
|
+
if (expectedPr) {
|
|
42
|
+
const actual = `${tour.pr.owner}/${tour.pr.repo}#${tour.pr.number}`;
|
|
43
|
+
if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const diff = readFileSync(diffPath, "utf8");
|
|
47
|
+
const guidelines = parseGuidelines(guidelinesPath && existsSync(guidelinesPath) ? readFileSync(guidelinesPath, "utf8") : "");
|
|
48
|
+
if (guidelines.errors.length) fail(guidelines.errors.map((error) => `.github/pr-docent.md: ${error}`));
|
|
49
|
+
const problems = [
|
|
50
|
+
...checkTour(tour, visibleLines(diff)),
|
|
51
|
+
...checkBrevity(tour, { changedLines: changedLineCount(diff), limits: guidelines.limits }),
|
|
52
|
+
];
|
|
53
|
+
if (problems.length) fail(problems);
|
|
54
|
+
|
|
55
|
+
console.log(`OK: ${tour.chapters.length} chapters, ${flattenSteps(tour).length} steps, ${tour.skipped.length} skipped files`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
You are writing a PR Docent review tour for pull request {{PR}}.
|
|
2
|
+
|
|
3
|
+
A tour guides a human reviewer through the diff on GitHub's Files changed page: a browser
|
|
4
|
+
extension scrolls to each step, highlights its lines and reads the note aloud with a speech
|
|
5
|
+
synthesizer, pointing at the lines each sentence talks about. Reviewers keep their eyes on the
|
|
6
|
+
code while they listen, so the tour must say what to look at and why, in reading order.
|
|
7
|
+
|
|
8
|
+
## Inputs
|
|
9
|
+
|
|
10
|
+
- `{{OUT}}/pr.json`: the PR's title, body and head commit.
|
|
11
|
+
- `{{OUT}}/pr.diff`: the diff exactly as GitHub shows it (3 lines of context).
|
|
12
|
+
- The current directory: the repository at the head commit (when generated locally, only the
|
|
13
|
+
files this PR changes). Use the Read tool to see line numbers.
|
|
14
|
+
|
|
15
|
+
## Output
|
|
16
|
+
|
|
17
|
+
Write the tour to `{{OUT}}/tour.json`, then run
|
|
18
|
+
|
|
19
|
+
node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}} --guidelines {{OUT}}/guidelines.md
|
|
20
|
+
|
|
21
|
+
and fix every problem it reports until it prints `OK`. Then stop. Do not post comments or
|
|
22
|
+
change any other file. Use these absolute paths as written and do not change directory:
|
|
23
|
+
`{{OUT}}/tour.json` is the only file you are allowed to write.
|
|
24
|
+
|
|
25
|
+
## Format
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"version": 1,
|
|
30
|
+
"pr": { "owner": "...", "repo": "...", "number": 0 },
|
|
31
|
+
"title": "한 줄 제목",
|
|
32
|
+
"summary": "PR이 무엇을 왜 바꾸는지 2~4문장",
|
|
33
|
+
"chapters": [
|
|
34
|
+
{
|
|
35
|
+
"title": "1. 챕터 제목",
|
|
36
|
+
"summary": "한 문장",
|
|
37
|
+
"steps": [
|
|
38
|
+
{
|
|
39
|
+
"file": "path/from/repo/root.kt",
|
|
40
|
+
"lines": [40, 58],
|
|
41
|
+
"side": "right",
|
|
42
|
+
"level": "focus",
|
|
43
|
+
"note": [
|
|
44
|
+
{ "text": "이 문장이 설명하는 줄을 가리킵니다.", "lines": [41, 45] },
|
|
45
|
+
{ "text": "다음 문장은 다른 줄을 가리킵니다.", "lines": [50, 58] }
|
|
46
|
+
],
|
|
47
|
+
"point": "리뷰어가 확인할 구체적인 질문"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"skipped": [{ "file": "package-lock.json", "reason": "의존성 잠금 파일" }]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Rules
|
|
57
|
+
|
|
58
|
+
Be brief. Reviewers listen while they look at the code, so every sentence must earn its place.
|
|
59
|
+
The checker enforces these limits for this diff ({{CHANGED_LINES}} changed lines):
|
|
60
|
+
- At most {{MAX_STEPS}} steps in total.
|
|
61
|
+
- At most {{SENTENCES_PER_STEP}} sentences per step, each at most {{SENTENCE_CHARS}} characters.
|
|
62
|
+
- `point`: one question, at most {{POINT_CHARS}} characters.
|
|
63
|
+
- `summary`: at most {{SUMMARY_CHARS}} characters; each chapter `summary`: at most
|
|
64
|
+
{{CHAPTER_SUMMARY_CHARS}}.
|
|
65
|
+
|
|
66
|
+
Coverage and order
|
|
67
|
+
- Every changed file must be visited by a step or listed in `skipped` with a short reason.
|
|
68
|
+
- Make steps only for changes a reviewer must think about. Put trivial changes in `skipped`
|
|
69
|
+
instead of steps: comment or doc-comment edits, imports, docs, test fixtures that pass values
|
|
70
|
+
through, lock files, generated code, renames, binaries. A file can have a step for its real
|
|
71
|
+
change and nothing for its trivial ones.
|
|
72
|
+
- Merge neighbouring changes that make one point into one step; never repeat a step.
|
|
73
|
+
- Order chapters the way a person should read the change, not by file path: what changes and
|
|
74
|
+
why, then the core logic, then how it is wired in, then tests.
|
|
75
|
+
|
|
76
|
+
Lines
|
|
77
|
+
- `lines` are line numbers in the new file (`"side": "right"`, the default). Use
|
|
78
|
+
`"side": "left"` with old-file numbers only for code that was deleted.
|
|
79
|
+
- Every line in a step's range must be shown in the diff: inside a hunk, not in the
|
|
80
|
+
unchanged code GitHub folds away. The checker lists what each file shows.
|
|
81
|
+
- Each note sentence's `lines` must sit inside its step's `lines` and point exactly at what
|
|
82
|
+
that sentence talks about, usually 1-8 lines. Leave `lines` out for a sentence about the
|
|
83
|
+
step as a whole.
|
|
84
|
+
|
|
85
|
+
Notes are spoken aloud
|
|
86
|
+
- Write in {{LANGUAGE}}. One sentence per note entry, one idea per sentence.
|
|
87
|
+
- Say why the change matters: intent, risk, or how it connects to other code. Do not restate
|
|
88
|
+
what the code plainly shows ("adds a parameter", "imports X", "updates the comment").
|
|
89
|
+
- Avoid symbols, brackets and long lists that sound bad in speech. Name identifiers as written.
|
|
90
|
+
|
|
91
|
+
Levels and review points
|
|
92
|
+
- `focus`: where a bug would hurt or a design decision is made; at most about a third of the
|
|
93
|
+
steps. `skim`: worth a look.
|
|
94
|
+
- `point` is one concrete question for the reviewer, grounded in the code you read: an edge
|
|
95
|
+
case, a failure mode, a contract another caller relies on. Leave it out when there is nothing
|
|
96
|
+
real to check. Never invent problems.
|
|
97
|
+
|
|
98
|
+
{{GUIDELINES}}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Checks a tour against the pull request's diff. Pure functions — no DOM, no chrome.*.
|
|
2
|
+
//
|
|
3
|
+
// GitHub's Files changed page shows each hunk of a unified diff with 3 lines of context,
|
|
4
|
+
// so a tour can only point at lines that appear in `git diff -U3`. Generated tours get
|
|
5
|
+
// line numbers wrong easily; this catches that before the tour is published.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lines visible on the Files changed page, per file: { right: Set, left: Set } where right
|
|
9
|
+
* holds new-file line numbers (context and additions) and left old-file ones (context and
|
|
10
|
+
* deletions). Keyed by the new path (the old path for deleted files).
|
|
11
|
+
*/
|
|
12
|
+
export function visibleLines(diffText) {
|
|
13
|
+
const files = new Map();
|
|
14
|
+
let current = null;
|
|
15
|
+
let inHunk = false;
|
|
16
|
+
let oldLine = 0;
|
|
17
|
+
let newLine = 0;
|
|
18
|
+
for (const line of String(diffText).split("\n")) {
|
|
19
|
+
if (line.startsWith("diff --git ")) {
|
|
20
|
+
// Every file has this header, including binaries and pure renames that have no hunks.
|
|
21
|
+
current = { right: new Set(), left: new Set() };
|
|
22
|
+
files.set(line.slice(line.lastIndexOf(" b/") + 3), current);
|
|
23
|
+
inHunk = false;
|
|
24
|
+
} else if (!current) {
|
|
25
|
+
continue;
|
|
26
|
+
} else if (line.startsWith("@@")) {
|
|
27
|
+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
28
|
+
if (match) [oldLine, newLine] = [Number(match[1]), Number(match[2])];
|
|
29
|
+
inHunk = Boolean(match);
|
|
30
|
+
} else if (!inHunk) {
|
|
31
|
+
continue; // index, mode, rename, ---/+++ headers
|
|
32
|
+
} else if (line.startsWith("+")) {
|
|
33
|
+
current.right.add(newLine++);
|
|
34
|
+
} else if (line.startsWith("-")) {
|
|
35
|
+
current.left.add(oldLine++);
|
|
36
|
+
} else if (line.startsWith(" ")) {
|
|
37
|
+
current.right.add(newLine++);
|
|
38
|
+
current.left.add(oldLine++);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return files;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Problems that would make the tour point at the wrong place or skip part of the PR:
|
|
46
|
+
* files outside the diff, lines not shown on the page, beats outside their step, repeated
|
|
47
|
+
* steps, and changed files the tour neither visits nor skips.
|
|
48
|
+
*/
|
|
49
|
+
export function checkTour(tour, files) {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const visited = new Set();
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
tour.chapters.forEach((chapter, c) =>
|
|
54
|
+
chapter.steps.forEach((step, s) => {
|
|
55
|
+
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
56
|
+
visited.add(step.file);
|
|
57
|
+
const key = `${step.file}|${step.side}|${step.lines}`;
|
|
58
|
+
if (seen.has(key)) errors.push(`${where}: repeats an earlier step on the same lines`);
|
|
59
|
+
seen.add(key);
|
|
60
|
+
const file = files.get(step.file);
|
|
61
|
+
if (!file) return errors.push(`${where}: file is not changed in this pull request`);
|
|
62
|
+
const shown = file[step.side];
|
|
63
|
+
const check = (lines, label) => {
|
|
64
|
+
const missing = range(lines).filter((n) => !shown.has(n));
|
|
65
|
+
if (missing.length) {
|
|
66
|
+
errors.push(`${label}: lines ${formatRanges(missing)} are not shown in the diff (${step.side} side shows ${formatRanges([...shown])})`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (step.lines) check(step.lines, where);
|
|
70
|
+
step.beats.forEach((beat, b) => {
|
|
71
|
+
if (!beat.lines) return;
|
|
72
|
+
if (!step.lines || beat.lines[0] < step.lines[0] || beat.lines[1] > step.lines[1]) {
|
|
73
|
+
errors.push(`${where} note[${b}]: lines ${formatRanges(range(beat.lines))} must be inside the step's lines`);
|
|
74
|
+
} else {
|
|
75
|
+
check(beat.lines, `${where} note[${b}]`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
for (const skipped of tour.skipped) visited.add(skipped.file);
|
|
81
|
+
for (const path of files.keys()) {
|
|
82
|
+
if (!visited.has(path)) errors.push(`${path}: changed but neither visited by a step nor listed in "skipped"`);
|
|
83
|
+
}
|
|
84
|
+
return errors;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Added plus removed lines, which sets how long a tour of the diff may be. */
|
|
88
|
+
export function changedLineCount(diffText) {
|
|
89
|
+
let count = 0;
|
|
90
|
+
let inHunk = false;
|
|
91
|
+
for (const line of String(diffText).split("\n")) {
|
|
92
|
+
if (line.startsWith("diff --git ")) inHunk = false;
|
|
93
|
+
else if (line.startsWith("@@")) inHunk = true;
|
|
94
|
+
else if (inHunk && (line.startsWith("+") || line.startsWith("-"))) count++;
|
|
95
|
+
}
|
|
96
|
+
return count;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function range([start, end]) {
|
|
100
|
+
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** [1, 2, 3, 7, 9, 10] → "1-3, 7, 9-10" */
|
|
104
|
+
export function formatRanges(numbers) {
|
|
105
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
106
|
+
const parts = [];
|
|
107
|
+
for (let i = 0; i < sorted.length; ) {
|
|
108
|
+
let j = i;
|
|
109
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++;
|
|
110
|
+
parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
|
|
111
|
+
i = j + 1;
|
|
112
|
+
}
|
|
113
|
+
return parts.join(", ") || "none";
|
|
114
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// How long a generated tour may be, and a repository's own instructions for it.
|
|
2
|
+
// Pure functions — no DOM, no chrome.*, no file system.
|
|
3
|
+
//
|
|
4
|
+
// A repository can add .github/pr-docent.md. Its optional front matter overrides the limits,
|
|
5
|
+
// and the rest is appended to the generator's instructions:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// maxSteps: 5
|
|
9
|
+
// sentenceChars: 100
|
|
10
|
+
// ---
|
|
11
|
+
// Use the domain terms 원장, 안분 and 파생 라인. Always mark migrations as focus.
|
|
12
|
+
|
|
13
|
+
import { flattenSteps, splitSentences } from "./tour.js";
|
|
14
|
+
|
|
15
|
+
export const GUIDELINES_PATH = ".github/pr-docent.md";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_LIMITS = {
|
|
18
|
+
maxSteps: null, // null: 3 + 1 per 25 changed lines, at most 20
|
|
19
|
+
sentencesPerStep: 3,
|
|
20
|
+
sentenceChars: 80,
|
|
21
|
+
pointChars: 120,
|
|
22
|
+
summaryChars: 200,
|
|
23
|
+
chapterSummaryChars: 60,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** { limits, instructions, errors } from the text of .github/pr-docent.md (or "" when absent). */
|
|
27
|
+
export function parseGuidelines(text) {
|
|
28
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
29
|
+
const errors = [];
|
|
30
|
+
const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
31
|
+
for (const line of match ? match[1].split(/\r?\n/) : []) {
|
|
32
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
33
|
+
const [, key, value] = line.replace(/\s+#.*$/, "").match(/^\s*(\w+)\s*:\s*(.*?)\s*$/) ?? [];
|
|
34
|
+
if (!key || !(key in DEFAULT_LIMITS)) errors.push(`Unknown setting: ${line.trim()}`);
|
|
35
|
+
else if (!/^\d+$/.test(value) || Number(value) < 1) errors.push(`${key} must be a positive whole number`);
|
|
36
|
+
else limits[key] = Number(value);
|
|
37
|
+
}
|
|
38
|
+
const instructions = String(text ?? "").slice(match ? match[0].length : 0).trim();
|
|
39
|
+
return { limits, instructions, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many steps a tour of a diff with [changedLines] added or removed lines may have. */
|
|
43
|
+
export function stepBudget(changedLines, limits = DEFAULT_LIMITS) {
|
|
44
|
+
return limits.maxSteps ?? Math.min(20, 3 + Math.ceil(changedLines / 25));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Problems that make a tour wordier than the limits allow. */
|
|
48
|
+
export function checkBrevity(tour, { changedLines, limits = DEFAULT_LIMITS }) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const steps = flattenSteps(tour);
|
|
51
|
+
const budget = stepBudget(changedLines, limits);
|
|
52
|
+
if (steps.length > budget) {
|
|
53
|
+
errors.push(`${steps.length} steps for ${changedLines} changed lines; at most ${budget}. Merge related steps and list trivial changes (comments, imports, docs, pass-through code) in "skipped".`);
|
|
54
|
+
}
|
|
55
|
+
if (tour.summary.length > limits.summaryChars) errors.push(`summary is ${tour.summary.length} characters; at most ${limits.summaryChars}`);
|
|
56
|
+
tour.chapters.forEach((chapter, c) => {
|
|
57
|
+
if (chapter.summary.length > limits.chapterSummaryChars) {
|
|
58
|
+
errors.push(`chapters[${c}].summary is ${chapter.summary.length} characters; at most ${limits.chapterSummaryChars}`);
|
|
59
|
+
}
|
|
60
|
+
chapter.steps.forEach((step, s) => {
|
|
61
|
+
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
62
|
+
const sentences = step.beats.flatMap((beat) => splitSentences(beat.text).map((x) => x.text));
|
|
63
|
+
if (sentences.length > limits.sentencesPerStep) {
|
|
64
|
+
errors.push(`${where}: ${sentences.length} sentences; at most ${limits.sentencesPerStep}. Keep only what the code doesn't already say.`);
|
|
65
|
+
}
|
|
66
|
+
for (const sentence of sentences) {
|
|
67
|
+
if (sentence.length > limits.sentenceChars) {
|
|
68
|
+
errors.push(`${where}: a sentence is ${sentence.length} characters; at most ${limits.sentenceChars}: "${sentence.slice(0, 30)}…"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (step.point.length > limits.pointChars) {
|
|
72
|
+
errors.push(`${where}: point is ${step.point.length} characters; at most ${limits.pointChars}. Ask one question.`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The generator's instructions: [template] (scripts/tour-prompt.md) with the paths, the limits
|
|
81
|
+
* for this diff and the repository's own instructions filled in.
|
|
82
|
+
*/
|
|
83
|
+
export function buildPrompt(template, { pr, out, tools, language, changedLines, guidelinesText = "" }) {
|
|
84
|
+
const { limits, instructions } = parseGuidelines(guidelinesText);
|
|
85
|
+
const vars = {
|
|
86
|
+
PR: pr,
|
|
87
|
+
OUT: out,
|
|
88
|
+
TOOLS: tools,
|
|
89
|
+
LANGUAGE: language,
|
|
90
|
+
CHANGED_LINES: changedLines,
|
|
91
|
+
MAX_STEPS: stepBudget(changedLines, limits),
|
|
92
|
+
SENTENCES_PER_STEP: limits.sentencesPerStep,
|
|
93
|
+
SENTENCE_CHARS: limits.sentenceChars,
|
|
94
|
+
POINT_CHARS: limits.pointChars,
|
|
95
|
+
SUMMARY_CHARS: limits.summaryChars,
|
|
96
|
+
CHAPTER_SUMMARY_CHARS: limits.chapterSummaryChars,
|
|
97
|
+
GUIDELINES: instructions
|
|
98
|
+
? `## Repository guidelines (${GUIDELINES_PATH})\n\nThese come from the repository and take precedence over the rules above.\n\n${instructions}\n`
|
|
99
|
+
: "",
|
|
100
|
+
};
|
|
101
|
+
return template.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
|
|
102
|
+
}
|
package/src/tour.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Tour model: validation, flattening and coverage. Pure functions — no DOM, no chrome.*.
|
|
2
|
+
//
|
|
3
|
+
// A tour (version 1):
|
|
4
|
+
// {
|
|
5
|
+
// "version": 1,
|
|
6
|
+
// "pr": { "owner": "your-org", "repo": "your-repo", "number": 42 },
|
|
7
|
+
// "title": "...", "summary": "...",
|
|
8
|
+
// "chapters": [{ "title": "...", "summary": "...", "steps": [
|
|
9
|
+
// { "file": "path/in/repo.kt", "lines": [10, 24], "side": "right", "level": "focus", "note": "...", "point": "..." }
|
|
10
|
+
// ]}],
|
|
11
|
+
// "skipped": [{ "file": "server/gradlew", "reason": "generated wrapper" }]
|
|
12
|
+
// }
|
|
13
|
+
// lines and side are optional (without lines the step points at the whole file);
|
|
14
|
+
// level is focus | skim | skip; point (optional) is what the reviewer should check.
|
|
15
|
+
// note is a string, or a list of beats that each point at the lines they talk about:
|
|
16
|
+
// "note": [{ "text": "findFocus finds ...", "lines": [58, 63] }, { "text": "..." }]
|
|
17
|
+
|
|
18
|
+
export const LEVELS = ["focus", "skim", "skip"];
|
|
19
|
+
|
|
20
|
+
/** Validates and normalizes raw JSON. Returns { tour, errors }; tour is null when there are errors. */
|
|
21
|
+
export function parseTour(raw) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
const fail = (msg) => errors.push(msg);
|
|
24
|
+
if (!raw || typeof raw !== "object") return { tour: null, errors: ["Tour must be a JSON object"] };
|
|
25
|
+
if (raw.version !== 1) fail(`Unsupported tour version: ${raw.version}`);
|
|
26
|
+
|
|
27
|
+
const pr = raw.pr || {};
|
|
28
|
+
if (!pr.owner || !pr.repo || !Number.isInteger(pr.number)) fail("pr.owner, pr.repo and pr.number are required");
|
|
29
|
+
if (!Array.isArray(raw.chapters) || raw.chapters.length === 0) fail("At least one chapter is required");
|
|
30
|
+
|
|
31
|
+
const chapters = (raw.chapters || []).map((chapter, c) => {
|
|
32
|
+
if (!chapter.title) fail(`chapters[${c}].title is required`);
|
|
33
|
+
if (!Array.isArray(chapter.steps) || chapter.steps.length === 0) fail(`chapters[${c}] needs at least one step`);
|
|
34
|
+
const steps = (chapter.steps || []).map((step, s) => {
|
|
35
|
+
const where = `chapters[${c}].steps[${s}]`;
|
|
36
|
+
if (typeof step.file !== "string" || !step.file) fail(`${where}.file is required`);
|
|
37
|
+
const lines = parseLines(step.lines, where, fail);
|
|
38
|
+
const side = step.side ?? "right";
|
|
39
|
+
if (side !== "left" && side !== "right") fail(`${where}.side must be "left" or "right"`);
|
|
40
|
+
const level = step.level ?? "skim";
|
|
41
|
+
if (!LEVELS.includes(level)) fail(`${where}.level must be one of ${LEVELS.join(", ")}`);
|
|
42
|
+
if (step.point !== undefined && typeof step.point !== "string") fail(`${where}.point must be a string`);
|
|
43
|
+
const beats = parseBeats(step.note, where, fail);
|
|
44
|
+
const note = beats.map((beat) => beat.text).join(" ");
|
|
45
|
+
return { file: step.file, lines, side, level, note, beats, point: step.point ?? "" };
|
|
46
|
+
});
|
|
47
|
+
return { title: String(chapter.title ?? ""), summary: String(chapter.summary ?? ""), steps };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const skipped = (raw.skipped || []).map((s) => ({ file: String(s.file ?? ""), reason: String(s.reason ?? "") }));
|
|
51
|
+
if (errors.length) return { tour: null, errors };
|
|
52
|
+
return {
|
|
53
|
+
tour: {
|
|
54
|
+
version: 1,
|
|
55
|
+
pr: { owner: pr.owner, repo: pr.repo, number: pr.number },
|
|
56
|
+
title: String(raw.title ?? ""),
|
|
57
|
+
summary: String(raw.summary ?? ""),
|
|
58
|
+
chapters,
|
|
59
|
+
skipped,
|
|
60
|
+
},
|
|
61
|
+
errors: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** [start, end] line range, or null when absent; reports a malformed one through [fail]. */
|
|
66
|
+
function parseLines(value, where, fail) {
|
|
67
|
+
if (value === undefined) return null;
|
|
68
|
+
const [start, end] = Array.isArray(value) ? value : [];
|
|
69
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1 || end < start) {
|
|
70
|
+
fail(`${where}.lines must be [start, end] with 1 <= start <= end`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return [start, end];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A note as beats: a plain string is one beat that points at no particular lines. */
|
|
77
|
+
function parseBeats(note, where, fail) {
|
|
78
|
+
if (!Array.isArray(note)) return note ? [{ text: String(note), lines: null }] : [];
|
|
79
|
+
return note.map((beat, b) => {
|
|
80
|
+
const at = `${where}.note[${b}]`;
|
|
81
|
+
if (typeof beat?.text !== "string" || !beat.text) fail(`${at}.text is required`);
|
|
82
|
+
return { text: String(beat?.text ?? ""), lines: parseLines(beat?.lines, at, fail) };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every step in order, with its chapter index and its position inside the chapter. */
|
|
87
|
+
export function flattenSteps(tour) {
|
|
88
|
+
return tour.chapters.flatMap((chapter, chapterIndex) =>
|
|
89
|
+
chapter.steps.map((step, stepIndex) => ({ ...step, chapterIndex, stepIndex, chapterTitle: chapter.title })),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** PR files the tour neither visits nor explicitly skips — the reviewer must look at these alone. */
|
|
94
|
+
export function uncoveredFiles(tour, prFiles) {
|
|
95
|
+
const covered = new Set([
|
|
96
|
+
...tour.chapters.flatMap((c) => c.steps.map((s) => s.file)),
|
|
97
|
+
...tour.skipped.map((s) => s.file),
|
|
98
|
+
]);
|
|
99
|
+
return prFiles.filter((file) => !covered.has(file));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Seconds a step stays on screen when narration is off: roughly reading speed, at least 4s. */
|
|
103
|
+
export function readingSeconds(text) {
|
|
104
|
+
return Math.max(4, Math.round(String(text).length / 12));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splits narration into sentences, each with its offset in [text], so it can be read (and
|
|
109
|
+
* resumed) a sentence at a time. A period after a digit ("2. 집중 단계") is a list number,
|
|
110
|
+
* not a sentence end.
|
|
111
|
+
*/
|
|
112
|
+
export function splitSentences(text) {
|
|
113
|
+
const sentences = [];
|
|
114
|
+
const pattern = /[^\d\s][.!?](?=\s)/g;
|
|
115
|
+
let start = 0;
|
|
116
|
+
for (let match; (match = pattern.exec(text)); ) {
|
|
117
|
+
const end = match.index + match[0].length;
|
|
118
|
+
pushSentence(sentences, text, start, end);
|
|
119
|
+
start = end;
|
|
120
|
+
}
|
|
121
|
+
pushSentence(sentences, text, start, text.length);
|
|
122
|
+
return sentences;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pushSentence(sentences, text, start, end) {
|
|
126
|
+
const raw = text.slice(start, end);
|
|
127
|
+
const trimmed = raw.trim();
|
|
128
|
+
if (trimmed) sentences.push({ start: start + raw.indexOf(trimmed), text: trimmed });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What to say for a step, one sentence per unit, each with the lines to point at while it
|
|
133
|
+
* is read (null: just the step's own range). Opens with the chapter title when given and
|
|
134
|
+
* ends with the review point.
|
|
135
|
+
*/
|
|
136
|
+
export function narrationUnits(step, { chapterTitle = "", pointLabel = "" } = {}) {
|
|
137
|
+
const units = [];
|
|
138
|
+
const add = (text, lines) => splitSentences(text).forEach((s) => units.push({ text: s.text, lines }));
|
|
139
|
+
if (chapterTitle) add(`${chapterTitle}.`, null);
|
|
140
|
+
for (const beat of step.beats) add(beat.text, beat.lines);
|
|
141
|
+
if (step.point) add(`${pointLabel}: ${step.point}`, null);
|
|
142
|
+
return units;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The tour for [pr] among code blocks of the PR's comments (the generator posts it as a
|
|
147
|
+
* JSON block): the last block that parses as a version 1 tour of this PR, or null.
|
|
148
|
+
*/
|
|
149
|
+
export function findTourInBlocks(blocks, { owner, repo, number }) {
|
|
150
|
+
let found = null;
|
|
151
|
+
for (const text of blocks) {
|
|
152
|
+
let raw;
|
|
153
|
+
try {
|
|
154
|
+
raw = JSON.parse(text);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (raw?.version === 1 && raw.pr?.owner === owner && raw.pr?.repo === repo && raw.pr?.number === number) found = raw;
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** "ko" when the tour's text has Hangul, otherwise "en": the language to narrate it in. */
|
|
164
|
+
export function tourLanguage(tour) {
|
|
165
|
+
const text = [tour.title, tour.summary, ...flattenSteps(tour).flatMap((s) => [s.note, s.point])].join(" ");
|
|
166
|
+
return /[가-힣]/.test(text) ? "ko" : "en";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The bundled tour file name for a PR, e.g. "your-org--your-repo--42.json". */
|
|
170
|
+
export function tourFileName({ owner, repo, number }) {
|
|
171
|
+
return `${owner}--${repo}--${number}.json`;
|
|
172
|
+
}
|