@danypops/tickets 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -39,6 +39,10 @@ export interface JiraBasicAuthOptions {
39
39
  email: string;
40
40
  token: string;
41
41
  project?: string;
42
+ /** Additional project keys the poller's background sync also pools into the ledger, beyond the single default `project` above -- see buildSyncQuery(). */
43
+ syncProjects?: string[];
44
+ /** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. */
45
+ syncMine?: boolean;
42
46
  timeoutMs?: number;
43
47
  /** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
44
48
  axiosAdapter?: AxiosAdapter;
@@ -55,6 +59,8 @@ export interface JiraOAuthOptions {
55
59
  accessToken: string;
56
60
  cloudId: string;
57
61
  project?: string;
62
+ syncProjects?: string[];
63
+ syncMine?: boolean;
58
64
  timeoutMs?: number;
59
65
  axiosAdapter?: AxiosAdapter;
60
66
  configDir?: string;
@@ -125,6 +131,8 @@ export class JiraRepository {
125
131
  /** Same auth/host config Version2Client was built from -- reused lazily by agileClient() so the Agile API client (board/quickfilter resolution) authenticates identically without duplicating the OAuth-vs-basic branching in the constructor below. */
126
132
  private readonly clientConfig: JiraClientConfig;
127
133
  private readonly project?: string;
134
+ private readonly syncProjects: string[];
135
+ private readonly syncMine: boolean;
128
136
  private readonly configDir?: string;
129
137
  /** display name (lowercased) -> { fieldId, schema type/items }, populated lazily from client.issueFields.getFields(). */
130
138
  private customFieldCache?: Map<string, { id: string; type: string; items?: string }>;
@@ -136,6 +144,8 @@ export class JiraRepository {
136
144
  constructor(name: string, opts: JiraOptions) {
137
145
  this.name = name;
138
146
  this.project = opts.project;
147
+ this.syncProjects = opts.syncProjects ?? [];
148
+ this.syncMine = opts.syncMine ?? false;
139
149
  this.configDir = opts.configDir;
140
150
 
141
151
  if (this.configDir) {
@@ -274,6 +284,25 @@ export class JiraRepository {
274
284
  return this.searchJql(query, limit);
275
285
  }
276
286
 
287
+ /**
288
+ * SyncScopeExpandable -- widens what the poller's own background sync pools
289
+ * into the local ledger beyond the single default `project` list() falls
290
+ * back to: every configured project (default plus syncProjects) ORed with
291
+ * "assignee = currentUser()" when syncMine is set, so issues assigned to
292
+ * you in a project nobody thought to list still get pooled. Returns
293
+ * undefined -- letting the poller fall back to plain list() -- when
294
+ * neither syncProjects nor syncMine adds anything beyond the default
295
+ * project's own existing behavior.
296
+ */
297
+ buildSyncQuery(): string | undefined {
298
+ const projects = [...new Set([this.project, ...this.syncProjects].filter((p): p is string => Boolean(p)))];
299
+ if (projects.length <= 1 && !this.syncMine) return undefined;
300
+ const clauses: string[] = [];
301
+ if (projects.length > 0) clauses.push(`project in (${projects.map(jqlQuote).join(", ")})`);
302
+ if (this.syncMine) clauses.push("assignee = currentUser()");
303
+ return `${clauses.join(" OR ")} ORDER BY created DESC`;
304
+ }
305
+
277
306
  /**
278
307
  * BoardQuickFilterDiscoverable -- resolves a board's quick filter (the same object
279
308
  * a board view's `quickFilter=` URL param and a backlog view's `customFilter=`
@@ -14,6 +14,7 @@ import {
14
14
  hasFieldDiscovery,
15
15
  hasRawQuery,
16
16
  hasStatusDiscovery,
17
+ hasSyncScopeExpansion,
17
18
  hasTemplateDiscovery,
18
19
  type IssueRepository,
19
20
  } from "../ports/repository.js";
@@ -89,6 +90,25 @@ export class TicketService {
89
90
  return this.repo(backend).list(filter);
90
91
  }
91
92
 
93
+ /**
94
+ * Fetches issues for the poller's own background sync pass (see
95
+ * daemon/poller.ts). Prefers a backend's own expanded sync scope
96
+ * (SyncScopeExpandable -- Jira: multiple configured projects plus
97
+ * everything assigned to the authenticated user, unioned into one query)
98
+ * over its plain default-project list() when one is configured; falls
99
+ * back to list() otherwise, so a backend with no sync scope configured
100
+ * (or with no such capability at all, e.g. GitHub/GitLab) behaves exactly
101
+ * as before.
102
+ */
103
+ async syncFetch(backend: string, limit: number): Promise<Issue[]> {
104
+ const repo = this.repo(backend);
105
+ if (hasSyncScopeExpansion(repo) && hasRawQuery(repo)) {
106
+ const query = repo.buildSyncQuery();
107
+ if (query) return repo.runQuery(query, limit);
108
+ }
109
+ return repo.list({ limit });
110
+ }
111
+
92
112
  async get(ref: string): Promise<Issue> {
93
113
  const { backend, key } = parseRef(ref);
94
114
  return this.repo(backend).get(key);
package/src/cli/index.ts CHANGED
@@ -40,7 +40,7 @@ program
40
40
  .command("list")
41
41
  .description("list issues on a backend")
42
42
  .requiredOption("-b, --backend <name>", "backend name")
43
- .option("--project <key>", "project key/id override (e.g. reach CNF or OCPBUGS on a Jira backend defaulting to another project)")
43
+ .option("--project <key>", "project key/id override (e.g. reach ENG or OPS on a Jira backend defaulting to another project)")
44
44
  .option("--status <status>", "filter by status")
45
45
  .option("--assignee <user>", "filter by assignee")
46
46
  .option("--label <label...>", "filter by label(s)")
@@ -109,7 +109,7 @@ program
109
109
  .command("search <query>")
110
110
  .description("search issues on a backend")
111
111
  .requiredOption("-b, --backend <name>", "backend name")
112
- .option("--project <key>", "project key/id override (e.g. reach CNF or OCPBUGS on a Jira backend defaulting to another project)")
112
+ .option("--project <key>", "project key/id override (e.g. reach ENG or OPS on a Jira backend defaulting to another project)")
113
113
  .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
114
114
  .action(async (query: string, opts) => {
115
115
  await withClient((client) => client.call("issue.search", { backend: opts.backend, query, limit: opts.limit, project: opts.project }));
@@ -27,6 +27,10 @@ export interface BackendConfig {
27
27
  owner?: string;
28
28
  project?: string;
29
29
  repo?: string;
30
+ /** Jira only: additional project keys the background poller also pools into the ledger, beyond the single default `project` above. */
31
+ syncProjects?: string[];
32
+ /** Jira only: when true, the background poller also pools everything assigned to the authenticated user, regardless of project. */
33
+ syncMine?: boolean;
30
34
  }
31
35
 
32
36
  export interface Config {
@@ -58,6 +62,22 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
58
62
  return env[envFallback];
59
63
  }
60
64
 
65
+ /** Config wins over the env var; the env var is a comma-separated list (JIRA_SYNC_PROJECTS=ENG,OPS). */
66
+ function resolveSyncProjects(cfg: BackendConfig, env: NodeJS.ProcessEnv): string[] | undefined {
67
+ if (cfg.syncProjects) return cfg.syncProjects;
68
+ const raw = env.JIRA_SYNC_PROJECTS;
69
+ if (!raw) return undefined;
70
+ return raw
71
+ .split(",")
72
+ .map((p) => p.trim())
73
+ .filter(Boolean);
74
+ }
75
+
76
+ function resolveSyncMine(cfg: BackendConfig, env: NodeJS.ProcessEnv): boolean {
77
+ if (cfg.syncMine !== undefined) return cfg.syncMine;
78
+ return /^(1|true|yes)$/i.test(env.JIRA_SYNC_MINE ?? "");
79
+ }
80
+
61
81
  /**
62
82
  * Resolution order, highest priority first: (1) a running Enigma vault, if
63
83
  * one happens to be configured for this backend — entirely optional, never a
@@ -208,6 +228,8 @@ async function createRepository(
208
228
  accessToken: auth.token,
209
229
  cloudId: auth.extra.cloudId,
210
230
  project: cfg.project ?? env.JIRA_PROJECT,
231
+ syncProjects: resolveSyncProjects(cfg, env),
232
+ syncMine: resolveSyncMine(cfg, env),
211
233
  configDir: configDir(),
212
234
  });
213
235
  }
@@ -219,6 +241,8 @@ async function createRepository(
219
241
  email,
220
242
  token: auth.token,
221
243
  project: cfg.project ?? env.JIRA_PROJECT,
244
+ syncProjects: resolveSyncProjects(cfg, env),
245
+ syncMine: resolveSyncMine(cfg, env),
222
246
  configDir: configDir(),
223
247
  });
224
248
  }
@@ -23,7 +23,7 @@ export async function syncOnce(
23
23
  const results: { backend: string; synced: number; error?: string }[] = [];
24
24
  for (const backend of backends) {
25
25
  try {
26
- const issues = await service.list(backend, { limit: DEFAULT_SYNC_LIMIT });
26
+ const issues = await service.syncFetch(backend, DEFAULT_SYNC_LIMIT);
27
27
  const synced = ledger.upsertMany(backend, issues);
28
28
  results.push({ backend, synced });
29
29
  logger?.debug("ledger sync ok", { backend, synced });
@@ -100,3 +100,20 @@ export interface BoardFilterDiscoverable {
100
100
  export function hasBoardFilterDiscovery(repo: IssueRepository): repo is IssueRepository & BoardFilterDiscoverable {
101
101
  return typeof (repo as Partial<BoardFilterDiscoverable>).discoverBoardFilterJql === "function";
102
102
  }
103
+
104
+ /**
105
+ * Optional capability — lets a backend widen what the poller's own background
106
+ * sync pools into the local ledger beyond list()'s single default-project
107
+ * filter (Jira: additional named projects, plus everything assigned to the
108
+ * authenticated user, unioned into one JQL string). Returns undefined when
109
+ * nothing beyond the default scope is configured, so the poller falls back
110
+ * to plain list() unchanged. Jira only; GitHub/GitLab have no equivalent
111
+ * multi-project-plus-assignee query language to expand into.
112
+ */
113
+ export interface SyncScopeExpandable {
114
+ buildSyncQuery(): string | undefined;
115
+ }
116
+
117
+ export function hasSyncScopeExpansion(repo: IssueRepository): repo is IssueRepository & SyncScopeExpandable {
118
+ return typeof (repo as Partial<SyncScopeExpandable>).buildSyncQuery === "function";
119
+ }
@@ -138,7 +138,7 @@ const OPERATIONS: readonly OperationSpec[] = [
138
138
  action: "ledger.search",
139
139
  description: "Searches the local pooled-issue ledger (no live backend call).",
140
140
  effect: "read",
141
- properties: { query: stringProp, limit: numberProp },
141
+ properties: { query: stringProp, limit: numberProp, backend: stringProp },
142
142
  required: ["query"],
143
143
  },
144
144
  {