@danypops/tickets 0.1.0 → 0.2.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/README.md +48 -4
- package/package.json +4 -2
- package/src/cli/index.ts +77 -1
- package/src/cli/systemd-service.ts +97 -0
- package/src/client/tickets-client.ts +7 -3
- package/src/daemon/bootstrap.ts +6 -2
- package/src/daemon/focus.ts +126 -0
- package/src/daemon/ops.ts +21 -0
- package/src/daemon/server.ts +20 -1
package/README.md
CHANGED
|
@@ -51,8 +51,37 @@ bun run src/cli/index.ts create -b github "Fix the thing" --label bug
|
|
|
51
51
|
bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
|
|
52
52
|
bun run src/cli/index.ts ledger search "login bug"
|
|
53
53
|
bun run src/cli/index.ts ledger stats
|
|
54
|
+
|
|
55
|
+
# Track the single ticket you're currently working on, with its full URL —
|
|
56
|
+
# survives daemon restarts, resolves the ref via the ledger first (no live
|
|
57
|
+
# call if it's already cached) and falls back to the backend otherwise.
|
|
58
|
+
bun run src/cli/index.ts focus set jira:PROJ-42
|
|
59
|
+
bun run src/cli/index.ts focus get
|
|
60
|
+
bun run src/cli/index.ts focus pause "waiting on review"
|
|
61
|
+
bun run src/cli/index.ts focus unpause
|
|
62
|
+
bun run src/cli/index.ts focus clear
|
|
54
63
|
```
|
|
55
64
|
|
|
65
|
+
### Running the daemon persistently (systemd --user)
|
|
66
|
+
|
|
67
|
+
`daemon start` spawns the daemon on demand and it lives only as long as
|
|
68
|
+
something keeps it alive. For a daemon that survives logout/reboot, install
|
|
69
|
+
it as a systemd `--user` service instead (Linux only):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun run src/cli/index.ts service install # writes + enables + (re)starts the unit
|
|
73
|
+
bun run src/cli/index.ts service status
|
|
74
|
+
bun run src/cli/index.ts service stop
|
|
75
|
+
bun run src/cli/index.ts service restart
|
|
76
|
+
bun run src/cli/index.ts service path # where the unit file lives
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`service install` points `ExecStart` at the exact `bun` binary and package
|
|
80
|
+
checkout currently running the CLI, so re-running it after an upgrade (a new
|
|
81
|
+
`npm`/`bun` global install, or a fresh checkout) picks up the new path
|
|
82
|
+
immediately via `daemon-reload` + `enable` + `restart` — no manual `stop`
|
|
83
|
+
needed first.
|
|
84
|
+
|
|
56
85
|
Once installed as a package, the same commands are available as `tickets`
|
|
57
86
|
and `tickets-daemon` (see `bin` in `package.json`).
|
|
58
87
|
|
|
@@ -139,11 +168,26 @@ up the new credential — `buildRepositories()` runs once at daemon startup.
|
|
|
139
168
|
`extensions/pi-tickets/` registers a single `tickets` tool for
|
|
140
169
|
[pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
|
|
141
170
|
`get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
|
|
142
|
-
`backends`, `ledger_search`, `ledger_stats`
|
|
171
|
+
`backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
|
|
172
|
+
`focus_pause`, `focus_unpause`, `focus_clear`). It talks to the same daemon
|
|
143
173
|
through the same authenticated RPC client the CLI uses — never a direct
|
|
144
|
-
backend call or a direct SQLite open. OAuth login
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
backend call or a direct SQLite open. OAuth login and daemon lifecycle
|
|
175
|
+
control are deliberately **not** exposed here (neither as a tool action nor
|
|
176
|
+
as the `/tickets` command below): approving OAuth access requires a human in
|
|
177
|
+
a browser, and stopping a shared daemon is an operational decision, not
|
|
178
|
+
something an LLM tool call or a casual keypress should trigger. Use
|
|
179
|
+
`tickets auth login`/`tickets daemon stop` from a terminal for those.
|
|
180
|
+
|
|
181
|
+
It also registers a `/tickets [query]` interactive TUI command (for the
|
|
182
|
+
human, not the LLM): a browsable list of every issue the daemon's ledger has
|
|
183
|
+
pooled across every configured backend in one flat list (no backend picker
|
|
184
|
+
needed). `↑↓` navigate, `enter` sets focus on the highlighted issue, `o`
|
|
185
|
+
opens its real web URL in a browser without closing the dialog, and `esc`
|
|
186
|
+
cancels. When a focus is already set, a "Clear current focus" row appears
|
|
187
|
+
first. A persistent footer status (`🎯 backend:key`, or `⏸` when paused)
|
|
188
|
+
shows the current focus at all times, refreshed on session start and after
|
|
189
|
+
every `tickets` tool call — so a focus the LLM sets via `focus_set` mid-
|
|
190
|
+
conversation shows up in the footer too, and vice versa.
|
|
147
191
|
|
|
148
192
|
To use it:
|
|
149
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
],
|
|
21
21
|
"scripts": {
|
|
22
22
|
"daemon": "bun run src/daemon/main.ts",
|
|
23
|
-
"test": "bun test",
|
|
23
|
+
"test": "bun test --path-ignore-patterns 'extensions/**'",
|
|
24
|
+
"test:extension": "cd extensions/pi-tickets && bun install && bun test",
|
|
25
|
+
"test:all": "npm run test && npm run test:extension",
|
|
24
26
|
"typecheck": "tsc --noEmit"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
|
|
|
14
14
|
import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
|
|
15
15
|
import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
|
|
16
16
|
import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
|
|
17
|
+
import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
|
|
17
18
|
|
|
18
19
|
function printJson(value: unknown): void {
|
|
19
20
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
@@ -31,7 +32,7 @@ async function withClient<T>(fn: (client: TicketsRpcClient) => Promise<T>): Prom
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
const program = new Command();
|
|
34
|
-
program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.
|
|
35
|
+
program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.2.0");
|
|
35
36
|
|
|
36
37
|
program
|
|
37
38
|
.command("list")
|
|
@@ -156,6 +157,43 @@ program
|
|
|
156
157
|
await withClient((client) => client.call("backends.list", {}));
|
|
157
158
|
});
|
|
158
159
|
|
|
160
|
+
const focus = program.command("focus").description("track the single ticket you're currently working on, with its full URL");
|
|
161
|
+
|
|
162
|
+
focus
|
|
163
|
+
.command("set <ref>")
|
|
164
|
+
.description('focus a ticket, e.g. "jira:PROJ-42" or "github:#7" (resolves and stores its real web URL)')
|
|
165
|
+
.action(async (ref: string) => {
|
|
166
|
+
await withClient((client) => client.call("focus.set", { ref }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
focus
|
|
170
|
+
.command("get")
|
|
171
|
+
.description("show the currently focused ticket, if any")
|
|
172
|
+
.action(async () => {
|
|
173
|
+
await withClient((client) => client.call("focus.get", {}));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
focus
|
|
177
|
+
.command("pause [reason]")
|
|
178
|
+
.description("pause the current focus without losing it (e.g. stepping away to do something else)")
|
|
179
|
+
.action(async (reason: string | undefined) => {
|
|
180
|
+
await withClient((client) => client.call("focus.pause", { reason }));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
focus
|
|
184
|
+
.command("unpause")
|
|
185
|
+
.description("resume a paused focus")
|
|
186
|
+
.action(async () => {
|
|
187
|
+
await withClient((client) => client.call("focus.unpause", {}));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
focus
|
|
191
|
+
.command("clear")
|
|
192
|
+
.description("clear the current focus")
|
|
193
|
+
.action(async () => {
|
|
194
|
+
await withClient((client) => client.call("focus.clear", {}));
|
|
195
|
+
});
|
|
196
|
+
|
|
159
197
|
const daemon = program.command("daemon").description("manage the tickets daemon process");
|
|
160
198
|
|
|
161
199
|
daemon
|
|
@@ -218,6 +256,44 @@ daemon
|
|
|
218
256
|
}
|
|
219
257
|
});
|
|
220
258
|
|
|
259
|
+
const service = program
|
|
260
|
+
.command("service")
|
|
261
|
+
.description("deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)");
|
|
262
|
+
|
|
263
|
+
service
|
|
264
|
+
.command("install")
|
|
265
|
+
.description("write, enable, and (re)start a tickets-daemon.service systemd --user unit pointed at this install")
|
|
266
|
+
.action(() => {
|
|
267
|
+
try {
|
|
268
|
+
const { unitPath } = installTicketsService();
|
|
269
|
+
printJson({ status: "installed", unitPath });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
for (const action of ["start", "stop", "restart", "status"] as const) {
|
|
277
|
+
service
|
|
278
|
+
.command(action)
|
|
279
|
+
.description(`systemctl --user ${action} tickets-daemon.service`)
|
|
280
|
+
.action(() => {
|
|
281
|
+
try {
|
|
282
|
+
systemctlTickets(action);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
service
|
|
291
|
+
.command("path")
|
|
292
|
+
.description("print where the systemd unit file would be written")
|
|
293
|
+
.action(() => {
|
|
294
|
+
printJson({ unitPath: systemdUnitPath() });
|
|
295
|
+
});
|
|
296
|
+
|
|
221
297
|
const auth = program.command("auth").description("delegated OAuth login (device flow for GitHub/GitLab, authorization code for Jira)");
|
|
222
298
|
|
|
223
299
|
auth
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploys the tickets daemon as a persistent systemd --user service, so it
|
|
3
|
+
* survives logout/reboot instead of only existing for as long as some CLI
|
|
4
|
+
* command's on-demand auto-spawn keeps it alive. Mirrors papyrus's own
|
|
5
|
+
* `papyrus service <install|start|stop|restart|status>` pattern exactly
|
|
6
|
+
* (see ~/Projects/papyrus/src/cli.ts) -- same shape, same systemctl --user
|
|
7
|
+
* verbs, same install order (write unit -> daemon-reload -> enable ->
|
|
8
|
+
* restart). Every side effect (file write, directory creation, systemctl
|
|
9
|
+
* invocation) is injectable so this is fully testable without touching a
|
|
10
|
+
* real filesystem or spawning a real systemctl process.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { resolveDaemonEntryPath } from "../client/tickets-client.js";
|
|
16
|
+
import { TICKETS_DAEMON_NAMES } from "../daemon/ops.js";
|
|
17
|
+
|
|
18
|
+
export interface SystemdUnitOptions {
|
|
19
|
+
bunBin: string;
|
|
20
|
+
daemonMainPath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderSystemdUnit(options: SystemdUnitOptions): string {
|
|
24
|
+
return `[Unit]
|
|
25
|
+
Description=Tickets daemon -- unified GitHub/GitLab/Jira issue tracking
|
|
26
|
+
After=default.target
|
|
27
|
+
|
|
28
|
+
[Service]
|
|
29
|
+
Type=simple
|
|
30
|
+
ExecStart=${options.bunBin} run ${options.daemonMainPath}
|
|
31
|
+
Restart=always
|
|
32
|
+
RestartSec=2
|
|
33
|
+
|
|
34
|
+
[Install]
|
|
35
|
+
WantedBy=default.target
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** XDG_CONFIG_HOME/systemd/user/tickets-daemon.service, falling back to ~/.config like systemd itself does. */
|
|
40
|
+
export function systemdUnitPath(env: Record<string, string | undefined> = process.env): string {
|
|
41
|
+
const configHome = env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config");
|
|
42
|
+
return join(configHome, "systemd", "user", TICKETS_DAEMON_NAMES.systemdUnitName);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CommandRunner = (command: string, args: string[]) => void;
|
|
46
|
+
|
|
47
|
+
const defaultRunner: CommandRunner = (command, args) => {
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(command, args, { stdio: "inherit" });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
52
|
+
throw new Error(`${command} ${args.join(" ")} failed (is systemd --user available? this feature is Linux-only): ${message}`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type SystemctlAction = "start" | "stop" | "restart" | "status" | "enable" | "daemon-reload";
|
|
57
|
+
|
|
58
|
+
/** Always targets the tickets unit name under --user scope; daemon-reload takes no unit argument. */
|
|
59
|
+
export function systemctlTickets(action: SystemctlAction, runner: CommandRunner = defaultRunner): void {
|
|
60
|
+
const args = action === "daemon-reload" ? ["--user", "daemon-reload"] : ["--user", action, TICKETS_DAEMON_NAMES.systemdUnitName];
|
|
61
|
+
runner("systemctl", args);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface InstallOptions {
|
|
65
|
+
bunBin?: string;
|
|
66
|
+
daemonMainPath?: string;
|
|
67
|
+
env?: Record<string, string | undefined>;
|
|
68
|
+
runner?: CommandRunner;
|
|
69
|
+
writeFile?: (path: string, content: string) => void;
|
|
70
|
+
ensureDir?: (path: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Writes the unit file, then daemon-reload -> enable -> restart, in that
|
|
75
|
+
* order -- systemd must see the file before enable/restart can act on it,
|
|
76
|
+
* and restart (not start) so re-running install after an upgrade picks up
|
|
77
|
+
* a changed ExecStart path immediately rather than requiring a manual stop.
|
|
78
|
+
*/
|
|
79
|
+
export function installTicketsService(opts: InstallOptions = {}): { unitPath: string } {
|
|
80
|
+
const unitPath = systemdUnitPath(opts.env);
|
|
81
|
+
const ensureDir = opts.ensureDir ?? ((dir: string) => mkdirSync(dir, { recursive: true }));
|
|
82
|
+
const writeFile = opts.writeFile ?? writeFileSync;
|
|
83
|
+
const runner = opts.runner ?? defaultRunner;
|
|
84
|
+
|
|
85
|
+
ensureDir(dirname(unitPath));
|
|
86
|
+
writeFile(
|
|
87
|
+
unitPath,
|
|
88
|
+
renderSystemdUnit({
|
|
89
|
+
bunBin: opts.bunBin ?? process.execPath,
|
|
90
|
+
daemonMainPath: opts.daemonMainPath ?? resolveDaemonEntryPath(),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
systemctlTickets("daemon-reload", runner);
|
|
94
|
+
systemctlTickets("enable", runner);
|
|
95
|
+
systemctlTickets("restart", runner);
|
|
96
|
+
return { unitPath };
|
|
97
|
+
}
|
|
@@ -29,10 +29,14 @@ async function isAlive(handle: DaemonHandle, token: string): Promise<boolean> {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** Absolute path to the daemon's real entry point, resolved from this package's own root. Used both to spawn it on demand and to point a systemd unit's ExecStart at it (see cli/systemd-service.ts). */
|
|
33
|
+
export function resolveDaemonEntryPath(): string {
|
|
33
34
|
const root = packageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
return join(root, "src", "daemon", "main.ts");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function spawnDaemon(): void {
|
|
39
|
+
const child = spawn("bun", ["run", resolveDaemonEntryPath()], { detached: true, stdio: "ignore" });
|
|
36
40
|
child.unref();
|
|
37
41
|
}
|
|
38
42
|
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
|
|
|
13
13
|
import { TicketService } from "../application/service.js";
|
|
14
14
|
import { buildRepositories, type Config, loadConfig } from "../config/config.js";
|
|
15
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
|
+
import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
|
|
16
17
|
import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
|
|
17
18
|
import { TICKETS_DAEMON_NAMES } from "./ops.js";
|
|
18
19
|
import { buildApp } from "./server.js";
|
|
@@ -39,6 +40,7 @@ export interface BootstrapOptions {
|
|
|
39
40
|
export interface BootstrappedDaemon {
|
|
40
41
|
db: Database;
|
|
41
42
|
ledger: Ledger;
|
|
43
|
+
focusStore: FocusStore;
|
|
42
44
|
service: TicketService;
|
|
43
45
|
options: StartDaemonOptions;
|
|
44
46
|
}
|
|
@@ -49,8 +51,9 @@ const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
|
49
51
|
export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
50
52
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
51
53
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
52
|
-
const db = openSqliteWithPragmas(paths.database, { migrations: LEDGER_MIGRATIONS });
|
|
54
|
+
const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
|
|
53
55
|
const ledger = new Ledger(db);
|
|
56
|
+
const focusStore = new FocusStore(db);
|
|
54
57
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
55
58
|
const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
|
|
56
59
|
const service = new TicketService(repos);
|
|
@@ -72,6 +75,7 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
|
72
75
|
buildApp({
|
|
73
76
|
service,
|
|
74
77
|
ledger,
|
|
78
|
+
focusStore,
|
|
75
79
|
token,
|
|
76
80
|
version,
|
|
77
81
|
logger,
|
|
@@ -82,5 +86,5 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
|
82
86
|
},
|
|
83
87
|
};
|
|
84
88
|
|
|
85
|
-
return { db, ledger, service, options };
|
|
89
|
+
return { db, ledger, focusStore, service, options };
|
|
86
90
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus — the single ticket currently being worked on, independent of any
|
|
3
|
+
* one CLI invocation or tool call. Unlike the Ledger (a cache of every issue
|
|
4
|
+
* the daemon has ever seen), Focus is a pointer: one ref, its resolved title
|
|
5
|
+
* and full web URL, and whether work on it is active or paused. Persisted
|
|
6
|
+
* so it survives daemon restarts. A singleton by design — there is never
|
|
7
|
+
* more than one ticket in focus at a time, so setting focus always replaces
|
|
8
|
+
* whatever was there, the same way switching your attention to a different
|
|
9
|
+
* ticket in real life replaces what you were just looking at.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import type { Migration } from "@danypops/daemon-kit/storage";
|
|
13
|
+
|
|
14
|
+
export const FOCUS_MIGRATIONS: Migration[] = [
|
|
15
|
+
{
|
|
16
|
+
version: 2,
|
|
17
|
+
up: (db) => {
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE ticket_focus (
|
|
20
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
21
|
+
ref TEXT NOT NULL,
|
|
22
|
+
title TEXT NOT NULL,
|
|
23
|
+
url TEXT NOT NULL,
|
|
24
|
+
status TEXT NOT NULL,
|
|
25
|
+
pause_reason TEXT,
|
|
26
|
+
updated_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
`);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export type FocusStatus = "active" | "paused";
|
|
34
|
+
|
|
35
|
+
export interface TicketFocusState {
|
|
36
|
+
ref: string;
|
|
37
|
+
title: string;
|
|
38
|
+
url: string;
|
|
39
|
+
status: FocusStatus;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
pauseReason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Invalid focus state transitions (nothing focused, double-pause, double-unpause) or a resolved issue with no URL to focus on. Maps to HTTP 400, not 500. */
|
|
45
|
+
export class FocusError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "FocusError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FocusRow {
|
|
53
|
+
ref: string;
|
|
54
|
+
title: string;
|
|
55
|
+
url: string;
|
|
56
|
+
status: FocusStatus;
|
|
57
|
+
pause_reason: string | null;
|
|
58
|
+
updated_at: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rowToState(row: FocusRow): TicketFocusState {
|
|
62
|
+
return {
|
|
63
|
+
ref: row.ref,
|
|
64
|
+
title: row.title,
|
|
65
|
+
url: row.url,
|
|
66
|
+
status: row.status,
|
|
67
|
+
updatedAt: row.updated_at,
|
|
68
|
+
...(row.pause_reason ? { pauseReason: row.pause_reason } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class FocusStore {
|
|
73
|
+
constructor(private readonly db: Database) {}
|
|
74
|
+
|
|
75
|
+
get(): TicketFocusState | undefined {
|
|
76
|
+
const row = this.db
|
|
77
|
+
.query("SELECT ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE id = 1")
|
|
78
|
+
.get() as FocusRow | null;
|
|
79
|
+
return row ? rowToState(row) : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Always lands "active" and drops any prior pause reason: switching focus onto a different ticket is not the same as resuming a pause on the old one. */
|
|
83
|
+
set(ref: string, title: string, url: string): TicketFocusState {
|
|
84
|
+
const updatedAt = new Date().toISOString();
|
|
85
|
+
this.db
|
|
86
|
+
.query(
|
|
87
|
+
`INSERT INTO ticket_focus (id, ref, title, url, status, pause_reason, updated_at)
|
|
88
|
+
VALUES (1, $ref, $title, $url, 'active', NULL, $updatedAt)
|
|
89
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
90
|
+
ref = excluded.ref, title = excluded.title, url = excluded.url,
|
|
91
|
+
status = 'active', pause_reason = NULL, updated_at = excluded.updated_at`,
|
|
92
|
+
)
|
|
93
|
+
.run({ $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
|
|
94
|
+
return { ref, title, url, status: "active", updatedAt };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pause(reason?: string): TicketFocusState {
|
|
98
|
+
const current = this.get();
|
|
99
|
+
if (!current) throw new FocusError("no ticket is currently focused");
|
|
100
|
+
if (current.status === "paused") throw new FocusError(`focus on "${current.ref}" is already paused`);
|
|
101
|
+
return this.transition("paused", reason);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
unpause(): TicketFocusState {
|
|
105
|
+
const current = this.get();
|
|
106
|
+
if (!current) throw new FocusError("no ticket is currently focused");
|
|
107
|
+
if (current.status === "active") throw new FocusError(`focus on "${current.ref}" is already active`);
|
|
108
|
+
return this.transition("active", undefined);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Returns whether a focus existed to clear (idempotent either way). */
|
|
112
|
+
clear(): boolean {
|
|
113
|
+
const existed = this.get() !== undefined;
|
|
114
|
+
this.db.exec("DELETE FROM ticket_focus WHERE id = 1");
|
|
115
|
+
return existed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private transition(status: FocusStatus, reason: string | undefined): TicketFocusState {
|
|
119
|
+
const updatedAt = new Date().toISOString();
|
|
120
|
+
this.db
|
|
121
|
+
.query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE id = 1")
|
|
122
|
+
.run({ $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
|
|
123
|
+
// Non-null: transition() is only ever called right after get() confirmed a row exists.
|
|
124
|
+
return this.get()!;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/daemon/ops.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
6
|
*/
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
8
|
+
import type { TicketFocusState } from "./focus.js";
|
|
8
9
|
|
|
9
10
|
export type TicketOperation =
|
|
10
11
|
| "backends.list"
|
|
@@ -18,6 +19,11 @@ export type TicketOperation =
|
|
|
18
19
|
| "issue.comment_add"
|
|
19
20
|
| "ledger.search"
|
|
20
21
|
| "ledger.stats"
|
|
22
|
+
| "focus.set"
|
|
23
|
+
| "focus.get"
|
|
24
|
+
| "focus.pause"
|
|
25
|
+
| "focus.unpause"
|
|
26
|
+
| "focus.clear"
|
|
21
27
|
| "daemon.shutdown";
|
|
22
28
|
|
|
23
29
|
export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
@@ -32,6 +38,11 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
32
38
|
"issue.comment_add": { ref: string; body: string };
|
|
33
39
|
"ledger.search": { query: string; limit?: number };
|
|
34
40
|
"ledger.stats": Record<string, never>;
|
|
41
|
+
"focus.set": { ref: string };
|
|
42
|
+
"focus.get": Record<string, never>;
|
|
43
|
+
"focus.pause": { reason?: string };
|
|
44
|
+
"focus.unpause": Record<string, never>;
|
|
45
|
+
"focus.clear": Record<string, never>;
|
|
35
46
|
"daemon.shutdown": Record<string, never>;
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -47,6 +58,11 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
47
58
|
"issue.comment_add": { comment: Comment };
|
|
48
59
|
"ledger.search": { issues: Issue[] };
|
|
49
60
|
"ledger.stats": { backends: { backend: string; count: number }[] };
|
|
61
|
+
"focus.set": { focus: TicketFocusState };
|
|
62
|
+
"focus.get": { focus: TicketFocusState | null };
|
|
63
|
+
"focus.pause": { focus: TicketFocusState };
|
|
64
|
+
"focus.unpause": { focus: TicketFocusState };
|
|
65
|
+
"focus.clear": { cleared: boolean };
|
|
50
66
|
"daemon.shutdown": { stopping: true };
|
|
51
67
|
}
|
|
52
68
|
|
|
@@ -62,6 +78,11 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
62
78
|
"issue.comment_add",
|
|
63
79
|
"ledger.search",
|
|
64
80
|
"ledger.stats",
|
|
81
|
+
"focus.set",
|
|
82
|
+
"focus.get",
|
|
83
|
+
"focus.pause",
|
|
84
|
+
"focus.unpause",
|
|
85
|
+
"focus.clear",
|
|
65
86
|
"daemon.shutdown",
|
|
66
87
|
];
|
|
67
88
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -8,12 +8,15 @@ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBear
|
|
|
8
8
|
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
9
9
|
import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
|
|
10
10
|
import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
|
|
11
|
+
import { parseRef } from "../domain/issue.js";
|
|
12
|
+
import { FocusError, type FocusStore } from "./focus.js";
|
|
11
13
|
import type { Ledger } from "./ledger.js";
|
|
12
14
|
import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
|
|
13
15
|
|
|
14
16
|
export interface TicketsAppDeps {
|
|
15
17
|
service: TicketService;
|
|
16
18
|
ledger: Ledger;
|
|
19
|
+
focusStore: FocusStore;
|
|
17
20
|
token: string;
|
|
18
21
|
version: string;
|
|
19
22
|
logger?: Logger;
|
|
@@ -44,6 +47,22 @@ const handlers: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
44
47
|
"issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
|
|
45
48
|
"ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit) }),
|
|
46
49
|
"ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
|
|
50
|
+
"focus.set": async (deps, input) => {
|
|
51
|
+
// Ledger-first: focusing a ticket already pooled locally needs no live
|
|
52
|
+
// backend call. Otherwise fall back to a live get (also validates the
|
|
53
|
+
// ref actually exists) and opportunistically warm the ledger with it,
|
|
54
|
+
// since a ticket you're about to focus on is exactly the kind of issue
|
|
55
|
+
// worth having cached locally.
|
|
56
|
+
const cached = deps.ledger.get(input.ref);
|
|
57
|
+
const issue = cached ?? (await deps.service.get(input.ref));
|
|
58
|
+
if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
|
|
59
|
+
if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
|
|
60
|
+
return { focus: deps.focusStore.set(input.ref, issue.title, issue.url) };
|
|
61
|
+
},
|
|
62
|
+
"focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
|
|
63
|
+
"focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
|
|
64
|
+
"focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
|
|
65
|
+
"focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
|
|
47
66
|
"daemon.shutdown": async (deps) => {
|
|
48
67
|
// Deferred so this handler's own response has already been handed back
|
|
49
68
|
// to Bun.serve before the process starts tearing down.
|
|
@@ -58,7 +77,7 @@ function isTicketOperation(value: unknown): value is TicketOperation {
|
|
|
58
77
|
|
|
59
78
|
function statusFor(error: unknown): number {
|
|
60
79
|
if (error instanceof IssueNotFoundError) return 404;
|
|
61
|
-
if (error instanceof UnknownBackendError || error instanceof NotSupportedError) return 400;
|
|
80
|
+
if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
|
|
62
81
|
if (error instanceof AuthRequiredError) return 422;
|
|
63
82
|
return 500;
|
|
64
83
|
}
|