rigjs
Version:
A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.
389 lines (364 loc) • 12.3 kB
text/typescript
import fs from 'fs';
import path from 'path';
import print from '../print';
import { CrewEntry, CrewProject, requireCrew, shortPath } from './config';
import { rootPath, writeText, readText } from './vault';
export interface PendingQuestion {
id: string;
title: string;
asked: string; // ISO date (YYYY-MM-DD)
askedBy?: string;
priority?: 'high' | 'medium' | 'low' | string;
why?: string;
need?: string;
notes?: string;
status: 'open' | 'resolved';
resolved?: string; // ISO date when resolved
answer?: string;
bodyExtras?: string; // extra free-form body lines we want to preserve
}
interface PendingListOpts {
crew?: string;
project?: string;
all?: boolean;
json?: boolean;
}
interface PendingAddOpts {
crew?: string;
project?: string;
why?: string;
need?: string;
priority?: string;
askedBy?: string;
}
interface PendingAnswerOpts {
crew?: string;
project?: string;
note?: string;
}
interface PendingRemoveOpts {
crew?: string;
project?: string;
}
export function pendingList(opts: PendingListOpts): void {
const crew = requireCrew(opts.crew);
const targets = resolveTargets(crew, opts.project);
const includeResolved = !!opts.all;
const result: { project: string; file: string; questions: PendingQuestion[] }[] = [];
for (const project of targets) {
const file = pendingFile(crew, project.name);
const questions = readQuestions(file)
.filter(q => includeResolved || q.status === 'open');
result.push({ project: project.name, file, questions });
}
if (opts.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ok: true, data: result }, null, 2));
return;
}
const total = result.reduce((sum, r) => sum + r.questions.length, 0);
if (total === 0) {
print.info(includeResolved
? 'no pending questions found.'
: 'no open pending questions. Use --all to include resolved.');
return;
}
print.info(`pending questions${includeResolved ? ' (all)' : ' (open)'}: ${total}`);
for (const block of result) {
if (block.questions.length === 0) continue;
print.info(`project: ${block.project} file: ${shortPath(block.file)}`);
for (const q of block.questions) {
const tag = q.status === 'resolved' ? '[resolved]' : `[${q.priority || 'open'}]`;
// eslint-disable-next-line no-console
console.log(`- ${q.id} ${tag} ${q.title}`);
if (q.why) {
// eslint-disable-next-line no-console
console.log(` why: ${q.why}`);
}
if (q.need) {
// eslint-disable-next-line no-console
console.log(` need: ${q.need}`);
}
if (q.answer) {
// eslint-disable-next-line no-console
console.log(` answer: ${q.answer}`);
}
}
}
}
export function pendingAdd(titleParts: string[] | undefined, opts: PendingAddOpts): void {
const crew = requireCrew(opts.crew);
const project = resolveSingleProject(crew, opts.project, 'add');
const title = (titleParts || []).join(' ').trim();
if (!title) {
print.error('missing question title. Usage: rig crew pending add "<title>" --project <name>');
process.exit(1);
}
const file = pendingFile(crew, project.name);
const questions = readQuestions(file);
const id = nextQuestionId(questions);
const today = isoDate(new Date());
const question: PendingQuestion = {
id,
title,
asked: today,
askedBy: opts.askedBy,
priority: opts.priority,
why: opts.why,
need: opts.need,
status: 'open',
};
questions.push(question);
writeQuestions(file, project, questions);
print.succeed(`added ${id} to ${shortPath(file)}`);
}
export function pendingAnswer(id: string, opts: PendingAnswerOpts): void {
const crew = requireCrew(opts.crew);
const located = findQuestion(crew, id, opts.project);
if (!located) {
print.error(`question not found: ${id}`);
process.exit(1);
}
const { project, questions, index } = located;
const question = questions[index];
question.status = 'resolved';
question.resolved = isoDate(new Date());
if (opts.note) question.answer = opts.note;
writeQuestions(pendingFile(crew, project.name), project, questions);
print.succeed(`resolved ${id} in project ${project.name}`);
}
export function pendingRemove(id: string, opts: PendingRemoveOpts): void {
const crew = requireCrew(opts.crew);
const located = findQuestion(crew, id, opts.project);
if (!located) {
print.error(`question not found: ${id}`);
process.exit(1);
}
const { project, questions, index } = located;
questions.splice(index, 1);
writeQuestions(pendingFile(crew, project.name), project, questions);
print.succeed(`removed ${id} from project ${project.name}`);
}
export function pendingFile(crew: CrewEntry, project: string): string {
return rootPath(crew, path.join('Projects', project, 'Pending-Questions.md'));
}
function resolveTargets(crew: CrewEntry, projectName?: string): CrewProject[] {
if (projectName) {
const project = (crew.projects || []).find(p => p.name === projectName);
if (!project) {
print.error(`unknown project: ${projectName}`);
process.exit(1);
}
return [project];
}
return crew.projects || [];
}
function resolveSingleProject(crew: CrewEntry, projectName: string | undefined, action: string): CrewProject {
const name = projectName || autoProject(crew);
if (!name) {
print.error(`cannot determine project for "${action}". Use --project <name>.`);
process.exit(1);
}
const project = (crew.projects || []).find(p => p.name === name);
if (!project) {
print.error(`unknown project: ${name}`);
process.exit(1);
}
return project;
}
function autoProject(crew: CrewEntry): string | undefined {
const cwd = process.cwd();
const match = (crew.projects || []).find(p => isInside(cwd, p.path));
return match?.name;
}
function findQuestion(
crew: CrewEntry,
id: string,
projectName?: string,
): { project: CrewProject; questions: PendingQuestion[]; index: number } | undefined {
const targets = resolveTargets(crew, projectName);
for (const project of targets) {
const file = pendingFile(crew, project.name);
const questions = readQuestions(file);
const index = questions.findIndex(q => q.id === id);
if (index >= 0) return { project, questions, index };
}
return undefined;
}
function isInside(child: string, parent: string): boolean {
const rel = path.relative(path.resolve(parent), path.resolve(child));
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
}
const HEADER_LINE = '<!-- rig-crew-pending:v1 -->';
const OPEN_HEADING = '## Open';
const RESOLVED_HEADING = '## Resolved';
export function readQuestions(file: string): PendingQuestion[] {
if (!fs.existsSync(file)) return [];
const text = readText(file);
const lines = text.split(/\r?\n/);
const questions: PendingQuestion[] = [];
let currentStatus: 'open' | 'resolved' = 'open';
let buffer: string[] = [];
let buffering = false;
const flush = () => {
if (!buffering) return;
const parsed = parseQuestionBlock(buffer, currentStatus);
if (parsed) questions.push(parsed);
buffer = [];
buffering = false;
};
for (const line of lines) {
if (line.trim() === OPEN_HEADING) {
flush();
currentStatus = 'open';
continue;
}
if (line.trim() === RESOLVED_HEADING) {
flush();
currentStatus = 'resolved';
continue;
}
if (/^###\s+/.test(line)) {
flush();
buffering = true;
buffer.push(line);
continue;
}
if (buffering) buffer.push(line);
}
flush();
return questions;
}
function parseQuestionBlock(lines: string[], status: 'open' | 'resolved'): PendingQuestion | undefined {
if (lines.length === 0) return undefined;
const heading = lines[0].replace(/^###\s+/, '').trim();
const m = heading.match(/^([A-Z]+-\d{6}-\d{3})\s*[—-]?\s*(.*)$/);
if (!m) return undefined;
const id = m[1];
let title = m[2].trim();
const resolvedMark = title.match(/_\(resolved\s+(\d{4}-\d{2}-\d{2})\)_\s*$/);
let resolved: string | undefined;
if (resolvedMark) {
resolved = resolvedMark[1];
title = title.slice(0, resolvedMark.index).trim();
}
const question: PendingQuestion = {
id,
title,
asked: '',
status,
};
if (resolved) {
question.status = 'resolved';
question.resolved = resolved;
}
const extras: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const bullet = line.match(/^\s*-\s+([A-Za-z][A-Za-z ]+?):\s+(.*)$/);
if (bullet) {
const key = bullet[1].toLowerCase().replace(/\s+/g, '-');
const value = bullet[2].trim();
switch (key) {
case 'asked':
question.asked = value;
break;
case 'asked-by':
question.askedBy = value;
break;
case 'priority':
question.priority = value;
break;
case 'why-needed':
case 'why':
question.why = value;
break;
case 'what-to-provide':
case 'need':
question.need = value;
break;
case 'notes':
question.notes = value;
break;
case 'resolved':
question.resolved = value;
question.status = 'resolved';
break;
case 'answer':
question.answer = value;
break;
default:
extras.push(line);
}
} else if (line.trim().length > 0) {
extras.push(line);
}
}
if (extras.length > 0) question.bodyExtras = extras.join('\n').trim();
return question;
}
function writeQuestions(file: string, project: CrewProject, questions: PendingQuestion[]): void {
const open = questions.filter(q => q.status === 'open');
const resolved = questions.filter(q => q.status === 'resolved');
const sections: string[] = [
`# ${project.name} Pending Questions`,
'',
HEADER_LINE,
'',
'Materials / facts / decisions the user must supply before the crew can proceed.',
'Add with `rig crew pending add "<title>" --project <name>`; resolve with `rig crew pending answer <id> --note "..."`.',
'',
OPEN_HEADING,
'',
];
if (open.length === 0) {
sections.push('_No open questions._');
sections.push('');
} else {
for (const q of open) sections.push(renderQuestion(q), '');
}
sections.push(RESOLVED_HEADING, '');
if (resolved.length === 0) {
sections.push('_No resolved questions yet._');
sections.push('');
} else {
for (const q of resolved) sections.push(renderQuestion(q), '');
}
writeText(file, sections.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n');
}
function renderQuestion(q: PendingQuestion): string {
const headingExtra = q.status === 'resolved' && q.resolved ? ` _(resolved ${q.resolved})_` : '';
const lines: string[] = [`### ${q.id} — ${q.title}${headingExtra}`];
if (q.asked) lines.push(`- Asked: ${q.asked}`);
if (q.askedBy) lines.push(`- Asked by: ${q.askedBy}`);
if (q.priority) lines.push(`- Priority: ${q.priority}`);
if (q.why) lines.push(`- Why needed: ${q.why}`);
if (q.need) lines.push(`- What to provide: ${q.need}`);
if (q.notes) lines.push(`- Notes: ${q.notes}`);
if (q.status === 'resolved') {
if (q.resolved && !q.asked) lines.push(`- Resolved: ${q.resolved}`);
if (q.answer) lines.push(`- Answer: ${q.answer}`);
}
if (q.bodyExtras) lines.push('', q.bodyExtras);
return lines.join('\n');
}
function nextQuestionId(questions: PendingQuestion[]): string {
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const prefix = `Q-${yy}${mm}${dd}-`;
let max = 0;
for (const q of questions) {
if (!q.id.startsWith(prefix)) continue;
const suffix = parseInt(q.id.slice(prefix.length), 10);
if (!Number.isNaN(suffix) && suffix > max) max = suffix;
}
return `${prefix}${String(max + 1).padStart(3, '0')}`;
}
function isoDate(d: Date): string {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}