@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 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
- `extensions/pi-tickets/` registers a single `tickets` tool for
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`). It talks to the same daemon
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 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`:
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
- { "extensions": ["/path/to/tickets/extensions/pi-tickets"] }
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 # 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
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.0",
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.2.1",
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": {
@@ -1,19 +1,39 @@
1
1
  /**
2
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.
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
- import { type FetchLike, HttpClient } from "./http.js";
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
- fetchImpl?: FetchLike;
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: string;
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 http: HttpClient;
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.http = new HttpClient({
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
- backend: "github",
63
- fetchImpl: opts.fetchImpl,
64
- headers: {
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 repoPath(): string {
92
+ private repoName(): string {
72
93
  if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
73
- return `/repos/${this.owner}/${this.repo}`;
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 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);
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 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`);
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 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");
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 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");
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 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);
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 number = parseIssueNumber(key);
137
- const raw = (await this.http.get<GhComment[]>(`${this.repoPath()}/issues/${number}/comments`)) ?? [];
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 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");
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 parseIssueNumber(key: string): string {
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 priorityFromLabels(labels: GhLabel[]): ReturnType<typeof parsePriority> {
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.name.toLowerCase();
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((l) => l.name) : undefined,
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,