@danypops/tickets 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 +196 -0
- package/RESEARCH.md +109 -0
- package/package.json +57 -0
- package/src/adapters/errors.ts +33 -0
- package/src/adapters/github.ts +201 -0
- package/src/adapters/gitlab.ts +237 -0
- package/src/adapters/http.ts +86 -0
- package/src/adapters/jira.ts +311 -0
- package/src/application/service.ts +84 -0
- package/src/auth/browser.ts +35 -0
- package/src/auth/device-flow.ts +140 -0
- package/src/auth/github-oauth.ts +44 -0
- package/src/auth/gitlab-oauth.ts +51 -0
- package/src/auth/jira-oauth.ts +251 -0
- package/src/auth/token-store.ts +73 -0
- package/src/cli/index.ts +337 -0
- package/src/client/tickets-client.ts +89 -0
- package/src/config/config.ts +162 -0
- package/src/daemon/bootstrap.ts +86 -0
- package/src/daemon/ledger.ts +124 -0
- package/src/daemon/main.ts +20 -0
- package/src/daemon/ops.ts +75 -0
- package/src/daemon/poller.ts +52 -0
- package/src/daemon/server.ts +100 -0
- package/src/domain/issue.ts +114 -0
- package/src/index.ts +31 -0
- package/src/ports/repository.ts +27 -0
- package/src/util/package-root.ts +27 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger — the daemon's local, independent pool of every issue it has seen.
|
|
3
|
+
* Mirrors emcee's SQLite Ledger concept: a passively-populated local index
|
|
4
|
+
* that keeps working (search, stats, last-known state) even when a backend
|
|
5
|
+
* is slow, rate-limited, or unreachable. Populated by poller.ts on its own
|
|
6
|
+
* schedule; queried directly by ledger.search / ledger.stats ops, independent
|
|
7
|
+
* of any live upstream call.
|
|
8
|
+
*/
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
10
|
+
import type { Migration } from "@danypops/daemon-kit/storage";
|
|
11
|
+
import type { Issue } from "../domain/issue.js";
|
|
12
|
+
|
|
13
|
+
export const LEDGER_MIGRATIONS: Migration[] = [
|
|
14
|
+
{
|
|
15
|
+
version: 1,
|
|
16
|
+
up: (db) => {
|
|
17
|
+
db.exec(`
|
|
18
|
+
CREATE TABLE issues (
|
|
19
|
+
ref TEXT PRIMARY KEY,
|
|
20
|
+
backend TEXT NOT NULL,
|
|
21
|
+
key TEXT NOT NULL,
|
|
22
|
+
title TEXT NOT NULL,
|
|
23
|
+
status TEXT NOT NULL,
|
|
24
|
+
priority TEXT NOT NULL,
|
|
25
|
+
url TEXT,
|
|
26
|
+
updated_at TEXT,
|
|
27
|
+
synced_at TEXT NOT NULL,
|
|
28
|
+
raw_json TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX idx_issues_backend ON issues (backend);
|
|
31
|
+
CREATE INDEX idx_issues_title ON issues (title);
|
|
32
|
+
`);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
interface IssueRow {
|
|
38
|
+
ref: string;
|
|
39
|
+
backend: string;
|
|
40
|
+
key: string;
|
|
41
|
+
title: string;
|
|
42
|
+
status: string;
|
|
43
|
+
priority: string;
|
|
44
|
+
url: string | null;
|
|
45
|
+
updated_at: string | null;
|
|
46
|
+
synced_at: string;
|
|
47
|
+
raw_json: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rowToIssue(row: IssueRow): Issue {
|
|
51
|
+
return JSON.parse(row.raw_json) as Issue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_SEARCH_LIMIT = 50;
|
|
55
|
+
const MAX_SEARCH_LIMIT = 200;
|
|
56
|
+
|
|
57
|
+
export class Ledger {
|
|
58
|
+
constructor(private readonly db: Database) {}
|
|
59
|
+
|
|
60
|
+
upsert(backend: string, issue: Issue): void {
|
|
61
|
+
this.db
|
|
62
|
+
.query(
|
|
63
|
+
`INSERT INTO issues (ref, backend, key, title, status, priority, url, updated_at, synced_at, raw_json)
|
|
64
|
+
VALUES ($ref, $backend, $key, $title, $status, $priority, $url, $updatedAt, $syncedAt, $rawJson)
|
|
65
|
+
ON CONFLICT(ref) DO UPDATE SET
|
|
66
|
+
title = excluded.title,
|
|
67
|
+
status = excluded.status,
|
|
68
|
+
priority = excluded.priority,
|
|
69
|
+
url = excluded.url,
|
|
70
|
+
updated_at = excluded.updated_at,
|
|
71
|
+
synced_at = excluded.synced_at,
|
|
72
|
+
raw_json = excluded.raw_json`,
|
|
73
|
+
)
|
|
74
|
+
.run({
|
|
75
|
+
$ref: issue.ref,
|
|
76
|
+
$backend: backend,
|
|
77
|
+
$key: issue.key,
|
|
78
|
+
$title: issue.title,
|
|
79
|
+
$status: issue.status,
|
|
80
|
+
$priority: issue.priority,
|
|
81
|
+
$url: issue.url ?? null,
|
|
82
|
+
$updatedAt: issue.updatedAt ?? null,
|
|
83
|
+
$syncedAt: new Date().toISOString(),
|
|
84
|
+
$rawJson: JSON.stringify(issue),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
upsertMany(backend: string, issues: Issue[]): number {
|
|
89
|
+
const tx = this.db.transaction((rows: Issue[]) => {
|
|
90
|
+
for (const issue of rows) this.upsert(backend, issue);
|
|
91
|
+
});
|
|
92
|
+
tx(issues);
|
|
93
|
+
return issues.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get(ref: string): Issue | undefined {
|
|
97
|
+
const row = this.db.query("SELECT * FROM issues WHERE ref = ?").get(ref) as IssueRow | null;
|
|
98
|
+
return row ? rowToIssue(row) : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Bounded LIKE search over title, newest-synced first. Explicit limit, capped hard. */
|
|
102
|
+
search(query: string, limit = DEFAULT_SEARCH_LIMIT): Issue[] {
|
|
103
|
+
const bounded = Math.min(Math.max(limit, 1), MAX_SEARCH_LIMIT);
|
|
104
|
+
const rows = this.db
|
|
105
|
+
.query("SELECT * FROM issues WHERE title LIKE ? ORDER BY synced_at DESC LIMIT ?")
|
|
106
|
+
.all(`%${query}%`, bounded) as IssueRow[];
|
|
107
|
+
return rows.map(rowToIssue);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
listByBackend(backend: string, limit = DEFAULT_SEARCH_LIMIT): Issue[] {
|
|
111
|
+
const bounded = Math.min(Math.max(limit, 1), MAX_SEARCH_LIMIT);
|
|
112
|
+
const rows = this.db
|
|
113
|
+
.query("SELECT * FROM issues WHERE backend = ? ORDER BY synced_at DESC LIMIT ?")
|
|
114
|
+
.all(backend, bounded) as IssueRow[];
|
|
115
|
+
return rows.map(rowToIssue);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
stats(): { backend: string; count: number }[] {
|
|
119
|
+
const rows = this.db
|
|
120
|
+
.query("SELECT backend, COUNT(*) as count FROM issues GROUP BY backend ORDER BY backend")
|
|
121
|
+
.all() as { backend: string; count: number }[];
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The real tickets-daemon binary. Requires Bun (bun:sqlite, Bun.serve via
|
|
4
|
+
* daemon-kit). Everything else in this package (the library, the CLI, the
|
|
5
|
+
* pi-tickets extension) is plain Node-compatible TypeScript and talks to
|
|
6
|
+
* this process only over the loopback HTTP RPC surface — see client.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { runDaemonProcess } from "@danypops/daemon-kit/daemon";
|
|
9
|
+
import { readPackageVersion } from "@danypops/daemon-kit/version";
|
|
10
|
+
import { bootstrap } from "./bootstrap.js";
|
|
11
|
+
|
|
12
|
+
const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
|
|
13
|
+
const { options } = bootstrap({ version });
|
|
14
|
+
|
|
15
|
+
runDaemonProcess({
|
|
16
|
+
...options,
|
|
17
|
+
onListen: (info) => {
|
|
18
|
+
options.logger?.info("tickets daemon listening", { host: info.host, port: info.port, version });
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RPC protocol shared between the tickets daemon (server.ts, running under
|
|
3
|
+
* Bun) and every client (cli/index.ts, extensions/pi-tickets, running under
|
|
4
|
+
* whatever consumes this package). Pure types, zero runtime imports, safe to
|
|
5
|
+
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
|
+
*/
|
|
7
|
+
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
8
|
+
|
|
9
|
+
export type TicketOperation =
|
|
10
|
+
| "backends.list"
|
|
11
|
+
| "issue.list"
|
|
12
|
+
| "issue.get"
|
|
13
|
+
| "issue.create"
|
|
14
|
+
| "issue.update"
|
|
15
|
+
| "issue.search"
|
|
16
|
+
| "issue.children"
|
|
17
|
+
| "issue.comments"
|
|
18
|
+
| "issue.comment_add"
|
|
19
|
+
| "ledger.search"
|
|
20
|
+
| "ledger.stats"
|
|
21
|
+
| "daemon.shutdown";
|
|
22
|
+
|
|
23
|
+
export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
24
|
+
"backends.list": Record<string, never>;
|
|
25
|
+
"issue.list": { backend: string; filter?: ListFilter };
|
|
26
|
+
"issue.get": { ref: string };
|
|
27
|
+
"issue.create": { backend: string; input: CreateInput };
|
|
28
|
+
"issue.update": { ref: string; input: UpdateInput };
|
|
29
|
+
"issue.search": { backend: string; query: string; limit?: number };
|
|
30
|
+
"issue.children": { ref: string };
|
|
31
|
+
"issue.comments": { ref: string };
|
|
32
|
+
"issue.comment_add": { ref: string; body: string };
|
|
33
|
+
"ledger.search": { query: string; limit?: number };
|
|
34
|
+
"ledger.stats": Record<string, never>;
|
|
35
|
+
"daemon.shutdown": Record<string, never>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
39
|
+
"backends.list": { backends: string[] };
|
|
40
|
+
"issue.list": { issues: Issue[] };
|
|
41
|
+
"issue.get": { issue: Issue };
|
|
42
|
+
"issue.create": { issue: Issue };
|
|
43
|
+
"issue.update": { issue: Issue };
|
|
44
|
+
"issue.search": { issues: Issue[] };
|
|
45
|
+
"issue.children": { issues: Issue[] };
|
|
46
|
+
"issue.comments": { comments: Comment[] };
|
|
47
|
+
"issue.comment_add": { comment: Comment };
|
|
48
|
+
"ledger.search": { issues: Issue[] };
|
|
49
|
+
"ledger.stats": { backends: { backend: string; count: number }[] };
|
|
50
|
+
"daemon.shutdown": { stopping: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
54
|
+
"backends.list",
|
|
55
|
+
"issue.list",
|
|
56
|
+
"issue.get",
|
|
57
|
+
"issue.create",
|
|
58
|
+
"issue.update",
|
|
59
|
+
"issue.search",
|
|
60
|
+
"issue.children",
|
|
61
|
+
"issue.comments",
|
|
62
|
+
"issue.comment_add",
|
|
63
|
+
"ledger.search",
|
|
64
|
+
"ledger.stats",
|
|
65
|
+
"daemon.shutdown",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Daemon path/state directory identity — the one place this name is spelled out. */
|
|
69
|
+
export const TICKETS_DAEMON_NAMES = {
|
|
70
|
+
stateDirectoryName: "tickets",
|
|
71
|
+
databaseFilename: "tickets.db",
|
|
72
|
+
tokenFilename: "token",
|
|
73
|
+
handleFilename: "handle.json",
|
|
74
|
+
systemdUnitName: "tickets-daemon.service",
|
|
75
|
+
} as const;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poller — pools issues from every configured backend into the local Ledger
|
|
3
|
+
* on its own schedule, independent of whether any client (CLI, pi-tickets)
|
|
4
|
+
* is currently asking for anything. A backend sync failure (rate limit,
|
|
5
|
+
* network, bad creds) is logged and skipped; it never crashes the daemon
|
|
6
|
+
* and never blocks other backends' syncs.
|
|
7
|
+
*/
|
|
8
|
+
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
9
|
+
import type { MaintenanceTask } from "@danypops/daemon-kit/daemon";
|
|
10
|
+
import type { TicketService } from "../application/service.js";
|
|
11
|
+
import type { Ledger } from "./ledger.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SYNC_LIMIT = 50;
|
|
14
|
+
|
|
15
|
+
/** Runs one sync pass across all given backends. Exported standalone for tests. */
|
|
16
|
+
export async function syncOnce(
|
|
17
|
+
service: TicketService,
|
|
18
|
+
ledger: Ledger,
|
|
19
|
+
backends: string[],
|
|
20
|
+
logger?: Logger,
|
|
21
|
+
): Promise<{ backend: string; synced: number; error?: string }[]> {
|
|
22
|
+
const results: { backend: string; synced: number; error?: string }[] = [];
|
|
23
|
+
for (const backend of backends) {
|
|
24
|
+
try {
|
|
25
|
+
const issues = await service.list(backend, { limit: DEFAULT_SYNC_LIMIT });
|
|
26
|
+
const synced = ledger.upsertMany(backend, issues);
|
|
27
|
+
results.push({ backend, synced });
|
|
28
|
+
logger?.debug("ledger sync ok", { backend, synced });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
results.push({ backend, synced: 0, error: message });
|
|
32
|
+
logger?.warn("ledger sync failed", { backend, error: message });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return results;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createSyncTask(
|
|
39
|
+
service: TicketService,
|
|
40
|
+
ledger: Ledger,
|
|
41
|
+
backends: string[],
|
|
42
|
+
intervalMs: number,
|
|
43
|
+
logger?: Logger,
|
|
44
|
+
): MaintenanceTask {
|
|
45
|
+
return {
|
|
46
|
+
name: "ledger-sync",
|
|
47
|
+
intervalMs,
|
|
48
|
+
run: async () => {
|
|
49
|
+
await syncOnce(service, ledger, backends, logger);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon HTTP surface: Bearer-token auth, /health, /ready, and a single
|
|
3
|
+
* dispatch endpoint (/api/v1/ops) per daemon-kit's http.ts convention.
|
|
4
|
+
* Every operation here has a CLI command (cli/index.ts) and a pi-tickets
|
|
5
|
+
* tool action — no operation exists only for one caller.
|
|
6
|
+
*/
|
|
7
|
+
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/daemon-kit/http";
|
|
8
|
+
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
9
|
+
import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
|
|
10
|
+
import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
|
|
11
|
+
import type { Ledger } from "./ledger.js";
|
|
12
|
+
import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
|
|
13
|
+
|
|
14
|
+
export interface TicketsAppDeps {
|
|
15
|
+
service: TicketService;
|
|
16
|
+
ledger: Ledger;
|
|
17
|
+
token: string;
|
|
18
|
+
version: string;
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
/**
|
|
21
|
+
* Invoked by the `daemon.shutdown` op, after the HTTP response is already
|
|
22
|
+
* queued to flush. Defaults set by bootstrap.ts self-signal the process so
|
|
23
|
+
* the same tested SIGINT/SIGTERM path (daemon-kit's runDaemonProcess) does
|
|
24
|
+
* the actual graceful stop — this hook only ever *requests* shutdown, it
|
|
25
|
+
* never calls process.exit directly.
|
|
26
|
+
*/
|
|
27
|
+
onShutdownRequested?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Handler<Op extends TicketOperation> = (
|
|
31
|
+
deps: TicketsAppDeps,
|
|
32
|
+
input: TicketOpInputs[Op],
|
|
33
|
+
) => Promise<TicketOpOutputs[Op]>;
|
|
34
|
+
|
|
35
|
+
const handlers: { [Op in TicketOperation]: Handler<Op> } = {
|
|
36
|
+
"backends.list": async (deps) => ({ backends: deps.service.backends() }),
|
|
37
|
+
"issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
|
|
38
|
+
"issue.get": async (deps, input) => ({ issue: await deps.service.get(input.ref) }),
|
|
39
|
+
"issue.create": async (deps, input) => ({ issue: await deps.service.create(input.backend, input.input) }),
|
|
40
|
+
"issue.update": async (deps, input) => ({ issue: await deps.service.update(input.ref, input.input) }),
|
|
41
|
+
"issue.search": async (deps, input) => ({ issues: await deps.service.search(input.backend, input.query, input.limit) }),
|
|
42
|
+
"issue.children": async (deps, input) => ({ issues: await deps.service.children(input.ref) }),
|
|
43
|
+
"issue.comments": async (deps, input) => ({ comments: await deps.service.comments(input.ref) }),
|
|
44
|
+
"issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
|
|
45
|
+
"ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit) }),
|
|
46
|
+
"ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
|
|
47
|
+
"daemon.shutdown": async (deps) => {
|
|
48
|
+
// Deferred so this handler's own response has already been handed back
|
|
49
|
+
// to Bun.serve before the process starts tearing down.
|
|
50
|
+
setTimeout(() => deps.onShutdownRequested?.(), 50);
|
|
51
|
+
return { stopping: true };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function isTicketOperation(value: unknown): value is TicketOperation {
|
|
56
|
+
return typeof value === "string" && (TICKET_OPERATIONS as string[]).includes(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function statusFor(error: unknown): number {
|
|
60
|
+
if (error instanceof IssueNotFoundError) return 404;
|
|
61
|
+
if (error instanceof UnknownBackendError || error instanceof NotSupportedError) return 400;
|
|
62
|
+
if (error instanceof AuthRequiredError) return 422;
|
|
63
|
+
return 500;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildApp(deps: TicketsAppDeps): { fetch(request: Request): Promise<Response> } {
|
|
67
|
+
return {
|
|
68
|
+
async fetch(request: Request): Promise<Response> {
|
|
69
|
+
const url = new URL(request.url);
|
|
70
|
+
|
|
71
|
+
if (!requireBearerToken(request, deps.token)) return errorResponse("unauthorized", 401);
|
|
72
|
+
if (request.method === "GET" && url.pathname === "/health") return healthResponse(deps.version);
|
|
73
|
+
if (request.method === "GET" && url.pathname === "/ready") return readyResponse(true);
|
|
74
|
+
|
|
75
|
+
if (url.pathname === "/api/v1/ops") {
|
|
76
|
+
if (request.method === "GET") return jsonResponse({ operations: TICKET_OPERATIONS });
|
|
77
|
+
if (request.method === "POST") {
|
|
78
|
+
let body: { op?: unknown; input?: unknown };
|
|
79
|
+
try {
|
|
80
|
+
body = (await request.json()) as { op?: unknown; input?: unknown };
|
|
81
|
+
} catch {
|
|
82
|
+
return errorResponse("invalid JSON body", 400);
|
|
83
|
+
}
|
|
84
|
+
if (!isTicketOperation(body.op)) return errorResponse(`unknown op: ${String(body.op)}`, 400);
|
|
85
|
+
const handler = handlers[body.op] as Handler<TicketOperation>;
|
|
86
|
+
try {
|
|
87
|
+
const result = await handler(deps, (body.input ?? {}) as never);
|
|
88
|
+
return jsonResponse({ result });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
deps.logger?.warn("op failed", { op: body.op, error: message });
|
|
92
|
+
return errorResponse(message, statusFor(error));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return errorResponse("not found", 404);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain package — the canonical, backend-agnostic representation of a work item.
|
|
3
|
+
* Zero external dependencies, zero I/O. Mirrors the shape independently reachable
|
|
4
|
+
* from GitHub Issues, GitLab Issues, and Jira issues (see RESEARCH.md for the
|
|
5
|
+
* source API docs each adapter was built against).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PRIORITIES = ["none", "urgent", "high", "medium", "low"] as const;
|
|
9
|
+
export type Priority = (typeof PRIORITIES)[number];
|
|
10
|
+
|
|
11
|
+
/** Accepts case-insensitive strings and falls back to "none" for anything unrecognized. */
|
|
12
|
+
export function parsePriority(value: unknown): Priority {
|
|
13
|
+
if (typeof value !== "string") return "none";
|
|
14
|
+
const lower = value.toLowerCase().trim();
|
|
15
|
+
return (PRIORITIES as readonly string[]).includes(lower) ? (lower as Priority) : "none";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const STATUSES = [
|
|
19
|
+
"backlog",
|
|
20
|
+
"todo",
|
|
21
|
+
"in_progress",
|
|
22
|
+
"in_review",
|
|
23
|
+
"done",
|
|
24
|
+
"canceled",
|
|
25
|
+
] as const;
|
|
26
|
+
export type Status = (typeof STATUSES)[number];
|
|
27
|
+
|
|
28
|
+
export function parseStatus(value: unknown, fallback: Status = "todo"): Status {
|
|
29
|
+
if (typeof value !== "string") return fallback;
|
|
30
|
+
const lower = value.toLowerCase().trim();
|
|
31
|
+
return (STATUSES as readonly string[]).includes(lower) ? (lower as Status) : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IssueParent {
|
|
35
|
+
key: string;
|
|
36
|
+
title: string;
|
|
37
|
+
status?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Comment {
|
|
41
|
+
id: string;
|
|
42
|
+
body: string;
|
|
43
|
+
author?: string;
|
|
44
|
+
createdAt?: string;
|
|
45
|
+
updatedAt?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The unified representation of a work item, regardless of which platform it lives on. */
|
|
49
|
+
export interface Issue {
|
|
50
|
+
/** "backend:key", e.g. "jira:PROJ-42" or "github:#7". */
|
|
51
|
+
ref: string;
|
|
52
|
+
id: string;
|
|
53
|
+
key: string;
|
|
54
|
+
title: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
status: Status;
|
|
57
|
+
/** The backend's own status string, preserved for round-tripping/debugging. */
|
|
58
|
+
rawStatus?: string;
|
|
59
|
+
priority: Priority;
|
|
60
|
+
labels?: string[];
|
|
61
|
+
assignee?: string;
|
|
62
|
+
reporter?: string;
|
|
63
|
+
project?: string;
|
|
64
|
+
issueType?: string;
|
|
65
|
+
resolution?: string;
|
|
66
|
+
parent?: IssueParent;
|
|
67
|
+
createdAt?: string;
|
|
68
|
+
updatedAt?: string;
|
|
69
|
+
url?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CreateInput {
|
|
73
|
+
title: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
status?: Status;
|
|
76
|
+
priority?: Priority;
|
|
77
|
+
labels?: string[];
|
|
78
|
+
assignee?: string;
|
|
79
|
+
project?: string;
|
|
80
|
+
issueType?: string;
|
|
81
|
+
parentKey?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface UpdateInput {
|
|
85
|
+
title?: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
status?: Status;
|
|
88
|
+
priority?: Priority;
|
|
89
|
+
labels?: string[];
|
|
90
|
+
assignee?: string;
|
|
91
|
+
resolution?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ListFilter {
|
|
95
|
+
project?: string;
|
|
96
|
+
status?: Status;
|
|
97
|
+
labels?: string[];
|
|
98
|
+
assignee?: string;
|
|
99
|
+
query?: string;
|
|
100
|
+
limit?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** "backend:key" ref parsing, split on the first colon only (keys may contain colons). */
|
|
104
|
+
export function parseRef(ref: string): { backend: string; key: string } {
|
|
105
|
+
const idx = ref.indexOf(":");
|
|
106
|
+
if (idx <= 0 || idx === ref.length - 1) {
|
|
107
|
+
throw new Error(`invalid ref ${JSON.stringify(ref)}: expected "backend:key"`);
|
|
108
|
+
}
|
|
109
|
+
return { backend: ref.slice(0, idx), key: ref.slice(idx + 1) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function makeRef(backend: string, key: string): string {
|
|
113
|
+
return `${backend}:${key}`;
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export * from "./domain/issue.js";
|
|
2
|
+
export * from "./ports/repository.js";
|
|
3
|
+
export * from "./adapters/errors.js";
|
|
4
|
+
export { GitHubRepository, type GitHubOptions } from "./adapters/github.js";
|
|
5
|
+
export { GitLabRepository, type GitLabOptions } from "./adapters/gitlab.js";
|
|
6
|
+
export { JiraRepository, type JiraOptions } from "./adapters/jira.js";
|
|
7
|
+
export { TicketService, UnknownBackendError, NotSupportedError } from "./application/service.js";
|
|
8
|
+
export {
|
|
9
|
+
type BackendConfig,
|
|
10
|
+
type Config,
|
|
11
|
+
loadConfig,
|
|
12
|
+
buildRepositories,
|
|
13
|
+
defaultConfigPath,
|
|
14
|
+
configDir,
|
|
15
|
+
} from "./config/config.js";
|
|
16
|
+
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
|
|
17
|
+
export {
|
|
18
|
+
createTicketsClient,
|
|
19
|
+
ensureDaemonRunning,
|
|
20
|
+
ticketsPaths,
|
|
21
|
+
type TicketsRpcClient,
|
|
22
|
+
} from "./client/tickets-client.js";
|
|
23
|
+
|
|
24
|
+
// Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
|
|
25
|
+
// — see RESEARCH.md for why each backend gets a different flow.
|
|
26
|
+
export * from "./auth/device-flow.js";
|
|
27
|
+
export * from "./auth/token-store.js";
|
|
28
|
+
export { openUrl } from "./auth/browser.js";
|
|
29
|
+
export * from "./auth/github-oauth.js";
|
|
30
|
+
export * from "./auth/gitlab-oauth.js";
|
|
31
|
+
export * from "./auth/jira-oauth.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound ports — contracts driven adapters (GitHub, GitLab, Jira, ...) implement.
|
|
3
|
+
* The application layer depends only on these interfaces, never on a concrete adapter.
|
|
4
|
+
*/
|
|
5
|
+
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
|
|
6
|
+
|
|
7
|
+
export interface IssueRepository {
|
|
8
|
+
/** Backend identifier used in refs, e.g. "github", "gitlab", "jira". */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
|
|
11
|
+
list(filter: ListFilter): Promise<Issue[]>;
|
|
12
|
+
get(key: string): Promise<Issue>;
|
|
13
|
+
create(input: CreateInput): Promise<Issue>;
|
|
14
|
+
update(key: string, input: UpdateInput): Promise<Issue>;
|
|
15
|
+
search(query: string, limit?: number): Promise<Issue[]>;
|
|
16
|
+
listChildren(key: string): Promise<Issue[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Optional capability — not every backend supports comments the same way (all three here do). */
|
|
20
|
+
export interface CommentCapable {
|
|
21
|
+
listComments(key: string): Promise<Comment[]>;
|
|
22
|
+
addComment(key: string, body: string): Promise<Comment>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasComments(repo: IssueRepository): repo is IssueRepository & CommentCapable {
|
|
26
|
+
return typeof (repo as Partial<CommentCapable>).listComments === "function";
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walks upward from `startDir` looking for this package's own package.json
|
|
6
|
+
* (matched by name, not just presence — a consumer's own package.json could
|
|
7
|
+
* sit above an installed copy). Bounded to `maxLevels` so a misconfigured
|
|
8
|
+
* install fails fast instead of walking to filesystem root.
|
|
9
|
+
*/
|
|
10
|
+
export function packageRoot(startDir: string, packageName = "@danypops/tickets", maxLevels = 8): string {
|
|
11
|
+
let dir = startDir;
|
|
12
|
+
for (let i = 0; i < maxLevels; i++) {
|
|
13
|
+
const candidate = join(dir, "package.json");
|
|
14
|
+
if (existsSync(candidate)) {
|
|
15
|
+
try {
|
|
16
|
+
const manifest = JSON.parse(readFileSync(candidate, "utf8")) as { name?: string };
|
|
17
|
+
if (manifest.name === packageName) return dir;
|
|
18
|
+
} catch {
|
|
19
|
+
// fall through and keep walking — an unrelated/unparsable package.json
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const parent = dirname(dir);
|
|
23
|
+
if (parent === dir) break;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`could not locate package root for "${packageName}" above ${startDir}`);
|
|
27
|
+
}
|