feat: Add create issue screen and API support

This commit is contained in:
Isaac Johnson 2026-05-31 22:06:53 -05:00
parent db6cb90215
commit 9dced00996
4 changed files with 242 additions and 3 deletions

View File

@ -248,3 +248,50 @@ export async function authenticateAndFetchRepos(
} }
} }
/**
* Creates a new issue in the specified repository.
*/
export async function createIssue(
config: Config,
title: string,
body: string
): Promise<Issue> {
const client = createAxiosInstance(config);
try {
const response = await client.post(`/repos/${config.owner}/${config.repo}/issues`, {
title,
body,
});
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,
};
} 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 an issue.');
}
throw new Error(`Failed to create issue: ${error.response.data?.message || error.message}`);
}
throw new Error(`Network Error: ${error.message}`);
}
}

View File

@ -68,6 +68,11 @@ async function bootstrap() {
saveConfig: true, saveConfig: true,
activeField: url ? (userid ? 'token' : 'userid') : 'url', activeField: url ? (userid ? 'token' : 'userid') : 'url',
}, },
createIssueForm: {
title: '',
body: '',
activeField: 'title',
},
}; };
// If parameters are provided, try direct connection first // If parameters are provided, try direct connection first

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, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos } from './api.js'; import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue } 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
@ -246,6 +246,8 @@ export class TuiEngine {
} }
} else if (this.state.screen === 'details') { } else if (this.state.screen === 'details') {
this.handleDetailsKeypress(str, key); this.handleDetailsKeypress(str, key);
} else if (this.state.screen === 'create-issue') {
this.handleCreateIssueKeypress(str, key);
} }
} }
@ -582,6 +584,16 @@ export class TuiEngine {
return; return;
} }
if (str === 'c' || str === 'C') {
// Navigate to Create Issue Screen
this.state.screen = 'create-issue';
this.state.createIssueForm.title = '';
this.state.createIssueForm.body = '';
this.state.createIssueForm.activeField = 'title';
this.render();
return;
}
if (str === 'f' || str === 'F') { if (str === 'f' || str === 'F') {
// Cycle states: open -> closed -> all // Cycle states: open -> closed -> all
const states: Array<'open' | 'closed' | 'all'> = ['open', 'closed', 'all']; const states: Array<'open' | 'closed' | 'all'> = ['open', 'closed', 'all'];
@ -681,6 +693,8 @@ export class TuiEngine {
this.renderListScreen(cols, rows); this.renderListScreen(cols, rows);
} else if (this.state.screen === 'details') { } else if (this.state.screen === 'details') {
this.renderDetailsScreen(cols, rows); this.renderDetailsScreen(cols, rows);
} else if (this.state.screen === 'create-issue') {
this.renderCreateIssueScreen(cols, rows);
} }
} }
@ -955,7 +969,7 @@ export class TuiEngine {
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Keyboard controls help line // Keyboard controls help line
const fullHelpLine = ' [↑/↓] Navigate [Enter] View [/] 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 [N/P] Page [R] Reload [O] Settings [Esc] Quit';
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `); const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
const repoLen = stripAnsi(repoStr).length; const repoLen = stripAnsi(repoStr).length;
@ -1166,5 +1180,169 @@ export class TuiEngine {
process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor
} }
} }
/**
* Draw Create Issue Screen
*/
private renderCreateIssueScreen(cols: number, rows: number) {
const borderCh = chalk.bold.hex('#4A90E2')('│');
// Header
const title = ' Create New Issue ';
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 form = this.state.createIssueForm;
// Title Field
let titleLabel = ' Title: ';
let titleVisible = form.title;
if (titleVisible.length > cols - 13) {
titleVisible = titleVisible.substring(titleVisible.length - (cols - 13));
}
let titleContent = titleVisible.padEnd(cols - 12);
if (form.activeField === 'title') {
titleLabel = chalk.yellow(' Title: ');
titleContent = chalk.inverse(titleVisible + ' ') + ' '.repeat(Math.max(0, cols - 13 - titleVisible.length));
}
console.log(borderCh + titleLabel + titleContent + borderCh);
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Body Field
let bodyLabel = chalk.bold(' Body (markdown supported): ');
if (form.activeField === 'body') {
bodyLabel = chalk.yellow.bold(' Body (markdown supported): ');
}
console.log(borderCh + bodyLabel + ' '.repeat(cols - stripAnsi(bodyLabel).length - 2) + borderCh);
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Body Content (multiline)
const errorOffset = this.state.error ? 2 : 0;
const bodyRows = rows - 10 - errorOffset; // available space for body
const lines = form.body.split('\n');
// Pagination for body if it gets too long
const startLine = Math.max(0, lines.length - bodyRows);
for (let i = 0; i < bodyRows; i++) {
const lineIdx = startLine + i;
let lineContent = '';
if (lineIdx < lines.length) {
lineContent = lines[lineIdx];
}
let visLine = lineContent;
if (visLine.length > cols - 5) {
visLine = visLine.substring(visLine.length - (cols - 5));
}
if (form.activeField === 'body' && lineIdx === lines.length - 1) {
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
} else {
visLine = visLine.padEnd(cols - 4);
}
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
}
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Help line
const helpLine = chalk.gray(' [Tab] Switch Fields [Ctrl+S] Submit [Esc] Cancel');
const repoStr = chalk.bold.cyan(` repo: ${this.state.config.owner}/${this.state.config.repo} `);
const repoLen = stripAnsi(repoStr).length;
const maxHelpLen = cols - repoLen - 1;
let helpLinePlain = helpLine;
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
}
const spaces = cols - stripAnsi(helpLinePlain).length - repoLen - 1;
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + repoStr);
}
/**
* Handle Keypress for Create Issue Screen
*/
private async handleCreateIssueKeypress(str: string, key: any) {
if (key && key.name === 'escape') {
this.state.screen = 'list';
this.state.error = null;
this.render();
return;
}
if (key && key.ctrl && key.name === 's') {
// Submit issue
if (!this.state.createIssueForm.title.trim()) {
this.state.error = 'Title is required to create an issue.';
this.render();
return;
}
this.state.error = null;
this.state.loading = true;
this.render();
try {
await createIssue(this.state.config, this.state.createIssueForm.title, this.state.createIssueForm.body);
this.state.screen = 'list';
this.state.loading = false;
// Refresh issues
this.state.currentPage = 1;
this.state.selectedIssueIndex = 0;
this.loadIssues();
} catch (err: any) {
this.state.error = err.message;
this.state.loading = false;
this.render();
}
return;
}
if (key && key.name === 'tab') {
this.state.createIssueForm.activeField = this.state.createIssueForm.activeField === 'title' ? 'body' : 'title';
this.render();
return;
}
// Text input handling
const activeField = this.state.createIssueForm.activeField;
let val = this.state.createIssueForm[activeField];
if (key && key.name === 'backspace') {
if (val.length > 0) {
this.state.createIssueForm[activeField] = val.slice(0, -1);
this.render();
}
return;
}
if (key && key.name === 'return') {
if (activeField === 'body') {
this.state.createIssueForm.body += '\n';
this.render();
} else {
// In title, return switches to body
this.state.createIssueForm.activeField = 'body';
this.render();
}
return;
}
if (str && str.length === 1 && !key.ctrl && !key.meta) {
this.state.error = null;
this.state.createIssueForm[activeField] += str;
this.render();
}
}
} }

View File

@ -48,7 +48,13 @@ export interface SetupForm {
activeField: 'url' | 'userid' | 'token' | 'saveConfig'; activeField: 'url' | 'userid' | 'token' | 'saveConfig';
} }
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details'; export interface CreateIssueForm {
title: string;
body: string;
activeField: 'title' | 'body';
}
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue';
export interface RepoItem { export interface RepoItem {
id: number; id: number;
@ -90,5 +96,8 @@ export interface AppState {
// Setup Form State // Setup Form State
setupForm: SetupForm; setupForm: SetupForm;
// Create Issue Form State
createIssueForm: CreateIssueForm;
} }