@agentrhq/webcmd 0.3.0 → 0.3.2

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.
Files changed (183) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +228 -24
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/producthunt/hot.js +1 -2
  26. package/clis/spotify/spotify.js +11 -11
  27. package/clis/twitter/article-evaluate.test.js +27 -0
  28. package/clis/twitter/article.js +73 -16
  29. package/clis/twitter/article.test.js +209 -0
  30. package/clis/twitter/download.js +6 -1
  31. package/clis/twitter/post.js +14 -1
  32. package/clis/twitter/profile.js +1 -1
  33. package/clis/twitter/profile.test.js +8 -0
  34. package/clis/twitter/quote.js +11 -1
  35. package/clis/twitter/reply.js +11 -1
  36. package/dist/src/browser/base-page.d.ts +26 -0
  37. package/dist/src/browser/base-page.js +23 -8
  38. package/dist/src/browser/base-page.test.js +45 -12
  39. package/dist/src/browser/command-catalog.d.ts +6 -0
  40. package/dist/src/browser/command-catalog.js +259 -0
  41. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  42. package/dist/src/browser/command-catalog.test.js +79 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  45. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  47. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  48. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  49. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  50. package/dist/src/browser/target-resolver.d.ts +1 -0
  51. package/dist/src/browser/target-resolver.js +99 -4
  52. package/dist/src/browser/target-resolver.test.js +141 -1
  53. package/dist/src/build-manifest.d.ts +6 -0
  54. package/dist/src/build-manifest.js +18 -1
  55. package/dist/src/build-manifest.test.js +57 -1
  56. package/dist/src/builtin-command-surface.d.ts +9 -0
  57. package/dist/src/builtin-command-surface.js +16 -0
  58. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  59. package/dist/src/check-hosted-contract.test.js +56 -0
  60. package/dist/src/cli.js +58 -106
  61. package/dist/src/cli.test.js +71 -1
  62. package/dist/src/command-presentation.d.ts +95 -0
  63. package/dist/src/command-presentation.js +486 -0
  64. package/dist/src/command-presentation.test.d.ts +1 -0
  65. package/dist/src/command-presentation.test.js +97 -0
  66. package/dist/src/command-surface.d.ts +43 -0
  67. package/dist/src/command-surface.js +205 -0
  68. package/dist/src/command-surface.test.d.ts +1 -0
  69. package/dist/src/command-surface.test.js +406 -0
  70. package/dist/src/commanderAdapter.d.ts +5 -1
  71. package/dist/src/commanderAdapter.js +12 -55
  72. package/dist/src/commanderAdapter.test.js +15 -3
  73. package/dist/src/community-plugin-sync.d.ts +11 -0
  74. package/dist/src/community-plugin-sync.js +138 -0
  75. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  76. package/dist/src/community-plugin-sync.test.js +123 -0
  77. package/dist/src/completion-fast.d.ts +4 -4
  78. package/dist/src/completion-fast.js +15 -34
  79. package/dist/src/completion-shared.d.ts +4 -0
  80. package/dist/src/completion-shared.js +38 -0
  81. package/dist/src/completion.js +3 -27
  82. package/dist/src/completion.test.js +31 -2
  83. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  84. package/dist/src/docs-sync-review-cli.test.js +322 -0
  85. package/dist/src/docs-sync-review.d.ts +117 -0
  86. package/dist/src/docs-sync-review.js +475 -0
  87. package/dist/src/docs-sync-review.test.d.ts +1 -0
  88. package/dist/src/docs-sync-review.test.js +324 -0
  89. package/dist/src/errors.d.ts +12 -3
  90. package/dist/src/errors.js +11 -7
  91. package/dist/src/errors.test.js +14 -1
  92. package/dist/src/execution.d.ts +1 -2
  93. package/dist/src/execution.js +4 -45
  94. package/dist/src/generate-release-notes-cli.test.js +7 -2
  95. package/dist/src/help.d.ts +4 -0
  96. package/dist/src/help.js +50 -255
  97. package/dist/src/help.test.js +27 -1
  98. package/dist/src/hosted/args.d.ts +2 -7
  99. package/dist/src/hosted/args.js +2 -99
  100. package/dist/src/hosted/args.test.js +15 -1
  101. package/dist/src/hosted/availability.d.ts +13 -0
  102. package/dist/src/hosted/availability.js +16 -0
  103. package/dist/src/hosted/availability.test.d.ts +1 -0
  104. package/dist/src/hosted/availability.test.js +191 -0
  105. package/dist/src/hosted/browser-args.d.ts +18 -0
  106. package/dist/src/hosted/browser-args.js +152 -0
  107. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  108. package/dist/src/hosted/browser-args.test.js +182 -0
  109. package/dist/src/hosted/client.d.ts +31 -2
  110. package/dist/src/hosted/client.js +441 -53
  111. package/dist/src/hosted/client.test.js +651 -32
  112. package/dist/src/hosted/config.d.ts +7 -2
  113. package/dist/src/hosted/config.js +26 -4
  114. package/dist/src/hosted/config.test.js +34 -1
  115. package/dist/src/hosted/contract.d.ts +94 -0
  116. package/dist/src/hosted/contract.js +208 -0
  117. package/dist/src/hosted/contract.test.d.ts +1 -0
  118. package/dist/src/hosted/contract.test.js +361 -0
  119. package/dist/src/hosted/credentials.d.ts +38 -0
  120. package/dist/src/hosted/credentials.js +248 -0
  121. package/dist/src/hosted/credentials.test.d.ts +1 -0
  122. package/dist/src/hosted/credentials.test.js +93 -0
  123. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  124. package/dist/src/hosted/file-contract.test.js +174 -0
  125. package/dist/src/hosted/files.d.ts +29 -0
  126. package/dist/src/hosted/files.js +296 -0
  127. package/dist/src/hosted/files.test.d.ts +1 -0
  128. package/dist/src/hosted/files.test.js +231 -0
  129. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  130. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  131. package/dist/src/hosted/manifest.d.ts +5 -0
  132. package/dist/src/hosted/manifest.js +21 -66
  133. package/dist/src/hosted/manifest.test.js +120 -2
  134. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  135. package/dist/src/hosted/output-parity.test.js +108 -0
  136. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  137. package/dist/src/hosted/root-command-surface.test.js +646 -0
  138. package/dist/src/hosted/runner.d.ts +7 -0
  139. package/dist/src/hosted/runner.js +554 -253
  140. package/dist/src/hosted/runner.test.js +1471 -53
  141. package/dist/src/hosted/setup.d.ts +3 -2
  142. package/dist/src/hosted/setup.js +45 -15
  143. package/dist/src/hosted/setup.test.js +133 -6
  144. package/dist/src/hosted/types.d.ts +101 -23
  145. package/dist/src/main.js +120 -108
  146. package/dist/src/manifest-types.d.ts +2 -0
  147. package/dist/src/output.d.ts +17 -2
  148. package/dist/src/output.js +88 -81
  149. package/dist/src/output.test.js +141 -51
  150. package/dist/src/pipeline/executor.test.js +1 -0
  151. package/dist/src/pipeline/steps/download.test.js +1 -0
  152. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  153. package/dist/src/plugin-create-cli.test.js +37 -0
  154. package/dist/src/plugin-manifest.d.ts +12 -0
  155. package/dist/src/plugin-manifest.js +16 -0
  156. package/dist/src/plugin-manifest.test.js +19 -1
  157. package/dist/src/plugin-scaffold.d.ts +4 -1
  158. package/dist/src/plugin-scaffold.js +4 -1
  159. package/dist/src/plugin-scaffold.test.js +23 -8
  160. package/dist/src/plugin.js +4 -1
  161. package/dist/src/plugin.test.js +13 -0
  162. package/dist/src/registry.d.ts +2 -0
  163. package/dist/src/release-notes.js +12 -4
  164. package/dist/src/release-notes.test.js +27 -15
  165. package/dist/src/root-command-surface.d.ts +31 -0
  166. package/dist/src/root-command-surface.js +106 -0
  167. package/dist/src/serialization.d.ts +1 -16
  168. package/dist/src/serialization.js +5 -55
  169. package/dist/src/stream-write.d.ts +12 -0
  170. package/dist/src/stream-write.js +91 -0
  171. package/dist/src/stream-write.test.d.ts +1 -0
  172. package/dist/src/stream-write.test.js +186 -0
  173. package/dist/src/types.d.ts +5 -0
  174. package/dist/src/utils.d.ts +1 -1
  175. package/dist/src/utils.js +2 -8
  176. package/dist/src/utils.test.js +50 -0
  177. package/hosted-contract.json +36759 -0
  178. package/package.json +6 -1
  179. package/scripts/check-hosted-contract.mjs +108 -0
  180. package/scripts/docs-sync-review.ts +332 -0
  181. package/scripts/sync-community-plugins.ts +14 -0
  182. package/skills/webcmd-autofix/SKILL.md +26 -37
  183. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -1,13 +1,32 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
5
  const { mockGetRegistry } = vi.hoisted(() => ({
3
6
  mockGetRegistry: vi.fn(() => new Map([
4
- ['github/issues', { site: 'github', name: 'issues' }],
7
+ ['github/issues', {
8
+ site: 'github',
9
+ name: 'issues',
10
+ aliases: ['issue-list'],
11
+ description: 'List issues',
12
+ access: 'read',
13
+ strategy: 'public',
14
+ browser: false,
15
+ args: [],
16
+ columns: [],
17
+ }],
5
18
  ])),
6
19
  }));
7
20
  vi.mock('./registry.js', () => ({
8
21
  getRegistry: mockGetRegistry,
9
22
  }));
10
23
  import { getCompletions } from './completion.js';
24
+ import { getCompletionsFromManifest } from './completion-fast.js';
25
+ const tempDirs = [];
26
+ afterEach(() => {
27
+ for (const dir of tempDirs.splice(0))
28
+ fs.rmSync(dir, { recursive: true, force: true });
29
+ });
11
30
  describe('getCompletions', () => {
12
31
  it('includes top-level built-ins that are registered outside the site registry', () => {
13
32
  const completions = getCompletions([], 1);
@@ -21,4 +40,14 @@ describe('getCompletions', () => {
21
40
  const completions = getCompletions([], 1);
22
41
  expect(completions).toContain('github');
23
42
  });
43
+ it('returns byte-identical candidates from registry and manifest metadata', () => {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-completion-parity-'));
45
+ tempDirs.push(dir);
46
+ const manifestPath = path.join(dir, 'cli-manifest.json');
47
+ fs.writeFileSync(manifestPath, JSON.stringify([
48
+ { site: 'github', name: 'issues', aliases: ['issue-list'] },
49
+ ]));
50
+ expect(getCompletionsFromManifest([], 1, [manifestPath])).toEqual(getCompletions([], 1));
51
+ expect(getCompletionsFromManifest(['github'], 2, [manifestPath])).toEqual(getCompletions(['github'], 2));
52
+ });
24
53
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,322 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { generateGeminiReview, loadDocumentation, loadPullRequestContext, runDocsSyncReview, upsertReviewComment, } from '../scripts/docs-sync-review.js';
6
+ import { MAX_DIFF_CHARACTERS, REVIEW_COMMENT_MARKER, REVIEW_JSON_SCHEMA, } from './docs-sync-review.js';
7
+ function context(overrides = {}) {
8
+ return {
9
+ number: 72,
10
+ title: 'Add profile option',
11
+ body: null,
12
+ draft: false,
13
+ headSha: 'abc123',
14
+ labels: [],
15
+ files: [{
16
+ path: 'src/cli.ts',
17
+ status: 'modified',
18
+ patch: '+ .option("--profile <name>")',
19
+ }],
20
+ ...overrides,
21
+ };
22
+ }
23
+ function createIo() {
24
+ let stdout = '';
25
+ let stderr = '';
26
+ return {
27
+ io: {
28
+ writeStdout(chunk) { stdout += chunk; },
29
+ writeStderr(chunk) { stderr += chunk; },
30
+ },
31
+ read: () => ({ stdout, stderr }),
32
+ };
33
+ }
34
+ function baseDependencies(pr = context()) {
35
+ return {
36
+ loadContext: vi.fn(async () => pr),
37
+ loadDocumentation: vi.fn(() => [{ path: 'docs/cli-reference.mdx', content: 'CLI reference' }]),
38
+ generateReview: vi.fn(async (_prompt, _model, _apiKey) => ({
39
+ verdict: 'likely_missing',
40
+ summary: 'The profile option is undocumented.',
41
+ findings: [{
42
+ surface: 'docs',
43
+ behaviorChange: 'The CLI accepts a profile name.',
44
+ changedPath: 'src/cli.ts',
45
+ evidence: '.option("--profile <name>")',
46
+ suggestedPath: 'docs/cli-reference.mdx',
47
+ reason: 'The supplied reference omits the option.',
48
+ }],
49
+ })),
50
+ upsertComment: vi.fn(async (_repository, _number, _token, _body) => undefined),
51
+ writeSummary: vi.fn(),
52
+ };
53
+ }
54
+ const ENV = {
55
+ GITHUB_REPOSITORY: 'acme/webcmd',
56
+ GH_TOKEN: 'github-token',
57
+ GEMINI_API_KEY: 'gemini-key',
58
+ GITHUB_STEP_SUMMARY: '/tmp/summary.md',
59
+ };
60
+ describe('runDocsSyncReview', () => {
61
+ it('returns usage error when the pull request number is missing', async () => {
62
+ const { io, read } = createIo();
63
+ const exitCode = await runDocsSyncReview(['node', 'script'], {}, {}, io);
64
+ expect(exitCode).toBe(1);
65
+ expect(read().stderr).toContain('Usage: docs-sync-review <pull-request-number>');
66
+ });
67
+ it('defers draft pull requests without loading docs or calling Gemini', async () => {
68
+ const deps = baseDependencies(context({ draft: true }));
69
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
70
+ expect(exitCode).toBe(0);
71
+ expect(deps.loadDocumentation).not.toHaveBeenCalled();
72
+ expect(deps.generateReview).not.toHaveBeenCalled();
73
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('deferred until this draft'));
74
+ });
75
+ it('honors docs-not-needed before calling Gemini', async () => {
76
+ const deps = baseDependencies(context({ labels: ['docs-not-needed'] }));
77
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
78
+ expect(exitCode).toBe(0);
79
+ expect(deps.generateReview).not.toHaveBeenCalled();
80
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('maintainer applied the docs-not-needed override'));
81
+ });
82
+ it('posts deterministic green without Gemini for resolved changes', async () => {
83
+ const deps = baseDependencies(context({
84
+ files: [{ path: 'src/cli.test.ts', status: 'modified', patch: '+test' }],
85
+ }));
86
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
87
+ expect(exitCode).toBe(0);
88
+ expect(deps.generateReview).not.toHaveBeenCalled();
89
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('🟢 No documentation gap found'));
90
+ });
91
+ it('posts unavailable orange without a Gemini key', async () => {
92
+ const deps = baseDependencies();
93
+ const { GEMINI_API_KEY: _key, ...env } = ENV;
94
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], env, deps);
95
+ expect(exitCode).toBe(0);
96
+ expect(deps.generateReview).not.toHaveBeenCalled();
97
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('Automated semantic review could not be completed'));
98
+ });
99
+ it('posts a validated Gemini review', async () => {
100
+ const deps = baseDependencies();
101
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
102
+ expect(exitCode).toBe(0);
103
+ expect(deps.generateReview).toHaveBeenCalledOnce();
104
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('🔴 Documentation update likely missing'));
105
+ });
106
+ it('turns provider errors into a neutral non-blocking orange comment', async () => {
107
+ const deps = baseDependencies();
108
+ deps.generateReview.mockRejectedValueOnce(new Error('Gemini quota exceeded'));
109
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
110
+ expect(exitCode).toBe(0);
111
+ const body = deps.upsertComment.mock.calls[0][3];
112
+ expect(body).toContain('Automated semantic review could not be completed.');
113
+ expect(body).not.toMatch(/gemini/i);
114
+ });
115
+ it('reviews every bounded chunk of a large pull request', async () => {
116
+ const deps = baseDependencies(context({
117
+ files: [
118
+ { path: 'src/cli.ts', status: 'modified', patch: `+${'x'.repeat(MAX_DIFF_CHARACTERS)}` },
119
+ { path: 'src/types.ts', status: 'modified', patch: '+export interface LaterChange {}' },
120
+ ],
121
+ }));
122
+ deps.generateReview.mockResolvedValue({
123
+ verdict: 'no_update_needed',
124
+ summary: 'Covered.',
125
+ findings: [],
126
+ });
127
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps);
128
+ expect(exitCode).toBe(0);
129
+ expect(deps.generateReview.mock.calls.length).toBeGreaterThan(1);
130
+ expect(deps.generateReview.mock.calls.map((call) => call[0]).join('\n')).toContain('src/types.ts');
131
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('🟢 No documentation gap found'));
132
+ });
133
+ it('writes the rendered review to the job summary when commenting fails', async () => {
134
+ const deps = baseDependencies();
135
+ deps.upsertComment.mockRejectedValueOnce(new Error('comments disabled'));
136
+ const { io, read } = createIo();
137
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps, io);
138
+ expect(exitCode).toBe(0);
139
+ expect(deps.writeSummary).toHaveBeenCalledWith(expect.stringContaining('Documentation update likely missing'), '/tmp/summary.md');
140
+ expect(read().stderr).toContain('Unable to update the pull request comment: comments disabled');
141
+ });
142
+ it('posts an unavailable advisory when pull request loading fails', async () => {
143
+ const deps = baseDependencies();
144
+ deps.loadContext.mockRejectedValueOnce(new Error('GitHub temporarily unavailable'));
145
+ const { io, read } = createIo();
146
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps, io);
147
+ expect(exitCode).toBe(0);
148
+ expect(deps.upsertComment).toHaveBeenCalledWith('acme/webcmd', 72, 'github-token', expect.stringContaining('Automated semantic review could not be completed'));
149
+ expect(read().stderr).toContain('GitHub temporarily unavailable');
150
+ });
151
+ it('keeps the review non-blocking when both comment and summary writes fail', async () => {
152
+ const deps = baseDependencies();
153
+ deps.upsertComment.mockRejectedValueOnce(new Error('comments disabled'));
154
+ deps.writeSummary.mockImplementationOnce(() => { throw new Error('summary unavailable'); });
155
+ const { io, read } = createIo();
156
+ const exitCode = await runDocsSyncReview(['node', 'script', '72'], ENV, deps, io);
157
+ expect(exitCode).toBe(0);
158
+ expect(read().stderr).toContain('Unable to write the workflow summary: summary unavailable');
159
+ });
160
+ });
161
+ function jsonResponse(value, status = 200) {
162
+ return new Response(JSON.stringify(value), {
163
+ status,
164
+ headers: { 'content-type': 'application/json' },
165
+ });
166
+ }
167
+ describe('GitHub API boundaries', () => {
168
+ it('loads pull request metadata and paginates changed files', async () => {
169
+ const firstPage = Array.from({ length: 100 }, (_, index) => ({
170
+ filename: `src/file-${index}.ts`,
171
+ status: 'modified',
172
+ patch: `+change ${index}`,
173
+ }));
174
+ const fetchImpl = vi.fn()
175
+ .mockResolvedValueOnce(jsonResponse({
176
+ number: 72,
177
+ title: 'Feature',
178
+ body: 'Description',
179
+ draft: false,
180
+ head: { sha: 'abc123' },
181
+ labels: [{ name: 'enhancement' }],
182
+ }))
183
+ .mockResolvedValueOnce(jsonResponse(firstPage))
184
+ .mockResolvedValueOnce(jsonResponse([{ filename: 'src/final.ts', status: 'added' }]));
185
+ const result = await loadPullRequestContext('acme/webcmd', 72, 'secret-token', fetchImpl);
186
+ expect(result).toMatchObject({
187
+ number: 72,
188
+ title: 'Feature',
189
+ body: 'Description',
190
+ draft: false,
191
+ headSha: 'abc123',
192
+ labels: ['enhancement'],
193
+ });
194
+ expect(result.files).toHaveLength(101);
195
+ expect(result.files.at(-1)).toEqual({ path: 'src/final.ts', status: 'added', patch: undefined });
196
+ expect(fetchImpl.mock.calls[2][0]).toContain('page=2');
197
+ expect(fetchImpl.mock.calls[0][1]).toMatchObject({
198
+ headers: expect.objectContaining({ Authorization: 'Bearer secret-token' }),
199
+ });
200
+ });
201
+ it('updates an existing bot-owned sticky comment', async () => {
202
+ const fetchImpl = vi.fn()
203
+ .mockResolvedValueOnce(jsonResponse([{
204
+ id: 123,
205
+ body: `${REVIEW_COMMENT_MARKER}\nOld`,
206
+ user: { login: 'github-actions[bot]' },
207
+ }]))
208
+ .mockResolvedValueOnce(jsonResponse({ id: 123 }));
209
+ await upsertReviewComment('acme/webcmd', 72, 'token', 'New body', fetchImpl);
210
+ expect(fetchImpl.mock.calls[1][0]).toContain('/issues/comments/123');
211
+ expect(fetchImpl.mock.calls[1][1]).toMatchObject({ method: 'PATCH', body: JSON.stringify({ body: 'New body' }) });
212
+ });
213
+ it('ignores a contributor marker and creates a bot comment', async () => {
214
+ const fetchImpl = vi.fn()
215
+ .mockResolvedValueOnce(jsonResponse([{
216
+ id: 456,
217
+ body: `${REVIEW_COMMENT_MARKER}\nFake`,
218
+ user: { login: 'contributor' },
219
+ }]))
220
+ .mockResolvedValueOnce(jsonResponse({ id: 789 }, 201));
221
+ await upsertReviewComment('acme/webcmd', 72, 'token', 'New body', fetchImpl);
222
+ expect(fetchImpl.mock.calls[1][0]).toContain('/issues/72/comments');
223
+ expect(fetchImpl.mock.calls[1][1]).toMatchObject({ method: 'POST' });
224
+ });
225
+ it('finds an older sticky comment by paginating issue comments', async () => {
226
+ const firstPage = Array.from({ length: 100 }, (_, index) => ({
227
+ id: index + 1,
228
+ body: 'Unrelated',
229
+ user: { login: 'someone' },
230
+ }));
231
+ const fetchImpl = vi.fn()
232
+ .mockResolvedValueOnce(jsonResponse(firstPage))
233
+ .mockResolvedValueOnce(jsonResponse([{
234
+ id: 321,
235
+ body: `${REVIEW_COMMENT_MARKER}\nOld`,
236
+ user: { login: 'github-actions[bot]' },
237
+ }]))
238
+ .mockResolvedValueOnce(jsonResponse({ id: 321 }));
239
+ await upsertReviewComment('acme/webcmd', 72, 'token', 'New body', fetchImpl);
240
+ expect(fetchImpl.mock.calls[1][0]).toContain('page=2');
241
+ expect(fetchImpl.mock.calls[2][0]).toContain('/issues/comments/321');
242
+ expect(fetchImpl.mock.calls[2][1]).toMatchObject({ method: 'PATCH' });
243
+ });
244
+ it('reports bounded GitHub errors without leaking the token', async () => {
245
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ message: 'x'.repeat(5_000) }, 500));
246
+ const error = await loadPullRequestContext('acme/webcmd', 72, 'top-secret', fetchImpl).catch((value) => value);
247
+ expect(error).toBeInstanceOf(Error);
248
+ expect(error.message).toContain('GitHub API 500');
249
+ expect(error.message).not.toContain('top-secret');
250
+ expect(error.message.length).toBeLessThan(800);
251
+ });
252
+ });
253
+ describe('Gemini and documentation boundaries', () => {
254
+ it('requests structured Gemini JSON with the selected model', async () => {
255
+ const generateContent = vi.fn(async () => ({
256
+ text: JSON.stringify({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] }),
257
+ }));
258
+ const createClient = vi.fn(() => ({ models: { generateContent } }));
259
+ const result = await generateGeminiReview('review prompt', 'gemini-test', 'api-key', createClient);
260
+ expect(createClient).toHaveBeenCalledWith('api-key');
261
+ expect(generateContent).toHaveBeenCalledWith({
262
+ model: 'gemini-test',
263
+ contents: 'review prompt',
264
+ config: expect.objectContaining({
265
+ responseMimeType: 'application/json',
266
+ responseJsonSchema: REVIEW_JSON_SCHEMA,
267
+ temperature: 0.1,
268
+ }),
269
+ });
270
+ expect(result).toEqual({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] });
271
+ });
272
+ it.each([
273
+ ['empty', ''],
274
+ ['invalid JSON', '{not json'],
275
+ ])('rejects %s Gemini output', async (_name, text) => {
276
+ const createClient = () => ({ models: { generateContent: async () => ({ text }) } });
277
+ await expect(generateGeminiReview('prompt', 'model', 'key', createClient)).rejects.toThrow();
278
+ });
279
+ it('reads only regular documentation files inside the repository root', () => {
280
+ const root = mkdtempSync(join(tmpdir(), 'docs-sync-review-'));
281
+ const outside = mkdtempSync(join(tmpdir(), 'docs-sync-outside-'));
282
+ try {
283
+ mkdirSync(join(root, 'docs'));
284
+ writeFileSync(join(root, 'README.md'), 'readme');
285
+ writeFileSync(join(root, 'docs', 'guide.mdx'), 'guide');
286
+ writeFileSync(join(outside, 'secret.txt'), 'secret');
287
+ symlinkSync(join(outside, 'secret.txt'), join(root, 'docs', 'linked.mdx'));
288
+ expect(loadDocumentation([
289
+ 'README.md',
290
+ 'docs/guide.mdx',
291
+ '../secret.txt',
292
+ 'docs/linked.mdx',
293
+ '/tmp/absolute.md',
294
+ ], root)).toEqual([
295
+ { path: 'README.md', content: 'readme' },
296
+ { path: 'docs/guide.mdx', content: 'guide' },
297
+ ]);
298
+ }
299
+ finally {
300
+ rmSync(root, { recursive: true, force: true });
301
+ rmSync(outside, { recursive: true, force: true });
302
+ }
303
+ });
304
+ });
305
+ describe('documentation sync workflow', () => {
306
+ it('uses trusted base-branch code and least-privilege credentials', () => {
307
+ const workflow = readFileSync('.github/workflows/docs-sync-review.yml', 'utf8');
308
+ expect(workflow).toContain('pull_request_target:');
309
+ for (const event of ['opened', 'reopened', 'synchronize', 'ready_for_review', 'labeled', 'unlabeled']) {
310
+ expect(workflow).toContain(event);
311
+ }
312
+ expect(workflow).toContain('contents: read');
313
+ expect(workflow).toContain('pull-requests: read');
314
+ expect(workflow).toContain('issues: write');
315
+ expect(workflow).toContain('ref: ${{ github.event.repository.default_branch }}');
316
+ expect(workflow).toContain('GH_TOKEN: ${{ github.token }}');
317
+ expect(workflow).toContain('GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}');
318
+ expect(workflow).toContain('GEMINI_DOCS_REVIEW_MODEL: ${{ vars.GEMINI_DOCS_REVIEW_MODEL }}');
319
+ expect(workflow).toContain('npm --silent run docs-sync-review -- "${{ github.event.pull_request.number }}"');
320
+ expect(workflow).not.toMatch(/pull_request\.head|head\.sha|github\.head_ref/);
321
+ });
322
+ });
@@ -0,0 +1,117 @@
1
+ export type ReviewVerdict = 'no_update_needed' | 'review_suggested' | 'likely_missing';
2
+ export type ReviewConfidence = 'high' | 'medium' | 'low';
3
+ export type ReviewSignal = 'none' | 'public' | 'ambiguous';
4
+ export interface ChangedFile {
5
+ path: string;
6
+ status: 'added' | 'modified' | 'removed' | 'renamed' | string;
7
+ patch?: string;
8
+ }
9
+ export interface PullRequestReviewContext {
10
+ number: number;
11
+ title: string;
12
+ body: string | null;
13
+ draft: boolean;
14
+ headSha: string;
15
+ labels: string[];
16
+ files: ChangedFile[];
17
+ }
18
+ export interface RoutingDecision {
19
+ route: 'resolved' | 'gemini';
20
+ signal: ReviewSignal;
21
+ verdict?: ReviewVerdict;
22
+ confidence?: ReviewConfidence;
23
+ reason: string;
24
+ }
25
+ export interface DocumentationExcerpt {
26
+ path: string;
27
+ content: string;
28
+ }
29
+ export interface PromptResult {
30
+ prompt: string;
31
+ diffText: string;
32
+ truncated: boolean;
33
+ }
34
+ export interface ReviewFinding {
35
+ surface: 'readme' | 'docs' | 'skill';
36
+ behaviorChange: string;
37
+ changedPath: string;
38
+ evidence: string;
39
+ suggestedPath: string;
40
+ reason: string;
41
+ }
42
+ export interface GeminiReview {
43
+ verdict: ReviewVerdict;
44
+ summary: string;
45
+ findings: ReviewFinding[];
46
+ }
47
+ export interface ReviewResult {
48
+ verdict: ReviewVerdict;
49
+ confidence: ReviewConfidence;
50
+ summary: string;
51
+ findings: ReviewFinding[];
52
+ source: 'deterministic' | 'semantic' | 'unavailable' | 'override' | 'deferred';
53
+ limitations: string[];
54
+ }
55
+ export declare const MAX_PATCH_CHARACTERS = 8000;
56
+ export declare const MAX_DIFF_CHARACTERS = 60000;
57
+ export declare const REVIEW_COMMENT_MARKER = "<!-- webcmd-docs-sync-review -->";
58
+ export declare const REVIEW_JSON_SCHEMA: {
59
+ readonly type: "object";
60
+ readonly additionalProperties: false;
61
+ readonly required: readonly ["verdict", "summary", "findings"];
62
+ readonly properties: {
63
+ readonly verdict: {
64
+ readonly type: "string";
65
+ readonly enum: readonly ["no_update_needed", "review_suggested", "likely_missing"];
66
+ };
67
+ readonly summary: {
68
+ readonly type: "string";
69
+ readonly description: "A concise explanation grounded only in the supplied pull request and documentation context.";
70
+ };
71
+ readonly findings: {
72
+ readonly type: "array";
73
+ readonly maxItems: 5;
74
+ readonly items: {
75
+ readonly type: "object";
76
+ readonly additionalProperties: false;
77
+ readonly required: readonly ["surface", "behaviorChange", "changedPath", "evidence", "suggestedPath", "reason"];
78
+ readonly properties: {
79
+ readonly surface: {
80
+ readonly type: "string";
81
+ readonly enum: readonly ["readme", "docs", "skill"];
82
+ };
83
+ readonly behaviorChange: {
84
+ readonly type: "string";
85
+ readonly description: "The user-visible or agent-visible behavior that changed.";
86
+ };
87
+ readonly changedPath: {
88
+ readonly type: "string";
89
+ readonly description: "An exact path from the supplied changed-file list.";
90
+ };
91
+ readonly evidence: {
92
+ readonly type: "string";
93
+ readonly description: "An exact short excerpt copied from the supplied patch.";
94
+ };
95
+ readonly suggestedPath: {
96
+ readonly type: "string";
97
+ readonly description: "A repository-relative README.md, docs/, or skills/ file path.";
98
+ };
99
+ readonly reason: {
100
+ readonly type: "string";
101
+ readonly description: "Why the supplied documentation does not cover the behavior change.";
102
+ };
103
+ };
104
+ };
105
+ };
106
+ };
107
+ };
108
+ export declare function classifyPullRequest(files: ChangedFile[]): RoutingDecision;
109
+ export declare function selectDocumentationPaths(files: ChangedFile[]): string[];
110
+ export declare function buildReviewPrompts(context: PullRequestReviewContext, documentation: DocumentationExcerpt[]): PromptResult[];
111
+ export declare function validateGeminiReview(raw: unknown, context: PullRequestReviewContext, routing: RoutingDecision, prompt: Pick<PromptResult, 'diffText' | 'truncated'>): ReviewResult;
112
+ export declare function mergeReviewResults(results: ReviewResult[]): ReviewResult;
113
+ export declare function createResolvedResult(routing: RoutingDecision): ReviewResult;
114
+ export declare function createUnavailableResult(_reason?: string): ReviewResult;
115
+ export declare function createOverrideResult(): ReviewResult;
116
+ export declare function createDeferredResult(): ReviewResult;
117
+ export declare function renderReviewComment(result: ReviewResult): string;