@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 +1 -1
- package/src/agent-tools/tickets-vehicle.ts +19 -3
- package/src/cli/index.ts +8 -0
- package/src/github/github.ts +45 -12
- package/src/gitlab/gitlab.ts +54 -9
- package/src/issue/issue.ts +24 -0
- package/src/jira/jira.ts +26 -0
package/package.json
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
});
|
package/src/github/github.ts
CHANGED
|
@@ -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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|
package/src/gitlab/gitlab.ts
CHANGED
|
@@ -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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/issue/issue.ts
CHANGED
|
@@ -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),
|