@vintasoftware/pr-review-canvas 0.1.0
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 +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// The node ids a mermaid source names. The validator uses them to tell a diagram link keyed on a
|
|
2
|
+
// real node from one keyed on a name the drawing does not have.
|
|
3
|
+
//
|
|
4
|
+
// Coverage: the four types the prompt recommends (flowchart, sequence, state, ER) are read
|
|
5
|
+
// statement by statement; every other type, and any source these readers find no name in, falls
|
|
6
|
+
// back to every word of the text. Each reader keeps names it is unsure about, since a name it
|
|
7
|
+
// misses would be reported as a mistake the model did not make.
|
|
8
|
+
|
|
9
|
+
import { diagramKind } from '../contract/mermaid-fences.js'
|
|
10
|
+
|
|
11
|
+
/** A bare word: an identifier, or a number a flowchart may use as a node id. */
|
|
12
|
+
const WORD_RE = /[A-Za-z_][\w.-]*|\d[\w.-]*/g
|
|
13
|
+
|
|
14
|
+
/** Words that open a statement instead of naming a node. */
|
|
15
|
+
const FLOW_KEYWORDS = new Set([
|
|
16
|
+
'flowchart',
|
|
17
|
+
'flowchart-elk',
|
|
18
|
+
'graph',
|
|
19
|
+
'subgraph',
|
|
20
|
+
'end',
|
|
21
|
+
'direction',
|
|
22
|
+
'accTitle',
|
|
23
|
+
'accDescr',
|
|
24
|
+
'LR',
|
|
25
|
+
'RL',
|
|
26
|
+
'TB',
|
|
27
|
+
'BT',
|
|
28
|
+
'TD',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
/** Statements that name styles, classes, or callbacks rather than nodes. */
|
|
32
|
+
const FLOW_SKIP = /^(class|classDef|click|style|linkStyle|href|callback)\b/
|
|
33
|
+
|
|
34
|
+
const STATE_KEYWORDS = new Set([
|
|
35
|
+
'stateDiagram',
|
|
36
|
+
'stateDiagram-v2',
|
|
37
|
+
'state',
|
|
38
|
+
'as',
|
|
39
|
+
'direction',
|
|
40
|
+
'note',
|
|
41
|
+
'end',
|
|
42
|
+
'left',
|
|
43
|
+
'right',
|
|
44
|
+
'of',
|
|
45
|
+
'classDef',
|
|
46
|
+
'class',
|
|
47
|
+
'accTitle',
|
|
48
|
+
'accDescr',
|
|
49
|
+
'LR',
|
|
50
|
+
'RL',
|
|
51
|
+
'TB',
|
|
52
|
+
'BT',
|
|
53
|
+
'TD',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const ER_KEYWORDS = new Set(['erDiagram', 'accTitle', 'accDescr', 'direction', 'LR', 'RL', 'TB', 'BT', 'TD'])
|
|
57
|
+
|
|
58
|
+
/** The text before the first `sep`, or all of it when there is none. */
|
|
59
|
+
function before(text: string, sep: string): string {
|
|
60
|
+
const at = text.indexOf(sep)
|
|
61
|
+
return at === -1 ? text : text.slice(0, at)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The words of a text, with no attempt at grammar. */
|
|
65
|
+
function words(text: string): string[] {
|
|
66
|
+
return text.match(WORD_RE) ?? []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The lines of the source without its comments and without empty lines. */
|
|
70
|
+
function lines(source: string): string[] {
|
|
71
|
+
return source
|
|
72
|
+
.replace(/\r\n/g, '\n')
|
|
73
|
+
.split('\n')
|
|
74
|
+
.map(line => line.trim())
|
|
75
|
+
.filter(line => line !== '' && !line.startsWith('%%'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Drops quoted text and the label brackets around it, innermost first. */
|
|
79
|
+
function stripLabels(line: string): string {
|
|
80
|
+
let out = line.replace(/"[^"]*"/g, ' ').replace(/\|[^|]*\|/g, ' ')
|
|
81
|
+
for (let i = 0; i < 6; i++) {
|
|
82
|
+
const next = out.replace(/\[[^[\]]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
|
|
83
|
+
if (next === out) {
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
out = next
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replaces the arrows and edge markers of a flowchart with spaces, the way mermaid's own lexer
|
|
93
|
+
* reads them: two or more of `-=.`, then one head, either `>` or the `x`/`o` of a crossing or
|
|
94
|
+
* circle arrow. A head is part of the arrow even with the next name glued to it (`A---oB` names
|
|
95
|
+
* `B`), while `A-->oven` keeps its `oven`, since that arrow already has its head. A leading
|
|
96
|
+
* `x`/`o` is the tail of a crossing or circle arrow only when the same letter closes it. An
|
|
97
|
+
* invisible link (`~~~`) takes no head, so `A~~~orange` keeps its `orange`.
|
|
98
|
+
*/
|
|
99
|
+
function stripFlowArrows(line: string): string {
|
|
100
|
+
return line.replace(/(?<![\w-])([ox])(?=[-=.]{2,}\1(?![-=.]))|<?[-=.]{2,}(?:>|[xo])?|~{2,}|&/g, ' ')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function flowchartIds(source: string): string[] {
|
|
104
|
+
const out: string[] = []
|
|
105
|
+
for (const line of lines(source)) {
|
|
106
|
+
if (FLOW_SKIP.test(line)) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
for (const word of words(stripFlowArrows(stripLabels(line)))) {
|
|
110
|
+
if (!FLOW_KEYWORDS.has(word)) {
|
|
111
|
+
out.push(word)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sequenceIds(source: string): string[] {
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
for (const line of lines(source)) {
|
|
121
|
+
const declared = /^(?:participant|actor|create\s+(?:participant|actor))\s+(.+)$/.exec(line)?.[1]
|
|
122
|
+
if (declared !== undefined) {
|
|
123
|
+
out.push(...words(declared.split(/\s+as\s+/)[0] ?? declared).slice(0, 1))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
// A message names its two sides; the text after the first colon is the message itself.
|
|
127
|
+
const head = before(line, ':')
|
|
128
|
+
if (!/-{1,2}[)>x-]/.test(head)) {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
for (const side of head.split(/(?:<<)?-{1,2}(?:>>|>|x|\))/)) {
|
|
132
|
+
out.push(...words(side))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stateIds(source: string): string[] {
|
|
139
|
+
const out: string[] = []
|
|
140
|
+
const take = (text: string): void => {
|
|
141
|
+
for (const word of words(text).slice(0, 1)) {
|
|
142
|
+
if (!STATE_KEYWORDS.has(word)) {
|
|
143
|
+
out.push(word)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const line of lines(source)) {
|
|
148
|
+
const bare = stripLabels(line).replace(/<<[^>]*>>/g, ' ')
|
|
149
|
+
if (bare.startsWith('state ')) {
|
|
150
|
+
const named = /\bas\s+([^\s{]+)/.exec(bare)?.[1]
|
|
151
|
+
take(named === undefined ? bare.slice('state '.length) : named)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (bare.includes('-->')) {
|
|
155
|
+
// `a --> b : label` carries the label on the right side.
|
|
156
|
+
for (const side of bare.split('-->')) {
|
|
157
|
+
take(before(side, ':'))
|
|
158
|
+
}
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
// `id : description` names a state and describes it.
|
|
162
|
+
take(before(bare, ':'))
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The quoted names on the entity side of an ER line, which is where a name holds a space. The
|
|
169
|
+
* scan stops at the colon that opens the relationship label, so the label is not read as a name.
|
|
170
|
+
*/
|
|
171
|
+
function quotedEntities(line: string): string[] {
|
|
172
|
+
const out: string[] = []
|
|
173
|
+
let quote = -1
|
|
174
|
+
for (let i = 0; i < line.length; i++) {
|
|
175
|
+
const ch = line[i]
|
|
176
|
+
if (ch === '"') {
|
|
177
|
+
if (quote === -1) {
|
|
178
|
+
quote = i + 1
|
|
179
|
+
} else {
|
|
180
|
+
out.push(line.slice(quote, i))
|
|
181
|
+
quote = -1
|
|
182
|
+
}
|
|
183
|
+
} else if (ch === ':' && quote === -1) {
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function erIds(source: string): string[] {
|
|
191
|
+
const out: string[] = []
|
|
192
|
+
let depth = 0
|
|
193
|
+
for (const line of lines(source)) {
|
|
194
|
+
// A relationship marker (`||--o{`) carries a brace of its own, so it is removed before the
|
|
195
|
+
// braces that open and close an attribute block are counted.
|
|
196
|
+
const clean = stripLabels(line).replace(/[|}{o.-]{2,}/g, ' ')
|
|
197
|
+
if (depth <= 0) {
|
|
198
|
+
out.push(...quotedEntities(line))
|
|
199
|
+
for (const word of words(before(clean, ':'))) {
|
|
200
|
+
if (!ER_KEYWORDS.has(word)) {
|
|
201
|
+
out.push(word)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
depth += (clean.match(/\{/g) ?? []).length - (clean.match(/\}/g) ?? []).length
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The node ids of a mermaid source. The first line decides how the source is read; a type this
|
|
212
|
+
* module does not know, or a source it reads nothing out of, falls back to every word in the text.
|
|
213
|
+
*/
|
|
214
|
+
export function diagramNodeIds(source: string): Set<string> {
|
|
215
|
+
const type = diagramKind(source)
|
|
216
|
+
let found: string[] = []
|
|
217
|
+
if (type === 'flowchart' || type === 'flowchart-elk' || type === 'graph') {
|
|
218
|
+
found = flowchartIds(source)
|
|
219
|
+
} else if (type === 'sequenceDiagram') {
|
|
220
|
+
found = sequenceIds(source)
|
|
221
|
+
} else if (type === 'stateDiagram') {
|
|
222
|
+
found = stateIds(source)
|
|
223
|
+
} else if (type === 'erDiagram') {
|
|
224
|
+
found = erIds(source)
|
|
225
|
+
}
|
|
226
|
+
return new Set(found.length > 0 ? found : words(source))
|
|
227
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// `pr-review doctor`: one pass over everything the tool needs before it can serve a review, as
|
|
2
|
+
// one JSON line. It reports instead of throwing, so a broken setup still answers.
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { parseGithubRemote } from '../config.js'
|
|
7
|
+
import type { Git } from '../git/git.js'
|
|
8
|
+
import type { GitHubClient } from '../github/gh.js'
|
|
9
|
+
import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
|
|
10
|
+
import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME } from './install-skill.js'
|
|
11
|
+
|
|
12
|
+
export const DOCTOR_CHECKS = ['git', 'origin', 'gh', 'ghAuth', 'dataDir', 'skill'] as const
|
|
13
|
+
export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number]
|
|
14
|
+
|
|
15
|
+
export interface DoctorCheck {
|
|
16
|
+
ok: boolean
|
|
17
|
+
detail: string
|
|
18
|
+
hint?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DoctorReport {
|
|
22
|
+
ok: boolean
|
|
23
|
+
version: string
|
|
24
|
+
checks: Record<DoctorCheckName, DoctorCheck> & { acpx?: DoctorCheck }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DoctorDeps {
|
|
28
|
+
git: Git
|
|
29
|
+
gh: GitHubClient
|
|
30
|
+
version: string
|
|
31
|
+
acpxVersion: () => Promise<string | null>
|
|
32
|
+
/** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
|
|
33
|
+
dataDirOverride?: string | undefined
|
|
34
|
+
/** Answers whether a file can be read; the skill check asks for the SKILL.md inside. */
|
|
35
|
+
exists: (file: string) => Promise<boolean>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function message(err: unknown): string {
|
|
39
|
+
return err instanceof Error ? err.message : String(err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Can the process write where canvases go? A probe file is written and removed. Its name is
|
|
44
|
+
* random and it is created with `wx`, so the probe writes over nothing.
|
|
45
|
+
*/
|
|
46
|
+
async function checkDataDir(dir: string): Promise<DoctorCheck> {
|
|
47
|
+
const probe = path.join(dir, `.doctor-probe-${randomBytes(8).toString('hex')}`)
|
|
48
|
+
try {
|
|
49
|
+
await ensureDataDir(dir)
|
|
50
|
+
await writeFile(probe, '', { encoding: 'utf8', flag: 'wx' })
|
|
51
|
+
await rm(probe, { force: true })
|
|
52
|
+
return { ok: true, detail: dir }
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, detail: `${dir}: ${message(err)}`, hint: 'pass --data-dir <dir> to a writable place' }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The skill the generation flow needs, in either harness's directory. */
|
|
59
|
+
async function checkSkill(repoRoot: string | null, exists: DoctorDeps['exists']): Promise<DoctorCheck> {
|
|
60
|
+
if (repoRoot === null) {
|
|
61
|
+
return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
|
|
62
|
+
}
|
|
63
|
+
const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
|
|
64
|
+
const found: string[] = []
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
// The file the harness reads, not the directory: an empty directory, a dangling link, and
|
|
67
|
+
// an unreadable file all install nothing.
|
|
68
|
+
if (await exists(path.join(target, 'SKILL.md'))) {
|
|
69
|
+
found.push(path.relative(repoRoot, target))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (found.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
detail: `${SKILL_NAME} is in neither ${CLAUDE_SKILLS_DIR} nor ${CODEX_SKILLS_DIR}`,
|
|
76
|
+
hint: 'run `pr-review install-skill`',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, detail: found.join(', ') }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
|
|
83
|
+
const hint = 'install with `npm install -g acpx` and check `acpx --version`'
|
|
84
|
+
try {
|
|
85
|
+
const version = (await deps.acpxVersion())?.trim()
|
|
86
|
+
return version
|
|
87
|
+
? { ok: true, detail: version }
|
|
88
|
+
: { ok: false, detail: 'acpx is missing or could not report its version', hint }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return { ok: false, detail: message(err), hint }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Runs core checks and, with allChecks, checks acpx for AI Chat. */
|
|
95
|
+
export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: boolean } = {}): Promise<DoctorReport> {
|
|
96
|
+
let repoRoot: string | null = null
|
|
97
|
+
let git: DoctorCheck
|
|
98
|
+
try {
|
|
99
|
+
repoRoot = await deps.git.topLevel()
|
|
100
|
+
git = { ok: true, detail: repoRoot }
|
|
101
|
+
} catch (err) {
|
|
102
|
+
git = { ok: false, detail: message(err), hint: 'run from a clone or pass --repo <dir>' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let origin: DoctorCheck
|
|
106
|
+
try {
|
|
107
|
+
const url = await deps.git.remoteUrl('origin')
|
|
108
|
+
const repo = url === null ? null : parseGithubRemote(url)
|
|
109
|
+
origin =
|
|
110
|
+
repo === null
|
|
111
|
+
? { ok: false, detail: url ?? 'no origin remote', hint: 'add a github.com origin' }
|
|
112
|
+
: { ok: true, detail: `${repo.owner}/${repo.name}` }
|
|
113
|
+
} catch (err) {
|
|
114
|
+
origin = { ok: false, detail: message(err), hint: 'add a github.com origin' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const status = await deps.gh.authStatus()
|
|
118
|
+
const gh: DoctorCheck = status.installed
|
|
119
|
+
? { ok: true, detail: status.detail }
|
|
120
|
+
: { ok: false, detail: status.detail, hint: 'install it from https://cli.github.com' }
|
|
121
|
+
const ghAuth: DoctorCheck = status.authenticated
|
|
122
|
+
? { ok: true, detail: status.detail }
|
|
123
|
+
: { ok: false, detail: status.detail, hint: 'run `gh auth login`' }
|
|
124
|
+
|
|
125
|
+
let dataDir: DoctorCheck
|
|
126
|
+
try {
|
|
127
|
+
const commonDir = await deps.git.commonDir()
|
|
128
|
+
dataDir = await checkDataDir(resolveDataDir({ override: deps.dataDirOverride, commonDir }))
|
|
129
|
+
} catch (err) {
|
|
130
|
+
dataDir = { ok: false, detail: message(err), hint: 'pass --data-dir <dir>' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const skill = await checkSkill(repoRoot, deps.exists)
|
|
134
|
+
const checks: DoctorReport['checks'] = { git, origin, gh, ghAuth, dataDir, skill }
|
|
135
|
+
if (options.allChecks) {
|
|
136
|
+
checks.acpx = await checkAcpx(deps)
|
|
137
|
+
}
|
|
138
|
+
return { ok: Object.values(checks).every(check => check.ok), version: deps.version, checks }
|
|
139
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of glob syntax `pr-review.config.yml` patterns use: `**` crosses directories,
|
|
3
|
+
* `*` and `?` stay inside one path segment. Patterns match repo-relative paths.
|
|
4
|
+
*/
|
|
5
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
6
|
+
let out = '^'
|
|
7
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
8
|
+
const ch = pattern[i]
|
|
9
|
+
if (ch === '*') {
|
|
10
|
+
if (pattern[i + 1] === '*') {
|
|
11
|
+
i += 1
|
|
12
|
+
if (pattern[i + 1] === '/') {
|
|
13
|
+
// `**/` matches zero or more whole directories.
|
|
14
|
+
i += 1
|
|
15
|
+
out += '(?:.*/)?'
|
|
16
|
+
} else {
|
|
17
|
+
out += '.*'
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
out += '[^/]*'
|
|
21
|
+
}
|
|
22
|
+
} else if (ch === '?') {
|
|
23
|
+
out += '[^/]'
|
|
24
|
+
} else if (ch !== undefined) {
|
|
25
|
+
out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return new RegExp(`${out}$`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function matchesGlob(pattern: string, filePath: string): boolean {
|
|
32
|
+
return globToRegExp(pattern).test(filePath)
|
|
33
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `pr-review install-skill`: link the bundled skill into the host repo's skill directories, so
|
|
2
|
+
// Claude Code (`.claude/skills`) and Codex (`.agents/skills`) both see `/pr-review-canvas`.
|
|
3
|
+
import { appendFile, cp, lstat, mkdir, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { PACKAGE_ROOT } from '../server/context.js'
|
|
6
|
+
import { readText } from '../store/atomic-json.js'
|
|
7
|
+
|
|
8
|
+
export const SKILL_NAME = 'pr-review-canvas'
|
|
9
|
+
export const SKILL_SOURCE_DIR = path.join(PACKAGE_ROOT, 'skills', SKILL_NAME)
|
|
10
|
+
export const CLAUDE_SKILLS_DIR = '.claude/skills'
|
|
11
|
+
/** Codex reads repo skills from `.agents/skills` under the project root (codex-rs/ext/skills). */
|
|
12
|
+
export const CODEX_SKILLS_DIR = '.agents/skills'
|
|
13
|
+
|
|
14
|
+
export async function ignoreLocalSettings(repoRoot: string): Promise<void> {
|
|
15
|
+
const file = path.join(repoRoot, '.gitignore')
|
|
16
|
+
const text = (await readText(file)) ?? ''
|
|
17
|
+
const entry = '.pr-review/settings.yml'
|
|
18
|
+
if (text.split(/\r?\n/).some(line => line === entry || line === `/${entry}`)) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n'
|
|
22
|
+
const separator = text.length > 0 && !text.endsWith('\n') ? newline : ''
|
|
23
|
+
await appendFile(file, `${separator}${entry}${newline}`, 'utf8')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InstallSkillOptions {
|
|
27
|
+
/** Absolute skills directories to install into; each gets `<dir>/pr-review-canvas`. */
|
|
28
|
+
targets: Array<{ kind: 'claude' | 'codex'; dir: string }>
|
|
29
|
+
source?: string
|
|
30
|
+
/** Windows copies; everything else links. */
|
|
31
|
+
platform?: NodeJS.Platform
|
|
32
|
+
/** Replace a real directory that already sits at the target. */
|
|
33
|
+
force?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A real directory (a customized copy of the skill) sits where the link would go. */
|
|
37
|
+
export class SkillDirExistsError extends Error {
|
|
38
|
+
readonly path: string
|
|
39
|
+
|
|
40
|
+
constructor(target: string) {
|
|
41
|
+
super(`${target} is a directory, not a link to the bundled skill`)
|
|
42
|
+
this.name = 'SkillDirExistsError'
|
|
43
|
+
this.path = target
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type InstallStatus = 'linked' | 'copied' | 'exists' | 'replaced'
|
|
48
|
+
|
|
49
|
+
export interface InstallSkillResult {
|
|
50
|
+
skill: string
|
|
51
|
+
targets: Array<{ kind: 'claude' | 'codex'; path: string; status: InstallStatus }>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What sits at the target now: nothing, a symlink (with its target), or something else. */
|
|
55
|
+
async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link'; to: string } | { kind: 'other' }> {
|
|
56
|
+
let stats: Awaited<ReturnType<typeof lstat>>
|
|
57
|
+
try {
|
|
58
|
+
stats = await lstat(target)
|
|
59
|
+
} catch {
|
|
60
|
+
return { kind: 'none' }
|
|
61
|
+
}
|
|
62
|
+
return stats.isSymbolicLink() ? { kind: 'link', to: await readlink(target) } : { kind: 'other' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A copy carries a marker file, so a later run can tell it from a hand-made directory. */
|
|
66
|
+
export const COPY_MARKER = '.pr-review-install'
|
|
67
|
+
|
|
68
|
+
async function installOne(
|
|
69
|
+
source: string,
|
|
70
|
+
dir: string,
|
|
71
|
+
copy: boolean,
|
|
72
|
+
force: boolean
|
|
73
|
+
): Promise<{ path: string; status: InstallStatus }> {
|
|
74
|
+
await mkdir(dir, { recursive: true })
|
|
75
|
+
const realDir = await realpath(dir)
|
|
76
|
+
const target = path.join(realDir, SKILL_NAME)
|
|
77
|
+
const current = await inspect(target)
|
|
78
|
+
if (current.kind === 'other' && !force && (await inspect(path.join(target, COPY_MARKER))).kind === 'none') {
|
|
79
|
+
throw new SkillDirExistsError(target)
|
|
80
|
+
}
|
|
81
|
+
if (copy) {
|
|
82
|
+
await rm(target, { recursive: true, force: true })
|
|
83
|
+
await cp(source, target, { recursive: true })
|
|
84
|
+
await writeFile(path.join(target, COPY_MARKER), `copied from ${source}\n`, 'utf8')
|
|
85
|
+
return { path: target, status: 'copied' }
|
|
86
|
+
}
|
|
87
|
+
const relative = path.relative(realDir, source)
|
|
88
|
+
if (current.kind === 'link' && current.to === relative) {
|
|
89
|
+
return { path: target, status: 'exists' }
|
|
90
|
+
}
|
|
91
|
+
if (current.kind !== 'none') {
|
|
92
|
+
await rm(target, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
await symlink(relative, target, 'dir')
|
|
95
|
+
return { path: target, status: current.kind === 'none' ? 'linked' : 'replaced' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function installSkill(opts: InstallSkillOptions): Promise<InstallSkillResult> {
|
|
99
|
+
const source = await realpath(opts.source ?? SKILL_SOURCE_DIR)
|
|
100
|
+
const copy = (opts.platform ?? process.platform) === 'win32'
|
|
101
|
+
const targets: InstallSkillResult['targets'] = []
|
|
102
|
+
for (const t of opts.targets) {
|
|
103
|
+
const done = await installOne(source, t.dir, copy, opts.force === true)
|
|
104
|
+
targets.push({ kind: t.kind, ...done })
|
|
105
|
+
}
|
|
106
|
+
return { skill: SKILL_NAME, targets }
|
|
107
|
+
}
|