@dac-tech-team-tw/itsaplan-cli 0.0.0-stage → 0.2.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,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ export class CliError extends Error {
3
+ code;
4
+ exitCode;
5
+ details;
6
+ constructor(code, message, exitCode = 2, details = {}) {
7
+ super(message);
8
+ this.code = code;
9
+ this.exitCode = exitCode;
10
+ this.details = details;
11
+ }
12
+ }
13
+ export const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
+ export const API_BASELINE = "1.9.0";
@@ -0,0 +1,9 @@
1
+ import { type Context, type Result } from "./contracts.js";
2
+ import { type IssueOptions } from "./issues.js";
3
+ export interface DevelopmentOptions extends IssueOptions {
4
+ repository?: string;
5
+ number?: string;
6
+ page?: string;
7
+ state?: string;
8
+ }
9
+ export declare function runDevelopment(ctx: Context, action: string, identifier: string | undefined, options: DevelopmentOptions): Promise<Result>;
@@ -0,0 +1,220 @@
1
+ import { CliError, } from "./contracts.js";
2
+ import { integer, object, objects, positive, publicData, resolve, } from "./issues.js";
3
+ import { applied, preview, verificationFailed, withWrite, writeFailure, } from "./writes.js";
4
+ const providers = ["github", "gitlab", "gitea", "forgejo", "bitbucket"];
5
+ const nonempty = (value) => typeof value === "string" && value.trim().length > 0;
6
+ function unsupported(message) {
7
+ throw new CliError("UNSUPPORTED_RESPONSE", message);
8
+ }
9
+ function required(value, flag) {
10
+ if (value === undefined)
11
+ throw new CliError("INVALID_ARGUMENT", `${flag} is required`);
12
+ return integer(value, flag);
13
+ }
14
+ async function repositories(ctx, issueId) {
15
+ const values = objects(await ctx.client.request(`/issues/${issueId}/development/repositories`), "connected repositories");
16
+ const ids = new Set();
17
+ return values.map((value) => {
18
+ if (!positive(value.id) ||
19
+ !["github", "gitlab"].includes(String(value.provider)) ||
20
+ !nonempty(value.fullName) ||
21
+ !nonempty(value.webUrl) ||
22
+ ids.has(value.id))
23
+ unsupported("Connected repository response has invalid or ambiguous identity");
24
+ ids.add(value.id);
25
+ return {
26
+ id: value.id,
27
+ provider: value.provider,
28
+ fullName: value.fullName,
29
+ webUrl: value.webUrl,
30
+ };
31
+ });
32
+ }
33
+ function connected(values, id) {
34
+ const repository = values.find((value) => value.id === id);
35
+ if (!repository)
36
+ throw new CliError("REPOSITORY_NOT_CONNECTED", "The selected repository is not connected to this issue's project");
37
+ return repository;
38
+ }
39
+ function link(value) {
40
+ const entry = object(value, "development link");
41
+ if (!positive(entry.id) ||
42
+ !providers.includes(String(entry.provider)) ||
43
+ !nonempty(entry.repository) ||
44
+ !["pull_request", "branch"].includes(String(entry.kind)) ||
45
+ (entry.kind === "pull_request"
46
+ ? !positive(entry.number)
47
+ : entry.number !== null || !nonempty(entry.sourceBranch)))
48
+ unsupported("Development link response is missing stable identity");
49
+ return entry;
50
+ }
51
+ function links(item) {
52
+ // Both exact issue routes expose `development` in the API baseline.
53
+ const values = objects(item.development, "issue development").map(link);
54
+ const ids = new Set();
55
+ const identities = new Set();
56
+ for (const value of values) {
57
+ const identity = JSON.stringify([
58
+ value.kind,
59
+ value.provider,
60
+ value.repository,
61
+ value.kind === "pull_request" ? value.number : value.sourceBranch,
62
+ ]);
63
+ if (ids.has(value.id) || identities.has(identity))
64
+ throw new CliError("CONFLICT", "Issue development links contain ambiguous identities; no write was sent", 5);
65
+ ids.add(value.id);
66
+ identities.add(identity);
67
+ }
68
+ return values;
69
+ }
70
+ function matches(value, repository, number) {
71
+ return (value.kind === "pull_request" &&
72
+ value.provider === repository.provider &&
73
+ value.repository === repository.fullName &&
74
+ value.number === number);
75
+ }
76
+ function pullRequests(value) {
77
+ const items = objects(value, "pull requests");
78
+ const numbers = new Set();
79
+ for (const item of items) {
80
+ if (!positive(item.number) ||
81
+ typeof item.title !== "string" ||
82
+ !(item.url === null || typeof item.url === "string") ||
83
+ !["open", "merged", "closed"].includes(String(item.state)) ||
84
+ typeof item.draft !== "boolean" ||
85
+ !(item.sourceBranch === null || typeof item.sourceBranch === "string") ||
86
+ typeof item.targetBranch !== "string" ||
87
+ !(item.headSha === null || typeof item.headSha === "string") ||
88
+ typeof item.updatedAt !== "string" ||
89
+ typeof item.linked !== "boolean" ||
90
+ numbers.has(item.number))
91
+ unsupported("Pull request response has invalid or ambiguous metadata");
92
+ numbers.add(item.number);
93
+ }
94
+ return items;
95
+ }
96
+ export async function runDevelopment(ctx, action, identifier, options) {
97
+ if (action === "link-pr")
98
+ return withWrite(options, async (actor) => {
99
+ const repositoryId = required(options.repository, "--repository");
100
+ const number = required(options.number, "--number");
101
+ const { item } = await resolve(ctx, identifier, options);
102
+ const repository = connected(await repositories(ctx, item.id), repositoryId);
103
+ const existing = links(item).find((value) => matches(value, repository, number));
104
+ const data = {
105
+ action: "link-pr",
106
+ changed: false,
107
+ alreadyLinked: Boolean(existing),
108
+ issueId: item.id,
109
+ identifier: item.identifier,
110
+ repositoryId,
111
+ provider: repository.provider,
112
+ repository: repository.fullName,
113
+ number,
114
+ };
115
+ if (existing)
116
+ data.linkId = existing.id;
117
+ const path = `/issues/${item.id}/development`;
118
+ const body = { repositoryId, number };
119
+ if (options.dryRun)
120
+ return preview(ctx, actor, data, path, body, [
121
+ "Exact pull request existence is verified by the server on write; discovery is not proof that this number exists.",
122
+ ...(existing
123
+ ? [
124
+ "This association already exists; a normal invocation is a no-op.",
125
+ ]
126
+ : []),
127
+ ]);
128
+ if (existing)
129
+ return {
130
+ data,
131
+ meta: { actor },
132
+ outcome: "not_applied",
133
+ verification: "passed",
134
+ };
135
+ let response;
136
+ try {
137
+ // The server looks up this exact number (including closed PRs) and upserts its identity.
138
+ response = await ctx.client.request(path, { method: "POST", body });
139
+ }
140
+ catch (error) {
141
+ return writeFailure(error, data, actor);
142
+ }
143
+ data.changed = true;
144
+ if (typeof response === "object" &&
145
+ response !== null &&
146
+ !Array.isArray(response)) {
147
+ const observed = response;
148
+ if (positive(observed.id))
149
+ data.linkId = observed.id;
150
+ }
151
+ try {
152
+ const verified = link(response);
153
+ if (matches(verified, repository, number))
154
+ return applied({ ...data, linkId: verified.id }, actor);
155
+ }
156
+ catch {
157
+ // A malformed accepted response can still be verified by the exact issue read.
158
+ }
159
+ try {
160
+ const current = object(await ctx.client.request(`/issues/${item.id}`), "issue");
161
+ if (current.id !== item.id || current.projectId !== item.projectId)
162
+ unsupported("Verification returned a different issue");
163
+ const verified = links(current).find((value) => matches(value, repository, number));
164
+ if (verified)
165
+ return applied({ ...data, linkId: verified.id }, actor);
166
+ }
167
+ catch {
168
+ // Never resend an accepted write when verification cannot establish the identity.
169
+ }
170
+ return verificationFailed(data, actor);
171
+ });
172
+ if (!["repositories", "pull-requests", "development"].includes(action))
173
+ throw new CliError("UNKNOWN_ACTION", `Unsupported development action: ${action}`);
174
+ if (options.limit !== undefined)
175
+ throw new CliError("UNSUPPORTED_PAGINATION", "Development commands do not support --limit; pull-requests uses --page and --state");
176
+ const { item } = await resolve(ctx, identifier, options);
177
+ const scope = { issueId: item.id, identifier: item.identifier };
178
+ if (action === "development") {
179
+ const items = links(item);
180
+ return {
181
+ data: { ...scope, items: publicData(items), nextCursor: null },
182
+ meta: { returned: items.length, completeness: "complete" },
183
+ };
184
+ }
185
+ if (action === "repositories") {
186
+ const items = await repositories(ctx, item.id);
187
+ return {
188
+ data: { ...scope, items, nextCursor: null },
189
+ meta: { returned: items.length, completeness: "complete" },
190
+ };
191
+ }
192
+ const repositoryId = required(options.repository, "--repository");
193
+ const page = options.page === undefined ? 1 : integer(options.page, "--page");
194
+ const state = options.state ?? "open";
195
+ if (state !== "open" && state !== "all")
196
+ throw new CliError("INVALID_ARGUMENT", "--state must be open or all");
197
+ const repository = connected(await repositories(ctx, item.id), repositoryId);
198
+ const response = object(await ctx.client.request(`/issues/${item.id}/development/repositories/${repositoryId}/pull-requests`, { query: { page, state } }), "pull request page");
199
+ const items = pullRequests(response.pullRequests);
200
+ if (response.nextPage !== null &&
201
+ (!positive(response.nextPage) || response.nextPage <= page))
202
+ unsupported("Pull request response has invalid nextPage");
203
+ return {
204
+ data: {
205
+ ...scope,
206
+ repositoryId,
207
+ provider: repository.provider,
208
+ repository: repository.fullName,
209
+ items: publicData(items),
210
+ nextPage: response.nextPage,
211
+ },
212
+ meta: {
213
+ page,
214
+ state,
215
+ returned: items.length,
216
+ completeness: page === 1 && response.nextPage === null ? "complete" : "partial",
217
+ completenessScope: "selected repository and state filter",
218
+ },
219
+ };
220
+ }
@@ -0,0 +1,9 @@
1
+ import { type Context, type Result } from "./contracts.js";
2
+ import { type IssueOptions } from "./issues.js";
3
+ export interface DocumentOptions extends IssueOptions {
4
+ title?: string;
5
+ visibility?: string;
6
+ document?: string;
7
+ }
8
+ export declare function runDocument(ctx: Context, action: string, documentId: string | undefined, options: DocumentOptions): Promise<Result>;
9
+ export declare function runIssueDocument(ctx: Context, action: string, identifier: string | undefined, options: DocumentOptions): Promise<Result>;
@@ -0,0 +1,256 @@
1
+ import { CliError, } from "./contracts.js";
2
+ import { getProject, integer, object, objects, positive, projectRef, publicData, readBody, resolve, setupFor, } from "./issues.js";
3
+ import { applied, preview, verificationFailed, withWrite, writeFailure, } from "./writes.js";
4
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
+ function unsupported(message) {
6
+ throw new CliError("UNSUPPORTED_RESPONSE", message);
7
+ }
8
+ function scopeFor(setup) {
9
+ if (!positive(setup.project.id) || typeof setup.project.ref !== "string")
10
+ unsupported("Project response is missing stable identifiers");
11
+ return {
12
+ projectId: setup.project.id,
13
+ projectRef: projectRef(setup.project.ref),
14
+ };
15
+ }
16
+ async function selectedScope(ctx, options) {
17
+ const ref = options.project ?? ctx.options.project ?? ctx.project;
18
+ if (ref === undefined)
19
+ throw new CliError("PROJECT_REQUIRED", "Provide --project team.KEY or configure a project");
20
+ return scopeFor(await getProject(ctx, projectRef(ref)));
21
+ }
22
+ function visibility(isPrivate) {
23
+ return {
24
+ visibility: isPrivate ? "private" : "project",
25
+ visibilityDescription: isPrivate
26
+ ? "Private to the document owner; not internet-public."
27
+ : "Visible to permitted project members; not internet-public.",
28
+ };
29
+ }
30
+ function pathFor(scope, documentId) {
31
+ return `/projects/${encodeURIComponent(scope.projectRef)}/documents${documentId === undefined ? "" : `/${documentId}`}`;
32
+ }
33
+ function document(value, scope, id) {
34
+ const entry = object(value, "document");
35
+ if (!positive(entry.id) || !positive(entry.projectId))
36
+ unsupported("Document response is missing stable identifiers");
37
+ if (entry.id !== id || entry.projectId !== scope.projectId)
38
+ throw new CliError("PROJECT_CONFLICT", "The document does not match the selected document and project");
39
+ if (typeof entry.title !== "string" ||
40
+ typeof entry.content !== "string" ||
41
+ typeof entry.isPrivate !== "boolean" ||
42
+ (entry.archivedAt !== null && typeof entry.archivedAt !== "string"))
43
+ unsupported("Document response is missing content, visibility, or archive status");
44
+ return entry;
45
+ }
46
+ async function readDocument(ctx, scope, id) {
47
+ return document(await ctx.client.request(pathFor(scope, id)), scope, id);
48
+ }
49
+ function issueLinks(value, scope) {
50
+ const entries = objects(value, "document issue links");
51
+ const key = scope.projectRef.slice(scope.projectRef.lastIndexOf(".") + 1);
52
+ if (entries.some((entry) => !positive(entry.issueId) ||
53
+ !positive(entry.sequenceNumber) ||
54
+ entry.identifier !== `${key}-${entry.sequenceNumber}` ||
55
+ typeof entry.title !== "string" ||
56
+ typeof entry.archived !== "boolean" ||
57
+ typeof entry.createdAt !== "string" ||
58
+ (entry.projectId !== undefined && entry.projectId !== scope.projectId)))
59
+ unsupported("Document issue links are missing identifiers or contain foreign project entries");
60
+ return entries;
61
+ }
62
+ async function readIssueLinks(ctx, scope, documentId) {
63
+ return issueLinks(await ctx.client.request(`${pathFor(scope, documentId)}/issues`), scope);
64
+ }
65
+ function finalContent(body, actor) {
66
+ const name = actor.name === "codex" ? "Codex" : "Claude";
67
+ const content = actor.kind === "agent" ? `${body.trimEnd()}\n\n— by ${name} via iap` : body;
68
+ if (content.length > 250000)
69
+ throw new CliError("BODY_LENGTH", "The final document content including its footer must be at most 250000 characters");
70
+ return content;
71
+ }
72
+ async function create(ctx, documentId, options, actor) {
73
+ if (documentId !== undefined)
74
+ throw new CliError("CONFLICTING_ARGUMENTS", "document create does not accept a document ID");
75
+ if (options.visibility !== "project" && options.visibility !== "private")
76
+ throw new CliError("VISIBILITY_REQUIRED", "Provide --visibility project|private; project visibility is not internet-public");
77
+ if (options.title === undefined ||
78
+ !options.title.trim() ||
79
+ options.title.length > 255)
80
+ throw new CliError("TITLE_LENGTH", "Provide a nonempty --title of at most 255 characters");
81
+ const body = await readBody(options);
82
+ if (body === undefined || !body.trim())
83
+ throw new CliError("BODY_REQUIRED", "Provide nonempty --body or --body-file");
84
+ const content = finalContent(body, actor);
85
+ const scope = await selectedScope(ctx, options);
86
+ const requestBody = {
87
+ title: options.title,
88
+ content,
89
+ isPrivate: options.visibility === "private",
90
+ };
91
+ const path = pathFor(scope);
92
+ const data = {
93
+ action: "create",
94
+ ...scope,
95
+ ...visibility(requestBody.isPrivate),
96
+ changed: false,
97
+ };
98
+ if (options.dryRun)
99
+ return preview(ctx, actor, data, path, requestBody);
100
+ let response;
101
+ try {
102
+ response = await ctx.client.request(path, {
103
+ method: "POST",
104
+ body: requestBody,
105
+ });
106
+ }
107
+ catch (error) {
108
+ return writeFailure(error, data, actor);
109
+ }
110
+ data.changed = true;
111
+ if (record(response) && positive(response.id))
112
+ data.documentId = response.id;
113
+ const matches = (value) => record(value) &&
114
+ value.id === data.documentId &&
115
+ positive(value.id) &&
116
+ value.projectId === scope.projectId &&
117
+ value.title === requestBody.title &&
118
+ value.content === content &&
119
+ value.isPrivate === requestBody.isPrivate;
120
+ if (matches(response))
121
+ return applied(data, actor);
122
+ if (positive(data.documentId)) {
123
+ try {
124
+ if (matches(await ctx.client.request(pathFor(scope, data.documentId))))
125
+ return applied(data, actor);
126
+ }
127
+ catch {
128
+ // An accepted create keeps its stable ID even when the verification read fails.
129
+ }
130
+ }
131
+ return verificationFailed(data, actor);
132
+ }
133
+ export async function runDocument(ctx, action, documentId, options) {
134
+ if (action === "create")
135
+ return withWrite(options, (actor) => create(ctx, documentId, options, actor));
136
+ if (action !== "view" && action !== "issues")
137
+ throw new CliError("UNKNOWN_ACTION", `Unsupported document action: ${action}`);
138
+ if (documentId === undefined)
139
+ throw new CliError("DOCUMENT_REQUIRED", "Provide a document ID");
140
+ const id = integer(documentId, "document ID");
141
+ const scope = await selectedScope(ctx, options);
142
+ const entry = await readDocument(ctx, scope, id);
143
+ const data = {
144
+ documentId: id,
145
+ ...scope,
146
+ ...visibility(entry.isPrivate),
147
+ };
148
+ if (action === "view")
149
+ return { data: { ...object(publicData(entry), "document"), ...data } };
150
+ const entries = await readIssueLinks(ctx, scope, id);
151
+ return {
152
+ data: { ...data, items: publicData(entries), nextCursor: null },
153
+ meta: {
154
+ returned: entries.length,
155
+ completeness: "complete",
156
+ scope: "caller-visible document links",
157
+ },
158
+ };
159
+ }
160
+ async function linkDocument(ctx, identifier, options, actor) {
161
+ if (options.document === undefined)
162
+ throw new CliError("DOCUMENT_REQUIRED", "Provide --document with an existing document ID");
163
+ const documentId = integer(options.document, "--document");
164
+ const { item, ref } = await resolve(ctx, identifier, options);
165
+ const scope = scopeFor(await setupFor(ctx, item, ref));
166
+ const entry = await readDocument(ctx, scope, documentId);
167
+ if (entry.archivedAt !== null)
168
+ throw new CliError("DOCUMENT_ARCHIVED", "Archived documents cannot be linked; restore the document first");
169
+ // The server permits linking a locked document; locking only restricts content changes.
170
+ const links = await readIssueLinks(ctx, scope, documentId);
171
+ const matches = (link) => link.issueId === item.id &&
172
+ link.sequenceNumber === item.sequenceNumber &&
173
+ link.identifier === item.identifier;
174
+ const alreadyLinked = links.some(matches);
175
+ const data = {
176
+ action: "link-document",
177
+ documentId,
178
+ issueId: item.id,
179
+ identifier: item.identifier,
180
+ ...scope,
181
+ ...visibility(entry.isPrivate),
182
+ changed: false,
183
+ alreadyLinked,
184
+ };
185
+ const path = `${pathFor(scope, documentId)}/issues`;
186
+ const body = { issueId: item.id };
187
+ if (options.dryRun)
188
+ return preview(ctx, actor, data, path, body, alreadyLinked
189
+ ? [
190
+ "This association already exists; a normal invocation is a no-op. The server rejects duplicate POSTs with 409.",
191
+ ]
192
+ : undefined);
193
+ if (alreadyLinked)
194
+ return {
195
+ data,
196
+ meta: { actor },
197
+ outcome: "not_applied",
198
+ verification: "passed",
199
+ };
200
+ let response;
201
+ try {
202
+ response = await ctx.client.request(path, { method: "POST", body });
203
+ }
204
+ catch (error) {
205
+ return writeFailure(error, data, actor);
206
+ }
207
+ data.changed = true;
208
+ try {
209
+ if (issueLinks([response], scope).some(matches))
210
+ return applied(data, actor);
211
+ }
212
+ catch {
213
+ // A malformed or unrelated accepted response needs an actual association read.
214
+ }
215
+ try {
216
+ if ((await readIssueLinks(ctx, scope, documentId)).some(matches))
217
+ return applied(data, actor);
218
+ }
219
+ catch {
220
+ // Preserve both known resource IDs on an accepted but unverifiable link.
221
+ }
222
+ return verificationFailed(data, actor);
223
+ }
224
+ export async function runIssueDocument(ctx, action, identifier, options) {
225
+ if (action === "link-document")
226
+ return withWrite(options, (actor) => linkDocument(ctx, identifier, options, actor));
227
+ if (action !== "documents")
228
+ throw new CliError("UNKNOWN_ACTION", `Unsupported issue document action: ${action}`);
229
+ const { item, ref } = await resolve(ctx, identifier, options);
230
+ const scope = scopeFor(await setupFor(ctx, item, ref));
231
+ const entries = objects(await ctx.client.request(`${pathFor(scope)}/for-issue/${item.id}`), "issue document links");
232
+ if (entries.some((entry) => !positive(entry.documentId) ||
233
+ typeof entry.title !== "string" ||
234
+ typeof entry.isPrivate !== "boolean" ||
235
+ typeof entry.archived !== "boolean" ||
236
+ typeof entry.createdAt !== "string" ||
237
+ (entry.projectId !== undefined && entry.projectId !== scope.projectId)))
238
+ unsupported("Issue document links are missing identifiers, visibility, or contain foreign project entries");
239
+ return {
240
+ data: {
241
+ issueId: item.id,
242
+ identifier: item.identifier,
243
+ ...scope,
244
+ items: entries.map((entry) => ({
245
+ ...object(publicData(entry), "document link"),
246
+ ...visibility(entry.isPrivate),
247
+ })),
248
+ nextCursor: null,
249
+ },
250
+ meta: {
251
+ returned: entries.length,
252
+ completeness: "complete",
253
+ scope: "caller-visible document links",
254
+ },
255
+ };
256
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { type Client, type RequestOptions } from "./contracts.js";
2
+ export declare function registerSecret(token: string): void;
3
+ export declare function redact(value: unknown): unknown;
4
+ export declare function validateRelativePath(input: string): string;
5
+ export declare function classifyWriteFailure(error: unknown): "not_applied" | "unknown";
6
+ export declare class HttpClient implements Client {
7
+ private readonly apiUrl;
8
+ private readonly token;
9
+ constructor(apiUrl: string, token: string | undefined);
10
+ request(input: string, options?: RequestOptions): Promise<unknown>;
11
+ }