issue details shows assignee
This commit is contained in:
parent
34e3efb8df
commit
7a1006508c
78
src/api.ts
78
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<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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ async function bootstrap() {
|
|||
selectedIssueComments: [],
|
||||
commentsLoading: false,
|
||||
detailScrollOffset: 0,
|
||||
assigneesInput: '',
|
||||
repos: [],
|
||||
selectedRepoIndex: 0,
|
||||
repoSearchQuery: '',
|
||||
|
|
|
|||
122
src/tui.ts
122
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue