add issue lables

This commit is contained in:
Isaac Johnson 2026-06-02 18:42:07 -05:00
parent 616685c9b2
commit e70afe6c05
4 changed files with 463 additions and 10 deletions

View File

@ -1,5 +1,5 @@
import axios from 'axios';
import { Config, Issue, Comment, RepoItem } from './types.js';
import { Config, Issue, Comment, RepoItem, Label } from './types.js';
/**
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
@ -598,3 +598,105 @@ export async function setIssueAssignees(
throw new Error(`Network Error: ${error.message}`);
}
}
/**
* Fetches all labels for a specific repository.
*/
export async function fetchLabels(config: Config): Promise<Label[]> {
const client = createAxiosInstance(config);
try {
const response = await client.get(`/repos/${config.owner}/${config.repo}/labels`);
return response.data.map((item: any) => ({
id: item.id,
name: item.name,
color: item.color,
description: item.description,
exclusive: item.exclusive,
}));
} catch (error: any) {
if (error.response) {
throw new Error(`Failed to fetch labels: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
/**
* Creates a new label in the specified repository.
*/
export async function createLabel(config: Config, label: Partial<Label>): Promise<Label> {
const client = createAxiosInstance(config);
try {
const response = await client.post(`/repos/${config.owner}/${config.repo}/labels`, {
name: label.name,
color: label.color, // expects hex without #
description: label.description,
exclusive: label.exclusive,
});
const item = response.data;
return {
id: item.id,
name: item.name,
color: item.color,
description: item.description,
exclusive: item.exclusive,
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to create a label.');
}
throw new Error(`Failed to create label: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
/**
* Updates an existing label.
*/
export async function updateLabel(config: Config, id: number, label: Partial<Label>): Promise<Label> {
const client = createAxiosInstance(config);
try {
const response = await client.patch(`/repos/${config.owner}/${config.repo}/labels/${id}`, {
name: label.name,
color: label.color,
description: label.description,
exclusive: label.exclusive,
});
const item = response.data;
return {
id: item.id,
name: item.name,
color: item.color,
description: item.description,
exclusive: item.exclusive,
};
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to update a label.');
}
throw new Error(`Failed to update label: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}
/**
* Deletes a label.
*/
export async function deleteLabel(config: Config, id: number): Promise<void> {
const client = createAxiosInstance(config);
try {
await client.delete(`/repos/${config.owner}/${config.repo}/labels/${id}`);
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: You must be logged in with a token to delete a label.');
}
throw new Error(`Failed to delete label: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}

View File

@ -85,6 +85,16 @@ async function bootstrap() {
addTimeForm: {
timeInput: '',
},
labels: [],
selectedLabelIndex: 0,
labelsLoading: false,
labelForm: {
name: '',
color: '',
description: '',
exclusive: false,
activeField: 'name',
},
};
// If parameters are provided, try direct connection first

View File

@ -4,7 +4,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { AppState, Issue, Comment } from './types.js';
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees } from './api.js';
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime, changeIssueState, setIssueAssignees, fetchLabels, createLabel, updateLabel, deleteLabel } from './api.js';
import { saveGlobalConfig } from './config.js';
// Setup readline for stdin keypress events
@ -400,10 +400,12 @@ export class TuiEngine {
this.handleEditIssueKeypress(str, key);
} else if (this.state.screen === 'add-time') {
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);
} else if (this.state.screen === 'labels-list') {
this.handleLabelsListKeypress(str, key);
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
this.handleLabelFormKeypress(str, key);
}
}
@ -778,6 +780,13 @@ export class TuiEngine {
return;
}
if (str === 'l' || str === 'L') {
this.state.screen = 'labels-list';
this.state.selectedLabelIndex = 0;
this.loadLabels();
return;
}
if (str === 'q' || str === 'Q') {
this.stop();
}
@ -923,6 +932,10 @@ export class TuiEngine {
this.renderAnimationScreen(cols, rows, ZOMBIE_FRAMES);
} else if (this.state.screen === 'set-assignees') {
this.renderSetAssigneesScreen(cols, rows);
} else if (this.state.screen === 'labels-list') {
this.renderLabelsList(cols, rows);
} else if (this.state.screen === 'create-label' || this.state.screen === 'edit-label') {
this.renderLabelForm(cols, rows);
}
}
@ -1122,11 +1135,12 @@ export class TuiEngine {
const stateWidth = 7;
const authorWidth = 14;
const assigneesWidth = 14;
const labelsWidth = 16;
const createdWidth = 12;
const commentsWidth = 6;
const timeWidth = 7;
// 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);
// Title takes all remaining space. There are 10 columns + 11 borders + 10 spaces = 21 extra chars
const titleWidth = Math.max(10, cols - idWidth - typeWidth - stateWidth - authorWidth - assigneesWidth - labelsWidth - createdWidth - commentsWidth - timeWidth - 21);
// Render Table Header
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
@ -1140,6 +1154,7 @@ export class TuiEngine {
padHeader('Title', titleWidth) + borderCh + ' ' +
padHeader('Author', authorWidth) + borderCh + ' ' +
padHeader('Assignees', assigneesWidth) + borderCh + ' ' +
padHeader('Labels', labelsWidth) + borderCh + ' ' +
padHeader('Created', createdWidth) + borderCh + ' ' +
padHeader('Coms', commentsWidth) + borderCh + ' ' +
padHeader('Time', timeWidth) + borderCh
@ -1179,6 +1194,26 @@ export class TuiEngine {
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);
let plainLen = 0;
let issueLabelsStr = '';
for (let idx = 0; idx < issue.labels.length; idx++) {
const l = issue.labels[idx];
let name = l.name;
if (name.length + 2 > labelsWidth) {
name = name.substring(0, labelsWidth - 3) + '…';
}
const lText = ` ${name} `;
if (plainLen + lText.length > labelsWidth) break;
issueLabelsStr += chalk.bgHex('#' + l.color).black(lText);
plainLen += lText.length;
if (idx < issue.labels.length - 1 && plainLen + 1 <= labelsWidth) {
issueLabelsStr += ' ';
plainLen += 1;
} else if (idx < issue.labels.length - 1) {
break;
}
}
issueLabelsStr += ' '.repeat(Math.max(0, labelsWidth - plainLen));
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);
@ -1190,6 +1225,7 @@ export class TuiEngine {
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
' ' + authorStr + borderCh +
' ' + assigneesStr + borderCh +
' ' + issueLabelsStr + borderCh +
' ' + createdStr + borderCh +
' ' + commentsStr + borderCh +
' ' + timeStr + borderCh;
@ -1224,7 +1260,7 @@ export class TuiEngine {
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Keyboard controls help line
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit';
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [C] Create [/] Search [S] Sort [F] State [T] Type [L] Labels [N/P] Page [R] Reload [O] Settings [Esc] Quit';
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
const repoLen = stripAnsi(repoStr).length;
@ -2395,8 +2431,295 @@ export class TuiEngine {
const printedRows = H + padTopCount + 5;
const remainingRows = Math.max(0, rows - printedRows - 1);
for (let i = 0; i < remainingRows; i++) {
console.log('');
}
// --- Labels Logic ---
private async loadLabels() {
this.state.labelsLoading = true;
this.state.error = null;
this.render();
try {
this.state.labels = await fetchLabels(this.state.config);
} catch (err: any) {
this.state.error = `Failed to load labels: ${err.message}`;
} finally {
this.state.labelsLoading = false;
this.render();
}
}
private async handleLabelsListKeypress(str: string, key: any) {
if (this.state.labelsLoading) return;
if ((key && key.name === 'escape') || str === '\u001b' || str === 'q' || str === 'Q') {
this.state.screen = 'list';
this.render();
return;
}
if (key && key.name === 'up') {
if (this.state.selectedLabelIndex > 0) {
this.state.selectedLabelIndex--;
this.render();
}
return;
}
if (key && key.name === 'down') {
if (this.state.selectedLabelIndex < this.state.labels.length - 1) {
this.state.selectedLabelIndex++;
this.render();
}
return;
}
if (str === 'c' || str === 'C') {
this.state.screen = 'create-label';
this.state.labelForm = {
name: '',
color: '000000',
description: '',
exclusive: false,
activeField: 'name'
};
this.render();
return;
}
if (str === 'e' || str === 'E') {
if (this.state.labels.length > 0) {
const lbl = this.state.labels[this.state.selectedLabelIndex];
this.state.screen = 'edit-label';
this.state.labelForm = {
name: lbl.name,
color: lbl.color,
description: lbl.description || '',
exclusive: lbl.exclusive || false,
activeField: 'name'
};
this.render();
}
return;
}
if ((key && key.name === 'delete') || str === 'd' || str === 'D') {
if (this.state.labels.length > 0) {
const lbl = this.state.labels[this.state.selectedLabelIndex];
this.state.labelsLoading = true;
this.render();
try {
await deleteLabel(this.state.config, lbl.id);
await this.loadLabels();
} catch (err: any) {
this.state.error = `Failed to delete label: ${err.message}`;
this.state.labelsLoading = false;
this.render();
}
}
return;
}
}
private async handleLabelFormKeypress(str: string, key: any) {
if ((key && key.name === 'escape') || str === '\u001b') {
this.state.screen = 'labels-list';
this.render();
return;
}
const form = this.state.labelForm;
const fields: Array<'name' | 'color' | 'description' | 'exclusive'> = ['name', 'color', 'description', 'exclusive'];
const currentIdx = fields.indexOf(form.activeField);
if (key && key.name === 'up') {
form.activeField = fields[(currentIdx - 1 + fields.length) % fields.length];
this.render();
return;
}
if (key && key.name === 'down') {
form.activeField = fields[(currentIdx + 1) % fields.length];
this.render();
return;
}
if (key && key.name === 'tab') {
form.activeField = fields[(currentIdx + 1) % fields.length];
this.render();
return;
}
if (str === ' ' && form.activeField === 'exclusive') {
form.exclusive = !form.exclusive;
this.render();
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
if (form.activeField === 'exclusive') {
form.exclusive = !form.exclusive;
this.render();
return;
}
if (!form.name.trim() || !form.color.trim()) {
this.state.error = 'Name and Color are required.';
this.render();
return;
}
this.state.labelsLoading = true;
this.state.error = null;
this.render();
try {
if (this.state.screen === 'create-label') {
await createLabel(this.state.config, {
name: form.name.trim(),
color: form.color.trim(),
description: form.description.trim(),
exclusive: form.exclusive
});
} else {
const lbl = this.state.labels[this.state.selectedLabelIndex];
await updateLabel(this.state.config, lbl.id, {
name: form.name.trim(),
color: form.color.trim(),
description: form.description.trim(),
exclusive: form.exclusive
});
}
this.state.screen = 'labels-list';
await this.loadLabels();
} catch (err: any) {
this.state.error = err.message;
this.state.labelsLoading = false;
this.render();
}
return;
}
if (key && key.name === 'backspace') {
if (form.activeField === 'name') form.name = form.name.slice(0, -1);
if (form.activeField === 'color') form.color = form.color.slice(0, -1);
if (form.activeField === 'description') form.description = form.description.slice(0, -1);
this.render();
return;
}
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
if (form.activeField === 'name') form.name += str;
if (form.activeField === 'color') form.color += str;
if (form.activeField === 'description') form.description += str;
this.render();
}
}
private renderLabelsList(cols: number, rows: number) {
const header = chalk.bgBlue.white.bold(` Repository Labels - ${this.state.config.owner}/${this.state.config.repo} `);
const headerPad = Math.max(0, Math.floor((cols - stripAnsi(header).length) / 2));
console.log(' '.repeat(headerPad) + header);
console.log(chalk.gray('─'.repeat(cols)));
if (this.state.labelsLoading) {
console.log(chalk.yellow(` Loading labels... ${SPINNER_FRAMES[spinnerIndex]}`));
} else if (this.state.labels.length === 0) {
console.log(chalk.gray(' No labels found.'));
} else {
const listHeight = rows - 6; // header(2), footer(2), errors etc.
let startIdx = 0;
if (this.state.selectedLabelIndex >= listHeight) {
startIdx = this.state.selectedLabelIndex - listHeight + 1;
}
for (let i = 0; i < listHeight; i++) {
const itemIdx = startIdx + i;
if (itemIdx >= this.state.labels.length) break;
const lbl = this.state.labels[itemIdx];
const isSelected = itemIdx === this.state.selectedLabelIndex;
let prefix = isSelected ? chalk.green(' > ') : ' ';
let lblDisplay = chalk.bgHex('#' + lbl.color).black(` ${lbl.name} `);
if (lbl.exclusive) lblDisplay += ' (exclusive)';
let line = prefix + lblDisplay;
if (lbl.description) {
line += chalk.gray(` - ${truncate(lbl.description, cols - 30)}`);
}
if (isSelected) {
console.log(chalk.bgGray(line + ' '.repeat(Math.max(0, cols - stripAnsi(line).length))));
} else {
console.log(line);
}
}
}
// Fill remaining space
const currentLine = this.state.labelsLoading ? 3 : Math.min(this.state.labels.length, rows - 6) + 2;
for (let i = currentLine; i < rows - 3; i++) console.log('');
if (this.state.error) {
console.log(chalk.red(` Error: ${this.state.error}`));
} else {
console.log('');
}
console.log(chalk.gray('─'.repeat(cols)));
console.log(chalk.gray(' [c] Create [e] Edit [d] Delete [esc] Back to Issues'));
}
private renderLabelForm(cols: number, rows: number) {
const isEdit = this.state.screen === 'edit-label';
const title = isEdit ? 'Edit Label' : 'Create Label';
const header = chalk.bgBlue.white.bold(` ${title} `);
const headerPad = Math.max(0, Math.floor((cols - stripAnsi(header).length) / 2));
console.log(' '.repeat(headerPad) + header);
console.log(chalk.gray('─'.repeat(cols)));
const form = this.state.labelForm;
const width = Math.min(60, cols - 4);
const leftPadStr = ' '.repeat(Math.max(0, Math.floor((cols - width) / 2)));
console.log('');
// Name
const nameLabel = form.activeField === 'name' ? chalk.cyan.bold('> Name: ') : ' Name: ';
console.log(leftPadStr + nameLabel + (form.name || chalk.gray('(empty)')));
console.log('');
// Color
const colorLabel = form.activeField === 'color' ? chalk.cyan.bold('> Color (hex without #): ') : ' Color: ';
console.log(leftPadStr + colorLabel + (form.color || chalk.gray('e.g. ff0000')));
if (form.color && form.color.length >= 3) {
try {
console.log(leftPadStr + ' Preview: ' + chalk.bgHex('#' + form.color).black(` ${form.name || 'Label'} `));
} catch (e) {
console.log(leftPadStr + ' Preview: ' + chalk.red('Invalid color'));
}
}
console.log('');
// Description
const descLabel = form.activeField === 'description' ? chalk.cyan.bold('> Description: ') : ' Description: ';
console.log(leftPadStr + descLabel + (form.description || chalk.gray('(empty)')));
console.log('');
// Exclusive
const exclLabel = form.activeField === 'exclusive' ? chalk.cyan.bold('> Exclusive: ') : ' Exclusive: ';
console.log(leftPadStr + exclLabel + (form.exclusive ? chalk.green('[x]') : '[ ]'));
console.log('');
for (let i = 13; i < rows - 3; i++) console.log('');
if (this.state.error) {
console.log(leftPadStr + chalk.red(` Error: ${this.state.error}`));
} else if (this.state.labelsLoading) {
console.log(leftPadStr + chalk.yellow(` Saving... ${SPINNER_FRAMES[spinnerIndex]}`));
} else {
console.log('');
}
console.log(chalk.gray('─'.repeat(cols)));
console.log(chalk.gray(' [↑/↓/Tab] Navigate [Enter] Save [Esc] Cancel'));
}
}

View File

@ -16,6 +16,8 @@ export interface Label {
id: number;
name: string;
color: string;
description?: string;
exclusive?: boolean;
}
export interface Issue {
@ -70,7 +72,15 @@ export interface AddTimeForm {
timeInput: string;
}
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees';
export interface LabelForm {
name: string;
color: string;
description: string;
exclusive: boolean;
activeField: 'name' | 'color' | 'description' | 'exclusive';
}
export type ScreenType = 'launch' | 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time' | 'confirm-state-change' | 'animating-close' | 'animating-reopen' | 'set-assignees' | 'labels-list' | 'create-label' | 'edit-label';
export interface RepoItem {
@ -126,5 +136,13 @@ export interface AppState {
// Add Time Form State
addTimeForm: AddTimeForm;
// Labels List State
labels: Label[];
selectedLabelIndex: number;
labelsLoading: boolean;
// Label Form State
labelForm: LabelForm;
}