@dac-tech-team-tw/itsaplan-cli 0.1.0 → 0.3.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.
@@ -0,0 +1,241 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { CliError } from "./contracts.js";
3
+ import { getProject, integer, me, object, objects, positive, projectRef, publicData, readBody } from "./issues.js";
4
+ import { applied, preview, verificationFailed, withWrite, writeFailure } from "./writes.js";
5
+ const statuses = ["proposed", "planned", "active", "completed", "canceled"];
6
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
+ function unsupported(message) {
8
+ throw new CliError("UNSUPPORTED_RESPONSE", message);
9
+ }
10
+ function selectedRef(ctx, options) {
11
+ const ref = options.project ?? ctx.options.project ?? ctx.project;
12
+ return ref === undefined ? undefined : projectRef(ref);
13
+ }
14
+ async function selectedProject(ctx, options) {
15
+ const ref = selectedRef(ctx, options);
16
+ if (ref === undefined)
17
+ throw new CliError("PROJECT_REQUIRED", "Provide --project team.KEY or configure a project");
18
+ return getProject(ctx, ref);
19
+ }
20
+ function initiative(value, id) {
21
+ const item = object(value, "initiative");
22
+ if (!positive(item.id) || !positive(item.projectId))
23
+ unsupported("Initiative response is missing stable identifiers");
24
+ if (id !== undefined && item.id !== id)
25
+ unsupported("Initiative response has a different ID");
26
+ return item;
27
+ }
28
+ async function projectFor(ctx, options, item) {
29
+ let ref = selectedRef(ctx, options);
30
+ if (ref === undefined) {
31
+ const projects = objects(await ctx.client.request("/projects"), "projects");
32
+ const matches = projects.filter((project) => project.id === item.projectId);
33
+ if (matches.length !== 1 || typeof matches[0].ref !== "string")
34
+ unsupported("Cannot resolve the initiative project; provide --project team.KEY");
35
+ ref = matches[0].ref;
36
+ }
37
+ const setup = await getProject(ctx, ref);
38
+ if (setup.project.id !== item.projectId)
39
+ throw new CliError("PROJECT_CONFLICT", "The initiative does not belong to the selected project");
40
+ return setup;
41
+ }
42
+ function canonical(setup) {
43
+ return projectRef(String(setup.project.ref));
44
+ }
45
+ function validateStatus(value, multiple) {
46
+ const values = multiple ? value.split(",") : [value];
47
+ if (!values.length || values.some((entry) => !statuses.includes(entry)))
48
+ throw new CliError("INVALID_STATUS", `--status must be ${statuses.join("|")}${multiple ? " or a comma-separated subset" : ""}`);
49
+ }
50
+ function labelId(setup, input) {
51
+ const matches = setup.labels.filter((entry) => entry.name === input || String(entry.id) === input);
52
+ if (!matches.length)
53
+ throw new CliError("VALUE_NOT_FOUND", `Label ${JSON.stringify(input)} is not available in this project`);
54
+ if (matches.length !== 1)
55
+ throw new CliError("AMBIGUOUS_VALUE", `Label ${JSON.stringify(input)} is ambiguous`);
56
+ return Number(matches[0].id);
57
+ }
58
+ async function ownerId(ctx, setup, value) {
59
+ const input = value === "@me" ? String((await me(ctx)).id) : value.replace(/^@/, "");
60
+ const matches = setup.assignees.filter((entry) => entry.kind === "member" && (entry.userId === input || entry.username === input));
61
+ if (!matches.length)
62
+ throw new CliError("VALUE_NOT_FOUND", `Owner ${JSON.stringify(value)} is not available in this project`);
63
+ if (matches.length !== 1)
64
+ throw new CliError("AMBIGUOUS_VALUE", `Owner ${JSON.stringify(value)} is ambiguous`);
65
+ return String(matches[0].userId);
66
+ }
67
+ function signedDescription(body, actor, editing) {
68
+ let value = body;
69
+ if (actor.kind === "agent") {
70
+ value = value.trimEnd();
71
+ const footer = /(?:^|\r?\n[ \t]*\r?\n)— (?:by|last edited by) (?:Codex|Claude) via iap$/u;
72
+ while (footer.test(value))
73
+ value = value.replace(footer, "").trimEnd();
74
+ value = `${value}\n\n— ${editing ? "last edited by" : "by"} ${actor.name === "codex" ? "Codex" : "Claude"} via iap`;
75
+ }
76
+ return value;
77
+ }
78
+ function date(value, flag) {
79
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`)) || new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value)
80
+ throw new CliError("INVALID_DATE", `${flag} must be a valid YYYY-MM-DD date`);
81
+ return value;
82
+ }
83
+ async function fields(ctx, setup, options, actor, item) {
84
+ for (const [value, remove, flag] of [
85
+ [options.owner, options.removeOwner, "owner"],
86
+ [options.priority, options.removePriority, "priority"],
87
+ [options.startDate, options.removeStartDate, "start-date"],
88
+ [options.targetDate, options.removeTargetDate, "target-date"],
89
+ ]) {
90
+ if (value !== undefined && remove)
91
+ throw new CliError("CONFLICTING_ARGUMENTS", `--${flag} and --remove-${flag} conflict`);
92
+ }
93
+ const body = {};
94
+ if (options.title !== undefined) {
95
+ if (!options.title.trim())
96
+ throw new CliError("INVALID_TITLE", "--title must not be empty");
97
+ body.title = options.title;
98
+ }
99
+ const description = await readBody(options);
100
+ if (description !== undefined)
101
+ body.description = signedDescription(description, actor, item !== undefined);
102
+ if (options.status !== undefined) {
103
+ validateStatus(options.status, false);
104
+ body.status = options.status;
105
+ }
106
+ if (options.owner !== undefined)
107
+ body.ownerUserId = await ownerId(ctx, setup, options.owner);
108
+ if (options.removeOwner)
109
+ body.ownerUserId = null;
110
+ if (options.priority !== undefined) {
111
+ if (!["urgent", "high", "medium", "low"].includes(options.priority))
112
+ throw new CliError("INVALID_PRIORITY", "--priority must be urgent|high|medium|low");
113
+ body.priority = options.priority;
114
+ }
115
+ if (options.removePriority)
116
+ body.priority = null;
117
+ if (options.startDate !== undefined)
118
+ body.startDate = date(options.startDate, "--start-date");
119
+ if (options.removeStartDate)
120
+ body.startDate = null;
121
+ if (options.targetDate !== undefined)
122
+ body.targetDate = date(options.targetDate, "--target-date");
123
+ if (options.removeTargetDate)
124
+ body.targetDate = null;
125
+ const add = [...(options.label ?? []), ...(options.addLabel ?? [])].map((name) => labelId(setup, name));
126
+ const remove = (options.removeLabel ?? []).map((name) => labelId(setup, name));
127
+ if (add.some((id) => remove.includes(id)))
128
+ throw new CliError("CONFLICTING_ARGUMENTS", "The same label cannot be added and removed");
129
+ if (add.length || remove.length) {
130
+ if (item && (!Array.isArray(item.labelIds) || !item.labelIds.every(positive)))
131
+ unsupported("Initiative response is missing labelIds; cannot preserve existing labels");
132
+ body.labelIds = [...new Set([...(item ? item.labelIds.filter((id) => !remove.includes(id)) : []), ...add])];
133
+ }
134
+ return body;
135
+ }
136
+ function matches(value, id, projectId, body) {
137
+ if (!record(value) || !positive(value.id) || value.id !== id || value.projectId !== projectId)
138
+ return false;
139
+ return Object.entries(body).every(([key, expected]) => {
140
+ const actual = value[key];
141
+ if (key === "labelIds" && Array.isArray(actual) && Array.isArray(expected))
142
+ return actual.every(positive) && isDeepStrictEqual([...actual].sort((a, b) => a - b), [...expected].sort((a, b) => a - b));
143
+ return isDeepStrictEqual(actual, expected);
144
+ });
145
+ }
146
+ async function list(ctx, options) {
147
+ const page = integer(options.page ?? 1, "--page");
148
+ const pageSize = integer(options.limit ?? 25, "--limit", 100);
149
+ if (options.status !== undefined)
150
+ validateStatus(options.status, true);
151
+ if (options.sort !== undefined && !["title", "priority", "targetDate", "owner"].includes(options.sort))
152
+ throw new CliError("INVALID_SORT", "--sort must be title|priority|targetDate|owner");
153
+ if (options.dir !== undefined && !["asc", "desc"].includes(options.dir))
154
+ throw new CliError("INVALID_DIRECTION", "--dir must be asc|desc");
155
+ const setup = await selectedProject(ctx, options);
156
+ const response = object(await ctx.client.request(`/projects/${encodeURIComponent(canonical(setup))}/initiatives`, { query: { page, pageSize, status: options.status, search: options.search, sort: options.sort, dir: options.dir } }), "initiative page");
157
+ const items = objects(response.items, "initiative items");
158
+ if (!Number.isSafeInteger(response.total) || response.total < 0 || response.page !== page || response.pageSize !== pageSize || items.length > pageSize || items.some((item) => !positive(item.id) || item.projectId !== setup.project.id))
159
+ unsupported("Invalid initiative page or foreign initiative");
160
+ const total = response.total;
161
+ const offset = (page - 1) * pageSize;
162
+ if (!Number.isSafeInteger(offset))
163
+ unsupported("Initiative page offset is invalid");
164
+ const nextPage = offset + pageSize < total ? page + 1 : null;
165
+ return { data: { items: publicData(items), total, page, pageSize, nextPage }, meta: { scope: { projectId: setup.project.id, projectRef: canonical(setup) }, limit: pageSize, returned: items.length, completeness: page === 1 && items.length === total ? "complete" : "partial" } };
166
+ }
167
+ async function writeInitiative(ctx, action, identifier, options, actor) {
168
+ const editing = action === "edit";
169
+ if (!editing && identifier !== undefined)
170
+ throw new CliError("CONFLICTING_ARGUMENTS", "initiative create does not accept an ID");
171
+ let item;
172
+ let setup;
173
+ if (editing) {
174
+ if (identifier === undefined)
175
+ throw new CliError("INITIATIVE_REQUIRED", "Provide a numeric initiative ID");
176
+ const id = integer(identifier, "initiative ID");
177
+ item = initiative(await ctx.client.request(`/initiatives/${id}`), id);
178
+ setup = await projectFor(ctx, options, item);
179
+ }
180
+ else
181
+ setup = await selectedProject(ctx, options);
182
+ const body = await fields(ctx, setup, options, actor, item);
183
+ if (!editing && body.title === undefined)
184
+ throw new CliError("TITLE_REQUIRED", "Provide --title");
185
+ if (editing && Object.keys(body).length === 0)
186
+ throw new CliError("NO_CHANGES", "Provide at least one initiative edit field");
187
+ if (item)
188
+ for (const key of Object.keys(body))
189
+ if (!(key in item))
190
+ unsupported(`Initiative response is missing ${key}; cannot build a trustworthy diff`);
191
+ const path = editing ? `/initiatives/${item.id}` : `/projects/${encodeURIComponent(canonical(setup))}/initiatives`;
192
+ const method = editing ? "PATCH" : "POST";
193
+ const diff = Object.fromEntries(Object.entries(body).map(([key, value]) => [key, { before: item ? item[key] : null, after: value }]));
194
+ const data = { action, projectId: setup.project.id, projectRef: canonical(setup), ...(item ? { initiativeId: item.id } : {}), changed: false, diff };
195
+ if (options.dryRun)
196
+ return preview(ctx, actor, data, path, body, [], method);
197
+ let response;
198
+ try {
199
+ response = await ctx.client.request(path, { method, body });
200
+ }
201
+ catch (error) {
202
+ return writeFailure(error, data, actor, "initiative");
203
+ }
204
+ data.changed = true;
205
+ if (!editing && record(response) && positive(response.id))
206
+ data.initiativeId = response.id;
207
+ if (matches(response, data.initiativeId, setup.project.id, body))
208
+ return applied(data, actor);
209
+ if (positive(data.initiativeId)) {
210
+ try {
211
+ if (matches(await ctx.client.request(`/initiatives/${data.initiativeId}`), data.initiativeId, setup.project.id, body))
212
+ return applied(data, actor);
213
+ }
214
+ catch { /* The accepted write retains its ID even if verification fails. */ }
215
+ }
216
+ return verificationFailed(data, actor, "initiative");
217
+ }
218
+ export async function runInitiative(ctx, action, initiativeId, options) {
219
+ if (action === "list") {
220
+ if (initiativeId !== undefined)
221
+ throw new CliError("CONFLICTING_ARGUMENTS", "initiative list does not accept an ID");
222
+ return list(ctx, options);
223
+ }
224
+ if (action === "view") {
225
+ if (initiativeId === undefined)
226
+ throw new CliError("INITIATIVE_REQUIRED", "Provide a numeric initiative ID");
227
+ const id = integer(initiativeId, "initiative ID");
228
+ const item = initiative(await ctx.client.request(`/initiatives/${id}`), id);
229
+ const setup = await projectFor(ctx, options, item);
230
+ return { data: { initiative: publicData(item), projectRef: canonical(setup) }, meta: { scope: { initiativeId: id, projectId: setup.project.id, projectRef: canonical(setup) } } };
231
+ }
232
+ if (action === "create" || action === "edit") {
233
+ const result = await withWrite(options, (actor) => writeInitiative(ctx, action, initiativeId, options, actor));
234
+ if (record(result.data)) {
235
+ const { initiativeId: id, projectId, projectRef: ref } = result.data;
236
+ result.meta = { ...result.meta, scope: { ...(id !== undefined ? { initiativeId: id } : {}), projectId, projectRef: ref } };
237
+ }
238
+ return result;
239
+ }
240
+ throw new CliError("INVALID_ARGUMENT", "Unknown initiative action");
241
+ }
package/dist/issues.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface IssueOptions {
3
3
  id?: string;
4
4
  project?: string;
5
5
  comments?: boolean;
6
+ includeState?: boolean;
7
+ outputSelected?: boolean;
6
8
  limit?: string | number;
7
9
  cursor?: string;
8
10
  search?: string;
package/dist/issues.js CHANGED
@@ -627,9 +627,12 @@ export async function runIssue(ctx, action, identifier, options) {
627
627
  throw new CliError("UNSUPPORTED_FILTER_COMBINATION", "--search cannot be combined with --assignee");
628
628
  if (options.search !== undefined && !options.search.trim())
629
629
  throw new CliError("INVALID_SEARCH", "--search must not be empty");
630
- let assigneeUserId;
631
- if (options.assignee !== undefined)
632
- assigneeUserId = await assignee(ctx, await getProject(ctx, ref), options.assignee);
630
+ const setup = options.assignee !== undefined || options.includeState
631
+ ? await getProject(ctx, ref)
632
+ : undefined;
633
+ const assigneeUserId = options.assignee !== undefined && setup
634
+ ? await assignee(ctx, setup, options.assignee)
635
+ : undefined;
633
636
  const items = objects(await ctx.client.request(`/projects/${encodeURIComponent(ref)}/issues${options.search !== undefined ? "/search" : ""}`, {
634
637
  query: {
635
638
  limit,
@@ -642,14 +645,57 @@ export async function runIssue(ctx, action, identifier, options) {
642
645
  typeof entry.identifier !== "string" ||
643
646
  typeof entry.title !== "string"))
644
647
  unsupported("List response is missing issue identifiers or titles");
648
+ let selectedItems = items;
649
+ if (options.includeState && setup) {
650
+ if (items.some((entry) => !positive(entry.columnId)))
651
+ unsupported("List response is missing issue state identifiers");
652
+ const columns = new Map(setup.columns.map(({ id, name, stateType }) => [
653
+ id,
654
+ { id, name, stateType },
655
+ ]));
656
+ selectedItems = items.map((entry) => ({
657
+ ...entry,
658
+ state: columns.get(entry.columnId) ?? {
659
+ id: entry.columnId,
660
+ unavailable: true,
661
+ },
662
+ }));
663
+ }
645
664
  return {
646
- data: { items: publicData(items), nextCursor: null },
647
- meta: { limit, returned: items.length, completeness: "unknown" },
665
+ data: { items: publicData(selectedItems), nextCursor: null },
666
+ meta: {
667
+ limit,
668
+ returned: items.length,
669
+ completeness: "unknown",
670
+ ...(options.outputSelected
671
+ ? {
672
+ scope: {
673
+ projectRef: setup?.project.ref ?? ref,
674
+ ...(options.search !== undefined
675
+ ? { search: options.search }
676
+ : {}),
677
+ ...(options.assignee !== undefined
678
+ ? { assignee: options.assignee, assigneeUserId }
679
+ : {}),
680
+ },
681
+ }
682
+ : {}),
683
+ },
648
684
  };
649
685
  }
650
686
  if (!["resolve", "view", "activity", "comments"].includes(action))
651
687
  throw new CliError("UNKNOWN_ACTION", `Unsupported issue action: ${action}`);
652
688
  const { item, ref } = await resolve(ctx, identifier, options);
689
+ const selectedMeta = options.outputSelected
690
+ ? {
691
+ scope: {
692
+ issueId: item.id,
693
+ identifier: item.identifier,
694
+ ...(ref ? { projectRef: ref } : {}),
695
+ projectId: item.projectId,
696
+ },
697
+ }
698
+ : {};
653
699
  if (action === "resolve")
654
700
  return {
655
701
  data: publicData({
@@ -660,6 +706,7 @@ export async function runIssue(ctx, action, identifier, options) {
660
706
  sequenceNumber: item.sequenceNumber,
661
707
  ...(ref ? { projectRef: ref } : {}),
662
708
  }),
709
+ ...(options.outputSelected ? { meta: selectedMeta } : {}),
663
710
  };
664
711
  if (action === "activity" || action === "comments") {
665
712
  const limit = options.limit === undefined ? 25 : integer(options.limit, "--limit", 100);
@@ -673,10 +720,27 @@ export async function runIssue(ctx, action, identifier, options) {
673
720
  limit,
674
721
  returned: page.items.length,
675
722
  completeness: page.nextCursor === null ? "complete" : "partial",
723
+ ...selectedMeta,
724
+ ...(options.outputSelected
725
+ ? {
726
+ read: {
727
+ entries: {
728
+ status: "read_page",
729
+ contentRead: true,
730
+ filter: action === "comments" ? "comments" : "all",
731
+ cursor: options.cursor ?? null,
732
+ nextCursor: encodeCursor(page.nextCursor),
733
+ },
734
+ attachments: { status: "not_read", contentRead: false },
735
+ },
736
+ }
737
+ : {}),
676
738
  },
677
739
  };
678
740
  }
679
741
  const setup = await setupFor(ctx, item, ref);
742
+ if (selectedMeta.scope)
743
+ selectedMeta.scope.projectRef = String(setup.project.ref);
680
744
  const attachments = objects(await ctx.client.request(`/issues/${item.id}/attachments`), "attachments");
681
745
  if (attachments.some((entry) => typeof entry.id !== "string" ||
682
746
  typeof entry.filename !== "string" ||
@@ -685,6 +749,18 @@ export async function runIssue(ctx, action, identifier, options) {
685
749
  unsupported("Invalid attachment metadata");
686
750
  const data = object(enriched(item, setup), "enriched issue");
687
751
  data.attachments = publicData(attachments);
752
+ const read = options.outputSelected
753
+ ? {
754
+ read: {
755
+ comments: { status: "not_read" },
756
+ attachments: {
757
+ status: "metadata_only",
758
+ returned: attachments.length,
759
+ contentRead: false,
760
+ },
761
+ },
762
+ }
763
+ : {};
688
764
  if (options.comments) {
689
765
  const page = await feed(ctx, item.id, 25, undefined, true);
690
766
  data.comments = {
@@ -694,16 +770,31 @@ export async function runIssue(ctx, action, identifier, options) {
694
770
  return {
695
771
  data,
696
772
  meta: {
773
+ ...selectedMeta,
697
774
  comments: {
698
775
  limit: 25,
699
776
  returned: page.items.length,
700
777
  completeness: page.nextCursor === null ? "complete" : "partial",
701
778
  },
702
779
  attachmentsContentRead: false,
780
+ ...(options.outputSelected
781
+ ? {
782
+ read: {
783
+ ...read.read,
784
+ comments: {
785
+ status: "read_page",
786
+ nextCursor: encodeCursor(page.nextCursor),
787
+ },
788
+ },
789
+ }
790
+ : {}),
703
791
  },
704
792
  };
705
793
  }
706
- return { data, meta: { attachmentsContentRead: false } };
794
+ return {
795
+ data,
796
+ meta: { attachmentsContentRead: false, ...selectedMeta, ...read },
797
+ };
707
798
  }
708
799
  // Shared resource workflows use the same identity, scope, input and outcome rules.
709
800
  export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, };
@@ -0,0 +1,12 @@
1
+ import { type Result } from "./contracts.js";
2
+ export type OutputKind = "project.list" | "project.view" | "issue.list" | "issue.view" | "issue.resolve" | "issue.comments" | "issue.activity";
3
+ export interface OutputOptions {
4
+ summary?: boolean;
5
+ fields?: string;
6
+ }
7
+ /** Validate against command schemas even when the eventual result is empty. */
8
+ export declare function validateOutput(kind: OutputKind, options: OutputOptions): void;
9
+ /** Only opt-in issue list state output needs project state lookup enrichment. */
10
+ export declare function needsIssueStates(kind: OutputKind, options: OutputOptions): boolean;
11
+ /** Shape data only: serialization, redaction and fetching belong to callers. */
12
+ export declare function projectOutput(kind: OutputKind, result: Result, options: OutputOptions): Result;