@danypops/tickets 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",
@@ -34,6 +34,7 @@ const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes
34
34
 
35
35
  const stringProp: LooseObjectProperty = { type: "string" };
36
36
  const numberProp: LooseObjectProperty = { type: "number" };
37
+ const booleanProp: LooseObjectProperty = { type: "boolean" };
37
38
 
38
39
  interface OperationSpec {
39
40
  readonly action: TicketOperation;
@@ -71,7 +72,8 @@ const OPERATIONS: readonly OperationSpec[] = [
71
72
  },
72
73
  {
73
74
  action: "issue.list",
74
- description: "Lists issues from one backend, optionally filtered.",
75
+ description:
76
+ "Lists issues from one backend, optionally filtered. reportedByMe/assignedToMe/reviewRequestedOfMe/qaContactIsMe filter to the caller's own tickets using each backend's own identity (no username needed); set more than one to OR them together. reviewRequestedOfMe is GitHub/GitLab-only (PR/MR reviewer requests); qaContactIsMe is Jira-only (its discovered 'QA Contact' field) -- an unsupported flag on a given backend throws rather than being silently ignored.",
75
77
  effect: "read",
76
78
  properties: {
77
79
  backend: stringProp,
@@ -80,11 +82,25 @@ const OPERATIONS: readonly OperationSpec[] = [
80
82
  assignee: stringProp,
81
83
  labels: stringArrayProp,
82
84
  limit: numberProp,
85
+ reportedByMe: booleanProp,
86
+ assignedToMe: booleanProp,
87
+ reviewRequestedOfMe: booleanProp,
88
+ qaContactIsMe: booleanProp,
83
89
  },
84
90
  required: ["backend"],
85
- mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
91
+ mapInput: ({ backend, project, status, assignee, labels, limit, reportedByMe, assignedToMe, reviewRequestedOfMe, qaContactIsMe }) => ({
86
92
  backend,
87
- filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
93
+ filter: definedEntriesOnly({
94
+ project,
95
+ status,
96
+ assignee,
97
+ labels,
98
+ limit,
99
+ reportedByMe,
100
+ assignedToMe,
101
+ reviewRequestedOfMe,
102
+ qaContactIsMe,
103
+ }),
88
104
  }),
89
105
  },
90
106
  {
package/src/cli/index.ts CHANGED
@@ -47,6 +47,10 @@ program
47
47
  .option("--assignee <user>", "filter by assignee")
48
48
  .option("--label <label...>", "filter by label(s)")
49
49
  .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
50
+ .option("--reported-by-me", "only issues reported/authored by you (Jira reporter / GitHub PR author / GitLab issue author)")
51
+ .option("--assigned-to-me", "only issues assigned to you")
52
+ .option("--review-requested-of-me", "only PRs/MRs where you're requested as a reviewer (GitHub/GitLab only)")
53
+ .option("--qa-contact-is-me", "only issues where you're the QA Contact (Jira only)")
50
54
  .action(async (opts) => {
51
55
  const filter: ListFilter = {
52
56
  project: opts.project,
@@ -54,6 +58,10 @@ program
54
58
  assignee: opts.assignee,
55
59
  labels: opts.label,
56
60
  limit: opts.limit,
61
+ reportedByMe: opts.reportedByMe,
62
+ assignedToMe: opts.assignedToMe,
63
+ reviewRequestedOfMe: opts.reviewRequestedOfMe,
64
+ qaContactIsMe: opts.qaContactIsMe,
57
65
  };
58
66
  await withClient((client) => client.call("issue.list", { backend: opts.backend, filter }));
59
67
  });
@@ -165,19 +165,34 @@ export class GitHubRepository {
165
165
  }
166
166
 
167
167
  async list(filter: ListFilter): Promise<Issue[]> {
168
+ if (filter.qaContactIsMe) throw new Error('github: "qaContactIsMe" is a Jira-only concept, not supported on the github backend');
168
169
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
169
- const raw = await this.call((signal) =>
170
- this.client.rest.issues.listForRepo({
171
- owner: this.owner,
172
- repo: this.repoName(),
173
- per_page: limit,
174
- state: filter.status ? mapStatusToGitHub(filter.status) : "all",
175
- assignee: filter.assignee,
176
- labels: filter.labels?.length ? filter.labels.join(",") : undefined,
177
- request: { signal },
178
- }),
179
- );
180
- return (raw as GhIssue[]).map((i) => toDomain(i));
170
+ const meQualifiers = buildMeQualifiers(filter);
171
+ if (meQualifiers.length === 0) {
172
+ const raw = await this.call((signal) =>
173
+ this.client.rest.issues.listForRepo({
174
+ owner: this.owner,
175
+ repo: this.repoName(),
176
+ per_page: limit,
177
+ state: filter.status ? mapStatusToGitHub(filter.status) : "all",
178
+ assignee: filter.assignee,
179
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
180
+ request: { signal },
181
+ }),
182
+ );
183
+ return (raw as GhIssue[]).map((i) => toDomain(i));
184
+ }
185
+ // Any "me" flag routes through the Search API instead of listForRepo: it's the only GitHub
186
+ // surface with a review-requested filter at all (confirmed against GitHub's own REST docs --
187
+ // listForRepo has no such parameter), and it happens to support author/assignee "me" qualifiers
188
+ // too, so one call covers every combination of the three flags. Response items are Issue-shaped
189
+ // (same pull_request: {merged_at} stub as listForRepo's own entries -- confirmed against
190
+ // @octokit/openapi-types' issue-search-result-item schema), so toDomain() applies unchanged.
191
+ const q = buildMeSearchQuery(this.owner, this.repoName(), filter, meQualifiers);
192
+ const result = (await this.call((signal) =>
193
+ this.client.rest.search.issuesAndPullRequests({ q, per_page: limit, request: { signal } }),
194
+ )) as { items: GhIssue[] };
195
+ return result.items.map((i) => toDomain(i));
181
196
  }
182
197
 
183
198
  async get(key: string): Promise<Issue> {
@@ -362,6 +377,24 @@ function mapStatusToGitHub(status: Status): "open" | "closed" {
362
377
  return status === "done" || status === "canceled" ? "closed" : "open";
363
378
  }
364
379
 
380
+ /** ListFilter.{reportedByMe,assignedToMe,reviewRequestedOfMe} -> GitHub Search API qualifiers -- see ListFilter's own doc comment. */
381
+ function buildMeQualifiers(filter: ListFilter): string[] {
382
+ const qualifiers: string[] = [];
383
+ if (filter.reportedByMe) qualifiers.push("author:@me");
384
+ if (filter.assignedToMe) qualifiers.push("assignee:@me");
385
+ if (filter.reviewRequestedOfMe) qualifiers.push("user-review-requested:@me");
386
+ return qualifiers;
387
+ }
388
+
389
+ /** Composes a full Search API `q` string: repo scope + status/labels (AND, matching list()'s own semantics) + the "me" qualifiers (OR'd together). */
390
+ function buildMeSearchQuery(owner: string, repo: string, filter: ListFilter, meQualifiers: readonly string[]): string {
391
+ const parts = [`repo:${owner}/${repo}`];
392
+ if (filter.status) parts.push(`is:${mapStatusToGitHub(filter.status)}`);
393
+ for (const label of filter.labels ?? []) parts.push(`label:"${label.replace(/"/g, '\\"')}"`);
394
+ parts.push(meQualifiers.length === 1 ? meQualifiers[0]! : `(${meQualifiers.join(" OR ")})`);
395
+ return parts.join(" ");
396
+ }
397
+
365
398
  function mapStatusFromGitHub(state: string): Status {
366
399
  return state.toLowerCase() === "closed" ? "done" : "todo";
367
400
  }
@@ -166,17 +166,54 @@ export class GitLabRepository {
166
166
  }
167
167
 
168
168
  async list(filter: ListFilter): Promise<Issue[]> {
169
+ if (filter.qaContactIsMe) throw new Error('gitlab: "qaContactIsMe" is a Jira-only concept, not supported on the gitlab backend');
170
+ if (filter.reviewRequestedOfMe) {
171
+ throw new Error(
172
+ 'gitlab: "reviewRequestedOfMe" is a merge-request-only concept (scope=reviews_for_me) that list()\'s Issues-only scope can\'t express -- see get()\'s "!<iid>" convention for merge requests',
173
+ );
174
+ }
169
175
  const limit = filter.limit && filter.limit > 0 ? filter.limit : 50;
170
- const raw = await this.call<GlIssue[]>(() =>
171
- this.client.Issues.all({
172
- projectId: this.projectId,
173
- perPage: limit,
174
- state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
175
- assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
176
- labels: filter.labels?.length ? filter.labels.join(",") : undefined,
177
- }),
176
+ const scopes = meScopes(filter);
177
+ if (scopes.length <= 1) {
178
+ const raw = await this.call<GlIssue[]>(() =>
179
+ this.client.Issues.all({
180
+ projectId: this.projectId,
181
+ perPage: limit,
182
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
183
+ assigneeUsername: filter.assignee ? [filter.assignee] : undefined,
184
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
185
+ ...(scopes[0] ? { scope: scopes[0] } : {}),
186
+ }),
187
+ );
188
+ return raw.map(toDomain);
189
+ }
190
+ // Both reportedByMe and assignedToMe: GitLab's own `scope` param takes exactly one value per
191
+ // call (no server-side OR across scopes), so the OR-of-roles group ListFilter's own doc comment
192
+ // describes is executed here as two calls, deduped client-side by ref -- the one place in this
193
+ // adapter an OR of "me" flags genuinely costs more than one request (Jira/GitHub both express
194
+ // it in a single native query).
195
+ const results = await Promise.all(
196
+ scopes.map((scope) =>
197
+ this.call<GlIssue[]>(() =>
198
+ this.client.Issues.all({
199
+ projectId: this.projectId,
200
+ perPage: limit,
201
+ state: filter.status ? mapStatusToGitLab(filter.status) : undefined,
202
+ labels: filter.labels?.length ? filter.labels.join(",") : undefined,
203
+ scope,
204
+ }),
205
+ ),
206
+ ),
178
207
  );
179
- return raw.map(toDomain);
208
+ const seen = new Set<string>();
209
+ const merged: Issue[] = [];
210
+ for (const raw of results.flat()) {
211
+ const issue = toDomain(raw);
212
+ if (seen.has(issue.ref)) continue;
213
+ seen.add(issue.ref);
214
+ merged.push(issue);
215
+ }
216
+ return merged.slice(0, limit);
180
217
  }
181
218
 
182
219
  async get(key: string): Promise<Issue> {
@@ -372,6 +409,14 @@ function mapStatusToGitLab(status: Status): "opened" | "closed" {
372
409
  return status === "done" || status === "canceled" ? "closed" : "opened";
373
410
  }
374
411
 
412
+ /** ListFilter.{reportedByMe,assignedToMe} -> GitLab Issues API `scope` values -- see ListFilter's own doc comment. */
413
+ function meScopes(filter: ListFilter): ("created_by_me" | "assigned_to_me")[] {
414
+ const scopes: ("created_by_me" | "assigned_to_me")[] = [];
415
+ if (filter.reportedByMe) scopes.push("created_by_me");
416
+ if (filter.assignedToMe) scopes.push("assigned_to_me");
417
+ return scopes;
418
+ }
419
+
375
420
  function mapStatusEventToGitLab(status: Status): "close" | "reopen" {
376
421
  return status === "done" || status === "canceled" ? "close" : "reopen";
377
422
  }
@@ -178,6 +178,30 @@ export interface ListFilter {
178
178
  assignee?: string;
179
179
  query?: string;
180
180
  limit?: number;
181
+ /**
182
+ * "Mine" filtering -- deliberately a small set of named, orthogonal boolean flags rather than
183
+ * either a hardcoded single `mine` concept or a generic cross-backend query language (see the
184
+ * research Doc "Tickets 'mine' filtering: grounded API research" for why both alternatives were
185
+ * rejected). Every backend maps each flag to its own native "current user" mechanism (Jira's JQL
186
+ * currentUser(), GitHub's Search API @me qualifiers, GitLab's scope=*_me) -- never a resolved
187
+ * literal username. Setting more than one of these ORs them together ("assignee OR reporter"),
188
+ * AND'd with every other ListFilter field exactly like today's plain fields. A backend that has
189
+ * no native equivalent for a given flag throws rather than silently ignoring it (e.g.
190
+ * reviewRequestedOfMe on Jira, qaContactIsMe on GitHub/GitLab) -- a silently-dropped role would
191
+ * make an incomplete result look complete.
192
+ */
193
+ /** Jira: `reporter = currentUser()`. GitHub: `author:@me` (Search API). GitLab: `scope=created_by_me` (Issues API). */
194
+ reportedByMe?: boolean;
195
+ /** Jira: `assignee = currentUser()`. GitHub: `assignee:@me` (Search API). GitLab: `scope=assigned_to_me` (Issues API). */
196
+ assignedToMe?: boolean;
197
+ /**
198
+ * GitHub: `user-review-requested:@me` (Search API, PRs only). GitLab: `scope=reviews_for_me` --
199
+ * a merge-request-only concept list()'s Issues-only scope can't express, so GitLab throws. Jira
200
+ * has no reviewer concept on plain issues, so Jira throws too.
201
+ */
202
+ reviewRequestedOfMe?: boolean;
203
+ /** Jira only, via the discovered "QA Contact" custom field (auto-discovers if never run). GitHub/GitLab throw -- no equivalent concept. */
204
+ qaContactIsMe?: boolean;
181
205
  }
182
206
 
183
207
  /** "backend:key" ref parsing, split on the first colon only (keys may contain colons). */
package/src/jira/jira.ts CHANGED
@@ -204,6 +204,11 @@ export class JiraRepository {
204
204
  * the background), not just the single legacy `project` config field.
205
205
  */
206
206
  async list(filter: ListFilter): Promise<Issue[]> {
207
+ if (filter.reviewRequestedOfMe) {
208
+ throw new Error(
209
+ 'jira: "reviewRequestedOfMe" has no meaning on Jira issues (no reviewer concept) -- omit this filter for the jira backend',
210
+ );
211
+ }
207
212
  const projects = filter.project ? [filter.project] : this.defaultProjects();
208
213
  const clauses: string[] = [];
209
214
  const scope = projectClause(projects);
@@ -211,9 +216,30 @@ export class JiraRepository {
211
216
  if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
212
217
  if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
213
218
  for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
219
+ const meClauses = await this.buildMeClauses(filter);
220
+ if (meClauses.length > 0) clauses.push(meClauses.length === 1 ? meClauses[0]! : `(${meClauses.join(" OR ")})`);
214
221
  return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
215
222
  }
216
223
 
224
+ /**
225
+ * Builds the OR'd group of "me" role clauses for list()'s ListFilter.{reportedByMe,assignedToMe,
226
+ * qaContactIsMe} flags -- see ListFilter's own doc comment for why this is a small set of named
227
+ * flags rather than a hardcoded single "mine" concept or a generic query language. "QA Contact"
228
+ * resolves through the same discoverFields()-backed manifest applyCustomFields() already uses
229
+ * (auto-discovering on first use), so an instance without that field throws a clear "unknown
230
+ * custom field" error rather than silently omitting the clause.
231
+ */
232
+ private async buildMeClauses(filter: ListFilter): Promise<string[]> {
233
+ const clauses: string[] = [];
234
+ if (filter.assignedToMe) clauses.push("assignee = currentUser()");
235
+ if (filter.reportedByMe) clauses.push("reporter = currentUser()");
236
+ if (filter.qaContactIsMe) {
237
+ await this.resolveCustomField("QA Contact");
238
+ clauses.push('"QA Contact" = currentUser()');
239
+ }
240
+ return clauses;
241
+ }
242
+
217
243
  async get(key: string): Promise<Issue> {
218
244
  const [raw, remoteLinks] = await Promise.all([
219
245
  this.call<JiraIssue>(() => this.client.issues.getIssue({ issueIdOrKey: key }), key),