@danypops/tickets 0.11.0 → 0.13.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/package.json +3 -3
- package/src/agent-tools/tickets-vehicle.ts +86 -5
- package/src/cli/index.ts +61 -0
- package/src/github/github.ts +45 -12
- package/src/gitlab/gitlab.ts +54 -9
- package/src/issue/issue.ts +24 -0
- package/src/jira/jira.ts +26 -0
- package/src/process/bootstrap.ts +21 -2
- package/src/process/watch-sync.ts +204 -0
- package/src/rpc/ops.ts +29 -0
- package/src/rpc/server.ts +47 -0
- package/src/sqlite/watches.ts +355 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/vehicle-core": "^0.
|
|
28
|
-
"@danypops/vehicle-server": "^0.
|
|
27
|
+
"@danypops/vehicle-core": "^0.15.0",
|
|
28
|
+
"@danypops/vehicle-server": "^0.21.0",
|
|
29
29
|
"@danypops/vehicle-client": "^0.7.1",
|
|
30
30
|
"@danypops/enigma-client": "^0.6.1",
|
|
31
31
|
"@gitbeaker/rest": "^43.8.0",
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
26
26
|
import type { BackendCapabilities, TicketService } from "../issue/service.js";
|
|
27
27
|
import type { TicketOperation } from "../rpc/ops.js";
|
|
28
|
-
import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
|
|
28
|
+
import { type HandlerCallContext, TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
|
|
29
29
|
import { withTicketsErrorParity } from "./error-mapping.js";
|
|
30
30
|
|
|
31
31
|
const OWNER = "tickets";
|
|
@@ -34,6 +34,7 @@ const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes
|
|
|
34
34
|
|
|
35
35
|
const stringProp: LooseObjectProperty = { type: "string" };
|
|
36
36
|
const numberProp: LooseObjectProperty = { type: "number" };
|
|
37
|
+
const booleanProp: LooseObjectProperty = { type: "boolean" };
|
|
37
38
|
|
|
38
39
|
interface OperationSpec {
|
|
39
40
|
readonly action: TicketOperation;
|
|
@@ -71,7 +72,8 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
71
72
|
},
|
|
72
73
|
{
|
|
73
74
|
action: "issue.list",
|
|
74
|
-
description:
|
|
75
|
+
description:
|
|
76
|
+
"Lists issues from one backend, optionally filtered. reportedByMe/assignedToMe/reviewRequestedOfMe/qaContactIsMe filter to the caller's own tickets using each backend's own identity (no username needed); set more than one to OR them together. reviewRequestedOfMe is GitHub/GitLab-only (PR/MR reviewer requests); qaContactIsMe is Jira-only (its discovered 'QA Contact' field) -- an unsupported flag on a given backend throws rather than being silently ignored.",
|
|
75
77
|
effect: "read",
|
|
76
78
|
properties: {
|
|
77
79
|
backend: stringProp,
|
|
@@ -80,11 +82,25 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
80
82
|
assignee: stringProp,
|
|
81
83
|
labels: stringArrayProp,
|
|
82
84
|
limit: numberProp,
|
|
85
|
+
reportedByMe: booleanProp,
|
|
86
|
+
assignedToMe: booleanProp,
|
|
87
|
+
reviewRequestedOfMe: booleanProp,
|
|
88
|
+
qaContactIsMe: booleanProp,
|
|
83
89
|
},
|
|
84
90
|
required: ["backend"],
|
|
85
|
-
mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
|
|
91
|
+
mapInput: ({ backend, project, status, assignee, labels, limit, reportedByMe, assignedToMe, reviewRequestedOfMe, qaContactIsMe }) => ({
|
|
86
92
|
backend,
|
|
87
|
-
filter: definedEntriesOnly({
|
|
93
|
+
filter: definedEntriesOnly({
|
|
94
|
+
project,
|
|
95
|
+
status,
|
|
96
|
+
assignee,
|
|
97
|
+
labels,
|
|
98
|
+
limit,
|
|
99
|
+
reportedByMe,
|
|
100
|
+
assignedToMe,
|
|
101
|
+
reviewRequestedOfMe,
|
|
102
|
+
qaContactIsMe,
|
|
103
|
+
}),
|
|
88
104
|
}),
|
|
89
105
|
},
|
|
90
106
|
{
|
|
@@ -249,6 +265,61 @@ const OPERATIONS: readonly OperationSpec[] = [
|
|
|
249
265
|
properties: { name: stringProp, limit: numberProp },
|
|
250
266
|
required: ["name"],
|
|
251
267
|
},
|
|
268
|
+
{
|
|
269
|
+
action: "issue.subscribe",
|
|
270
|
+
description:
|
|
271
|
+
"Has the daemon keep watching one issue in the background -- comments, status, and other field changes are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId scopes this watch to one caller (defaults to this calling Pi session's own real session id, then a shared anonymous subscriber for a raw RPC client with no session at all); scheduleMs bounds how often that subscriber's watch is refreshed, in milliseconds (omit to refresh on every background sync tick). projectRoot attributes this subscription to a project -- defaults to this calling session's own cwd; rarely needs to be passed explicitly.",
|
|
272
|
+
effect: "local-write",
|
|
273
|
+
properties: { ref: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
|
|
274
|
+
required: ["ref"],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
action: "issue.unsubscribe",
|
|
278
|
+
description:
|
|
279
|
+
"Stops the daemon watching one issue in the background. Idempotent -- no error if it wasn't subscribed. subscriberId removes only that one caller's watch, leaving any other subscriber's own watch on the same issue intact.",
|
|
280
|
+
effect: "local-write",
|
|
281
|
+
properties: { ref: stringProp, subscriberId: stringProp },
|
|
282
|
+
required: ["ref"],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
action: "issue.subscribed",
|
|
286
|
+
description:
|
|
287
|
+
"Every issue this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
|
|
288
|
+
effect: "read",
|
|
289
|
+
properties: { subscriberId: stringProp },
|
|
290
|
+
required: [],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
action: "query.subscribe",
|
|
294
|
+
description:
|
|
295
|
+
"Has the daemon keep re-running one saved query in the background -- new matching items or items that drop out are reported through watch_events, no manual re-checking needed. Idempotent. subscriberId/scheduleMs/projectRoot behave exactly like issue.subscribe's own.",
|
|
296
|
+
effect: "local-write",
|
|
297
|
+
properties: { name: stringProp, subscriberId: stringProp, scheduleMs: numberProp, projectRoot: stringProp },
|
|
298
|
+
required: ["name"],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
action: "query.unsubscribe",
|
|
302
|
+
description: "Stops the daemon re-running one saved query in the background. Idempotent -- no error if it wasn't subscribed.",
|
|
303
|
+
effect: "local-write",
|
|
304
|
+
properties: { name: stringProp, subscriberId: stringProp },
|
|
305
|
+
required: ["name"],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
action: "query.subscribed",
|
|
309
|
+
description:
|
|
310
|
+
"Every saved query this subscriber is currently watching -- never a live backend call, cheap to call frequently. subscriberId defaults to this calling Pi session's own real session id.",
|
|
311
|
+
effect: "read",
|
|
312
|
+
properties: { subscriberId: stringProp },
|
|
313
|
+
required: [],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
action: "watch.events",
|
|
317
|
+
description:
|
|
318
|
+
"New change events (comments, status, label/field changes, saved-query membership changes) for everything this subscriber currently watches, since sinceId -- cheaper than re-fetching every watched issue/query yourself. Pass the previous call's lastId as sinceId to page forward; omit it once to start from 'now' without replaying history.",
|
|
319
|
+
effect: "read",
|
|
320
|
+
properties: { subscriberId: stringProp, sinceId: numberProp, limit: numberProp },
|
|
321
|
+
required: [],
|
|
322
|
+
},
|
|
252
323
|
{
|
|
253
324
|
action: "stage.add",
|
|
254
325
|
description:
|
|
@@ -403,7 +474,17 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
|
|
|
403
474
|
bindVehicleOperation(
|
|
404
475
|
operation,
|
|
405
476
|
() => async (context) =>
|
|
406
|
-
withTicketsErrorParity<unknown>(() =>
|
|
477
|
+
withTicketsErrorParity<unknown>(() => {
|
|
478
|
+
// Threaded through unconditionally -- harmless for the vast majority of handlers that
|
|
479
|
+
// never read it; issue.subscribe/query.subscribe (and their siblings) use it to default
|
|
480
|
+
// subscriberId/projectRoot from this real call's own session identity, mirroring
|
|
481
|
+
// @danypops/pipes' own ci.subscribe. See HandlerCallContext's own doc comment.
|
|
482
|
+
const callContext: HandlerCallContext = {
|
|
483
|
+
callerSessionId: context.callerSessionId,
|
|
484
|
+
callerProjectRoot: context.callerProjectRoot,
|
|
485
|
+
};
|
|
486
|
+
return handler(deps, mapInput(context.input as Record<string, unknown>) as never, callContext);
|
|
487
|
+
}),
|
|
407
488
|
),
|
|
408
489
|
);
|
|
409
490
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -47,6 +47,10 @@ program
|
|
|
47
47
|
.option("--assignee <user>", "filter by assignee")
|
|
48
48
|
.option("--label <label...>", "filter by label(s)")
|
|
49
49
|
.option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
|
|
50
|
+
.option("--reported-by-me", "only issues reported/authored by you (Jira reporter / GitHub PR author / GitLab issue author)")
|
|
51
|
+
.option("--assigned-to-me", "only issues assigned to you")
|
|
52
|
+
.option("--review-requested-of-me", "only PRs/MRs where you're requested as a reviewer (GitHub/GitLab only)")
|
|
53
|
+
.option("--qa-contact-is-me", "only issues where you're the QA Contact (Jira only)")
|
|
50
54
|
.action(async (opts) => {
|
|
51
55
|
const filter: ListFilter = {
|
|
52
56
|
project: opts.project,
|
|
@@ -54,6 +58,10 @@ program
|
|
|
54
58
|
assignee: opts.assignee,
|
|
55
59
|
labels: opts.label,
|
|
56
60
|
limit: opts.limit,
|
|
61
|
+
reportedByMe: opts.reportedByMe,
|
|
62
|
+
assignedToMe: opts.assignedToMe,
|
|
63
|
+
reviewRequestedOfMe: opts.reviewRequestedOfMe,
|
|
64
|
+
qaContactIsMe: opts.qaContactIsMe,
|
|
57
65
|
};
|
|
58
66
|
await withClient((client) => client.call("issue.list", { backend: opts.backend, filter }));
|
|
59
67
|
});
|
|
@@ -150,6 +158,37 @@ program
|
|
|
150
158
|
await withClient((client) => client.call("issue.merge", { ref, method: opts.method }));
|
|
151
159
|
});
|
|
152
160
|
|
|
161
|
+
program
|
|
162
|
+
.command("subscribe <ref>")
|
|
163
|
+
.description("watch one issue in the background -- comments, status, and other field changes are reported via `watch-events`")
|
|
164
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
165
|
+
.action(async (ref: string, opts) => {
|
|
166
|
+
await withClient((client) => client.call("issue.subscribe", { ref, scheduleMs: opts.scheduleMs }));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
program
|
|
170
|
+
.command("unsubscribe <ref>")
|
|
171
|
+
.description("stop watching one issue")
|
|
172
|
+
.action(async (ref: string) => {
|
|
173
|
+
await withClient((client) => client.call("issue.unsubscribe", { ref }));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
program
|
|
177
|
+
.command("subscribed")
|
|
178
|
+
.description("list every issue you're currently watching")
|
|
179
|
+
.action(async () => {
|
|
180
|
+
await withClient((client) => client.call("issue.subscribed", {}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("watch-events")
|
|
185
|
+
.description("new change events for everything you're currently watching (issues and saved queries), since --since-id")
|
|
186
|
+
.option("--since-id <id>", "only events after this event id", (v) => Number.parseInt(v, 10))
|
|
187
|
+
.option("--limit <n>", "max events", (v) => Number.parseInt(v, 10))
|
|
188
|
+
.action(async (opts) => {
|
|
189
|
+
await withClient((client) => client.call("watch.events", { sinceId: opts.sinceId, limit: opts.limit }));
|
|
190
|
+
});
|
|
191
|
+
|
|
153
192
|
const comment = program.command("comment").description("comment operations");
|
|
154
193
|
|
|
155
194
|
comment
|
|
@@ -313,6 +352,28 @@ queryCmd
|
|
|
313
352
|
await withClient((client) => client.call("query.run", { name, limit: opts.limit }));
|
|
314
353
|
});
|
|
315
354
|
|
|
355
|
+
queryCmd
|
|
356
|
+
.command("subscribe <name>")
|
|
357
|
+
.description("watch one saved query in the background -- new/dropped items are reported via `watch-events`")
|
|
358
|
+
.option("--schedule-ms <ms>", "minimum check cadence for this subscription, in milliseconds", (v) => Number.parseInt(v, 10))
|
|
359
|
+
.action(async (name: string, opts) => {
|
|
360
|
+
await withClient((client) => client.call("query.subscribe", { name, scheduleMs: opts.scheduleMs }));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
queryCmd
|
|
364
|
+
.command("unsubscribe <name>")
|
|
365
|
+
.description("stop watching one saved query")
|
|
366
|
+
.action(async (name: string) => {
|
|
367
|
+
await withClient((client) => client.call("query.unsubscribe", { name }));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
queryCmd
|
|
371
|
+
.command("subscribed")
|
|
372
|
+
.description("list every saved query you're currently watching")
|
|
373
|
+
.action(async () => {
|
|
374
|
+
await withClient((client) => client.call("query.subscribed", {}));
|
|
375
|
+
});
|
|
376
|
+
|
|
316
377
|
discoverCmd
|
|
317
378
|
.command("board_quickfilter")
|
|
318
379
|
.description(
|
package/src/github/github.ts
CHANGED
|
@@ -165,19 +165,34 @@ export class GitHubRepository {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
168
|
+
if (filter.qaContactIsMe) throw new Error('github: "qaContactIsMe" is a Jira-only concept, not supported on the github backend');
|
|
168
169
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
170
|
+
const meQualifiers = buildMeQualifiers(filter);
|
|
171
|
+
if (meQualifiers.length === 0) {
|
|
172
|
+
const raw = await this.call((signal) =>
|
|
173
|
+
this.client.rest.issues.listForRepo({
|
|
174
|
+
owner: this.owner,
|
|
175
|
+
repo: this.repoName(),
|
|
176
|
+
per_page: limit,
|
|
177
|
+
state: filter.status ? mapStatusToGitHub(filter.status) : "all",
|
|
178
|
+
assignee: filter.assignee,
|
|
179
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
180
|
+
request: { signal },
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
return (raw as GhIssue[]).map((i) => toDomain(i));
|
|
184
|
+
}
|
|
185
|
+
// Any "me" flag routes through the Search API instead of listForRepo: it's the only GitHub
|
|
186
|
+
// surface with a review-requested filter at all (confirmed against GitHub's own REST docs --
|
|
187
|
+
// listForRepo has no such parameter), and it happens to support author/assignee "me" qualifiers
|
|
188
|
+
// too, so one call covers every combination of the three flags. Response items are Issue-shaped
|
|
189
|
+
// (same pull_request: {merged_at} stub as listForRepo's own entries -- confirmed against
|
|
190
|
+
// @octokit/openapi-types' issue-search-result-item schema), so toDomain() applies unchanged.
|
|
191
|
+
const q = buildMeSearchQuery(this.owner, this.repoName(), filter, meQualifiers);
|
|
192
|
+
const result = (await this.call((signal) =>
|
|
193
|
+
this.client.rest.search.issuesAndPullRequests({ q, per_page: limit, request: { signal } }),
|
|
194
|
+
)) as { items: GhIssue[] };
|
|
195
|
+
return result.items.map((i) => toDomain(i));
|
|
181
196
|
}
|
|
182
197
|
|
|
183
198
|
async get(key: string): Promise<Issue> {
|
|
@@ -362,6 +377,24 @@ function mapStatusToGitHub(status: Status): "open" | "closed" {
|
|
|
362
377
|
return status === "done" || status === "canceled" ? "closed" : "open";
|
|
363
378
|
}
|
|
364
379
|
|
|
380
|
+
/** ListFilter.{reportedByMe,assignedToMe,reviewRequestedOfMe} -> GitHub Search API qualifiers -- see ListFilter's own doc comment. */
|
|
381
|
+
function buildMeQualifiers(filter: ListFilter): string[] {
|
|
382
|
+
const qualifiers: string[] = [];
|
|
383
|
+
if (filter.reportedByMe) qualifiers.push("author:@me");
|
|
384
|
+
if (filter.assignedToMe) qualifiers.push("assignee:@me");
|
|
385
|
+
if (filter.reviewRequestedOfMe) qualifiers.push("user-review-requested:@me");
|
|
386
|
+
return qualifiers;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Composes a full Search API `q` string: repo scope + status/labels (AND, matching list()'s own semantics) + the "me" qualifiers (OR'd together). */
|
|
390
|
+
function buildMeSearchQuery(owner: string, repo: string, filter: ListFilter, meQualifiers: readonly string[]): string {
|
|
391
|
+
const parts = [`repo:${owner}/${repo}`];
|
|
392
|
+
if (filter.status) parts.push(`is:${mapStatusToGitHub(filter.status)}`);
|
|
393
|
+
for (const label of filter.labels ?? []) parts.push(`label:"${label.replace(/"/g, '\\"')}"`);
|
|
394
|
+
parts.push(meQualifiers.length === 1 ? meQualifiers[0]! : `(${meQualifiers.join(" OR ")})`);
|
|
395
|
+
return parts.join(" ");
|
|
396
|
+
}
|
|
397
|
+
|
|
365
398
|
function mapStatusFromGitHub(state: string): Status {
|
|
366
399
|
return state.toLowerCase() === "closed" ? "done" : "todo";
|
|
367
400
|
}
|
package/src/gitlab/gitlab.ts
CHANGED
|
@@ -166,17 +166,54 @@ export class GitLabRepository {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
169
|
+
if (filter.qaContactIsMe) throw new Error('gitlab: "qaContactIsMe" is a Jira-only concept, not supported on the gitlab backend');
|
|
170
|
+
if (filter.reviewRequestedOfMe) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'gitlab: "reviewRequestedOfMe" is a merge-request-only concept (scope=reviews_for_me) that list()\'s Issues-only scope can\'t express -- see get()\'s "!<iid>" convention for merge requests',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
169
175
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
const scopes = meScopes(filter);
|
|
177
|
+
if (scopes.length <= 1) {
|
|
178
|
+
const raw = await this.call<GlIssue[]>(() =>
|
|
179
|
+
this.client.Issues.all({
|
|
180
|
+
projectId: this.projectId,
|
|
181
|
+
perPage: limit,
|
|
182
|
+
state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
|
|
183
|
+
assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
|
|
184
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
185
|
+
...(scopes[0] ? { scope: scopes[0] } : {}),
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
return raw.map(toDomain);
|
|
189
|
+
}
|
|
190
|
+
// Both reportedByMe and assignedToMe: GitLab's own `scope` param takes exactly one value per
|
|
191
|
+
// call (no server-side OR across scopes), so the OR-of-roles group ListFilter's own doc comment
|
|
192
|
+
// describes is executed here as two calls, deduped client-side by ref -- the one place in this
|
|
193
|
+
// adapter an OR of "me" flags genuinely costs more than one request (Jira/GitHub both express
|
|
194
|
+
// it in a single native query).
|
|
195
|
+
const results = await Promise.all(
|
|
196
|
+
scopes.map((scope) =>
|
|
197
|
+
this.call<GlIssue[]>(() =>
|
|
198
|
+
this.client.Issues.all({
|
|
199
|
+
projectId: this.projectId,
|
|
200
|
+
perPage: limit,
|
|
201
|
+
state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
|
|
202
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
203
|
+
scope,
|
|
204
|
+
}),
|
|
205
|
+
),
|
|
206
|
+
),
|
|
178
207
|
);
|
|
179
|
-
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
const merged: Issue[] = [];
|
|
210
|
+
for (const raw of results.flat()) {
|
|
211
|
+
const issue = toDomain(raw);
|
|
212
|
+
if (seen.has(issue.ref)) continue;
|
|
213
|
+
seen.add(issue.ref);
|
|
214
|
+
merged.push(issue);
|
|
215
|
+
}
|
|
216
|
+
return merged.slice(0, limit);
|
|
180
217
|
}
|
|
181
218
|
|
|
182
219
|
async get(key: string): Promise<Issue> {
|
|
@@ -372,6 +409,14 @@ function mapStatusToGitLab(status: Status): "opened" | "closed" {
|
|
|
372
409
|
return status === "done" || status === "canceled" ? "closed" : "opened";
|
|
373
410
|
}
|
|
374
411
|
|
|
412
|
+
/** ListFilter.{reportedByMe,assignedToMe} -> GitLab Issues API `scope` values -- see ListFilter's own doc comment. */
|
|
413
|
+
function meScopes(filter: ListFilter): ("created_by_me" | "assigned_to_me")[] {
|
|
414
|
+
const scopes: ("created_by_me" | "assigned_to_me")[] = [];
|
|
415
|
+
if (filter.reportedByMe) scopes.push("created_by_me");
|
|
416
|
+
if (filter.assignedToMe) scopes.push("assigned_to_me");
|
|
417
|
+
return scopes;
|
|
418
|
+
}
|
|
419
|
+
|
|
375
420
|
function mapStatusEventToGitLab(status: Status): "close" | "reopen" {
|
|
376
421
|
return status === "done" || status === "canceled" ? "close" : "reopen";
|
|
377
422
|
}
|
package/src/issue/issue.ts
CHANGED
|
@@ -178,6 +178,30 @@ export interface ListFilter {
|
|
|
178
178
|
assignee?: string;
|
|
179
179
|
query?: string;
|
|
180
180
|
limit?: number;
|
|
181
|
+
/**
|
|
182
|
+
* "Mine" filtering -- deliberately a small set of named, orthogonal boolean flags rather than
|
|
183
|
+
* either a hardcoded single `mine` concept or a generic cross-backend query language (see the
|
|
184
|
+
* research Doc "Tickets 'mine' filtering: grounded API research" for why both alternatives were
|
|
185
|
+
* rejected). Every backend maps each flag to its own native "current user" mechanism (Jira's JQL
|
|
186
|
+
* currentUser(), GitHub's Search API @me qualifiers, GitLab's scope=*_me) -- never a resolved
|
|
187
|
+
* literal username. Setting more than one of these ORs them together ("assignee OR reporter"),
|
|
188
|
+
* AND'd with every other ListFilter field exactly like today's plain fields. A backend that has
|
|
189
|
+
* no native equivalent for a given flag throws rather than silently ignoring it (e.g.
|
|
190
|
+
* reviewRequestedOfMe on Jira, qaContactIsMe on GitHub/GitLab) -- a silently-dropped role would
|
|
191
|
+
* make an incomplete result look complete.
|
|
192
|
+
*/
|
|
193
|
+
/** Jira: `reporter = currentUser()`. GitHub: `author:@me` (Search API). GitLab: `scope=created_by_me` (Issues API). */
|
|
194
|
+
reportedByMe?: boolean;
|
|
195
|
+
/** Jira: `assignee = currentUser()`. GitHub: `assignee:@me` (Search API). GitLab: `scope=assigned_to_me` (Issues API). */
|
|
196
|
+
assignedToMe?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* GitHub: `user-review-requested:@me` (Search API, PRs only). GitLab: `scope=reviews_for_me` --
|
|
199
|
+
* a merge-request-only concept list()'s Issues-only scope can't express, so GitLab throws. Jira
|
|
200
|
+
* has no reviewer concept on plain issues, so Jira throws too.
|
|
201
|
+
*/
|
|
202
|
+
reviewRequestedOfMe?: boolean;
|
|
203
|
+
/** Jira only, via the discovered "QA Contact" custom field (auto-discovers if never run). GitHub/GitLab throw -- no equivalent concept. */
|
|
204
|
+
qaContactIsMe?: boolean;
|
|
181
205
|
}
|
|
182
206
|
|
|
183
207
|
/** "backend:key" ref parsing, split on the first colon only (keys may contain colons). */
|
package/src/jira/jira.ts
CHANGED
|
@@ -204,6 +204,11 @@ export class JiraRepository {
|
|
|
204
204
|
* the background), not just the single legacy `project` config field.
|
|
205
205
|
*/
|
|
206
206
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
207
|
+
if (filter.reviewRequestedOfMe) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
'jira: "reviewRequestedOfMe" has no meaning on Jira issues (no reviewer concept) -- omit this filter for the jira backend',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
207
212
|
const projects = filter.project ? [filter.project] : this.defaultProjects();
|
|
208
213
|
const clauses: string[] = [];
|
|
209
214
|
const scope = projectClause(projects);
|
|
@@ -211,9 +216,30 @@ export class JiraRepository {
|
|
|
211
216
|
if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
|
|
212
217
|
if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
|
|
213
218
|
for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
|
|
219
|
+
const meClauses = await this.buildMeClauses(filter);
|
|
220
|
+
if (meClauses.length > 0) clauses.push(meClauses.length === 1 ? meClauses[0]! : `(${meClauses.join(" OR ")})`);
|
|
214
221
|
return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
|
|
215
222
|
}
|
|
216
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Builds the OR'd group of "me" role clauses for list()'s ListFilter.{reportedByMe,assignedToMe,
|
|
226
|
+
* qaContactIsMe} flags -- see ListFilter's own doc comment for why this is a small set of named
|
|
227
|
+
* flags rather than a hardcoded single "mine" concept or a generic query language. "QA Contact"
|
|
228
|
+
* resolves through the same discoverFields()-backed manifest applyCustomFields() already uses
|
|
229
|
+
* (auto-discovering on first use), so an instance without that field throws a clear "unknown
|
|
230
|
+
* custom field" error rather than silently omitting the clause.
|
|
231
|
+
*/
|
|
232
|
+
private async buildMeClauses(filter: ListFilter): Promise<string[]> {
|
|
233
|
+
const clauses: string[] = [];
|
|
234
|
+
if (filter.assignedToMe) clauses.push("assignee = currentUser()");
|
|
235
|
+
if (filter.reportedByMe) clauses.push("reporter = currentUser()");
|
|
236
|
+
if (filter.qaContactIsMe) {
|
|
237
|
+
await this.resolveCustomField("QA Contact");
|
|
238
|
+
clauses.push('"QA Contact" = currentUser()');
|
|
239
|
+
}
|
|
240
|
+
return clauses;
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
async get(key: string): Promise<Issue> {
|
|
218
244
|
const [raw, remoteLinks] = await Promise.all([
|
|
219
245
|
this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key),
|
package/src/process/bootstrap.ts
CHANGED
|
@@ -19,8 +19,10 @@ import { buildApp, type TicketsAppDeps } from "../rpc/server.js";
|
|
|
19
19
|
import { FOCUS_MIGRATIONS, FocusStore } from "../sqlite/focus.js";
|
|
20
20
|
import { LEDGER_MIGRATIONS, Ledger } from "../sqlite/ledger.js";
|
|
21
21
|
import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
22
|
+
import { WATCH_MIGRATIONS, WatchStore } from "../sqlite/watches.js";
|
|
22
23
|
import { StageStore } from "../stage/store.js";
|
|
23
24
|
import { createSyncTask } from "./poller.js";
|
|
25
|
+
import { createIssueWatchSyncTask, createQueryWatchSyncTask } from "./watch-sync.js";
|
|
24
26
|
|
|
25
27
|
export interface BootstrapOptions {
|
|
26
28
|
pathEnv?: PathEnvironment;
|
|
@@ -39,6 +41,10 @@ export interface BootstrapOptions {
|
|
|
39
41
|
checkpointIntervalMs?: number;
|
|
40
42
|
/** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
|
|
41
43
|
backendRefreshIntervalMs?: number;
|
|
44
|
+
/** How often every subscribed issue is re-fetched and diffed. Defaults to DEFAULT_ISSUE_WATCH_INTERVAL_MS. */
|
|
45
|
+
issueWatchIntervalMs?: number;
|
|
46
|
+
/** How often every subscribed saved query is re-run and diffed. Defaults to DEFAULT_QUERY_WATCH_INTERVAL_MS. */
|
|
47
|
+
queryWatchIntervalMs?: number;
|
|
42
48
|
/**
|
|
43
49
|
* Overrides the daemon.shutdown op's effect. Defaults to sending this
|
|
44
50
|
* process SIGTERM, which vehicle-server's runDaemonProcess already handles
|
|
@@ -54,6 +60,7 @@ export interface BootstrappedDaemon {
|
|
|
54
60
|
focusStore: FocusStore;
|
|
55
61
|
queries: SavedQueryStore;
|
|
56
62
|
stageStore: StageStore;
|
|
63
|
+
watches: WatchStore;
|
|
57
64
|
service: TicketService;
|
|
58
65
|
options: StartDaemonOptions;
|
|
59
66
|
}
|
|
@@ -61,15 +68,23 @@ export interface BootstrappedDaemon {
|
|
|
61
68
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
62
69
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
63
70
|
const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
|
|
71
|
+
/** Deliberately coarser than pipes' own 30s RUN_POOL_SYNC_INTERVAL_MS -- a CI run's status changes
|
|
72
|
+
* on the order of seconds/minutes; an issue's comments/status change on the order of minutes/hours,
|
|
73
|
+
* so polling that fast would only waste API quota against GitHub/GitLab/Jira's own rate limits. */
|
|
74
|
+
const DEFAULT_ISSUE_WATCH_INTERVAL_MS = 60_000;
|
|
75
|
+
const DEFAULT_QUERY_WATCH_INTERVAL_MS = 60_000;
|
|
64
76
|
|
|
65
77
|
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
66
78
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
67
79
|
const token = ensureAuthToken(paths.token, "Tickets");
|
|
68
|
-
const db = openSqliteWithPragmas(paths.database, {
|
|
80
|
+
const db = openSqliteWithPragmas(paths.database, {
|
|
81
|
+
migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS, ...SAVED_QUERY_MIGRATIONS, ...WATCH_MIGRATIONS],
|
|
82
|
+
});
|
|
69
83
|
const ledger = new Ledger(db);
|
|
70
84
|
const focusStore = new FocusStore(db);
|
|
71
85
|
const queries = new SavedQueryStore(db);
|
|
72
86
|
const stageStore = new StageStore();
|
|
87
|
+
const watches = new WatchStore(db);
|
|
73
88
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
74
89
|
const config = opts.config ?? loadConfig();
|
|
75
90
|
const buildRepos = opts.buildRepositories ?? buildRepositories;
|
|
@@ -88,6 +103,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
88
103
|
focusStore,
|
|
89
104
|
queries,
|
|
90
105
|
stageStore,
|
|
106
|
+
watches,
|
|
91
107
|
token,
|
|
92
108
|
version,
|
|
93
109
|
logger,
|
|
@@ -100,6 +116,8 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
100
116
|
logger,
|
|
101
117
|
maintenanceTasks: [
|
|
102
118
|
createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
|
|
119
|
+
createIssueWatchSyncTask(service, watches, opts.issueWatchIntervalMs ?? DEFAULT_ISSUE_WATCH_INTERVAL_MS, logger),
|
|
120
|
+
createQueryWatchSyncTask(service, queries, watches, opts.queryWatchIntervalMs ?? DEFAULT_QUERY_WATCH_INTERVAL_MS, logger),
|
|
103
121
|
{
|
|
104
122
|
name: "checkpoint",
|
|
105
123
|
intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
@@ -127,6 +145,7 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
127
145
|
focusStore,
|
|
128
146
|
queries,
|
|
129
147
|
stageStore,
|
|
148
|
+
watches,
|
|
130
149
|
token,
|
|
131
150
|
version,
|
|
132
151
|
logger,
|
|
@@ -138,5 +157,5 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
138
157
|
},
|
|
139
158
|
};
|
|
140
159
|
|
|
141
|
-
return { db, ledger, focusStore, queries, stageStore, service, options };
|
|
160
|
+
return { db, ledger, focusStore, queries, stageStore, watches, service, options };
|
|
142
161
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch sync — the tickets daemon's own analog of @danypops/pipes' run/monitor.ts's syncRunPool,
|
|
3
|
+
* for individual issues and saved queries instead of CI jobs. Two independent maintenance tasks
|
|
4
|
+
* (createIssueWatchSyncTask / createQueryWatchSyncTask, wired in process/bootstrap.ts), same
|
|
5
|
+
* shape as pipes': read the current subscription list fresh from SQLite every tick, group by key
|
|
6
|
+
* (so N subscribers on the same ref/query share one live fetch), skip anything not yet due per its
|
|
7
|
+
* own scheduleMs, fetch once per due group, diff against the last-cached snapshot, and only
|
|
8
|
+
* persist a WatchEvent (via WatchStore.recordEvent) on a real, human-describable change.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately never unsubscribes anything on its own (see watches.ts's own doc comment) -- a
|
|
11
|
+
* failed fetch for one key is logged and skipped, retried next tick, exactly like pipes' own
|
|
12
|
+
* per-group isolation.
|
|
13
|
+
*/
|
|
14
|
+
import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
|
|
15
|
+
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
16
|
+
import type { TicketService } from "../issue/service.js";
|
|
17
|
+
import type { SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
18
|
+
import type { IssueWatchSubscription, QueryWatchSubscription, WatchStore } from "../sqlite/watches.js";
|
|
19
|
+
|
|
20
|
+
const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
21
|
+
|
|
22
|
+
const DEFAULT_QUERY_WATCH_LIMIT = 50;
|
|
23
|
+
|
|
24
|
+
/** True if this subscription's own cadence has elapsed since it was last checked -- always true for a subscription with no scheduleMs, matching pipes' own isDue. */
|
|
25
|
+
function isDue(subscription: { scheduleMs?: number; lastCheckedAt?: Date }, nowMs: number): boolean {
|
|
26
|
+
if (subscription.scheduleMs === undefined) return true;
|
|
27
|
+
if (subscription.lastCheckedAt === undefined) return true;
|
|
28
|
+
return nowMs - subscription.lastCheckedAt.getTime() >= subscription.scheduleMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
|
|
32
|
+
const groups = new Map<K, T[]>();
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const k = key(item);
|
|
35
|
+
const existing = groups.get(k);
|
|
36
|
+
if (existing) existing.push(item);
|
|
37
|
+
else groups.set(k, [item]);
|
|
38
|
+
}
|
|
39
|
+
return groups;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort comment count: undefined (never diffed on) for a backend that doesn't support
|
|
44
|
+
* comments at all (NotSupportedError) or a transient failure fetching them -- a missing comment
|
|
45
|
+
* count must never itself look like "0 comments" and falsely report "N new comments" once support
|
|
46
|
+
* (or connectivity) returns.
|
|
47
|
+
*/
|
|
48
|
+
async function tryCommentCount(service: TicketService, ref: string): Promise<number | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
return (await service.comments(ref)).length;
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Human-readable diffs between two issue snapshots, most specific first. Empty means "no real, describable change" even if fetched_at moved. */
|
|
57
|
+
function diffIssueSnapshot(
|
|
58
|
+
previous: { status: string; updatedAt?: string; commentCount?: number } | undefined,
|
|
59
|
+
current: { status: string; updatedAt?: string; commentCount?: number },
|
|
60
|
+
): string[] {
|
|
61
|
+
if (!previous) return [];
|
|
62
|
+
const changes: string[] = [];
|
|
63
|
+
if (current.status !== previous.status) changes.push(`status: ${previous.status} -> ${current.status}`);
|
|
64
|
+
if (current.commentCount !== undefined && previous.commentCount !== undefined && current.commentCount > previous.commentCount) {
|
|
65
|
+
const added = current.commentCount - previous.commentCount;
|
|
66
|
+
changes.push(`${added} new comment${added === 1 ? "" : "s"}`);
|
|
67
|
+
}
|
|
68
|
+
// A generic fallback for a backend-reported update this diff can't further characterize (a field
|
|
69
|
+
// edit, a label/assignee change, ...) -- only surfaced when nothing more specific already explains
|
|
70
|
+
// it, so a status change never also reports a redundant "updated".
|
|
71
|
+
if (changes.length === 0 && current.updatedAt !== undefined && current.updatedAt !== previous.updatedAt) {
|
|
72
|
+
changes.push("updated");
|
|
73
|
+
}
|
|
74
|
+
return changes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface IssueWatchChange {
|
|
78
|
+
ref: string;
|
|
79
|
+
changes: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One tick: fetches every due watched issue once (deduped across subscribers), diffs, and records a WatchEvent per real change. */
|
|
83
|
+
export async function syncIssueWatches(
|
|
84
|
+
service: TicketService,
|
|
85
|
+
watches: WatchStore,
|
|
86
|
+
logger: Logger = NOOP_LOGGER,
|
|
87
|
+
onChange?: (change: IssueWatchChange) => void,
|
|
88
|
+
now: () => number = Date.now,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const groups = groupBy(watches.issueSubscriptions(), (s: IssueWatchSubscription) => s.ref);
|
|
91
|
+
const nowMs = now();
|
|
92
|
+
const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
|
|
93
|
+
|
|
94
|
+
await Promise.all(
|
|
95
|
+
due.map(async ([ref, subs]) => {
|
|
96
|
+
try {
|
|
97
|
+
const issue = await service.get(ref);
|
|
98
|
+
const commentCount = await tryCommentCount(service, ref);
|
|
99
|
+
const previous = watches.getIssueSnapshot(ref);
|
|
100
|
+
const fetchedAt = new Date(nowMs);
|
|
101
|
+
watches.upsertIssueSnapshot({ ref, status: issue.status, updatedAt: issue.updatedAt, commentCount: commentCount ?? 0, fetchedAt });
|
|
102
|
+
|
|
103
|
+
const changes = diffIssueSnapshot(
|
|
104
|
+
previous ? { status: previous.status, updatedAt: previous.updatedAt, commentCount: previous.commentCount } : undefined,
|
|
105
|
+
{ status: issue.status, updatedAt: issue.updatedAt, commentCount },
|
|
106
|
+
);
|
|
107
|
+
if (changes.length > 0) {
|
|
108
|
+
const message = `${ref} (${issue.title}): ${changes.join(", ")}`;
|
|
109
|
+
watches.recordEvent("issue", ref, message, fetchedAt);
|
|
110
|
+
onChange?.({ ref, changes });
|
|
111
|
+
}
|
|
112
|
+
for (const subscription of subs) watches.markIssueChecked(ref, subscription.subscriberId, fetchedAt);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger.warn("issue watch sync failed for one ref", { ref, error: error instanceof Error ? error.message : String(error) });
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface QueryWatchChange {
|
|
121
|
+
name: string;
|
|
122
|
+
added: string[];
|
|
123
|
+
removed: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One tick: re-runs every due watched saved query once, diffs the *set* of matching refs against last time, and records a WatchEvent when items appeared or dropped out. */
|
|
127
|
+
export async function syncQueryWatches(
|
|
128
|
+
service: TicketService,
|
|
129
|
+
queries: SavedQueryStore,
|
|
130
|
+
watches: WatchStore,
|
|
131
|
+
logger: Logger = NOOP_LOGGER,
|
|
132
|
+
onChange?: (change: QueryWatchChange) => void,
|
|
133
|
+
now: () => number = Date.now,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const groups = groupBy(watches.queryWatchSubscriptions(), (s: QueryWatchSubscription) => s.name);
|
|
136
|
+
const nowMs = now();
|
|
137
|
+
const due = [...groups.entries()].filter(([, subs]) => subs.some((s) => isDue(s, nowMs)));
|
|
138
|
+
|
|
139
|
+
await Promise.all(
|
|
140
|
+
due.map(async ([name, subs]) => {
|
|
141
|
+
try {
|
|
142
|
+
const saved = queries.get(name);
|
|
143
|
+
if (!saved) {
|
|
144
|
+
logger.warn("watched saved query no longer exists", { name });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const issues = await service.runQuery(saved.backend, saved.query, DEFAULT_QUERY_WATCH_LIMIT);
|
|
148
|
+
const refs = issues.map((issue) => issue.ref);
|
|
149
|
+
const previous = watches.getQuerySnapshot(name);
|
|
150
|
+
const fetchedAt = new Date(nowMs);
|
|
151
|
+
watches.upsertQuerySnapshot({ name, refs, fetchedAt });
|
|
152
|
+
|
|
153
|
+
if (previous) {
|
|
154
|
+
const previousSet = new Set(previous.refs);
|
|
155
|
+
const currentSet = new Set(refs);
|
|
156
|
+
const added = refs.filter((ref) => !previousSet.has(ref));
|
|
157
|
+
const removed = previous.refs.filter((ref) => !currentSet.has(ref));
|
|
158
|
+
if (added.length > 0 || removed.length > 0) {
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
if (added.length > 0) parts.push(`${added.length} new: ${added.join(", ")}`);
|
|
161
|
+
if (removed.length > 0) parts.push(`${removed.length} dropped out: ${removed.join(", ")}`);
|
|
162
|
+
const message = `"${name}": ${parts.join("; ")}`;
|
|
163
|
+
watches.recordEvent("query", name, message, fetchedAt);
|
|
164
|
+
onChange?.({ name, added, removed });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const subscription of subs) watches.markQueryChecked(name, subscription.subscriberId, fetchedAt);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.warn("query watch sync failed for one saved query", { name, error: error instanceof Error ? error.message : String(error) });
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** MaintenanceTask wrapper for syncIssueWatches -- reads watches.issueSubscriptions() fresh every tick, so an empty watch list means this tick does no live fetches at all (see this module's own doc comment). */
|
|
176
|
+
export function createIssueWatchSyncTask(
|
|
177
|
+
service: TicketService,
|
|
178
|
+
watches: WatchStore,
|
|
179
|
+
intervalMs: number,
|
|
180
|
+
logger?: Logger,
|
|
181
|
+
onChange?: (change: IssueWatchChange) => void,
|
|
182
|
+
): MaintenanceTask {
|
|
183
|
+
return {
|
|
184
|
+
name: "issue-watch-sync",
|
|
185
|
+
intervalMs,
|
|
186
|
+
run: () => syncIssueWatches(service, watches, logger, onChange),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** MaintenanceTask wrapper for syncQueryWatches -- same empty-watch-list-is-a-no-op shape as createIssueWatchSyncTask. */
|
|
191
|
+
export function createQueryWatchSyncTask(
|
|
192
|
+
service: TicketService,
|
|
193
|
+
queries: SavedQueryStore,
|
|
194
|
+
watches: WatchStore,
|
|
195
|
+
intervalMs: number,
|
|
196
|
+
logger?: Logger,
|
|
197
|
+
onChange?: (change: QueryWatchChange) => void,
|
|
198
|
+
): MaintenanceTask {
|
|
199
|
+
return {
|
|
200
|
+
name: "query-watch-sync",
|
|
201
|
+
intervalMs,
|
|
202
|
+
run: () => syncQueryWatches(service, queries, watches, logger, onChange),
|
|
203
|
+
};
|
|
204
|
+
}
|
package/src/rpc/ops.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { BackendCapabilities } from "../issue/service.js";
|
|
|
9
9
|
import type { Template } from "../issue/template.js";
|
|
10
10
|
import type { TicketFocusState } from "../sqlite/focus.js";
|
|
11
11
|
import type { SavedQuery } from "../sqlite/saved-queries.js";
|
|
12
|
+
import type { IssueWatchSubscription, QueryWatchSubscription, WatchEvent } from "../sqlite/watches.js";
|
|
12
13
|
import type { StagedItem, StagePatchFields, StagePayload } from "../stage/store.js";
|
|
13
14
|
|
|
14
15
|
export type TicketOperation =
|
|
@@ -40,6 +41,13 @@ export type TicketOperation =
|
|
|
40
41
|
| "query.list"
|
|
41
42
|
| "query.remove"
|
|
42
43
|
| "query.run"
|
|
44
|
+
| "issue.subscribe"
|
|
45
|
+
| "issue.unsubscribe"
|
|
46
|
+
| "issue.subscribed"
|
|
47
|
+
| "query.subscribe"
|
|
48
|
+
| "query.unsubscribe"
|
|
49
|
+
| "query.subscribed"
|
|
50
|
+
| "watch.events"
|
|
43
51
|
| "stage.add"
|
|
44
52
|
| "stage.list"
|
|
45
53
|
| "stage.show"
|
|
@@ -77,6 +85,13 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
77
85
|
"query.list": Record<string, never>;
|
|
78
86
|
"query.remove": { name: string };
|
|
79
87
|
"query.run": { name: string; limit?: number };
|
|
88
|
+
"issue.subscribe": { ref: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
|
|
89
|
+
"issue.unsubscribe": { ref: string; subscriberId?: string };
|
|
90
|
+
"issue.subscribed": { subscriberId?: string };
|
|
91
|
+
"query.subscribe": { name: string; subscriberId?: string; scheduleMs?: number; projectRoot?: string };
|
|
92
|
+
"query.unsubscribe": { name: string; subscriberId?: string };
|
|
93
|
+
"query.subscribed": { subscriberId?: string };
|
|
94
|
+
"watch.events": { subscriberId?: string; sinceId?: number; limit?: number };
|
|
80
95
|
"stage.add": { payload: StagePayload };
|
|
81
96
|
"stage.list": Record<string, never>;
|
|
82
97
|
"stage.show": { id: string };
|
|
@@ -118,6 +133,13 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
|
118
133
|
"query.list": { queries: SavedQuery[] };
|
|
119
134
|
"query.remove": { removed: boolean };
|
|
120
135
|
"query.run": { issues: Issue[] };
|
|
136
|
+
"issue.subscribe": { subscribed: true };
|
|
137
|
+
"issue.unsubscribe": { unsubscribed: true };
|
|
138
|
+
"issue.subscribed": { watches: IssueWatchSubscription[] };
|
|
139
|
+
"query.subscribe": { subscribed: true };
|
|
140
|
+
"query.unsubscribe": { unsubscribed: true };
|
|
141
|
+
"query.subscribed": { watches: QueryWatchSubscription[] };
|
|
142
|
+
"watch.events": { events: WatchEvent[]; lastId: number };
|
|
121
143
|
"stage.add": { item: StagedItem };
|
|
122
144
|
"stage.list": { items: StagedItem[] };
|
|
123
145
|
"stage.show": { item: StagedItem };
|
|
@@ -156,6 +178,13 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
|
|
|
156
178
|
"query.list",
|
|
157
179
|
"query.remove",
|
|
158
180
|
"query.run",
|
|
181
|
+
"issue.subscribe",
|
|
182
|
+
"issue.unsubscribe",
|
|
183
|
+
"issue.subscribed",
|
|
184
|
+
"query.subscribe",
|
|
185
|
+
"query.unsubscribe",
|
|
186
|
+
"query.subscribed",
|
|
187
|
+
"watch.events",
|
|
159
188
|
"stage.add",
|
|
160
189
|
"stage.list",
|
|
161
190
|
"stage.show",
|
package/src/rpc/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { TicketService } from "../issue/service.js";
|
|
|
16
16
|
import { FocusError, type FocusStore } from "../sqlite/focus.js";
|
|
17
17
|
import type { Ledger } from "../sqlite/ledger.js";
|
|
18
18
|
import { SavedQueryNotFoundError, type SavedQueryStore } from "../sqlite/saved-queries.js";
|
|
19
|
+
import type { WatchStore } from "../sqlite/watches.js";
|
|
19
20
|
import type { StagePayload, StageStore } from "../stage/store.js";
|
|
20
21
|
import { statusForKnownTicketError } from "./error-status.js";
|
|
21
22
|
import { type StagePushResult, TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
|
|
@@ -45,6 +46,20 @@ export interface TicketsAppDeps {
|
|
|
45
46
|
*/
|
|
46
47
|
vehicleRegistry: VehicleRegistry;
|
|
47
48
|
stageStore: StageStore;
|
|
49
|
+
watches: WatchStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The subset of a Vehicle call's own identity a handful of handlers read for a sensible default --
|
|
54
|
+
* see issue.subscribe/query.subscribe below, which default subscriberId/projectRoot from these
|
|
55
|
+
* when the caller doesn't pass them explicitly, mirroring @danypops/pipes' own ci.subscribe.
|
|
56
|
+
* Always undefined-safe: absent entirely for the raw HTTP /api/v1/ops dispatch (which has no Pi
|
|
57
|
+
* session behind it), present when the call came through agent-tools/tickets-vehicle.ts's
|
|
58
|
+
* Vehicle registration (see its own VehicleOperationContext.callerSessionId/callerProjectRoot).
|
|
59
|
+
*/
|
|
60
|
+
export interface HandlerCallContext {
|
|
61
|
+
callerSessionId?: string;
|
|
62
|
+
callerProjectRoot?: string;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
// Narrower than TicketsAppDeps on purpose: no real handler reads
|
|
@@ -53,6 +68,7 @@ export interface TicketsAppDeps {
|
|
|
53
68
|
export type Handler<Op extends TicketOperation> = (
|
|
54
69
|
deps: Omit<TicketsAppDeps, "vehicleRegistry">,
|
|
55
70
|
input: TicketOpInputs[Op],
|
|
71
|
+
callContext?: HandlerCallContext,
|
|
56
72
|
) => Promise<TicketOpOutputs[Op]>;
|
|
57
73
|
|
|
58
74
|
/**
|
|
@@ -109,6 +125,37 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
|
|
|
109
125
|
if (!saved) throw new SavedQueryNotFoundError(input.name);
|
|
110
126
|
return { issues: await deps.service.runQuery(saved.backend, saved.query, input.limit) };
|
|
111
127
|
},
|
|
128
|
+
"issue.subscribe": async (deps, input, callContext) => {
|
|
129
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
130
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
131
|
+
deps.watches.subscribeIssue(input.ref, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
132
|
+
return { subscribed: true };
|
|
133
|
+
},
|
|
134
|
+
"issue.unsubscribe": async (deps, input, callContext) => {
|
|
135
|
+
deps.watches.unsubscribeIssue(input.ref, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
136
|
+
return { unsubscribed: true };
|
|
137
|
+
},
|
|
138
|
+
"issue.subscribed": async (deps, input, callContext) => ({
|
|
139
|
+
watches: deps.watches.issueSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
140
|
+
}),
|
|
141
|
+
"query.subscribe": async (deps, input, callContext) => {
|
|
142
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
143
|
+
const projectRoot = input.projectRoot ?? callContext?.callerProjectRoot;
|
|
144
|
+
deps.watches.subscribeQuery(input.name, { subscriberId, scheduleMs: input.scheduleMs, projectRoot });
|
|
145
|
+
return { subscribed: true };
|
|
146
|
+
},
|
|
147
|
+
"query.unsubscribe": async (deps, input, callContext) => {
|
|
148
|
+
deps.watches.unsubscribeQuery(input.name, input.subscriberId ?? callContext?.callerSessionId ?? "");
|
|
149
|
+
return { unsubscribed: true };
|
|
150
|
+
},
|
|
151
|
+
"query.subscribed": async (deps, input, callContext) => ({
|
|
152
|
+
watches: deps.watches.queryWatchSubscriptionsFor(input.subscriberId ?? callContext?.callerSessionId ?? ""),
|
|
153
|
+
}),
|
|
154
|
+
"watch.events": async (deps, input, callContext) => {
|
|
155
|
+
const subscriberId = input.subscriberId ?? callContext?.callerSessionId ?? "";
|
|
156
|
+
const events = deps.watches.eventsSince(subscriberId, input.sinceId ?? 0, input.limit);
|
|
157
|
+
return { events, lastId: events.at(-1)?.id ?? input.sinceId ?? 0 };
|
|
158
|
+
},
|
|
112
159
|
"stage.add": async (deps, input) => ({ item: deps.stageStore.add(input.payload) }),
|
|
113
160
|
"stage.list": async (deps) => ({ items: deps.stageStore.list() }),
|
|
114
161
|
"stage.show": async (deps, input) => ({ item: deps.stageStore.show(input.id) }),
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watches — the daemon's local subscription + last-known-snapshot store for individual issues
|
|
3
|
+
* and saved queries, mirroring @danypops/pipes' own job_watches/run_snapshots split
|
|
4
|
+
* (packages/pipes/src/sqlite/run-pool.ts) one domain over: `issue_watches`/`query_watches` are
|
|
5
|
+
* the authoritative subscription lists the background sync tasks (process/watch-sync.ts) iterate;
|
|
6
|
+
* `issue_watch_snapshots`/`query_watch_snapshots` hold each watched key's last-observed state,
|
|
7
|
+
* independent of subscriptions, so a sync tick can tell "did this actually change" apart from
|
|
8
|
+
* "this is the first time we've ever looked." `watch_events` is the append-only, cursor-readable
|
|
9
|
+
* change log a client polls (watch.events) instead of re-deriving a diff itself.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately does NOT auto-unsubscribe on any kind of "terminal" state the way run_snapshots
|
|
12
|
+
* does for a finished CI run: an issue can be reopened and a saved query's result set has no
|
|
13
|
+
* notion of "done" at all, so nothing here is a permanent completion signal worth stopping a
|
|
14
|
+
* background poll over. A subscription only ever ends via an explicit issue.unsubscribe/
|
|
15
|
+
* query.unsubscribe call.
|
|
16
|
+
*/
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import type { Migration } from "@danypops/vehicle-server/storage";
|
|
19
|
+
|
|
20
|
+
export const WATCH_MIGRATIONS: Migration[] = [
|
|
21
|
+
{
|
|
22
|
+
version: 4,
|
|
23
|
+
up: (db) => {
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE issue_watches (
|
|
26
|
+
ref TEXT NOT NULL,
|
|
27
|
+
subscriber_id TEXT NOT NULL DEFAULT '',
|
|
28
|
+
schedule_ms INTEGER,
|
|
29
|
+
last_checked_at INTEGER,
|
|
30
|
+
project_root TEXT,
|
|
31
|
+
PRIMARY KEY (ref, subscriber_id)
|
|
32
|
+
);
|
|
33
|
+
CREATE TABLE issue_watch_snapshots (
|
|
34
|
+
ref TEXT PRIMARY KEY,
|
|
35
|
+
status TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT,
|
|
37
|
+
comment_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
fetched_at INTEGER NOT NULL
|
|
39
|
+
);
|
|
40
|
+
CREATE TABLE query_watches (
|
|
41
|
+
name TEXT NOT NULL,
|
|
42
|
+
subscriber_id TEXT NOT NULL DEFAULT '',
|
|
43
|
+
schedule_ms INTEGER,
|
|
44
|
+
last_checked_at INTEGER,
|
|
45
|
+
project_root TEXT,
|
|
46
|
+
PRIMARY KEY (name, subscriber_id)
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE query_watch_snapshots (
|
|
49
|
+
name TEXT PRIMARY KEY,
|
|
50
|
+
refs_json TEXT NOT NULL,
|
|
51
|
+
fetched_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE watch_events (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
kind TEXT NOT NULL,
|
|
56
|
+
key TEXT NOT NULL,
|
|
57
|
+
message TEXT NOT NULL,
|
|
58
|
+
created_at INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX watch_events_kind_key_idx ON watch_events(kind, key);
|
|
61
|
+
`);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export interface IssueWatchSubscription {
|
|
67
|
+
ref: string;
|
|
68
|
+
subscriberId: string;
|
|
69
|
+
scheduleMs?: number;
|
|
70
|
+
lastCheckedAt?: Date;
|
|
71
|
+
projectRoot?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface QueryWatchSubscription {
|
|
75
|
+
name: string;
|
|
76
|
+
subscriberId: string;
|
|
77
|
+
scheduleMs?: number;
|
|
78
|
+
lastCheckedAt?: Date;
|
|
79
|
+
projectRoot?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IssueWatchSnapshot {
|
|
83
|
+
ref: string;
|
|
84
|
+
status: string;
|
|
85
|
+
updatedAt?: string;
|
|
86
|
+
commentCount: number;
|
|
87
|
+
fetchedAt: Date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface QueryWatchSnapshot {
|
|
91
|
+
name: string;
|
|
92
|
+
refs: string[];
|
|
93
|
+
fetchedAt: Date;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type WatchEventKind = "issue" | "query";
|
|
97
|
+
|
|
98
|
+
export interface WatchEvent {
|
|
99
|
+
id: number;
|
|
100
|
+
kind: WatchEventKind;
|
|
101
|
+
/** The watched issue's ref, or the watched query's name. */
|
|
102
|
+
key: string;
|
|
103
|
+
message: string;
|
|
104
|
+
createdAt: Date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface IssueWatchRow {
|
|
108
|
+
ref: string;
|
|
109
|
+
subscriber_id: string;
|
|
110
|
+
schedule_ms: number | null;
|
|
111
|
+
last_checked_at: number | null;
|
|
112
|
+
project_root: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface QueryWatchRow {
|
|
116
|
+
name: string;
|
|
117
|
+
subscriber_id: string;
|
|
118
|
+
schedule_ms: number | null;
|
|
119
|
+
last_checked_at: number | null;
|
|
120
|
+
project_root: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toIssueSubscription(row: IssueWatchRow): IssueWatchSubscription {
|
|
124
|
+
return {
|
|
125
|
+
ref: row.ref,
|
|
126
|
+
subscriberId: row.subscriber_id,
|
|
127
|
+
scheduleMs: row.schedule_ms ?? undefined,
|
|
128
|
+
lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
|
|
129
|
+
projectRoot: row.project_root ?? undefined,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toQuerySubscription(row: QueryWatchRow): QueryWatchSubscription {
|
|
134
|
+
return {
|
|
135
|
+
name: row.name,
|
|
136
|
+
subscriberId: row.subscriber_id,
|
|
137
|
+
scheduleMs: row.schedule_ms ?? undefined,
|
|
138
|
+
lastCheckedAt: row.last_checked_at !== null ? new Date(row.last_checked_at) : undefined,
|
|
139
|
+
projectRoot: row.project_root ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class WatchStore {
|
|
144
|
+
constructor(private readonly db: Database) {}
|
|
145
|
+
|
|
146
|
+
// ---- issue watches ----
|
|
147
|
+
|
|
148
|
+
subscribeIssue(ref: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
|
|
149
|
+
const subscriberId = options?.subscriberId ?? "";
|
|
150
|
+
this.db
|
|
151
|
+
.query(
|
|
152
|
+
`INSERT INTO issue_watches (ref, subscriber_id, schedule_ms, project_root)
|
|
153
|
+
VALUES ($ref, $subscriberId, $scheduleMs, $projectRoot)
|
|
154
|
+
ON CONFLICT(ref, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
|
|
155
|
+
)
|
|
156
|
+
.run({
|
|
157
|
+
$ref: ref,
|
|
158
|
+
$subscriberId: subscriberId,
|
|
159
|
+
$scheduleMs: options?.scheduleMs ?? null,
|
|
160
|
+
$projectRoot: options?.projectRoot ?? null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
unsubscribeIssue(ref: string, subscriberId = ""): void {
|
|
165
|
+
this.db
|
|
166
|
+
.query("DELETE FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId")
|
|
167
|
+
.run({ $ref: ref, $subscriberId: subscriberId });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
isIssueSubscribed(ref: string, subscriberId = ""): boolean {
|
|
171
|
+
return (
|
|
172
|
+
this.db.query("SELECT 1 FROM issue_watches WHERE ref = $ref AND subscriber_id = $subscriberId").get({
|
|
173
|
+
$ref: ref,
|
|
174
|
+
$subscriberId: subscriberId,
|
|
175
|
+
}) !== null
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every individual issue subscription -- what the sync task iterates. */
|
|
180
|
+
issueSubscriptions(): IssueWatchSubscription[] {
|
|
181
|
+
const rows = this.db.query("SELECT * FROM issue_watches").all() as IssueWatchRow[];
|
|
182
|
+
return rows.map(toIssueSubscription);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Subscriptions scoped to one subscriber -- what issue.subscribed returns. */
|
|
186
|
+
issueSubscriptionsFor(subscriberId: string): IssueWatchSubscription[] {
|
|
187
|
+
const rows = this.db.query("SELECT * FROM issue_watches WHERE subscriber_id = $subscriberId").all({
|
|
188
|
+
$subscriberId: subscriberId,
|
|
189
|
+
}) as IssueWatchRow[];
|
|
190
|
+
return rows.map(toIssueSubscription);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
markIssueChecked(ref: string, subscriberId: string, at: Date): void {
|
|
194
|
+
this.db
|
|
195
|
+
.query("UPDATE issue_watches SET last_checked_at = $at WHERE ref = $ref AND subscriber_id = $subscriberId")
|
|
196
|
+
.run({ $at: at.getTime(), $ref: ref, $subscriberId: subscriberId });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getIssueSnapshot(ref: string): IssueWatchSnapshot | undefined {
|
|
200
|
+
const row = this.db.query("SELECT * FROM issue_watch_snapshots WHERE ref = $ref").get({ $ref: ref }) as {
|
|
201
|
+
ref: string;
|
|
202
|
+
status: string;
|
|
203
|
+
updated_at: string | null;
|
|
204
|
+
comment_count: number;
|
|
205
|
+
fetched_at: number;
|
|
206
|
+
} | null;
|
|
207
|
+
if (!row) return undefined;
|
|
208
|
+
return {
|
|
209
|
+
ref: row.ref,
|
|
210
|
+
status: row.status,
|
|
211
|
+
updatedAt: row.updated_at ?? undefined,
|
|
212
|
+
commentCount: row.comment_count,
|
|
213
|
+
fetchedAt: new Date(row.fetched_at),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
upsertIssueSnapshot(snapshot: IssueWatchSnapshot): void {
|
|
218
|
+
this.db
|
|
219
|
+
.query(
|
|
220
|
+
`INSERT INTO issue_watch_snapshots (ref, status, updated_at, comment_count, fetched_at)
|
|
221
|
+
VALUES ($ref, $status, $updatedAt, $commentCount, $fetchedAt)
|
|
222
|
+
ON CONFLICT(ref) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at, comment_count = excluded.comment_count, fetched_at = excluded.fetched_at`,
|
|
223
|
+
)
|
|
224
|
+
.run({
|
|
225
|
+
$ref: snapshot.ref,
|
|
226
|
+
$status: snapshot.status,
|
|
227
|
+
$updatedAt: snapshot.updatedAt ?? null,
|
|
228
|
+
$commentCount: snapshot.commentCount,
|
|
229
|
+
$fetchedAt: snapshot.fetchedAt.getTime(),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- query watches ----
|
|
234
|
+
|
|
235
|
+
subscribeQuery(name: string, options?: { subscriberId?: string; scheduleMs?: number; projectRoot?: string }): void {
|
|
236
|
+
const subscriberId = options?.subscriberId ?? "";
|
|
237
|
+
this.db
|
|
238
|
+
.query(
|
|
239
|
+
`INSERT INTO query_watches (name, subscriber_id, schedule_ms, project_root)
|
|
240
|
+
VALUES ($name, $subscriberId, $scheduleMs, $projectRoot)
|
|
241
|
+
ON CONFLICT(name, subscriber_id) DO UPDATE SET schedule_ms = excluded.schedule_ms, project_root = excluded.project_root`,
|
|
242
|
+
)
|
|
243
|
+
.run({
|
|
244
|
+
$name: name,
|
|
245
|
+
$subscriberId: subscriberId,
|
|
246
|
+
$scheduleMs: options?.scheduleMs ?? null,
|
|
247
|
+
$projectRoot: options?.projectRoot ?? null,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
unsubscribeQuery(name: string, subscriberId = ""): void {
|
|
252
|
+
this.db
|
|
253
|
+
.query("DELETE FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId")
|
|
254
|
+
.run({ $name: name, $subscriberId: subscriberId });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
isQuerySubscribed(name: string, subscriberId = ""): boolean {
|
|
258
|
+
return (
|
|
259
|
+
this.db.query("SELECT 1 FROM query_watches WHERE name = $name AND subscriber_id = $subscriberId").get({
|
|
260
|
+
$name: name,
|
|
261
|
+
$subscriberId: subscriberId,
|
|
262
|
+
}) !== null
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
queryWatchSubscriptions(): QueryWatchSubscription[] {
|
|
267
|
+
const rows = this.db.query("SELECT * FROM query_watches").all() as QueryWatchRow[];
|
|
268
|
+
return rows.map(toQuerySubscription);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
queryWatchSubscriptionsFor(subscriberId: string): QueryWatchSubscription[] {
|
|
272
|
+
const rows = this.db.query("SELECT * FROM query_watches WHERE subscriber_id = $subscriberId").all({
|
|
273
|
+
$subscriberId: subscriberId,
|
|
274
|
+
}) as QueryWatchRow[];
|
|
275
|
+
return rows.map(toQuerySubscription);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
markQueryChecked(name: string, subscriberId: string, at: Date): void {
|
|
279
|
+
this.db
|
|
280
|
+
.query("UPDATE query_watches SET last_checked_at = $at WHERE name = $name AND subscriber_id = $subscriberId")
|
|
281
|
+
.run({ $at: at.getTime(), $name: name, $subscriberId: subscriberId });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
getQuerySnapshot(name: string): QueryWatchSnapshot | undefined {
|
|
285
|
+
const row = this.db.query("SELECT * FROM query_watch_snapshots WHERE name = $name").get({ $name: name }) as {
|
|
286
|
+
name: string;
|
|
287
|
+
refs_json: string;
|
|
288
|
+
fetched_at: number;
|
|
289
|
+
} | null;
|
|
290
|
+
if (!row) return undefined;
|
|
291
|
+
return { name: row.name, refs: JSON.parse(row.refs_json) as string[], fetchedAt: new Date(row.fetched_at) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
upsertQuerySnapshot(snapshot: QueryWatchSnapshot): void {
|
|
295
|
+
this.db
|
|
296
|
+
.query(
|
|
297
|
+
`INSERT INTO query_watch_snapshots (name, refs_json, fetched_at)
|
|
298
|
+
VALUES ($name, $refsJson, $fetchedAt)
|
|
299
|
+
ON CONFLICT(name) DO UPDATE SET refs_json = excluded.refs_json, fetched_at = excluded.fetched_at`,
|
|
300
|
+
)
|
|
301
|
+
.run({ $name: snapshot.name, $refsJson: JSON.stringify(snapshot.refs), $fetchedAt: snapshot.fetchedAt.getTime() });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- change events ----
|
|
305
|
+
|
|
306
|
+
/** Appends one change event -- called only by the sync tasks, once per real diff. */
|
|
307
|
+
recordEvent(kind: WatchEventKind, key: string, message: string, at: Date = new Date()): void {
|
|
308
|
+
this.db
|
|
309
|
+
.query("INSERT INTO watch_events (kind, key, message, created_at) VALUES ($kind, $key, $message, $createdAt)")
|
|
310
|
+
.run({ $kind: kind, $key: key, $message: message, $createdAt: at.getTime() });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Events since `sinceId` (exclusive), newest-last, bounded by `limit`, scoped to keys the given
|
|
315
|
+
* subscriber is *currently* subscribed to (an EXISTS join against issue_watches/query_watches --
|
|
316
|
+
* same scoping shape run-pool.ts's watchedRunsWithProjectLabels already uses for subscriberId).
|
|
317
|
+
* An event for a key this subscriber never subscribed to, or already unsubscribed from, never
|
|
318
|
+
* appears here -- avoids a global firehose leaking one session's watches into another's.
|
|
319
|
+
*/
|
|
320
|
+
eventsSince(subscriberId: string, sinceId: number, limit = 100): WatchEvent[] {
|
|
321
|
+
const bounded = Math.max(1, Math.min(500, Math.floor(limit)));
|
|
322
|
+
const rows = this.db
|
|
323
|
+
.query(
|
|
324
|
+
`SELECT * FROM watch_events
|
|
325
|
+
WHERE id > $sinceId
|
|
326
|
+
AND (
|
|
327
|
+
(kind = 'issue' AND EXISTS (SELECT 1 FROM issue_watches WHERE issue_watches.ref = watch_events.key AND issue_watches.subscriber_id = $subscriberId))
|
|
328
|
+
OR
|
|
329
|
+
(kind = 'query' AND EXISTS (SELECT 1 FROM query_watches WHERE query_watches.name = watch_events.key AND query_watches.subscriber_id = $subscriberId))
|
|
330
|
+
)
|
|
331
|
+
ORDER BY id ASC
|
|
332
|
+
LIMIT $limit`,
|
|
333
|
+
)
|
|
334
|
+
.all({ $sinceId: sinceId, $subscriberId: subscriberId, $limit: bounded }) as Array<{
|
|
335
|
+
id: number;
|
|
336
|
+
kind: string;
|
|
337
|
+
key: string;
|
|
338
|
+
message: string;
|
|
339
|
+
created_at: number;
|
|
340
|
+
}>;
|
|
341
|
+
return rows.map((row) => ({
|
|
342
|
+
id: row.id,
|
|
343
|
+
kind: row.kind as WatchEventKind,
|
|
344
|
+
key: row.key,
|
|
345
|
+
message: row.message,
|
|
346
|
+
createdAt: new Date(row.created_at),
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The highest event id recorded so far, or 0 if none -- lets a fresh subscriber start its cursor at "now" instead of replaying every historical event. */
|
|
351
|
+
latestEventId(): number {
|
|
352
|
+
const row = this.db.query("SELECT MAX(id) as max_id FROM watch_events").get() as { max_id: number | null } | null;
|
|
353
|
+
return row?.max_id ?? 0;
|
|
354
|
+
}
|
|
355
|
+
}
|