@danypops/tickets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Popsuevich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # tickets
2
+
3
+ A unified CLI, daemon, and TypeScript library for issue tracking across
4
+ **GitHub**, **GitLab**, and **Jira** — plus a `pi-tickets` extension so a
5
+ coding agent can query and mutate issues the same way the CLI does.
6
+
7
+ ## Why a daemon
8
+
9
+ Every backend adapter pools issues into a local SQLite ledger on its own
10
+ schedule, independent of whether anything is currently asking for data —
11
+ `tickets ledger search`/`ledger stats` and the ledger ops still answer from
12
+ the last successful sync even if a backend is slow, rate-limited, or
13
+ temporarily unreachable. The CLI and the pi-tickets extension are both thin,
14
+ interchangeable clients of one authenticated RPC daemon; neither talks to
15
+ GitHub/GitLab/Jira or opens the SQLite ledger directly. See
16
+ [RESEARCH.md](RESEARCH.md) for the sources this was built against.
17
+
18
+ ## Requirements
19
+
20
+ - **[Bun](https://bun.sh) 1.1+.** The daemon uses `bun:sqlite` and
21
+ `Bun.serve` (via `@danypops/daemon-kit`); the CLI, library, and pi-tickets
22
+ extension are plain TypeScript but currently ship as source, run through
23
+ Bun rather than a compiled Node build.
24
+ - `@danypops/daemon-kit` comes from the public npm registry (`^0.2.1`) —
25
+ no local checkout or `file:` path needed, `bun install` fetches it directly.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ bun install
31
+ ```
32
+
33
+ ## Run
34
+
35
+ ```bash
36
+ # Start the daemon (binds 127.0.0.1 on an ephemeral port; writes a handle +
37
+ # auth token under $XDG_RUNTIME_DIR/tickets and $XDG_STATE_HOME/tickets).
38
+ bun run daemon
39
+
40
+ # Or let the CLI manage it — every issue/ledger command below auto-starts the
41
+ # daemon on first use if it isn't already running.
42
+ bun run src/cli/index.ts daemon status # never auto-starts; just checks
43
+ bun run src/cli/index.ts daemon start
44
+ bun run src/cli/index.ts daemon stop # asks it to shut down gracefully
45
+ bun run src/cli/index.ts daemon restart
46
+
47
+ bun run src/cli/index.ts backends
48
+ bun run src/cli/index.ts list -b github --status todo
49
+ bun run src/cli/index.ts get jira:PROJ-42
50
+ bun run src/cli/index.ts create -b github "Fix the thing" --label bug
51
+ bun run src/cli/index.ts comment add jira:PROJ-42 "Looks good, shipping"
52
+ bun run src/cli/index.ts ledger search "login bug"
53
+ bun run src/cli/index.ts ledger stats
54
+ ```
55
+
56
+ Once installed as a package, the same commands are available as `tickets`
57
+ and `tickets-daemon` (see `bin` in `package.json`).
58
+
59
+ ## Configuration
60
+
61
+ ### Environment variables
62
+
63
+ ```bash
64
+ # GitHub (token optional for public-repo reads)
65
+ export GITHUB_TOKEN=ghp_xxx
66
+ export GITHUB_OWNER=your-org
67
+ export GITHUB_REPO=your-repo
68
+
69
+ # GitLab (token optional for public-project reads)
70
+ export GITLAB_TOKEN=glpat-xxx
71
+ export GITLAB_PROJECT=namespace/project
72
+ export GITLAB_URL=https://gitlab.example.com # optional, defaults to gitlab.com
73
+
74
+ # Jira
75
+ export JIRA_API_TOKEN=xxx
76
+ export JIRA_URL=https://yourcompany.atlassian.net
77
+ export JIRA_EMAIL=you@yourcompany.com
78
+ export JIRA_PROJECT=PROJ
79
+ ```
80
+
81
+ ### Config file (multi-instance)
82
+
83
+ `$XDG_CONFIG_HOME/tickets/config.yaml` (default `~/.config/tickets/config.yaml`):
84
+
85
+ ```yaml
86
+ backends:
87
+ github:
88
+ owner: your-org
89
+ repo: your-repo
90
+ token_env: GITHUB_TOKEN
91
+
92
+ gitlab:
93
+ project: namespace/project
94
+ token_env: GITLAB_TOKEN
95
+
96
+ jira:
97
+ url: https://yourcompany.atlassian.net
98
+ email: you@yourcompany.com
99
+ token_env: JIRA_API_TOKEN
100
+ project: PROJ
101
+
102
+ jira-staging: # multi-instance: same type, different name
103
+ type: jira
104
+ url: https://staging.atlassian.net
105
+ email: you@yourcompany.com
106
+ token_env: JIRA_STAGING_TOKEN
107
+ ```
108
+
109
+ ## Delegated OAuth login (instead of a static token)
110
+
111
+ Each backend supports a different real delegated-auth flow — see
112
+ [RESEARCH.md](RESEARCH.md) for exactly which, and why Jira's is shaped
113
+ differently from GitHub/GitLab's:
114
+
115
+ ```bash
116
+ # GitHub / GitLab: device flow — opens a browser, prints a short code.
117
+ tickets auth login --backend github --client-id <your-github-oauth-app-client-id>
118
+ tickets auth login --backend gitlab --client-id <your-gitlab-application-id>
119
+
120
+ # Jira: authorization code grant — opens a browser, receives the callback
121
+ # on a local loopback server. Atlassian's 3LO apps are confidential clients
122
+ # (no PKCE, no device flow), so a client secret is required here.
123
+ tickets auth login --backend jira \
124
+ --client-id <your-atlassian-oauth-client-id> \
125
+ --client-secret <your-atlassian-oauth-client-secret>
126
+
127
+ tickets auth status
128
+ tickets auth logout github
129
+ ```
130
+
131
+ A stored, still-fresh delegated token always takes precedence over a static
132
+ config/env token for that backend. Tokens are written to
133
+ `$XDG_STATE_HOME/tickets/oauth/<backend>.json`, mode `0600`, and are never
134
+ printed by any command. **Restart the daemon** after logging in so it picks
135
+ up the new credential — `buildRepositories()` runs once at daemon startup.
136
+
137
+ ## The `pi-tickets` extension
138
+
139
+ `extensions/pi-tickets/` registers a single `tickets` tool for
140
+ [pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
141
+ `get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
142
+ `backends`, `ledger_search`, `ledger_stats`). It talks to the same daemon
143
+ through the same authenticated RPC client the CLI uses — never a direct
144
+ backend call or a direct SQLite open. OAuth login is deliberately **not** a
145
+ tool action: approving access requires a human in a browser, which belongs
146
+ in a terminal (`tickets auth login`), not an LLM tool call.
147
+
148
+ To use it:
149
+
150
+ ```bash
151
+ cd extensions/pi-tickets
152
+ bun install
153
+ ```
154
+
155
+ then either symlink (or copy) `extensions/pi-tickets` into
156
+ `~/.pi/agent/extensions/pi-tickets`, or add its path to `settings.json`:
157
+
158
+ ```json
159
+ { "extensions": ["/path/to/tickets/extensions/pi-tickets"] }
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ bun install
166
+ bun run typecheck # tsc --noEmit against src/ and test/
167
+ bun test # domain, adapters, application service, auth flows, daemon
168
+ cd extensions/pi-tickets && bun install && bun test && bun run typecheck
169
+ ```
170
+
171
+ Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take an
172
+ injectable `fetchImpl`, and the daemon tests (`test/daemon/`) run the real
173
+ `@danypops/daemon-kit` `startDaemon()`/SQLite/HTTP stack against a scratch
174
+ XDG root with a fake `IssueRepository`.
175
+
176
+ ## Architecture
177
+
178
+ ```
179
+ Driver (inbound) Application Driven (outbound)
180
+ ┌───────────────┐ ┌─────────────────┐ ┌──────────────────┐
181
+ │ CLI (commander)│──RPC──▶│ │ │ GitHub adapter │
182
+ │ pi-tickets │──RPC──▶│ tickets-daemon │───────▶│ GitLab adapter │
183
+ │ (Pi tool) │ │ (TicketService │───────▶│ Jira adapter │
184
+ └───────────────┘ │ + Ledger │───────▶│ SQLite (Ledger) │
185
+ │ + Poller) │ └──────────────────┘
186
+ └─────────────────┘
187
+ built on @danypops/daemon-kit
188
+ (paths, storage, http, logging, daemon, rpc-client)
189
+ ```
190
+
191
+ Hexagonal architecture: `src/domain` has zero I/O, `src/ports` defines the
192
+ outbound contract, `src/adapters` implement it per backend, `src/application`
193
+ orchestrates by parsing `backend:key` refs and routing to the named
194
+ repository, and `src/daemon` is the only place that owns the SQLite ledger,
195
+ wraps it in a Bearer-authenticated HTTP RPC surface, and runs the pooling
196
+ poller as a `daemon-kit` maintenance task.
package/RESEARCH.md ADDED
@@ -0,0 +1,109 @@
1
+ # Research notes
2
+
3
+ This file records the primary sources each adapter and auth flow was built
4
+ against, so the design decisions here are traceable instead of guessed.
5
+
6
+ ## Issue-tracking REST APIs
7
+
8
+ - **GitHub REST API v3 — Issues**: https://docs.github.com/en/rest/issues/issues
9
+ Confirmed live. `githubIssue` field shapes (`number`, `state`, `labels`,
10
+ `pull_request` sentinel for filtering PRs out of the issues endpoint) and
11
+ auth (`Authorization: token <PAT>`, optional for public-repo reads) match
12
+ this doc.
13
+ - **GitLab REST API v4 — Issues**: https://docs.gitlab.com/api/issues/
14
+ Confirmed live. Endpoint shapes (`/api/v4/projects/:id/issues`, `iid` vs
15
+ `id`, `state`/`state_event`) and auth (`PRIVATE-TOKEN` header for personal
16
+ access tokens) match this doc.
17
+ - **Jira Cloud/Server REST API v2**: https://developer.atlassian.com/cloud/jira/platform/rest/v2/
18
+ `/rest/api/2/issue/{key}`, `/rest/api/2/search` (JQL), and the
19
+ transition-based status model (`/rest/api/2/issue/{key}/transitions`) match
20
+ this doc. Jira does not support a direct "set status" field PUT — status is
21
+ workflow-owned, hence `JiraRepository.transitionTo`.
22
+
23
+ The three adapters were also cross-checked against a real, working Go
24
+ implementation of the same three backends (a separate project, not shipped
25
+ here) before being ported to TypeScript, which is why request/response
26
+ shapes are exact rather than approximate.
27
+
28
+ ## Delegated OAuth (vs. static personal access tokens)
29
+
30
+ Static PATs are simple but are exactly the kind of long-lived, broad-scope,
31
+ easily-copy-pasted secret that delegated auth exists to avoid. Each backend's
32
+ *actual* supported delegated flow was checked directly against its own docs
33
+ before implementing anything — the three backends turned out to support three
34
+ different flows, not one:
35
+
36
+ - **GitHub — OAuth 2.0 Device Authorization Grant (RFC 8628)**:
37
+ https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
38
+ `POST https://github.com/login/device/code` → user opens
39
+ `verification_uri`, enters `user_code` → daemon polls
40
+ `POST https://github.com/login/oauth/access_token` with
41
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code`. No client secret,
42
+ no redirect URI, no local callback server — ideal for a headless daemon.
43
+ Requires the device flow to be enabled on a registered GitHub OAuth App;
44
+ the client ID is public.
45
+
46
+ - **GitLab — OAuth 2.0 Device Authorization Grant**:
47
+ https://docs.gitlab.com/api/oauth2/ ("Device Authorization Grant", GA in
48
+ GitLab 17.9, also available on GitLab.com). Same RFC 8628 shape as GitHub,
49
+ different paths: `POST {url}/oauth/authorize_device`,
50
+ `POST {url}/oauth/token`. Requires a "non-confidential" GitLab OAuth
51
+ application (no client secret for this flow either).
52
+
53
+ - **Jira/Atlassian — OAuth 2.0 (3LO), Authorization Code grant, no PKCE**:
54
+ https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/
55
+ Atlassian's 3LO apps are confidential clients: the docs page has zero
56
+ mentions of PKCE, `code_challenge`, or a device flow — confirmed absent,
57
+ not assumed. Token exchange requires `client_id` **and** `client_secret`.
58
+ This is also the only one of the three that needs a local redirect: we bind
59
+ an ephemeral loopback HTTP server (`src/auth/jira-oauth.ts`,
60
+ `startCallbackServer`), send the user to
61
+ `https://auth.atlassian.com/authorize`, and receive the code on
62
+ `http://127.0.0.1:{port}/callback`.
63
+
64
+ A second, easy-to-miss detail from the same doc: an OAuth 2.0 (3LO) access
65
+ token is **not** used against the tenant's own `*.atlassian.net` domain the
66
+ way a Basic-auth API token is. You first call
67
+ `GET https://api.atlassian.com/oauth/token/accessible-resources` with the
68
+ access token to discover the site's `cloudId`, then all further API calls
69
+ go through `https://api.atlassian.com/ex/jira/{cloudId}/...` with
70
+ `Authorization: Bearer <token>`. `JiraRepository` models this as a second,
71
+ separate constructor mode (`JiraOAuthOptions`) alongside the original
72
+ Basic-auth mode (`JiraBasicAuthOptions`) — same port, two adapters-within-
73
+ the-adapter, picked by `config.ts`'s auth precedence.
74
+
75
+ Refresh tokens require `offline_access` in the requested scope and use the
76
+ standard `grant_type=refresh_token` shape (`refreshJiraToken`).
77
+
78
+ ### What isn't implemented (honest scope boundary)
79
+
80
+ - GitHub/GitLab OAuth token refresh: not implemented. GitHub's classic OAuth
81
+ App device-flow tokens are effectively non-expiring in practice; GitLab's
82
+ do expire (`expires_in` in the response) and *do* support the standard
83
+ `grant_type=refresh_token` shape, but a refresh path for it hasn't been
84
+ wired up yet — re-running `tickets auth login --backend gitlab` is the
85
+ current workaround. Jira's refresh function (`refreshJiraToken`) exists and
86
+ is tested, but nothing calls it automatically yet; `config.ts` only checks
87
+ freshness and falls back to a static token when a stored OAuth token has
88
+ expired.
89
+ - Multiple Atlassian sites per token: `loginWithJiraAuthorizationCode` picks
90
+ the first accessible site unless a `chooseSite` callback is given. Fine for
91
+ the common single-site case; a config option to pick by hostname would be
92
+ the natural follow-up.
93
+
94
+ ## Architecture inspiration
95
+
96
+ The domain/ports/adapters/application-service split (and the CLI/MCP-style
97
+ single-tool "one entry point per capability" shape the pi-tickets extension
98
+ follows) is adapted from a separate, existing Go project that implements the
99
+ same idea (issue tracking across Linear/GitHub/GitLab/Jira with a hexagonal
100
+ architecture) — ported here in scope to GitHub/GitLab/Jira and to
101
+ TypeScript/Bun, not a line-for-line translation.
102
+
103
+ The daemon itself (XDG paths, auth-token bootstrap, SQLite pragmas/migration
104
+ runner, structured logging, Bearer-token HTTP RPC, process lifecycle) is
105
+ built on [`@danypops/daemon-kit`](https://www.npmjs.com/package/@danypops/daemon-kit)
106
+ (published to npm; this project depends on `^0.2.1`), a shared substrate
107
+ used by several other supervised Bun daemons in the same ecosystem.
108
+ See that package's own README for the substrate's design rationale; this
109
+ project only documents how it's *used* here.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@danypops/tickets",
3
+ "version": "0.1.0",
4
+ "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "tickets": "./src/cli/index.ts",
9
+ "tickets-daemon": "./src/daemon/main.ts"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "types": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "RESEARCH.md"
20
+ ],
21
+ "scripts": {
22
+ "daemon": "bun run src/daemon/main.ts",
23
+ "test": "bun test",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@danypops/daemon-kit": "^0.2.1",
28
+ "commander": "^12.1.0",
29
+ "yaml": "^2.6.0"
30
+ },
31
+ "devDependencies": {
32
+ "bun-types": "^1.3.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "bun": ">=1.1.0"
40
+ },
41
+ "keywords": [
42
+ "issue-tracker",
43
+ "jira",
44
+ "github",
45
+ "gitlab",
46
+ "cli",
47
+ "daemon"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/DanyPops/tickets.git"
52
+ },
53
+ "homepage": "https://github.com/DanyPops/tickets#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/DanyPops/tickets/issues"
56
+ }
57
+ }
@@ -0,0 +1,33 @@
1
+ export class IssueNotFoundError extends Error {
2
+ constructor(backend: string, key: string) {
3
+ super(`${backend}: issue not found: ${key}`);
4
+ this.name = "IssueNotFoundError";
5
+ }
6
+ }
7
+
8
+ export class AuthRequiredError extends Error {
9
+ constructor(backend: string, tokenEnv: string) {
10
+ super(`${backend}: write operation requires ${tokenEnv} to be set`);
11
+ this.name = "AuthRequiredError";
12
+ }
13
+ }
14
+
15
+ export class ApiError extends Error {
16
+ constructor(
17
+ public readonly backend: string,
18
+ public readonly method: string,
19
+ public readonly path: string,
20
+ public readonly status: number,
21
+ public readonly body: string,
22
+ ) {
23
+ super(`${backend} API error: ${method} ${path}: ${status}: ${body}`);
24
+ this.name = "ApiError";
25
+ }
26
+ }
27
+
28
+ export class InvalidUrlError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "InvalidUrlError";
32
+ }
33
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * GitHub adapter — driven implementation of IssueRepository/CommentCapable against
3
+ * the GitHub REST API v3 (docs: https://docs.github.com/en/rest/issues/issues).
4
+ * Token is optional: public repos allow unauthenticated reads at a lower rate limit.
5
+ */
6
+ import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
7
+ import { parsePriority } from "../domain/issue.js";
8
+ import { AuthRequiredError } from "./errors.js";
9
+ import { type FetchLike, HttpClient } from "./http.js";
10
+
11
+ export interface GitHubOptions {
12
+ owner: string;
13
+ repo?: string;
14
+ token?: string;
15
+ baseUrl?: string;
16
+ fetchImpl?: FetchLike;
17
+ }
18
+
19
+ interface GhUser {
20
+ login: string;
21
+ }
22
+ interface GhLabel {
23
+ id: number;
24
+ name: string;
25
+ }
26
+ interface GhIssue {
27
+ number: number;
28
+ title: string;
29
+ body: string | null;
30
+ state: string;
31
+ html_url: string;
32
+ user: GhUser | null;
33
+ assignee: GhUser | null;
34
+ labels: GhLabel[];
35
+ created_at: string;
36
+ updated_at: string;
37
+ pull_request?: unknown;
38
+ }
39
+ interface GhComment {
40
+ id: number;
41
+ body: string;
42
+ created_at: string;
43
+ updated_at: string;
44
+ user: GhUser | null;
45
+ }
46
+
47
+ export class GitHubRepository {
48
+ readonly name: string;
49
+ private readonly http: HttpClient;
50
+ private readonly owner: string;
51
+ private repo?: string;
52
+ private readonly readOnly: boolean;
53
+
54
+ constructor(name: string, opts: GitHubOptions) {
55
+ if (!opts.owner) throw new Error("github: owner is required");
56
+ this.name = name;
57
+ this.owner = opts.owner;
58
+ this.repo = opts.repo;
59
+ this.readOnly = !opts.token;
60
+ this.http = new HttpClient({
61
+ baseUrl: opts.baseUrl ?? "https://api.github.com",
62
+ backend: "github",
63
+ fetchImpl: opts.fetchImpl,
64
+ headers: {
65
+ Accept: "application/vnd.github.v3+json",
66
+ ...(opts.token ? { Authorization: `token ${opts.token}` } : {}),
67
+ },
68
+ });
69
+ }
70
+
71
+ private repoPath(): string {
72
+ if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
73
+ return `/repos/${this.owner}/${this.repo}`;
74
+ }
75
+
76
+ private requireAuth(): void {
77
+ if (this.readOnly) throw new AuthRequiredError("github", "GITHUB_TOKEN");
78
+ }
79
+
80
+ async list(filter: ListFilter): Promise<Issue[]> {
81
+ const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
82
+ const params = new URLSearchParams({ per_page: String(limit), state: "all" });
83
+ if (filter.status) params.set("state", mapStatusToGitHub(filter.status));
84
+ if (filter.assignee) params.set("assignee", filter.assignee);
85
+ if (filter.labels?.length) params.set("labels", filter.labels.join(","));
86
+
87
+ const raw = (await this.http.get<GhIssue[]>(`${this.repoPath()}/issues?${params}`)) ?? [];
88
+ return raw.filter((i) => !i.pull_request).map(toDomain);
89
+ }
90
+
91
+ async get(key: string): Promise<Issue> {
92
+ const number = parseIssueNumber(key);
93
+ const raw = await this.http.get<GhIssue>(`${this.repoPath()}/issues/${number}`);
94
+ if (!raw) throw new Error(`github: empty response for #${number}`);
95
+ if (raw.pull_request) throw new Error(`github: #${number} is a pull request, not an issue`);
96
+ return toDomain(raw);
97
+ }
98
+
99
+ async create(input: CreateInput): Promise<Issue> {
100
+ this.requireAuth();
101
+ const body: Record<string, unknown> = { title: input.title, body: input.description ?? "" };
102
+ if (input.labels?.length) body.labels = input.labels;
103
+ if (input.assignee) body.assignees = [input.assignee];
104
+ const raw = await this.http.post<GhIssue>(`${this.repoPath()}/issues`, body);
105
+ if (!raw) throw new Error("github: create returned no body");
106
+ return toDomain(raw);
107
+ }
108
+
109
+ async update(key: string, input: UpdateInput): Promise<Issue> {
110
+ this.requireAuth();
111
+ const number = parseIssueNumber(key);
112
+ const body: Record<string, unknown> = {};
113
+ if (input.title !== undefined) body.title = input.title;
114
+ if (input.description !== undefined) body.body = input.description;
115
+ if (input.status !== undefined) body.state = mapStatusToGitHub(input.status);
116
+ if (input.labels !== undefined) body.labels = input.labels;
117
+ if (input.assignee !== undefined) body.assignees = input.assignee ? [input.assignee] : [];
118
+ const raw = await this.http.patch<GhIssue>(`${this.repoPath()}/issues/${number}`, body);
119
+ if (!raw) throw new Error("github: update returned no body");
120
+ return toDomain(raw);
121
+ }
122
+
123
+ async search(query: string, limit = 50): Promise<Issue[]> {
124
+ const scope = this.repo ? `repo:${this.owner}/${this.repo}` : `org:${this.owner}`;
125
+ const q = encodeURIComponent(`${scope} ${query}`);
126
+ const result = await this.http.get<{ items: GhIssue[] }>(`/search/issues?q=${q}&per_page=${limit}`);
127
+ return (result?.items ?? []).filter((i) => !i.pull_request).map(toDomain);
128
+ }
129
+
130
+ // GitHub has no native sub-issue relationship exposed via REST v3.
131
+ async listChildren(_key: string): Promise<Issue[]> {
132
+ return [];
133
+ }
134
+
135
+ async listComments(key: string): Promise<Comment[]> {
136
+ const number = parseIssueNumber(key);
137
+ const raw = (await this.http.get<GhComment[]>(`${this.repoPath()}/issues/${number}/comments`)) ?? [];
138
+ return raw.map(commentToDomain);
139
+ }
140
+
141
+ async addComment(key: string, body: string): Promise<Comment> {
142
+ this.requireAuth();
143
+ const number = parseIssueNumber(key);
144
+ const raw = await this.http.post<GhComment>(`${this.repoPath()}/issues/${number}/comments`, { body });
145
+ if (!raw) throw new Error("github: add comment returned no body");
146
+ return commentToDomain(raw);
147
+ }
148
+ }
149
+
150
+ function parseIssueNumber(key: string): string {
151
+ const stripped = key.replace(/^#/, "");
152
+ const idx = stripped.lastIndexOf("#");
153
+ return idx >= 0 ? stripped.slice(idx + 1) : stripped;
154
+ }
155
+
156
+ function mapStatusToGitHub(status: Status): "open" | "closed" {
157
+ return status === "done" || status === "canceled" ? "closed" : "open";
158
+ }
159
+
160
+ function mapStatusFromGitHub(state: string): Status {
161
+ return state.toLowerCase() === "closed" ? "done" : "todo";
162
+ }
163
+
164
+ function priorityFromLabels(labels: GhLabel[]): ReturnType<typeof parsePriority> {
165
+ for (const l of labels) {
166
+ const lower = l.name.toLowerCase();
167
+ if (lower.includes("urgent") || lower.includes("critical")) return "urgent";
168
+ if (lower.includes("high")) return "high";
169
+ if (lower.includes("medium")) return "medium";
170
+ if (lower.includes("low")) return "low";
171
+ }
172
+ return "none";
173
+ }
174
+
175
+ function toDomain(gh: GhIssue): Issue {
176
+ return {
177
+ ref: `github:#${gh.number}`,
178
+ id: String(gh.number),
179
+ key: `#${gh.number}`,
180
+ title: gh.title,
181
+ description: gh.body ?? undefined,
182
+ status: mapStatusFromGitHub(gh.state),
183
+ rawStatus: gh.state,
184
+ priority: priorityFromLabels(gh.labels ?? []),
185
+ labels: gh.labels?.length ? gh.labels.map((l) => l.name) : undefined,
186
+ assignee: gh.assignee?.login,
187
+ url: gh.html_url,
188
+ createdAt: gh.created_at,
189
+ updatedAt: gh.updated_at,
190
+ };
191
+ }
192
+
193
+ function commentToDomain(c: GhComment): Comment {
194
+ return {
195
+ id: String(c.id),
196
+ body: c.body,
197
+ author: c.user?.login,
198
+ createdAt: c.created_at,
199
+ updatedAt: c.updated_at,
200
+ };
201
+ }