docugate 0.2.8 → 0.3.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/README.md +200 -57
- package/dist/api.d.ts +44 -0
- package/dist/api.js +11 -5
- package/dist/args.d.ts +29 -0
- package/dist/args.js +121 -0
- package/dist/auth.d.ts +21 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +62 -0
- package/dist/check.d.ts +22 -0
- package/dist/check.js +4 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +214 -165
- package/dist/commands.d.ts +35 -0
- package/dist/commands.js +293 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +43 -3
- package/dist/developers.d.ts +35 -0
- package/dist/developers.js +439 -0
- package/dist/init.d.ts +130 -0
- package/dist/init.js +102 -36
- package/dist/keys/core.d.ts +108 -0
- package/dist/keys/core.js +142 -0
- package/dist/keys/express.d.ts +21 -0
- package/dist/keys/express.js +27 -0
- package/dist/keys/fastify.d.ts +21 -0
- package/dist/keys/fastify.js +21 -0
- package/dist/keys/format.d.ts +11 -0
- package/dist/keys/format.js +46 -0
- package/dist/keys/hono.d.ts +19 -0
- package/dist/keys/hono.js +21 -0
- package/dist/keys/index.d.ts +13 -0
- package/dist/keys/index.js +12 -0
- package/dist/logo-pixels.d.ts +4 -0
- package/dist/logo-pixels.js +35 -0
- package/dist/logo.d.ts +19 -0
- package/dist/logo.js +68 -0
- package/dist/menu.d.ts +16 -0
- package/dist/menu.js +157 -0
- package/dist/openapi.d.ts +38 -0
- package/dist/openapi.js +2 -1
- package/dist/rules.d.ts +69 -0
- package/dist/rules.js +2 -1
- package/dist/select.d.ts +75 -0
- package/dist/select.js +215 -0
- package/dist/session.d.ts +46 -0
- package/dist/session.js +54 -0
- package/dist/spinner.d.ts +12 -0
- package/dist/spinner.js +12 -12
- package/dist/status.d.ts +46 -0
- package/dist/status.js +166 -0
- package/dist/sync/ai.d.ts +44 -0
- package/dist/sync/ai.js +147 -0
- package/dist/sync/apply.d.ts +25 -0
- package/dist/sync/apply.js +104 -0
- package/dist/sync/changes.d.ts +58 -0
- package/dist/sync/changes.js +167 -0
- package/dist/sync/command.d.ts +12 -0
- package/dist/sync/command.js +236 -0
- package/dist/sync/guard.d.ts +15 -0
- package/dist/sync/guard.js +21 -0
- package/dist/sync/hook.d.ts +10 -0
- package/dist/sync/hook.js +62 -0
- package/dist/sync/impact.d.ts +23 -0
- package/dist/sync/impact.js +93 -0
- package/dist/sync/pr.d.ts +37 -0
- package/dist/sync/pr.js +106 -0
- package/dist/sync/run.d.ts +50 -0
- package/dist/sync/run.js +124 -0
- package/dist/sync/state.d.ts +8 -0
- package/dist/sync/state.js +22 -0
- package/dist/sync/workflow.d.ts +20 -0
- package/dist/sync/workflow.js +84 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +16 -0
- package/dist/tour-init.d.ts +61 -0
- package/dist/tour-install.d.ts +38 -0
- package/dist/tour-install.js +58 -0
- package/dist/tour-server.d.ts +21 -0
- package/dist/tour.d.ts +60 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +63 -0
- package/package.json +67 -47
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { packageVersion } from '../banner.js';
|
|
5
|
+
import { CONFIG_FILE, loadConfig } from '../config.js';
|
|
6
|
+
import { readText } from '../text.js';
|
|
7
|
+
import { FRESHNESS_VALUES } from '../rules.js';
|
|
8
|
+
import { bold, dim, gold, green, plural, red, yellow } from '../ui.js';
|
|
9
|
+
import { defaultGit, SyncError } from './changes.js';
|
|
10
|
+
import { installHook, removeHook } from './hook.js';
|
|
11
|
+
import { runSync } from './run.js';
|
|
12
|
+
import { repoFromRemote } from './pr.js';
|
|
13
|
+
import { writeWorkflow, WORKFLOW_FILE } from './workflow.js';
|
|
14
|
+
const out = (line = '') => console.log(line);
|
|
15
|
+
/** `docugate sync ...`. Returns the exit code. */
|
|
16
|
+
export async function syncCommand(root, args, deps = {}) {
|
|
17
|
+
const quiet = args.bools.has('quiet');
|
|
18
|
+
try {
|
|
19
|
+
if (args.sub === 'init')
|
|
20
|
+
return syncInit(root, args);
|
|
21
|
+
if (args.sub === 'hook')
|
|
22
|
+
return syncHook(root, args);
|
|
23
|
+
if (args.sub)
|
|
24
|
+
throw new SyncError(`Unknown sync command "${args.sub}". Try: sync, sync init, sync hook install, sync hook remove.`);
|
|
25
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI && !args.bools.has('no-input');
|
|
26
|
+
const options = {
|
|
27
|
+
since: args.since,
|
|
28
|
+
dryRun: args.bools.has('dry-run'),
|
|
29
|
+
pr: args.bools.has('pr'),
|
|
30
|
+
yes: args.bools.has('yes'),
|
|
31
|
+
model: args.model,
|
|
32
|
+
maxFiles: args.maxFiles ? Number(args.maxFiles) : undefined,
|
|
33
|
+
};
|
|
34
|
+
if (args.maxFiles && !(Number.isInteger(options.maxFiles) && options.maxFiles > 0)) {
|
|
35
|
+
throw new SyncError('--max-files needs a whole number above zero.');
|
|
36
|
+
}
|
|
37
|
+
const result = await runSync(root, options, {
|
|
38
|
+
...deps,
|
|
39
|
+
log: quiet ? undefined : (line) => out(` ${dim(line)}`),
|
|
40
|
+
confirm: interactive && !options.yes ? ask : undefined,
|
|
41
|
+
});
|
|
42
|
+
return report(result, quiet);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// A hook must never be the reason a commit looks broken.
|
|
46
|
+
if (quiet)
|
|
47
|
+
return 0;
|
|
48
|
+
if (error instanceof SyncError) {
|
|
49
|
+
console.error(`${red('error')} ${error.message}`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function ask(question) {
|
|
56
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
57
|
+
try {
|
|
58
|
+
const answer = (await rl.question(` ${question} ${dim('[Y/n]')} `)).trim().toLowerCase();
|
|
59
|
+
return answer === '' || answer.startsWith('y');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rl.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function report(r, quiet) {
|
|
66
|
+
const affected = r.impact?.pages ?? [];
|
|
67
|
+
if (quiet) {
|
|
68
|
+
if (affected.length) {
|
|
69
|
+
out(`docugate: ${plural(affected.length, 'docs page')} may be out of date after this change. Run \`docugate sync\` to update ${affected.length === 1 ? 'it' : 'them'}.`);
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
out();
|
|
74
|
+
if (r.changes) {
|
|
75
|
+
const c = r.changes;
|
|
76
|
+
out(` ${bold('Docs sync')}${r.dryRun ? dim(' (dry run: nothing is sent or written)') : ''}`);
|
|
77
|
+
out(` ${plural(c.commits.length, 'commit')} and ${plural(c.files.length, 'code file')} changed ${c.range}`);
|
|
78
|
+
}
|
|
79
|
+
if (r.skipped) {
|
|
80
|
+
out(` ${yellow(r.skipped.replace(/^Skipped: /, ''))}`);
|
|
81
|
+
out();
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (r.dryRun) {
|
|
85
|
+
out();
|
|
86
|
+
if (affected.length) {
|
|
87
|
+
out(` ${bold('Pages likely affected')}`);
|
|
88
|
+
for (const p of affected)
|
|
89
|
+
out(` ${gold(p.path.padEnd(32))} ${dim(p.reasons.slice(0, 2).join('; '))}`);
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
out(` ${dim('No existing page names these changes.')}`);
|
|
93
|
+
if (r.impact?.uncovered.length) {
|
|
94
|
+
out();
|
|
95
|
+
out(` ${bold('Not covered by any page')}`);
|
|
96
|
+
for (const f of r.impact.uncovered.slice(0, 12))
|
|
97
|
+
out(` ${f}`);
|
|
98
|
+
if (r.impact.uncovered.length > 12)
|
|
99
|
+
out(` ${dim(`and ${r.impact.uncovered.length - 12} more`)}`);
|
|
100
|
+
}
|
|
101
|
+
staleTour(r);
|
|
102
|
+
out();
|
|
103
|
+
out(` ${dim('Next:')} docugate sync`);
|
|
104
|
+
out();
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (r.provider)
|
|
108
|
+
out(` Written with ${r.provider}`);
|
|
109
|
+
out();
|
|
110
|
+
if (r.written.length) {
|
|
111
|
+
out(` ${green('Updated')} ${plural(r.written.length, 'page')}`);
|
|
112
|
+
for (const e of r.written)
|
|
113
|
+
out(` ${e.action === 'create' ? green('new ') : ' '}${gold(e.path)}${e.reason ? dim(` ${e.reason}`) : ''}`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
out(` ${green('The docs already match the code.')} Nothing was changed.`);
|
|
117
|
+
if (r.summary)
|
|
118
|
+
out(` ${dim(r.summary)}`);
|
|
119
|
+
}
|
|
120
|
+
if (r.rejected.length) {
|
|
121
|
+
out();
|
|
122
|
+
out(` ${yellow('Left out')}`);
|
|
123
|
+
for (const x of r.rejected)
|
|
124
|
+
out(` ${x.path} ${dim(x.reason)}`);
|
|
125
|
+
}
|
|
126
|
+
staleTour(r);
|
|
127
|
+
if (r.pr) {
|
|
128
|
+
out();
|
|
129
|
+
out(r.pr.url ? ` ${green('Pull request')} ${r.pr.url}` : ` ${yellow(r.pr.note ?? `Pushed ${r.pr.branch}.`)}`);
|
|
130
|
+
}
|
|
131
|
+
else if (r.written.length) {
|
|
132
|
+
out();
|
|
133
|
+
out(` ${dim('Next:')} read the changes, then commit them. To get a pull request instead, run ${bold('docugate sync --pr')}.`);
|
|
134
|
+
}
|
|
135
|
+
out();
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
function staleTour(r) {
|
|
139
|
+
if (!r.staleTour.length)
|
|
140
|
+
return;
|
|
141
|
+
out();
|
|
142
|
+
out(` ${yellow('Tour stops that look out of date')} ${dim('(only reported; DocuGate never edits a tour)')}`);
|
|
143
|
+
for (const t of r.staleTour.slice(0, 8))
|
|
144
|
+
out(` ${t.file ? `${t.file} ` : ''}${t.message}`);
|
|
145
|
+
}
|
|
146
|
+
// ── sync init ───────────────────────────────────────────────────────────
|
|
147
|
+
function syncInit(root, args) {
|
|
148
|
+
let freshness = args.freshness;
|
|
149
|
+
if (freshness && !FRESHNESS_VALUES.includes(freshness)) {
|
|
150
|
+
throw new SyncError(`--freshness must be one of ${FRESHNESS_VALUES.join(', ')}.`);
|
|
151
|
+
}
|
|
152
|
+
const loaded = loadConfig(root);
|
|
153
|
+
if (loaded.errors.length)
|
|
154
|
+
throw new SyncError(`docugate.json has a problem: ${loaded.errors[0].message}`);
|
|
155
|
+
freshness ??= loaded.config.freshness ?? 'weekly';
|
|
156
|
+
// docugate.json is where "how often" lives, so the workflow and the file agree.
|
|
157
|
+
if (loaded.config.freshness !== freshness)
|
|
158
|
+
saveConfigKey(root, 'freshness', freshness);
|
|
159
|
+
const git = defaultGit;
|
|
160
|
+
let branch = 'main';
|
|
161
|
+
try {
|
|
162
|
+
branch = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root).trim().replace(/^origin\//, '');
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], root).trim() || 'main';
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* not a git repository: the workflow still gets written with main */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const result = writeWorkflow(root, { freshness, docsDir: loaded.docsDir, branch, version: packageVersion(), provider: loaded.config.sync?.provider }, args.bools.has('force'));
|
|
173
|
+
out();
|
|
174
|
+
if (result.status === 'off') {
|
|
175
|
+
out(` ${yellow('freshness is "off"')}, so no workflow was written. Pick how often docs should be checked:`);
|
|
176
|
+
out(` docugate sync init --freshness weekly ${dim('(or daily, or push)')}`);
|
|
177
|
+
out();
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
const when = freshness === 'push' ? `on every push to ${branch}` : freshness === 'daily' ? 'every day' : 'every Monday';
|
|
181
|
+
out(` ${result.status === 'kept' ? dim('kept') : green(result.status)} ${WORKFLOW_FILE} ${dim(`(runs ${when})`)}`);
|
|
182
|
+
if (result.status === 'kept' && !args.bools.has('force') && existsSync(join(root, WORKFLOW_FILE))) {
|
|
183
|
+
out(` ${dim('It already exists. Add --force to rewrite it from your current settings.')}`);
|
|
184
|
+
}
|
|
185
|
+
let repo = null;
|
|
186
|
+
try {
|
|
187
|
+
repo = repoFromRemote(git(['remote', 'get-url', 'origin'], root));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* no remote yet */
|
|
191
|
+
}
|
|
192
|
+
const key = loaded.config.sync?.provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
|
193
|
+
out();
|
|
194
|
+
out(` ${bold('Two things to do on GitHub, once:')}`);
|
|
195
|
+
out(` 1. Add your AI key as a repository secret named ${gold(key)}`);
|
|
196
|
+
out(` ${dim(repo ? `https://github.com/${repo}/settings/secrets/actions` : 'Settings > Secrets and variables > Actions')}`);
|
|
197
|
+
out(` 2. Allow Actions to open pull requests: Settings > Actions > General > Workflow permissions`);
|
|
198
|
+
out(` ${dim('and tick "Allow GitHub Actions to create and approve pull requests"')}`);
|
|
199
|
+
out();
|
|
200
|
+
out(` Then commit the workflow. ${dim('Next:')} docugate sync hook install ${dim('(optional: a heads-up after each commit)')}`);
|
|
201
|
+
out();
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
/** Set one top-level key in docugate.json, keeping everything else as it was. */
|
|
205
|
+
function saveConfigKey(root, key, value) {
|
|
206
|
+
const path = join(root, CONFIG_FILE);
|
|
207
|
+
let data = {};
|
|
208
|
+
if (existsSync(path)) {
|
|
209
|
+
try {
|
|
210
|
+
data = JSON.parse(readText(path));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new SyncError('docugate.json is not valid JSON, so it was not changed.');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
data[key] = value;
|
|
217
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
218
|
+
}
|
|
219
|
+
// ── sync hook ───────────────────────────────────────────────────────────
|
|
220
|
+
function syncHook(root, args) {
|
|
221
|
+
if (args.sub2 !== 'install' && args.sub2 !== 'remove') {
|
|
222
|
+
throw new SyncError('Use `sync hook install` or `sync hook remove`.');
|
|
223
|
+
}
|
|
224
|
+
out();
|
|
225
|
+
if (args.sub2 === 'install') {
|
|
226
|
+
const r = installHook(root);
|
|
227
|
+
out(` ${green(r.status)} ${r.path}`);
|
|
228
|
+
out(` ${dim('After each commit it tells you which docs pages may be out of date. It sends nothing and changes nothing.')}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const r = removeHook(root);
|
|
232
|
+
out(r.removed ? ` ${green('removed')} the DocuGate part of ${r.path}` : ` ${dim('There was no DocuGate hook to remove.')}`);
|
|
233
|
+
}
|
|
234
|
+
out();
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The commit message tag, and the branch prefix, that mark DocuGate's own work. */
|
|
2
|
+
export declare const SYNC_TAG = "[docugate-sync]";
|
|
3
|
+
export declare const SYNC_BRANCH_PREFIX = "docugate/sync";
|
|
4
|
+
/**
|
|
5
|
+
* Whether a run should stand down because it was caused by a sync itself.
|
|
6
|
+
* Without this a merged sync pull request would start the next sync, which
|
|
7
|
+
* would open another pull request, and so on.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldSkip(input: {
|
|
10
|
+
headMessage?: string;
|
|
11
|
+
branch?: string;
|
|
12
|
+
/** Every path the triggering change touched. */
|
|
13
|
+
changedPaths?: string[];
|
|
14
|
+
docsDir: string;
|
|
15
|
+
}): string | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The commit message tag, and the branch prefix, that mark DocuGate's own work. */
|
|
2
|
+
export const SYNC_TAG = '[docugate-sync]';
|
|
3
|
+
export const SYNC_BRANCH_PREFIX = 'docugate/sync';
|
|
4
|
+
/**
|
|
5
|
+
* Whether a run should stand down because it was caused by a sync itself.
|
|
6
|
+
* Without this a merged sync pull request would start the next sync, which
|
|
7
|
+
* would open another pull request, and so on.
|
|
8
|
+
*/
|
|
9
|
+
export function shouldSkip(input) {
|
|
10
|
+
if (input.headMessage?.includes(SYNC_TAG))
|
|
11
|
+
return `the latest commit is a DocuGate sync (${SYNC_TAG}).`;
|
|
12
|
+
if (input.branch?.replace(/^refs\/heads\//, '').startsWith(SYNC_BRANCH_PREFIX)) {
|
|
13
|
+
return `this is a DocuGate sync branch (${SYNC_BRANCH_PREFIX}*).`;
|
|
14
|
+
}
|
|
15
|
+
const docs = input.docsDir === '.' ? '' : input.docsDir.replace(/^\/|\/$/g, '');
|
|
16
|
+
const paths = input.changedPaths ?? [];
|
|
17
|
+
if (paths.length && paths.every((p) => (docs && p.startsWith(`${docs}/`)) || p.startsWith('.docugate/'))) {
|
|
18
|
+
return 'the change only touches documentation.';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GitFn } from './changes.js';
|
|
2
|
+
export declare function installHook(root: string, git?: GitFn): {
|
|
3
|
+
path: string;
|
|
4
|
+
status: 'installed' | 'updated';
|
|
5
|
+
};
|
|
6
|
+
export declare function removeHook(root: string, git?: GitFn): {
|
|
7
|
+
path: string;
|
|
8
|
+
removed: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function hookInstalled(root: string, git?: GitFn): boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { defaultGit } from './changes.js';
|
|
4
|
+
// A git post-commit hook that says, after each commit, how many pages the code
|
|
5
|
+
// has left behind. It runs `sync --dry-run`: no AI, no cost, no files changed,
|
|
6
|
+
// and it never fails a commit.
|
|
7
|
+
const START = '# >>> docugate sync (managed) >>>';
|
|
8
|
+
const END = '# <<< docugate sync (managed) <<<';
|
|
9
|
+
const BLOCK = `${START}
|
|
10
|
+
# Tells you which docs pages the latest changes may have left out of date.
|
|
11
|
+
# Remove with: docugate sync hook remove
|
|
12
|
+
if command -v docugate >/dev/null 2>&1; then
|
|
13
|
+
docugate sync --dry-run --quiet || true
|
|
14
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
15
|
+
npx --no-install docugate sync --dry-run --quiet 2>/dev/null || true
|
|
16
|
+
fi
|
|
17
|
+
${END}
|
|
18
|
+
`;
|
|
19
|
+
function hookPath(root, git) {
|
|
20
|
+
const rel = git(['rev-parse', '--git-path', 'hooks/post-commit'], root).trim();
|
|
21
|
+
return isAbsolute(rel) ? rel : join(root, rel);
|
|
22
|
+
}
|
|
23
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
const strip = (text) => text.replace(new RegExp(`\\n?${escape(START)}[\\s\\S]*?${escape(END)}\\n?`), '\n');
|
|
25
|
+
export function installHook(root, git = defaultGit) {
|
|
26
|
+
const path = hookPath(root, git);
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
writeFileSync(path, `#!/bin/sh\n${BLOCK}`);
|
|
29
|
+
chmodSync(path, 0o755);
|
|
30
|
+
return { path, status: 'installed' };
|
|
31
|
+
}
|
|
32
|
+
const existing = readFileSync(path, 'utf8');
|
|
33
|
+
const had = existing.includes(START);
|
|
34
|
+
const base = had ? strip(existing) : existing;
|
|
35
|
+
writeFileSync(path, `${base.replace(/\n*$/, '\n')}\n${BLOCK}`);
|
|
36
|
+
chmodSync(path, 0o755);
|
|
37
|
+
return { path, status: had ? 'updated' : 'installed' };
|
|
38
|
+
}
|
|
39
|
+
export function removeHook(root, git = defaultGit) {
|
|
40
|
+
const path = hookPath(root, git);
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return { path, removed: false };
|
|
43
|
+
const existing = readFileSync(path, 'utf8');
|
|
44
|
+
if (!existing.includes(START))
|
|
45
|
+
return { path, removed: false };
|
|
46
|
+
const rest = strip(existing).trim();
|
|
47
|
+
// What is left is nothing but the shebang: the hook was ours alone.
|
|
48
|
+
if (!rest || /^#!.*$/.test(rest))
|
|
49
|
+
rmSync(path, { force: true });
|
|
50
|
+
else
|
|
51
|
+
writeFileSync(path, `${rest}\n`);
|
|
52
|
+
return { path, removed: true };
|
|
53
|
+
}
|
|
54
|
+
export function hookInstalled(root, git = defaultGit) {
|
|
55
|
+
try {
|
|
56
|
+
const path = hookPath(root, git);
|
|
57
|
+
return existsSync(path) && readFileSync(path, 'utf8').includes(START);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChangedFile } from './changes.js';
|
|
2
|
+
export type PageInfo = {
|
|
3
|
+
/** Relative to the repository root, like `docs/api.md`. */
|
|
4
|
+
path: string;
|
|
5
|
+
title: string;
|
|
6
|
+
headings: string[];
|
|
7
|
+
content: string;
|
|
8
|
+
};
|
|
9
|
+
export type PageImpact = {
|
|
10
|
+
path: string;
|
|
11
|
+
title: string;
|
|
12
|
+
score: number;
|
|
13
|
+
reasons: string[];
|
|
14
|
+
};
|
|
15
|
+
export type Impact = {
|
|
16
|
+
pages: PageImpact[];
|
|
17
|
+
/** Changed files that no page mentions and no map entry covers. */
|
|
18
|
+
uncovered: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare function loadPages(root: string, docsDir: string): PageInfo[];
|
|
21
|
+
/** Everything the changes added or removed that a page might have named. */
|
|
22
|
+
export declare function symbolsIn(files: ChangedFile[]): string[];
|
|
23
|
+
export declare function scoreImpact(pages: PageInfo[], files: ChangedFile[], map?: Record<string, string[]>): Impact;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { walk } from '../check.js';
|
|
4
|
+
import { globToRegExp } from '../openapi.js';
|
|
5
|
+
import { firstHeading } from '../rules.js';
|
|
6
|
+
import { readText } from '../text.js';
|
|
7
|
+
export function loadPages(root, docsDir) {
|
|
8
|
+
const dir = docsDir === '.' ? '' : docsDir;
|
|
9
|
+
const base = dir ? join(root, dir) : root;
|
|
10
|
+
if (!existsSync(base))
|
|
11
|
+
return [];
|
|
12
|
+
return walk(base)
|
|
13
|
+
.filter((f) => /\.md$/i.test(f))
|
|
14
|
+
.map((f) => {
|
|
15
|
+
const path = dir ? `${dir}/${f}` : f;
|
|
16
|
+
const content = readText(join(base, f)).replace(/\r\n/g, '\n');
|
|
17
|
+
return {
|
|
18
|
+
path,
|
|
19
|
+
title: firstHeading(content) ?? f.replace(/\.md$/i, ''),
|
|
20
|
+
headings: [...content.matchAll(/^#{1,4}\s+(.+)$/gm)].map((m) => m[1].trim()),
|
|
21
|
+
content,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Names too common to mean anything when a page happens to contain them. */
|
|
26
|
+
const GENERIC = new Set(['index', 'main', 'app', 'utils', 'util', 'types', 'config', 'constants', 'helpers', 'test']);
|
|
27
|
+
/** What one file's change added or removed that a page might have named. */
|
|
28
|
+
function symbolsOf(file) {
|
|
29
|
+
const found = new Set();
|
|
30
|
+
for (const line of file.diff.split('\n')) {
|
|
31
|
+
if (!/^[+-]/.test(line) || /^(\+\+\+|---)/.test(line))
|
|
32
|
+
continue;
|
|
33
|
+
const decl = line.match(/^[+-]\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum|const|def|func|fn)\s+([A-Za-z_]\w{3,})/);
|
|
34
|
+
if (decl)
|
|
35
|
+
found.add(decl[1]);
|
|
36
|
+
for (const route of line.matchAll(/['"`](\/[A-Za-z0-9_\-/:{}.]{3,})['"`]/g))
|
|
37
|
+
found.add(route[1]);
|
|
38
|
+
for (const env of line.matchAll(/\b([A-Z][A-Z0-9]*_[A-Z0-9_]{2,})\b/g))
|
|
39
|
+
found.add(env[1]);
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
/** Everything the changes added or removed that a page might have named. */
|
|
44
|
+
export function symbolsIn(files) {
|
|
45
|
+
return [...new Set(files.flatMap((f) => [...symbolsOf(f)]))];
|
|
46
|
+
}
|
|
47
|
+
const stem = (path) => (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '');
|
|
48
|
+
export function scoreImpact(pages, files, map = {}) {
|
|
49
|
+
const symbolsByFile = files.map((f) => ({ path: f.path, symbols: symbolsOf(f) }));
|
|
50
|
+
const results = new Map();
|
|
51
|
+
const covered = new Set();
|
|
52
|
+
const hit = (page, points, reason, file) => {
|
|
53
|
+
const entry = results.get(page.path) ?? { path: page.path, title: page.title, score: 0, reasons: [] };
|
|
54
|
+
entry.score += points;
|
|
55
|
+
if (!entry.reasons.includes(reason))
|
|
56
|
+
entry.reasons.push(reason);
|
|
57
|
+
results.set(page.path, entry);
|
|
58
|
+
if (file)
|
|
59
|
+
covered.add(file);
|
|
60
|
+
};
|
|
61
|
+
const mapped = Object.entries(map).map(([page, globs]) => ({
|
|
62
|
+
page: page.replace(/^\.?\//, ''),
|
|
63
|
+
globs: globs.map(globToRegExp),
|
|
64
|
+
raw: globs,
|
|
65
|
+
}));
|
|
66
|
+
for (const page of pages) {
|
|
67
|
+
const text = page.content;
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const pin = mapped.find((m) => (m.page === page.path || page.path.endsWith(`/${m.page}`)) && m.globs.some((g) => g.test(file.path)));
|
|
70
|
+
if (pin)
|
|
71
|
+
hit(page, 10, `pinned to ${pin.raw.find((g) => globToRegExp(g).test(file.path))}`, file.path);
|
|
72
|
+
if (text.includes(file.path))
|
|
73
|
+
hit(page, 5, `names ${file.path}`, file.path);
|
|
74
|
+
else {
|
|
75
|
+
const name = stem(file.path);
|
|
76
|
+
const baseName = file.path.split('/').pop();
|
|
77
|
+
if (!GENERIC.has(name.toLowerCase()) && name.length >= 4 && text.includes(baseName)) {
|
|
78
|
+
hit(page, 2, `names ${baseName}`, file.path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const { path, symbols } of symbolsByFile) {
|
|
83
|
+
for (const symbol of symbols) {
|
|
84
|
+
if (text.includes(symbol))
|
|
85
|
+
hit(page, 3, `mentions ${symbol}`, path);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
pages: [...results.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)),
|
|
91
|
+
uncovered: files.map((f) => f.path).filter((p) => !covered.has(p)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Changes, GitFn } from './changes.js';
|
|
2
|
+
import type { DocEdit } from './ai.js';
|
|
3
|
+
import type { Rejected } from './apply.js';
|
|
4
|
+
export declare const SYNC_BRANCH = "docugate/sync";
|
|
5
|
+
export type PrInput = {
|
|
6
|
+
root: string;
|
|
7
|
+
title: string;
|
|
8
|
+
body: string;
|
|
9
|
+
/** Everything to commit: the pages and `.docugate/sync.json`. */
|
|
10
|
+
files: string[];
|
|
11
|
+
};
|
|
12
|
+
export type PrResult = {
|
|
13
|
+
branch: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
note?: string;
|
|
16
|
+
};
|
|
17
|
+
export type PrFn = (input: PrInput) => Promise<PrResult>;
|
|
18
|
+
export type PrDeps = {
|
|
19
|
+
git?: GitFn;
|
|
20
|
+
fetchFn?: typeof fetch;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
};
|
|
23
|
+
/** `owner/repo` from a GitHub remote URL, whether it is https or ssh. */
|
|
24
|
+
export declare function repoFromRemote(remote: string): string | null;
|
|
25
|
+
export declare function buildPrBody(input: {
|
|
26
|
+
summary: string;
|
|
27
|
+
written: DocEdit[];
|
|
28
|
+
rejected: Rejected[];
|
|
29
|
+
changes: Changes;
|
|
30
|
+
docsDir: string;
|
|
31
|
+
/** `owner/slug` of the DocuGate space, when the repository has one. */
|
|
32
|
+
space?: string;
|
|
33
|
+
siteUrl?: string;
|
|
34
|
+
staleTour?: string[];
|
|
35
|
+
}): string;
|
|
36
|
+
/** Commit the files on the sync branch, push it, and open or update the pull request. */
|
|
37
|
+
export declare function defaultPr(deps?: PrDeps): PrFn;
|
package/dist/sync/pr.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { defaultGit, SyncError } from './changes.js';
|
|
2
|
+
import { SYNC_BRANCH_PREFIX, SYNC_TAG } from './guard.js';
|
|
3
|
+
import { pathToSlug } from '../rules.js';
|
|
4
|
+
// Hands the docs over for review. One rolling branch and one pull request:
|
|
5
|
+
// each run rewrites the same branch, so a week of unmerged syncs is one pull
|
|
6
|
+
// request that grows, not a pile of them. Nothing is ever merged from here.
|
|
7
|
+
export const SYNC_BRANCH = SYNC_BRANCH_PREFIX;
|
|
8
|
+
/** `owner/repo` from a GitHub remote URL, whether it is https or ssh. */
|
|
9
|
+
export function repoFromRemote(remote) {
|
|
10
|
+
const m = remote.trim().match(/github\.com[:/]([^/]+\/[^/\s]+?)(?:\.git)?$/);
|
|
11
|
+
return m ? m[1] : null;
|
|
12
|
+
}
|
|
13
|
+
export function buildPrBody(input) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(`DocuGate read the code changes ${input.changes.range} (${input.changes.commits.length} commit${input.changes.commits.length === 1 ? '' : 's'}, ${input.changes.files.length} file${input.changes.files.length === 1 ? '' : 's'}) and updated the docs to match.`, '');
|
|
16
|
+
if (input.summary)
|
|
17
|
+
lines.push(input.summary, '');
|
|
18
|
+
lines.push('**This is generated. Read it before you merge it.**', '', '### Pages');
|
|
19
|
+
const docs = input.docsDir === '.' ? '' : `${input.docsDir.replace(/\/$/, '')}/`;
|
|
20
|
+
for (const e of input.written) {
|
|
21
|
+
const slug = pathToSlug(docs && e.path.startsWith(docs) ? e.path.slice(docs.length) : e.path);
|
|
22
|
+
const link = input.space && input.siteUrl ? ` ([on the site](${input.siteUrl.replace(/\/$/, '')}/${input.space}/${slug}))` : '';
|
|
23
|
+
lines.push(`- \`${e.path}\` ${e.action === 'create' ? '(new)' : ''}${e.reason ? `: ${e.reason}` : ''}${link}`.replace(' ', ' '));
|
|
24
|
+
}
|
|
25
|
+
if (input.rejected.length) {
|
|
26
|
+
lines.push('', '### Left out');
|
|
27
|
+
for (const r of input.rejected)
|
|
28
|
+
lines.push(`- \`${r.path}\` ${r.reason}`);
|
|
29
|
+
}
|
|
30
|
+
if (input.staleTour?.length) {
|
|
31
|
+
lines.push('', '### Tour stops that look out of date', 'DocuGate never edits a tour. These are for you to look at:');
|
|
32
|
+
for (const t of input.staleTour)
|
|
33
|
+
lines.push(`- ${t}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push('', `Commits: \`${input.changes.from ? input.changes.from.slice(0, 7) : 'start'}..${input.changes.to.slice(0, 7)}\``, '', `<sub>Made by \`docugate sync\`. Merging this moves the sync forward; closing it changes nothing.</sub>`);
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
/** Commit the files on the sync branch, push it, and open or update the pull request. */
|
|
39
|
+
export function defaultPr(deps = {}) {
|
|
40
|
+
const git = deps.git ?? defaultGit;
|
|
41
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
42
|
+
const env = deps.env ?? process.env;
|
|
43
|
+
return async ({ root, title, body, files }) => {
|
|
44
|
+
const run = (args) => git(args, root).trim();
|
|
45
|
+
const tryRun = (args) => {
|
|
46
|
+
try {
|
|
47
|
+
return run(args);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const headSha = run(['rev-parse', 'HEAD']);
|
|
54
|
+
const current = tryRun(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
55
|
+
const base = env.GITHUB_BASE_REF || (current && current !== 'HEAD' ? current : env.GITHUB_REF_NAME) || 'main';
|
|
56
|
+
const returnTo = current && current !== 'HEAD' ? current : headSha;
|
|
57
|
+
// CI has no git identity; a developer's own is left alone.
|
|
58
|
+
const identity = tryRun(['config', 'user.email'])
|
|
59
|
+
? []
|
|
60
|
+
: ['-c', 'user.name=docugate[bot]', '-c', 'user.email=docugate[bot]@users.noreply.github.com'];
|
|
61
|
+
run(['checkout', '-B', SYNC_BRANCH]);
|
|
62
|
+
let url;
|
|
63
|
+
let note;
|
|
64
|
+
try {
|
|
65
|
+
run(['add', '--', ...files]);
|
|
66
|
+
run([...identity, 'commit', '-m', `docs: sync with code changes ${SYNC_TAG}`]);
|
|
67
|
+
try {
|
|
68
|
+
run(['push', '--force', '-u', 'origin', SYNC_BRANCH]);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new SyncError(`Could not push the ${SYNC_BRANCH} branch: ${error.message.split('\n')[0]}`);
|
|
72
|
+
}
|
|
73
|
+
const repo = repoFromRemote(tryRun(['remote', 'get-url', 'origin']) ?? '');
|
|
74
|
+
const token = env.GITHUB_TOKEN || env.GH_TOKEN;
|
|
75
|
+
if (!repo)
|
|
76
|
+
note = `Pushed ${SYNC_BRANCH}. Open a pull request from it on GitHub.`;
|
|
77
|
+
else if (!token)
|
|
78
|
+
note = `Pushed ${SYNC_BRANCH}. Set GITHUB_TOKEN to open the pull request automatically, or open it from https://github.com/${repo}/pull/new/${SYNC_BRANCH}`;
|
|
79
|
+
else {
|
|
80
|
+
const api = (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/$/, '');
|
|
81
|
+
const headers = { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'content-type': 'application/json', 'user-agent': 'docugate' };
|
|
82
|
+
const owner = repo.split('/')[0];
|
|
83
|
+
const existing = await fetchFn(`${api}/repos/${repo}/pulls?head=${owner}:${SYNC_BRANCH}&state=open`, { headers });
|
|
84
|
+
const open = existing.ok ? (await existing.json()) : [];
|
|
85
|
+
if (open[0]) {
|
|
86
|
+
const res = await fetchFn(`${api}/repos/${repo}/pulls/${open[0].number}`, { method: 'PATCH', headers, body: JSON.stringify({ title, body }) });
|
|
87
|
+
url = res.ok ? (await res.json()).html_url : open[0].html_url;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const res = await fetchFn(`${api}/repos/${repo}/pulls`, { method: 'POST', headers, body: JSON.stringify({ title, body, head: SYNC_BRANCH, base }) });
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const detail = ((await res.text().catch(() => '')) || '').slice(0, 200);
|
|
93
|
+
note = `Pushed ${SYNC_BRANCH}, but GitHub would not open the pull request (${res.status}). ${res.status === 403 ? 'In the repository settings, allow Actions to create pull requests. ' : ''}${detail}`;
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
url = (await res.json()).html_url;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
// The developer's own branch is left as it was.
|
|
102
|
+
tryRun(['checkout', returnTo]);
|
|
103
|
+
}
|
|
104
|
+
return { branch: SYNC_BRANCH, url, note };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Issue } from '../config.js';
|
|
2
|
+
import type { AiFn } from './ai.js';
|
|
3
|
+
import type { Rejected } from './apply.js';
|
|
4
|
+
import type { Changes, GitFn } from './changes.js';
|
|
5
|
+
import type { Impact } from './impact.js';
|
|
6
|
+
import type { PrFn } from './pr.js';
|
|
7
|
+
import type { DocEdit } from './ai.js';
|
|
8
|
+
export type SyncOptions = {
|
|
9
|
+
/** `last` (the default), a duration like `7d`, or a git ref. */
|
|
10
|
+
since?: string;
|
|
11
|
+
/** Only report what would be updated: no AI, no writes. */
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
/** Push the result and open a pull request. */
|
|
14
|
+
pr?: boolean;
|
|
15
|
+
/** Do not ask before sending the changes to the AI provider. */
|
|
16
|
+
yes?: boolean;
|
|
17
|
+
maxFiles?: number;
|
|
18
|
+
model?: string;
|
|
19
|
+
/** The branch this is running on, when git cannot say (CI). */
|
|
20
|
+
branch?: string;
|
|
21
|
+
};
|
|
22
|
+
export type SyncDeps = {
|
|
23
|
+
git?: GitFn;
|
|
24
|
+
aiFn?: AiFn;
|
|
25
|
+
prFn?: PrFn;
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
now?: Date;
|
|
28
|
+
/** Asked before the changes leave the machine. Absent means do not ask. */
|
|
29
|
+
confirm?: (message: string) => Promise<boolean>;
|
|
30
|
+
log?: (line: string) => void;
|
|
31
|
+
};
|
|
32
|
+
export type SyncResult = {
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
/** Why nothing was done, when that is so. */
|
|
35
|
+
skipped?: string;
|
|
36
|
+
changes?: Changes;
|
|
37
|
+
impact?: Impact;
|
|
38
|
+
/** Tour stops that no longer match the code. Only ever reported. */
|
|
39
|
+
staleTour: Issue[];
|
|
40
|
+
summary?: string;
|
|
41
|
+
written: DocEdit[];
|
|
42
|
+
rejected: Rejected[];
|
|
43
|
+
provider?: string;
|
|
44
|
+
pr?: {
|
|
45
|
+
branch: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
note?: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export declare function runSync(root: string, options?: SyncOptions, deps?: SyncDeps): Promise<SyncResult>;
|