@juppytt/fws 0.1.3 → 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.
@@ -71,7 +71,19 @@
71
71
  "Bash(sed:*)",
72
72
  "Bash(gh issue:*)",
73
73
  "Bash(gh pr:*)",
74
- "Bash(npm version:*)"
74
+ "Bash(npm version:*)",
75
+ "Bash(fws:*)",
76
+ "Bash(gh --help)",
77
+ "Bash(gh environment:*)",
78
+ "Bash(gh api:*)",
79
+ "Bash(GH_TOKEN=fake GH_HOST=localhost:4200 gh api /user)",
80
+ "Bash(gh config:*)",
81
+ "Bash(GH_TOKEN=fake GH_HOST=localhost:4200 HTTPS_PROXY=http://localhost:4101 SSL_CERT_FILE=/home/juhee/.local/share/fws/certs/ca.crt gh api /user)",
82
+ "Bash(export GH_TOKEN=fake)",
83
+ "Bash(curl -s http://localhost:4100/user)",
84
+ "Bash(curl -s http://localhost:4100/repos/testuser/my-project/issues)",
85
+ "Bash(export GH_REPO=testuser/my-project)",
86
+ "Bash(curl -s -X POST http://localhost:4100/graphql -H 'Content-Type: application/json' -d '{\"query\":\"query { repository\\(owner:\\\\\"testuser\\\\\",name:\\\\\"my-project\\\\\"\\) { issue: issueOrPullRequest\\(number:1\\) { ...on Issue { number title state body } } } }\",\"variables\":{}}')"
75
87
  ]
76
88
  }
77
89
  }
package/README.md CHANGED
@@ -1,22 +1,29 @@
1
- # fws — Fake Google Workspace
1
+ # fws — Fake Web Services
2
2
 
3
- A local mock server that redirects the `gws` CLI, enabling Google Workspace testing without OAuth authentication.
3
+ A local mock server for testing CLI tools and agents against fake web services without real credentials. Supports Google Workspace (`gws` CLI), GitHub (`gh` CLI), and more.
4
4
 
5
5
  Built with [Claude Code](https://claude.ai/code).
6
6
 
7
7
  ## How it works
8
8
 
9
- `gws` sends API requests to the `rootUrl` defined in its discovery cache (`~/.config/gws/cache/*.json`). fws rewrites these URLs to `http://localhost:4100/` and sets `GOOGLE_WORKSPACE_CLI_TOKEN=fake` to bypass auth.
9
+ fws runs a local HTTP mock server (port 4100) and a MITM CONNECT proxy (port 4101) that intercepts HTTPS traffic to `*.googleapis.com` and `api.github.com`, forwarding it to the mock server.
10
10
 
11
- For helper commands (`+triage`, `+send`, `+reply`, etc.) that hardcode `googleapis.com` URLs, fws runs a MITM CONNECT proxy on port 4101 that intercepts HTTPS traffic and forwards it to the local mock server.
11
+ For `gws`: discovery cache URLs are rewritten to localhost, and `GOOGLE_WORKSPACE_CLI_TOKEN=fake` bypasses auth.
12
+ For `gh`: `HTTPS_PROXY` routes traffic through the MITM proxy, and `GH_TOKEN=fake` bypasses auth.
12
13
 
13
14
  All data lives **in memory**. When the server stops, everything is lost unless you save a snapshot first. Use `fws snapshot save` to persist state.
14
15
 
15
16
  ## Install
16
17
 
17
18
  ```bash
18
- npm install
19
- npm link # makes `fws` command available globally
19
+ npm install -g @juppytt/fws
20
+ ```
21
+
22
+ Or from source:
23
+
24
+ ```bash
25
+ git clone https://github.com/juppytt/fws.git && cd fws
26
+ npm install && npm link
20
27
  ```
21
28
 
22
29
  ## Quick Start
@@ -30,21 +37,23 @@ export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.local/share/fws/config
30
37
  export GOOGLE_WORKSPACE_CLI_TOKEN=fake
31
38
  export HTTPS_PROXY=http://localhost:4101
32
39
  export SSL_CERT_FILE=~/.local/share/fws/certs/ca.crt
40
+ export GH_TOKEN=fake
33
41
 
34
- # Try some commands
35
- gws gmail users messages list --params '{"userId":"me"}'
42
+ # Try gws commands
36
43
  gws gmail +triage
37
44
  gws calendar events list --params '{"calendarId":"primary"}'
38
45
  gws drive files list
39
- gws tasks tasklists list
40
- gws sheets spreadsheets get --params '{"spreadsheetId":"sheet001"}'
41
- gws people people connections list --params '{"resourceName":"people/me","personFields":"names"}'
46
+
47
+ # Try gh commands
48
+ gh issue list # requires GH_REPO=testuser/my-project
49
+ gh api /repos/testuser/my-project/issues
50
+ gh api /user
42
51
 
43
52
  # When done
44
53
  fws server stop
45
54
  ```
46
55
 
47
- The server starts with sample seed data (5 emails, 4 calendar events, 5 drive files, 2 tasks, 1 spreadsheet, 2 contacts) so you can try gws commands immediately.
56
+ The server starts with sample seed data so you can try commands immediately.
48
57
 
49
58
  ## Usage
50
59
 
@@ -101,17 +110,20 @@ Snapshots are stored in `~/.local/share/fws/snapshots/` (override with `FWS_DATA
101
110
  | Tasks | 1 task list with 2 tasks (1 pending, 1 completed) |
102
111
  | Sheets | 1 spreadsheet ("Budget 2026") |
103
112
  | People | 2 contacts (Alice, Bob), 1 contact group |
113
+ | GitHub | 1 repo (testuser/my-project), 2 issues, 1 PR, 1 comment |
104
114
 
105
115
  ## API support
106
116
 
107
- Gmail (28/79 + 5 helpers), Calendar (21/37), Drive (18/57), Tasks (14/14), Sheets (7/17), People (16/24). 135 tests, 89 gws CLI validated.
117
+ **Google Workspace**: Gmail (28/79 + 5 helpers), Calendar (21/37), Drive (18/57), Tasks (14/14), Sheets (7/17), People (16/24).
118
+ **GitHub**: REST (repos, issues, PRs, comments, labels, search) + GraphQL (issue/PR list and view).
108
119
 
109
- See [docs/gws-support.md](docs/gws-support.md) for the full endpoint-by-endpoint table.
120
+ See [docs/gws-support.md](docs/gws-support.md) for endpoint tables.
110
121
 
111
122
  ## Documentation
112
123
 
113
124
  - [docs/cli-reference.md](docs/cli-reference.md) — Full CLI reference with all flags, HTTP API equivalents, and examples
114
- - [docs/gws-support.md](docs/gws-support.md) — Endpoint-by-endpoint support table
125
+ - [docs/gws-support.md](docs/gws-support.md) — Google Workspace endpoint support table
126
+ - [docs/gh-support.md](docs/gh-support.md) — GitHub endpoint support table
115
127
 
116
128
  ## Structure
117
129
 
package/bin/fws.ts CHANGED
@@ -29,11 +29,14 @@ async function ensureDir(dir: string): Promise<void> {
29
29
  await fs.mkdir(dir, { recursive: true });
30
30
  }
31
31
 
32
+ import { createRequire } from 'node:module';
33
+ const pkg = createRequire(import.meta.url)('../package.json');
34
+
32
35
  const program = new Command();
33
36
  program
34
37
  .name('fws')
35
- .description('Fake Google Workspace — local mock server for gws CLI testing')
36
- .version('0.1.0');
38
+ .description('Fake Web Services — local mock server for testing CLI tools and agents without real credentials')
39
+ .version(pkg.version);
37
40
 
38
41
  // === Server commands ===
39
42
  const serverCmd = program.command('server');
@@ -144,16 +147,20 @@ serverCmd
144
147
  const caPath = serverInfo.caPath || path.join(getDataDir(), 'certs', 'ca.crt');
145
148
 
146
149
  console.log(`fws server started on port ${port} (pid ${child.pid})\n`);
147
- console.log(`To use with gws:\n`);
150
+ console.log(`Run this to configure your shell:\n`);
151
+ console.log(` eval $(fws server env)\n`);
152
+ console.log(`Or set manually:\n`);
148
153
  console.log(` export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=${configDir}`);
149
154
  console.log(` export GOOGLE_WORKSPACE_CLI_TOKEN=fake`);
150
155
  console.log(` export HTTPS_PROXY=http://localhost:${proxyPort}`);
151
- console.log(` export SSL_CERT_FILE=${caPath}\n`);
156
+ console.log(` export SSL_CERT_FILE=${caPath}`);
157
+ console.log(` export GH_TOKEN=fake`);
158
+ console.log(` export GH_REPO=testuser/my-project\n`);
152
159
  console.log(`Then try:\n`);
153
- console.log(` gws gmail users messages list --params '{"userId":"me"}'`);
154
160
  console.log(` gws gmail +triage`);
155
- console.log(` gws calendar events list --params '{"calendarId":"primary"}'`);
156
- console.log(` gws drive files list\n`);
161
+ console.log(` gws drive files list`);
162
+ console.log(` gh issue list`);
163
+ console.log(` gh api /user\n`);
157
164
  console.log(`Stop with: fws server stop`);
158
165
  } else {
159
166
  const log = await fs.readFile(logFile, 'utf-8').catch(() => '');
@@ -178,6 +185,28 @@ serverCmd
178
185
  }
179
186
  });
180
187
 
188
+ serverCmd
189
+ .command('env')
190
+ .description('Print export statements for shell (use with eval)')
191
+ .action(async () => {
192
+ try {
193
+ const info = JSON.parse(await fs.readFile(getServerInfoPath(), 'utf-8'));
194
+ const configDir = path.join(getDataDir(), 'config');
195
+ const caPath = info.caPath || path.join(getDataDir(), 'certs', 'ca.crt');
196
+ const proxyPort = info.proxyPort || info.port + 1;
197
+
198
+ console.log(`export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=${configDir}`);
199
+ console.log(`export GOOGLE_WORKSPACE_CLI_TOKEN=fake`);
200
+ console.log(`export HTTPS_PROXY=http://localhost:${proxyPort}`);
201
+ console.log(`export SSL_CERT_FILE=${caPath}`);
202
+ console.log(`export GH_TOKEN=fake`);
203
+ console.log(`export GH_REPO=testuser/my-project`);
204
+ } catch {
205
+ console.error('No running server found. Start with: fws server start');
206
+ process.exit(1);
207
+ }
208
+ });
209
+
181
210
  serverCmd
182
211
  .command('status')
183
212
  .description('Show server status')
@@ -0,0 +1,63 @@
1
+ # GitHub API Support Status
2
+
3
+ fws mocks the GitHub REST API and a subset of GraphQL, accessed via the `gh` CLI through the MITM proxy.
4
+
5
+ All supported endpoints are validated through actual `gh` CLI commands in `test/gh-validation.test.ts` (15 tests).
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ fws server start
11
+ eval $(fws server env)
12
+
13
+ # gh commands now hit the local mock
14
+ gh issue list
15
+ gh api /user
16
+ ```
17
+
18
+ Requires: `GH_TOKEN=fake`, `HTTPS_PROXY`, `SSL_CERT_FILE` (set by `fws server env`).
19
+ Optional: `GH_REPO=testuser/my-project` (needed for `gh issue list`, `gh pr list`).
20
+
21
+ ## REST API
22
+
23
+ | gh command | Method | Path | Status |
24
+ |------------|--------|------|--------|
25
+ | `gh api /user` | GET | /user | ✅ gh-tested |
26
+ | `gh api /users/:username` | GET | /users/:username | ✅ gh-tested |
27
+ | `gh api /user/repos` | GET | /user/repos | ✅ gh-tested |
28
+ | `gh api /user/repos` | POST | /user/repos | ✅ gh-tested |
29
+ | `gh api /repos/:owner/:repo` | GET | /repos/:owner/:repo | ✅ gh-tested |
30
+ | `gh api /repos/.../issues` | GET | /repos/:owner/:repo/issues | ✅ gh-tested |
31
+ | `gh api /repos/.../issues` | POST | /repos/:owner/:repo/issues | ✅ gh-tested |
32
+ | `gh api /repos/.../issues/:n` | GET | /repos/:owner/:repo/issues/:number | ✅ gh-tested |
33
+ | `gh api /repos/.../issues/:n` | PATCH | /repos/:owner/:repo/issues/:number | ✅ gh-tested |
34
+ | `gh api /repos/.../issues/:n/comments` | GET | /repos/:owner/:repo/issues/:number/comments | ✅ gh-tested |
35
+ | `gh api /repos/.../issues/:n/comments` | POST | /repos/:owner/:repo/issues/:number/comments | ✅ gh-tested |
36
+ | `gh api /repos/.../pulls` | GET | /repos/:owner/:repo/pulls | ✅ gh-tested |
37
+ | `gh api /repos/.../pulls` | POST | /repos/:owner/:repo/pulls | ✅ gh-tested |
38
+ | `gh api /repos/.../pulls/:n` | GET | /repos/:owner/:repo/pulls/:number | ✅ gh-tested |
39
+ | `gh api /repos/.../pulls/:n` | PATCH | /repos/:owner/:repo/pulls/:number | ✅ gh-tested |
40
+ | `gh api /repos/.../pulls/:n/merge` | PUT | /repos/:owner/:repo/pulls/:number/merge | ✅ gh-tested |
41
+ | `gh api /repos/.../labels` | GET | /repos/:owner/:repo/labels | ✅ gh-tested |
42
+ | `gh api /search/issues` | GET | /search/issues?q=... | ✅ gh-tested |
43
+
44
+ ## GraphQL
45
+
46
+ | gh command | Query | Status |
47
+ |------------|-------|--------|
48
+ | `gh issue list` | repository.issues | ✅ gh-tested |
49
+ | `gh issue view N` | repository.issueOrPullRequest | ✅ gh-tested |
50
+ | `gh pr list` | repository.pullRequests | ✅ gh-tested |
51
+ | `gh pr view N` | repository.pullRequest | ✅ gh-tested |
52
+ | Project items queries | repository.issue/pullRequest.projectItems | ✅ stub (returns empty) |
53
+
54
+ ## Seed data
55
+
56
+ | Resource | Data |
57
+ |----------|------|
58
+ | User | `testuser` (Test User, testuser@example.com) |
59
+ | Repo | `testuser/my-project` (TypeScript, public, 2 open issues) |
60
+ | Issue #1 | "Fix login bug" (open, bug label, assigned to testuser, 1 comment from bob) |
61
+ | Issue #2 | "Add dark mode support" (open, enhancement label) |
62
+ | PR #3 | "Fix SSO login flow" (open, fix/sso-login -> main, Fixes #1) |
63
+ | Comment | bob on issue #1: "I can reproduce this. Happens with Google SSO specifically." |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@juppytt/fws",
3
- "version": "0.1.3",
4
- "description": "Fake Google Workspace — local mock server for gws CLI testing",
3
+ "version": "0.3.0",
4
+ "description": "Fake Web Services — local mock server for testing CLI tools and agents without real credentials",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "fws": "bin/fws-cli.js"
package/src/proxy/mitm.ts CHANGED
@@ -5,7 +5,8 @@ import crypto from 'node:crypto';
5
5
  import fs from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
 
8
- const GOOGLEAPIS_HOSTS = [
8
+ const INTERCEPTED_HOSTS = [
9
+ // Google Workspace
9
10
  'gmail.googleapis.com',
10
11
  'www.googleapis.com',
11
12
  'tasks.googleapis.com',
@@ -20,6 +21,8 @@ const GOOGLEAPIS_HOSTS = [
20
21
  'people.googleapis.com',
21
22
  'sheets.googleapis.com',
22
23
  'admin.googleapis.com',
24
+ // GitHub
25
+ 'api.github.com',
23
26
  ];
24
27
 
25
28
  interface CertPair {
@@ -149,7 +152,7 @@ export function startMitmProxy(mockPort: number, proxyPort: number): http.Server
149
152
  const port = parseInt(portStr) || 443;
150
153
 
151
154
  // Only intercept googleapis.com hosts
152
- if (!GOOGLEAPIS_HOSTS.some(h => hostname === h || hostname.endsWith('.' + h))) {
155
+ if (!INTERCEPTED_HOSTS.some(h => hostname === h || hostname.endsWith('.' + h))) {
153
156
  // Pass through to real server
154
157
  const serverSocket = net.connect(port, hostname, () => {
155
158
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
package/src/server/app.ts CHANGED
@@ -5,6 +5,7 @@ import { driveRoutes } from './routes/drive.js';
5
5
  import { tasksRoutes } from './routes/tasks.js';
6
6
  import { sheetsRoutes } from './routes/sheets.js';
7
7
  import { peopleRoutes } from './routes/people.js';
8
+ import { githubRoutes } from './routes/github.js';
8
9
  import { controlRoutes } from './routes/control.js';
9
10
  import { errorHandler } from './middleware.js';
10
11
 
@@ -19,6 +20,7 @@ export function createApp(): express.Express {
19
20
  app.use(tasksRoutes());
20
21
  app.use(sheetsRoutes());
21
22
  app.use(peopleRoutes());
23
+ app.use(githubRoutes());
22
24
 
23
25
  app.use(errorHandler);
24
26
 
@@ -0,0 +1,571 @@
1
+ import { Router } from 'express';
2
+ import { getStore } from '../../store/index.js';
3
+ import { generateId } from '../../util/id.js';
4
+
5
+ export function githubRoutes(): Router {
6
+ const r = Router();
7
+ // gh api sends to api.github.com without prefix
8
+ // GH Enterprise uses /api/v3/ prefix
9
+ // Support both
10
+ const P = '';
11
+
12
+ // === GraphQL ===
13
+ // gh issue list, gh pr list, etc. use GraphQL
14
+ r.post('/graphql', (req, res) => {
15
+ const store = getStore();
16
+ const query = req.body.query || '';
17
+ const variables = req.body.variables || {};
18
+
19
+ // Detect what the query is asking for and return appropriate data
20
+
21
+ // Single PR by number (gh pr view)
22
+ if (query.includes('pullRequest(number:') && !query.includes('pullRequests(') && !query.includes('projectItems')) {
23
+ const repoOwner = variables.owner || store.github.user.login;
24
+ const repoName = variables.repo || Object.values(store.github.repos)[0]?.name || '';
25
+ const key = `${repoOwner}/${repoName}`;
26
+ const num = variables.pr_number || variables.number || 0;
27
+
28
+ const pull = store.github.pulls[key]?.[num];
29
+ if (!pull) {
30
+ return res.json({ data: { repository: { pullRequest: null } } });
31
+ }
32
+
33
+ const commentsKey = `${key}/issues/${num}`;
34
+ const comments = store.github.comments[commentsKey] || [];
35
+
36
+ return res.json({
37
+ data: {
38
+ repository: {
39
+ pullRequest: {
40
+ __typename: 'PullRequest',
41
+ number: pull.number,
42
+ url: pull.html_url,
43
+ title: pull.title,
44
+ body: pull.body || '',
45
+ state: pull.state === 'merged' ? 'MERGED' : pull.state.toUpperCase(),
46
+ createdAt: pull.created_at,
47
+ isDraft: pull.draft,
48
+ maintainerCanModify: true,
49
+ mergeable: pull.mergeable ? 'MERGEABLE' : 'CONFLICTING',
50
+ additions: 10,
51
+ deletions: 3,
52
+ headRefName: pull.head.ref,
53
+ baseRefName: pull.base.ref,
54
+ headRepositoryOwner: { id: `U_${pull.user.id}`, login: pull.user.login, name: pull.user.login },
55
+ headRepository: { id: 'R_1', name: repoName },
56
+ isCrossRepository: false,
57
+ id: `PR_${pull.id}`,
58
+ author: { login: pull.user.login, id: `U_${pull.user.id}`, name: pull.user.login },
59
+ autoMergeRequest: null,
60
+ reviewRequests: { nodes: [] },
61
+ reviews: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null }, totalCount: 0 },
62
+ assignees: { nodes: [], totalCount: 0 },
63
+ labels: { nodes: [], totalCount: 0 },
64
+ milestone: null,
65
+ comments: {
66
+ nodes: comments.map(c => ({
67
+ id: `C_${c.id}`,
68
+ author: { login: c.user.login, id: `U_${c.user.id}`, name: c.user.login },
69
+ authorAssociation: 'NONE',
70
+ body: c.body,
71
+ createdAt: c.created_at,
72
+ includesCreatedEdit: false,
73
+ isMinimized: false,
74
+ minimizedReason: '',
75
+ reactionGroups: [],
76
+ url: c.html_url,
77
+ viewerDidAuthor: false,
78
+ })),
79
+ pageInfo: { hasNextPage: false, endCursor: null },
80
+ totalCount: comments.length,
81
+ },
82
+ reactionGroups: [],
83
+ commits: { totalCount: 1 },
84
+ statusCheckRollup: { nodes: [{ commit: { statusCheckRollup: { contexts: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } } }] },
85
+ },
86
+ },
87
+ },
88
+ });
89
+ }
90
+
91
+ // Single issue/PR by number (gh issue view)
92
+ if (query.includes('issueOrPullRequest')) {
93
+ const repoOwner = variables.owner || store.github.user.login;
94
+ const repoName = variables.repo || Object.values(store.github.repos)[0]?.name || '';
95
+ const key = `${repoOwner}/${repoName}`;
96
+ const num = variables.number || 0;
97
+
98
+ if (!num) {
99
+ return res.json({ data: { repository: { issue: null } } });
100
+ }
101
+
102
+ const issue = store.github.issues[key]?.[num];
103
+ const pull = store.github.pulls[key]?.[num];
104
+ const item = issue || pull;
105
+
106
+ if (item) {
107
+ const isIssue = !!issue;
108
+ const commentsKey = `${key}/issues/${num}`;
109
+ const comments = store.github.comments[commentsKey] || [];
110
+
111
+ const node = {
112
+ __typename: isIssue ? 'Issue' : 'PullRequest',
113
+ number: item.number,
114
+ url: (item as any).html_url,
115
+ state: (item as any).state === 'merged' ? 'MERGED' : (item as any).state.toUpperCase(),
116
+ stateReason: null,
117
+ createdAt: item.created_at,
118
+ title: item.title,
119
+ body: item.body || '',
120
+ id: `ID_${item.id}`,
121
+ author: { login: item.user.login, id: `U_${item.user.id}`, name: item.user.login },
122
+ milestone: null,
123
+ assignees: {
124
+ nodes: isIssue ? (issue!.assignees || []).map(a => ({ id: `U_${a.id}`, login: a.login, name: a.login, databaseId: a.id })) : [],
125
+ totalCount: isIssue ? (issue!.assignees || []).length : 0,
126
+ },
127
+ labels: {
128
+ nodes: isIssue ? (issue!.labels || []).map(l => ({ id: `L_${l.id}`, name: l.name, description: '', color: l.color })) : [],
129
+ totalCount: isIssue ? (issue!.labels || []).length : 0,
130
+ },
131
+ reactionGroups: [],
132
+ comments: {
133
+ nodes: comments.slice(-1).map(c => ({
134
+ author: { login: c.user.login, id: `U_${c.user.id}`, name: c.user.login },
135
+ authorAssociation: 'NONE',
136
+ body: c.body,
137
+ createdAt: c.created_at,
138
+ includesCreatedEdit: false,
139
+ isMinimized: false,
140
+ minimizedReason: '',
141
+ reactionGroups: [],
142
+ })),
143
+ totalCount: comments.length,
144
+ },
145
+ // PR-specific fields
146
+ ...(pull ? {
147
+ headRefName: pull.head.ref,
148
+ baseRefName: pull.base.ref,
149
+ isDraft: pull.draft,
150
+ mergeable: pull.mergeable ? 'MERGEABLE' : 'CONFLICTING',
151
+ } : {}),
152
+ };
153
+ return res.json({ data: { repository: { issue: node } } });
154
+ }
155
+
156
+ return res.json({ data: { repository: { issue: null, issueOrPullRequest: null } } });
157
+ }
158
+
159
+ // Issue list query (not single issue or project items)
160
+ if ((query.includes('issues(') || query.includes('issues {')) && !query.includes('projectItems')) {
161
+ const repoOwner = variables.owner || store.github.user.login;
162
+ const repoName = variables.repo || Object.values(store.github.repos)[0]?.name || '';
163
+ const key = `${repoOwner}/${repoName}`;
164
+ const issues = Object.values(store.github.issues[key] || {});
165
+ const states = variables.states || ['OPEN'];
166
+
167
+ const filteredIssues = issues.filter(i => {
168
+ const gqlState = i.state.toUpperCase();
169
+ return states.includes(gqlState);
170
+ });
171
+
172
+ return res.json({
173
+ data: {
174
+ repository: {
175
+ hasIssuesEnabled: true,
176
+ issues: {
177
+ totalCount: filteredIssues.length,
178
+ nodes: filteredIssues.map(i => ({
179
+ number: i.number,
180
+ title: i.title,
181
+ state: i.state.toUpperCase(),
182
+ createdAt: i.created_at,
183
+ updatedAt: i.updated_at,
184
+ author: { login: i.user.login },
185
+ labels: { nodes: i.labels.map(l => ({ name: l.name, color: l.color })) },
186
+ assignees: { nodes: i.assignees.map(a => ({ login: a.login })) },
187
+ url: i.html_url,
188
+ })),
189
+ pageInfo: { hasNextPage: false, endCursor: null },
190
+ },
191
+ },
192
+ },
193
+ });
194
+ }
195
+
196
+ if (query.includes('pullRequests(') || query.includes('pullRequests {')) {
197
+ const repoOwner = variables.owner || store.github.user.login;
198
+ const repoName = variables.repo || Object.values(store.github.repos)[0]?.name || '';
199
+ const key = `${repoOwner}/${repoName}`;
200
+ const pulls = Object.values(store.github.pulls[key] || {});
201
+ const states = variables.states || ['OPEN'];
202
+
203
+ const filteredPulls = pulls.filter(p => {
204
+ const gqlState = p.state === 'merged' ? 'MERGED' : p.state.toUpperCase();
205
+ return states.includes(gqlState);
206
+ });
207
+
208
+ return res.json({
209
+ data: {
210
+ repository: {
211
+ pullRequests: {
212
+ totalCount: filteredPulls.length,
213
+ nodes: filteredPulls.map(p => ({
214
+ number: p.number,
215
+ title: p.title,
216
+ state: p.state === 'merged' ? 'MERGED' : p.state.toUpperCase(),
217
+ createdAt: p.created_at,
218
+ updatedAt: p.updated_at,
219
+ author: { login: p.user.login },
220
+ headRefName: p.head.ref,
221
+ baseRefName: p.base.ref,
222
+ isDraft: p.draft,
223
+ url: p.html_url,
224
+ })),
225
+ pageInfo: { hasNextPage: false, endCursor: null },
226
+ },
227
+ },
228
+ },
229
+ });
230
+ }
231
+
232
+ // Project items query stub
233
+ if (query.includes('projectItems')) {
234
+ const projectItems = { totalCount: 0, nodes: [], pageInfo: { hasNextPage: false, endCursor: null } };
235
+ // Return only the field the query asks for
236
+ const repoData: any = {};
237
+ if (query.includes('issue(')) repoData.issue = { projectItems };
238
+ if (query.includes('pullRequest(')) repoData.pullRequest = { projectItems };
239
+ return res.json({ data: { repository: repoData } });
240
+ }
241
+
242
+ // Fallback for unknown queries
243
+ res.json({ data: {} });
244
+ });
245
+
246
+ // === User ===
247
+
248
+ r.get(`${P}/user`, (_req, res) => {
249
+ res.json(getStore().github.user);
250
+ });
251
+
252
+ r.get(`${P}/users/:username`, (req, res) => {
253
+ const store = getStore();
254
+ if (req.params.username === store.github.user.login) {
255
+ return res.json(store.github.user);
256
+ }
257
+ res.status(404).json({ message: 'Not Found' });
258
+ });
259
+
260
+ // === Repos ===
261
+
262
+ r.get(`${P}/repos/:owner/:repo`, (req, res) => {
263
+ const key = `${req.params.owner}/${req.params.repo}`;
264
+ const repo = getStore().github.repos[key];
265
+ if (!repo) {
266
+ return res.status(404).json({ message: 'Not Found' });
267
+ }
268
+ res.json(repo);
269
+ });
270
+
271
+ r.get(`${P}/user/repos`, (_req, res) => {
272
+ res.json(Object.values(getStore().github.repos));
273
+ });
274
+
275
+ r.post(`${P}/user/repos`, (req, res) => {
276
+ const store = getStore();
277
+ const name = req.body.name || 'new-repo';
278
+ const fullName = `${store.github.user.login}/${name}`;
279
+ const now = new Date().toISOString();
280
+ const repo = {
281
+ id: Math.floor(Math.random() * 100000),
282
+ name,
283
+ full_name: fullName,
284
+ owner: { login: store.github.user.login, id: store.github.user.id, type: 'User' },
285
+ private: req.body.private || false,
286
+ html_url: `https://github.com/${fullName}`,
287
+ description: req.body.description || null,
288
+ fork: false,
289
+ created_at: now,
290
+ updated_at: now,
291
+ pushed_at: now,
292
+ default_branch: 'main',
293
+ open_issues_count: 0,
294
+ language: null,
295
+ topics: [],
296
+ };
297
+ store.github.repos[fullName] = repo;
298
+ store.github.issues[fullName] = {};
299
+ store.github.pulls[fullName] = {};
300
+ res.status(201).json(repo);
301
+ });
302
+
303
+ // === Issues ===
304
+
305
+ r.get(`${P}/repos/:owner/:repo/issues`, (req, res) => {
306
+ const key = `${req.params.owner}/${req.params.repo}`;
307
+ const issuesMap = getStore().github.issues[key];
308
+ if (!issuesMap) {
309
+ return res.status(404).json({ message: 'Not Found' });
310
+ }
311
+ let issues = Object.values(issuesMap);
312
+ const state = req.query.state as string || 'open';
313
+ if (state !== 'all') {
314
+ issues = issues.filter(i => i.state === state);
315
+ }
316
+ // Include PRs unless filtered out
317
+ const pullsMap = getStore().github.pulls[key] || {};
318
+ if (state !== 'all') {
319
+ const prs = Object.values(pullsMap).filter(p => p.state === state);
320
+ const prIssues = prs.map(p => ({
321
+ ...p,
322
+ labels: [],
323
+ assignees: [],
324
+ comments: 0,
325
+ pull_request: { url: `${P}/repos/${key}/pulls/${p.number}` },
326
+ }));
327
+ issues = [...issues, ...prIssues as any];
328
+ }
329
+ issues.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
330
+ res.json(issues);
331
+ });
332
+
333
+ r.get(`${P}/repos/:owner/:repo/issues/:number`, (req, res) => {
334
+ const key = `${req.params.owner}/${req.params.repo}`;
335
+ const num = parseInt(req.params.number);
336
+ const issue = getStore().github.issues[key]?.[num];
337
+ if (!issue) {
338
+ // Check pulls
339
+ const pull = getStore().github.pulls[key]?.[num];
340
+ if (pull) return res.json({ ...pull, pull_request: { url: '' } });
341
+ return res.status(404).json({ message: 'Not Found' });
342
+ }
343
+ res.json(issue);
344
+ });
345
+
346
+ r.post(`${P}/repos/:owner/:repo/issues`, (req, res) => {
347
+ const key = `${req.params.owner}/${req.params.repo}`;
348
+ const store = getStore();
349
+ if (!store.github.issues[key]) store.github.issues[key] = {};
350
+
351
+ const existing = Object.values(store.github.issues[key]);
352
+ const existingPulls = Object.values(store.github.pulls[key] || {});
353
+ const maxNum = Math.max(0, ...existing.map(i => i.number), ...existingPulls.map(p => p.number));
354
+ const num = maxNum + 1;
355
+ const now = new Date().toISOString();
356
+
357
+ const issue = {
358
+ id: Math.floor(Math.random() * 100000),
359
+ number: num,
360
+ title: req.body.title || '',
361
+ body: req.body.body || null,
362
+ state: 'open' as const,
363
+ labels: (req.body.labels || []).map((l: string, i: number) => ({ id: i + 100, name: l, color: '000000' })),
364
+ assignees: (req.body.assignees || []).map((a: string) => ({ login: a, id: 0 })),
365
+ user: { login: store.github.user.login, id: store.github.user.id },
366
+ created_at: now,
367
+ updated_at: now,
368
+ closed_at: null,
369
+ html_url: `https://github.com/${key}/issues/${num}`,
370
+ comments: 0,
371
+ };
372
+ store.github.issues[key][num] = issue;
373
+
374
+ // Update repo issue count
375
+ const repo = store.github.repos[key];
376
+ if (repo) repo.open_issues_count++;
377
+
378
+ res.status(201).json(issue);
379
+ });
380
+
381
+ r.patch(`${P}/repos/:owner/:repo/issues/:number`, (req, res) => {
382
+ const key = `${req.params.owner}/${req.params.repo}`;
383
+ const num = parseInt(req.params.number);
384
+ const store = getStore();
385
+ const issue = store.github.issues[key]?.[num];
386
+ if (!issue) {
387
+ return res.status(404).json({ message: 'Not Found' });
388
+ }
389
+ const wasOpen = issue.state === 'open';
390
+ Object.assign(issue, req.body, { number: num, id: issue.id });
391
+ issue.updated_at = new Date().toISOString();
392
+ if (req.body.state === 'closed' && !issue.closed_at) {
393
+ issue.closed_at = new Date().toISOString();
394
+ }
395
+ // Update count
396
+ const repo = store.github.repos[key];
397
+ if (repo) {
398
+ if (wasOpen && issue.state === 'closed') repo.open_issues_count--;
399
+ if (!wasOpen && issue.state === 'open') repo.open_issues_count++;
400
+ }
401
+ res.json(issue);
402
+ });
403
+
404
+ // === Issue Comments ===
405
+
406
+ r.get(`${P}/repos/:owner/:repo/issues/:number/comments`, (req, res) => {
407
+ const key = `${req.params.owner}/${req.params.repo}/issues/${req.params.number}`;
408
+ const comments = getStore().github.comments[key] || [];
409
+ res.json(comments);
410
+ });
411
+
412
+ r.post(`${P}/repos/:owner/:repo/issues/:number/comments`, (req, res) => {
413
+ const issueKey = `${req.params.owner}/${req.params.repo}/issues/${req.params.number}`;
414
+ const store = getStore();
415
+ if (!store.github.comments[issueKey]) store.github.comments[issueKey] = [];
416
+
417
+ const now = new Date().toISOString();
418
+ const comment = {
419
+ id: Math.floor(Math.random() * 100000),
420
+ body: req.body.body || '',
421
+ user: { login: store.github.user.login, id: store.github.user.id },
422
+ created_at: now,
423
+ updated_at: now,
424
+ html_url: `https://github.com/${req.params.owner}/${req.params.repo}/issues/${req.params.number}#issuecomment-${Date.now()}`,
425
+ };
426
+ store.github.comments[issueKey].push(comment);
427
+
428
+ // Update issue comment count
429
+ const repoKey = `${req.params.owner}/${req.params.repo}`;
430
+ const num = parseInt(req.params.number);
431
+ const issue = store.github.issues[repoKey]?.[num];
432
+ if (issue) issue.comments++;
433
+
434
+ res.status(201).json(comment);
435
+ });
436
+
437
+ // === Pull Requests ===
438
+
439
+ r.get(`${P}/repos/:owner/:repo/pulls`, (req, res) => {
440
+ const key = `${req.params.owner}/${req.params.repo}`;
441
+ const pullsMap = getStore().github.pulls[key];
442
+ if (!pullsMap) {
443
+ return res.status(404).json({ message: 'Not Found' });
444
+ }
445
+ let pulls = Object.values(pullsMap);
446
+ const state = req.query.state as string || 'open';
447
+ if (state !== 'all') {
448
+ pulls = pulls.filter(p => p.state === state);
449
+ }
450
+ res.json(pulls);
451
+ });
452
+
453
+ r.get(`${P}/repos/:owner/:repo/pulls/:number`, (req, res) => {
454
+ const key = `${req.params.owner}/${req.params.repo}`;
455
+ const num = parseInt(req.params.number);
456
+ const pull = getStore().github.pulls[key]?.[num];
457
+ if (!pull) {
458
+ return res.status(404).json({ message: 'Not Found' });
459
+ }
460
+ res.json(pull);
461
+ });
462
+
463
+ r.post(`${P}/repos/:owner/:repo/pulls`, (req, res) => {
464
+ const key = `${req.params.owner}/${req.params.repo}`;
465
+ const store = getStore();
466
+ if (!store.github.pulls[key]) store.github.pulls[key] = {};
467
+
468
+ const existingIssues = Object.values(store.github.issues[key] || {});
469
+ const existingPulls = Object.values(store.github.pulls[key]);
470
+ const maxNum = Math.max(0, ...existingIssues.map(i => i.number), ...existingPulls.map(p => p.number));
471
+ const num = maxNum + 1;
472
+ const now = new Date().toISOString();
473
+
474
+ const pull = {
475
+ id: Math.floor(Math.random() * 100000),
476
+ number: num,
477
+ title: req.body.title || '',
478
+ body: req.body.body || null,
479
+ state: 'open' as const,
480
+ head: { ref: req.body.head || 'feature', sha: generateId(8), label: `${store.github.user.login}:${req.body.head || 'feature'}` },
481
+ base: { ref: req.body.base || 'main', sha: generateId(8), label: `${store.github.user.login}:${req.body.base || 'main'}` },
482
+ user: { login: store.github.user.login, id: store.github.user.id },
483
+ created_at: now,
484
+ updated_at: now,
485
+ merged_at: null,
486
+ closed_at: null,
487
+ html_url: `https://github.com/${key}/pull/${num}`,
488
+ mergeable: true,
489
+ draft: req.body.draft || false,
490
+ };
491
+ store.github.pulls[key][num] = pull;
492
+ res.status(201).json(pull);
493
+ });
494
+
495
+ r.patch(`${P}/repos/:owner/:repo/pulls/:number`, (req, res) => {
496
+ const key = `${req.params.owner}/${req.params.repo}`;
497
+ const num = parseInt(req.params.number);
498
+ const store = getStore();
499
+ const pull = store.github.pulls[key]?.[num];
500
+ if (!pull) {
501
+ return res.status(404).json({ message: 'Not Found' });
502
+ }
503
+ Object.assign(pull, req.body, { number: num, id: pull.id });
504
+ pull.updated_at = new Date().toISOString();
505
+ if (req.body.state === 'closed' && !pull.closed_at) {
506
+ pull.closed_at = new Date().toISOString();
507
+ }
508
+ res.json(pull);
509
+ });
510
+
511
+ // Merge PR
512
+ r.put(`${P}/repos/:owner/:repo/pulls/:number/merge`, (req, res) => {
513
+ const key = `${req.params.owner}/${req.params.repo}`;
514
+ const num = parseInt(req.params.number);
515
+ const store = getStore();
516
+ const pull = store.github.pulls[key]?.[num];
517
+ if (!pull) {
518
+ return res.status(404).json({ message: 'Not Found' });
519
+ }
520
+ if (pull.state !== 'open') {
521
+ return res.status(405).json({ message: 'Pull request is not mergeable' });
522
+ }
523
+ pull.state = 'merged';
524
+ pull.merged_at = new Date().toISOString();
525
+ pull.closed_at = pull.merged_at;
526
+ res.json({
527
+ sha: generateId(8),
528
+ merged: true,
529
+ message: 'Pull Request successfully merged',
530
+ });
531
+ });
532
+
533
+ // === Labels ===
534
+
535
+ r.get(`${P}/repos/:owner/:repo/labels`, (req, res) => {
536
+ const key = `${req.params.owner}/${req.params.repo}`;
537
+ const issues = Object.values(getStore().github.issues[key] || {});
538
+ const labelMap = new Map<string, any>();
539
+ for (const issue of issues) {
540
+ for (const label of issue.labels) {
541
+ labelMap.set(label.name, label);
542
+ }
543
+ }
544
+ res.json(Array.from(labelMap.values()));
545
+ });
546
+
547
+ // === Search ===
548
+
549
+ r.get(`${P}/search/issues`, (req, res) => {
550
+ const q = (req.query.q as string || '').toLowerCase();
551
+ const store = getStore();
552
+ const results: any[] = [];
553
+
554
+ for (const [repoKey, issuesMap] of Object.entries(store.github.issues)) {
555
+ for (const issue of Object.values(issuesMap)) {
556
+ const text = `${issue.title} ${issue.body || ''} ${issue.labels.map(l => l.name).join(' ')}`.toLowerCase();
557
+ if (text.includes(q) || q.includes(`repo:${repoKey}`)) {
558
+ results.push(issue);
559
+ }
560
+ }
561
+ }
562
+
563
+ res.json({
564
+ total_count: results.length,
565
+ incomplete_results: false,
566
+ items: results,
567
+ });
568
+ });
569
+
570
+ return r;
571
+ }
package/src/store/seed.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FwsStore, GmailLabel, GmailMessage, CalendarEvent, DriveFile, TaskList, Task, Spreadsheet, Person, ContactGroup } from './types.js';
1
+ import type { FwsStore, GmailLabel, GmailMessage, CalendarEvent, DriveFile, TaskList, Task, Spreadsheet, Person, ContactGroup, GitHubStore } from './types.js';
2
2
  import { generateEtag } from '../util/id.js';
3
3
 
4
4
  const SYSTEM_LABELS: GmailLabel[] = [
@@ -307,6 +307,103 @@ export function createSeedStore(): FwsStore {
307
307
  },
308
308
  },
309
309
  },
310
+ github: {
311
+ user: {
312
+ login: 'testuser',
313
+ id: 1,
314
+ name: 'Test User',
315
+ email: 'testuser@example.com',
316
+ avatar_url: 'https://github.com/testuser.png',
317
+ html_url: 'https://github.com/testuser',
318
+ type: 'User',
319
+ },
320
+ repos: {
321
+ 'testuser/my-project': {
322
+ id: 101,
323
+ name: 'my-project',
324
+ full_name: 'testuser/my-project',
325
+ owner: { login: 'testuser', id: 1, type: 'User' },
326
+ private: false,
327
+ html_url: 'https://github.com/testuser/my-project',
328
+ description: 'A sample project for testing',
329
+ fork: false,
330
+ created_at: '2026-01-01T00:00:00Z',
331
+ updated_at: '2026-04-07T00:00:00Z',
332
+ pushed_at: '2026-04-07T00:00:00Z',
333
+ default_branch: 'main',
334
+ open_issues_count: 2,
335
+ language: 'TypeScript',
336
+ topics: ['testing', 'mock'],
337
+ },
338
+ },
339
+ issues: {
340
+ 'testuser/my-project': {
341
+ 1: {
342
+ id: 1001,
343
+ number: 1,
344
+ title: 'Fix login bug',
345
+ body: 'Users are getting 401 errors when trying to log in with SSO.',
346
+ state: 'open',
347
+ labels: [{ id: 1, name: 'bug', color: 'd73a4a' }],
348
+ assignees: [{ login: 'testuser', id: 1 }],
349
+ user: { login: 'alice', id: 2 },
350
+ created_at: '2026-04-05T10:00:00Z',
351
+ updated_at: '2026-04-06T14:00:00Z',
352
+ closed_at: null,
353
+ html_url: 'https://github.com/testuser/my-project/issues/1',
354
+ comments: 1,
355
+ },
356
+ 2: {
357
+ id: 1002,
358
+ number: 2,
359
+ title: 'Add dark mode support',
360
+ body: 'It would be great to have a dark mode option.',
361
+ state: 'open',
362
+ labels: [{ id: 2, name: 'enhancement', color: 'a2eeef' }],
363
+ assignees: [],
364
+ user: { login: 'bob', id: 3 },
365
+ created_at: '2026-04-06T09:00:00Z',
366
+ updated_at: '2026-04-06T09:00:00Z',
367
+ closed_at: null,
368
+ html_url: 'https://github.com/testuser/my-project/issues/2',
369
+ comments: 0,
370
+ },
371
+ },
372
+ },
373
+ pulls: {
374
+ 'testuser/my-project': {
375
+ 3: {
376
+ id: 2001,
377
+ number: 3,
378
+ title: 'Fix SSO login flow',
379
+ body: 'Fixes #1. Updated the auth middleware to handle SSO tokens correctly.',
380
+ state: 'open',
381
+ head: { ref: 'fix/sso-login', sha: 'abc1234', label: 'testuser:fix/sso-login' },
382
+ base: { ref: 'main', sha: 'def5678', label: 'testuser:main' },
383
+ user: { login: 'testuser', id: 1 },
384
+ created_at: '2026-04-07T08:00:00Z',
385
+ updated_at: '2026-04-07T08:00:00Z',
386
+ merged_at: null,
387
+ closed_at: null,
388
+ html_url: 'https://github.com/testuser/my-project/pull/3',
389
+ mergeable: true,
390
+ draft: false,
391
+ },
392
+ },
393
+ },
394
+ comments: {
395
+ 'testuser/my-project/issues/1': [
396
+ {
397
+ id: 3001,
398
+ body: 'I can reproduce this. Happens with Google SSO specifically.',
399
+ user: { login: 'bob', id: 3 },
400
+ created_at: '2026-04-06T14:00:00Z',
401
+ updated_at: '2026-04-06T14:00:00Z',
402
+ html_url: 'https://github.com/testuser/my-project/issues/1#issuecomment-3001',
403
+ },
404
+ ],
405
+ },
406
+ },
310
407
  };
311
408
  }
312
409
 
@@ -7,6 +7,7 @@ export interface FwsStore {
7
7
  tasks: TasksStore;
8
8
  sheets: SheetsStore;
9
9
  people: PeopleStore;
10
+ github: GitHubStore;
10
11
  }
11
12
 
12
13
  // === Gmail ===
@@ -223,3 +224,85 @@ export interface ContactGroup {
223
224
  memberCount: number;
224
225
  memberResourceNames?: string[];
225
226
  }
227
+
228
+ // === GitHub ===
229
+
230
+ export interface GitHubStore {
231
+ user: GitHubUser;
232
+ repos: Record<string, GitHubRepo>;
233
+ issues: Record<string, Record<number, GitHubIssue>>; // "owner/repo" -> number -> issue
234
+ pulls: Record<string, Record<number, GitHubPull>>;
235
+ comments: Record<string, GitHubComment[]>; // "owner/repo/issues/number" -> comments
236
+ }
237
+
238
+ export interface GitHubUser {
239
+ login: string;
240
+ id: number;
241
+ name: string;
242
+ email: string;
243
+ avatar_url: string;
244
+ html_url: string;
245
+ type: 'User';
246
+ }
247
+
248
+ export interface GitHubRepo {
249
+ id: number;
250
+ name: string;
251
+ full_name: string;
252
+ owner: { login: string; id: number; type: string };
253
+ private: boolean;
254
+ html_url: string;
255
+ description: string | null;
256
+ fork: boolean;
257
+ created_at: string;
258
+ updated_at: string;
259
+ pushed_at: string;
260
+ default_branch: string;
261
+ open_issues_count: number;
262
+ language: string | null;
263
+ topics: string[];
264
+ }
265
+
266
+ export interface GitHubIssue {
267
+ id: number;
268
+ number: number;
269
+ title: string;
270
+ body: string | null;
271
+ state: 'open' | 'closed';
272
+ labels: Array<{ id: number; name: string; color: string }>;
273
+ assignees: Array<{ login: string; id: number }>;
274
+ user: { login: string; id: number };
275
+ created_at: string;
276
+ updated_at: string;
277
+ closed_at: string | null;
278
+ html_url: string;
279
+ comments: number;
280
+ pull_request?: { url: string };
281
+ }
282
+
283
+ export interface GitHubPull {
284
+ id: number;
285
+ number: number;
286
+ title: string;
287
+ body: string | null;
288
+ state: 'open' | 'closed' | 'merged';
289
+ head: { ref: string; sha: string; label: string };
290
+ base: { ref: string; sha: string; label: string };
291
+ user: { login: string; id: number };
292
+ created_at: string;
293
+ updated_at: string;
294
+ merged_at: string | null;
295
+ closed_at: string | null;
296
+ html_url: string;
297
+ mergeable: boolean | null;
298
+ draft: boolean;
299
+ }
300
+
301
+ export interface GitHubComment {
302
+ id: number;
303
+ body: string;
304
+ user: { login: string; id: number };
305
+ created_at: string;
306
+ updated_at: string;
307
+ html_url: string;
308
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { createTestHarness, type TestHarness } from './helpers/harness.js';
3
+
4
+ /**
5
+ * End-to-end validation: GitHub endpoints tested through the actual gh CLI.
6
+ */
7
+ describe('gh CLI validation', () => {
8
+ let h: TestHarness;
9
+
10
+ beforeAll(async () => {
11
+ h = await createTestHarness();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await h.cleanup();
16
+ });
17
+
18
+ describe('REST API', () => {
19
+ it('gh api /user', async () => {
20
+ const { stdout, exitCode } = await h.ghProxy('api /user');
21
+ expect(exitCode).toBe(0);
22
+ const data = JSON.parse(stdout);
23
+ expect(data.login).toBe('testuser');
24
+ expect(data.name).toBe('Test User');
25
+ });
26
+
27
+ it('gh api /repos/testuser/my-project', async () => {
28
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project');
29
+ expect(exitCode).toBe(0);
30
+ const data = JSON.parse(stdout);
31
+ expect(data.full_name).toBe('testuser/my-project');
32
+ });
33
+
34
+ it('gh api /repos/.../issues (list)', async () => {
35
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues');
36
+ expect(exitCode).toBe(0);
37
+ const data = JSON.parse(stdout);
38
+ expect(data.length).toBeGreaterThan(0);
39
+ expect(data.some((i: any) => i.title === 'Fix login bug')).toBe(true);
40
+ });
41
+
42
+ it('gh api /repos/.../issues/1 (get)', async () => {
43
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues/1');
44
+ expect(exitCode).toBe(0);
45
+ const data = JSON.parse(stdout);
46
+ expect(data.title).toBe('Fix login bug');
47
+ expect(data.state).toBe('open');
48
+ });
49
+
50
+ it('gh api /repos/.../issues (create)', async () => {
51
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues -f title="Test Issue" -f body="Created by test"');
52
+ expect(exitCode).toBe(0);
53
+ const data = JSON.parse(stdout);
54
+ expect(data.title).toBe('Test Issue');
55
+ expect(data.number).toBeGreaterThan(0);
56
+ });
57
+
58
+ it('gh api /repos/.../issues/1/comments (create)', async () => {
59
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues/1/comments -f body="Test comment"');
60
+ expect(exitCode).toBe(0);
61
+ const data = JSON.parse(stdout);
62
+ expect(data.body).toBe('Test comment');
63
+ });
64
+
65
+ it('gh api /repos/.../issues/1/comments (list)', async () => {
66
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues/1/comments');
67
+ expect(exitCode).toBe(0);
68
+ const data = JSON.parse(stdout);
69
+ expect(data.length).toBeGreaterThan(0);
70
+ });
71
+
72
+ it('gh api /repos/.../pulls (list)', async () => {
73
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/pulls');
74
+ expect(exitCode).toBe(0);
75
+ const data = JSON.parse(stdout);
76
+ expect(data.length).toBeGreaterThan(0);
77
+ expect(data[0].title).toBe('Fix SSO login flow');
78
+ });
79
+
80
+ it('gh api /repos/.../pulls/3 (get)', async () => {
81
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/pulls/3');
82
+ expect(exitCode).toBe(0);
83
+ const data = JSON.parse(stdout);
84
+ expect(data.title).toBe('Fix SSO login flow');
85
+ expect(data.head.ref).toBe('fix/sso-login');
86
+ });
87
+
88
+ it('gh api /search/issues', async () => {
89
+ const { stdout, exitCode } = await h.ghProxy('api "/search/issues?q=bug"');
90
+ expect(exitCode).toBe(0);
91
+ const data = JSON.parse(stdout);
92
+ expect(data.total_count).toBeGreaterThan(0);
93
+ });
94
+
95
+ it('gh api issue close (PATCH)', async () => {
96
+ const { stdout, exitCode } = await h.ghProxy('api /repos/testuser/my-project/issues/2 -X PATCH -f state=closed');
97
+ expect(exitCode).toBe(0);
98
+ const data = JSON.parse(stdout);
99
+ expect(data.state).toBe('closed');
100
+ });
101
+ });
102
+
103
+ describe('GraphQL (high-level commands)', () => {
104
+ it('gh issue list', async () => {
105
+ const { stdout, exitCode } = await h.ghProxyWithRepo('issue list');
106
+ expect(exitCode).toBe(0);
107
+ expect(stdout).toContain('Fix login bug');
108
+ });
109
+
110
+ it('gh issue view 1', async () => {
111
+ const { stdout, exitCode } = await h.ghProxyWithRepo('issue view 1');
112
+ expect(exitCode).toBe(0);
113
+ expect(stdout).toContain('Fix login bug');
114
+ expect(stdout).toContain('OPEN');
115
+ });
116
+
117
+ it('gh pr list', async () => {
118
+ const { stdout, exitCode } = await h.ghProxyWithRepo('pr list');
119
+ expect(exitCode).toBe(0);
120
+ expect(stdout).toContain('Fix SSO login flow');
121
+ });
122
+
123
+ it('gh pr view 3', async () => {
124
+ const { stdout, exitCode } = await h.ghProxyWithRepo('pr view 3');
125
+ expect(exitCode).toBe(0);
126
+ expect(stdout).toContain('Fix SSO login flow');
127
+ expect(stdout).toContain('OPEN');
128
+ });
129
+ });
130
+ });
@@ -16,6 +16,10 @@ export interface TestHarness {
16
16
  gws: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
17
17
  /** Run gws command with MITM proxy (for helper commands like +triage) */
18
18
  gwsProxy: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
19
+ /** Run gh command with MITM proxy */
20
+ ghProxy: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
21
+ /** Run gh command with MITM proxy and GH_REPO set */
22
+ ghProxyWithRepo: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
19
23
  cleanup: () => Promise<void>;
20
24
  }
21
25
 
@@ -81,9 +85,9 @@ export async function createTestHarness(): Promise<TestHarness> {
81
85
  return result;
82
86
  }
83
87
 
84
- function runGws(args: string, env: Record<string, string | undefined>): Promise<{ stdout: string; stderr: string; exitCode: number }> {
88
+ function runCmd(bin: string, args: string, env: Record<string, string | undefined>): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85
89
  return new Promise((resolve) => {
86
- execFile(gwsPath, parseArgs(args), { env, timeout: 10000 }, (err, stdout, stderr) => {
90
+ execFile(bin, parseArgs(args), { env, timeout: 10000 }, (err, stdout, stderr) => {
87
91
  resolve({
88
92
  stdout: stdout || '',
89
93
  stderr: stderr || '',
@@ -93,12 +97,26 @@ export async function createTestHarness(): Promise<TestHarness> {
93
97
  });
94
98
  }
95
99
 
100
+ const ghEnv = {
101
+ ...proxyEnv,
102
+ GH_TOKEN: 'fake',
103
+ };
104
+
105
+ const ghEnvWithRepo = {
106
+ ...ghEnv,
107
+ GH_REPO: 'testuser/my-project',
108
+ };
109
+
110
+ const ghPath = process.env.GH_PATH || 'gh';
111
+
96
112
  return {
97
113
  port,
98
114
  fetch: (urlPath: string, init?: RequestInit) =>
99
115
  globalThis.fetch(`http://localhost:${port}${urlPath}`, init),
100
- gws: (args: string) => runGws(args, baseEnv),
101
- gwsProxy: (args: string) => runGws(args, proxyEnv),
116
+ gws: (args: string) => runCmd(gwsPath, args, baseEnv),
117
+ gwsProxy: (args: string) => runCmd(gwsPath, args, proxyEnv),
118
+ ghProxy: (args: string) => runCmd(ghPath, args, ghEnv),
119
+ ghProxyWithRepo: (args: string) => runCmd(ghPath, args, ghEnvWithRepo),
102
120
  cleanup: async () => {
103
121
  server.close();
104
122
  proxyServer.close();