@danypops/tickets 0.4.4 → 0.4.6
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/package.json +4 -2
- package/src/auth/token-store.ts +2 -2
- package/src/cli/index.ts +4 -1
- package/src/client/tickets-client.ts +29 -3
- package/src/config/config.ts +2 -2
- package/src/daemon/bootstrap.ts +17 -8
- package/src/daemon/focus.ts +1 -1
- package/src/daemon/ledger.ts +1 -1
- package/src/daemon/main.ts +3 -3
- package/src/daemon/poller.ts +2 -2
- package/src/daemon/server.ts +38 -11
- package/src/index.ts +2 -0
- package/src/vehicle/tickets-vehicle.ts +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/
|
|
27
|
+
"@danypops/vehicle-core": "^0.1.1",
|
|
28
|
+
"@danypops/vehicle-server": "^0.1.1",
|
|
29
|
+
"@danypops/vehicle-client": "^0.1.1",
|
|
28
30
|
"@danypops/enigma-client": "^0.3.0",
|
|
29
31
|
"@gitbeaker/rest": "^43.8.0",
|
|
30
32
|
"commander": "^12.1.0",
|
package/src/auth/token-store.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local, per-backend persistence for delegated OAuth tokens — separate from
|
|
3
|
-
*
|
|
3
|
+
* vehicle-server's own daemon-auth token (paths.token, which authenticates RPC
|
|
4
4
|
* callers to the daemon). This one holds what the daemon uses to authenticate
|
|
5
5
|
* *to* GitHub/GitLab/Jira on the user's behalf. Same security posture as
|
|
6
|
-
*
|
|
6
|
+
* vehicle-server's ensureAuthToken: 0700 directory, 0600 files, atomic write.
|
|
7
7
|
*/
|
|
8
8
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
package/src/cli/index.ts
CHANGED
|
@@ -40,12 +40,14 @@ program
|
|
|
40
40
|
.command("list")
|
|
41
41
|
.description("list issues on a backend")
|
|
42
42
|
.requiredOption("-b, --backend <name>", "backend name")
|
|
43
|
+
.option("--project <key>", "project key/id override (e.g. reach CNF or OCPBUGS on a Jira backend defaulting to another project)")
|
|
43
44
|
.option("--status <status>", "filter by status")
|
|
44
45
|
.option("--assignee <user>", "filter by assignee")
|
|
45
46
|
.option("--label <label...>", "filter by label(s)")
|
|
46
47
|
.option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
|
|
47
48
|
.action(async (opts) => {
|
|
48
49
|
const filter: ListFilter = {
|
|
50
|
+
project: opts.project,
|
|
49
51
|
status: opts.status ? parseStatus(opts.status) : undefined,
|
|
50
52
|
assignee: opts.assignee,
|
|
51
53
|
labels: opts.label,
|
|
@@ -107,9 +109,10 @@ program
|
|
|
107
109
|
.command("search <query>")
|
|
108
110
|
.description("search issues on a backend")
|
|
109
111
|
.requiredOption("-b, --backend <name>", "backend name")
|
|
112
|
+
.option("--project <key>", "project key/id override (e.g. reach CNF or OCPBUGS on a Jira backend defaulting to another project)")
|
|
110
113
|
.option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
|
|
111
114
|
.action(async (query: string, opts) => {
|
|
112
|
-
await withClient((client) => client.call("issue.search", { backend: opts.backend, query, limit: opts.limit }));
|
|
115
|
+
await withClient((client) => client.call("issue.search", { backend: opts.backend, query, limit: opts.limit, project: opts.project }));
|
|
113
116
|
});
|
|
114
117
|
|
|
115
118
|
program
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import type { DaemonHandle } from "@danypops/
|
|
11
|
-
import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/
|
|
12
|
-
import { AuthenticatedRpcClient } from "@danypops/
|
|
10
|
+
import type { DaemonHandle } from "@danypops/vehicle-server/paths";
|
|
11
|
+
import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
12
|
+
import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
|
|
13
13
|
import { packageRoot } from "../util/package-root.js";
|
|
14
14
|
import { TICKETS_DAEMON_NAMES, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "../daemon/ops.js";
|
|
15
15
|
|
|
@@ -83,6 +83,32 @@ export async function ensureDaemonRunning(
|
|
|
83
83
|
return { baseUrl: `http://${handle.host}:${handle.port}`, token };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export interface VehicleClientTarget {
|
|
87
|
+
/** Base URL for tickets' VehicleRegistry (see ../vehicle/tickets-vehicle.ts) -- @danypops/vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke, /vehicle/cancel routes under this. */
|
|
88
|
+
baseUrl: string;
|
|
89
|
+
token: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Narrow, side-effect-free surface for a Vehicle-projected domain consumer --
|
|
94
|
+
* same daemon, same handle file, same Bearer token every other tickets RPC
|
|
95
|
+
* call already uses (see daemon/server.ts's buildApp, which mounts the
|
|
96
|
+
* Vehicle HTTP app at /vehicle/* on this same port). Deliberately does NOT
|
|
97
|
+
* call ensureDaemonRunning: that spawns the daemon and mints a fresh auth
|
|
98
|
+
* token file as a side effect, which is wrong to do just from a Pi
|
|
99
|
+
* extension loading and registering its tool schemas -- only reads the
|
|
100
|
+
* handle if the daemon has already started, mirroring how Papyrus's own
|
|
101
|
+
* resolveVehicleClientTarget() tolerates "never started" by returning
|
|
102
|
+
* undefined rather than throwing or spawning.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveVehicleClientTarget(env?: Record<string, string | undefined>): VehicleClientTarget | undefined {
|
|
105
|
+
const paths = ticketsPaths(env);
|
|
106
|
+
const handle = readDaemonHandle(paths.handle);
|
|
107
|
+
if (!handle) return undefined;
|
|
108
|
+
const token = ensureAuthToken(paths.token, "Tickets");
|
|
109
|
+
return { baseUrl: `http://${handle.host}:${handle.port}`, token };
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
export type TicketsRpcClient = AuthenticatedRpcClient<TicketOperation, TicketOpInputs, TicketOpOutputs>;
|
|
87
113
|
|
|
88
114
|
export async function createTicketsClient(opts: EnsureDaemonOptions = {}): Promise<TicketsRpcClient> {
|
package/src/config/config.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
|
-
import type { MaintenanceTask } from "@danypops/
|
|
11
|
-
import type { Logger } from "@danypops/
|
|
10
|
+
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
11
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
12
12
|
import { GitHubRepository } from "../adapters/github.js";
|
|
13
13
|
import { GitLabRepository } from "../adapters/gitlab.js";
|
|
14
14
|
import { JiraRepository } from "../adapters/jira.js";
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Composition root for the tickets daemon: wires paths, auth token, ledger
|
|
3
3
|
* storage, real backend repositories, and the sync poller into the options
|
|
4
|
-
*
|
|
4
|
+
* vehicle-server's startDaemon()/runDaemonProcess() expect. Everything here is
|
|
5
5
|
* injectable so tests can substitute fake repositories and a scratch XDG
|
|
6
6
|
* root instead of hitting real GitHub/GitLab/Jira or the real home directory.
|
|
7
7
|
*/
|
|
8
8
|
import type { Database } from "bun:sqlite";
|
|
9
|
-
import { createLogger, type Logger } from "@danypops/
|
|
10
|
-
import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/
|
|
11
|
-
import { checkpoint, openSqliteWithPragmas } from "@danypops/
|
|
12
|
-
import type { StartDaemonOptions } from "@danypops/
|
|
9
|
+
import { createLogger, type Logger } from "@danypops/vehicle-server/logging";
|
|
10
|
+
import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
|
|
11
|
+
import { checkpoint, openSqliteWithPragmas } from "@danypops/vehicle-server/storage";
|
|
12
|
+
import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
|
|
13
13
|
import { TicketService } from "../application/service.js";
|
|
14
14
|
import { buildRepositories, type BuildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
|
|
15
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
16
|
import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
|
|
17
17
|
import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
|
|
18
18
|
import { TICKETS_DAEMON_NAMES } from "./ops.js";
|
|
19
|
-
import { buildApp } from "./server.js";
|
|
19
|
+
import { buildApp, type TicketsAppDeps } from "./server.js";
|
|
20
20
|
import { createSyncTask } from "./poller.js";
|
|
21
|
+
import { createTicketsVehicleRegistry } from "../vehicle/tickets-vehicle.js";
|
|
21
22
|
|
|
22
23
|
export interface BootstrapOptions {
|
|
23
24
|
pathEnv?: PathEnvironment;
|
|
@@ -38,7 +39,7 @@ export interface BootstrapOptions {
|
|
|
38
39
|
backendRefreshIntervalMs?: number;
|
|
39
40
|
/**
|
|
40
41
|
* Overrides the daemon.shutdown op's effect. Defaults to sending this
|
|
41
|
-
* process SIGTERM, which
|
|
42
|
+
* process SIGTERM, which vehicle-server's runDaemonProcess already handles
|
|
42
43
|
* with a tested graceful stop (see main.ts). Tests override this instead
|
|
43
44
|
* of self-signaling the test runner's own process.
|
|
44
45
|
*/
|
|
@@ -69,6 +70,13 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
69
70
|
const repos = opts.repos ?? (await buildRepos(config));
|
|
70
71
|
const service = new TicketService(repos);
|
|
71
72
|
const version = opts.version ?? "0.0.0-dev";
|
|
73
|
+
const onShutdownRequested = opts.onShutdownRequested ?? (() => process.kill(process.pid, "SIGTERM"));
|
|
74
|
+
|
|
75
|
+
// Built from the same base deps buildApp's TicketsAppDeps describes, minus
|
|
76
|
+
// the registry field itself -- createTicketsVehicleRegistry never reads
|
|
77
|
+
// deps.vehicleRegistry, so this ordering is safe (see server.ts's own
|
|
78
|
+
// comment on why the registry is built outside it, not imported into it).
|
|
79
|
+
const vehicleRegistry = createTicketsVehicleRegistry({ service, ledger, focusStore, token, version, logger, onShutdownRequested } as TicketsAppDeps);
|
|
72
80
|
|
|
73
81
|
const options: StartDaemonOptions = {
|
|
74
82
|
daemonLabel: "Tickets",
|
|
@@ -95,7 +103,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
95
103
|
token,
|
|
96
104
|
version,
|
|
97
105
|
logger,
|
|
98
|
-
onShutdownRequested
|
|
106
|
+
onShutdownRequested,
|
|
107
|
+
vehicleRegistry,
|
|
99
108
|
}),
|
|
100
109
|
onShutdown: () => {
|
|
101
110
|
db.close();
|
package/src/daemon/focus.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ticket in real life replaces what you were just looking at.
|
|
10
10
|
*/
|
|
11
11
|
import type { Database } from "bun:sqlite";
|
|
12
|
-
import type { Migration } from "@danypops/
|
|
12
|
+
import type { Migration } from "@danypops/vehicle-server/storage";
|
|
13
13
|
|
|
14
14
|
export const FOCUS_MIGRATIONS: Migration[] = [
|
|
15
15
|
{
|
package/src/daemon/ledger.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* of any live upstream call.
|
|
8
8
|
*/
|
|
9
9
|
import type { Database } from "bun:sqlite";
|
|
10
|
-
import type { Migration } from "@danypops/
|
|
10
|
+
import type { Migration } from "@danypops/vehicle-server/storage";
|
|
11
11
|
import type { Issue } from "../domain/issue.js";
|
|
12
12
|
|
|
13
13
|
export const LEDGER_MIGRATIONS: Migration[] = [
|
package/src/daemon/main.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
3
|
* The real tickets-daemon binary. Requires Bun (bun:sqlite, Bun.serve via
|
|
4
|
-
*
|
|
4
|
+
* vehicle-server). Everything else in this package (the library, the CLI, the
|
|
5
5
|
* pi-tickets extension) is plain Node-compatible TypeScript and talks to
|
|
6
6
|
* this process only over the loopback HTTP RPC surface — see client.ts.
|
|
7
7
|
*/
|
|
8
|
-
import { runDaemonProcess } from "@danypops/
|
|
9
|
-
import { readPackageVersion } from "@danypops/
|
|
8
|
+
import { runDaemonProcess } from "@danypops/vehicle-server/daemon";
|
|
9
|
+
import { readPackageVersion } from "@danypops/vehicle-server/version";
|
|
10
10
|
import { bootstrap } from "./bootstrap.js";
|
|
11
11
|
|
|
12
12
|
const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
|
package/src/daemon/poller.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* network, bad creds) is logged and skipped; it never crashes the daemon
|
|
6
6
|
* and never blocks other backends' syncs.
|
|
7
7
|
*/
|
|
8
|
-
import type { Logger } from "@danypops/
|
|
9
|
-
import type { MaintenanceTask } from "@danypops/
|
|
8
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
9
|
+
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
10
10
|
import type { TicketService } from "../application/service.js";
|
|
11
11
|
import type { Ledger } from "./ledger.js";
|
|
12
12
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon HTTP surface: Bearer-token auth, /health, /ready,
|
|
3
|
-
* dispatch endpoint (/api/v1/ops) per
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Daemon HTTP surface: Bearer-token auth, /health, /ready, a single
|
|
3
|
+
* dispatch endpoint (/api/v1/ops) per vehicle-server's http.ts convention,
|
|
4
|
+
* and a VehicleRegistry (see ../vehicle/tickets-vehicle.ts) mounted at
|
|
5
|
+
* /vehicle/* -- same daemon, same auth, same port, not a second service to
|
|
6
|
+
* stand up. Every operation here has a CLI command (cli/index.ts) and a
|
|
7
|
+
* pi-tickets tool action — no operation exists only for one caller.
|
|
6
8
|
*/
|
|
7
|
-
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/
|
|
8
|
-
import type { Logger } from "@danypops/
|
|
9
|
+
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
|
|
10
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
11
|
+
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
12
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
9
13
|
import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
|
|
10
14
|
import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
|
|
11
15
|
import { parseRef } from "../domain/issue.js";
|
|
@@ -23,19 +27,36 @@ export interface TicketsAppDeps {
|
|
|
23
27
|
/**
|
|
24
28
|
* Invoked by the `daemon.shutdown` op, after the HTTP response is already
|
|
25
29
|
* queued to flush. Defaults set by bootstrap.ts self-signal the process so
|
|
26
|
-
* the same tested SIGINT/SIGTERM path (
|
|
30
|
+
* the same tested SIGINT/SIGTERM path (vehicle-server's runDaemonProcess) does
|
|
27
31
|
* the actual graceful stop — this hook only ever *requests* shutdown, it
|
|
28
32
|
* never calls process.exit directly.
|
|
29
33
|
*/
|
|
30
34
|
onShutdownRequested?: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Built by ../vehicle/tickets-vehicle.ts's createTicketsVehicleRegistry,
|
|
37
|
+
* from the same base deps this interface describes -- passed in rather
|
|
38
|
+
* than built here to avoid a server.ts <-> tickets-vehicle.ts import cycle
|
|
39
|
+
* (tickets-vehicle.ts already imports TICKET_OP_HANDLERS and this type
|
|
40
|
+
* from this file).
|
|
41
|
+
*/
|
|
42
|
+
vehicleRegistry: VehicleRegistry;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
// Narrower than TicketsAppDeps on purpose: no real handler reads
|
|
46
|
+
// deps.vehicleRegistry, and vehicle/tickets-vehicle.ts's own registry
|
|
47
|
+
// builder needs to call these before a registry exists to put there.
|
|
48
|
+
export type Handler<Op extends TicketOperation> = (
|
|
49
|
+
deps: Omit<TicketsAppDeps, "vehicleRegistry">,
|
|
35
50
|
input: TicketOpInputs[Op],
|
|
36
51
|
) => Promise<TicketOpOutputs[Op]>;
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The one real implementation of every ticket operation, shared verbatim by
|
|
55
|
+
* the hand-rolled /api/v1/ops dispatch below and vehicle/tickets-vehicle.ts's
|
|
56
|
+
* VehicleRegistry projection -- never reimplemented a second time for the
|
|
57
|
+
* newer surface.
|
|
58
|
+
*/
|
|
59
|
+
export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
39
60
|
"backends.list": async (deps) => ({ backends: deps.service.backends() }),
|
|
40
61
|
"issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
|
|
41
62
|
"issue.get": async (deps, input) => ({ issue: await deps.service.get(input.ref) }),
|
|
@@ -88,11 +109,17 @@ function statusFor(error: unknown): number {
|
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
export function buildApp(deps: TicketsAppDeps): { fetch(request: Request): Promise<Response> } {
|
|
112
|
+
// Same Bearer token as the rest of this API -- the Vehicle-projected
|
|
113
|
+
// domain (see ../vehicle/tickets-vehicle.ts) rides the same daemon, same
|
|
114
|
+
// auth, same port; it is not a second service to stand up or
|
|
115
|
+
// authenticate against separately.
|
|
116
|
+
const vehicleApp = createVehicleHttpApp({ registry: deps.vehicleRegistry, token: deps.token });
|
|
91
117
|
return {
|
|
92
118
|
async fetch(request: Request): Promise<Response> {
|
|
93
119
|
const url = new URL(request.url);
|
|
94
120
|
|
|
95
121
|
if (!requireBearerToken(request, deps.token)) return errorResponse("unauthorized", 401);
|
|
122
|
+
if (url.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(request);
|
|
96
123
|
if (request.method === "GET" && url.pathname === "/health") return healthResponse(deps.version);
|
|
97
124
|
if (request.method === "GET" && url.pathname === "/ready") return readyResponse(true);
|
|
98
125
|
|
|
@@ -106,7 +133,7 @@ export function buildApp(deps: TicketsAppDeps): { fetch(request: Request): Promi
|
|
|
106
133
|
return errorResponse("invalid JSON body", 400);
|
|
107
134
|
}
|
|
108
135
|
if (!isTicketOperation(body.op)) return errorResponse(`unknown op: ${String(body.op)}`, 400);
|
|
109
|
-
const handler =
|
|
136
|
+
const handler = TICKET_OP_HANDLERS[body.op] as Handler<TicketOperation>;
|
|
110
137
|
try {
|
|
111
138
|
const result = await handler(deps, (body.input ?? {}) as never);
|
|
112
139
|
return jsonResponse({ result });
|
package/src/index.ts
CHANGED
|
@@ -18,9 +18,11 @@ export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
|
|
|
18
18
|
export {
|
|
19
19
|
createTicketsClient,
|
|
20
20
|
ensureDaemonRunning,
|
|
21
|
+
resolveVehicleClientTarget,
|
|
21
22
|
ticketsPaths,
|
|
22
23
|
type EnsureDaemonOptions,
|
|
23
24
|
type TicketsRpcClient,
|
|
25
|
+
type VehicleClientTarget,
|
|
24
26
|
} from "./client/tickets-client.js";
|
|
25
27
|
|
|
26
28
|
// Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every real ticket operation projected as its own VehicleRegistry entry,
|
|
3
|
+
* one per TicketOperation, instead of pi-tickets' hand-rolled
|
|
4
|
+
* `tickets(action=X)` mega-tool. Operation names are already dotted
|
|
5
|
+
* (issue.list, focus.set, discover.fields, ...) in daemon/ops.ts -- Vehicle's
|
|
6
|
+
* tool-name projection turns each into its own Pi tool (issue_list,
|
|
7
|
+
* focus_set, discover_fields, ...) with zero renaming needed.
|
|
8
|
+
*
|
|
9
|
+
* Delegates every operation to daemon/server.ts's TICKET_OP_HANDLERS, the
|
|
10
|
+
* exact same implementation the existing /api/v1/ops dispatch calls -- this
|
|
11
|
+
* is a projection/contract layer on top of the existing application logic,
|
|
12
|
+
* not a second copy of it.
|
|
13
|
+
*
|
|
14
|
+
* daemon.shutdown is deliberately excluded: it's an admin/lifecycle
|
|
15
|
+
* operation, not something an agent should be able to call as a tool.
|
|
16
|
+
*/
|
|
17
|
+
import { bindVehicleOperation, defineLooseObjectSchema, defineVehicleOperation, passthroughVehicleSchema, type VehicleEffect, type LooseObjectProperty } from "@danypops/vehicle-core";
|
|
18
|
+
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
19
|
+
import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../daemon/server.js";
|
|
20
|
+
import type { TicketOperation } from "../daemon/ops.js";
|
|
21
|
+
|
|
22
|
+
const OWNER = "tickets";
|
|
23
|
+
|
|
24
|
+
const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
25
|
+
|
|
26
|
+
const stringProp: LooseObjectProperty = { type: "string" };
|
|
27
|
+
const numberProp: LooseObjectProperty = { type: "number" };
|
|
28
|
+
|
|
29
|
+
interface OperationSpec {
|
|
30
|
+
readonly action: TicketOperation;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly effect: VehicleEffect;
|
|
33
|
+
readonly properties: Record<string, LooseObjectProperty>;
|
|
34
|
+
readonly required: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const OPERATIONS: readonly OperationSpec[] = [
|
|
38
|
+
{ action: "backends.list", description: "Lists every configured backend name (github, gitlab, jira, ...).", effect: "read", properties: {}, required: [] },
|
|
39
|
+
{ action: "issue.list", description: "Lists issues from one backend, optionally filtered.", effect: "read", properties: { backend: stringProp, filter: { type: "object" } }, required: ["backend"] },
|
|
40
|
+
{ action: "issue.get", description: "Gets one issue by its ref (e.g. \"github:#42\").", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
|
|
41
|
+
{
|
|
42
|
+
action: "issue.create",
|
|
43
|
+
description: "Creates a new issue on a live backend -- a real, externally visible write, not a local draft.",
|
|
44
|
+
effect: "external-write",
|
|
45
|
+
properties: { backend: stringProp, input: { type: "object" } },
|
|
46
|
+
required: ["backend", "input"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
action: "issue.update",
|
|
50
|
+
description: "Updates an existing issue on its live backend -- a real, externally visible write.",
|
|
51
|
+
effect: "external-write",
|
|
52
|
+
properties: { ref: stringProp, input: { type: "object" } },
|
|
53
|
+
required: ["ref", "input"],
|
|
54
|
+
},
|
|
55
|
+
{ action: "issue.search", description: "Searches one backend's issues by text query.", effect: "read", properties: { backend: stringProp, query: stringProp, limit: numberProp, project: stringProp }, required: ["backend", "query"] },
|
|
56
|
+
{ action: "issue.children", description: "Lists an issue's child issues.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
|
|
57
|
+
{ action: "issue.comments", description: "Lists an issue's comments.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
|
|
58
|
+
{
|
|
59
|
+
action: "issue.comment_add",
|
|
60
|
+
description: "Adds a comment to an issue on its live backend -- a real, externally visible write.",
|
|
61
|
+
effect: "external-write",
|
|
62
|
+
properties: { ref: stringProp, body: stringProp },
|
|
63
|
+
required: ["ref", "body"],
|
|
64
|
+
},
|
|
65
|
+
{ action: "ledger.search", description: "Searches the local pooled-issue ledger (no live backend call).", effect: "read", properties: { query: stringProp, limit: numberProp }, required: ["query"] },
|
|
66
|
+
{ action: "ledger.stats", description: "Per-backend counts of issues pooled into the local ledger.", effect: "read", properties: {}, required: [] },
|
|
67
|
+
{ action: "focus.set", description: "Sets the currently focused issue, by ref.", effect: "local-write", properties: { ref: stringProp }, required: ["ref"] },
|
|
68
|
+
{ action: "focus.get", description: "Gets the currently focused issue, if any.", effect: "read", properties: {}, required: [] },
|
|
69
|
+
{ action: "focus.pause", description: "Pauses focus with an optional reason, without clearing it.", effect: "local-write", properties: { reason: stringProp }, required: [] },
|
|
70
|
+
{ action: "focus.unpause", description: "Resumes a paused focus.", effect: "local-write", properties: {}, required: [] },
|
|
71
|
+
{ action: "focus.clear", description: "Clears the currently focused issue.", effect: "local-write", properties: {}, required: [] },
|
|
72
|
+
{ action: "discover.fields", description: "Discovers a backend's custom field display names and IDs (Jira).", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
|
|
73
|
+
{ action: "discover.statuses", description: "Discovers a backend's real status names.", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
|
|
74
|
+
{
|
|
75
|
+
action: "discover.template",
|
|
76
|
+
description: "Samples recent issues for a project/issueType and extracts a reusable description template (Jira).",
|
|
77
|
+
effect: "read",
|
|
78
|
+
properties: { backend: stringProp, project: stringProp, issueType: stringProp, sampleSize: numberProp },
|
|
79
|
+
required: ["backend", "project", "issueType"],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Builds a VehicleRegistry exposing every real ticket operation, backed by
|
|
85
|
+
* the exact same deps shape the daemon's own hand-rolled dispatch already
|
|
86
|
+
* uses -- minus vehicleRegistry itself, which doesn't exist yet while this
|
|
87
|
+
* is being built (bootstrap.ts constructs the full TicketsAppDeps by adding
|
|
88
|
+
* this registry to the same base object afterward).
|
|
89
|
+
*/
|
|
90
|
+
export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicleRegistry">): VehicleRegistry {
|
|
91
|
+
const registry = new VehicleRegistry({ name: "tickets", version: "1.0.0", description: "Unified issue tracking across GitHub, GitLab, and Jira." });
|
|
92
|
+
|
|
93
|
+
for (const spec of OPERATIONS) {
|
|
94
|
+
const operation = defineVehicleOperation({
|
|
95
|
+
name: spec.action,
|
|
96
|
+
version: 1,
|
|
97
|
+
description: spec.description,
|
|
98
|
+
input: defineLooseObjectSchema(spec.properties, spec.required),
|
|
99
|
+
output: passthroughVehicleSchema,
|
|
100
|
+
permissions: ["tickets:read", "tickets:write"],
|
|
101
|
+
effect: spec.effect,
|
|
102
|
+
idempotency: { mode: spec.effect === "read" ? "safe" : "unsafe" },
|
|
103
|
+
limits: LIMITS,
|
|
104
|
+
});
|
|
105
|
+
const handler = TICKET_OP_HANDLERS[spec.action];
|
|
106
|
+
registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => handler(deps, context.input as never)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return registry;
|
|
110
|
+
}
|