@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 +21 -0
- package/README.md +196 -0
- package/RESEARCH.md +109 -0
- package/package.json +57 -0
- package/src/adapters/errors.ts +33 -0
- package/src/adapters/github.ts +201 -0
- package/src/adapters/gitlab.ts +237 -0
- package/src/adapters/http.ts +86 -0
- package/src/adapters/jira.ts +311 -0
- package/src/application/service.ts +84 -0
- package/src/auth/browser.ts +35 -0
- package/src/auth/device-flow.ts +140 -0
- package/src/auth/github-oauth.ts +44 -0
- package/src/auth/gitlab-oauth.ts +51 -0
- package/src/auth/jira-oauth.ts +251 -0
- package/src/auth/token-store.ts +73 -0
- package/src/cli/index.ts +337 -0
- package/src/client/tickets-client.ts +89 -0
- package/src/config/config.ts +162 -0
- package/src/daemon/bootstrap.ts +86 -0
- package/src/daemon/ledger.ts +124 -0
- package/src/daemon/main.ts +20 -0
- package/src/daemon/ops.ts +75 -0
- package/src/daemon/poller.ts +52 -0
- package/src/daemon/server.ts +100 -0
- package/src/domain/issue.ts +114 -0
- package/src/index.ts +31 -0
- package/src/ports/repository.ts +27 -0
- package/src/util/package-root.ts +27 -0
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
|
+
}
|