@mattstack/rt-client 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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/client.d.ts +15 -0
- package/dist/commands.d.ts +68 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +114 -0
- package/dist/relay.d.ts +15 -0
- package/dist/repos.d.ts +7 -0
- package/dist/transport.d.ts +14 -0
- package/package.json +41 -0
- package/src/client.ts +54 -0
- package/src/commands.ts +56 -0
- package/src/index.ts +22 -0
- package/src/relay.ts +61 -0
- package/src/repos.ts +34 -0
- package/src/transport.ts +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Goodwin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @mattstack/rt-client
|
|
2
|
+
|
|
3
|
+
Typed client for the [rt](https://rt.cool) daemon.
|
|
4
|
+
|
|
5
|
+
rt keeps the per-repo state a dev environment needs: worktrees, assigned ports,
|
|
6
|
+
tokens, dev servers, and a live event relay. This package is how other programs
|
|
7
|
+
read and drive that state without shelling out to the CLI and parsing text.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mattstack/rt-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { rtCommand, readProjectMRs } from '@mattstack/rt-client';
|
|
15
|
+
|
|
16
|
+
const repos = await rtCommand(['repos', 'list']);
|
|
17
|
+
const mrs = await readProjectMRs('group/repo');
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires a running rt daemon. Install rt with `brew install m4ttheweric/tap/rt`,
|
|
21
|
+
then `rt verify`.
|
|
22
|
+
|
|
23
|
+
`@mattstack/glance` is a peer dependency: rt-client returns glance's forge types
|
|
24
|
+
so merge request shapes stay identical across rt, gitq, and mr-board.
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
2
|
+
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData } from "./commands.ts";
|
|
3
|
+
/**
|
|
4
|
+
* One repo's project open-MR store. A cold repo forces a full paginated sync
|
|
5
|
+
* on the daemon side when maxAgeMs demands it, which can run tens of seconds
|
|
6
|
+
* on a big project -- hence the long timeout.
|
|
7
|
+
*
|
|
8
|
+
* `demand` tells the daemon what this caller needs covered (client id, the
|
|
9
|
+
* authors it cares about, when it declared that need), so the daemon can
|
|
10
|
+
* size the sync to actually cover it rather than trusting the store's
|
|
11
|
+
* self-reported source.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readProjectMRs(repoName: string, maxAgeMs?: number, opts?: RtClientOptions, demand?: DemandDecl): Promise<RtResponse<ProjectMRsData>>;
|
|
14
|
+
export declare function readDiscussions(repoName: string, iid: number, opts?: RtClientOptions): Promise<RtResponse<DiscussionsData>>;
|
|
15
|
+
export declare function readMrsByBranch(repoName: string, branches: string[], opts?: RtClientOptions): Promise<RtResponse<MrByBranchData>>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed command catalog for the rt daemon: one entry per command name,
|
|
3
|
+
* pairing its payload shape with its response data shape. `client.ts` builds
|
|
4
|
+
* its functions against this map so a new command only needs an entry here
|
|
5
|
+
* plus one function, never a change to the transport itself.
|
|
6
|
+
*/
|
|
7
|
+
import type { PullRequest, MRDetail } from "@mattstack/glance";
|
|
8
|
+
export type Discussion = MRDetail["discussions"][number];
|
|
9
|
+
/** What a caller declares it needs, so the daemon can size the sync to cover it. */
|
|
10
|
+
export interface DemandDecl {
|
|
11
|
+
client: string;
|
|
12
|
+
authors: string[];
|
|
13
|
+
declaredAt: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ProjectMRsScope {
|
|
16
|
+
authors: string[];
|
|
17
|
+
windowDays: number;
|
|
18
|
+
uncovered: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface ProjectMRsData {
|
|
21
|
+
mrs: Record<string, {
|
|
22
|
+
pr: PullRequest;
|
|
23
|
+
fetchedAt: number;
|
|
24
|
+
}>;
|
|
25
|
+
listSyncedAt: number;
|
|
26
|
+
source: "poll" | "events" | "mutation";
|
|
27
|
+
syncedAt: number;
|
|
28
|
+
scope?: ProjectMRsScope;
|
|
29
|
+
}
|
|
30
|
+
export interface DiscussionsData {
|
|
31
|
+
discussions: Discussion[];
|
|
32
|
+
fetchedAt: number;
|
|
33
|
+
stale?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface MrByBranchEntry {
|
|
36
|
+
pr: PullRequest;
|
|
37
|
+
source: "store" | "forge";
|
|
38
|
+
}
|
|
39
|
+
export interface MrByBranchData {
|
|
40
|
+
byBranch: Record<string, MrByBranchEntry | null>;
|
|
41
|
+
syncedAt: number;
|
|
42
|
+
}
|
|
43
|
+
export interface Commands {
|
|
44
|
+
"project-mrs:read": {
|
|
45
|
+
payload: {
|
|
46
|
+
repoName: string;
|
|
47
|
+
maxAgeMs?: number;
|
|
48
|
+
demand?: DemandDecl;
|
|
49
|
+
};
|
|
50
|
+
data: ProjectMRsData;
|
|
51
|
+
};
|
|
52
|
+
"discussions:read": {
|
|
53
|
+
payload: {
|
|
54
|
+
repoName: string;
|
|
55
|
+
iid: number;
|
|
56
|
+
};
|
|
57
|
+
data: DiscussionsData;
|
|
58
|
+
};
|
|
59
|
+
"mr:by-branch": {
|
|
60
|
+
payload: {
|
|
61
|
+
repoName: string;
|
|
62
|
+
branches: string[];
|
|
63
|
+
};
|
|
64
|
+
data: MrByBranchData;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export type CommandName = keyof Commands;
|
|
68
|
+
export declare const COMMAND_NAMES: readonly CommandName[];
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
|
|
2
|
+
export type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
3
|
+
export { readProjectMRs, readDiscussions, readMrsByBranch } from "./client.ts";
|
|
4
|
+
export { COMMAND_NAMES } from "./commands.ts";
|
|
5
|
+
export type { Discussion, DemandDecl, ProjectMRsScope, ProjectMRsData, DiscussionsData, MrByBranchEntry, MrByBranchData, Commands, CommandName, } from "./commands.ts";
|
|
6
|
+
export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
|
|
7
|
+
export type { RelayEventType } from "./relay.ts";
|
|
8
|
+
export { repoNameForPath } from "./repos.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/transport.ts
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
var DEFAULT_SOCK = join(homedir(), ".rt", "rt.sock");
|
|
5
|
+
async function rtCommand(cmd, payload, opts = {}) {
|
|
6
|
+
const sockPath = opts.sockPath ?? DEFAULT_SOCK;
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(`http://localhost/${cmd}`, {
|
|
9
|
+
unix: sockPath,
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify(payload),
|
|
13
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15000)
|
|
14
|
+
});
|
|
15
|
+
return await res.json();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
18
|
+
return { ok: false, error: `rt daemon unreachable at ${sockPath}: ${msg}` };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// src/client.ts
|
|
22
|
+
function readProjectMRs(repoName, maxAgeMs, opts = {}, demand) {
|
|
23
|
+
const payload = { repoName };
|
|
24
|
+
if (maxAgeMs !== undefined)
|
|
25
|
+
payload.maxAgeMs = maxAgeMs;
|
|
26
|
+
if (demand !== undefined)
|
|
27
|
+
payload.demand = demand;
|
|
28
|
+
return rtCommand("project-mrs:read", payload, { sockPath: opts.sockPath, timeoutMs: 90000 });
|
|
29
|
+
}
|
|
30
|
+
function readDiscussions(repoName, iid, opts = {}) {
|
|
31
|
+
return rtCommand("discussions:read", { repoName, iid }, { sockPath: opts.sockPath, timeoutMs: 30000 });
|
|
32
|
+
}
|
|
33
|
+
function readMrsByBranch(repoName, branches, opts = {}) {
|
|
34
|
+
return rtCommand("mr:by-branch", { repoName, branches }, { sockPath: opts.sockPath, timeoutMs: 60000 });
|
|
35
|
+
}
|
|
36
|
+
// src/commands.ts
|
|
37
|
+
var COMMAND_NAMES = ["project-mrs:read", "discussions:read", "mr:by-branch"];
|
|
38
|
+
// src/relay.ts
|
|
39
|
+
var DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
|
|
40
|
+
function subscribe(onEvent, opts = {}) {
|
|
41
|
+
const url = opts.wsUrl ?? DEFAULT_WS_URL;
|
|
42
|
+
let ws = null;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
let attempt = 0;
|
|
45
|
+
let timer;
|
|
46
|
+
const connect = () => {
|
|
47
|
+
if (stopped)
|
|
48
|
+
return;
|
|
49
|
+
ws = new WebSocket(url);
|
|
50
|
+
ws.onopen = () => {
|
|
51
|
+
attempt = 0;
|
|
52
|
+
};
|
|
53
|
+
ws.onmessage = (ev) => {
|
|
54
|
+
try {
|
|
55
|
+
const frame = JSON.parse(String(ev.data));
|
|
56
|
+
if (typeof frame.type === "string")
|
|
57
|
+
onEvent(frame.type, frame.data);
|
|
58
|
+
} catch {}
|
|
59
|
+
};
|
|
60
|
+
ws.onclose = () => {
|
|
61
|
+
if (stopped)
|
|
62
|
+
return;
|
|
63
|
+
const delay = Math.min(30000, 1000 * 2 ** attempt++);
|
|
64
|
+
timer = setTimeout(connect, delay);
|
|
65
|
+
};
|
|
66
|
+
ws.onerror = () => {
|
|
67
|
+
try {
|
|
68
|
+
ws?.close();
|
|
69
|
+
} catch {}
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
connect();
|
|
73
|
+
return () => {
|
|
74
|
+
stopped = true;
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
try {
|
|
77
|
+
ws?.close();
|
|
78
|
+
} catch {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// src/repos.ts
|
|
82
|
+
import { existsSync, readFileSync } from "fs";
|
|
83
|
+
import { homedir as homedir2 } from "os";
|
|
84
|
+
import { join as join2 } from "path";
|
|
85
|
+
function defaultReposJsonPath() {
|
|
86
|
+
return join2(homedir2(), ".rt", "repos.json");
|
|
87
|
+
}
|
|
88
|
+
function repoNameForPath(repoPath, reposJsonPath) {
|
|
89
|
+
const path = reposJsonPath ?? defaultReposJsonPath();
|
|
90
|
+
try {
|
|
91
|
+
if (!existsSync(path))
|
|
92
|
+
return null;
|
|
93
|
+
const raw = readFileSync(path, "utf8");
|
|
94
|
+
const index = JSON.parse(raw);
|
|
95
|
+
for (const [repoName, value] of Object.entries(index)) {
|
|
96
|
+
if (value === repoPath)
|
|
97
|
+
return repoName;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export {
|
|
105
|
+
subscribe,
|
|
106
|
+
rtCommand,
|
|
107
|
+
repoNameForPath,
|
|
108
|
+
readProjectMRs,
|
|
109
|
+
readMrsByBranch,
|
|
110
|
+
readDiscussions,
|
|
111
|
+
DEFAULT_WS_URL,
|
|
112
|
+
DEFAULT_SOCK,
|
|
113
|
+
COMMAND_NAMES
|
|
114
|
+
};
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push events from the rt daemon's WebSocket relay (127.0.0.1:9401),
|
|
3
|
+
* `{ type, data, timestamp }` frames.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim from mr-board's src/rt-client.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { RtClientOptions } from "./transport.ts";
|
|
8
|
+
export type RelayEventType = "project-mrs" | "discussions:update" | "discussions:new-comments" | "mr:status" | (string & {});
|
|
9
|
+
export declare const DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
|
|
10
|
+
/**
|
|
11
|
+
* Subscribe to the daemon's broadcast channel. Reconnects with capped
|
|
12
|
+
* exponential backoff (1s to 30s) until the returned stop function runs;
|
|
13
|
+
* the daemon being down just means silence, never a crash.
|
|
14
|
+
*/
|
|
15
|
+
export declare function subscribe(onEvent: (type: RelayEventType, data: unknown) => void, opts?: RtClientOptions): () => void;
|
package/dist/repos.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact-match lookup: returns the repo name whose recorded path equals
|
|
3
|
+
* `repoPath`, or null if the file is missing, corrupt, or has no match.
|
|
4
|
+
* Never throws -- a resolution failure just means the caller falls back to
|
|
5
|
+
* whatever it had before (an unqualified path, a prompt, etc).
|
|
6
|
+
*/
|
|
7
|
+
export declare function repoNameForPath(repoPath: string, reposJsonPath?: string): string | null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RtResponse<T = unknown> {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
data?: T;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RtClientOptions {
|
|
7
|
+
sockPath?: string;
|
|
8
|
+
wsUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_SOCK: string;
|
|
11
|
+
export declare function rtCommand<T = unknown>(cmd: string, payload: Record<string, unknown>, opts?: {
|
|
12
|
+
sockPath?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<RtResponse<T>>;
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mattstack/rt-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bun": "./src/index.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./test/fake-daemon.ts": "./test/fake-daemon.ts"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@mattstack/glance": ">=0.13.0"
|
|
16
|
+
},
|
|
17
|
+
"description": "Typed client for the rt daemon: repos, worktrees, ports, tokens, and the event relay",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/m4ttstack/rt.git",
|
|
22
|
+
"directory": "packages/rt-client"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://rt.cool",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/m4ttstack/rt/issues"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
|
|
36
|
+
"check-types": "tsc --noEmit -p tsconfig.json"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed reads against the rt daemon's command surface (see commands.ts).
|
|
3
|
+
* Every function degrades to `{ ok: false, error }` instead of throwing,
|
|
4
|
+
* inherited from rtCommand -- callers surface daemon-down verbatim.
|
|
5
|
+
*/
|
|
6
|
+
import { rtCommand } from "./transport.ts";
|
|
7
|
+
import type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
8
|
+
import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData } from "./commands.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One repo's project open-MR store. A cold repo forces a full paginated sync
|
|
12
|
+
* on the daemon side when maxAgeMs demands it, which can run tens of seconds
|
|
13
|
+
* on a big project -- hence the long timeout.
|
|
14
|
+
*
|
|
15
|
+
* `demand` tells the daemon what this caller needs covered (client id, the
|
|
16
|
+
* authors it cares about, when it declared that need), so the daemon can
|
|
17
|
+
* size the sync to actually cover it rather than trusting the store's
|
|
18
|
+
* self-reported source.
|
|
19
|
+
*/
|
|
20
|
+
export function readProjectMRs(
|
|
21
|
+
repoName: string,
|
|
22
|
+
maxAgeMs?: number,
|
|
23
|
+
opts: RtClientOptions = {},
|
|
24
|
+
demand?: DemandDecl,
|
|
25
|
+
): Promise<RtResponse<ProjectMRsData>> {
|
|
26
|
+
const payload: Record<string, unknown> = { repoName };
|
|
27
|
+
if (maxAgeMs !== undefined) payload.maxAgeMs = maxAgeMs;
|
|
28
|
+
if (demand !== undefined) payload.demand = demand;
|
|
29
|
+
return rtCommand<ProjectMRsData>("project-mrs:read", payload, { sockPath: opts.sockPath, timeoutMs: 90_000 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readDiscussions(
|
|
33
|
+
repoName: string,
|
|
34
|
+
iid: number,
|
|
35
|
+
opts: RtClientOptions = {},
|
|
36
|
+
): Promise<RtResponse<DiscussionsData>> {
|
|
37
|
+
return rtCommand<DiscussionsData>(
|
|
38
|
+
"discussions:read",
|
|
39
|
+
{ repoName, iid },
|
|
40
|
+
{ sockPath: opts.sockPath, timeoutMs: 30_000 },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readMrsByBranch(
|
|
45
|
+
repoName: string,
|
|
46
|
+
branches: string[],
|
|
47
|
+
opts: RtClientOptions = {},
|
|
48
|
+
): Promise<RtResponse<MrByBranchData>> {
|
|
49
|
+
return rtCommand<MrByBranchData>(
|
|
50
|
+
"mr:by-branch",
|
|
51
|
+
{ repoName, branches },
|
|
52
|
+
{ sockPath: opts.sockPath, timeoutMs: 60_000 },
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed command catalog for the rt daemon: one entry per command name,
|
|
3
|
+
* pairing its payload shape with its response data shape. `client.ts` builds
|
|
4
|
+
* its functions against this map so a new command only needs an entry here
|
|
5
|
+
* plus one function, never a change to the transport itself.
|
|
6
|
+
*/
|
|
7
|
+
import type { PullRequest, MRDetail } from "@mattstack/glance";
|
|
8
|
+
|
|
9
|
+
export type Discussion = MRDetail["discussions"][number];
|
|
10
|
+
|
|
11
|
+
/** What a caller declares it needs, so the daemon can size the sync to cover it. */
|
|
12
|
+
export interface DemandDecl {
|
|
13
|
+
client: string;
|
|
14
|
+
authors: string[];
|
|
15
|
+
declaredAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProjectMRsScope {
|
|
19
|
+
authors: string[];
|
|
20
|
+
windowDays: number;
|
|
21
|
+
uncovered: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProjectMRsData {
|
|
25
|
+
mrs: Record<string, { pr: PullRequest; fetchedAt: number }>;
|
|
26
|
+
listSyncedAt: number;
|
|
27
|
+
source: "poll" | "events" | "mutation";
|
|
28
|
+
syncedAt: number;
|
|
29
|
+
scope?: ProjectMRsScope;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DiscussionsData {
|
|
33
|
+
discussions: Discussion[];
|
|
34
|
+
fetchedAt: number;
|
|
35
|
+
stale?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MrByBranchEntry {
|
|
39
|
+
pr: PullRequest;
|
|
40
|
+
source: "store" | "forge";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MrByBranchData {
|
|
44
|
+
byBranch: Record<string, MrByBranchEntry | null>;
|
|
45
|
+
syncedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Commands {
|
|
49
|
+
"project-mrs:read": { payload: { repoName: string; maxAgeMs?: number; demand?: DemandDecl }; data: ProjectMRsData };
|
|
50
|
+
"discussions:read": { payload: { repoName: string; iid: number }; data: DiscussionsData };
|
|
51
|
+
"mr:by-branch": { payload: { repoName: string; branches: string[] }; data: MrByBranchData };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CommandName = keyof Commands;
|
|
55
|
+
|
|
56
|
+
export const COMMAND_NAMES: readonly CommandName[] = ["project-mrs:read", "discussions:read", "mr:by-branch"];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { rtCommand, DEFAULT_SOCK } from "./transport.ts";
|
|
2
|
+
export type { RtResponse, RtClientOptions } from "./transport.ts";
|
|
3
|
+
|
|
4
|
+
export { readProjectMRs, readDiscussions, readMrsByBranch } from "./client.ts";
|
|
5
|
+
|
|
6
|
+
export { COMMAND_NAMES } from "./commands.ts";
|
|
7
|
+
export type {
|
|
8
|
+
Discussion,
|
|
9
|
+
DemandDecl,
|
|
10
|
+
ProjectMRsScope,
|
|
11
|
+
ProjectMRsData,
|
|
12
|
+
DiscussionsData,
|
|
13
|
+
MrByBranchEntry,
|
|
14
|
+
MrByBranchData,
|
|
15
|
+
Commands,
|
|
16
|
+
CommandName,
|
|
17
|
+
} from "./commands.ts";
|
|
18
|
+
|
|
19
|
+
export { subscribe, DEFAULT_WS_URL } from "./relay.ts";
|
|
20
|
+
export type { RelayEventType } from "./relay.ts";
|
|
21
|
+
|
|
22
|
+
export { repoNameForPath } from "./repos.ts";
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push events from the rt daemon's WebSocket relay (127.0.0.1:9401),
|
|
3
|
+
* `{ type, data, timestamp }` frames.
|
|
4
|
+
*
|
|
5
|
+
* Ported verbatim from mr-board's src/rt-client.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { RtClientOptions } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
export type RelayEventType =
|
|
10
|
+
| "project-mrs"
|
|
11
|
+
| "discussions:update"
|
|
12
|
+
| "discussions:new-comments"
|
|
13
|
+
| "mr:status"
|
|
14
|
+
| (string & {});
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_WS_URL = "ws://127.0.0.1:9401/ws";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe to the daemon's broadcast channel. Reconnects with capped
|
|
20
|
+
* exponential backoff (1s to 30s) until the returned stop function runs;
|
|
21
|
+
* the daemon being down just means silence, never a crash.
|
|
22
|
+
*/
|
|
23
|
+
export function subscribe(
|
|
24
|
+
onEvent: (type: RelayEventType, data: unknown) => void,
|
|
25
|
+
opts: RtClientOptions = {},
|
|
26
|
+
): () => void {
|
|
27
|
+
const url = opts.wsUrl ?? DEFAULT_WS_URL;
|
|
28
|
+
let ws: WebSocket | null = null;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let attempt = 0;
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
|
|
33
|
+
const connect = () => {
|
|
34
|
+
if (stopped) return;
|
|
35
|
+
ws = new WebSocket(url);
|
|
36
|
+
ws.onopen = () => { attempt = 0; };
|
|
37
|
+
ws.onmessage = (ev) => {
|
|
38
|
+
try {
|
|
39
|
+
const frame = JSON.parse(String(ev.data)) as { type?: unknown; data?: unknown };
|
|
40
|
+
if (typeof frame.type === "string") onEvent(frame.type, frame.data);
|
|
41
|
+
} catch {
|
|
42
|
+
// Non-JSON frame; ignore.
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
ws.onclose = () => {
|
|
46
|
+
if (stopped) return;
|
|
47
|
+
const delay = Math.min(30_000, 1_000 * 2 ** attempt++);
|
|
48
|
+
timer = setTimeout(connect, delay);
|
|
49
|
+
};
|
|
50
|
+
ws.onerror = () => {
|
|
51
|
+
try { ws?.close(); } catch { /* already closed */ }
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
connect();
|
|
56
|
+
return () => {
|
|
57
|
+
stopped = true;
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
try { ws?.close(); } catch { /* already closed */ }
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/repos.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-name resolution against rt's global index (~/.rt/repos.json), a flat
|
|
3
|
+
* `{ "<repoName>": "<absolute path>" }` map. Lets a client resolve "what
|
|
4
|
+
* directory am I in" to "what does the daemon call this repo" without
|
|
5
|
+
* maintaining its own copy of the mapping.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
|
|
11
|
+
function defaultReposJsonPath(): string {
|
|
12
|
+
return join(homedir(), ".rt", "repos.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Exact-match lookup: returns the repo name whose recorded path equals
|
|
17
|
+
* `repoPath`, or null if the file is missing, corrupt, or has no match.
|
|
18
|
+
* Never throws -- a resolution failure just means the caller falls back to
|
|
19
|
+
* whatever it had before (an unqualified path, a prompt, etc).
|
|
20
|
+
*/
|
|
21
|
+
export function repoNameForPath(repoPath: string, reposJsonPath?: string): string | null {
|
|
22
|
+
const path = reposJsonPath ?? defaultReposJsonPath();
|
|
23
|
+
try {
|
|
24
|
+
if (!existsSync(path)) return null;
|
|
25
|
+
const raw = readFileSync(path, "utf8");
|
|
26
|
+
const index = JSON.parse(raw) as Record<string, unknown>;
|
|
27
|
+
for (const [repoName, value] of Object.entries(index)) {
|
|
28
|
+
if (value === repoPath) return repoName;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rt daemon transport: HTTP over a unix socket (`~/.rt/rt.sock`).
|
|
3
|
+
*
|
|
4
|
+
* POST http://localhost/<cmd> with a JSON payload, response envelope
|
|
5
|
+
* `{ ok, data?, error? }`. Every call degrades to `{ ok: false, error }`
|
|
6
|
+
* instead of throwing, so callers surface daemon-down verbatim.
|
|
7
|
+
*
|
|
8
|
+
* Ported verbatim from mr-board's src/rt-client.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
export interface RtResponse<T = unknown> {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
data?: T;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RtClientOptions {
|
|
20
|
+
sockPath?: string;
|
|
21
|
+
wsUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_SOCK = join(homedir(), ".rt", "rt.sock");
|
|
25
|
+
|
|
26
|
+
export async function rtCommand<T = unknown>(
|
|
27
|
+
cmd: string,
|
|
28
|
+
payload: Record<string, unknown>,
|
|
29
|
+
opts: { sockPath?: string; timeoutMs?: number } = {},
|
|
30
|
+
): Promise<RtResponse<T>> {
|
|
31
|
+
const sockPath = opts.sockPath ?? DEFAULT_SOCK;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`http://localhost/${cmd}`, {
|
|
34
|
+
unix: sockPath,
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify(payload),
|
|
38
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
|
|
39
|
+
// Bun's `unix` fetch option isn't in the standard RequestInit type.
|
|
40
|
+
} as RequestInit);
|
|
41
|
+
return (await res.json()) as RtResponse<T>;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return { ok: false, error: `rt daemon unreachable at ${sockPath}: ${msg}` };
|
|
45
|
+
}
|
|
46
|
+
}
|