Skip to content
Closed
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
],
"scripts": {
"build": "tsc && chmod +x dist/main.js",
"prepare": "npm run build",
"clean": "rm -rf dist/",
"start": "tsx src/main.ts",
"test": "vitest run",
Expand Down
141 changes: 141 additions & 0 deletions src/commands/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,4 +349,145 @@ export function setupDocumentsCommands(program: Command): void {
},
),
);

/**
* Update document
*
* Command: `linearis documents update <documentId> [options]`
*
* Updates an existing Linear document. Supports updating title, content,
* project/team associations, icon, color, and trash status.
*/
documents
.command("update <documentId>")
.description("Update a document")
.option("-t, --title <title>", "new title")
.option("-c, --content <content>", "new content (markdown)")
.option("--icon <icon>", "icon emoji or name")
.option("--color <color>", "icon color")
.option("--project <projectId>", "new project (name or ID)")
.option("--clear-project", "remove project association")
.option("--team <teamId>", "new team (key, name, or ID)")
.option("--clear-team", "remove team association")
.option("--trash", "move document to trash")
.option("--untrash", "restore document from trash")
.option("--sort-order <order>", "sort order (number)")
.action(
handleAsyncCommand(async (documentId: string, options: any, command: Command) => {
// Validate mutually exclusive options
if (options.project && options.clearProject) {
throw new Error(
"Cannot use --project and --clear-project together"
);
}

if (options.team && options.clearTeam) {
throw new Error(
"Cannot use --team and --clear-team together"
);
}

if (options.trash && options.untrash) {
throw new Error(
"Cannot use --trash and --untrash together"
);
}

const linearService = await createLinearService(
command.parent!.parent!.opts()
);

// Get Linear SDK client
const client = linearService.getLinearClient();

// Build update arguments
const updateArgs: any = {};

if (options.title !== undefined) {
updateArgs.title = options.title;
}

if (options.content !== undefined) {
updateArgs.content = options.content;
}

if (options.icon !== undefined) {
updateArgs.icon = options.icon;
}

if (options.color !== undefined) {
updateArgs.color = options.color;
}

// Resolve and set project ID
if (options.project) {
updateArgs.projectId = await linearService.resolveProjectId(
options.project
);
} else if (options.clearProject) {
updateArgs.projectId = null;
}

// Resolve and set team ID
if (options.team) {
updateArgs.teamId = await linearService.resolveTeamId(options.team);
} else if (options.clearTeam) {
updateArgs.teamId = null;
}

// Handle trash status
if (options.trash) {
updateArgs.trashed = true;
} else if (options.untrash) {
updateArgs.trashed = false;
}

// Handle sort order
if (options.sortOrder !== undefined) {
const sortOrder = parseFloat(options.sortOrder);
if (isNaN(sortOrder)) {
throw new Error("--sort-order must be a valid number");
}
updateArgs.sortOrder = sortOrder;
}

// Check if any updates were provided
if (Object.keys(updateArgs).length === 0) {
throw new Error(
"No update options provided. Use --help to see available options."
);
}

// Update document using Linear SDK
const documentPayload = await client.updateDocument(documentId, updateArgs);
const document = await documentPayload.document;

if (!document) {
throw new Error(`Failed to update document with ID "${documentId}"`);
}

// Fetch optional relationships for output
const project = await document.project;

// Output document details
outputSuccess({
id: document.id,
title: document.title,
content: document.content || undefined,
url: document.url,
createdAt: document.createdAt
? new Date(document.createdAt).toISOString()
: new Date().toISOString(),
updatedAt: document.updatedAt
? new Date(document.updatedAt).toISOString()
: new Date().toISOString(),
project: project
? {
id: project.id,
name: project.name,
}
: undefined,
});
})
);
}
18 changes: 8 additions & 10 deletions src/commands/embeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from "commander";
import { getApiToken } from "../utils/auth.js";
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
import { FileService } from "../utils/file-service.js";
import { createGraphQLService } from "../utils/graphql-service.js";

/**
* Setup embeds commands on the program
Expand Down Expand Up @@ -96,11 +97,13 @@ export function setupEmbedsCommands(program: Command): void {
handleAsyncCommand(
async (filePath: string, _options: any, command: Command) => {
// Get API token from parent command options for authentication
const apiToken = await getApiToken(command.parent!.parent!.opts());
const rootOpts = command.parent!.parent!.opts();
const apiToken = await getApiToken(rootOpts);

// Create file service and initiate upload
// Create file service and graphQL service for upload
const fileService = new FileService(apiToken);
const result = await fileService.uploadFile(filePath);
const graphQLService = await createGraphQLService(rootOpts);
const result = await fileService.uploadFile({ filePath }, graphQLService);

if (result.success) {
// Successful upload with asset URL
Expand All @@ -111,15 +114,10 @@ export function setupEmbedsCommands(program: Command): void {
message: `File uploaded successfully: ${result.assetUrl}`,
});
} else {
// Include status code for debugging
const error: any = {
outputSuccess({
success: false,
error: result.error,
};
if (result.statusCode) {
error.statusCode = result.statusCode;
}
outputSuccess(error);
});
}
},
),
Expand Down
112 changes: 109 additions & 3 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createGraphQLService } from "../utils/graphql-service.js";
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
import { createLinearService } from "../utils/linear-service.js";
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
import { getApiToken } from "../utils/auth.js";
import { FileService } from "../utils/file-service.js";

/**
* Setup issues commands on the program
Expand Down Expand Up @@ -32,14 +34,16 @@ export function setupIssuesCommands(program: Command): void {
/**
* List issues
*
* Command: `linearis issues list [--limit <number>]`
* Command: `linearis issues list [--limit <number>] [--assigned] [--in-progress]`
*
* Lists issues with all relationships in a single optimized GraphQL query.
* Includes comments, assignees, projects, labels, and state information.
*/
issues.command("list")
.description("List issues.")
.option("-l, --limit <number>", "limit results", "25")
.option("--assigned", "filter by issues assigned to current user")
.option("--in-progress", "filter by issues in progress state")
.action(
handleAsyncCommand(
async (options: any, command: Command) => {
Expand All @@ -54,7 +58,11 @@ export function setupIssuesCommands(program: Command): void {
);

// Fetch issues with optimized single query
const result = await issuesService.getIssues(parseInt(options.limit));
const result = await issuesService.getIssues({
limit: parseInt(options.limit),
assignedToMe: options.assigned,
inProgress: options.inProgress,
});
outputSuccess(result);
},
),
Expand Down Expand Up @@ -114,6 +122,7 @@ export function setupIssuesCommands(program: Command): void {
.description("Create new issue.")
.option("-d, --description <desc>", "issue description")
.option("-a, --assignee <assigneeId>", "assign to user ID")
.option("--self-assigned", "assign issue to current user (cannot be used with --assignee)")
.option("-p, --priority <priority>", "priority level (1-4)")
.option("--project <project>", "add to project (name or ID)")
.option(
Expand All @@ -134,6 +143,13 @@ export function setupIssuesCommands(program: Command): void {
.action(
handleAsyncCommand(
async (title: string, options: any, command: Command) => {
// Check for mutually exclusive assignee flags
if (options.assignee && options.selfAssigned) {
throw new Error(
"Cannot use --assignee and --self-assigned together",
);
}

const [graphQLService, linearService] = await Promise.all([
createGraphQLService(command.parent!.parent!.opts()),
createLinearService(command.parent!.parent!.opts()),
Expand All @@ -143,6 +159,17 @@ export function setupIssuesCommands(program: Command): void {
linearService,
);

// Get assignee ID - either from --assignee or from current user
let assigneeId = options.assignee;
if (options.selfAssigned) {
const viewerQuery = `query GetViewer { viewer { id } }`;
const viewerResult = await graphQLService.rawRequest(viewerQuery);
if (!viewerResult.viewer) {
throw new Error("Failed to get current user information");
}
assigneeId = viewerResult.viewer.id;
}

// Prepare labels array if provided
let labelIds: string[] | undefined;
if (options.labels) {
Expand All @@ -153,7 +180,7 @@ export function setupIssuesCommands(program: Command): void {
title,
teamId: options.team, // GraphQL service handles team resolution
description: options.description,
assigneeId: options.assignee,
assigneeId,
priority: options.priority ? parseInt(options.priority) : undefined,
projectId: options.project, // GraphQL service handles project resolution
statusId: options.status,
Expand Down Expand Up @@ -252,6 +279,16 @@ export function setupIssuesCommands(program: Command): void {
"set cycle (can use name or ID, will try to resolve within team context first)",
)
.option("--clear-cycle", "clear existing cycle assignment")
.optionsGroup("File attachment options:")
.option(
"--attach-file <paths...>",
"attach files to issue (can specify multiple files)"
)
.optionsGroup("Document linking options:")
.option(
"--attach-document <documentId>",
"link document to issue (creates issue relation)"
)
.action(
handleAsyncCommand(
async (issueId: string, options: any, command: Command) => {
Expand Down Expand Up @@ -314,6 +351,75 @@ export function setupIssuesCommands(program: Command): void {
linearService,
);

// Handle file attachments if --attach-file is provided
if (options.attachFile && options.attachFile.length > 0) {
const apiToken = await getApiToken(command.parent!.parent!.opts());
const fileService = new FileService(apiToken);

// Upload files and create attachments
const uploadResults = await fileService.uploadAndAttachFiles(
issueId,
options.attachFile,
graphQLService,
);

// Report results
const successfulUploads = uploadResults.filter((r) => r.success && r.attachmentCreated);
const failedUploads = uploadResults.filter((r) => !r.success || !r.attachmentCreated);

if (successfulUploads.length > 0) {
console.log(
`Successfully attached ${successfulUploads.length} file(s) to issue ${issueId}:`,
);
for (const upload of successfulUploads) {
console.log(` - ${upload.filename}`);
}
}

if (failedUploads.length > 0) {
console.error(`Failed to attach ${failedUploads.length} file(s):`);
for (const failed of failedUploads) {
console.error(` - ${failed.filename}: ${failed.error}`);
}
}
}

// Handle document linking if --attach-document is provided
if (options.attachDocument) {
const client = linearService.getLinearClient();

try {
// Resolve issue ID to UUID (SDK needs actual UUID)
const resolvedIssueId = await linearService.resolveIssueId(issueId);

// Fetch the document to get its title and URL
const document = await client.document(options.attachDocument);

// Fetch current issue to get existing description
const issue = await client.issue(resolvedIssueId);
const currentDescription = issue.description || "";

// Append document link to issue description
const documentLink = `\n\n---\n**Related Document:** [${document.title}](${document.url})`;
const newDescription = currentDescription + documentLink;

// Update issue with new description
await client.updateIssue(resolvedIssueId, {
description: newDescription,
});

console.log(
`Successfully added document link to issue ${issueId}: ${document.url}`,
);
} catch (error) {
console.error(
`Failed to link document: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

// Prepare update arguments for GraphQL service
let labelIds: string[] | undefined;
if (options.clearLabels) {
Expand Down
Loading