diff --git a/src/api.ts b/src/api.ts index db78552..5b01ca3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -68,7 +68,7 @@ export async function fetchIssues( state: 'open' | 'closed' | 'all'; type: 'issues' | 'pulls' | 'all'; q: string; - sortField: 'created' | 'updated' | 'comments'; + sortField: 'created' | 'updated' | 'comments' | 'assignees'; sortOrder: 'asc' | 'desc'; } ): Promise<{ issues: Issue[]; totalCount: number }> { @@ -129,6 +129,11 @@ export async function fetchIssues( })), pull_request: item.pull_request, total_tracked_time: 0, + assignees: (item.assignees || []).map((u: any) => ({ + id: u.id, + login: u.login, + full_name: u.full_name || '', + })), })); // Fetch tracked times concurrently for all issues @@ -147,6 +152,10 @@ export async function fetchIssues( diff = new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); } else if (options.sortField === 'comments') { diff = b.comments - a.comments; + } else if (options.sortField === 'assignees') { + const aAssignees = (a.assignees || []).map(u => u.login).join(','); + const bAssignees = (b.assignees || []).map(u => u.login).join(','); + diff = aAssignees.localeCompare(bAssignees); } return options.sortOrder === 'desc' ? diff : -diff; }); @@ -193,6 +202,11 @@ export async function fetchIssue( })), pull_request: item.pull_request, total_tracked_time: trackedTime, + assignees: (item.assignees || []).map((u: any) => ({ + id: u.id, + login: u.login, + full_name: u.full_name || '', + })), }; } catch (error: any) { if (error.response) { @@ -350,6 +364,11 @@ export async function createIssue( color: l.color, })), pull_request: item.pull_request, + assignees: (item.assignees || []).map((u: any) => ({ + id: u.id, + login: u.login, + full_name: u.full_name || '', + })), }; } catch (error: any) { if (error.response) { @@ -436,6 +455,11 @@ export async function editIssue( color: l.color, })), pull_request: item.pull_request, + assignees: (item.assignees || []).map((u: any) => ({ + id: u.id, + login: u.login, + full_name: u.full_name || '', + })), }; } catch (error: any) { if (error.response) { @@ -522,3 +546,55 @@ export async function fetchIssueTrackedTimeTotal( return 0; } } + +/** + * Updates the assignees for an issue. + */ +export async function setIssueAssignees( + config: Config, + issueNumber: number, + assignees: string[] +): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.patch(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`, { + assignees, + }); + + const item = response.data; + return { + id: item.id, + number: item.number, + title: item.title, + state: item.state, + body: item.body || '', + user: { + id: item.user?.id || 0, + login: item.user?.login || 'unknown', + full_name: item.user?.full_name || '', + }, + created_at: item.created_at, + updated_at: item.updated_at, + comments: item.comments_count || item.comments || 0, + labels: (item.labels || []).map((l: any) => ({ + id: l.id, + name: l.name, + color: l.color, + })), + pull_request: item.pull_request, + assignees: (item.assignees || []).map((u: any) => ({ + id: u.id, + login: u.login, + full_name: u.full_name || '', + })), + }; + } catch (error: any) { + if (error.response) { + if (error.response.status === 401) { + throw new Error('Unauthorized: Please check your token.'); + } + throw new Error(`Failed to update assignees: ${error.response.data?.message || error.message}`); + } + throw new Error(`Network Error: ${error.message}`); + } +} diff --git a/src/index.ts b/src/index.ts index 002b482..36139a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ async function bootstrap() { selectedIssueComments: [], commentsLoading: false, detailScrollOffset: 0, + assigneesInput: '', repos: [], selectedRepoIndex: 0, repoSearchQuery: '', diff --git a/src/tui.ts b/src/tui.ts index 6fbb112..fb879ce 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,7 +1,7 @@ import readline from 'readline'; import chalk from 'chalk'; import { AppState, Issue, Comment } from './types.js'; -import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState } from './api.js'; +import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees } from './api.js'; import { saveGlobalConfig } from './config.js'; // Setup readline for stdin keypress events @@ -383,6 +383,8 @@ export class TuiEngine { this.handleAddTimeKeypress(str, key); } else if (this.state.screen === 'confirm-state-change') { this.handleConfirmStateChangeKeypress(str, key); + } else if (this.state.screen === 'set-assignees') { + this.handleSetAssigneesKeypress(str, key); } } @@ -702,8 +704,8 @@ export class TuiEngine { } if (str === 's' || str === 'S') { - // Cycle sorts: created -> updated -> comments - const fields: Array<'created' | 'updated' | 'comments'> = ['created', 'updated', 'comments']; + // Cycle sorts: created -> updated -> comments -> assignees + const fields: Array<'created' | 'updated' | 'comments' | 'assignees'> = ['created', 'updated', 'comments', 'assignees']; const currentSortIdx = fields.indexOf(this.state.sortField); // If desc, switch to asc. If asc, switch to next field desc! @@ -848,6 +850,16 @@ export class TuiEngine { return; } + if (str === 'a' || str === 'A') { + if (this.state.selectedIssue) { + this.state.screen = 'set-assignees'; + this.state.assigneesInput = (this.state.selectedIssue.assignees || []).map(u => u.login).join(', '); + this.state.error = null; + this.render(); + } + return; + } + if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') { this.state.screen = 'list'; this.state.selectedIssue = null; @@ -888,6 +900,8 @@ export class TuiEngine { this.renderAnimationScreen(cols, rows, GRAVESTONE_FRAMES); } else if (this.state.screen === 'animating-reopen') { this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES); + } else if (this.state.screen === 'set-assignees') { + this.renderSetAssigneesScreen(cols, rows); } } @@ -1086,11 +1100,12 @@ export class TuiEngine { const typeWidth = 5; const stateWidth = 7; const authorWidth = 14; + const assigneesWidth = 14; const createdWidth = 12; const commentsWidth = 6; const timeWidth = 7; - // Title takes all remaining space. There are 8 columns + 9 borders + 8 spaces = 17 extra chars - const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - timeWidth - 17); + // Title takes all remaining space. There are 9 columns + 10 borders + 9 spaces = 19 extra chars + const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - createdWidth - commentsWidth - timeWidth - 19); // Render Table Header const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w)); @@ -1103,6 +1118,7 @@ export class TuiEngine { padHeader('State', stateWidth) + borderCh + ' ' + padHeader('Title', titleWidth) + borderCh + ' ' + padHeader('Author', authorWidth) + borderCh + ' ' + + padHeader('Assignees', assigneesWidth) + borderCh + ' ' + padHeader('Created', createdWidth) + borderCh + ' ' + padHeader('Coms', commentsWidth) + borderCh + ' ' + padHeader('Time', timeWidth) + borderCh @@ -1140,6 +1156,8 @@ export class TuiEngine { const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth); const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth); + const assigneesNames = (issue.assignees || []).map(u => u.login).join(','); + const assigneesStr = truncate(assigneesNames, assigneesWidth).padEnd(assigneesWidth); const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth); const commentsStr = String(issue.comments).padEnd(commentsWidth); const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth); @@ -1150,6 +1168,7 @@ export class TuiEngine { ' ' + stateStr + borderCh + ' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh + ' ' + authorStr + borderCh + + ' ' + assigneesStr + borderCh + ' ' + createdStr + borderCh + ' ' + commentsStr + borderCh + ' ' + timeStr + borderCh; @@ -1243,9 +1262,11 @@ export class TuiEngine { } const labels = issue.labels.map(l => chalk.bgHex('#' + l.color).black(` ${l.name} `)).join(' '); + const assigneesStr = (issue.assignees || []).map(u => u.login).join(', '); + const assigneesDisp = assigneesStr ? chalk.magenta(assigneesStr) : chalk.gray('none'); const timeStr = formatTime(issue.total_tracked_time); - const metaLine = ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)} Time: ${chalk.yellow(timeStr)}`; + const metaLine = ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Assignees: ${assigneesDisp} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)} Time: ${chalk.yellow(timeStr)}`; const metaPlainLen = stripAnsi(metaLine).length; const padding = Math.max(0, cols - 2 - metaPlainLen); console.log(borderCh + metaLine + ' '.repeat(padding) + borderCh); @@ -1322,7 +1343,7 @@ export class TuiEngine { } const actionKey = issue.state === 'open' ? 'Close' : 'Reopen'; - const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`); + const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [A] Assign [E] Edit [T] Add Time [X] ${actionKey} [R] Reload [O] Settings ${scrollHelp}`); process.stdout.write(helpLine); } @@ -2095,5 +2116,92 @@ export class TuiEngine { return; } } -} + private renderSetAssigneesScreen(cols: number, rows: number) { + const borderCh = chalk.bold.hex('#4A90E2')('│'); + + const title = ' Set Assignees '; + const leftLen = stripAnsi(title).length; + let leftHeader = chalk.bold.hex('#4A90E2')(title); + + console.log(leftHeader + ' '.repeat(Math.max(1, cols - leftLen - 1))); + console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐')); + + if (this.state.error) { + console.log(borderCh + chalk.red(` Error: ${this.state.error}`).padEnd(cols - 2) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + } + const label = chalk.yellow(' Assignees (comma-separated usernames): '); + console.log(borderCh + label + ' '.repeat(cols - stripAnsi(label).length - 2) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + const inputVal = this.state.assigneesInput; + let visVal = inputVal; + if (visVal.length > cols - 5) { + visVal = visVal.substring(visVal.length - (cols - 5)); + } + visVal = visVal + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visVal).length)); + console.log(borderCh + ' ' + visVal + ' ' + borderCh); + + const fillRows = rows - (this.state.error ? 8 : 6) - 1; + for (let i = 0; i < fillRows; i++) { + console.log(borderCh + ' '.repeat(cols - 2) + borderCh); + } + + console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); + + const helpLine = chalk.gray(' [Enter] Submit [Esc] Cancel (leave blank to clear assignees)'); + const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : ''; + const spaces = cols - stripAnsi(helpLine).length - stripAnsi(issueStr).length; + process.stdout.write(helpLine + ' '.repeat(Math.max(0, spaces)) + issueStr); + } + + private async handleSetAssigneesKeypress(str: string, key: any) { + if (key && key.name === 'escape') { + this.state.screen = 'details'; + this.state.error = null; + this.render(); + return; + } + + if (key && key.name === 'backspace') { + if (this.state.assigneesInput.length > 0) { + this.state.assigneesInput = this.state.assigneesInput.slice(0, -1); + this.render(); + } + return; + } + + if (key && key.name === 'return') { + const issue = this.state.selectedIssue; + if (!issue) return; + + this.state.error = null; + this.state.loading = true; + this.render(); + + const assignees = this.state.assigneesInput + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + + try { + await setIssueAssignees(this.state.config, issue.number, assignees); + this.state.screen = 'details'; + this.state.loading = false; + this.reloadSingleIssue(); + } catch (err: any) { + this.state.error = err.message; + this.state.loading = false; + this.render(); + } + return; + } + + if (str && str.length === 1 && !key.ctrl && !key.meta) { + this.state.error = null; + this.state.assigneesInput += str; + this.render(); + } + } +} diff --git a/src/types.ts b/src/types.ts index 244ea73..c3c1b3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,7 @@ export interface Issue { labels: Label[]; pull_request?: any | null; total_tracked_time?: number; // Total time in seconds + assignees?: User[]; } export interface Comment { @@ -69,7 +70,7 @@ export interface AddTimeForm { timeInput: string; } -export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen'; +export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees'; export interface RepoItem { @@ -95,7 +96,7 @@ export interface AppState { searchQuery: string; stateFilter: 'open' | 'closed' | 'all'; typeFilter: 'issues' | 'pulls' | 'all'; - sortField: 'created' | 'updated' | 'comments'; + sortField: 'created' | 'updated' | 'comments' | 'assignees'; sortOrder: 'asc' | 'desc'; // Detail Screen State @@ -103,6 +104,7 @@ export interface AppState { selectedIssueComments: Comment[]; commentsLoading: boolean; detailScrollOffset: number; + assigneesInput: string; // Repo Picker State repos: RepoItem[]; diff --git a/swagger.json b/swagger.json new file mode 100644 index 0000000..e69de29