@danypops/tickets 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -20
- package/package.json +7 -3
- package/src/adapters/github.ts +135 -56
- package/src/adapters/gitlab.ts +98 -42
- package/src/adapters/jira.ts +128 -46
- package/src/auth/enigma-source.ts +65 -0
- package/src/cli/index.ts +77 -1
- package/src/cli/systemd-service.ts +97 -0
- package/src/client/tickets-client.ts +7 -3
- package/src/config/config.ts +30 -19
- package/src/daemon/bootstrap.ts +8 -4
- package/src/daemon/focus.ts +126 -0
- package/src/daemon/main.ts +1 -1
- package/src/daemon/ops.ts +21 -0
- package/src/daemon/server.ts +20 -1
- package/src/domain/issue.ts +4 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -51,8 +51,37 @@ bun run src/cli/index.ts create -b github "Fix the thing" --label bug
|
|
|
51
51
|
bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
|
|
52
52
|
bun run src/cli/index.ts ledger search "login bug"
|
|
53
53
|
bun run src/cli/index.ts ledger stats
|
|
54
|
+
|
|
55
|
+
# Track the single ticket you're currently working on, with its full URL —
|
|
56
|
+
# survives daemon restarts, resolves the ref via the ledger first (no live
|
|
57
|
+
# call if it's already cached) and falls back to the backend otherwise.
|
|
58
|
+
bun run src/cli/index.ts focus set jira:PROJ-42
|
|
59
|
+
bun run src/cli/index.ts focus get
|
|
60
|
+
bun run src/cli/index.ts focus pause "waiting on review"
|
|
61
|
+
bun run src/cli/index.ts focus unpause
|
|
62
|
+
bun run src/cli/index.ts focus clear
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Running the daemon persistently (systemd --user)
|
|
66
|
+
|
|
67
|
+
`daemon start` spawns the daemon on demand and it lives only as long as
|
|
68
|
+
something keeps it alive. For a daemon that survives logout/reboot, install
|
|
69
|
+
it as a systemd `--user` service instead (Linux only):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun run src/cli/index.ts service install # writes + enables + (re)starts the unit
|
|
73
|
+
bun run src/cli/index.ts service status
|
|
74
|
+
bun run src/cli/index.ts service stop
|
|
75
|
+
bun run src/cli/index.ts service restart
|
|
76
|
+
bun run src/cli/index.ts service path # where the unit file lives
|
|
54
77
|
```
|
|
55
78
|
|
|
79
|
+
`service install` points `ExecStart` at the exact `bun` binary and package
|
|
80
|
+
checkout currently running the CLI, so re-running it after an upgrade (a new
|
|
81
|
+
`npm`/`bun` global install, or a fresh checkout) picks up the new path
|
|
82
|
+
immediately via `daemon-reload` + `enable` + `restart` — no manual `stop`
|
|
83
|
+
needed first.
|
|
84
|
+
|
|
56
85
|
Once installed as a package, the same commands are available as `tickets`
|
|
57
86
|
and `tickets-daemon` (see `bin` in `package.json`).
|
|
58
87
|
|
|
@@ -136,36 +165,45 @@ up the new credential — `buildRepositories()` runs once at daemon startup.
|
|
|
136
165
|
|
|
137
166
|
## The `pi-tickets` extension
|
|
138
167
|
|
|
139
|
-
|
|
168
|
+
Published as `@danypops/pi-tickets`. `../../extensions/pi-tickets/` (this repo's workspace member) registers a single `tickets` tool for
|
|
140
169
|
[pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
|
|
141
170
|
`get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
|
|
142
|
-
`backends`, `ledger_search`, `ledger_stats`
|
|
171
|
+
`backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
|
|
172
|
+
`focus_pause`, `focus_unpause`, `focus_clear`). It talks to the same daemon
|
|
143
173
|
through the same authenticated RPC client the CLI uses — never a direct
|
|
144
|
-
backend call or a direct SQLite open. OAuth login
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
backend call or a direct SQLite open. OAuth login and daemon lifecycle
|
|
175
|
+
control are deliberately **not** exposed here (neither as a tool action nor
|
|
176
|
+
as the `/tickets` command below): approving OAuth access requires a human in
|
|
177
|
+
a browser, and stopping a shared daemon is an operational decision, not
|
|
178
|
+
something an LLM tool call or a casual keypress should trigger. Use
|
|
179
|
+
`tickets auth login`/`tickets daemon stop` from a terminal for those.
|
|
180
|
+
|
|
181
|
+
It also registers a `/tickets [query]` interactive TUI command (for the
|
|
182
|
+
human, not the LLM): a browsable list of every issue the daemon's ledger has
|
|
183
|
+
pooled across every configured backend in one flat list (no backend picker
|
|
184
|
+
needed). `↑↓` navigate, `enter` sets focus on the highlighted issue, `o`
|
|
185
|
+
opens its real web URL in a browser without closing the dialog, and `esc`
|
|
186
|
+
cancels. When a focus is already set, a "Clear current focus" row appears
|
|
187
|
+
first. A persistent footer status (`🎯 backend:key`, or `⏸` when paused)
|
|
188
|
+
shows the current focus at all times, refreshed on session start and after
|
|
189
|
+
every `tickets` tool call — so a focus the LLM sets via `focus_set` mid-
|
|
190
|
+
conversation shows up in the footer too, and vice versa.
|
|
191
|
+
|
|
192
|
+
To use it, add it to pi's `settings.json`:
|
|
157
193
|
|
|
158
194
|
```json
|
|
159
|
-
{ "
|
|
195
|
+
{ "packages": ["npm:@danypops/pi-tickets"] }
|
|
160
196
|
```
|
|
161
197
|
|
|
198
|
+
Or, for local development against this monorepo, point at the workspace
|
|
199
|
+
member directory instead: `{ "packages": ["/path/to/tickets/extensions/pi-tickets"] }`.
|
|
200
|
+
|
|
162
201
|
## Development
|
|
163
202
|
|
|
164
203
|
```bash
|
|
165
|
-
bun install
|
|
166
|
-
bun run typecheck
|
|
167
|
-
bun test
|
|
168
|
-
cd extensions/pi-tickets && bun install && bun test && bun run typecheck
|
|
204
|
+
bun install # from the repo root -- links both workspace members
|
|
205
|
+
bun run typecheck # both packages
|
|
206
|
+
bun test # both packages
|
|
169
207
|
```
|
|
170
208
|
|
|
171
209
|
Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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,11 @@
|
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@danypops/daemon-kit": "^0.
|
|
27
|
+
"@danypops/daemon-kit": "^0.3.0",
|
|
28
|
+
"@gitbeaker/rest": "^43.8.0",
|
|
28
29
|
"commander": "^12.1.0",
|
|
30
|
+
"jira.js": "^5.4.0",
|
|
31
|
+
"octokit": "^5.0.5",
|
|
29
32
|
"yaml": "^2.6.0"
|
|
30
33
|
},
|
|
31
34
|
"devDependencies": {
|
|
@@ -48,7 +51,8 @@
|
|
|
48
51
|
],
|
|
49
52
|
"repository": {
|
|
50
53
|
"type": "git",
|
|
51
|
-
"url": "git+https://github.com/DanyPops/tickets.git"
|
|
54
|
+
"url": "git+https://github.com/DanyPops/tickets.git",
|
|
55
|
+
"directory": "packages/tickets"
|
|
52
56
|
},
|
|
53
57
|
"homepage": "https://github.com/DanyPops/tickets#readme",
|
|
54
58
|
"bugs": {
|
package/src/adapters/github.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub adapter — driven implementation of IssueRepository/CommentCapable against
|
|
3
|
-
* the GitHub REST API v3 (
|
|
4
|
-
*
|
|
3
|
+
* the GitHub REST API v3, via octokit (github.com/octokit) rather than a hand-rolled
|
|
4
|
+
* HTTP client: it's GitHub's own official SDK, generated from GitHub's OpenAPI spec,
|
|
5
|
+
* and its typed `assignees: string[]` matches the real write contract exactly (see
|
|
6
|
+
* RESEARCH.md). Token is optional: public repos allow unauthenticated reads at a
|
|
7
|
+
* lower rate limit.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: the `octokit` meta-package bundles @octokit/plugin-retry and
|
|
10
|
+
* @octokit/plugin-throttling ON by default, with default onRateLimit/
|
|
11
|
+
* onSecondaryRateLimit handlers that silently SLEEP for GitHub's advertised
|
|
12
|
+
* Retry-After window (which for an exhausted hourly quota can be tens of
|
|
13
|
+
* minutes) before even attempting a retry -- confirmed for real: a live smoke
|
|
14
|
+
* test against an already-rate-limited endpoint hung with zero output rather
|
|
15
|
+
* than failing fast. That's the opposite of this project's own design (the
|
|
16
|
+
* daemon's ledger exists so live calls can fail fast and the caller decides
|
|
17
|
+
* what to do next, not so octokit can unilaterally decide to block for an
|
|
18
|
+
* indeterminate duration). Both plugins are explicitly disabled below, and
|
|
19
|
+
* every call carries a hard timeout matching the old hand-rolled HttpClient's.
|
|
5
20
|
*/
|
|
21
|
+
import { Octokit } from "octokit";
|
|
22
|
+
import { RequestError } from "@octokit/request-error";
|
|
6
23
|
import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
|
|
7
24
|
import { parsePriority } from "../domain/issue.js";
|
|
8
|
-
import { AuthRequiredError } from "./errors.js";
|
|
9
|
-
|
|
25
|
+
import { ApiError, AuthRequiredError, IssueNotFoundError } from "./errors.js";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
28
|
|
|
11
29
|
export interface GitHubOptions {
|
|
12
30
|
owner: string;
|
|
13
31
|
repo?: string;
|
|
14
32
|
token?: string;
|
|
15
33
|
baseUrl?: string;
|
|
16
|
-
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
/** Injected in tests instead of hitting a real network — see @octokit/types' RequestRequestOptions.fetch. */
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
39
|
interface GhUser {
|
|
@@ -31,14 +51,14 @@ interface GhIssue {
|
|
|
31
51
|
html_url: string;
|
|
32
52
|
user: GhUser | null;
|
|
33
53
|
assignee: GhUser | null;
|
|
34
|
-
labels: GhLabel[];
|
|
54
|
+
labels: (GhLabel | string)[];
|
|
35
55
|
created_at: string;
|
|
36
56
|
updated_at: string;
|
|
37
57
|
pull_request?: unknown;
|
|
38
58
|
}
|
|
39
59
|
interface GhComment {
|
|
40
60
|
id: number;
|
|
41
|
-
body
|
|
61
|
+
body?: string;
|
|
42
62
|
created_at: string;
|
|
43
63
|
updated_at: string;
|
|
44
64
|
user: GhUser | null;
|
|
@@ -46,31 +66,32 @@ interface GhComment {
|
|
|
46
66
|
|
|
47
67
|
export class GitHubRepository {
|
|
48
68
|
readonly name: string;
|
|
49
|
-
private readonly
|
|
69
|
+
private readonly client: Octokit;
|
|
50
70
|
private readonly owner: string;
|
|
51
71
|
private repo?: string;
|
|
52
72
|
private readonly readOnly: boolean;
|
|
53
73
|
|
|
74
|
+
private readonly timeoutMs: number;
|
|
75
|
+
|
|
54
76
|
constructor(name: string, opts: GitHubOptions) {
|
|
55
77
|
if (!opts.owner) throw new Error("github: owner is required");
|
|
56
78
|
this.name = name;
|
|
57
79
|
this.owner = opts.owner;
|
|
58
80
|
this.repo = opts.repo;
|
|
59
81
|
this.readOnly = !opts.token;
|
|
60
|
-
this.
|
|
82
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
83
|
+
this.client = new Octokit({
|
|
84
|
+
auth: opts.token,
|
|
61
85
|
baseUrl: opts.baseUrl ?? "https://api.github.com",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
Accept: "application/vnd.github.v3+json",
|
|
66
|
-
...(opts.token ? { Authorization: `token ${opts.token}` } : {}),
|
|
67
|
-
},
|
|
86
|
+
retry: { enabled: false },
|
|
87
|
+
throttle: { onRateLimit: () => false, onSecondaryRateLimit: () => false },
|
|
88
|
+
...(opts.fetchImpl ? { request: { fetch: opts.fetchImpl } } : {}),
|
|
68
89
|
});
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
private
|
|
92
|
+
private repoName(): string {
|
|
72
93
|
if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
|
|
73
|
-
return
|
|
94
|
+
return this.repo;
|
|
74
95
|
}
|
|
75
96
|
|
|
76
97
|
private requireAuth(): void {
|
|
@@ -79,52 +100,70 @@ export class GitHubRepository {
|
|
|
79
100
|
|
|
80
101
|
async list(filter: ListFilter): Promise<Issue[]> {
|
|
81
102
|
const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
const raw = await this.call((signal) =>
|
|
104
|
+
this.client.rest.issues.listForRepo({
|
|
105
|
+
owner: this.owner,
|
|
106
|
+
repo: this.repoName(),
|
|
107
|
+
per_page: limit,
|
|
108
|
+
state: filter.status ? mapStatusToGitHub(filter.status) : "all",
|
|
109
|
+
assignee: filter.assignee,
|
|
110
|
+
labels: filter.labels?.length ? filter.labels.join(",") : undefined,
|
|
111
|
+
request: { signal },
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
return (raw as GhIssue[]).filter((i) => !i.pull_request).map(toDomain);
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
async get(key: string): Promise<Issue> {
|
|
92
|
-
const
|
|
93
|
-
const raw = await this.
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
const issue_number = parseIssueNumber(key);
|
|
119
|
+
const raw = (await this.call((signal) =>
|
|
120
|
+
this.client.rest.issues.get({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
|
|
121
|
+
)) as GhIssue;
|
|
122
|
+
if (raw.pull_request) throw new Error(`github: #${issue_number} is a pull request, not an issue`);
|
|
96
123
|
return toDomain(raw);
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
async create(input: CreateInput): Promise<Issue> {
|
|
100
127
|
this.requireAuth();
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
const raw = (await this.call((signal) =>
|
|
129
|
+
this.client.rest.issues.create({
|
|
130
|
+
owner: this.owner,
|
|
131
|
+
repo: this.repoName(),
|
|
132
|
+
title: input.title,
|
|
133
|
+
body: input.description ?? "",
|
|
134
|
+
labels: input.labels?.length ? input.labels : undefined,
|
|
135
|
+
assignees: input.assignee ? [input.assignee] : undefined,
|
|
136
|
+
request: { signal },
|
|
137
|
+
}),
|
|
138
|
+
)) as GhIssue;
|
|
106
139
|
return toDomain(raw);
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
async update(key: string, input: UpdateInput): Promise<Issue> {
|
|
110
143
|
this.requireAuth();
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
144
|
+
const issue_number = parseIssueNumber(key);
|
|
145
|
+
const raw = (await this.call((signal) =>
|
|
146
|
+
this.client.rest.issues.update({
|
|
147
|
+
owner: this.owner,
|
|
148
|
+
repo: this.repoName(),
|
|
149
|
+
issue_number,
|
|
150
|
+
title: input.title,
|
|
151
|
+
body: input.description,
|
|
152
|
+
state: input.status !== undefined ? mapStatusToGitHub(input.status) : undefined,
|
|
153
|
+
labels: input.labels,
|
|
154
|
+
assignees: input.assignee !== undefined ? (input.assignee ? [input.assignee] : []) : undefined,
|
|
155
|
+
request: { signal },
|
|
156
|
+
}),
|
|
157
|
+
)) as GhIssue;
|
|
120
158
|
return toDomain(raw);
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
async search(query: string, limit = 50): Promise<Issue[]> {
|
|
124
162
|
const scope = this.repo ? `repo:${this.owner}/${this.repo}` : `org:${this.owner}`;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
163
|
+
const result = (await this.call((signal) =>
|
|
164
|
+
this.client.rest.search.issuesAndPullRequests({ q: `${scope} ${query}`, per_page: limit, request: { signal } }),
|
|
165
|
+
)) as { items: GhIssue[] };
|
|
166
|
+
return result.items.filter((i) => !i.pull_request).map(toDomain);
|
|
128
167
|
}
|
|
129
168
|
|
|
130
169
|
// GitHub has no native sub-issue relationship exposed via REST v3.
|
|
@@ -133,24 +172,60 @@ export class GitHubRepository {
|
|
|
133
172
|
}
|
|
134
173
|
|
|
135
174
|
async listComments(key: string): Promise<Comment[]> {
|
|
136
|
-
const
|
|
137
|
-
const raw = (await this.
|
|
175
|
+
const issue_number = parseIssueNumber(key);
|
|
176
|
+
const raw = (await this.call((signal) =>
|
|
177
|
+
this.client.rest.issues.listComments({ owner: this.owner, repo: this.repoName(), issue_number, request: { signal } }),
|
|
178
|
+
)) as GhComment[];
|
|
138
179
|
return raw.map(commentToDomain);
|
|
139
180
|
}
|
|
140
181
|
|
|
141
182
|
async addComment(key: string, body: string): Promise<Comment> {
|
|
142
183
|
this.requireAuth();
|
|
143
|
-
const
|
|
144
|
-
const raw = await this.
|
|
145
|
-
|
|
184
|
+
const issue_number = parseIssueNumber(key);
|
|
185
|
+
const raw = (await this.call((signal) =>
|
|
186
|
+
this.client.rest.issues.createComment({ owner: this.owner, repo: this.repoName(), issue_number, body, request: { signal } }),
|
|
187
|
+
)) as GhComment;
|
|
146
188
|
return commentToDomain(raw);
|
|
147
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs an octokit call, unwraps `.data`, and maps RequestError onto this
|
|
193
|
+
* project's shared error taxonomy. Uses a plain AbortController + setTimeout
|
|
194
|
+
* (not AbortSignal.timeout()) specifically so the timer can be cleared the
|
|
195
|
+
* moment the call settles, matching the old hand-rolled HttpClient's
|
|
196
|
+
* finally-block discipline -- AbortSignal.timeout() has no way to cancel
|
|
197
|
+
* early once created, and confirmed for real that letting it linger shows
|
|
198
|
+
* up as measurable delay (each call leaves a live timer sitting in the
|
|
199
|
+
* event loop until it eventually fires). With retry/throttling disabled
|
|
200
|
+
* above, a stalled connection or rate-limit response now fails predictably
|
|
201
|
+
* within `timeoutMs` instead of hanging.
|
|
202
|
+
*/
|
|
203
|
+
private async call<T>(fn: (signal: AbortSignal) => Promise<{ data: T }>): Promise<T> {
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
206
|
+
try {
|
|
207
|
+
const res = await fn(controller.signal);
|
|
208
|
+
return res.data;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (err instanceof RequestError) {
|
|
211
|
+
if (err.status === 404) throw new IssueNotFoundError("github", err.request.url);
|
|
212
|
+
throw new ApiError("github", err.request.method, err.request.url, err.status, redact(err.message));
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
148
219
|
}
|
|
149
220
|
|
|
150
|
-
function
|
|
221
|
+
function redact(text: string): string {
|
|
222
|
+
return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseIssueNumber(key: string): number {
|
|
151
226
|
const stripped = key.replace(/^#/, "");
|
|
152
227
|
const idx = stripped.lastIndexOf("#");
|
|
153
|
-
return idx >= 0 ? stripped.slice(idx + 1) : stripped;
|
|
228
|
+
return Number(idx >= 0 ? stripped.slice(idx + 1) : stripped);
|
|
154
229
|
}
|
|
155
230
|
|
|
156
231
|
function mapStatusToGitHub(status: Status): "open" | "closed" {
|
|
@@ -161,9 +236,13 @@ function mapStatusFromGitHub(state: string): Status {
|
|
|
161
236
|
return state.toLowerCase() === "closed" ? "done" : "todo";
|
|
162
237
|
}
|
|
163
238
|
|
|
164
|
-
function
|
|
239
|
+
function labelName(label: GhLabel | string): string {
|
|
240
|
+
return typeof label === "string" ? label : label.name;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function priorityFromLabels(labels: (GhLabel | string)[]): ReturnType<typeof parsePriority> {
|
|
165
244
|
for (const l of labels) {
|
|
166
|
-
const lower = l.
|
|
245
|
+
const lower = labelName(l).toLowerCase();
|
|
167
246
|
if (lower.includes("urgent") || lower.includes("critical")) return "urgent";
|
|
168
247
|
if (lower.includes("high")) return "high";
|
|
169
248
|
if (lower.includes("medium")) return "medium";
|
|
@@ -182,7 +261,7 @@ function toDomain(gh: GhIssue): Issue {
|
|
|
182
261
|
status: mapStatusFromGitHub(gh.state),
|
|
183
262
|
rawStatus: gh.state,
|
|
184
263
|
priority: priorityFromLabels(gh.labels ?? []),
|
|
185
|
-
labels: gh.labels?.length ? gh.labels.map(
|
|
264
|
+
labels: gh.labels?.length ? gh.labels.map(labelName) : undefined,
|
|
186
265
|
assignee: gh.assignee?.login,
|
|
187
266
|
url: gh.html_url,
|
|
188
267
|
createdAt: gh.created_at,
|
|
@@ -193,7 +272,7 @@ function toDomain(gh: GhIssue): Issue {
|
|
|
193
272
|
function commentToDomain(c: GhComment): Comment {
|
|
194
273
|
return {
|
|
195
274
|
id: String(c.id),
|
|
196
|
-
body: c.body,
|
|
275
|
+
body: c.body ?? "",
|
|
197
276
|
author: c.user?.login,
|
|
198
277
|
createdAt: c.created_at,
|
|
199
278
|
updatedAt: c.updated_at,
|