@danypops/tickets 0.1.0 → 0.2.1
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 +58 -20
- package/package.json +7 -3
- package/src/adapters/github.ts +135 -56
- package/src/adapters/gitlab.ts +98 -42
- package/src/adapters/jira.ts +128 -46
- package/src/auth/enigma-source.ts +65 -0
- 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/config/config.ts +30 -19
- package/src/daemon/bootstrap.ts +8 -4
- package/src/daemon/focus.ts +126 -0
- package/src/daemon/main.ts +1 -1
- package/src/daemon/ops.ts +21 -0
- package/src/daemon/server.ts +20 -1
- package/src/domain/issue.ts +4 -0
- package/src/index.ts +2 -0
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/config/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { GitLabRepository } from "../adapters/gitlab.js";
|
|
|
12
12
|
import { JiraRepository } from "../adapters/jira.js";
|
|
13
13
|
import type { IssueRepository } from "../ports/repository.js";
|
|
14
14
|
import { isTokenFresh, loadToken } from "../auth/token-store.js";
|
|
15
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "../auth/enigma-source.js";
|
|
15
16
|
|
|
16
17
|
export interface BackendConfig {
|
|
17
18
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -55,19 +56,27 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
60
|
+
* one happens to be configured for this backend — entirely optional, never a
|
|
61
|
+
* hard dependency, and bounded so Tickets never waits long for it (see
|
|
62
|
+
* auth/enigma-source.ts); (2) a locally stored, still-fresh delegated OAuth
|
|
63
|
+
* token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
|
|
64
|
+
* static config/env PAT. (1) is additive to the pre-Enigma precedence this
|
|
65
|
+
* project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
|
|
66
|
+
* for the auth flows each backend actually supports (device flow for
|
|
67
|
+
* GitHub/GitLab, authorization code for Jira, which has no device flow or
|
|
68
|
+
* PKCE).
|
|
64
69
|
*/
|
|
65
|
-
function preferredAuth(
|
|
70
|
+
export async function preferredAuth(
|
|
66
71
|
name: string,
|
|
67
72
|
cfg: BackendConfig,
|
|
68
73
|
env: NodeJS.ProcessEnv,
|
|
69
74
|
envFallback: string,
|
|
70
|
-
|
|
75
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
76
|
+
): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
|
|
77
|
+
const fromEnigma = await tryEnigma(name, { env });
|
|
78
|
+
if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
|
|
79
|
+
|
|
71
80
|
const stored = loadToken(name, { env });
|
|
72
81
|
if (stored && isTokenFresh(stored)) {
|
|
73
82
|
return { token: stored.accessToken, oauth: true, extra: stored.extra };
|
|
@@ -80,45 +89,47 @@ function preferredAuth(
|
|
|
80
89
|
* inferrable purely from environment variables when not present in the config file.
|
|
81
90
|
* Config-file entries take precedence over bare env-var inference for the same name.
|
|
82
91
|
*/
|
|
83
|
-
export function buildRepositories(
|
|
92
|
+
export async function buildRepositories(
|
|
84
93
|
config: Config,
|
|
85
94
|
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
-
|
|
95
|
+
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
96
|
+
): Promise<Record<string, IssueRepository>> {
|
|
87
97
|
const repos: Record<string, IssueRepository> = {};
|
|
88
98
|
|
|
89
99
|
for (const [name, cfg] of Object.entries(config.backends)) {
|
|
90
100
|
const type = cfg.type ?? name;
|
|
91
|
-
const repo = createRepository(name, type, cfg, env);
|
|
101
|
+
const repo = await createRepository(name, type, cfg, env, tryEnigma);
|
|
92
102
|
if (repo) repos[name] = repo;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
if (!repos.github && (env.GITHUB_OWNER || env.GITHUB_TOKEN)) {
|
|
96
|
-
const repo = createRepository("github", "github", {}, env);
|
|
106
|
+
const repo = await createRepository("github", "github", {}, env, tryEnigma);
|
|
97
107
|
if (repo) repos.github = repo;
|
|
98
108
|
}
|
|
99
109
|
if (!repos.gitlab && (env.GITLAB_PROJECT || env.GITLAB_TOKEN)) {
|
|
100
|
-
const repo = createRepository("gitlab", "gitlab", {}, env);
|
|
110
|
+
const repo = await createRepository("gitlab", "gitlab", {}, env, tryEnigma);
|
|
101
111
|
if (repo) repos.gitlab = repo;
|
|
102
112
|
}
|
|
103
113
|
if (!repos.jira && env.JIRA_URL) {
|
|
104
|
-
const repo = createRepository("jira", "jira", {}, env);
|
|
114
|
+
const repo = await createRepository("jira", "jira", {}, env, tryEnigma);
|
|
105
115
|
if (repo) repos.jira = repo;
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
return repos;
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
function createRepository(
|
|
121
|
+
async function createRepository(
|
|
112
122
|
name: string,
|
|
113
123
|
type: string,
|
|
114
124
|
cfg: BackendConfig,
|
|
115
125
|
env: NodeJS.ProcessEnv,
|
|
116
|
-
|
|
126
|
+
tryEnigma: TryEnigmaCredential,
|
|
127
|
+
): Promise<IssueRepository | undefined> {
|
|
117
128
|
switch (type) {
|
|
118
129
|
case "github": {
|
|
119
130
|
const owner = cfg.owner ?? env.GITHUB_OWNER;
|
|
120
131
|
if (!owner) return undefined;
|
|
121
|
-
const auth = preferredAuth(name, cfg, env, "GITHUB_TOKEN");
|
|
132
|
+
const auth = await preferredAuth(name, cfg, env, "GITHUB_TOKEN", tryEnigma);
|
|
122
133
|
return new GitHubRepository(name, {
|
|
123
134
|
owner,
|
|
124
135
|
repo: cfg.repo ?? env.GITHUB_REPO,
|
|
@@ -129,7 +140,7 @@ function createRepository(
|
|
|
129
140
|
case "gitlab": {
|
|
130
141
|
const project = cfg.project ?? env.GITLAB_PROJECT;
|
|
131
142
|
if (!project) return undefined;
|
|
132
|
-
const auth = preferredAuth(name, cfg, env, "GITLAB_TOKEN");
|
|
143
|
+
const auth = await preferredAuth(name, cfg, env, "GITLAB_TOKEN", tryEnigma);
|
|
133
144
|
return new GitLabRepository(name, {
|
|
134
145
|
projectId: project,
|
|
135
146
|
token: auth.token,
|
|
@@ -138,7 +149,7 @@ function createRepository(
|
|
|
138
149
|
});
|
|
139
150
|
}
|
|
140
151
|
case "jira": {
|
|
141
|
-
const auth = preferredAuth(name, cfg, env, "JIRA_API_TOKEN");
|
|
152
|
+
const auth = await preferredAuth(name, cfg, env, "JIRA_API_TOKEN", tryEnigma);
|
|
142
153
|
if (auth.oauth && auth.token && auth.extra?.cloudId) {
|
|
143
154
|
return new JiraRepository(name, {
|
|
144
155
|
accessToken: auth.token,
|
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
|
}
|
|
@@ -46,13 +48,14 @@ export interface BootstrappedDaemon {
|
|
|
46
48
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
47
49
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
48
50
|
|
|
49
|
-
export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
|
|
51
|
+
export async function bootstrap(opts: BootstrapOptions = {}): Promise<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
|
-
const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
|
|
58
|
+
const repos = opts.repos ?? (await buildRepositories(opts.config ?? loadConfig()));
|
|
56
59
|
const service = new TicketService(repos);
|
|
57
60
|
const version = opts.version ?? "0.0.0-dev";
|
|
58
61
|
|
|
@@ -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/main.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { readPackageVersion } from "@danypops/daemon-kit/version";
|
|
|
10
10
|
import { bootstrap } from "./bootstrap.js";
|
|
11
11
|
|
|
12
12
|
const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
|
|
13
|
-
const { options } = bootstrap({ version });
|
|
13
|
+
const { options } = await bootstrap({ version });
|
|
14
14
|
|
|
15
15
|
runDaemonProcess({
|
|
16
16
|
...options,
|
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
|
}
|
package/src/domain/issue.ts
CHANGED
|
@@ -79,6 +79,8 @@ export interface CreateInput {
|
|
|
79
79
|
project?: string;
|
|
80
80
|
issueType?: string;
|
|
81
81
|
parentKey?: string;
|
|
82
|
+
/** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
|
|
83
|
+
customFields?: Record<string, string>;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
export interface UpdateInput {
|
|
@@ -89,6 +91,8 @@ export interface UpdateInput {
|
|
|
89
91
|
labels?: string[];
|
|
90
92
|
assignee?: string;
|
|
91
93
|
resolution?: string;
|
|
94
|
+
/** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
|
|
95
|
+
customFields?: Record<string, string>;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface ListFilter {
|
package/src/index.ts
CHANGED
|
@@ -14,10 +14,12 @@ export {
|
|
|
14
14
|
configDir,
|
|
15
15
|
} from "./config/config.js";
|
|
16
16
|
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
|
|
17
|
+
export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
|
|
17
18
|
export {
|
|
18
19
|
createTicketsClient,
|
|
19
20
|
ensureDaemonRunning,
|
|
20
21
|
ticketsPaths,
|
|
22
|
+
type EnsureDaemonOptions,
|
|
21
23
|
type TicketsRpcClient,
|
|
22
24
|
} from "./client/tickets-client.js";
|
|
23
25
|
|