@danypops/tickets 0.10.2 → 0.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -25
- package/package.json +4 -4
- package/src/agent-tools/error-mapping.ts +122 -0
- package/src/agent-tools/tickets-vehicle.ts +3 -12
- package/src/cli/index.ts +3 -3
- package/src/github/github.ts +40 -3
- package/src/gitlab/gitlab.ts +31 -4
- package/src/index.ts +1 -1
- package/src/issue/errors.ts +29 -0
- package/src/issue/repository.ts +25 -0
- package/src/issue/service.ts +19 -0
- package/src/issue/transport-error.ts +68 -0
- package/src/jira/jira.ts +78 -22
- package/src/rpc/ops.ts +2 -1
package/README.md
CHANGED
|
@@ -18,11 +18,12 @@ GitHub/GitLab/Jira or opens the SQLite ledger directly. See
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
20
|
- **[Bun](https://bun.sh) 1.1+.** The daemon uses `bun:sqlite` and
|
|
21
|
-
`Bun.serve`
|
|
22
|
-
extension are plain TypeScript but currently ship as source
|
|
23
|
-
Bun rather than a compiled Node build.
|
|
24
|
-
- `@danypops/
|
|
25
|
-
|
|
21
|
+
`Bun.serve` through `@danypops/vehicle-server`; the CLI, library, and
|
|
22
|
+
pi-tickets extension are plain TypeScript but currently ship as source and
|
|
23
|
+
run through Bun rather than a compiled Node build.
|
|
24
|
+
- The published `@danypops/vehicle-*` packages provide the daemon, authenticated
|
|
25
|
+
RPC/Vehicle contracts, resilient client, Pi projection, and Armada service
|
|
26
|
+
integration. No local Vehicle checkout or `file:` dependency is required.
|
|
26
27
|
|
|
27
28
|
## Install
|
|
28
29
|
|
|
@@ -44,6 +45,8 @@ bun run src/cli/index.ts daemon start
|
|
|
44
45
|
bun run src/cli/index.ts daemon stop # asks it to shut down gracefully
|
|
45
46
|
bun run src/cli/index.ts daemon restart
|
|
46
47
|
|
|
48
|
+
# Reports capabilities plus local read/write readiness and missing setting names.
|
|
49
|
+
# It never probes provider connectivity and never returns credential values.
|
|
47
50
|
bun run src/cli/index.ts backends
|
|
48
51
|
bun run src/cli/index.ts list -b github --status todo
|
|
49
52
|
# get includes fixVersions, issueLinks, externalLinks (Jira "Web Links", e.g.
|
|
@@ -76,25 +79,25 @@ bun run src/cli/index.ts discover statuses -b jira
|
|
|
76
79
|
bun run src/cli/index.ts discover template -b jira --project PROJ --issue-type Bug
|
|
77
80
|
```
|
|
78
81
|
|
|
79
|
-
### Running the daemon persistently (
|
|
82
|
+
### Running the daemon persistently (Armada)
|
|
80
83
|
|
|
81
|
-
`daemon start`
|
|
82
|
-
|
|
83
|
-
it as a systemd `--user` service instead (Linux only):
|
|
84
|
+
`daemon start` is the on-demand path. For a daemon owned by the native service
|
|
85
|
+
manager and reconciled from desired state, register it with Armada:
|
|
84
86
|
|
|
85
87
|
```bash
|
|
86
|
-
bun run src/cli/index.ts service install
|
|
88
|
+
bun run src/cli/index.ts service install # Armada upsert + reconcile
|
|
89
|
+
bun run src/cli/index.ts service uninstall # Armada remove
|
|
90
|
+
|
|
91
|
+
# Direct lifecycle actions currently target systemd --user (Linux):
|
|
87
92
|
bun run src/cli/index.ts service status
|
|
88
93
|
bun run src/cli/index.ts service stop
|
|
89
94
|
bun run src/cli/index.ts service restart
|
|
90
|
-
bun run src/cli/index.ts service path # where the unit file lives
|
|
91
95
|
```
|
|
92
96
|
|
|
93
|
-
`service install`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
needed first.
|
|
97
|
+
`service install` records the exact Bun binary, CLI entry path, version, handle
|
|
98
|
+
path, restart policy, and readiness probe in Armada's fleet manifest. Armada
|
|
99
|
+
then projects that declaration through systemd, launchd, or Windows Task
|
|
100
|
+
Scheduler. Re-run `service install` after upgrading or moving the package.
|
|
98
101
|
|
|
99
102
|
Once installed as a package, the same commands are available as `tickets`
|
|
100
103
|
and `tickets-daemon` (see `bin` in `package.json`).
|
|
@@ -273,13 +276,13 @@ member directory instead: `{ "packages": ["/path/to/tickets/packages/pi-tickets"
|
|
|
273
276
|
```bash
|
|
274
277
|
bun install # from the repo root -- links both workspace members
|
|
275
278
|
bun run typecheck # both packages
|
|
276
|
-
bun test
|
|
279
|
+
bun run test # both packages, sequential isolated test processes
|
|
277
280
|
```
|
|
278
281
|
|
|
279
|
-
Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take
|
|
280
|
-
|
|
281
|
-
`@danypops/
|
|
282
|
-
XDG root with a fake `IssueRepository`.
|
|
282
|
+
Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take injectable
|
|
283
|
+
transport implementations, and the daemon tests (`test/rpc/`, `test/sqlite/`,
|
|
284
|
+
`test/process/`) run the real `@danypops/vehicle-server` daemon/SQLite/HTTP
|
|
285
|
+
stack against a scratch XDG root with a fake `IssueRepository`.
|
|
283
286
|
|
|
284
287
|
## Architecture
|
|
285
288
|
|
|
@@ -292,13 +295,14 @@ Driver (inbound) Application Driven (outbound)
|
|
|
292
295
|
└───────────────┘ │ + Ledger │───────▶│ SQLite (Ledger) │
|
|
293
296
|
│ + Poller) │ └──────────────────┘
|
|
294
297
|
└─────────────────┘
|
|
295
|
-
built on @danypops/
|
|
296
|
-
(paths, storage,
|
|
298
|
+
built on @danypops/vehicle-server
|
|
299
|
+
(Vehicle registry, paths, storage, HTTP, logging,
|
|
300
|
+
daemon lifecycle, Armada service integration)
|
|
297
301
|
```
|
|
298
302
|
|
|
299
303
|
Hexagonal architecture: `src/domain` has zero I/O, `src/ports` defines the
|
|
300
304
|
outbound contract, `src/adapters` implement it per backend, `src/application`
|
|
301
305
|
orchestrates by parsing `backend:key` refs and routing to the named
|
|
302
306
|
repository, and `src/daemon` is the only place that owns the SQLite ledger,
|
|
303
|
-
wraps it in a Bearer-authenticated HTTP
|
|
304
|
-
poller as a
|
|
307
|
+
wraps it in a Bearer-authenticated HTTP/Vehicle surface, and runs the pooling
|
|
308
|
+
poller as a Vehicle maintenance task.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
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,9 +24,9 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/vehicle-core": "^0.
|
|
28
|
-
"@danypops/vehicle-server": "^0.18.
|
|
29
|
-
"@danypops/vehicle-client": "^0.
|
|
27
|
+
"@danypops/vehicle-core": "^0.13.0",
|
|
28
|
+
"@danypops/vehicle-server": "^0.18.4",
|
|
29
|
+
"@danypops/vehicle-client": "^0.7.1",
|
|
30
30
|
"@danypops/enigma-client": "^0.6.1",
|
|
31
31
|
"@gitbeaker/rest": "^43.8.0",
|
|
32
32
|
"commander": "^12.1.0",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isVehicleError, VehicleError } from "@danypops/vehicle-core";
|
|
2
|
+
import { ApiError, AuthRequiredError, BackendConfigurationError, BackendConnectionError, InvalidUrlError } from "../issue/errors.js";
|
|
3
|
+
import { statusForKnownTicketError } from "../rpc/error-status.js";
|
|
4
|
+
|
|
5
|
+
function apiErrorToVehicle(error: ApiError): VehicleError {
|
|
6
|
+
const details = { backend: error.backend, status: error.status };
|
|
7
|
+
|
|
8
|
+
if (error.status === 401 || error.status === 403) {
|
|
9
|
+
return new VehicleError("backend-authentication-failed", `${error.backend}: authentication or authorization was rejected`, {
|
|
10
|
+
category: "authorization",
|
|
11
|
+
details,
|
|
12
|
+
recovery: { message: "Check the configured credential and its backend permissions, then retry." },
|
|
13
|
+
cause: error,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
if (error.status === 408) {
|
|
17
|
+
return new VehicleError("backend-timeout", `${error.backend}: backend request timed out`, {
|
|
18
|
+
category: "timeout",
|
|
19
|
+
retryable: true,
|
|
20
|
+
details,
|
|
21
|
+
recovery: { message: "Retry after backend connectivity recovers." },
|
|
22
|
+
cause: error,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (error.status === 409) {
|
|
26
|
+
return new VehicleError("backend-conflict", `${error.backend}: backend rejected the request because its state changed`, {
|
|
27
|
+
category: "conflict",
|
|
28
|
+
details,
|
|
29
|
+
recovery: { message: "Refresh the issue and retry against its current state." },
|
|
30
|
+
cause: error,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (error.status === 429) {
|
|
34
|
+
return new VehicleError("backend-rate-limited", `${error.backend}: backend API rate limit exceeded`, {
|
|
35
|
+
category: "capacity",
|
|
36
|
+
retryable: true,
|
|
37
|
+
details,
|
|
38
|
+
recovery: { message: "Retry after the backend rate limit resets; cached ledger reads remain available." },
|
|
39
|
+
cause: error,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (error.status >= 500) {
|
|
43
|
+
return new VehicleError("backend-unavailable", `${error.backend}: backend API is unavailable (${error.status})`, {
|
|
44
|
+
category: "unavailable",
|
|
45
|
+
retryable: true,
|
|
46
|
+
details,
|
|
47
|
+
recovery: { message: "Retry later; for reads, use ledger.search while the live backend is unavailable." },
|
|
48
|
+
cause: error,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return new VehicleError("backend-request-rejected", `${error.backend}: backend rejected the request (${error.status})`, {
|
|
52
|
+
category: "validation",
|
|
53
|
+
details,
|
|
54
|
+
recovery: { message: "Check the operation input and backend-specific constraints, then retry." },
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Converts reviewed Tickets failures into actionable, wire-safe Vehicle failures. */
|
|
60
|
+
export function toTicketsVehicleError(error: unknown): VehicleError {
|
|
61
|
+
if (isVehicleError(error)) return error;
|
|
62
|
+
|
|
63
|
+
if (error instanceof BackendConfigurationError) {
|
|
64
|
+
return new VehicleError("backend-not-configured", error.message, {
|
|
65
|
+
category: "validation",
|
|
66
|
+
recovery: { message: error.recovery },
|
|
67
|
+
cause: error,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (error instanceof BackendConnectionError) {
|
|
71
|
+
return new VehicleError(error.kind === "timeout" ? "backend-timeout" : "backend-unavailable", error.message, {
|
|
72
|
+
category: error.kind === "timeout" ? "timeout" : "unavailable",
|
|
73
|
+
retryable: true,
|
|
74
|
+
details: { backend: error.backend },
|
|
75
|
+
recovery: {
|
|
76
|
+
message: "Check the configured URL and network, VPN, or DNS connectivity; cached ledger reads remain available.",
|
|
77
|
+
},
|
|
78
|
+
cause: error,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (error instanceof ApiError) return apiErrorToVehicle(error);
|
|
82
|
+
if (error instanceof InvalidUrlError) {
|
|
83
|
+
return new VehicleError("invalid-backend-url", "Backend URL configuration is invalid", {
|
|
84
|
+
category: "validation",
|
|
85
|
+
recovery: { message: "Use an HTTPS backend URL (HTTP is accepted only for localhost), then restart the daemon." },
|
|
86
|
+
cause: error,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (error instanceof AuthRequiredError) {
|
|
90
|
+
return new VehicleError("backend-authentication-required", error.message, {
|
|
91
|
+
category: "authorization",
|
|
92
|
+
recovery: { message: "Configure the backend credential, restart the daemon if needed, and retry." },
|
|
93
|
+
cause: error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const status = statusForKnownTicketError(error);
|
|
98
|
+
if (status === 404) {
|
|
99
|
+
return new VehicleError("not-found", (error as Error).message, { category: "not_found", cause: error });
|
|
100
|
+
}
|
|
101
|
+
if (status === 400) {
|
|
102
|
+
return new VehicleError("operation-rejected", (error as Error).message, { category: "validation", cause: error });
|
|
103
|
+
}
|
|
104
|
+
if (status === 422) {
|
|
105
|
+
return new VehicleError("operation-rejected", (error as Error).message, { category: "authorization", cause: error });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Unknown exceptions stay opaque: only reviewed domain/config/transport errors above
|
|
109
|
+
// may cross the daemon boundary with their original message.
|
|
110
|
+
return new VehicleError("handler-failed", "Tickets operation failed unexpectedly", {
|
|
111
|
+
category: "internal",
|
|
112
|
+
cause: error,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function withTicketsErrorParity<T>(run: () => T | Promise<T>): Promise<T> {
|
|
117
|
+
try {
|
|
118
|
+
return await run();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw toTicketsVehicleError(error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import {
|
|
18
18
|
bindVehicleOperation,
|
|
19
|
-
defineErrorMapping,
|
|
20
19
|
defineLooseObjectSchema,
|
|
21
20
|
defineVehicleOperation,
|
|
22
21
|
type LooseObjectProperty,
|
|
@@ -25,21 +24,12 @@ import {
|
|
|
25
24
|
} from "@danypops/vehicle-core";
|
|
26
25
|
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
27
26
|
import type { BackendCapabilities, TicketService } from "../issue/service.js";
|
|
28
|
-
import { statusForKnownTicketError } from "../rpc/error-status.js";
|
|
29
27
|
import type { TicketOperation } from "../rpc/ops.js";
|
|
30
28
|
import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
|
|
29
|
+
import { withTicketsErrorParity } from "./error-mapping.js";
|
|
31
30
|
|
|
32
31
|
const OWNER = "tickets";
|
|
33
32
|
|
|
34
|
-
const withTicketsErrorParity = defineErrorMapping(
|
|
35
|
-
[
|
|
36
|
-
{ matches: (error) => statusForKnownTicketError(error) === 404, category: "not_found" },
|
|
37
|
-
{ matches: (error) => statusForKnownTicketError(error) === 400, category: "validation" },
|
|
38
|
-
{ matches: (error) => statusForKnownTicketError(error) === 422, category: "authorization" },
|
|
39
|
-
],
|
|
40
|
-
{ fallbackCategory: "internal", fallbackCode: "handler-failed", fallbackMessage: "Tickets operation failed" },
|
|
41
|
-
);
|
|
42
|
-
|
|
43
33
|
const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
44
34
|
|
|
45
35
|
const stringProp: LooseObjectProperty = { type: "string" };
|
|
@@ -73,7 +63,8 @@ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unkn
|
|
|
73
63
|
const OPERATIONS: readonly OperationSpec[] = [
|
|
74
64
|
{
|
|
75
65
|
action: "backends.list",
|
|
76
|
-
description:
|
|
66
|
+
description:
|
|
67
|
+
"Lists configured backends with capabilities and local credential-safe read/write readiness. Connectivity is not probed and is always reported as not_checked.",
|
|
77
68
|
effect: "read",
|
|
78
69
|
properties: {},
|
|
79
70
|
required: [],
|
package/src/cli/index.ts
CHANGED
|
@@ -160,7 +160,7 @@ ledger
|
|
|
160
160
|
|
|
161
161
|
program
|
|
162
162
|
.command("backends")
|
|
163
|
-
.description("list configured
|
|
163
|
+
.description("list configured backends, capabilities, and local read/write readiness (no connectivity probe)")
|
|
164
164
|
.action(async () => {
|
|
165
165
|
await withClient((client) => client.call("backends.list", {}));
|
|
166
166
|
});
|
|
@@ -500,12 +500,12 @@ program
|
|
|
500
500
|
const service = program
|
|
501
501
|
.command("service")
|
|
502
502
|
.description(
|
|
503
|
-
"
|
|
503
|
+
"register the tickets daemon with Armada's cross-platform desired-state fleet (direct start/stop/restart/status actions currently require systemd --user)",
|
|
504
504
|
);
|
|
505
505
|
|
|
506
506
|
service
|
|
507
507
|
.command("install")
|
|
508
|
-
.description("register this install with Armada as the tickets vehicle and reconcile it
|
|
508
|
+
.description("register this install with Armada as the tickets vehicle and reconcile it through the native service manager")
|
|
509
509
|
.action(() => {
|
|
510
510
|
const cli = ticketsServiceCli();
|
|
511
511
|
const result = cli.install();
|
package/src/github/github.ts
CHANGED
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
import { RequestError } from "@octokit/request-error";
|
|
23
23
|
import { Octokit } from "octokit";
|
|
24
|
-
import { ApiError, AuthRequiredError, IssueNotFoundError } from "../issue/errors.js";
|
|
24
|
+
import { ApiError, AuthRequiredError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
|
|
25
25
|
import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
|
|
26
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
27
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
26
28
|
|
|
27
29
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
30
|
|
|
@@ -90,7 +92,13 @@ export class GitHubRepository {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
private repoName(): string {
|
|
93
|
-
if (!this.repo)
|
|
95
|
+
if (!this.repo) {
|
|
96
|
+
throw new BackendConfigurationError(
|
|
97
|
+
"github",
|
|
98
|
+
"repository is not configured; set GITHUB_REPO (or the backend's repo setting) and restart the tickets daemon",
|
|
99
|
+
"Set GITHUB_REPO (or the backend's repo setting), then restart the tickets daemon.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
94
102
|
return this.repo;
|
|
95
103
|
}
|
|
96
104
|
|
|
@@ -98,6 +106,32 @@ export class GitHubRepository {
|
|
|
98
106
|
if (this.readOnly) throw new AuthRequiredError("github", "GITHUB_TOKEN");
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
110
|
+
const repositoryMissing = this.repo ? [] : ["GITHUB_REPO"];
|
|
111
|
+
const writeMissing = [...repositoryMissing, ...(this.readOnly ? ["GITHUB_TOKEN"] : [])];
|
|
112
|
+
return {
|
|
113
|
+
backendType: "github",
|
|
114
|
+
connectivity: "not_checked",
|
|
115
|
+
read: this.repo
|
|
116
|
+
? { state: "ready", missingConfiguration: [] }
|
|
117
|
+
: {
|
|
118
|
+
state: "partial",
|
|
119
|
+
missingConfiguration: repositoryMissing,
|
|
120
|
+
recovery:
|
|
121
|
+
"Set GITHUB_REPO (or the backend's repo setting) for repository list/get/comment operations; organization search remains available.",
|
|
122
|
+
},
|
|
123
|
+
write:
|
|
124
|
+
writeMissing.length === 0
|
|
125
|
+
? { state: "ready", missingConfiguration: [] }
|
|
126
|
+
: {
|
|
127
|
+
state: "blocked",
|
|
128
|
+
missingConfiguration: writeMissing,
|
|
129
|
+
recovery:
|
|
130
|
+
"Configure the repository scope and GITHUB_TOKEN (or equivalent backend settings) before using live write operations.",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
101
135
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
102
136
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
103
137
|
const raw = await this.call((signal) =>
|
|
@@ -209,7 +243,10 @@ export class GitHubRepository {
|
|
|
209
243
|
const res = await fn(controller.signal);
|
|
210
244
|
return res.data;
|
|
211
245
|
} catch (err) {
|
|
212
|
-
if (err instanceof
|
|
246
|
+
if (err instanceof BackendConfigurationError) throw err;
|
|
247
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
248
|
+
if (transportKind) throw new BackendConnectionError("github", transportKind, err);
|
|
249
|
+
if (err instanceof RequestError && err.response) {
|
|
213
250
|
if (err.status === 404) throw new IssueNotFoundError("github", err.request.url);
|
|
214
251
|
throw new ApiError("github", err.request.method, err.request.url, err.status, redact(err.message));
|
|
215
252
|
}
|
package/src/gitlab/gitlab.ts
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
import { isIP } from "node:net";
|
|
15
15
|
import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
|
|
16
16
|
import { Gitlab } from "@gitbeaker/rest";
|
|
17
|
-
import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "../issue/errors.js";
|
|
17
|
+
import { ApiError, AuthRequiredError, BackendConnectionError, InvalidUrlError, IssueNotFoundError } from "../issue/errors.js";
|
|
18
18
|
import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
|
|
19
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
20
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
19
21
|
|
|
20
22
|
export interface GitLabOptions {
|
|
21
23
|
projectId: string;
|
|
@@ -89,6 +91,27 @@ export class GitLabRepository {
|
|
|
89
91
|
if (this.readOnly) throw new AuthRequiredError("gitlab", "GITLAB_TOKEN");
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
95
|
+
return {
|
|
96
|
+
backendType: "gitlab",
|
|
97
|
+
connectivity: "not_checked",
|
|
98
|
+
read: this.readOnly
|
|
99
|
+
? {
|
|
100
|
+
state: "partial",
|
|
101
|
+
missingConfiguration: ["GITLAB_TOKEN"],
|
|
102
|
+
recovery: "Configure GITLAB_TOKEN for private-project reads; unauthenticated reads remain limited to public projects.",
|
|
103
|
+
}
|
|
104
|
+
: { state: "ready", missingConfiguration: [] },
|
|
105
|
+
write: this.readOnly
|
|
106
|
+
? {
|
|
107
|
+
state: "blocked",
|
|
108
|
+
missingConfiguration: ["GITLAB_TOKEN"],
|
|
109
|
+
recovery: "Configure GITLAB_TOKEN (or delegated OAuth) before using live write operations.",
|
|
110
|
+
}
|
|
111
|
+
: { state: "ready", missingConfiguration: [] },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
93
116
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
94
117
|
const raw = await this.call<GlIssue[]>(() =>
|
|
@@ -183,11 +206,15 @@ export class GitLabRepository {
|
|
|
183
206
|
return (await fn()) as T;
|
|
184
207
|
} catch (err) {
|
|
185
208
|
if (err instanceof GitbeakerRequestError) {
|
|
186
|
-
const status = err.cause?.response?.status
|
|
209
|
+
const status = err.cause?.response?.status;
|
|
187
210
|
const url = err.cause?.request?.url ?? "";
|
|
188
|
-
if (status
|
|
189
|
-
|
|
211
|
+
if (status !== undefined) {
|
|
212
|
+
if (status === 404) throw new IssueNotFoundError("gitlab", url);
|
|
213
|
+
throw new ApiError("gitlab", err.cause?.request?.method ?? "?", url, status, redact(err.message));
|
|
214
|
+
}
|
|
190
215
|
}
|
|
216
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
217
|
+
if (transportKind) throw new BackendConnectionError("gitlab", transportKind, err);
|
|
191
218
|
throw err;
|
|
192
219
|
}
|
|
193
220
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ export { type GitLabOptions, GitLabRepository } from "./gitlab/gitlab.js";
|
|
|
28
28
|
export * from "./issue/errors.js";
|
|
29
29
|
export * from "./issue/issue.js";
|
|
30
30
|
export * from "./issue/repository.js";
|
|
31
|
-
export { NotSupportedError, TicketService, UnknownBackendError } from "./issue/service.js";
|
|
31
|
+
export { type BackendCapabilities, NotSupportedError, TicketService, UnknownBackendError } from "./issue/service.js";
|
|
32
32
|
export { type JiraOptions, JiraRepository } from "./jira/jira.js";
|
|
33
33
|
export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./rpc/ops.js";
|
|
34
34
|
export type { FocusStatus, TicketFocusState } from "./sqlite/focus.js";
|
package/src/issue/errors.ts
CHANGED
|
@@ -12,6 +12,35 @@ export class AuthRequiredError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** A reviewed, user-actionable backend setup failure safe to expose to clients. */
|
|
16
|
+
export class BackendConfigurationError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly backend: string,
|
|
19
|
+
message: string,
|
|
20
|
+
public readonly recovery: string,
|
|
21
|
+
) {
|
|
22
|
+
super(`${backend}: ${message}`);
|
|
23
|
+
this.name = "BackendConfigurationError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A transport failure with no trustworthy HTTP response (DNS, VPN, connection, or timeout). */
|
|
28
|
+
export class BackendConnectionError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
public readonly backend: string,
|
|
31
|
+
public readonly kind: "unreachable" | "timeout" = "unreachable",
|
|
32
|
+
cause?: unknown,
|
|
33
|
+
) {
|
|
34
|
+
super(
|
|
35
|
+
kind === "timeout"
|
|
36
|
+
? `${backend}: backend request timed out; retry or check backend connectivity`
|
|
37
|
+
: `${backend}: unable to reach the backend API; check the configured URL and network, VPN, or DNS connectivity`,
|
|
38
|
+
{ cause },
|
|
39
|
+
);
|
|
40
|
+
this.name = "BackendConnectionError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
export class ApiError extends Error {
|
|
16
45
|
constructor(
|
|
17
46
|
public readonly backend: string,
|
package/src/issue/repository.ts
CHANGED
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "./issue.js";
|
|
6
6
|
import type { Template } from "./template.js";
|
|
7
7
|
|
|
8
|
+
export type BackendReadinessState = "ready" | "partial" | "blocked" | "unknown";
|
|
9
|
+
|
|
10
|
+
export interface BackendOperationReadiness {
|
|
11
|
+
readonly state: BackendReadinessState;
|
|
12
|
+
/** Names only; never configuration values. */
|
|
13
|
+
readonly missingConfiguration: readonly string[];
|
|
14
|
+
readonly recovery?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Local configuration assessment. Connectivity is intentionally never inferred or probed here. */
|
|
18
|
+
export interface BackendConfigurationReadiness {
|
|
19
|
+
readonly backendType: string;
|
|
20
|
+
readonly connectivity: "not_checked";
|
|
21
|
+
readonly read: BackendOperationReadiness;
|
|
22
|
+
readonly write: BackendOperationReadiness;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConfigurationInspectable {
|
|
26
|
+
configurationReadiness(): BackendConfigurationReadiness;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasConfigurationReadiness(repo: IssueRepository): repo is IssueRepository & ConfigurationInspectable {
|
|
30
|
+
return typeof (repo as Partial<ConfigurationInspectable>).configurationReadiness === "function";
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
export interface IssueRepository {
|
|
9
34
|
/** Backend identifier used in refs, e.g. "github", "gitlab", "jira". */
|
|
10
35
|
readonly name: string;
|
package/src/issue/service.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "./issue.js";
|
|
8
8
|
import { parseRef } from "./issue.js";
|
|
9
9
|
import {
|
|
10
|
+
type BackendConfigurationReadiness,
|
|
10
11
|
hasBoardFilterDiscovery,
|
|
11
12
|
hasBoardQuickFilterDiscovery,
|
|
12
13
|
hasComments,
|
|
14
|
+
hasConfigurationReadiness,
|
|
13
15
|
hasFieldDiscovery,
|
|
14
16
|
hasRawQuery,
|
|
15
17
|
hasStatusDiscovery,
|
|
@@ -21,6 +23,7 @@ import type { Template } from "./template.js";
|
|
|
21
23
|
|
|
22
24
|
export interface BackendCapabilities {
|
|
23
25
|
readonly name: string;
|
|
26
|
+
readonly readiness: BackendConfigurationReadiness;
|
|
24
27
|
readonly supportsRawQuery: boolean;
|
|
25
28
|
readonly supportsFieldDiscovery: boolean;
|
|
26
29
|
readonly supportsStatusDiscovery: boolean;
|
|
@@ -54,6 +57,22 @@ export class TicketService {
|
|
|
54
57
|
backendCapabilities(): BackendCapabilities[] {
|
|
55
58
|
return Object.values(this.repos).map((repo) => ({
|
|
56
59
|
name: repo.name,
|
|
60
|
+
readiness: hasConfigurationReadiness(repo)
|
|
61
|
+
? repo.configurationReadiness()
|
|
62
|
+
: {
|
|
63
|
+
backendType: repo.name,
|
|
64
|
+
connectivity: "not_checked",
|
|
65
|
+
read: {
|
|
66
|
+
state: "unknown",
|
|
67
|
+
missingConfiguration: [],
|
|
68
|
+
recovery: "This adapter does not expose local configuration readiness.",
|
|
69
|
+
},
|
|
70
|
+
write: {
|
|
71
|
+
state: "unknown",
|
|
72
|
+
missingConfiguration: [],
|
|
73
|
+
recovery: "This adapter does not expose local configuration readiness.",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
57
76
|
supportsRawQuery: hasRawQuery(repo),
|
|
58
77
|
supportsFieldDiscovery: hasFieldDiscovery(repo),
|
|
59
78
|
supportsStatusDiscovery: hasStatusDiscovery(repo),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed transport classification shared by provider adapters.
|
|
3
|
+
*
|
|
4
|
+
* Only stable error names/codes emitted by the runtimes and HTTP clients we
|
|
5
|
+
* use are accepted. Messages are deliberately ignored: they are unstable,
|
|
6
|
+
* may contain credentials/URLs, and can make an arbitrary programming error
|
|
7
|
+
* look like a network outage.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type BackendTransportFailureKind = "unreachable" | "timeout";
|
|
11
|
+
|
|
12
|
+
const TIMEOUT_CODES = new Set([
|
|
13
|
+
"ABORT_ERR",
|
|
14
|
+
"ECONNABORTED",
|
|
15
|
+
"ERR_CANCELED",
|
|
16
|
+
"ESOCKETTIMEDOUT",
|
|
17
|
+
"ETIMEDOUT",
|
|
18
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
19
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
20
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const UNREACHABLE_CODES = new Set([
|
|
24
|
+
"CERT_HAS_EXPIRED",
|
|
25
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
26
|
+
"EAI_AGAIN",
|
|
27
|
+
"ECONNREFUSED",
|
|
28
|
+
"ECONNRESET",
|
|
29
|
+
"EHOSTUNREACH",
|
|
30
|
+
"ENETUNREACH",
|
|
31
|
+
"ENOTFOUND",
|
|
32
|
+
"EPIPE",
|
|
33
|
+
"ERR_NETWORK",
|
|
34
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
35
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
36
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
37
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
38
|
+
"UND_ERR_SOCKET",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const TIMEOUT_NAMES = new Set(["AbortError", "GitbeakerTimeoutError", "TimeoutError"]);
|
|
42
|
+
|
|
43
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
44
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns undefined for every unreviewed/unknown exception. */
|
|
48
|
+
export function classifyBackendTransportFailure(error: unknown): BackendTransportFailureKind | undefined {
|
|
49
|
+
let current: unknown = error;
|
|
50
|
+
const seen = new Set<unknown>();
|
|
51
|
+
|
|
52
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
53
|
+
const candidate = record(current);
|
|
54
|
+
if (!candidate || seen.has(current)) return undefined;
|
|
55
|
+
seen.add(current);
|
|
56
|
+
|
|
57
|
+
const name = typeof candidate.name === "string" ? candidate.name : undefined;
|
|
58
|
+
if (name && TIMEOUT_NAMES.has(name)) return "timeout";
|
|
59
|
+
|
|
60
|
+
const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : undefined;
|
|
61
|
+
if (code && TIMEOUT_CODES.has(code)) return "timeout";
|
|
62
|
+
if (code && UNREACHABLE_CODES.has(code)) return "unreachable";
|
|
63
|
+
|
|
64
|
+
current = candidate.cause;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
package/src/jira/jira.ts
CHANGED
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
import type { AxiosAdapter } from "axios";
|
|
17
17
|
import type { HttpException, Config as JiraClientConfig } from "jira.js";
|
|
18
18
|
import { AgileClient, Version2Client } from "jira.js";
|
|
19
|
-
import { ApiError, IssueNotFoundError } from "../issue/errors.js";
|
|
19
|
+
import { ApiError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
|
|
20
20
|
import type { Comment, CreateInput, Issue, IssueLink, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
|
|
21
|
+
import type { BackendConfigurationReadiness } from "../issue/repository.js";
|
|
21
22
|
import type { Template } from "../issue/template.js";
|
|
22
23
|
import { buildTemplateBody, extractTemplateSections } from "../issue/template.js";
|
|
24
|
+
import { classifyBackendTransportFailure } from "../issue/transport-error.js";
|
|
23
25
|
import * as manifest from "./manifest.js";
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -39,9 +41,9 @@ export interface JiraBasicAuthOptions {
|
|
|
39
41
|
email: string;
|
|
40
42
|
token: string;
|
|
41
43
|
project?: string;
|
|
42
|
-
/** Additional project keys the
|
|
44
|
+
/** Additional default project keys, beyond the single `project` above -- widens list()/search()'s own default scope (no explicit project given) as well as the background poller's sync, both via defaultProjects(). See buildSyncQuery(). */
|
|
43
45
|
syncProjects?: string[];
|
|
44
|
-
/** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. */
|
|
46
|
+
/** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. list()/search() are unaffected -- pass an explicit assignee filter for that. */
|
|
45
47
|
syncMine?: boolean;
|
|
46
48
|
timeoutMs?: number;
|
|
47
49
|
/** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
|
|
@@ -180,15 +182,36 @@ export class JiraRepository {
|
|
|
180
182
|
this.client = new Version2Client(this.clientConfig);
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
configurationReadiness(): BackendConfigurationReadiness {
|
|
186
|
+
return {
|
|
187
|
+
backendType: "jira",
|
|
188
|
+
connectivity: "not_checked",
|
|
189
|
+
read: { state: "ready", missingConfiguration: [] },
|
|
190
|
+
write: this.project
|
|
191
|
+
? { state: "ready", missingConfiguration: [] }
|
|
192
|
+
: {
|
|
193
|
+
state: "partial",
|
|
194
|
+
missingConfiguration: ["JIRA_PROJECT"],
|
|
195
|
+
recovery: "Set JIRA_PROJECT (or pass input.project) for issue creation; updates and comments remain available.",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* An explicit filter.project always wins and narrows to exactly that one
|
|
202
|
+
* project; with none given, defaults to every project this repository
|
|
203
|
+
* cares about (defaultProjects() -- the same set buildSyncQuery() pools in
|
|
204
|
+
* the background), not just the single legacy `project` config field.
|
|
205
|
+
*/
|
|
183
206
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
184
|
-
const
|
|
207
|
+
const projects = filter.project ? [filter.project] : this.defaultProjects();
|
|
185
208
|
const clauses: string[] = [];
|
|
186
|
-
|
|
209
|
+
const scope = projectClause(projects);
|
|
210
|
+
if (scope) clauses.push(scope);
|
|
187
211
|
if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
|
|
188
212
|
if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
|
|
189
213
|
for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
|
|
190
|
-
|
|
191
|
-
return this.searchJql(jql, filter.limit ?? 50);
|
|
214
|
+
return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
|
|
192
215
|
}
|
|
193
216
|
|
|
194
217
|
async get(key: string): Promise<Issue> {
|
|
@@ -213,18 +236,27 @@ export class JiraRepository {
|
|
|
213
236
|
return (await fn()) as T;
|
|
214
237
|
} catch (err) {
|
|
215
238
|
const status = (err as Partial<HttpException>)?.status;
|
|
216
|
-
|
|
239
|
+
const responseStatus = (err as { response?: { status?: unknown } })?.response?.status;
|
|
240
|
+
if (typeof status === "number" && typeof responseStatus === "number") {
|
|
217
241
|
if (status === 404) throw new IssueNotFoundError("jira", key ?? "?");
|
|
218
242
|
const message = err instanceof Error ? err.message : String(err);
|
|
219
243
|
throw new ApiError("jira", "?", key ?? "?", status, redact(message));
|
|
220
244
|
}
|
|
245
|
+
const transportKind = classifyBackendTransportFailure(err);
|
|
246
|
+
if (transportKind) throw new BackendConnectionError("jira", transportKind, err);
|
|
221
247
|
throw err;
|
|
222
248
|
}
|
|
223
249
|
}
|
|
224
250
|
|
|
225
251
|
async create(input: CreateInput): Promise<Issue> {
|
|
226
252
|
const project = input.project ?? this.project;
|
|
227
|
-
if (!project)
|
|
253
|
+
if (!project) {
|
|
254
|
+
throw new BackendConfigurationError(
|
|
255
|
+
"jira",
|
|
256
|
+
"project is required to create an issue; pass input.project or configure JIRA_PROJECT",
|
|
257
|
+
"Pass input.project or set JIRA_PROJECT (or the backend's project setting), then retry.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
228
260
|
const fields: Record<string, unknown> = {
|
|
229
261
|
project: { key: project },
|
|
230
262
|
summary: input.title,
|
|
@@ -259,10 +291,12 @@ export class JiraRepository {
|
|
|
259
291
|
}
|
|
260
292
|
|
|
261
293
|
async search(query: string, limit = 50, project?: string): Promise<Issue[]> {
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
294
|
+
const projects = project ? [project] : this.defaultProjects();
|
|
295
|
+
const clauses: string[] = [];
|
|
296
|
+
const scope = projectClause(projects);
|
|
297
|
+
if (scope) clauses.push(scope);
|
|
298
|
+
clauses.push(`text ~ ${jqlQuote(query)}`);
|
|
299
|
+
return this.searchJql(buildJql(clauses, "AND"), limit);
|
|
266
300
|
}
|
|
267
301
|
|
|
268
302
|
async listChildren(key: string): Promise<Issue[]> {
|
|
@@ -284,23 +318,33 @@ export class JiraRepository {
|
|
|
284
318
|
return this.searchJql(query, limit);
|
|
285
319
|
}
|
|
286
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Every project this repository defaults to when a caller doesn't name one
|
|
323
|
+
* explicitly -- the single `project` config plus `syncProjects`, deduped.
|
|
324
|
+
* Shared by list()/search()'s own default-scope resolution and by
|
|
325
|
+
* buildSyncQuery() below, so "which projects do we care about" is answered
|
|
326
|
+
* in exactly one place instead of once per method.
|
|
327
|
+
*/
|
|
328
|
+
private defaultProjects(): string[] {
|
|
329
|
+
return [...new Set([this.project, ...this.syncProjects].filter((p): p is string => Boolean(p)))];
|
|
330
|
+
}
|
|
331
|
+
|
|
287
332
|
/**
|
|
288
333
|
* SyncScopeExpandable -- widens what the poller's own background sync pools
|
|
289
|
-
* into the local ledger beyond
|
|
290
|
-
* back to: every configured project (default plus syncProjects) ORed with
|
|
334
|
+
* into the local ledger beyond defaultProjects() alone: ORs in
|
|
291
335
|
* "assignee = currentUser()" when syncMine is set, so issues assigned to
|
|
292
|
-
* you in a project nobody
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* project's own existing behavior.
|
|
336
|
+
* you in a project nobody listed still get pooled. Returns undefined --
|
|
337
|
+
* letting the poller fall back to plain list() -- when syncMine adds
|
|
338
|
+
* nothing beyond what list() already does with 0-1 default projects.
|
|
296
339
|
*/
|
|
297
340
|
buildSyncQuery(): string | undefined {
|
|
298
|
-
const projects =
|
|
341
|
+
const projects = this.defaultProjects();
|
|
299
342
|
if (projects.length <= 1 && !this.syncMine) return undefined;
|
|
300
343
|
const clauses: string[] = [];
|
|
301
|
-
|
|
344
|
+
const scope = projectClause(projects);
|
|
345
|
+
if (scope) clauses.push(scope);
|
|
302
346
|
if (this.syncMine) clauses.push("assignee = currentUser()");
|
|
303
|
-
return
|
|
347
|
+
return buildJql(clauses, "OR");
|
|
304
348
|
}
|
|
305
349
|
|
|
306
350
|
/**
|
|
@@ -586,6 +630,18 @@ function jqlQuote(value: string): string {
|
|
|
586
630
|
return `"${value.replace(/"/g, '\\"')}"`;
|
|
587
631
|
}
|
|
588
632
|
|
|
633
|
+
/** Shared by list()/search()/buildSyncQuery() -- `project = X` for one project, `project in (...)` for several, undefined for none. */
|
|
634
|
+
function projectClause(projects: readonly string[]): string | undefined {
|
|
635
|
+
if (projects.length === 0) return undefined;
|
|
636
|
+
if (projects.length === 1) return `project = ${jqlQuote(projects[0]!)}`;
|
|
637
|
+
return `project in (${projects.map(jqlQuote).join(", ")})`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Shared by list()/search()/buildSyncQuery() -- clauses joined by `joiner`, always ordered by `orderBy`. An empty clause list still yields valid JQL ("ORDER BY ..."), matching every one of this file's own pre-existing unscoped queries. */
|
|
641
|
+
function buildJql(clauses: readonly string[], joiner: "AND" | "OR", orderBy = "created DESC"): string {
|
|
642
|
+
return `${clauses.join(` ${joiner} `)} ORDER BY ${orderBy}`.trim();
|
|
643
|
+
}
|
|
644
|
+
|
|
589
645
|
function mapStatusToJira(status: Status): string {
|
|
590
646
|
switch (status) {
|
|
591
647
|
case "backlog":
|
package/src/rpc/ops.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
6
|
*/
|
|
7
7
|
import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../issue/issue.js";
|
|
8
|
+
import type { BackendCapabilities } from "../issue/service.js";
|
|
8
9
|
import type { Template } from "../issue/template.js";
|
|
9
10
|
import type { TicketFocusState } from "../sqlite/focus.js";
|
|
10
11
|
import type { SavedQuery } from "../sqlite/saved-queries.js";
|
|
@@ -83,7 +84,7 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
|
|
|
83
84
|
export type StagePushResult = { issue: Issue } | { comment: Comment };
|
|
84
85
|
|
|
85
86
|
export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
|
|
86
|
-
"backends.list": { backends:
|
|
87
|
+
"backends.list": { backends: BackendCapabilities[] };
|
|
87
88
|
"issue.list": { issues: Issue[] };
|
|
88
89
|
"issue.get": { issue: Issue };
|
|
89
90
|
"issue.create": { issue: Issue };
|