tests, add and view time
This commit is contained in:
parent
d3676e635f
commit
d767df3088
61
src/api.ts
61
src/api.ts
|
|
@ -128,8 +128,16 @@ export async function fetchIssues(
|
|||
color: l.color,
|
||||
})),
|
||||
pull_request: item.pull_request,
|
||||
total_tracked_time: 0,
|
||||
}));
|
||||
|
||||
// Fetch tracked times concurrently for all issues
|
||||
await Promise.all(
|
||||
issues.map(async (issue) => {
|
||||
issue.total_tracked_time = await fetchIssueTrackedTimeTotal(config, issue.number);
|
||||
})
|
||||
);
|
||||
|
||||
return { issues, totalCount };
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
|
|
@ -150,6 +158,7 @@ export async function fetchIssue(
|
|||
try {
|
||||
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}`);
|
||||
const item = response.data;
|
||||
const trackedTime = await fetchIssueTrackedTimeTotal(config, issueNumber);
|
||||
return {
|
||||
id: item.id,
|
||||
number: item.number,
|
||||
|
|
@ -170,6 +179,7 @@ export async function fetchIssue(
|
|||
color: l.color,
|
||||
})),
|
||||
pull_request: item.pull_request,
|
||||
total_tracked_time: trackedTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
|
|
@ -424,3 +434,54 @@ export async function editIssue(
|
|||
throw new Error(`Network Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds time to an issue.
|
||||
* @param time Time in seconds
|
||||
*/
|
||||
export async function addIssueTime(
|
||||
config: Config,
|
||||
issueNumber: number,
|
||||
time: number
|
||||
): Promise<any> {
|
||||
const client = createAxiosInstance(config);
|
||||
try {
|
||||
const response = await client.post(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`, {
|
||||
time,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
throw new Error('Unauthorized: You must be logged in with a token to add time.');
|
||||
}
|
||||
throw new Error(`Failed to add time: ${error.response.data?.message || error.message}`);
|
||||
}
|
||||
throw new Error(`Network Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the total tracked time for a specific issue.
|
||||
* @returns Total time in seconds
|
||||
*/
|
||||
export async function fetchIssueTrackedTimeTotal(
|
||||
config: Config,
|
||||
issueNumber: number
|
||||
): Promise<number> {
|
||||
const client = createAxiosInstance(config);
|
||||
try {
|
||||
const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/times`);
|
||||
const times: any[] = response.data;
|
||||
let total = 0;
|
||||
for (const t of times) {
|
||||
if (t && typeof t.time === 'number') {
|
||||
total += t.time;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch (error: any) {
|
||||
// If times are not supported or missing, just return 0
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ async function bootstrap() {
|
|||
body: '',
|
||||
activeField: 'title',
|
||||
},
|
||||
addTimeForm: {
|
||||
timeInput: '',
|
||||
},
|
||||
};
|
||||
|
||||
// If parameters are provided, try direct connection first
|
||||
|
|
|
|||
181
src/tui.ts
181
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 } from './api.js';
|
||||
import { fetchIssues, fetchIssue, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos, createIssue, createIssueComment, editIssue, addIssueTime } from './api.js';
|
||||
import { saveGlobalConfig } from './config.js';
|
||||
|
||||
// Setup readline for stdin keypress events
|
||||
|
|
@ -73,12 +73,20 @@ export function formatDate(dateStr: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate strings to a specific length.
|
||||
*/
|
||||
// Utility to safely truncate strings
|
||||
function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.substring(0, length - 3) + '...';
|
||||
return str.substring(0, length - 1) + '…';
|
||||
}
|
||||
|
||||
// Utility to format seconds into h m format
|
||||
function formatTime(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return '0m';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -281,6 +289,8 @@ export class TuiEngine {
|
|||
this.handleAddCommentKeypress(str, key);
|
||||
} else if (this.state.screen === 'edit-issue') {
|
||||
this.handleEditIssueKeypress(str, key);
|
||||
} else if (this.state.screen === 'add-time') {
|
||||
this.handleAddTimeKeypress(str, key);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -727,6 +737,16 @@ export class TuiEngine {
|
|||
return;
|
||||
}
|
||||
|
||||
if (str === 't' || str === 'T') {
|
||||
if (this.state.selectedIssue) {
|
||||
this.state.screen = 'add-time';
|
||||
this.state.addTimeForm.timeInput = '';
|
||||
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;
|
||||
|
|
@ -759,6 +779,8 @@ export class TuiEngine {
|
|||
this.renderAddCommentScreen(cols, rows);
|
||||
} else if (this.state.screen === 'edit-issue') {
|
||||
this.renderEditIssueScreen(cols, rows);
|
||||
} else if (this.state.screen === 'add-time') {
|
||||
this.renderAddTimeScreen(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -943,8 +965,9 @@ export class TuiEngine {
|
|||
const authorWidth = 14;
|
||||
const createdWidth = 12;
|
||||
const commentsWidth = 6;
|
||||
const timeWidth = 7;
|
||||
// Title takes all remaining space
|
||||
const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - 8);
|
||||
const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - timeWidth - 9);
|
||||
|
||||
// Render Table Header
|
||||
const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w));
|
||||
|
|
@ -958,7 +981,8 @@ export class TuiEngine {
|
|||
padHeader('Title', titleWidth) + borderCh + ' ' +
|
||||
padHeader('Author', authorWidth) + borderCh + ' ' +
|
||||
padHeader('Created', createdWidth) + borderCh + ' ' +
|
||||
padHeader('Coms', commentsWidth) + borderCh
|
||||
padHeader('Coms', commentsWidth) + borderCh + ' ' +
|
||||
padHeader('Time', timeWidth) + borderCh
|
||||
);
|
||||
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
||||
|
||||
|
|
@ -995,6 +1019,7 @@ export class TuiEngine {
|
|||
const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth);
|
||||
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);
|
||||
|
||||
let rowText =
|
||||
' ' + idStr + borderCh +
|
||||
|
|
@ -1003,7 +1028,8 @@ export class TuiEngine {
|
|||
' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh +
|
||||
' ' + authorStr + borderCh +
|
||||
' ' + createdStr + borderCh +
|
||||
' ' + commentsStr + borderCh;
|
||||
' ' + commentsStr + borderCh +
|
||||
' ' + timeStr + borderCh;
|
||||
|
||||
if (isSelected) {
|
||||
rowText = chalk.bgHex('#2E4E7E').white.bold(rowText);
|
||||
|
|
@ -1092,8 +1118,9 @@ export class TuiEngine {
|
|||
}
|
||||
|
||||
const labels = issue.labels.map(l => chalk.bgHex('#' + l.color).black(` ${l.name} `)).join(' ');
|
||||
const timeStr = formatTime(issue.total_tracked_time);
|
||||
|
||||
console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)}`.padEnd(cols - 2) + borderCh);
|
||||
console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)} Time: ${chalk.yellow(timeStr)}`.padEnd(cols - 2 + (issue.state === 'open' ? 10 : 9) + 10 + 10) + borderCh);
|
||||
if (labels) {
|
||||
console.log(borderCh + ` Labels: ${labels}`.padEnd(cols - 2 + labels.length - issue.labels.map(l=>l.name.length + 10).reduce((a,b)=>a+b, 0)) + borderCh); // offset for raw ANSI chars
|
||||
}
|
||||
|
|
@ -1160,7 +1187,7 @@ export class TuiEngine {
|
|||
scrollHelp += chalk.yellow(` (${pct}%)`);
|
||||
}
|
||||
|
||||
const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [E] Edit [R] Reload [O] Settings ${scrollHelp}`);
|
||||
const helpLine = chalk.gray(` [Esc/Backspace] Back to List [C] Add Comment [E] Edit [T] Add Time [R] Reload [O] Settings ${scrollHelp}`);
|
||||
process.stdout.write(helpLine);
|
||||
}
|
||||
|
||||
|
|
@ -1708,5 +1735,139 @@ export class TuiEngine {
|
|||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private renderAddTimeScreen(cols: number, rows: number) {
|
||||
const borderCh = chalk.bold.hex('#4A90E2')('│');
|
||||
|
||||
// Header
|
||||
const title = ' Add Time ';
|
||||
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.addTimeForm;
|
||||
|
||||
// Time Field
|
||||
const timeLabel = chalk.yellow.bold(' Time Spent (e.g. 1h 30m, 45m): ');
|
||||
console.log(borderCh + timeLabel + ' '.repeat(Math.max(0, cols - stripAnsi(timeLabel).length - 2)) + borderCh);
|
||||
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
|
||||
|
||||
// Input Box
|
||||
let visLine = form.timeInput;
|
||||
if (visLine.length > cols - 5) {
|
||||
visLine = visLine.substring(visLine.length - (cols - 5));
|
||||
}
|
||||
|
||||
visLine = visLine + chalk.inverse(' ') + ' '.repeat(Math.max(0, cols - 5 - stripAnsi(visLine).length));
|
||||
|
||||
console.log(borderCh + ' ' + visLine + ' ' + borderCh);
|
||||
|
||||
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
|
||||
|
||||
// Help line
|
||||
const helpLine = chalk.gray(' [Ctrl+S] Submit [Esc] Cancel');
|
||||
const issueStr = this.state.selectedIssue ? chalk.bold.cyan(` Issue #${this.state.selectedIssue.number} `) : '';
|
||||
const issueLen = stripAnsi(issueStr).length;
|
||||
|
||||
const maxHelpLen = cols - issueLen - 1;
|
||||
let helpLinePlain = helpLine;
|
||||
if (stripAnsi(helpLinePlain).length > maxHelpLen) {
|
||||
helpLinePlain = chalk.gray(stripAnsi(helpLinePlain).substring(0, maxHelpLen - 3) + '...');
|
||||
}
|
||||
|
||||
const spaces = cols - stripAnsi(helpLinePlain).length - issueLen - 1;
|
||||
process.stdout.write(helpLinePlain + ' '.repeat(Math.max(0, spaces)) + issueStr);
|
||||
}
|
||||
|
||||
private async handleAddTimeKeypress(str: string, key: any) {
|
||||
if (key && key.name === 'escape') {
|
||||
this.state.screen = 'details';
|
||||
this.state.error = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key && key.ctrl && key.name === 's') {
|
||||
const input = this.state.addTimeForm.timeInput.trim();
|
||||
if (!input) {
|
||||
this.state.error = 'Time input cannot be empty.';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.selectedIssue) {
|
||||
this.state.error = 'No issue selected.';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse time input (e.g. 1h 30m, 45m, 2h)
|
||||
let totalSeconds = 0;
|
||||
const hMatch = input.match(/(\d+)\s*h/);
|
||||
const mMatch = input.match(/(\d+)\s*m/);
|
||||
|
||||
if (hMatch) {
|
||||
totalSeconds += parseInt(hMatch[1], 10) * 3600;
|
||||
}
|
||||
if (mMatch) {
|
||||
totalSeconds += parseInt(mMatch[1], 10) * 60;
|
||||
}
|
||||
|
||||
// If neither h nor m matched, but it's just a number, assume minutes
|
||||
if (!hMatch && !mMatch && /^\d+$/.test(input)) {
|
||||
totalSeconds += parseInt(input, 10) * 60;
|
||||
} else if (!hMatch && !mMatch) {
|
||||
this.state.error = "Invalid format. Use '1h 30m', '45m', or just minutes like '45'.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalSeconds <= 0) {
|
||||
this.state.error = "Time must be greater than 0.";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.error = null;
|
||||
this.state.loading = true;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
await addIssueTime(this.state.config, this.state.selectedIssue.number, totalSeconds);
|
||||
this.state.screen = 'details';
|
||||
this.state.loading = false;
|
||||
// Reload to show the time tracked
|
||||
this.reloadSingleIssue();
|
||||
} catch (err: any) {
|
||||
this.state.error = err.message;
|
||||
this.state.loading = false;
|
||||
this.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let val = this.state.addTimeForm.timeInput;
|
||||
|
||||
if (key && key.name === 'backspace') {
|
||||
if (val.length > 0) {
|
||||
this.state.addTimeForm.timeInput = val.slice(0, -1);
|
||||
this.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (str && str.length === 1 && !key.ctrl && !key.meta && str !== '\n' && str !== '\r') {
|
||||
this.state.error = null;
|
||||
this.state.addTimeForm.timeInput += str;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/types.ts
10
src/types.ts
|
|
@ -30,6 +30,7 @@ export interface Issue {
|
|||
comments: number;
|
||||
labels: Label[];
|
||||
pull_request?: any | null;
|
||||
total_tracked_time?: number; // Total time in seconds
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
|
|
@ -64,7 +65,11 @@ export interface EditIssueForm {
|
|||
activeField: 'title' | 'body';
|
||||
}
|
||||
|
||||
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue';
|
||||
export interface AddTimeForm {
|
||||
timeInput: string;
|
||||
}
|
||||
|
||||
export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details' | 'create-issue' | 'add-comment' | 'edit-issue' | 'add-time';
|
||||
|
||||
|
||||
export interface RepoItem {
|
||||
|
|
@ -116,5 +121,8 @@ export interface AppState {
|
|||
|
||||
// Edit Issue Form State
|
||||
editIssueForm: EditIssueForm;
|
||||
|
||||
// Add Time Form State
|
||||
addTimeForm: AddTimeForm;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import axios from 'axios';
|
||||
|
||||
const configPath = os.homedir() + '/.config/fjtui/fjtui.json';
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
axios.post(config.url + '/api/v1/repos/' + config.repo + '/issues/1/times', { time: 3600 }, {
|
||||
headers: { 'Authorization': 'token ' + config.token }
|
||||
}).then(r => console.log('Added time')).catch(e => console.error(e.message));
|
||||
|
||||
axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues/1', {
|
||||
headers: { 'Authorization': 'token ' + config.token }
|
||||
}).then(r => console.log(Object.keys(r.data))).catch(e => console.error(e.message));
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import axios from 'axios';
|
||||
|
||||
const configPath = os.homedir() + '/.config/fjtui/fjtui.json';
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues/1/times', {
|
||||
headers: { 'Authorization': 'token ' + config.token }
|
||||
}).then(r => console.log(JSON.stringify(r.data, null, 2))).catch(e => console.error(e.message));
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import axios from 'axios';
|
||||
|
||||
const configPath = os.homedir() + '/.config/fjtui/fjtui.json';
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
axios.get(config.url + '/api/v1/repos/' + config.repo + '/times', {
|
||||
headers: { 'Authorization': 'token ' + config.token }
|
||||
}).then(r => console.log(JSON.stringify(r.data.slice(0, 2), null, 2))).catch(e => console.error(e.message));
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import axios from 'axios';
|
||||
|
||||
const configPath = os.homedir() + '/.config/fjtui/fjtui.json';
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
axios.get(config.url + '/api/v1/repos/' + config.repo + '/issues', {
|
||||
headers: { 'Authorization': 'token ' + config.token }
|
||||
}).then(r => console.log(JSON.stringify(r.data[0], null, 2))).catch(e => console.error(e.message));
|
||||
Loading…
Reference in New Issue