diff --git a/README.md b/README.md index ea4513b..af635e6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A modern, extensible command-line interface for Atlassian JIRA built with Factor ## ✨ Features - 📋 **Issue Management**: Create, read, update, and delete JIRA issues with full CRUD operations +- 💬 **Comment Management**: Add, list, edit, and delete comments on issues with file support - 📝 **Markdown Support**: Export issues to markdown files and create/update issues from markdown - 📊 **Project Information**: View project details, statistics, and team insights - 🏃 **Sprint Management**: Monitor sprint progress, burndown charts, and team velocity @@ -218,6 +219,44 @@ jira issue list --jql "project = PROJ AND status = 'In Progress'" jira issue list --jql "bug" --limit 5 ``` +### Manage Comments + +```bash +# Add a comment to an issue +jira issue comment add PROJ-123 "Review completed" + +# Add multi-line comment +jira issue comment add PROJ-123 "Build status: +- Unit tests: ✓ +- Integration tests: ✓ +- Deployment: pending" + +# Add comment from file +jira issue comment add PROJ-123 --file ./review-notes.md + +# Add internal comment (visible only to team) +jira issue comment add PROJ-123 "Internal note" --internal + +# List all comments on an issue +jira issue comment list PROJ-123 + +# List comments in JSON format +jira issue comment list PROJ-123 --format json + +# Edit an existing comment +jira issue comment edit 12345 "Updated comment text" + +# Edit comment from file +jira issue comment edit 12345 --file ./updated-notes.md + +# Delete a comment (requires confirmation) +jira issue comment delete 12345 --force + +# Using command alias +jira issue c add PROJ-123 "Quick comment" +jira issue c list PROJ-123 +``` + ### Project Management ```bash @@ -256,6 +295,10 @@ jira sprint list --board 123 --state active | `issue create` | Create new issue | **Required:** `--project `, `--type `, `--summary `
**Optional:** `--description `, `--description-file `, `--assignee `, `--priority ` | | `issue edit ` | Edit an existing issue (alias: update) | **At least one required:**
`--summary `, `--description `, `--description-file `, `--assignee `, `--priority ` | | `issue delete ` | Delete issue | **Required:** `--force` | +| `issue comment add [text]` | Add comment to issue (alias: c) | `[text]` or `--file `
**Optional:** `--internal` | +| `issue comment list ` | List comments on issue | `--format ` (default: table) | +| `issue comment edit [text]` | Edit existing comment | `[text]` or `--file ` | +| `issue comment delete ` | Delete comment | **Required:** `--force` | | `project list` | List all projects | `--type `, `--category ` | | `project view ` | View project details | - | | `project components ` | List project components | - | @@ -310,6 +353,21 @@ jira issue edit PROJ-123 --summary "Updated summary" # Delete issue (requires --force) jira issue delete PROJ-123 --force +# Add comment to an issue +jira issue comment add PROJ-123 "Review completed" + +# Add comment from file +jira issue comment add PROJ-123 --file ./review-notes.md + +# List all comments +jira issue comment list PROJ-123 + +# Edit a comment +jira issue comment edit 12345 "Updated comment" + +# Delete a comment +jira issue comment delete 12345 --force + # List all projects jira project list @@ -467,6 +525,7 @@ This project is licensed under the ISC License - see the [LICENSE](https://githu ## Roadmap - [x] Basic issue management (create, read, update, delete) +- [x] Comment management (add, list, edit, delete) - [x] Project and sprint management - [x] Configuration management - [x] Non-interactive, automation-friendly CLI @@ -477,7 +536,7 @@ This project is licensed under the ISC License - see the [LICENSE](https://githu - [ ] Bulk operations - [ ] Integration with other Atlassian tools - [ ] Issue attachments management -- [ ] Comments and workflows +- [ ] Workflows and transitions - [ ] Custom fields support - [ ] Time tracking diff --git a/bin/commands/issue.js b/bin/commands/issue.js index 62a51d5..6c09e5b 100644 --- a/bin/commands/issue.js +++ b/bin/commands/issue.js @@ -3,7 +3,8 @@ const { createIssuesTable, displayIssueDetails, formatIssueAsMarkdown, - buildJQL + buildJQL, + createCommentsTable } = require('../../lib/utils'); const chalk = require('chalk'); const fs = require('fs'); @@ -141,6 +142,89 @@ function createIssueCommand(factory) { } }); + // Comment subcommand + const commentCommand = command + .command('comment') + .description('Manage issue comments') + .alias('c'); + + commentCommand + .command('add [text]') + .description('add a comment to an issue\n\n' + + 'Examples:\n' + + ' $ jira issue comment add PROJ-123 "Review completed"\n' + + ' $ jira issue comment add PROJ-123 --file ./notes.md\n' + + ' $ jira issue comment add PROJ-123 "Internal note" --internal') + .option('--file ', 'read comment body from file') + .option('--internal', 'mark comment as internal/private') + .action(async (key, text, options) => { + const io = factory.getIOStreams(); + const client = await factory.getJiraClient(); + + try { + await addComment(client, io, key, text, options); + } catch (err) { + io.error(`Failed to add comment: ${err.message}`); + process.exit(1); + } + }); + + commentCommand + .command('list ') + .description('list comments on an issue\n\n' + + 'Examples:\n' + + ' $ jira issue comment list PROJ-123\n' + + ' $ jira issue comment list PROJ-123 --format json') + .option('--format ', 'output format (table, json)', 'table') + .action(async (key, options) => { + const io = factory.getIOStreams(); + const client = await factory.getJiraClient(); + + try { + await listComments(client, io, key, options); + } catch (err) { + io.error(`Failed to list comments: ${err.message}`); + process.exit(1); + } + }); + + commentCommand + .command('edit [text]') + .description('edit an existing comment\n\n' + + 'Examples:\n' + + ' $ jira issue comment edit 12345 "Updated comment"\n' + + ' $ jira issue comment edit 12345 --file ./updated-notes.md') + .option('--file ', 'read comment body from file') + .action(async (commentId, text, options) => { + const io = factory.getIOStreams(); + const client = await factory.getJiraClient(); + + try { + await editComment(client, io, commentId, text, options); + } catch (err) { + io.error(`Failed to edit comment: ${err.message}`); + process.exit(1); + } + }); + + commentCommand + .command('delete ') + .description('delete a comment\n\n' + + 'Examples:\n' + + ' $ jira issue comment delete 12345 --force') + .option('-f, --force', 'force delete without confirmation') + .action(async (commentId, options) => { + const io = factory.getIOStreams(); + const client = await factory.getJiraClient(); + + try { + await deleteComment(client, io, commentId, options); + } catch (err) { + io.error(`Failed to delete comment: ${err.message}`); + process.exit(1); + } + }); + return command; } @@ -379,4 +463,117 @@ async function deleteIssue(client, io, issueKey, options = {}) { io.success(`Issue ${issueKey} deleted successfully`); } +async function addComment(client, io, issueKey, text, options = {}) { + // Validate that we have comment text from either argument or file + if (!text && !options.file) { + throw new Error( + 'Comment text is required.\n\n' + + 'Usage: jira issue comment add [text] [options]\n\n' + + 'Provide comment text either as:\n' + + ' - Direct argument: jira issue comment add PROJ-123 "Comment text"\n' + + ' - From file: jira issue comment add PROJ-123 --file ./comment.md\n\n' + + 'Options:\n' + + ' --file Read comment body from file\n' + + ' --internal Mark comment as internal/private' + ); + } + + if (text && options.file) { + throw new Error('Cannot use both text argument and --file option. Please use only one.'); + } + + let commentBody = text; + if (options.file) { + commentBody = readDescriptionFile(options.file); + } + + const spinner = io.spinner(`Adding comment to ${issueKey}...`); + const comment = await client.addComment(issueKey, commentBody, { + internal: options.internal + }); + spinner.stop(); + + io.success(`Comment added to ${issueKey}`); + io.out(`Comment ID: ${comment.id}`); + + if (options.internal) { + io.info('Comment marked as internal'); + } +} + +async function listComments(client, io, issueKey, options = {}) { + const spinner = io.spinner(`Fetching comments for ${issueKey}...`); + + try { + const result = await client.getComments(issueKey); + spinner.stop(); + + if (!result.comments || result.comments.length === 0) { + io.info(`No comments found for ${issueKey}`); + return; + } + + io.out(chalk.bold(`\nComments for ${issueKey} (${result.comments.length} total):\n`)); + + if (options.format === 'json') { + io.out(JSON.stringify(result.comments, null, 2)); + } else { + const table = createCommentsTable(result.comments); + io.out(table.toString()); + } + + } catch (err) { + spinner.stop(); + throw err; + } +} + +async function editComment(client, io, commentId, text, options = {}) { + // Validate that we have comment text from either argument or file + if (!text && !options.file) { + throw new Error( + 'Comment text is required.\n\n' + + 'Usage: jira issue comment edit [text] [options]\n\n' + + 'Provide comment text either as:\n' + + ' - Direct argument: jira issue comment edit 12345 "Updated text"\n' + + ' - From file: jira issue comment edit 12345 --file ./updated.md\n\n' + + 'Options:\n' + + ' --file Read comment body from file' + ); + } + + if (text && options.file) { + throw new Error('Cannot use both text argument and --file option. Please use only one.'); + } + + let commentBody = text; + if (options.file) { + commentBody = readDescriptionFile(options.file); + } + + const spinner = io.spinner(`Updating comment ${commentId}...`); + await client.updateComment(commentId, commentBody); + spinner.stop(); + + io.success(`Comment ${commentId} updated successfully`); +} + +async function deleteComment(client, io, commentId, options = {}) { + io.out(chalk.bold.red('\nWARNING: You are about to delete this comment:')); + io.out(` Comment ID: ${chalk.cyan(commentId)}\n`); + + if (!options.force) { + throw new Error( + 'Deletion requires --force flag to confirm.\n' + + `Use: jira issue comment delete ${commentId} --force` + ); + } + + const spinner = io.spinner('Deleting comment...'); + await client.deleteComment(commentId); + spinner.stop(); + + io.success(`Comment ${commentId} deleted successfully`); +} + module.exports = createIssueCommand; diff --git a/lib/jira-client.js b/lib/jira-client.js index dda262b..e70ef5f 100644 --- a/lib/jira-client.js +++ b/lib/jira-client.js @@ -149,6 +149,43 @@ class JiraClient { }); return response.data; } + + // Comments + async getComments(issueKey) { + const response = await this.client.get(`/issue/${issueKey}/comment`); + return response.data; + } + + async addComment(issueKey, body, options = {}) { + const commentData = { + body: body + }; + + // Add visibility if internal flag is set + if (options.internal) { + commentData.visibility = { + type: 'role', + value: 'Administrators' + }; + } + + const response = await this.client.post(`/issue/${issueKey}/comment`, commentData); + return response.data; + } + + async updateComment(commentId, body) { + const commentData = { + body: body + }; + + const response = await this.client.put(`/comment/${commentId}`, commentData); + return response.data; + } + + async deleteComment(commentId) { + await this.client.delete(`/comment/${commentId}`); + return true; + } } module.exports = JiraClient; \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js index 7262cab..92a2a2f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -209,6 +209,54 @@ function info(message) { console.log(chalk.blue('ℹ'), message); } +// Create table for comments +function createCommentsTable(comments) { + const table = new Table({ + head: [ + chalk.bold('ID'), + chalk.bold('Author'), + chalk.bold('Body'), + chalk.bold('Created'), + chalk.bold('Updated') + ], + colWidths: [15, 20, 50, 12, 12], + wordWrap: true + }); + + comments.forEach(comment => { + const body = comment.body || ''; + const truncatedBody = body.length > 150 ? + body.substring(0, 147) + '...' : body; + + table.push([ + chalk.cyan(comment.id), + comment.author ? comment.author.displayName : 'Unknown', + truncatedBody, + formatDate(comment.created), + formatDate(comment.updated) + ]); + }); + + return table; +} + +// Display single comment details +function displayCommentDetails(comment) { + console.log(chalk.bold(`\nComment ID: ${comment.id}`)); + console.log(chalk.gray('─'.repeat(60))); + console.log(`${chalk.bold('Author:')} ${comment.author ? comment.author.displayName : 'Unknown'}`); + console.log(`${chalk.bold('Created:')} ${formatDate(comment.created)}`); + console.log(`${chalk.bold('Updated:')} ${formatDate(comment.updated)}`); + + if (comment.visibility) { + console.log(`${chalk.bold('Visibility:')} ${comment.visibility.type} - ${comment.visibility.value}`); + } + + console.log(`\n${chalk.bold('Body:')}`); + console.log(comment.body || 'No content'); + console.log(''); +} + module.exports = { formatDate, formatIssueForTable, @@ -221,5 +269,7 @@ module.exports = { success, error, warning, - info + info, + createCommentsTable, + displayCommentDetails }; diff --git a/package-lock.json b/package-lock.json index a8bf6cb..3b5fcc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pchuri/jira-cli", - "version": "1.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pchuri/jira-cli", - "version": "1.1.1", + "version": "2.2.0", "license": "ISC", "dependencies": { "axios": "^1.12.2", diff --git a/tests/commands/issue.test.js b/tests/commands/issue.test.js index 84f860c..5a08a8b 100644 --- a/tests/commands/issue.test.js +++ b/tests/commands/issue.test.js @@ -27,6 +27,9 @@ describe('IssueCommand', () => { updateIssue: jest.fn(), assignIssue: jest.fn(), addComment: jest.fn(), + getComments: jest.fn(), + updateComment: jest.fn(), + deleteComment: jest.fn(), getTransitions: jest.fn(), transitionIssue: jest.fn() }; @@ -66,6 +69,46 @@ describe('IssueCommand', () => { const viewCommand = commands.find(cmd => cmd.name() === 'view'); expect(viewCommand).toBeDefined(); + + const commentCommand = commands.find(cmd => cmd.name() === 'comment'); + expect(commentCommand).toBeDefined(); + }); + }); + + describe('comment subcommand', () => { + let commentCommand; + + beforeEach(() => { + commentCommand = issueCommand.commands.find(cmd => cmd.name() === 'comment'); + }); + + it('should exist with correct alias', () => { + expect(commentCommand).toBeDefined(); + expect(commentCommand.aliases()).toContain('c'); + }); + + it('should have add subcommand', () => { + const addCommand = commentCommand.commands.find(cmd => cmd.name() === 'add'); + expect(addCommand).toBeDefined(); + expect(addCommand.description()).toContain('add a comment'); + }); + + it('should have list subcommand', () => { + const listCommand = commentCommand.commands.find(cmd => cmd.name() === 'list'); + expect(listCommand).toBeDefined(); + expect(listCommand.description()).toContain('list comments'); + }); + + it('should have edit subcommand', () => { + const editCommand = commentCommand.commands.find(cmd => cmd.name() === 'edit'); + expect(editCommand).toBeDefined(); + expect(editCommand.description()).toContain('edit an existing comment'); + }); + + it('should have delete subcommand', () => { + const deleteCommand = commentCommand.commands.find(cmd => cmd.name() === 'delete'); + expect(deleteCommand).toBeDefined(); + expect(deleteCommand.description()).toContain('delete a comment'); }); }); diff --git a/tests/jira-client.test.js b/tests/jira-client.test.js index 5e058ac..8f6c01e 100644 --- a/tests/jira-client.test.js +++ b/tests/jira-client.test.js @@ -136,6 +136,69 @@ describe('JiraClient', () => { expect(client.client.get).toHaveBeenCalledWith('/project'); expect(result).toEqual(mockProjects); }); + + test('getComments should make correct API call', async () => { + const mockComments = { + comments: [ + { id: '10000', body: 'Test comment', author: { displayName: 'Test User' } } + ] + }; + client.client.get.mockResolvedValue({ data: mockComments }); + + const result = await client.getComments('TEST-1'); + + expect(client.client.get).toHaveBeenCalledWith('/issue/TEST-1/comment'); + expect(result).toEqual(mockComments); + }); + + test('addComment should make correct API call', async () => { + const mockComment = { id: '10001', body: 'New comment' }; + client.client.post.mockResolvedValue({ data: mockComment }); + + const result = await client.addComment('TEST-1', 'New comment'); + + expect(client.client.post).toHaveBeenCalledWith('/issue/TEST-1/comment', { + body: 'New comment' + }); + expect(result).toEqual(mockComment); + }); + + test('addComment with internal flag should include visibility', async () => { + const mockComment = { id: '10001', body: 'Internal comment' }; + client.client.post.mockResolvedValue({ data: mockComment }); + + const result = await client.addComment('TEST-1', 'Internal comment', { internal: true }); + + expect(client.client.post).toHaveBeenCalledWith('/issue/TEST-1/comment', { + body: 'Internal comment', + visibility: { + type: 'role', + value: 'Administrators' + } + }); + expect(result).toEqual(mockComment); + }); + + test('updateComment should make correct API call', async () => { + const mockComment = { id: '10000', body: 'Updated comment' }; + client.client.put.mockResolvedValue({ data: mockComment }); + + const result = await client.updateComment('10000', 'Updated comment'); + + expect(client.client.put).toHaveBeenCalledWith('/comment/10000', { + body: 'Updated comment' + }); + expect(result).toEqual(mockComment); + }); + + test('deleteComment should make correct API call', async () => { + client.client.delete.mockResolvedValue({}); + + const result = await client.deleteComment('10000'); + + expect(client.client.delete).toHaveBeenCalledWith('/comment/10000'); + expect(result).toBe(true); + }); }); // Error handling tests