import { Command, Options, Args } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { IssueSchema, ActivitiesResponseSchema, IssueLinksResponseSchema, IssueLinkSchema, CommentsResponseSchema, WorklogsResponseSchema, WorklogSchema, } from "../config.js"; import { findIssueBySeq, getLabelId, getMemberId, getStateId, parseIssueRef, resolveProject, } from "../resolve.js"; import { jsonMode, xmlMode, toXml } from "../output.js"; import { escapeHtmlText } from "../format.js"; const refArg = Args.text({ name: "ref" }).pipe( Args.withDescription("Issue reference, e.g. PROJ-29"), ); // --- Typed payload interfaces --- interface IssueUpdatePayload { state?: string; priority?: string; name?: string; description_html?: string; assignees?: string[]; label_ids?: string[]; } interface IssueCreatePayload { name: string; priority?: string; state?: string; description_html?: string; assignees?: string[]; label_ids?: string[]; } interface WorklogPayload { duration: number; description?: string; } // --- issue get --- export function issueGetHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* Console.log(JSON.stringify(issue, null, 2)); }); } export const issueGet = Command.make( "get", { ref: refArg }, issueGetHandler, ).pipe( Command.withDescription( "Print full JSON for an issue. Useful for inspecting all fields (state, priority, assignees, labels, etc.).", ), ); // --- issue update --- const stateOption = Options.optional(Options.text("state")).pipe( Options.withDescription("State group or name (e.g. backlog, completed)"), ); const priorityOption = Options.optional( Options.choice("priority", ["urgent", "high", "medium", "low", "none"]), ).pipe(Options.withDescription("Issue priority")); const titleUpdateOption = Options.optional(Options.text("title")).pipe( Options.withDescription("Issue title"), ); const descriptionOption = Options.optional(Options.text("description")).pipe( Options.withDescription("Issue description as HTML (e.g. '

Details

')"), ); const assigneeOption = Options.optional(Options.text("assignee")).pipe( Options.withDescription("Assign to a member (display name, email, or UUID)"), ); const labelOption = Options.optional(Options.text("label")).pipe( Options.withDescription("Set issue label by name"), ); const noAssigneeOption = Options.boolean("no-assignee").pipe( Options.withDescription("Clear all assignees"), Options.withDefault(false), ); export function issueUpdateHandler({ ref, state, priority, title, description, assignee, label, noAssignee, }: { ref: string; state: Option.Option; priority: Option.Option; title: Option.Option; description: Option.Option; assignee: Option.Option; label: Option.Option; noAssignee: boolean; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const body: IssueUpdatePayload = {}; if (Option.isSome(state)) { body.state = yield* getStateId(projectId, state.value); } if (Option.isSome(priority)) { body.priority = priority.value; } if (Option.isSome(title)) { body.name = title.value; } if (Option.isSome(description)) { body.description_html = description.value; } if (noAssignee) { body.assignees = []; } else if (Option.isSome(assignee)) { const memberId = yield* getMemberId(assignee.value); body.assignees = [memberId]; } if (Option.isSome(label)) { const labelId = yield* getLabelId(projectId, label.value); body.label_ids = [labelId]; } if (Object.keys(body).length === 0) { yield* Effect.fail( new Error( "Nothing to update. Specify --state, --priority, --title, --description, --assignee, --label, or --no-assignee", ), ); } const raw = yield* api.patch( `projects/${projectId}/issues/${issue.id}/`, body, ); const updated = yield* decodeOrFail(IssueSchema, raw); const stateName = typeof updated.state === "object" ? updated.state.name : updated.state; yield* Console.log( `Updated ${ref}: state=${stateName} priority=${updated.priority}`, ); }); } export const issueUpdate = Command.make( "update", { state: stateOption, priority: priorityOption, title: titleUpdateOption, description: descriptionOption, assignee: assigneeOption, label: labelOption, noAssignee: noAssigneeOption, ref: refArg, }, issueUpdateHandler, ).pipe( Command.withDescription( 'Update an issue\'s state, priority, title, description, or assignee. Options must come before the REF argument.\n\nExamples:\n plane issue update --state completed PROJ-29\n plane issue update --priority high WEB-5\n plane issue update --title "New issue title" PROJ-29\n plane issue update --assignee "Jane Doe" PROJ-29\n plane issue update --no-assignee PROJ-29\n plane issue update --description "New description" PROJ-29', ), ); // --- issue comment --- const textArg = Args.text({ name: "text" }).pipe( Args.withDescription("Comment text to add"), ); export function issueCommentHandler({ ref, text, }: { ref: string; text: string; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const escaped = escapeHtmlText(text); yield* api.post(`projects/${projectId}/issues/${issue.id}/comments/`, { comment_html: `

${escaped}

`, }); yield* Console.log(`Comment added to ${ref}`); }); } export const issueComment = Command.make( "comment", { ref: refArg, text: textArg }, issueCommentHandler, ).pipe( Command.withDescription( 'Add a comment to an issue. The text is wrapped in

tags and HTML-escaped.\n\nExample:\n plane issue comment PROJ-29 "Fixed in latest build"', ), ); // --- issue create --- const titleArg = Args.text({ name: "title" }).pipe( Args.withDescription("Issue title"), ); const projectRefArg = Args.text({ name: "project" }).pipe( Args.withDescription("Project identifier (e.g. PROJ)"), ); const createPriorityOption = Options.optional( Options.choice("priority", ["urgent", "high", "medium", "low", "none"]), ).pipe(Options.withDescription("Issue priority")); const createStateOption = Options.optional(Options.text("state")).pipe( Options.withDescription("Initial state group or name"), ); const createDescriptionOption = Options.optional( Options.text("description"), ).pipe( Options.withDescription("Issue description as HTML (e.g. '

Details

')"), ); const createAssigneeOption = Options.optional(Options.text("assignee")).pipe( Options.withDescription("Assign to a member (display name, email, or UUID)"), ); const createLabelOption = Options.optional(Options.text("label")).pipe( Options.withDescription("Set issue label by name"), ); export function issueCreateHandler({ project, title, priority, state, description, assignee, label, }: { project: string; title: string; priority: Option.Option; state: Option.Option; description: Option.Option; assignee: Option.Option; label: Option.Option; }) { return Effect.gen(function* () { const { key, id: projectId } = yield* resolveProject(project); const body: IssueCreatePayload = { name: title }; if (Option.isSome(priority)) body.priority = priority.value; if (Option.isSome(state)) body.state = yield* getStateId(projectId, state.value); if (Option.isSome(description)) { body.description_html = description.value; } if (Option.isSome(assignee)) { const memberId = yield* getMemberId(assignee.value); body.assignees = [memberId]; } if (Option.isSome(label)) { const labelId = yield* getLabelId(projectId, label.value); body.label_ids = [labelId]; } const raw = yield* api.post(`projects/${projectId}/issues/`, body); const created = yield* decodeOrFail(IssueSchema, raw); yield* Console.log( `Created ${key}-${created.sequence_id}: ${created.name}`, ); }); } export const issueCreate = Command.make( "create", { priority: createPriorityOption, state: createStateOption, description: createDescriptionOption, assignee: createAssigneeOption, label: createLabelOption, project: projectRefArg, title: titleArg, }, issueCreateHandler, ).pipe( Command.withDescription( 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"', ), ); // --- issue activity --- export function issueActivityHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const raw = yield* api.get( `projects/${projectId}/issues/${issue.id}/activities/`, ); const { results } = yield* decodeOrFail(ActivitiesResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; } if (xmlMode) { yield* Console.log(toXml(results)); return; } if (results.length === 0) { yield* Console.log("No activity found"); return; } const lines = results.map((a) => { const who = a.actor_detail?.display_name ?? "?"; const when = a.created_at.slice(0, 16).replace("T", " "); if (a.field) { const from = a.old_value ?? "—"; const to = a.new_value ?? "—"; return `${when} ${who} ${a.field}: ${from} → ${to}`; } return `${when} ${who} ${a.verb ?? "updated"}`; }); yield* Console.log(lines.join("\n")); }); } export const issueActivity = Command.make( "activity", { ref: refArg }, issueActivityHandler, ).pipe( Command.withDescription( "Show audit trail for an issue — who changed what and when.\n\nExample:\n plane issue activity PROJ-29", ), ); // --- issue link list --- export function issueLinkListHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const raw = yield* api.get( `projects/${projectId}/issues/${issue.id}/issue-links/`, ); const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; } if (xmlMode) { yield* Console.log(toXml(results)); return; } if (results.length === 0) { yield* Console.log("No links"); return; } const lines = results.map( (l) => `${l.id} ${l.title ?? "(no title)"} ${l.url}`, ); yield* Console.log(lines.join("\n")); }); } export const issueLinkList = Command.make( "list", { ref: refArg }, issueLinkListHandler, ).pipe(Command.withDescription("List URL links attached to an issue.")); // --- issue link add --- const urlArg = Args.text({ name: "url" }).pipe( Args.withDescription("URL to link"), ); const linkTitleOption = Options.optional(Options.text("title")).pipe( Options.withDescription("Human-readable title for the link"), ); export function issueLinkAddHandler({ ref, url, title, }: { ref: string; url: string; title: Option.Option; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const body: Record = { url }; if (Option.isSome(title)) body["title"] = title.value; const raw = yield* api.post( `projects/${projectId}/issues/${issue.id}/issue-links/`, body, ); const link = yield* decodeOrFail(IssueLinkSchema, raw); yield* Console.log(`Link added: ${link.id} ${link.url}`); }); } export const issueLinkAdd = Command.make( "add", { title: linkTitleOption, ref: refArg, url: urlArg }, issueLinkAddHandler, ).pipe( Command.withDescription( 'Attach a URL link to an issue.\n\nExamples:\n plane issue link add PROJ-29 https://github.com/org/repo/pull/42\n plane issue link add --title "Design doc" PROJ-29 https://docs.example.com', ), ); // --- issue link remove --- const linkIdArg = Args.text({ name: "link-id" }).pipe( Args.withDescription("Link ID (from 'plane issue link list')"), ); export function issueLinkRemoveHandler({ ref, linkId, }: { ref: string; linkId: string; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.delete( `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`, ); yield* Console.log(`Link ${linkId} removed from ${ref}`); }); } export const issueLinkRemove = Command.make( "remove", { ref: refArg, linkId: linkIdArg }, issueLinkRemoveHandler, ).pipe(Command.withDescription("Remove a URL link from an issue by link ID.")); // --- issue link (parent) --- export const issueLink = Command.make("link").pipe( Command.withDescription( "Manage URL links on an issue. Subcommands: list, add, remove", ), Command.withSubcommands([issueLinkList, issueLinkAdd, issueLinkRemove]), ); // --- issue comments list --- export function issueCommentsListHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const raw = yield* api.get( `projects/${projectId}/issues/${issue.id}/comments/`, ); const { results } = yield* decodeOrFail(CommentsResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; } if (xmlMode) { yield* Console.log(toXml(results)); return; } if (results.length === 0) { yield* Console.log("No comments"); return; } const lines = results.map((c) => { const who = c.actor_detail?.display_name ?? "?"; const when = c.created_at.slice(0, 16).replace("T", " "); const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim(); return `${c.id} ${when} ${who}: ${text}`; }); yield* Console.log(lines.join("\n")); }); } export const issueCommentsList = Command.make( "list", { ref: refArg }, issueCommentsListHandler, ).pipe( Command.withDescription( "List comments on an issue. Shows comment ID, timestamp, author, and plain text.\n\nExample:\n plane issue comments list PROJ-29", ), ); // --- issue comment update --- const commentIdArg = Args.text({ name: "comment-id" }).pipe( Args.withDescription("Comment ID (from 'plane issue comments list')"), ); export function issueCommentUpdateHandler({ ref, commentId, text, }: { ref: string; commentId: string; text: string; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const escaped = escapeHtmlText(text); yield* api.patch( `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, { comment_html: `

${escaped}

` }, ); yield* Console.log(`Comment ${commentId} updated`); }); } export const issueCommentUpdate = Command.make( "update", { ref: refArg, commentId: commentIdArg, text: textArg }, issueCommentUpdateHandler, ).pipe( Command.withDescription( 'Edit an existing comment.\n\nExample:\n plane issue comments update PROJ-29 "Updated text"', ), ); // --- issue comment delete --- export function issueCommentDeleteHandler({ ref, commentId, }: { ref: string; commentId: string; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.delete( `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, ); yield* Console.log(`Comment ${commentId} deleted`); }); } export const issueCommentDelete = Command.make( "delete", { ref: refArg, commentId: commentIdArg }, issueCommentDeleteHandler, ).pipe(Command.withDescription("Delete a comment from an issue.")); // --- issue comments (parent) --- export const issueComments = Command.make("comments").pipe( Command.withDescription( "Manage comments on an issue. Subcommands: list, update, delete\n\nNote: use 'plane issue comment REF TEXT' to add a new comment.", ), Command.withSubcommands([ issueCommentsList, issueCommentUpdate, issueCommentDelete, ]), ); // --- issue worklogs list --- export function issueWorklogsListHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const raw = yield* api.get( `projects/${projectId}/issues/${issue.id}/worklogs/`, ); const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; } if (xmlMode) { yield* Console.log(toXml(results)); return; } if (results.length === 0) { yield* Console.log("No worklogs"); return; } const lines = results.map((w) => { const who = w.logged_by_detail?.display_name ?? "?"; const when = w.created_at.slice(0, 10); const hrs = (w.duration / 60).toFixed(1); const desc = w.description ?? ""; return `${w.id} ${when} ${who} ${hrs}h ${desc}`; }); yield* Console.log(lines.join("\n")); }); } export const issueWorklogsList = Command.make( "list", { ref: refArg }, issueWorklogsListHandler, ).pipe( Command.withDescription( "List time log entries for an issue. Duration shown in hours.\n\nExample:\n plane issue worklogs list PROJ-29", ), ); // --- issue worklogs add --- const durationArg = Args.integer({ name: "minutes" }).pipe( Args.withDescription("Time spent in minutes"), ); const worklogDescOption = Options.optional(Options.text("description")).pipe( Options.withDescription("Optional description of work done"), ); export function issueWorklogsAddHandler({ ref, duration, description, }: { ref: string; duration: number; description: Option.Option; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); const body: WorklogPayload = { duration }; if (Option.isSome(description)) body.description = description.value; const raw = yield* api.post( `projects/${projectId}/issues/${issue.id}/worklogs/`, body, ); const log = yield* decodeOrFail(WorklogSchema, raw); const hrs = (log.duration / 60).toFixed(1); yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`); }); } export const issueWorklogsAdd = Command.make( "add", { description: worklogDescOption, ref: refArg, duration: durationArg }, issueWorklogsAddHandler, ).pipe( Command.withDescription( 'Log time spent on an issue (duration in minutes).\n\nExamples:\n plane issue worklogs add PROJ-29 90\n plane issue worklogs add --description "code review" PROJ-29 30', ), ); // --- issue worklogs (parent) --- export const issueWorklogs = Command.make("worklogs").pipe( Command.withDescription( "Manage time logs for an issue. Subcommands: list, add", ), Command.withSubcommands([issueWorklogsList, issueWorklogsAdd]), ); // --- issue delete --- export function issueDeleteHandler({ ref }: { ref: string }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.delete(`projects/${projectId}/issues/${issue.id}/`); yield* Console.log(`Deleted ${ref}`); }); } export const issueDelete = Command.make( "delete", { ref: refArg }, issueDeleteHandler, ).pipe( Command.withDescription( "Permanently delete an issue. This cannot be undone.", ), ); // --- issue (parent) --- export const issue = Command.make("issue").pipe( Command.withDescription( "Manage individual issues. Use 'plane issue --help' for details.\n\nSubcommands: get, create, update, delete, comment, activity, link, comments, worklogs", ), Command.withSubcommands([ issueGet, issueCreate, issueUpdate, issueDelete, issueComment, issueActivity, issueLink, issueComments, issueWorklogs, ]), );