@rsktash/beads-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- package/server/ws.test.js +52 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation helpers for protocol payloads.
|
|
3
|
+
*
|
|
4
|
+
* Provides schema checks for subscription specs and selected mutations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Known subscription types supported by the server.
|
|
9
|
+
*
|
|
10
|
+
* @type {Set<string>}
|
|
11
|
+
*/
|
|
12
|
+
const SUBSCRIPTION_TYPES = new Set([
|
|
13
|
+
'all-issues',
|
|
14
|
+
'epics',
|
|
15
|
+
'blocked-issues',
|
|
16
|
+
'ready-issues',
|
|
17
|
+
'in-progress-issues',
|
|
18
|
+
'closed-issues',
|
|
19
|
+
'search-issues',
|
|
20
|
+
'issue-detail'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate a subscribe-list payload and normalize to a SubscriptionSpec.
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown} payload
|
|
27
|
+
* @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
|
|
28
|
+
*/
|
|
29
|
+
export function validateSubscribeListPayload(payload) {
|
|
30
|
+
if (!payload || typeof payload !== 'object') {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
code: 'bad_request',
|
|
34
|
+
message: 'payload must be an object'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const any =
|
|
38
|
+
/** @type {{ id?: unknown, type?: unknown, params?: unknown }} */ (payload);
|
|
39
|
+
|
|
40
|
+
const id = typeof any.id === 'string' ? any.id : '';
|
|
41
|
+
if (id.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
code: 'bad_request',
|
|
45
|
+
message: 'payload.id must be a non-empty string'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const type = typeof any.type === 'string' ? any.type : '';
|
|
50
|
+
if (type.length === 0 || !SUBSCRIPTION_TYPES.has(type)) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'bad_request',
|
|
54
|
+
message: `payload.type must be one of: ${Array.from(SUBSCRIPTION_TYPES).join(', ')}`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @type {Record<string, string|number|boolean> | undefined} */
|
|
59
|
+
let params;
|
|
60
|
+
if (any.params !== undefined) {
|
|
61
|
+
if (
|
|
62
|
+
!any.params ||
|
|
63
|
+
typeof any.params !== 'object' ||
|
|
64
|
+
Array.isArray(any.params)
|
|
65
|
+
) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 'bad_request',
|
|
69
|
+
message: 'payload.params must be an object when provided'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
params = /** @type {Record<string, string|number|boolean>} */ (any.params);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Per-type param schemas
|
|
76
|
+
if (type === 'issue-detail') {
|
|
77
|
+
const id = String(params?.id ?? '').trim();
|
|
78
|
+
if (id.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: 'bad_request',
|
|
82
|
+
message: 'params.id must be a non-empty string'
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
params = { id };
|
|
86
|
+
} else if (type === 'search-issues') {
|
|
87
|
+
/** @type {Record<string, string|number|boolean>} */
|
|
88
|
+
const cleaned = {};
|
|
89
|
+
if (params && typeof params.q === 'string') cleaned.q = params.q;
|
|
90
|
+
if (params && typeof params.status === 'string') cleaned.status = params.status;
|
|
91
|
+
if (params && typeof params.type === 'string') cleaned.type = params.type;
|
|
92
|
+
if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
|
|
93
|
+
if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
|
|
94
|
+
params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
95
|
+
} else if (type === 'closed-issues') {
|
|
96
|
+
/** @type {Record<string, string|number|boolean>} */
|
|
97
|
+
const cleaned = {};
|
|
98
|
+
if (params && 'since' in params) {
|
|
99
|
+
const since = params.since;
|
|
100
|
+
const n = typeof since === 'number' ? since : Number.NaN;
|
|
101
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: 'bad_request',
|
|
105
|
+
message: 'params.since must be a non-negative number (epoch ms)'
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
cleaned.since = n;
|
|
109
|
+
}
|
|
110
|
+
// Allow pagination params
|
|
111
|
+
if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
|
|
112
|
+
if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
|
|
113
|
+
params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
114
|
+
} else {
|
|
115
|
+
// Allow pagination params (limit, offset) for all list types
|
|
116
|
+
/** @type {Record<string, string|number|boolean>} */
|
|
117
|
+
const cleaned = {};
|
|
118
|
+
if (params && typeof params.limit === 'number') cleaned.limit = params.limit;
|
|
119
|
+
if (params && typeof params.offset === 'number') cleaned.offset = params.offset;
|
|
120
|
+
params = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ok: true, id, spec: { type, params } };
|
|
124
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveWorkspaceDatabase } from './db.js';
|
|
4
|
+
import { debug } from './logging.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Watch the resolved workspace database target and invoke a callback after a
|
|
8
|
+
* debounce window.
|
|
9
|
+
*
|
|
10
|
+
* For SQLite workspaces this watches the DB file's parent directory and filters
|
|
11
|
+
* by file name. For non-SQLite backends (for example Dolt), this watches the
|
|
12
|
+
* workspace `.beads` directory.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} root_dir - Project root directory (starting point for resolution).
|
|
15
|
+
* @param {() => void} onChange - Called when changes are detected.
|
|
16
|
+
* @param {{ debounce_ms?: number, cooldown_ms?: number, explicit_db?: string }} [options]
|
|
17
|
+
* @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
|
|
18
|
+
*/
|
|
19
|
+
export function watchDb(root_dir, onChange, options = {}) {
|
|
20
|
+
const debounce_ms = options.debounce_ms ?? 250;
|
|
21
|
+
const cooldown_ms = options.cooldown_ms ?? 1000;
|
|
22
|
+
const log = debug('watcher');
|
|
23
|
+
|
|
24
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
25
|
+
let timer;
|
|
26
|
+
/** @type {fs.FSWatcher | undefined} */
|
|
27
|
+
let watcher;
|
|
28
|
+
let cooldown_until = 0;
|
|
29
|
+
let current_path = '';
|
|
30
|
+
let current_dir = '';
|
|
31
|
+
let current_file = '';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Schedule the debounced onChange callback.
|
|
35
|
+
*/
|
|
36
|
+
const schedule = () => {
|
|
37
|
+
if (timer) {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
timer = setTimeout(() => {
|
|
41
|
+
onChange();
|
|
42
|
+
cooldown_until = Date.now() + cooldown_ms;
|
|
43
|
+
}, debounce_ms);
|
|
44
|
+
timer.unref();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Attach a watcher to the directory containing the resolved DB path.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} base_dir
|
|
51
|
+
* @param {string | undefined} explicit_db
|
|
52
|
+
*/
|
|
53
|
+
const bind = (base_dir, explicit_db) => {
|
|
54
|
+
const resolved = resolveWorkspaceDatabase({ cwd: base_dir, explicit_db });
|
|
55
|
+
current_path = resolved.path;
|
|
56
|
+
if (pathIsDirectory(current_path)) {
|
|
57
|
+
current_dir = current_path;
|
|
58
|
+
current_file = '';
|
|
59
|
+
} else {
|
|
60
|
+
current_dir = path.dirname(current_path);
|
|
61
|
+
current_file = path.basename(current_path);
|
|
62
|
+
}
|
|
63
|
+
if (!resolved.exists) {
|
|
64
|
+
log(
|
|
65
|
+
'resolved workspace database missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
|
|
66
|
+
current_path
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// (Re)create watcher
|
|
71
|
+
try {
|
|
72
|
+
watcher = fs.watch(
|
|
73
|
+
current_dir,
|
|
74
|
+
{ persistent: true },
|
|
75
|
+
(event_type, filename) => {
|
|
76
|
+
if (current_file && filename && String(filename) !== current_file) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (event_type === 'change' || event_type === 'rename') {
|
|
80
|
+
if (Date.now() < cooldown_until) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
log('fs %s %s', event_type, filename || '');
|
|
84
|
+
schedule();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log('unable to watch directory %s %o', current_dir, err);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// initial bind
|
|
94
|
+
bind(root_dir, options.explicit_db);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
get path() {
|
|
98
|
+
return current_path;
|
|
99
|
+
},
|
|
100
|
+
close() {
|
|
101
|
+
if (timer) {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
timer = undefined;
|
|
104
|
+
}
|
|
105
|
+
watcher?.close();
|
|
106
|
+
},
|
|
107
|
+
/**
|
|
108
|
+
* Re-resolve and reattach watcher when root_dir or explicit_db changes.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ root_dir?: string, explicit_db?: string }} [opts]
|
|
111
|
+
*/
|
|
112
|
+
rebind(opts = {}) {
|
|
113
|
+
const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
|
|
114
|
+
const next_explicit = opts.explicit_db ?? options.explicit_db;
|
|
115
|
+
const next_resolved = resolveWorkspaceDatabase({
|
|
116
|
+
cwd: next_root,
|
|
117
|
+
explicit_db: next_explicit
|
|
118
|
+
});
|
|
119
|
+
const next_path = next_resolved.path;
|
|
120
|
+
if (next_path !== current_path) {
|
|
121
|
+
// swap watcher
|
|
122
|
+
watcher?.close();
|
|
123
|
+
cooldown_until = 0;
|
|
124
|
+
bind(next_root, next_explicit);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string} file_path
|
|
132
|
+
*/
|
|
133
|
+
function pathIsDirectory(file_path) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.statSync(file_path).isDirectory();
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { watchDb } from './watcher.js';
|
|
3
|
+
|
|
4
|
+
/** @type {{ dir: string, cb: (event: string, filename?: string) => void, w: { close: () => void } }[]} */
|
|
5
|
+
const watchers = [];
|
|
6
|
+
|
|
7
|
+
vi.mock('node:fs', () => {
|
|
8
|
+
const watch = vi.fn((dir, _opts, cb) => {
|
|
9
|
+
// Minimal event emitter interface for FSWatcher
|
|
10
|
+
const handlers = /** @type {{ close: Array<() => void> }} */ ({
|
|
11
|
+
close: []
|
|
12
|
+
});
|
|
13
|
+
const w = {
|
|
14
|
+
close: () => handlers.close.forEach((fn) => fn())
|
|
15
|
+
};
|
|
16
|
+
watchers.push({ dir, cb, w });
|
|
17
|
+
return /** @type {any} */ (w);
|
|
18
|
+
});
|
|
19
|
+
return { default: { watch }, watch };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
watchers.length = 0;
|
|
24
|
+
vi.useFakeTimers();
|
|
25
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('watchDb', () => {
|
|
29
|
+
test('debounces rapid change events', () => {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const handle = watchDb('/repo', () => calls.push(null), {
|
|
32
|
+
debounce_ms: 100,
|
|
33
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
34
|
+
});
|
|
35
|
+
expect(watchers.length).toBe(1);
|
|
36
|
+
const { cb } = watchers[0];
|
|
37
|
+
|
|
38
|
+
// Fire multiple changes in quick succession
|
|
39
|
+
cb('change', 'ui.db');
|
|
40
|
+
cb('change', 'ui.db');
|
|
41
|
+
cb('rename', 'ui.db');
|
|
42
|
+
|
|
43
|
+
// Nothing yet until debounce passes
|
|
44
|
+
expect(calls.length).toBe(0);
|
|
45
|
+
vi.advanceTimersByTime(99);
|
|
46
|
+
expect(calls.length).toBe(0);
|
|
47
|
+
vi.advanceTimersByTime(1);
|
|
48
|
+
expect(calls.length).toBe(1);
|
|
49
|
+
|
|
50
|
+
// Cleanup
|
|
51
|
+
handle.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('ignores other filenames', () => {
|
|
55
|
+
const calls = [];
|
|
56
|
+
const handle = watchDb('/repo', () => calls.push(null), {
|
|
57
|
+
debounce_ms: 50,
|
|
58
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
59
|
+
});
|
|
60
|
+
const { cb } = watchers[0];
|
|
61
|
+
cb('change', 'something-else.db');
|
|
62
|
+
vi.advanceTimersByTime(60);
|
|
63
|
+
expect(calls.length).toBe(0);
|
|
64
|
+
handle.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('rebind attaches to new db path', () => {
|
|
68
|
+
const calls = [];
|
|
69
|
+
const handle = watchDb('/repo', () => calls.push(null), {
|
|
70
|
+
debounce_ms: 50,
|
|
71
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
72
|
+
});
|
|
73
|
+
expect(watchers.length).toBe(1);
|
|
74
|
+
const first = watchers[0];
|
|
75
|
+
|
|
76
|
+
// Rebind to a different DB path
|
|
77
|
+
handle.rebind({ explicit_db: '/other/.beads/alt.db' });
|
|
78
|
+
|
|
79
|
+
// A new watcher is created
|
|
80
|
+
expect(watchers.length).toBe(2);
|
|
81
|
+
const second = watchers[1];
|
|
82
|
+
|
|
83
|
+
// Old watcher should ignore new file name
|
|
84
|
+
first.cb('change', 'ui.db');
|
|
85
|
+
vi.advanceTimersByTime(60);
|
|
86
|
+
expect(calls.length).toBe(0);
|
|
87
|
+
|
|
88
|
+
// New watcher reacts
|
|
89
|
+
second.cb('change', 'alt.db');
|
|
90
|
+
vi.advanceTimersByTime(60);
|
|
91
|
+
expect(calls.length).toBe(1);
|
|
92
|
+
|
|
93
|
+
handle.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('ignores changes during cooldown window', () => {
|
|
97
|
+
const calls = [];
|
|
98
|
+
const handle = watchDb('/repo', () => calls.push(null), {
|
|
99
|
+
debounce_ms: 10,
|
|
100
|
+
cooldown_ms: 100,
|
|
101
|
+
explicit_db: '/repo/.beads/ui.db'
|
|
102
|
+
});
|
|
103
|
+
const { cb } = watchers[0];
|
|
104
|
+
|
|
105
|
+
cb('change', 'ui.db');
|
|
106
|
+
vi.advanceTimersByTime(10);
|
|
107
|
+
expect(calls.length).toBe(1);
|
|
108
|
+
|
|
109
|
+
cb('change', 'ui.db');
|
|
110
|
+
vi.advanceTimersByTime(10);
|
|
111
|
+
expect(calls.length).toBe(1);
|
|
112
|
+
|
|
113
|
+
vi.advanceTimersByTime(100);
|
|
114
|
+
cb('change', 'ui.db');
|
|
115
|
+
vi.advanceTimersByTime(10);
|
|
116
|
+
expect(calls.length).toBe(2);
|
|
117
|
+
|
|
118
|
+
handle.close();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { getGitUserName, runBd, runBdJson } from './bd.js';
|
|
3
|
+
import { handleMessage } from './ws.js';
|
|
4
|
+
|
|
5
|
+
vi.mock('./bd.js', () => ({
|
|
6
|
+
runBd: vi.fn(),
|
|
7
|
+
runBdJson: vi.fn(),
|
|
8
|
+
getGitUserName: vi.fn()
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
function makeStubSocket() {
|
|
12
|
+
return {
|
|
13
|
+
sent: /** @type {string[]} */ ([]),
|
|
14
|
+
readyState: 1,
|
|
15
|
+
OPEN: 1,
|
|
16
|
+
/** @param {string} msg */
|
|
17
|
+
send(msg) {
|
|
18
|
+
this.sent.push(String(msg));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('get-comments handler', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('returns comments array on success', async () => {
|
|
29
|
+
const rj = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
30
|
+
const comments = [
|
|
31
|
+
{
|
|
32
|
+
id: 1,
|
|
33
|
+
issue_id: 'UI-1',
|
|
34
|
+
author: 'alice',
|
|
35
|
+
text: 'First comment',
|
|
36
|
+
created_at: '2025-01-01T00:00:00Z'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 2,
|
|
40
|
+
issue_id: 'UI-1',
|
|
41
|
+
author: 'bob',
|
|
42
|
+
text: 'Second comment',
|
|
43
|
+
created_at: '2025-01-02T00:00:00Z'
|
|
44
|
+
}
|
|
45
|
+
];
|
|
46
|
+
rj.mockResolvedValueOnce({ code: 0, stdoutJson: comments });
|
|
47
|
+
|
|
48
|
+
const ws = makeStubSocket();
|
|
49
|
+
await handleMessage(
|
|
50
|
+
/** @type {any} */ (ws),
|
|
51
|
+
Buffer.from(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
id: 'req-1',
|
|
54
|
+
type: /** @type {any} */ ('get-comments'),
|
|
55
|
+
payload: { id: 'UI-1' }
|
|
56
|
+
})
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(ws.sent.length).toBe(1);
|
|
61
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
62
|
+
expect(reply.ok).toBe(true);
|
|
63
|
+
expect(reply.payload).toEqual(comments);
|
|
64
|
+
|
|
65
|
+
// Verify bd was called with correct args
|
|
66
|
+
expect(rj).toHaveBeenCalledWith(['comments', 'UI-1', '--json']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('returns error when issue id missing', async () => {
|
|
70
|
+
const ws = makeStubSocket();
|
|
71
|
+
await handleMessage(
|
|
72
|
+
/** @type {any} */ (ws),
|
|
73
|
+
Buffer.from(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
id: 'req-2',
|
|
76
|
+
type: /** @type {any} */ ('get-comments'),
|
|
77
|
+
payload: {}
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(ws.sent.length).toBe(1);
|
|
83
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
84
|
+
expect(reply.ok).toBe(false);
|
|
85
|
+
expect(reply.error.code).toBe('bad_request');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('returns error when bd command fails', async () => {
|
|
89
|
+
const rj = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
90
|
+
rj.mockResolvedValueOnce({ code: 1, stderr: 'Issue not found' });
|
|
91
|
+
|
|
92
|
+
const ws = makeStubSocket();
|
|
93
|
+
await handleMessage(
|
|
94
|
+
/** @type {any} */ (ws),
|
|
95
|
+
Buffer.from(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
id: 'req-3',
|
|
98
|
+
type: /** @type {any} */ ('get-comments'),
|
|
99
|
+
payload: { id: 'UI-999' }
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(ws.sent.length).toBe(1);
|
|
105
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
106
|
+
expect(reply.ok).toBe(false);
|
|
107
|
+
expect(reply.error.code).toBe('bd_error');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('add-comment handler', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.clearAllMocks();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('adds comment with git author and returns updated comments', async () => {
|
|
117
|
+
const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
|
|
118
|
+
const rb = /** @type {import('vitest').Mock} */ (runBd);
|
|
119
|
+
const rj = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
120
|
+
|
|
121
|
+
// Mock git config user.name
|
|
122
|
+
gitUser.mockResolvedValueOnce('Test User');
|
|
123
|
+
// Mock bd comment command
|
|
124
|
+
rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
|
|
125
|
+
// Mock bd comments --json (returns updated list)
|
|
126
|
+
const updatedComments = [
|
|
127
|
+
{
|
|
128
|
+
id: 1,
|
|
129
|
+
issue_id: 'UI-1',
|
|
130
|
+
author: 'Test User',
|
|
131
|
+
text: 'New comment',
|
|
132
|
+
created_at: '2025-01-01T00:00:00Z'
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
rj.mockResolvedValueOnce({ code: 0, stdoutJson: updatedComments });
|
|
136
|
+
|
|
137
|
+
const ws = makeStubSocket();
|
|
138
|
+
await handleMessage(
|
|
139
|
+
/** @type {any} */ (ws),
|
|
140
|
+
Buffer.from(
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
id: 'req-4',
|
|
143
|
+
type: /** @type {any} */ ('add-comment'),
|
|
144
|
+
payload: { id: 'UI-1', text: 'New comment' }
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(ws.sent.length).toBe(1);
|
|
150
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
151
|
+
expect(reply.ok).toBe(true);
|
|
152
|
+
expect(reply.payload).toEqual(updatedComments);
|
|
153
|
+
|
|
154
|
+
// Verify bd was called with correct args including --author
|
|
155
|
+
expect(rb).toHaveBeenCalledWith([
|
|
156
|
+
'comment',
|
|
157
|
+
'UI-1',
|
|
158
|
+
'New comment',
|
|
159
|
+
'--author',
|
|
160
|
+
'Test User'
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('adds comment without author when git user name is empty', async () => {
|
|
165
|
+
const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
|
|
166
|
+
const rb = /** @type {import('vitest').Mock} */ (runBd);
|
|
167
|
+
const rj = /** @type {import('vitest').Mock} */ (runBdJson);
|
|
168
|
+
|
|
169
|
+
// Mock empty git user name
|
|
170
|
+
gitUser.mockResolvedValueOnce('');
|
|
171
|
+
// Mock bd comment command
|
|
172
|
+
rb.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' });
|
|
173
|
+
// Mock bd comments --json
|
|
174
|
+
rj.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
|
|
175
|
+
|
|
176
|
+
const ws = makeStubSocket();
|
|
177
|
+
await handleMessage(
|
|
178
|
+
/** @type {any} */ (ws),
|
|
179
|
+
Buffer.from(
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
id: 'req-5',
|
|
182
|
+
type: /** @type {any} */ ('add-comment'),
|
|
183
|
+
payload: { id: 'UI-1', text: 'Anonymous comment' }
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(ws.sent.length).toBe(1);
|
|
189
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
190
|
+
expect(reply.ok).toBe(true);
|
|
191
|
+
|
|
192
|
+
// Verify bd was called without --author
|
|
193
|
+
expect(rb).toHaveBeenCalledWith(['comment', 'UI-1', 'Anonymous comment']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('returns error when text is empty', async () => {
|
|
197
|
+
const ws = makeStubSocket();
|
|
198
|
+
await handleMessage(
|
|
199
|
+
/** @type {any} */ (ws),
|
|
200
|
+
Buffer.from(
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
id: 'req-6',
|
|
203
|
+
type: /** @type {any} */ ('add-comment'),
|
|
204
|
+
payload: { id: 'UI-1', text: '' }
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(ws.sent.length).toBe(1);
|
|
210
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
211
|
+
expect(reply.ok).toBe(false);
|
|
212
|
+
expect(reply.error.code).toBe('bad_request');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('returns error when id is missing', async () => {
|
|
216
|
+
const ws = makeStubSocket();
|
|
217
|
+
await handleMessage(
|
|
218
|
+
/** @type {any} */ (ws),
|
|
219
|
+
Buffer.from(
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
id: 'req-7',
|
|
222
|
+
type: /** @type {any} */ ('add-comment'),
|
|
223
|
+
payload: { text: 'Some text' }
|
|
224
|
+
})
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
expect(ws.sent.length).toBe(1);
|
|
229
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
230
|
+
expect(reply.ok).toBe(false);
|
|
231
|
+
expect(reply.error.code).toBe('bad_request');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('returns error when bd comment command fails', async () => {
|
|
235
|
+
const gitUser = /** @type {import('vitest').Mock} */ (getGitUserName);
|
|
236
|
+
const rb = /** @type {import('vitest').Mock} */ (runBd);
|
|
237
|
+
|
|
238
|
+
gitUser.mockResolvedValueOnce('Test User');
|
|
239
|
+
rb.mockResolvedValueOnce({
|
|
240
|
+
code: 1,
|
|
241
|
+
stdout: '',
|
|
242
|
+
stderr: 'Issue not found'
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const ws = makeStubSocket();
|
|
246
|
+
await handleMessage(
|
|
247
|
+
/** @type {any} */ (ws),
|
|
248
|
+
Buffer.from(
|
|
249
|
+
JSON.stringify({
|
|
250
|
+
id: 'req-8',
|
|
251
|
+
type: /** @type {any} */ ('add-comment'),
|
|
252
|
+
payload: { id: 'UI-999', text: 'Comment' }
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(ws.sent.length).toBe(1);
|
|
258
|
+
const reply = JSON.parse(ws.sent[0]);
|
|
259
|
+
expect(reply.ok).toBe(false);
|
|
260
|
+
expect(reply.error.code).toBe('bd_error');
|
|
261
|
+
});
|
|
262
|
+
});
|