diff --git a/.gitignore b/.gitignore index e8cc7606..056f60e5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tmp .env .DS_Store debug.md + +backlog/.imdone +backlog/stories diff --git a/README.md b/README.md index 29b417d3..1eeaa650 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Downloads](https://img.shields.io/npm/dm/imdone-core.svg)](https://npmjs.org/package/imdone-core) [![Build Status](https://github.com/imdone/imdone-core/actions/workflows/ci.yml/badge.svg) -Imdone is text based kanban processor with a simple syntax that uses [comment tags like TODO and FIXME](https://en.wikipedia.org/wiki/Comment_%28computer_programming%29#Tags) and [todo.txt format](https://github.com/todotxt/todo.txt#todotxt-format). This model allows the user to create and modify tasks using the keyboard and automatically establishes a link between their tasks and work. Get [imdone](https://imdone.io) to see your projects board and this library in action. +Imdone is text based kanban processor with a simple syntax that uses [comment tags like TODO and FIXME](https://en.wikipedia.org/wiki/Comment_%28computer_programming%29#Tags) and [todo.txt format](https://github.com/todotxt/todo.txt#todotxt-format). This model allows the user to create and modify tasks using the keyboard and automatically establishes a link between their tasks and work. Get [imdone](https://imdone.io) or use the cli to see your projects board and this library in action. ![imdone-screenshot.png (5120×2838)](https://imdone.io/docs/images/card-anatomy.png) diff --git a/backlog/.imdone/config.yml b/backlog/.imdone/config.yml deleted file mode 100644 index cf028d06..00000000 --- a/backlog/.imdone/config.yml +++ /dev/null @@ -1,58 +0,0 @@ -keepEmptyPriority: true -code: - include_lists: - - TODO - - DOING - - DONE - - PLANNING - - FIXME - - ARCHIVE - - HACK - - CHANGED - - XXX - - IDEA - - NOTE - - REVIEW -lists: - - hidden: false - name: TODO - id: jea6phpllxpsj7j - - hidden: false - name: DOING - id: jea6phpllxpsj7k - - hidden: false - ignore: true - name: DONE - id: jea6phpllxpsj7l -settings: - openIn: default - customOpenIn: '' - editorTheme: blackboard - journalType: New File - journalPath: . - appendNewCardsTo: imdone-tasks.md - newCardSyntax: HASHTAG - replaceSpacesWith: '-' - plugins: - devMode: false - journalTemplate: null - theme: dark - views: [] - name: backlog - cards: - colors: [] - template: | - - - trackChanges: false - metaNewLine: false - addCompletedMeta: false - addCheckBoxTasks: false - doneList: DONE - taskPrefix: '##' - tagPrefix: + - metaSep: ':' - orderMeta: true - maxLines: 6 - addNewCardsToTop: true - defaultList: TODO diff --git a/backlog/.imdone/properties/card.js b/backlog/.imdone/properties/card.js deleted file mode 100644 index e587d64d..00000000 --- a/backlog/.imdone/properties/card.js +++ /dev/null @@ -1,17 +0,0 @@ -const generateRandomString = (length) => { - let result = ''; - const characters = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; -}; - -module.exports = function (task) { - const project = this.project - return { - sid: generateRandomString(8) - } -} \ No newline at end of file diff --git a/backlog/README.md b/backlog/README.md new file mode 100644 index 00000000..02c21e83 --- /dev/null +++ b/backlog/README.md @@ -0,0 +1,139 @@ +imdone backlog +==== + +This directory contains folders for each backlog item. + +## Work on a story +### After collaborative design, import a story and story tasks from markdown +```bash +npx imdone import <` should be the markdown title +- [x] On import always remove the contents of the `backlog/story/` +- [x] Shold handle a file with the following format +- [x] Make sure checked items are put in DONE list +```markdown +# + +This is the story summary + +## Tasks +- [ ] An ungrouped task + +### +- [ ] A task in a group + +### +- [ ] A task in a group +``` +- [x] use `markdown-it.parse` to create AST +- [x] Save story-id project path so it's available for starting a task + +### Day to day work after collaborative story design + +#### Start a task +```bash +npx imdone start +``` +- [ ] This should find the task and create a branch named `story////` +- [ ] Move the task to the `DOING` list +- [ ] If the branch exists, check it out +- [ ] Set the task id in session so we know what to close +- [ ] Save the branch name in session so we can check it out again + +#### Add breadcrumbs for the next developer or ensemble +1. Open the file with the same name as the branch under backlog and add content!!! +2. commit and push the branch or run `mob done` + +#### Complete a task +```bash +npx imdone done +``` + +#### List tasks in a story +```bash +npx imdone ls -p backlog -s +``` +- [ ] `./backlog` is the default project folder +- [ ] If the current branch starts with `story/`, parse the story id from the branch name +- [ ] If story from branch name isn't found and If the storyId is in the session use it +- [ ] Can also pass in the story id with the `-s option +- [ ] Can also use the filter option + +#### List tasks in a story and group +```bash +npx imdone ls -p backlog -s -g +``` +- [ ] `./backlog` is the default project folder +- [ ] If the current branch starts with `story/`, parse the story id and group from the branch name +- [ ] Can also use the filter option + +#### Update task + +##### With the CLI +```bash +npx imdone update task -p backlog -g +``` +- [ ] This should move a task to a different group and/or change it's text + +## Adding tasks without import + +### Initializing a backlog +```bash +npx imdone init -p backlog +``` +- [ ] `./backlog` is the default project folder +- [ ] Make `devops/imdone` the defaults for init + +### Add a story +Run this from the root of the project to add a story +```bash +npx imdone add story -p backlog -s -l BACKLOG "Add a story from the command line" +``` +- [ ] `./backlog` is the default project folder +- [ ] This should initialize a new imdone project in `backlog/` +- [ ] Create a task for the story and return the meta sid +- [ ] The task should be in `backlog/story//README.md` +- [ ] The task should have `task-id:` meta and `story` tag + +### Add a story task +```bash +npx imdone add task -p backlog -s -l TODO "Add a story task from the command line" +``` +- [ ] `./backlog` is the default project folder +- [ ] This should initialize a new imdone project in `backlog/story//ungrouped`, containing a task with `task-id:` and `story-id:` meta and return the +- [ ] use default list if no list is present + +### List task groups for a story +Use ls to list the directories in `backlog/story/` + +### Add a story task to a group +```bash +npx imdone add task -p backlog -s -g "" -l TODO "Add a story task with group from the command line" +``` +- [ ] `./backlog` is the default project folder +- [ ] This should initialize a new imdone project in `backlog/story//`, containing a task with `story-id:`, `group:` and `task-id:` meta and return the +- [ ] use default list if no list is present + +#### With the UI +- Open the story folder as a project in imdone +- Under board actions select the **Create task in ** from the menu + diff --git a/cli.js b/cli.js index c629ea02..6dd3cb4d 100755 --- a/cli.js +++ b/cli.js @@ -1,43 +1,87 @@ #!/usr/bin/env node const { program } = require('commander'); -const { imdoneInit, addTask, listTasks } = require('./lib/controlers/CliControler') +const { + DEFAULT_CONFIG_PATH, + imdoneInit, + importMarkdown, + startTask, + addTask, + listTasks +} = require('./lib/cli/CliControler') const package = require('./package.json') +const path = require('path') const { log, info, warn, logQueue } = hideLogs() +const PROJECT_OPTION = '-p, --project-path ' +const PROJECT_OPTION_DESCRIPTION = 'The path to the imdone project' +const CONFIG_OPTION = '-c, --config-path ' +const CONFIG_OPTION_DESCRIPTION = 'The path to the imdone config file' -// TODO ## Add an option to add properties/card.js program .version(package.version, '-v, --version', 'output the current version') .command('init') .description('initialize imdone project') -.option('-p, --project-path ', 'The path to the imdone project') -.option('-c, --config-path ', 'The path to the imdone config file') +.option(PROJECT_OPTION, PROJECT_OPTION_DESCRIPTION) +.option(CONFIG_OPTION, CONFIG_OPTION_DESCRIPTION, DEFAULT_CONFIG_PATH) .action(async function () { - let { projectPath = process.env.PWD, configPath } = this.opts() - await imdoneInit(projectPath, configPath) + let { projectPath, configPath } = this.opts() + await imdoneInit({projectPath, configPath}) +}) + +program +.command('import') +.description('import markdown from STDIN') +.option(PROJECT_OPTION, PROJECT_OPTION_DESCRIPTION) +.action(async function () { + let { projectPath } = this.opts() + const isTTY = process.stdin.isTTY; + const stdin = process.stdin; + if (isTTY) return console.error('Markdown must be provided as stdin') + + var markdown = ''; + + stdin.on('readable', function() { + var chunk = stdin.read(); + if(chunk !== null){ + markdown += chunk; + } + }); + stdin.on('end', async function() { + await importMarkdown(projectPath, markdown, log) + }); +}) + +program +.command('start ') +.description('start a task by id') +.option(PROJECT_OPTION, PROJECT_OPTION_DESCRIPTION) +.action(async function () { + const taskId = this.args[0] + let { projectPath } = this.opts() + await startTask(projectPath, taskId, log) }) program .command('add ') .description('add a task') -.option('-p, --project-path ', 'The path to the imdone project') +.option(PROJECT_OPTION, PROJECT_OPTION_DESCRIPTION) .option('-l, --list ', 'The task list to use') .option('-t, --tags ', 'The tags to use') .option('-c, --contexts ', 'The contexts to use') .action(async function () { - let { projectPath = process.env.PWD, list, tags, contexts } = this.opts() - await addTask(this.args[0], projectPath, list, tags, contexts) + let { projectPath, list, tags, contexts } = this.opts() + await addTask({task: this.args[0], projectPath, list, tags, contexts, log}) }) program .command('ls') .description('list tasks') -.option('-p, --project-path ', 'The path to the imdone project') +.option(PROJECT_OPTION, PROJECT_OPTION_DESCRIPTION) .option('-f, --filter ', 'The filter to use') .option('-j, --json', 'Output as json') .action(async function () { - let { projectPath = process.env.PWD, filter, json } = this.opts() + let { projectPath, filter, json } = this.opts() await listTasks(projectPath, filter, json, log) }) program.parse(); diff --git a/devops/imdone/actions/board.js b/devops/imdone/actions/board.js new file mode 100644 index 00000000..2090f6b7 --- /dev/null +++ b/devops/imdone/actions/board.js @@ -0,0 +1,19 @@ +module.exports = function (task) { + const project = this.project + const groups = [...new Set( + project.getCards('meta.group = *').map((card) => card.meta.group && card.meta.group[0]) + )].map(group => { + const name = group + const value = `"${group}"` + return { name, value} + }) + return [{name: 'All tasks', value: '*'}, ...groups].map((group) => { + const filterValue = encodeURIComponent(`meta.group = ${group.value} or tags = story`) + return { + title: group.name, + action: function () { + project.openUrl(`imdone://active.repo?filter=${filterValue}`) + } + } + }) +} \ No newline at end of file diff --git a/devops/imdone/actions/card.js b/devops/imdone/actions/card.js new file mode 100644 index 00000000..ceda8c67 --- /dev/null +++ b/devops/imdone/actions/card.js @@ -0,0 +1,4 @@ +module.exports = function (task) { + const project = this.project + return [] +} \ No newline at end of file diff --git a/devops/imdone/config.yml b/devops/imdone/config.yml index cf028d06..71c77b75 100644 --- a/devops/imdone/config.yml +++ b/devops/imdone/config.yml @@ -1,6 +1,7 @@ keepEmptyPriority: true code: include_lists: + - NOTE - TODO - DOING - DONE @@ -13,7 +14,16 @@ code: - IDEA - NOTE - REVIEW + - BACKLOG lists: + - hidden: false + ignore: false + name: NOTE + id: jea6phpllxpsj7m + - name: BACKLOG + hidden: false + ignore: false + id: jea67colm3nm22s - hidden: false name: TODO id: jea6phpllxpsj7j @@ -21,7 +31,7 @@ lists: name: DOING id: jea6phpllxpsj7k - hidden: false - ignore: true + ignore: false name: DONE id: jea6phpllxpsj7l settings: @@ -43,7 +53,7 @@ settings: colors: [] template: | - + trackChanges: false metaNewLine: false addCompletedMeta: false diff --git a/devops/imdone/properties/card.js b/devops/imdone/properties/card.js index e587d64d..536ed2fc 100644 --- a/devops/imdone/properties/card.js +++ b/devops/imdone/properties/card.js @@ -12,6 +12,6 @@ const generateRandomString = (length) => { module.exports = function (task) { const project = this.project return { - sid: generateRandomString(8) + sid: generateRandomString(5) } } \ No newline at end of file diff --git a/lib/cli/CliControler.js b/lib/cli/CliControler.js new file mode 100644 index 00000000..b1312ea9 --- /dev/null +++ b/lib/cli/CliControler.js @@ -0,0 +1,225 @@ +const { resolve } = path = require('path') +const eol = require('eol') +const { readFile, mkdir, rm, cp } = require('fs/promises') +const {simpleGit} = require('simple-git') +const { loadYAML } = require('../tools') +const Config = require('../config') +const { createFileSystemProject } = require('../project-factory') +const parseMarkdownStory = require('./MarkdownStoryParser') +const { + setProjectPath, + getProjectPath, + setTaskId, + getTaskId, + setStoryId, + getStoryId, + setBranchName +} = require('./StoryTaskSession') + +const DEFAULT_PROJECT_DIR = 'backlog' +const STORIES_DIR = 'stories' +const TASKS_DIR = 'tasks' +const TASK_ID = 'task-id' +const STORY_ID = 'story-id' +const TODO = 'TODO' +const DOING = 'DOING' +const DONE = 'DONE' +const ORDER = 'order' +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'devops', 'imdone', 'config.yml') +const CARD_PROPERTIES_PATH = path.join(__dirname, '..', '..', 'devops', 'imdone', 'properties') +const ACTIONS_PATH = path.join(__dirname, '..', '..', 'devops', 'imdone', 'actions') +module.exports = CliController = { + git: simpleGit, +} + +const defaultProjectPath = path.join(process.env.PWD, DEFAULT_PROJECT_DIR) + +CliController.DEFAULT_PROJECT_DIR = DEFAULT_PROJECT_DIR +CliController.DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_PATH +CliController.imdoneInit = async function ({projectPath = defaultProjectPath, configPath = DEFAULT_CONFIG_PATH, tasksDir = STORIES_DIR}) { + projectPath = resolve(projectPath) + configPath = resolve(configPath) + + await cp(CARD_PROPERTIES_PATH, path.join(projectPath, '.imdone', 'properties'), { recursive: true }) + await cp(ACTIONS_PATH, path.join(projectPath, '.imdone', 'actions'), { recursive: true }) + await mkdir(path.join(projectPath, tasksDir), {recursive: true}) + + const config = await newConfigFromFile(configPath) + config.name = path.basename(projectPath) + config.journalPath = tasksDir + + return await init(projectPath, config) +} + +CliController.addTask = async function ({task, projectPath = defaultProjectPath, list, tags, contexts, log}) { + const project = await init(projectPath) + const file = await project.addTaskToFile({list, content: task, tags, contexts}) + file.rollback() + .extractTasks(project.config) + log(file.tasks[0].meta[TASK_ID][0]) +} + +CliController.listTasks = async function (projectPath = defaultProjectPath, filter, json, log) { + const project = await init(projectPath) + const tasks = project.getCards(filter) + const lists = project.getLists({tasks}) + + if (json) return log(JSON.stringify(lists, null, 2)) + + if (project.getCards('meta.group=*', tasks).length > 0) { + const groupedTasks = getGroupedTasks(tasks) + logProject(log, project) + logGroupedTasks(log, project, groupedTasks) + } else { + logProject(log, project) + logLists(log, project, lists, '##') + } +} + +CliController.importMarkdown = async function(projectPath = defaultProjectPath, markdown, log) { + const project = await CliController.imdoneInit({projectPath}) + const { storyId, description, tasks } = parseMarkdownStory(markdown) + const storyProject = await createStoryProject(projectPath, storyId, log) + setProjectPath(storyProject.path) + await addStoryTask(storyProject, storyId, eol.split(description)[0], tasks, log) + + tasks.forEach(async (task, i) => { + const order = (i + 1) * (10) + const list = task.done ? DONE : TODO + const file = await storyProject.addTaskToFile({list, tags: ['task'], content: task.text}) + file.rollback() + .extractTasks(project.config) + + await storyProject.addMetadata(file.tasks[0], 'group', task.group) + await storyProject.addMetadata(file.tasks[0], STORY_ID, storyId) + await storyProject.addMetadata(file.tasks[0], ORDER, order) + }) +} + +// #### Start a task +// ```bash +// npx imdone start +// ``` +// - [x] This should find the task and create a branch named `story////` +// - [x] Move the task to the `DOING` list +// - [x] If the branch exists, check it out +// - [x] Set the task id in session so we know what to close +// - [x] Save the branch name in session so we can check it out again +// - [ ] Pull the branch if it exists +CliController.startTask = async function (projectPath = defaultProjectPath, taskId, log) { + const project = await init(projectPath) + const task = project.getCards().find(({meta}) => meta[TASK_ID] && meta[TASK_ID][0] === taskId) + const storyId = task.meta[STORY_ID][0] + const taskName = project.sanitizeFileName(task.text) + const branchName = `story/${storyId}/task/${taskName}` + + await setProjectPath(getStoryProjectPath(projectPath, storyId)) + await setTaskId(taskId) + await setStoryId(storyId) + await setBranchName(branchName) + + project.moveTask(task, DOING, 0) + + const git = CliController.git() + await git.fetch() + const branches = await git.branchLocal() + + if (branches.current !== branchName) { + if (!branches.all.includes(branchName)) await git.checkoutBranch(branchName, 'HEAD') + else await git.checkout(branchName) + } + + const remoteBranches = await git.branch(['-r']) + if (remoteBranches.all.includes(`origin/${branchName}`)) await git.pull() +} + +async function init(projectPath, config) { + projectPath = resolve(projectPath) + const project = createFileSystemProject({path: projectPath, config}) + await project.init() + return project +} + +async function addStoryTask(storyProject, storyId, description) { + const storyPath = path.join(storyProject.path, 'README.md') + const file = await storyProject.addTaskToFile({ path: storyPath, list: 'NOTE', tags: ['story'], content: description }) + file.rollback() + .extractTasks(storyProject.config) + await storyProject.addMetadata(file.tasks[0], ORDER, 0) + await storyProject.addMetadata(file.tasks[0], STORY_ID, storyId) +} + +async function createStoryProject(projectPath, storyId, log) { + const storyProjectPath = getStoryProjectPath(projectPath, storyId) + try { + await rm(storyProjectPath, { recursive: true }) + await mkdir(storyProjectPath, { recursive: true }) + } catch (e) { + log(`${e.code}: ${e.path}`) + } + const tasksDir = TASKS_DIR + const storyProject = await CliController.imdoneInit({ projectPath: storyProjectPath, tasksDir }) + storyProject.removeList('BACKLOG') + return storyProject +} + +function getStoryProjectPath(projectPath, storyId) { + return path.join(projectPath, STORIES_DIR, storyId) +} + +async function newConfigFromFile(configPath) { + const config = await readFile(configPath, 'utf8') + return new Config(loadYAML(config)) +} + +function getGroupedTasks(tasks) { + const groupedTasks = {"Stories": []} + tasks.forEach((task) => { + const group = task.meta.group + if (group) { + if (!groupedTasks[group]) groupedTasks[group] = [] + groupedTasks[group].push(task) + } else { + groupedTasks["Stories"].push(task) + } + }) + return groupedTasks +} + +function logGroupedTasks(log, project, groupedTasks) { + Object.keys(groupedTasks).forEach((group) => { + log(`## ${group}`) + log('') + logLists(log, project, project.getLists({tasks: groupedTasks[group]}), '###') + }) +} + +function logLists(log, project, lists, heading) { + lists.forEach((list) => { + const tasks = list.tasks + if (tasks.length > 0) { + log(`${heading} ${list.name}`) + log('') + logTasks(log, project, tasks) + log('') + } + }) +} + +function logTasks(log, project, tasks) { + tasks.forEach(task => logTask(log, project, task)) +} + +function logProject(log, project) { + log(project.name) + log('====') + log('') +} + +function logTask(log, project, task) { + const doneMark = (task.list === project.config.getDoneList()) ? 'x' : ' ' + log(`- [${doneMark}] ${task.text}`) + task.description.forEach((line) => { + log(` ${line}`) + }) +} diff --git a/lib/cli/StoryTaskSession.js b/lib/cli/StoryTaskSession.js new file mode 100644 index 00000000..a2e7a7d9 --- /dev/null +++ b/lib/cli/StoryTaskSession.js @@ -0,0 +1,73 @@ +const path = require('path') +const {readFile, writeFile, mkdir, access} = require('fs/promises') +const homeDir = require('os').homedir() +const sessionPath = path.join(homeDir, '.imdone', 'session.json') + +async function ensureSessionFileExists() { + try { + await access(path.dirname(sessionPath)) + } catch (error) { + if (error.code === 'ENOENT') { + await mkdir(path.dirname(sessionPath), {recursive: true}) + await writeFile(sessionPath, '{}') + } + } +} + +async function getUserSession() { + await ensureSessionFileExists() + const session = await readFile(sessionPath, 'utf8') + return JSON.parse(session) +} + +async function saveUserSession(session) { + await ensureSessionFileExists() + await writeFile(sessionPath, JSON.stringify(session, null, 2)) +} + +const StoryTaskSession = {} + +StoryTaskSession.setProjectPath = async function (projectPath) { + const session = await getUserSession() + session.projectPath = projectPath + await saveUserSession(session) +} + +StoryTaskSession.getProjectPath = async function () { + const session = await getUserSession() + return session.projectPath +} + +StoryTaskSession.setTaskId = async function (taskId) { + const session = await getUserSession() + session.taskId = taskId + await saveUserSession(session) +} + +StoryTaskSession.getTaskId = async function () { + const session = await getUserSession() + return session.taskId +} + +StoryTaskSession.setStoryId = async function (storyId) { + const session = await getUserSession() + session.storyId = storyId + await saveUserSession(session) +} + +StoryTaskSession.getStoryId = async function () { + const session = await getUserSession() + return session.storyId +} + +StoryTaskSession.setBranchName = async function (branchName) { + const session = await getUserSession() + session.branchName = branchName +} + +StoryTaskSession.getBranchName = async function () { + const session = await getUserSession() + return session.branchName +} + +module.exports = StoryTaskSession \ No newline at end of file diff --git a/lib/cli/__tests__/markdownStoryParser.spec.js b/lib/cli/__tests__/markdownStoryParser.spec.js new file mode 100644 index 00000000..56bb26dc --- /dev/null +++ b/lib/cli/__tests__/markdownStoryParser.spec.js @@ -0,0 +1,41 @@ +const parse = require("../MarkdownStoryParser") +const expect = require("chai").expect +describe('markdownStoryParser', () => { + it('should parse a markdown story name', () => { + const markdown = `# story-id + +This is the story description. + +## Tasks +- [ ] An unfinished task + +### Phase one (Interfaces) +- [x] A task in phase one +- [ ] Another task in phase one + - [ ] A sub task in phase one + Some more data about the task + +### Phase two (Implementation) +- [ ] A task in phase two +` + const { storyId, description, tasks } = parse(markdown) + expect(storyId).to.equal('story-id') + expect(description).to.equal('This is the story description.') + expect(tasks.length).to.equal(5) + expect(tasks[0].text).to.equal('An unfinished task') + expect(tasks[0].group).to.equal('Ungrouped Tasks') + expect(tasks[0].done).to.equal(false) + expect(tasks[1].text).to.equal('A task in phase one') + expect(tasks[1].group).to.equal('Phase one (Interfaces)') + expect(tasks[1].done).to.equal(true) + expect(tasks[2].text).to.equal('Another task in phase one') + expect(tasks[2].group).to.equal('Phase one (Interfaces)') + expect(tasks[2].done).to.equal(false) + expect(tasks[3].text).to.equal("A sub task in phase one\nSome more data about the task") + expect(tasks[3].group).to.equal('Phase one (Interfaces)') + expect(tasks[3].done).to.equal(false) + expect(tasks[4].text).to.equal('A task in phase two') + expect(tasks[4].group).to.equal('Phase two (Implementation)') + expect(tasks[4].done).to.equal(false) + }) +}) \ No newline at end of file diff --git a/lib/cli/markdownStoryParser.js b/lib/cli/markdownStoryParser.js new file mode 100644 index 00000000..5d2131e7 --- /dev/null +++ b/lib/cli/markdownStoryParser.js @@ -0,0 +1,80 @@ +const { parseMD } = require('../tools') + +function isOpen(token, type, tag = token.tag) { + return token.type === type + '_open' && token.tag === tag +} + +function isClose(token, type, tag = token.tag) { + return token.type === type + '_close' && token.tag === tag +} + +function getHeadingLevel(tag) { + return parseInt(tag.substring(1), 10) +} + +module.exports = function parse(markdown) { + const heading = 'heading' + const paragraph = 'paragraph' + const inline = 'inline' + const bulletList = 'bullet_list' + const listItem = 'list_item' + let storyId, description, groupName = '', tasks = [] + let inHeading = false + let inParagraph = false + let inBulletList = false + let inListItem = false + let inTasks = false + let headingLevel = 0 + const ast = parseMD(markdown) + + ast.forEach((token) => { + const { type, tag, markup, content } = token + if (isOpen(token, heading)) { + inHeading = true + headingLevel = getHeadingLevel(tag) + } else if (isClose(token, heading)) { + inHeading = false + } + + if (isOpen(token, bulletList)) { + inBulletList = true + } else if (isClose(token, bulletList)) { + inBulletList = false + } + + if (isOpen(token, listItem)) { + inListItem = true + } else if (isClose(token, listItem)) { + inListItem = false + } + + if (isOpen(token, paragraph)) { + inParagraph = true + } else if (isClose(token, paragraph)) { + inParagraph = false + } + + if (inHeading && type == inline) { + groupName = content + if (headingLevel == 1) { + storyId = groupName + } + } + + if (inParagraph && headingLevel == 1 && type == inline) { + description = content + } + + if (groupName.toLowerCase().endsWith('tasks') && headingLevel == 2) { + inTasks = true + } + + if (inTasks && inBulletList && inListItem && inParagraph && type == inline) { + const group = headingLevel > 2 ? groupName : 'Ungrouped Tasks' + const done = /^\[x\]\s/.test(content) + const text = content.replace(/^\[.\]\s/, '') + tasks.push({ group, text, done }) + } + }) + return { storyId, description, tasks } +} \ No newline at end of file diff --git a/lib/config.js b/lib/config.js index eaf1507b..12b999cc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -30,6 +30,14 @@ class Config { this.settings.defaultFilter = filter } + get name() { + _get(this, 'settings.name', '') + } + + set name(name) { + this.settings.name = name + } + includeList(name) { return ( this.code && @@ -123,6 +131,10 @@ class Config { return _get(this, 'settings.journalPath', '') } + set journalPath(path) { + this.settings.journalPath = path + } + get journalTemplate() { const template = _get(this, 'settings.journalTemplate', '') return template === 'null' ? '' : template || '' diff --git a/lib/controlers/CliControler.js b/lib/controlers/CliControler.js deleted file mode 100644 index 9fa7866d..00000000 --- a/lib/controlers/CliControler.js +++ /dev/null @@ -1,102 +0,0 @@ -const { resolve } = require('path') -const { createFileSystemProject } = require('../project-factory') -const { loadYAML } = require('../tools') -const { readFile } = require('fs/promises') -const Config = require('../config') - -newConfigFromFile = async (configPath) => { - const config = await readFile(configPath, 'utf8') - return new Config(loadYAML(config)) -} - - -module.exports = { - imdoneInit: async function (projectPath, configPath) { - projectPath = resolve(projectPath) - let config - if (configPath) { - configPath = resolve(configPath) - config = await newConfigFromFile(configPath) - } - const project = createFileSystemProject({path: projectPath, config}) - return await project.init() - }, - - addTask: async function (task, projectPath, list, tags, contexts) { - projectPath = resolve(projectPath) - const project = createFileSystemProject({path: projectPath}) - await project.init() - const data = await project.addTaskToFile({list, content: task, tags, contexts}) - }, - - listTasks: async function (projectPath, filter, json, log) { - projectPath = resolve(projectPath) - const project = createFileSystemProject({path: projectPath}) - await project.init() - const tasks = project.getCards(filter) - const lists = project.getLists({tasks}) - - if (json) return log(JSON.stringify(lists, null, 2)) - - if (project.getCards('meta.group=*', tasks).length > 0) { - const groupedTasks = getGroupedTasks(tasks) - logProject(log, project) - logGroupedTasks(log, project, groupedTasks) - } else { - logProject(log, project) - logLists(log, project, lists, '##') - } - } -} - -function getGroupedTasks(tasks) { - const groupedTasks = {"Ungrouped Tasks": []} - tasks.forEach((task) => { - const group = task.meta.group - if (group) { - if (!groupedTasks[group]) groupedTasks[group] = [] - groupedTasks[group].push(task) - } else { - groupedTasks["Ungrouped Tasks"].push(task) - } - }) - return groupedTasks -} - -function logGroupedTasks(log, project, groupedTasks) { - Object.keys(groupedTasks).forEach((group) => { - log(`## ${group}`) - log('') - logLists(log, project, project.getLists({tasks: groupedTasks[group]}), '###') - }) -} - -function logLists(log, project, lists, heading) { - lists.forEach((list) => { - const tasks = list.tasks - if (tasks.length > 0) { - log(`${heading} ${list.name}`) - log('') - logTasks(log, project, tasks) - log('') - } - }) -} - -function logTasks(log, project, tasks) { - tasks.forEach(task => logTask(log, project, task)) -} - -function logProject(log, project) { - log(project.name) - log('====') - log('') -} - -function logTask(log, project, task) { - const doneMark = (task.list === project.config.getDoneList()) ? 'x' : ' ' - log(`- [${doneMark}] ${task.text}`) - task.description.forEach((line) => { - log(` ${line}`) - }) -} diff --git a/lib/file.js b/lib/file.js index 862c0878..d5a07331 100644 --- a/lib/file.js +++ b/lib/file.js @@ -696,7 +696,6 @@ File.prototype.modifyTask = function (task, config, noEmit) { if (this.getContent().trim() === newContent.trim()) return this.setContent(newContent) this.setModified(true) - console.log('modifyTask setModified true for file:', this.path) if (!noEmit) { this.emit('task.modified', task) this.emit('file.modified', this) @@ -979,10 +978,25 @@ File.prototype.getPath = function () { return this.path } +File.prototype.getFullPath = function () { + return this.repoId + this.path +} + File.prototype.getId = function () { return this.getPath() } +File.prototype.reset = function () { + this.previousContent = this.content + this.content = null + return this +} + +File.prototype.rollback = function () { + this.content = this.previousContent + return this +} + File.prototype.setContent = function (content) { this.content = content return this diff --git a/lib/project.js b/lib/project.js index 30e1aa85..ad131d0d 100644 --- a/lib/project.js +++ b/lib/project.js @@ -15,6 +15,7 @@ const _isString = require('lodash.isstring') const exec = require('child_process').exec const fastSort = require('fast-sort/dist/sort.js') const Task = require('./task') +const eol = require('eol') function calculateTotals(lists) { const totals = {} @@ -170,6 +171,10 @@ module.exports = class WorkerProject extends Project { return this.getCards(this.filter) } + removeList(list) { + this.repo.removeList(list) + } + getLists(opts) { const {tasks = this.getDefaultFilteredCards(), populateFiltered = false} = opts || {} return Repository.getTasksByList(this.repo, tasks, true, populateFiltered) @@ -296,6 +301,15 @@ module.exports = class WorkerProject extends Project { return this.updateCardContent(task, content) } + async moveTask(task, newList, newPos) { + return new Promise((resolve, reject) => { + this.repo.moveTask({task, newList, newPos}, (err) => { + if (err) return reject(err) + resolve() + }) + }) + } + async updateCardContent(task, content) { return new Promise((resolve, reject) => { this.repo.modifyTaskFromContent(task, content, (err) => { @@ -344,14 +358,14 @@ module.exports = class WorkerProject extends Project { } newCard(opts = {}) { - let { list, path, template } = opts + let { list, path, template, title } = opts if (path) { path = this.getFullPath(path) if (!fileGateway.existsSync(path)) { fileGateway.writeFileSync(path, '') } } else { - path = this.getFullPath(this.getNewCardsFile()) + path = this.getFullPath(this.getNewCardsFile({title})) } const stat = fileGateway.statSync(path) if (!template) template = this.getNewCardTemplate(path, stat) @@ -367,8 +381,8 @@ module.exports = class WorkerProject extends Project { } async addTaskToFile({path, list, content, tags, contexts}) { - const cardData = this.newCard({list, path}) - const filePath = this.getNewCardsFile({title: content}) + const cardData = this.newCard({list, path, title: eol.split(content)[0]}) + const filePath = cardData.path if (tags) { let tagContent = '' tags.forEach(tag => { @@ -385,9 +399,9 @@ module.exports = class WorkerProject extends Project { } const cardContent = content + cardData.template return new Promise((resolve, reject) => { - this.repo.addTaskToFile(filePath, list, cardContent, (err) => { + this.repo.addTaskToFile(filePath, list, cardContent, (err, file) => { if (err) return reject(err) - resolve(cardData) + resolve(file) }) }) } @@ -459,9 +473,7 @@ module.exports = class WorkerProject extends Project { return this.getJournalFile().fullFilePath if (journalType === JOURNAL_TYPE.NEW_FILE) { if (!title) return this.getFullPath(this.config.journalPath) - let fileName = `${sanitize(removeMD(title))}.md` - if (this.config.replaceSpacesWith) - fileName = fileName.replace(/ /g, this.config.replaceSpacesWith) + const fileName = `${this.sanitizeFileName(title)}.md` const fileFolder = this.getFullPath(this.config.journalPath) const filePath = _path.join(fileFolder, fileName) @@ -474,6 +486,13 @@ module.exports = class WorkerProject extends Project { } } + sanitizeFileName(name) { + let fileName = sanitize(removeMD(name)) + if (this.config.replaceSpacesWith) + fileName = fileName.replace(/ /g, this.config.replaceSpacesWith) + return fileName + } + getJournalFile() { const month = moment().format('YYYY-MM') const today = moment().format('YYYY-MM-DD') diff --git a/lib/repository.js b/lib/repository.js index 9f25fd5a..4f2c1cfc 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -203,20 +203,21 @@ Repository.prototype.addList = function (list, cb) { } Repository.prototype.removeList = function (list, cb) { - cb = tools.cb(cb) - if (!this.listExists(list)) return cb() - var self = this - var fn = function (err) { - if (!err) self.emit('list.modified', list) - cb(err) - } + return new Promise((resolve, reject) => { + var fn = (err) => { + if (err) return cb && cb(err) || reject(err) + this.emit('list.modified', list) + resolve() + } + if (!this.listExists(list)) return fn() - var lists = _reject(this.getLists(), { name: list }) - if (this.config.code && this.config.code.include_lists) { - this.config.code.include_lists = _reject(this.config.code.include_lists, list) - } - this.setLists(lists) - this.saveConfig(fn) + var lists = _reject(this.getLists(), { name: list }) + if (this.config.code && this.config.code.include_lists) { + this.config.code.include_lists = _reject(this.config.code.include_lists, list) + } + this.setLists(lists) + this.saveConfig(fn) + }) } /** @@ -518,7 +519,7 @@ Repository.prototype.getFilesWithTasks = function () { } Repository.prototype.resetFile = function (file) { - file.content = null + file.reset() file.removeListener('task.found', this.taskFoundListener) file.removeListener('task.modified', this.taskModifiedListener) } diff --git a/lib/tools.js b/lib/tools.js index bb4aa43b..df3297ec 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -94,6 +94,9 @@ module.exports = { marked: function (content) { return markdown.render(content) }, + parseMD: function (content) { + return markdown.parse(content, {}) + }, /** * Description diff --git a/notes/story-tasks.md b/notes/story-tasks.md index 93babbed..c2e047a1 100644 --- a/notes/story-tasks.md +++ b/notes/story-tasks.md @@ -4,7 +4,7 @@ is-epic:"story tasks" - [ ] #READY As a developer I would like to quickly add story tasks in my repo so they can be published for stakeholder visibility. - + **Scenarios** - [x] Developer relizes a new IaC task must be added to the story, so they run `imdone add "Add IaC for new GET orders endpoint"` - [x] Developer realizesa new IaC task must be added to the story, so they open imdone and add a new task with the text `Add IaC for new GET orders endpoint` diff --git a/package-lock.json b/package-lock.json index 1d25597c..07de743c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,9 +66,13 @@ "sane": "^4.1.0", "sanitize-filename": "^1.6.3", "sift": "^13.4.0", + "simple-git": "^3.19.1", "uniqid": "^5.4.0", "xregexp": "^5.1.0" }, + "bin": { + "imdone": "cli.js" + }, "devDependencies": { "chai": "^4.3.6", "colors": "^1.4.0", @@ -628,6 +632,19 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" + }, "node_modules/@kwvanderlinde/markdown-it-wikilinks": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@kwvanderlinde/markdown-it-wikilinks/-/markdown-it-wikilinks-1.0.2.tgz", @@ -6277,6 +6294,20 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-git": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.1.tgz", + "integrity": "sha512-Ck+rcjVaE1HotraRAS8u/+xgTvToTuoMkT9/l9lvuP5jftwnYUp6DwuJzsKErHgfyRk8IB8pqGHWEbM3tLgV1w==", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/sinon": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.2.tgz", @@ -7941,6 +7972,19 @@ } } }, + "@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "requires": { + "debug": "^4.1.1" + } + }, + "@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" + }, "@kwvanderlinde/markdown-it-wikilinks": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@kwvanderlinde/markdown-it-wikilinks/-/markdown-it-wikilinks-1.0.2.tgz", @@ -12300,6 +12344,16 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "simple-git": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.1.tgz", + "integrity": "sha512-Ck+rcjVaE1HotraRAS8u/+xgTvToTuoMkT9/l9lvuP5jftwnYUp6DwuJzsKErHgfyRk8IB8pqGHWEbM3tLgV1w==", + "requires": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + } + }, "sinon": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/sinon/-/sinon-13.0.2.tgz", diff --git a/package.json b/package.json index 2a32d368..1698736c 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "sane": "^4.1.0", "sanitize-filename": "^1.6.3", "sift": "^13.4.0", + "simple-git": "^3.19.1", "uniqid": "^5.4.0", "xregexp": "^5.1.0" },