diff --git a/.github/workflows/changeset-bot.yml b/.github/workflows/changeset-bot.yml index f7975f8..9a74aee 100644 --- a/.github/workflows/changeset-bot.yml +++ b/.github/workflows/changeset-bot.yml @@ -79,61 +79,78 @@ jobs: id: generate-changeset uses: actions/github-script@v7 with: - script: | - const prTitle = `${{ steps.pr-info.outputs.pr_title }}`; - const commits = `${{ steps.pr-info.outputs.commits }}`; - - let versionType = 'patch'; - let description = prTitle; - - const combined = (prTitle + ' ' + commits).toLowerCase(); - - if (combined.includes('breaking') || - combined.includes('major') || - combined.includes('remove') && !combined.includes('bug') || - combined.includes('delete') && !combined.includes('test') || - combined.includes('incompatible')) { - versionType = 'major'; - } - else if (combined.includes('feat') || - combined.includes('feature') || - combined.includes('add') || - combined.includes('new') || - combined.includes('minor') || - combined.includes('enhance') || - combined.includes('improve') && !combined.includes('fix') || - combined.includes('command') || - combined.includes('option')) { - versionType = 'minor'; - } - // Patch version indicators (default) - else if (combined.includes('fix') || - combined.includes('bug') || - combined.includes('patch') || - combined.includes('update') || - combined.includes('refactor') || - combined.includes('perf') || - combined.includes('docs') || - combined.includes('style') || - combined.includes('test')) { - versionType = 'patch'; - } - - description = prTitle - .replace(/^(feat|fix|docs|style|refactor|test|chore|perf)(\([^)]+\))?: /i, '') - .replace(/^(add|fix|update|remove|delete|improve|enhance): /i, '') - .trim(); - - if (!description || description.length < 5) { - description = prTitle; - } - - description = description.charAt(0).toUpperCase() + description.slice(1); - - core.setOutput('version_type', versionType); - core.setOutput('description', description); - - console.log(`Generated changeset: ${versionType} - ${description}`); + script: | + const prTitle = `${{ steps.pr-info.outputs.pr_title }}`; + const prBody = `${{ steps.pr-info.outputs.pr_body }}`; + const commits = `${{ steps.pr-info.outputs.commits }}`; + + // Get PR labels for manual override + const pr = context.payload.pull_request; + const labels = pr.labels.map(label => label.name.toLowerCase()); + + let versionType = 'patch'; // Conservative default + let description = prTitle; + + // Manual override via labels (highest priority) + if (labels.includes('major') || labels.includes('breaking')) { + versionType = 'major'; + } else if (labels.includes('minor') || labels.includes('feature')) { + versionType = 'minor'; + } else if (labels.includes('patch') || labels.includes('bugfix')) { + versionType = 'patch'; + } else { + // Manual override via PR title syntax: [major], [minor], [patch] + const titleMatch = prTitle.match(/^\[(major|minor|patch)\]/i); + if (titleMatch) { + versionType = titleMatch[1].toLowerCase(); + description = prTitle.replace(/^\[(major|minor|patch)\]\s*/i, ''); + } else { + // Automatic detection (much more conservative) + const combined = (prTitle + ' ' + prBody + ' ' + commits).toLowerCase(); + + // Major: Only with very explicit breaking change indicators + if ((combined.includes('breaking change') || + combined.includes('breaking:') || + combined.includes('major:') || + combined.includes('!breaking') || + (combined.includes('remove') && combined.includes('api')) || + (combined.includes('delete') && combined.includes('command')))) { + versionType = 'major'; + } + // Minor: New features, commands, enhancements + else if (combined.includes('feat:') || + combined.includes('feature:') || + combined.includes('add command') || + combined.includes('new command') || + combined.includes('add feature') || + combined.includes('new feature') || + combined.includes('minor:') || + combined.includes('enhance') || + (combined.includes('add') && (combined.includes('option') || combined.includes('flag')))) { + versionType = 'minor'; + } + // Everything else defaults to patch + } + } + + // Clean up description + description = description + .replace(/^(feat|fix|docs|style|refactor|test|chore|perf)(\([^)]+\))?: /i, '') + .replace(/^(add|fix|update|remove|delete|improve|enhance): /i, '') + .replace(/^\[(major|minor|patch)\]\s*/i, '') + .trim(); + + if (!description || description.length < 5) { + description = prTitle; + } + + description = description.charAt(0).toUpperCase() + description.slice(1); + + core.setOutput('version_type', versionType); + core.setOutput('description', description); + + console.log(`Generated changeset: ${versionType} - ${description}`); + console.log(`Detection method: ${labels.length > 0 ? 'labels' : titleMatch ? 'title syntax' : 'automatic'}`); - name: Create changeset file if: steps.pr-info.outputs.skip != 'true' diff --git a/README.md b/README.md index 8b9064f..c278b55 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,31 @@ src/ This is currently a personal learning project, but feedback and suggestions are welcome! +How to control version bumps: + +### Method 1: Use GitHub Labels +Add these labels to your repository and apply them to PRs: +- `major` or `breaking` → Major version bump +- `minor` or `feature` → Minor version bump +- `patch` or `bugfix` → Patch version bump + +### Method 2: Use PR Title Syntax +Start your PR title with the version type in brackets: +- `[major] Remove deprecated API endpoints` +- `[minor] Add new search command` +- `[patch] Fix memory leak in dump command` + +### Method 3: Automatic Detection (Conservative) +The system will now only auto-detect major bumps with very explicit indicators like: +- "breaking change" +- "breaking:" +- "major:" +- "!breaking" +- "remove api" +- "delete command" + +**Everything else defaults to patch unless you have clear feature indicators for minor.** + ## License MIT diff --git a/src/commands/dump.command.ts b/src/commands/dump.command.ts index 9b9cfb0..1a5fa1e 100644 --- a/src/commands/dump.command.ts +++ b/src/commands/dump.command.ts @@ -2,10 +2,50 @@ import { randomUUID } from "crypto"; import type { BrainDump, FluxConfig } from "../types"; import { FLUX_BRAIN_DUMP_PATH, FLUX_CONFIG_PATH } from "../utils/constants"; import { createBrainDumpFileIfNotExists, getConfigFile, getCurrentBranch, getFluxPath, getGitUncommittedChanges, getMonthString, getWorkingDir } from "../utils/"; +import { editor } from '@inquirer/prompts'; -export async function brainDumpAddCommand(message: string[]) { + +export async function handleBrainDump(message: string[], options: { multiline?: boolean }) { + try { + let finalMessage: string; + + if (options.multiline) { + console.log('Opening editor for multiline input...'); + const initialText = message ? message.join(' ') : ''; + + const multilineInput = await editor({ + message: 'Enter your brain dump (save & exit when done):', + default: initialText, + waitForUserInput: false + }); + + if (!multilineInput.trim()) { + console.log('Brain dump cancelled - no content provided'); + return; + } + + finalMessage = multilineInput.trim(); + } else { + if (!message || message.length === 0) { + console.log('Please provide a message: flux dump "your message"'); + return; + } + finalMessage = message.join(' '); + } + + await brainDumpAddCommand(finalMessage, { multiline: options.multiline }); + + } catch (error) { + console.error('Error creating brain dump:', error instanceof Error ? error.message : 'Unknown error'); + process.exit(1); + } +} + + +export async function brainDumpAddCommand(message: string, options: { multiline?: boolean } = {}) { const fluxPath = await getFluxPath() const fs = await import("fs"); + console.log("Creating brain dump..."); const monthString = getMonthString(); @@ -19,20 +59,25 @@ export async function brainDumpAddCommand(message: string[]) { const newDump: BrainDump = { id: randomUUID(), timestamp: new Date().toISOString(), - message: message.join(' '), + message: message, workingDir, branch, hasUncommittedChanges }; - const data: { dumps: BrainDump[] } = JSON.parse(fs.readFileSync(`${fluxPath}${FLUX_BRAIN_DUMP_PATH}/${monthString}.json`, 'utf8')); config.sorted ? data.dumps.unshift(newDump) : data.dumps.push(newDump); fs.writeFileSync(`${fluxPath}${FLUX_BRAIN_DUMP_PATH}/${monthString}.json`, JSON.stringify(data, null, 2)); - // After writeFileSync, add: - console.log(`✅ Brain dump saved: "${message.join(' ')}"`); + const displayMessage = message.length > 50 + ? message.substring(0, 47) + "..." + : message; + + const preview = message.includes('\n') + ? `${message.split('\n')[0]}... (multiline)` + : displayMessage; + console.log(`✅ Brain dump saved: "${preview}"`); } diff --git a/src/commands/search.command.ts b/src/commands/search.command.ts index 8ed21bf..0bec7e1 100644 --- a/src/commands/search.command.ts +++ b/src/commands/search.command.ts @@ -1,5 +1,5 @@ import Fuse from "fuse.js" -import { FLUX_BRAIN_DUMP_PATH, getAllBrainDumpFilePaths, getConfigFile, getFluxPath, getMonthString, searchResultFormat } from "../utils"; +import { displaySearchResults, FLUX_BRAIN_DUMP_PATH, getAllBrainDumpFilePaths, getConfigFile, getFluxPath, getMonthString, searchResultFormat } from "../utils"; import { createFuseInstance } from "../utils/fuse.instance"; import type { BrainDump } from "../types"; import fs from "fs"; @@ -8,52 +8,46 @@ export async function searchBrainDumpCommand(query: string[]) { console.log("Searching all brain dumps..."); const fluxPath = await getFluxPath() const config = await getConfigFile(fluxPath); - const monthString = getMonthString(); + const searchQuery = query.join(' ').trim(); - let searchResults = [] + let searchResults: Array<{ item: BrainDump, score?: number }> = []; const allFilePaths = await getAllBrainDumpFilePaths(fluxPath); for await (const filePath of allFilePaths) { const fileData: { dumps: BrainDump[] } = JSON.parse(fs.readFileSync(filePath, 'utf8')); - const fuse = createFuseInstance(fileData.dumps, config); - const results = fuse.search(query.join(' ')); - searchResults.push(...results); - if (searchResults.length > 30) { - break; + + if (searchQuery) { + const fuse = createFuseInstance(fileData.dumps, config); + const results = fuse.search(searchQuery); + searchResults.push(...results); + } else { + const recentDumps = fileData.dumps + .filter(dump => dump && dump.message && dump.message.trim() !== '') + .map(dump => ({ + item: dump, + score: 0, + timestamp: new Date(dump.timestamp).getTime() + })); + + searchResults.push(...recentDumps); } } + if (searchQuery) { + searchResults.sort((a, b) => (a.score || 0) - (b.score || 0)); + } else { + searchResults.sort((a, b) => { + const timeA = new Date(a.item.timestamp).getTime(); + const timeB = new Date(b.item.timestamp).getTime(); + return timeB - timeA; + }); + } - if (query.length > 0) { - if (searchResults.length === 0) { - console.log("No brain dumps found matching the query."); - return; - } + const resultLimit = config?.search?.resultLimit || (searchQuery ? 10 : 5); + const limitedResults = searchResults.slice(0, resultLimit); - const resultLimit = config?.search?.resultLimit || 10; - const limitedResults = searchResults.slice(0, resultLimit); - console.log(`Found ${searchResults.length} brain dumps matching the query${searchResults.length > resultLimit ? ` (showing first ${resultLimit})` : ''}:`); - limitedResults.forEach((result, index) => { - const dump = result.item; - console.log(searchResultFormat({ index: index, timestamp: dump.timestamp, message: dump.message, score: result.score?.toFixed(2) })) - }); - } else { - const resultLimit = config?.search?.resultLimit || 3; - let totalCount = 0; - - for await (const filePath of allFilePaths) { - if (totalCount >= resultLimit) { - break; - } - - const fileData: { dumps: BrainDump[] } = JSON.parse(fs.readFileSync(filePath, 'utf8')); - for (let i = 0; i < fileData.dumps.length && totalCount < resultLimit; i++) { - const dump = fileData.dumps[i]; - if (!dump || !dump.message || dump.message.trim() === '') { - continue; - } - totalCount += 1; - console.log(searchResultFormat({ index: totalCount, timestamp: dump.timestamp, message: dump.message, score: '0.00' })) - } - } + if (searchResults.length > limitedResults.length) { + console.log(`\n(Showing ${limitedResults.length} of ${searchResults.length} results)`); } + + displaySearchResults(limitedResults, searchQuery || undefined); } diff --git a/src/index.ts b/src/index.ts index 450bd0e..185fa62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node import { Command } from "commander"; import { initFluxCommand, resetFluxCommand } from "./commands/init.command"; -import { brainDumpAddCommand } from "./commands/dump.command"; +import { brainDumpAddCommand, handleBrainDump } from "./commands/dump.command"; import { searchBrainDumpCommand } from "./commands/search.command"; import { configCommand } from "./commands/config.command"; import { helpOption } from "./commands/flux.option"; import packageJson from "../package.json" import { getFluxPath } from "./utils"; + const program = new Command() program.name(`flux`).description('Git-aware CLI context manager for ADHD developers').version(packageJson.version); @@ -19,9 +20,12 @@ program.command('reset') .description('Resets flux in the current repository') .action(resetFluxCommand) -program.command('dump ') - .description('Add a brain dump with a message. You can also include tags by using #tag in the message.') - .action(brainDumpAddCommand) +program.command('dump [message...]') + .option('-m, --multiline', 'Enable multiline input mode') + .description('Add a brain dump with a message. Use --multiline for multi-line input.') + .action(async (message, options) => { + await handleBrainDump(message, options); + }) program.command('search [query...]') .description('Search brain dumps with a query. If no query is provided, lists all brain dumps for the current month.') @@ -33,8 +37,5 @@ program.command('config ') .description('Update configuration fields. Example: flux config search.limit 10') .action(configCommand) -program.command("test").action(async () => { - console.log(`Path is - ${await getFluxPath()}`) -}) program.parse(process.argv); diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 2b052ed..86761d8 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -1,3 +1,6 @@ +import { brainDumpAddCommand } from "../commands/dump.command"; +import type { BrainDump } from "../types"; + export function getMonthString(): string { const currentDate = new Date(); const year = currentDate.getFullYear(); @@ -5,7 +8,119 @@ export function getMonthString(): string { return `${year}-${month}`; } + export function searchResultFormat({ message, timestamp, score, index }: { message: string, timestamp: string, score?: string, index?: number }): string { const formattedTimestamp = new Date(timestamp).toLocaleString(); - return `${index !== undefined ? index + 1 : ''}. [${score || '0.00'}] [${formattedTimestamp}] ${message}` + const indexStr = index !== undefined ? `${index + 1}.` : ''; + const scoreStr = `[${score || '0.00'}]`; + const timeStr = `[${formattedTimestamp}]`; + + const formattedMessage = formatMessageForDisplay(message); + + return `${indexStr} ${scoreStr} ${timeStr} ${formattedMessage}`; +} + +function formatMessageForDisplay(message: string): string { + const lines = message.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length <= 1) { + const singleLine = lines[0] || message; + return singleLine.length > 80 ? singleLine.substring(0, 77) + '...' : singleLine; + } + + const firstLine = lines[0]; + const truncatedFirst = firstLine?.length > 60 ? firstLine?.substring(0, 57) + '...' : firstLine; + const remainingCount = lines.length - 1; + + return `${truncatedFirst} (+${remainingCount} more line${remainingCount === 1 ? '' : 's'})`; +} + +export function displaySearchResults(results: Array<{ item: BrainDump, score?: number }>, query?: string) { + if (results.length === 0) { + if (query) { + console.log(`No brain dumps found matching "${query}"`); + } else { + console.log("No brain dumps found. Try 'flux dump' to create your first one!"); + } + return; + } + + const queryText = query ? ` for "${query}"` : ''; + console.log(`\nFound ${results.length} brain dump${results.length === 1 ? '' : 's'}${queryText}:\n`); + + + const terminalWidth = process.stdout.columns || 80; + const maxIndexWidth = results.length.toString().length; + + results.forEach((result, index) => { + const dump = result.item; + const score = result.score?.toFixed(2) || '0.00'; + const shortId = dump.id.substring(0, 8); + + + const indexStr = `${(index + 1).toString().padStart(maxIndexWidth)}`; + const scoreStr = `[${score}]`; + const idStr = `${shortId}`; + + const headerLine = `${indexStr} ${idStr} ${scoreStr}`; + console.log(headerLine); + + const messageIndent = ' '.repeat(maxIndexWidth + 1); + + + const lines = dump.message.split('\n').map(l => l.trim()).filter(l => l.length > 0); + const availableWidth = terminalWidth - messageIndent.length - 2; + + if (lines.length === 0) { + console.log(`${messageIndent}(empty message)`); + } else { + lines.forEach((line, lineIndex) => { + if (lineIndex < 3) { + const truncatedLine = line.length > availableWidth + ? line.substring(0, availableWidth - 3) + '...' + : line; + console.log(`${messageIndent}${truncatedLine}`); + } + }); + + if (lines.length > 3) { + console.log(`${messageIndent}... (+${lines.length - 3} more line${lines.length - 3 === 1 ? '' : 's'})`); + } + } + + const contextInfo = []; + const date = new Date(dump.timestamp); + const timeAgo = getTimeAgo(date); + contextInfo.push('----------------'); + contextInfo.push(`${timeAgo}`); + contextInfo.push('----------------'); + contextInfo.push('\n'); + + if (dump.branch && dump.branch !== 'main') { + contextInfo.push(`${dump.branch}${dump.hasUncommittedChanges ? ' (uncommitted)' : ''}`); + } + + if (contextInfo.length > 0) { + console.log(`${messageIndent}${contextInfo.join(' • ')}`); + } + + console.log(''); + }); + + console.log(`!! Use the 8-character ID (like ${results[0]?.item.id.substring(0, 8)}) to reference specific dumps\n`); +} + + +function getTimeAgo(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); } diff --git a/src/utils/lib.ts b/src/utils/lib.ts index 30593b4..b360adf 100644 --- a/src/utils/lib.ts +++ b/src/utils/lib.ts @@ -11,7 +11,6 @@ export async function getFluxPath() { let fullPath = cwd.split(path.sep); while (true) { let parentPath = fullPath.join(path.sep) + "/.flux" - console.log(`testing ${parentPath}`) if (fs.existsSync(parentPath)) { return parentPath.split(".flux")[0] break;