@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.
- package/.claude/settings.local.json +13 -1
- package/README.md +27 -15
- package/bin/fws.ts +36 -7
- package/docs/gh-support.md +63 -0
- package/package.json +2 -2
- package/src/proxy/mitm.ts +5 -2
- package/src/server/app.ts +2 -0
- package/src/server/routes/github.ts +571 -0
- package/src/store/seed.ts +98 -1
- package/src/store/types.ts +83 -0
- package/test/gh-validation.test.ts +130 -0
- package/test/helpers/harness.ts +22 -4
|
@@ -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
|
|
1
|
+
# fws — Fake Web Services
|
|
2
2
|
|
|
3
|
-
A local mock server
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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).
|
|
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
|
|
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) —
|
|
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
|
|
36
|
-
.version(
|
|
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(`
|
|
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}
|
|
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
|
|
156
|
-
console.log(`
|
|
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.
|
|
4
|
-
"description": "Fake
|
|
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
|
|
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 (!
|
|
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
|
|
package/src/store/types.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/helpers/harness.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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) =>
|
|
101
|
-
gwsProxy: (args: string) =>
|
|
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();
|