Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format

---

## [Unreleased]

[Unreleased]: https://github.com/czottmann/linearis/compare/v2025.12.3...HEAD

### Added

- New `projects create` command to create Linear projects <!-- ENG-3224 -->
- Required `--team` flag (accepts team key, name, or UUID; comma-separated for multiple teams)
- Optional flags: `--description`, `--color`, `--icon`, `--lead`, `--target-date`, `--start-date`, `--state`
- Validates date format (YYYY-MM-DD) and state values before sending to API
- New `--sort-order` flag for `issues create` and `issues update` commands <!-- ENG-3224 -->
- Allows controlling issue position relative to other issues (lower values = higher in list)
- Validates numeric input before sending to API

### Fixed

- `LinearProject` type now includes `startDate` field (was previously fetched but not exposed) <!-- ENG-3224 -->

---

## [2025.12.3] - 2025-12-11

[2025.12.3]: https://github.com/czottmann/linearis/compare/v2025.12.2...v2025.12.3
Expand Down
32 changes: 32 additions & 0 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export function setupIssuesCommands(program: Command): void {
)
.option("--status <status>", "status name or ID")
.option("--parent-ticket <parentId>", "parent issue ID or identifier")
.option(
"--sort-order <number>",
"position relative to other issues (lower = higher in list)",
)
.action(
handleAsyncCommand(
async (title: string, options: any, command: Command) => {
Expand All @@ -143,6 +147,17 @@ export function setupIssuesCommands(program: Command): void {
linearService,
);

// Validate sortOrder if provided
let sortOrder: number | undefined;
if (options.sortOrder !== undefined) {
sortOrder = parseFloat(options.sortOrder);
if (!Number.isFinite(sortOrder)) {
throw new Error(
`Invalid --sort-order value "${options.sortOrder}": must be a number`,
);
}
}

// Prepare labels array if provided
let labelIds: string[] | undefined;
if (options.labels) {
Expand All @@ -161,6 +176,7 @@ export function setupIssuesCommands(program: Command): void {
parentId: options.parentTicket, // GraphQL service handles parent resolution
milestoneId: options.projectMilestone,
cycleId: options.cycle,
sortOrder,
};

const result = await issuesService.createIssue(createArgs);
Expand Down Expand Up @@ -252,6 +268,10 @@ 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")
.option(
"--sort-order <number>",
"position relative to other issues (lower = higher in list)",
)
.action(
handleAsyncCommand(
async (issueId: string, options: any, command: Command) => {
Expand Down Expand Up @@ -314,6 +334,17 @@ export function setupIssuesCommands(program: Command): void {
linearService,
);

// Validate sortOrder if provided
let sortOrder: number | undefined;
if (options.sortOrder !== undefined) {
sortOrder = parseFloat(options.sortOrder);
if (!Number.isFinite(sortOrder)) {
throw new Error(
`Invalid --sort-order value "${options.sortOrder}": must be a number`,
);
}
}

// Prepare update arguments for GraphQL service
let labelIds: string[] | undefined;
if (options.clearLabels) {
Expand All @@ -339,6 +370,7 @@ export function setupIssuesCommands(program: Command): void {
milestoneId: options.projectMilestone ||
(options.clearProjectMilestone ? null : undefined),
cycleId: options.cycle || (options.clearCycle ? null : undefined),
sortOrder,
};

const labelMode = options.labelBy || "adding";
Expand Down
93 changes: 92 additions & 1 deletion src/commands/projects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Command } from "commander";
import { createGraphQLService } from "../utils/graphql-service.js";
import { GraphQLProjectsService } from "../utils/graphql-projects-service.js";
import { createLinearService } from "../utils/linear-service.js";
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";

Expand Down Expand Up @@ -45,9 +47,98 @@ export function setupProjectsCommands(program: Command): void {
.action(handleAsyncCommand(async (_options: any, command: Command) => {
// Initialize Linear service for project operations
const service = await createLinearService(command.parent!.parent!.opts());

// Fetch all projects with their relationships
const result = await service.getProjects();
outputSuccess(result);
}));

/**
* Create a new project
*
* Command: `linearis projects create <name> --team <team> [options]`
*
* Creates a new project with the specified name and team.
* Team is required by the Linear API.
*/
projects
.command("create <name>")
.description("Create a new project")
.requiredOption(
"--team <team>",
"team key, name, or ID (required, can specify multiple with commas)",
)
.option("-d, --description <desc>", "project description")
.option("--color <color>", "project color (hex code)")
.option("--icon <icon>", "project icon")
.option("--lead <leadId>", "project lead user ID (UUID)")
.option("--target-date <date>", "target completion date (YYYY-MM-DD)")
.option("--start-date <date>", "project start date (YYYY-MM-DD)")
.option(
"--state <state>",
"project state: planned, started, paused, completed, canceled",
)
.action(
handleAsyncCommand(async (name: string, options: any, command: Command) => {
const [graphQLService, linearService] = await Promise.all([
createGraphQLService(command.parent!.parent!.opts()),
createLinearService(command.parent!.parent!.opts()),
]);
const projectsService = new GraphQLProjectsService(
graphQLService,
linearService,
);

// Parse team IDs (comma-separated)
const teamIds = options.team.split(",").map((t: string) => t.trim());

// Validate state if provided
const validStates = [
"planned",
"started",
"paused",
"completed",
"canceled",
];
if (options.state && !validStates.includes(options.state)) {
throw new Error(
`Invalid --state "${options.state}". Must be one of: ${validStates.join(", ")}`,
);
}

// Validate date format (YYYY-MM-DD)
const isValidDate = (dateStr: string): boolean => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false;
const date = new Date(dateStr);
return !isNaN(date.getTime());
};

if (options.targetDate && !isValidDate(options.targetDate)) {
throw new Error(
`Invalid --target-date "${options.targetDate}". Must be YYYY-MM-DD format.`,
);
}

if (options.startDate && !isValidDate(options.startDate)) {
throw new Error(
`Invalid --start-date "${options.startDate}". Must be YYYY-MM-DD format.`,
);
}

const createArgs = {
name,
teamIds,
description: options.description,
color: options.color,
icon: options.icon,
leadId: options.lead,
targetDate: options.targetDate,
startDate: options.startDate,
state: options.state,
};

const result = await projectsService.createProject(createArgs);
outputSuccess(result);
}),
);
}
3 changes: 3 additions & 0 deletions src/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ export * from "./documents.js";

// Attachment queries and mutations
export * from "./attachments.js";

// Project queries and mutations
export * from "./projects.js";
68 changes: 68 additions & 0 deletions src/queries/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* GraphQL queries and mutations for project operations
*
* Contains project-specific queries including the create mutation.
* Uses consistent response structure matching LinearProject type.
*/

/**
* Project response fragment with all relationships
*
* Includes teams, lead, and all standard project fields.
* Used for consistent responses across project operations.
*/
export const PROJECT_FRAGMENT = `
id
name
description
state
progress
teams {
nodes {
id
key
name
}
}
lead {
id
name
}
targetDate
startDate
createdAt
updatedAt
`;

/**
* Create project mutation with complete response
*
* Creates a new project and returns complete project data including
* all relationships. TeamIds is required by the GraphQL API.
*/
export const CREATE_PROJECT_MUTATION = `
mutation CreateProject($input: ProjectCreateInput!) {
projectCreate(input: $input) {
success
project {
${PROJECT_FRAGMENT}
}
}
}
`;

/**
* List projects query with filtering
*
* Fetches projects with optional team filtering.
* Returns complete project data for each result.
*/
export const LIST_PROJECTS_QUERY = `
query ListProjects($first: Int!, $filter: ProjectFilter) {
projects(first: $first, filter: $filter) {
nodes {
${PROJECT_FRAGMENT}
}
}
}
`;
2 changes: 2 additions & 0 deletions src/utils/graphql-issues-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export class GraphQLIssuesService {
if (finalLabelIds !== undefined) {
updateInput.labelIds = finalLabelIds;
}
if (args.sortOrder !== undefined) updateInput.sortOrder = args.sortOrder;

const updateResult = await this.graphQLService.rawRequest(
UPDATE_ISSUE_MUTATION,
Expand Down Expand Up @@ -614,6 +615,7 @@ export class GraphQLIssuesService {
if (finalParentId) createInput.parentId = finalParentId;
if (finalMilestoneId) createInput.projectMilestoneId = finalMilestoneId;
if (finalCycleId) createInput.cycleId = finalCycleId;
if (args.sortOrder !== undefined) createInput.sortOrder = args.sortOrder;

const createResult = await this.graphQLService.rawRequest(
CREATE_ISSUE_MUTATION,
Expand Down
Loading