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';
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<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: [],
commentsLoading: false,
detailScrollOffset: 0,
assigneesInput: '',
repos: [],
selectedRepoIndex: 0,
repoSearchQuery: '',

View File

@ -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();
}
}
}

View File

@ -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[];

0
swagger.json Normal file
View File