Skip to content

Commit 9553108

Browse files
committed
feat: restore document attachment compatibility
1 parent aa91a15 commit 9553108

5 files changed

Lines changed: 417 additions & 67 deletions

File tree

graphql/mutations/documents.graphql

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
# GraphQL mutations for Linear documents
33
#
44
# Documents are standalone entities that can be associated with projects,
5-
# initiatives, or teams. To link a document to an issue, use the
6-
# attachments API (see attachments.graphql).
5+
# initiatives, issues, or teams.
76
# ------------------------------------------------------------
87

98
# Create a new document mutation

src/commands/attachments.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import type { Command } from "commander";
22
import { createContext, getRootOpts } from "../common/context.js";
3+
import { invalidParameterError } from "../common/errors.js";
34
import { handleCommand, outputSuccess } from "../common/output.js";
45
import { type DomainMeta, formatDomainUsage } from "../common/usage.js";
5-
import type { AttachmentFilter } from "../gql/graphql.js";
6+
import type {
7+
AttachmentCreateInput,
8+
AttachmentFilter,
9+
} from "../gql/graphql.js";
610
import { resolveIssueId } from "../resolvers/issue-resolver.js";
711
import {
812
createAttachment,
@@ -28,16 +32,39 @@ export const ATTACHMENTS_META: DomainMeta = {
2832
};
2933

3034
interface ListOptions {
35+
issue?: string;
3136
sourceType?: string;
3237
title?: string;
3338
createdAfter?: string;
3439
createdBefore?: string;
3540
}
3641

3742
interface CreateOptions {
43+
issue?: string;
3844
title: string;
3945
url: string;
4046
subtitle?: string;
47+
comment?: string;
48+
iconUrl?: string;
49+
}
50+
51+
function resolveIssueArgument(
52+
positionalIssue: string | undefined,
53+
optionIssue: string | undefined,
54+
): string {
55+
if (positionalIssue && optionIssue) {
56+
throw invalidParameterError(
57+
"--issue",
58+
"cannot be combined with positional issue",
59+
);
60+
}
61+
62+
const issue = positionalIssue ?? optionIssue;
63+
if (!issue) {
64+
throw invalidParameterError("issue", "is required");
65+
}
66+
67+
return issue;
4168
}
4269

4370
function buildAttachmentFilter(
@@ -71,8 +98,9 @@ export function setupAttachmentsCommands(program: Command): void {
7198
attachments.action(() => attachments.help());
7299

73100
attachments
74-
.command("list <issue>")
101+
.command("list [issue]")
75102
.description("list attachments on an issue")
103+
.option("--issue <issue>", "issue identifier (alias for positional issue)")
76104
.option(
77105
"--source-type <type>",
78106
"filter by source type (e.g. github, slack)",
@@ -83,39 +111,47 @@ export function setupAttachmentsCommands(program: Command): void {
83111
.action(
84112
handleCommand(async (...args: unknown[]) => {
85113
const [issue, options, command] = args as [
86-
string,
114+
string | undefined,
87115
ListOptions,
88116
Command,
89117
];
118+
const issueIdentifier = resolveIssueArgument(issue, options.issue);
90119
const ctx = createContext(getRootOpts(command));
91-
const issueId = await resolveIssueId(ctx.sdk, issue);
120+
const issueId = await resolveIssueId(ctx.sdk, issueIdentifier);
92121
const filter = buildAttachmentFilter(options);
93122
const result = await listAttachments(ctx.gql, issueId, filter);
94123
outputSuccess(result);
95124
}),
96125
);
97126

98127
attachments
99-
.command("create <issue>")
128+
.command("create [issue]")
100129
.description("create an attachment on an issue")
130+
.option("--issue <issue>", "issue identifier (alias for positional issue)")
101131
.requiredOption("--title <title>", "attachment title")
102132
.requiredOption("--url <url>", "attachment URL")
103133
.option("--subtitle <text>", "attachment subtitle")
134+
.option("--comment <text>", "comment body to create with the attachment")
135+
.option("--icon-url <url>", "attachment icon URL")
104136
.action(
105137
handleCommand(async (...args: unknown[]) => {
106138
const [issue, options, command] = args as [
107-
string,
139+
string | undefined,
108140
CreateOptions,
109141
Command,
110142
];
143+
const issueIdentifier = resolveIssueArgument(issue, options.issue);
111144
const ctx = createContext(getRootOpts(command));
112-
const issueId = await resolveIssueId(ctx.sdk, issue);
113-
const result = await createAttachment(ctx.gql, {
145+
const issueId = await resolveIssueId(ctx.sdk, issueIdentifier);
146+
const input: AttachmentCreateInput = {
114147
issueId,
115148
title: options.title,
116149
url: options.url,
117150
...(options.subtitle && { subtitle: options.subtitle }),
118-
});
151+
...(options.comment && { commentBody: options.comment }),
152+
...(options.iconUrl && { iconUrl: options.iconUrl }),
153+
};
154+
const result = await createAttachment(ctx.gql, input);
119155
outputSuccess(result);
120156
}),
121157
);

src/commands/documents.ts

Lines changed: 53 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import type { Command } from "commander";
22
import { createContext, getRootOpts } from "../common/context.js";
3+
import { invalidParameterError } from "../common/errors.js";
34
import { handleCommand, outputSuccess, parseLimit } from "../common/output.js";
45
import { type DomainMeta, formatDomainUsage } from "../common/usage.js";
5-
import type { DocumentUpdateInput } from "../gql/graphql.js";
6+
import type { DocumentFilter, DocumentUpdateInput } from "../gql/graphql.js";
67
import { resolveIssueId } from "../resolvers/issue-resolver.js";
78
import { resolveProjectId } from "../resolvers/project-resolver.js";
89
import { resolveTeamId } from "../resolvers/team-resolver.js";
9-
import {
10-
createAttachment,
11-
listAttachments,
12-
} from "../services/attachment-service.js";
10+
import { listAttachments } from "../services/attachment-service.js";
1311
import {
1412
createDocument,
1513
deleteDocument,
1614
getDocument,
1715
listDocuments,
18-
listDocumentsBySlugIds,
1916
updateDocument,
2017
} from "../services/document-service.js";
2118

@@ -27,6 +24,7 @@ interface DocumentCreateOptions {
2724
icon?: string;
2825
color?: string;
2926
issue?: string;
27+
attachTo?: string;
3028
}
3129

3230
interface DocumentUpdateOptions {
@@ -66,11 +64,30 @@ export function extractDocumentIdFromUrl(url: string): string | null {
6664

6765
return docSlug.substring(lastHyphenIndex + 1) || null;
6866
} catch {
69-
// URL constructor throws on malformed input — treat as unresolvable
67+
// URL constructor throws on malformed input — treat as unresolvable.
7068
return null;
7169
}
7270
}
7371

72+
function buildIssueDocumentFilter(
73+
issueId: string,
74+
legacyDocumentSlugIds: string[],
75+
): DocumentFilter {
76+
const issueFilter: DocumentFilter = { issue: { id: { eq: issueId } } };
77+
if (legacyDocumentSlugIds.length === 0) {
78+
return issueFilter;
79+
}
80+
81+
return {
82+
or: [
83+
issueFilter,
84+
...legacyDocumentSlugIds.map((slugId) => ({
85+
slugId: { eq: slugId },
86+
})),
87+
],
88+
};
89+
}
90+
7491
export const DOCUMENTS_META: DomainMeta = {
7592
name: "documents",
7693
summary: "long-form markdown docs attached to projects or issues",
@@ -115,48 +132,35 @@ export function setupDocumentsCommands(program: Command): void {
115132

116133
const limit = parseLimit(options.limit || "50");
117134

135+
let projectId: string | undefined;
136+
if (options.project) {
137+
projectId = await resolveProjectId(ctx.sdk, options.project);
138+
}
139+
140+
let issueId: string | undefined;
118141
if (options.issue) {
119-
const issueId = await resolveIssueId(ctx.sdk, options.issue);
120-
const attachments = await listAttachments(ctx.gql, issueId);
142+
issueId = await resolveIssueId(ctx.sdk, options.issue);
143+
}
121144

122-
const documentSlugIds = [
145+
let filter: DocumentFilter | undefined;
146+
if (projectId) {
147+
filter = { project: { id: { eq: projectId } } };
148+
} else if (issueId) {
149+
const attachments = await listAttachments(ctx.gql, issueId);
150+
const legacyDocumentSlugIds = [
123151
...new Set(
124152
attachments
125153
.map((att) => extractDocumentIdFromUrl(att.url))
126154
.filter((id): id is string => id !== null),
127155
),
128156
];
129-
130-
if (documentSlugIds.length === 0) {
131-
outputSuccess({
132-
nodes: [],
133-
pageInfo: { hasNextPage: false, endCursor: null },
134-
});
135-
return;
136-
}
137-
138-
const documents = await listDocumentsBySlugIds(
139-
ctx.gql,
140-
documentSlugIds,
141-
);
142-
outputSuccess({
143-
nodes: documents,
144-
pageInfo: { hasNextPage: false, endCursor: null },
145-
});
146-
return;
147-
}
148-
149-
let projectId: string | undefined;
150-
if (options.project) {
151-
projectId = await resolveProjectId(ctx.sdk, options.project);
157+
filter = buildIssueDocumentFilter(issueId, legacyDocumentSlugIds);
152158
}
153159

154160
const documents = await listDocuments(ctx.gql, {
155161
limit,
156162
after: options.after,
157-
filter: projectId
158-
? { project: { id: { eq: projectId } } }
159-
: undefined,
163+
filter,
160164
});
161165

162166
outputSuccess(documents);
@@ -187,9 +191,18 @@ export function setupDocumentsCommands(program: Command): void {
187191
.option("--icon <icon>", "document icon")
188192
.option("--color <color>", "icon color")
189193
.option("--issue <issue>", "also attach document to issue (e.g., ABC-123)")
194+
.option("--attach-to <issue>", "alias for --issue")
190195
.action(
191196
handleCommand(async (...args: unknown[]) => {
192197
const [options, command] = args as [DocumentCreateOptions, Command];
198+
if (options.issue && options.attachTo) {
199+
throw invalidParameterError(
200+
"--attach-to",
201+
"cannot be combined with --issue",
202+
);
203+
}
204+
205+
const issueIdentifier = options.issue ?? options.attachTo;
193206
const rootOpts = getRootOpts(command);
194207
const ctx = createContext(rootOpts);
195208

@@ -199,36 +212,20 @@ export function setupDocumentsCommands(program: Command): void {
199212
const teamId = options.team
200213
? await resolveTeamId(ctx.sdk, options.team)
201214
: undefined;
215+
const issueId = issueIdentifier
216+
? await resolveIssueId(ctx.sdk, issueIdentifier)
217+
: undefined;
202218

203219
const document = await createDocument(ctx.gql, {
204220
title: options.title,
205221
content: options.content,
206222
projectId,
207223
teamId,
224+
issueId,
208225
icon: options.icon,
209226
color: options.color,
210227
});
211228

212-
if (options.issue) {
213-
const issueId = await resolveIssueId(ctx.sdk, options.issue);
214-
215-
try {
216-
await createAttachment(ctx.gql, {
217-
issueId,
218-
url: document.url,
219-
title: document.title,
220-
});
221-
} catch (attachError) {
222-
const errorMessage =
223-
attachError instanceof Error
224-
? attachError.message
225-
: String(attachError);
226-
throw new Error(
227-
`Document created (${document.id}) but failed to attach to issue "${options.issue}": ${errorMessage}.`,
228-
);
229-
}
230-
}
231-
232229
outputSuccess(document);
233230
}),
234231
);

0 commit comments

Comments
 (0)