issue details shows assignee

This commit is contained in:
Isaac Johnson 2026-06-02 13:41:33 -05:00
parent 34e3efb8df
commit 7a1006508c
5 changed files with 198 additions and 11 deletions

View File

@ -68,7 +68,7 @@ export async function fetchIssues(
state: 'open' | 'closed' | 'all'; state: 'open' | 'closed' | 'all';
type: 'issues' | 'pulls' | 'all'; type: 'issues' | 'pulls' | 'all';
q: string; q: string;
sortField: 'created' | 'updated' | 'comments'; sortField: 'created' | 'updated' | 'comments' | 'assignees';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }
): Promise<{ issues: Issue[]; totalCount: number }> { ): Promise<{ issues: Issue[]; totalCount: number }> {
@ -129,6 +129,11 @@ export async function fetchIssues(
})), })),
pull_request: item.pull_request, pull_request: item.pull_request,
total_tracked_time: 0, 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 // 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(); diff = new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
} else if (options.sortField === 'comments') { } else if (options.sortField === 'comments') {
diff = b.comments - a.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; return options.sortOrder === 'desc' ? diff : -diff;
}); });
@ -193,6 +202,11 @@ export async function fetchIssue(
})), })),
pull_request: item.pull_request, pull_request: item.pull_request,
total_tracked_time: trackedTime, total_tracked_time: trackedTime,
assignees: (item.assignees || []).map((u: any) => ({
id: u.id,
login: u.login,
full_name: u.full_name || '',
})),
}; };
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {
@ -350,6 +364,11 @@ export async function createIssue(
color: l.color, color: l.color,
})), })),
pull_request: item.pull_request, 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) { } catch (error: any) {
if (error.response) { if (error.response) {
@ -436,6 +455,11 @@ export async function editIssue(
color: l.color, color: l.color,
})), })),
pull_request: item.pull_request, 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) { } catch (error: any) {
if (error.response) { if (error.response) {
@ -522,3 +546,55 @@ export async function fetchIssueTrackedTimeTotal(
return 0; return 0;
} }
} }
/**
* Updates the assignees for an issue.
*/
export async function setIssueAssignees(
config: Config,
issueNumber: number,
assignees: string[]
): Promise<Issue> {
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}`);
}
}

View File

@ -57,6 +57,7 @@ async function bootstrap() {
selectedIssueComments: [], selectedIssueComments: [],
commentsLoading: false, commentsLoading: false,
detailScrollOffset: 0, detailScrollOffset: 0,
assigneesInput: '',
repos: [], repos: [],
selectedRepoIndex: 0, selectedRepoIndex: 0,
repoSearchQuery: '', repoSearchQuery: '',

View File

@ -1,7 +1,7 @@
import readline from 'readline'; import readline from 'readline';
import chalk from 'chalk'; import chalk from 'chalk';
import { AppState, Issue, Comment } from './types.js'; 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'; import { saveGlobalConfig } from './config.js';
// Setup readline for stdin keypress events // Setup readline for stdin keypress events
@ -383,6 +383,8 @@ export class TuiEngine {
this.handleAddTimeKeypress(str, key); this.handleAddTimeKeypress(str, key);
} else if (this.state.screen === 'confirm-state-change') { } else if (this.state.screen === 'confirm-state-change') {
this.handleConfirmStateChangeKeypress(str, key); 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') { if (str === 's' || str === 'S') {
// Cycle sorts: created -> updated -> comments // Cycle sorts: created -> updated -> comments -> assignees
const fields: Array<'created' | 'updated' | 'comments'> = ['created', 'updated', 'comments']; const fields: Array<'created' | 'updated' | 'comments' | 'assignees'> = ['created', 'updated', 'comments', 'assignees'];
const currentSortIdx = fields.indexOf(this.state.sortField); const currentSortIdx = fields.indexOf(this.state.sortField);
// If desc, switch to asc. If asc, switch to next field desc! // If desc, switch to asc. If asc, switch to next field desc!
@ -848,6 +850,16 @@ export class TuiEngine {
return; 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') { if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') {
this.state.screen = 'list'; this.state.screen = 'list';
this.state.selectedIssue = null; this.state.selectedIssue = null;
@ -888,6 +900,8 @@ export class TuiEngine {
this.renderAnimationScreen(cols, rows, GRAVESTONE_FRAMES); this.renderAnimationScreen(cols, rows, GRAVESTONE_FRAMES);
} else if (this.state.screen === 'animating-reopen') { } else if (this.state.screen === 'animating-reopen') {
this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES); 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 typeWidth = 5;
const stateWidth = 7; const stateWidth = 7;
const authorWidth = 14; const authorWidth = 14;
const assigneesWidth = 14;
const createdWidth = 12; const createdWidth = 12;
const commentsWidth = 6; const commentsWidth = 6;
const timeWidth = 7; const timeWidth = 7;
// Title takes all remaining space. There are 8 columns + 9 borders + 8 spaces = 17 extra chars // 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 - createdWidth - commentsWidth - timeWidth - 17); const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - createdWidth - commentsWidth - timeWidth - 19);
// Render Table Header // Render Table Header
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w)); const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
@ -1103,6 +1118,7 @@ export class TuiEngine {
padHeader('State', stateWidth) + borderCh + ' ' + padHeader('State', stateWidth) + borderCh + ' ' +
padHeader('Title', titleWidth) + borderCh + ' ' + padHeader('Title', titleWidth) + borderCh + ' ' +
padHeader('Author', authorWidth) + borderCh + ' ' + padHeader('Author', authorWidth) + borderCh + ' ' +
padHeader('Assignees', assigneesWidth) + borderCh + ' ' +
padHeader('Created', createdWidth) + borderCh + ' ' + padHeader('Created', createdWidth) + borderCh + ' ' +
padHeader('Coms', commentsWidth) + borderCh + ' ' + padHeader('Coms', commentsWidth) + borderCh + ' ' +
padHeader('Time', timeWidth) + borderCh padHeader('Time', timeWidth) + borderCh
@ -1140,6 +1156,8 @@ export class TuiEngine {
const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth); const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth);
const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth); 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 createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth);
const commentsStr = String(issue.comments).padEnd(commentsWidth); const commentsStr = String(issue.comments).padEnd(commentsWidth);
const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth); const timeStr = formatTime(issue.total_tracked_time).padEnd(timeWidth);
@ -1150,6 +1168,7 @@ export class TuiEngine {
' ' + stateStr + borderCh + ' ' + stateStr + borderCh +
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh + ' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
' ' + authorStr + borderCh + ' ' + authorStr + borderCh +
' ' + assigneesStr + borderCh +
' ' + createdStr + borderCh + ' ' + createdStr + borderCh +
' ' + commentsStr + borderCh + ' ' + commentsStr + borderCh +
' ' + timeStr + 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 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 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 metaPlainLen = stripAnsi(metaLine).length;
const padding = Math.max(0, cols - 2 - metaPlainLen); const padding = Math.max(0, cols - 2 - metaPlainLen);
console.log(borderCh + metaLine + ' '.repeat(padding) + borderCh); console.log(borderCh + metaLine + ' '.repeat(padding) + borderCh);
@ -1322,7 +1343,7 @@ export class TuiEngine {
} }
const actionKey = issue.state === 'open' ? 'Close' : 'Reopen'; 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); process.stdout.write(helpLine);
} }
@ -2095,5 +2116,92 @@ export class TuiEngine {
return; 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();
}
}
}

View File

@ -31,6 +31,7 @@ export interface Issue {
labels: Label[]; labels: Label[];
pull_request?: any | null; pull_request?: any | null;
total_tracked_time?: number; // Total time in seconds total_tracked_time?: number; // Total time in seconds
assignees?: User[];
} }
export interface Comment { export interface Comment {
@ -69,7 +70,7 @@ export interface AddTimeForm {
timeInput: string; 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 { export interface RepoItem {
@ -95,7 +96,7 @@ export interface AppState {
searchQuery: string; searchQuery: string;
stateFilter: 'open' | 'closed' | 'all'; stateFilter: 'open' | 'closed' | 'all';
typeFilter: 'issues' | 'pulls' | 'all'; typeFilter: 'issues' | 'pulls' | 'all';
sortField: 'created' | 'updated' | 'comments'; sortField: 'created' | 'updated' | 'comments' | 'assignees';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
// Detail Screen State // Detail Screen State
@ -103,6 +104,7 @@ export interface AppState {
selectedIssueComments: Comment[]; selectedIssueComments: Comment[];
commentsLoading: boolean; commentsLoading: boolean;
detailScrollOffset: number; detailScrollOffset: number;
assigneesInput: string;
// Repo Picker State // Repo Picker State
repos: RepoItem[]; repos: RepoItem[];

0
swagger.json Normal file
View File