Compare commits

...

11 Commits
4.5.0 ... 4.9.3

5 changed files with 228 additions and 33 deletions

141
README.md Normal file
View File

@ -0,0 +1,141 @@
# Personal Issue Tracker
This is [Jonathan Bernard's](mailto:jonathan@jdbernard.com) personal issue
tracker. In it's current form it is essentially a way to keep an curated list of
TODO's, organizing them by workflow category (todo, todo-today, dormant, etc.)
and context (Personal, Work, etc.).
## Categories
`pit` organizes issues into the following workflow categories:
- `current` - actively in progress
- `todo` - to be addressed in the future
- `todo-today` - chosen to be addressed today
- `pending` - blocked by some third party
- `dormant` - long-term things I don't want to forget but don't need in front
of me every day.
- `done`
In my typical workflow the `todo` category serves as a collection point for
things I want to keep track of. Then on a a daily basis I review issues in the
`todo` category and move a selection to the `todo-today` category. I also try
to keep the total number of issues in the `todo` below about a dozen. If there
are more than a dozen things in my `todo` category I will identify the lowest
priority items and move them to the `dormant` category.
## Issue Properties
`pit` allows arbitrary properties to be attached to issues in the form of
key-value pairs. On the command line these can be provided via the `-p` or
`--properties` parameter in the form
`-p <prop1Name>:<prop1Value>;<prop2Name>:<prop2Value>[;...]`
There are a couple of properties that pit will recognize automatically:
- `context`: the context organization feature is implemented using issue
properties.
- `created`: `pit` uses this property to timestamp an issue when it is created.
- `completed`: `pit` uses this property to timestamp an issue when it is moved
to the `done` category.
- `pending`: `pit` looks to this property to provide extra information about
issues in the `pending` category. Typically I use this to note who or what is
blocking the issue and why.
Some other common properties I use are:
- `resolution`: for short notes about why an issue was moved to `done`,
especially if it the action wasn't taken or if it is not completely clear
that this issue was completed.
## Configuration Options
`pit` allows configuration via command-line options and via a configuration
file. There is some overlap between the two methods of configuring `pit`, but
it is not a complete mapping.
### Config File
`pit` looks for a JSON configuration file in the following places (in order):
1. From a file path passed on the command line via the `--config <cfgFile>` parameter,
2. `./.pitrc`, in the current working directory,
3. From a file path set in the `PITRC` environment variable.
4. `$HOME/.pitrc`, in the user's home directory.
#### Sample Config File
This example illustrates all of the possible configuration options.
```json
{
"api": {
"apiKeys": [
"50cdcb660554e2d50fd88bd40b6579717bf00643f6ff57f108baf16c8c083f77",
"e4fc1aac49fc1f2f7f4ca6b1f04d41a4ccdd58e13bb53b41da97703d47267ceb",
]
},
"cli": {
"defaultContext": "personal",
"verbose": false,
"termWidth": 120,
"triggerPtk": true
},
"contexts": {
"nla-music": "New Life Music",
"nla-youth-band": "New Life Youth Band",
"acn": "Accenture",
"hff": "Hope Family Fellowship"
},
"tasksDir": "/mnt/c/Users/Jonathan Bernard/synced/tasks"
}
```
#### Explanation of configurable options.
In general, options supplied on the CLI directly will override options supplied
in the configuration file. All options are optional unless stated otherwise.
* `api`: configuration options specific to the API service.
- `apiKeys`: a list of Bearer tokens accepted by the API for the purpose of
authenticating API requests.
* `cli`: configuration options specific to the CLI.
- `defaultContext`: if present all invokations to the CLI will
be in this context. This is like adding a `--context <defaultContext>`
parameter to every CLI invocation. Any actual `--context` parameter will
override this value.
- `verbose`: Show issue details when listing issues (same as
`--verbose` flag).
- `termWidth`: Set the expected width of the terminal (for wrapping text).
- `triggerPtk`: If set to `true`, invoke the `ptk` command to start and stop
timers when issues move to the `current` and `done` categories
respectively.
* `contexts`: `pit` allows issues to be organized into different contexts via
a `context` property on the issue. The CLI groups issues according to
context. When printing contexts the CLI will take the value from the issues'
`context` properties and capatalize it. In some cases you may wish to have a
different display value for a context. I like to use abbreviations for long
context names to reduce the need to type, `hff` for "Hope Family Fellowship",
for example. The `contexts` config option allows you to provide a map of
context values to context display names See the sample file below for an
example.
Note that this mapping does not have to have entries for all contexts, only
those you wish to provide with an alternate display form. For example, in the
configuration sample above the default context is `personal`, a value not
present in the `contexts` configuration. `personal` will be displayed as
"Personal"; it does not need an alternate display name.
* `tasksDir` **required**: a file path to the root directory for the issue
repository (same as `--tasks-dir` CLI parameter).
- CLI parameter: *cannot be specified via CLI*
- config file key: `contexts`

View File

@ -1,8 +1,6 @@
# Package # Package
include "src/pitpkg/version.nim" version = "4.9.3"
version = PIT_VERSION
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal issue tracker." description = "Personal issue tracker."
license = "MIT" license = "MIT"
@ -11,5 +9,17 @@ bin = @["pit", "pit_api"]
# Dependencies # Dependencies
requires @[ "nim >= 0.19.0", "cliutils 0.6.1", "docopt 0.6.8", "jester 0.4.1", requires @[
"langutils >= 0.4.0", "timeutils 0.4.0", "uuids 0.1.10" ] "nim >= 0.19.0",
"docopt 0.6.8",
"jester 0.4.1",
"uuids 0.1.10",
"https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.4",
"https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.4.0",
"https://git.jdb-labs.com/jdb/update-nim-package-version"
]
task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pitpkg/version.nim'"

View File

@ -1,11 +1,11 @@
## Personal Issue Tracker CLI interface ## Personal Issue Tracker CLI interface
## ==================================== ## ====================================
import cliutils, docopt, json, logging, options, os, ospaths, sequtils, import algorithm, cliutils, docopt, json, logging, options, os, sequtils,
tables, terminal, times, timeutils, unicode, uuids std/wordwrap, tables, terminal, times, timeutils, unicode, uuids
from nre import re from nre import re
import strutils except capitalize, toUpper, toLower import strutils except alignLeft, capitalize, strip, toUpper, toLower
import pitpkg/private/libpit import pitpkg/private/libpit
export libpit export libpit
@ -36,6 +36,7 @@ proc initContext(args: Table[string, Value]): CliContext =
let cliCfg = CombinedConfig(docopt: args, json: cliJson) let cliCfg = CombinedConfig(docopt: args, json: cliJson)
result = CliContext( result = CliContext(
cfg: pitCfg,
contexts: pitCfg.contexts, contexts: pitCfg.contexts,
defaultContext: defaultContext:
if not cliJson.hasKey("defaultContext"): none(string) if not cliJson.hasKey("defaultContext"): none(string)
@ -43,12 +44,12 @@ proc initContext(args: Table[string, Value]): CliContext =
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"], verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
issues: newTable[IssueState, seq[Issue]](), issues: newTable[IssueState, seq[Issue]](),
tasksDir: pitCfg.tasksDir, tasksDir: pitCfg.tasksDir,
termWidth: parseInt(cliCfg.getVal("term-width", "80")), termWidth: parseInt(cliCfg.getVal("termWidth", "80")),
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false)) triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
proc getIssueContextDisplayName(ctx: CliContext, context: string): string = proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
if not ctx.contexts.hasKey(context): if not ctx.contexts.hasKey(context):
if context.isNilOrWhitespace: return "<default>" if context.isEmptyOrWhitespace: return "<default>"
else: return context.capitalize() else: return context.capitalize()
return ctx.contexts[context] return ctx.contexts[context]
@ -66,7 +67,7 @@ proc formatIssue(ctx: CliContext, issue: Issue): string =
result &= "--------".withColor(fgBlack, true) & "\n" result &= "--------".withColor(fgBlack, true) & "\n"
if not issue.details.isNilOrWhitespace: if not issue.details.isEmptyOrWhitespace:
result &= issue.details.strip.withColor(fgCyan) & "\n" result &= issue.details.strip.withColor(fgCyan) & "\n"
proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "", proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
@ -74,7 +75,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
result = "" result = ""
var showDetails = not issue.details.isNilOrWhitespace and verbose var showDetails = not issue.details.isEmptyOrWhitespace and verbose
var prefixLen = 0 var prefixLen = 0
var summaryIndentLen = indent.len + 7 var summaryIndentLen = indent.len + 7
@ -82,7 +83,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' ' if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' '
# Wrap and write the summary. # Wrap and write the summary.
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wordWrap(width - summaryIndentLen).indent(summaryIndentLen) var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wrapWords(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1] wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
@ -102,7 +103,7 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("pending"): if issue.hasProp("pending"):
let startIdx = "Pending: ".len let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - summaryIndentLen) var pendingText = issue["pending"].wrapWords(width - startIdx - summaryIndentLen)
.indent(startIdx) .indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen) pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan) result &= "\n" & pendingText.withColor(fgCyan)
@ -241,9 +242,12 @@ when isMainModule:
let doc = """ let doc = """
Usage: Usage:
pit ( new | add) <summary> [<state>] [options] pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options] pit list contexts
pit list [<stateOrId>] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options] pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <ref>... pit edit <ref>...
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state> pit reorder <state>
pit delegate <id> <delegated-to> pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>... pit ( delete | rm ) <id>...
@ -285,6 +289,8 @@ Options:
configured in the .pitrc file) configured in the .pitrc file)
--term-width <width> Manually set the terminal width to use. --term-width <width> Manually set the terminal width to use.
--ptk Enable PTK integration for this command.
""" """
logging.addHandler(newConsoleLogger()) logging.addHandler(newConsoleLogger())
@ -357,6 +363,29 @@ Options:
else: edit(ctx.tasksDir.loadIssueById(editRef)) else: edit(ctx.tasksDir.loadIssueById(editRef))
elif args["tag"]:
if not args["--tags"]: raise newException(Exception, "no tags given")
let newTags = ($args["--tags"]).split(",").mapIt(it.strip)
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
issue.tags = deduplicate(issue.tags & newTags)
issue.store()
elif args["untag"]:
let tagsToRemove: seq[string] =
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
else: @[]
for id in @(args["<id>"]):
var issue = ctx.tasksDir.loadIssueById(id)
if tagsToRemove.len > 0:
issue.tags = issue.tags.filter(
proc (tag: string): bool = not tagsToRemove.anyIt(it == tag))
else: issue.tags = @[]
issue.store()
elif args["start"] or args["todo-today"] or args["done"] or elif args["start"] or args["todo-today"] or args["done"] or
args["pending"] or args["todo"] or args["suspend"]: args["pending"] or args["todo"] or args["suspend"]:
@ -376,11 +405,18 @@ Options:
if targetState == Done: issue["completed"] = getTime().local.formatIso8601 if targetState == Done: issue["completed"] = getTime().local.formatIso8601
issue.changeState(ctx.tasksDir, targetState) issue.changeState(ctx.tasksDir, targetState)
if ctx.triggerPtk: if ctx.triggerPtk or args["--ptk"]:
if targetState == Current: if targetState == Current:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0])) let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
var cmd = "ptk start" var cmd = "ptk start"
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\"" if issue.tags.len > 0 or issue.properties.hasKey("context"):
let tags = concat(
issue.tags,
if issue.properties.hasKey("context"): @[issue.properties["context"]]
else: @[]
)
cmd &= " -g \"" & tags.join(",") & "\""
cmd &= " -n \"pit-id: " & $issue.id & "\""
cmd &= " \"" & issue.summary & "\"" cmd &= " \"" & issue.summary & "\""
discard execShellCmd(cmd) discard execShellCmd(cmd)
elif targetState == Done or targetState == Pending: elif targetState == Done or targetState == Pending:
@ -440,11 +476,10 @@ Options:
var stateOption = none(IssueState) var stateOption = none(IssueState)
var issueIdOption = none(string) var issueIdOption = none(string)
if args["<listable>"]: if args["contexts"]: listContexts = true
if $args["<listable>"] == "contexts": listContexts = true elif args["<stateOrId>"]:
else: try: stateOption = some(parseEnum[IssueState]($args["<stateOrId>"]))
try: stateOption = some(parseEnum[IssueState]($args["<listable>"])) except: issueIdOption = some($args["<stateOrId>"])
except: issueIdOption = some($args["<listable>"])
# List the known contexts # List the known contexts
if listContexts: if listContexts:
@ -455,7 +490,13 @@ Options:
if issue.hasProp("context") and not uniqContexts.contains(issue["context"]): if issue.hasProp("context") and not uniqContexts.contains(issue["context"]):
uniqContexts.add(issue["context"]) uniqContexts.add(issue["context"])
for c in uniqContexts: stdout.writeLine(c) let maxLen = foldl(uniqContexts,
if a.len > b.len: a
else: b
).len
for c in uniqContexts.sorted:
stdout.writeLine(c.alignLeft(maxLen+2) & ctx.getIssueContextDisplayName(c))
# List a specific issue # List a specific issue
elif issueIdOption.isSome: elif issueIdOption.isSome:

View File

@ -1,4 +1,4 @@
import cliutils, docopt, json, logging, langutils, options, os, ospaths, import cliutils, docopt, json, logging, langutils, options, os,
sequtils, strutils, tables, times, timeutils, uuids sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex from nre import find, match, re, Regex
@ -49,6 +49,7 @@ proc `[]`*(issue: Issue, key: string): string =
proc `[]=`*(issue: Issue, key: string, value: string) = proc `[]=`*(issue: Issue, key: string, value: string) =
issue.properties[key] = value issue.properties[key] = value
## Issue property accessors
proc hasProp*(issue: Issue, key: string): bool = proc hasProp*(issue: Issue, key: string): bool =
return issue.properties.hasKey(key) return issue.properties.hasKey(key)
@ -62,6 +63,7 @@ proc getDateTime*(issue: Issue, key: string, default: DateTime): DateTime =
proc setDateTime*(issue: Issue, key: string, dt: DateTime) = proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
issue.properties[key] = dt.formatIso8601 issue.properties[key] = dt.formatIso8601
## Issue filtering
proc initFilter*(): IssueFilter = proc initFilter*(): IssueFilter =
result = IssueFilter( result = IssueFilter(
completedRange: none(tuple[b, e: DateTime]), completedRange: none(tuple[b, e: DateTime]),
@ -120,7 +122,7 @@ proc fromStorageFormat*(id: string, issueTxt: string): Issue =
of ReadingProps: of ReadingProps:
# Ignore empty lines # Ignore empty lines
if line.isNilOrWhitespace: continue if line.isEmptyOrWhitespace: continue
# Look for the sentinal to start parsing as detail lines # Look for the sentinal to start parsing as detail lines
if line == "--------": if line == "--------":
@ -147,9 +149,9 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
lines.add(issue.summary) lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):") if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties: for key, val in issue.properties:
if not val.isNilOrWhitespace: lines.add(key & ": " & val) if not val.isEmptyOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(",")) if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if not isNilOrWhitespace(issue.details) or withComments: if not isEmptyOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"") if withComments: lines.add("# Details go below the \"--------\"")
lines.add("--------") lines.add("--------")
lines.add(issue.details) lines.add(issue.details)
@ -168,6 +170,7 @@ proc loadIssueById*(tasksDir, id: string): Issue =
raise newException(KeyError, "cannot find issue for id: " & id) raise newException(KeyError, "cannot find issue for id: " & id)
proc store*(issue: Issue, withComments = false) = proc store*(issue: Issue, withComments = false) =
discard existsOrCreateDir(issue.filePath.parentDir)
writeFile(issue.filepath, toStorageFormat(issue, withComments)) writeFile(issue.filepath, toStorageFormat(issue, withComments))
proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) = proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = false) =
@ -179,7 +182,7 @@ proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = fa
else: else:
issue.filepath = stateDir / filename issue.filepath = stateDir / filename
issue.store() issue.store(withComments)
proc storeOrder*(issues: seq[Issue], path: string) = proc storeOrder*(issues: seq[Issue], path: string) =
var orderLines = newSeq[string]() var orderLines = newSeq[string]()
@ -200,7 +203,7 @@ proc loadIssues*(path: string): seq[Issue] =
toSeq(orderFile.lines) toSeq(orderFile.lines)
.mapIt(it.split(' ')[0]) .mapIt(it.split(' ')[0])
.deduplicate .deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace) .filterIt(not it.startsWith("> ") and not it.isEmptyOrWhitespace)
else: newSeq[string]() else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool] type TaggedIssue = tuple[issue: Issue, ordered: bool]
@ -267,7 +270,7 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
if not existsFile(pitrcFilename): if not existsFile(pitrcFilename):
warn "pit: could not find .pitrc file: " & pitrcFilename warn "pit: could not find .pitrc file: " & pitrcFilename
if isNilOrWhitespace(pitrcFilename): if isEmptyOrWhitespace(pitrcFilename):
pitrcFilename = $getEnv("HOME") & "/.pitrc" pitrcFilename = $getEnv("HOME") & "/.pitrc"
var cfgFile: File var cfgFile: File
try: try:
@ -293,7 +296,7 @@ proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitCo
for k, v in cfgJson["contexts"]: for k, v in cfgJson["contexts"]:
result.contexts[k] = v.getStr() result.contexts[k] = v.getStr()
if isNilOrWhitespace(result.tasksDir): if isEmptyOrWhitespace(result.tasksDir):
raise newException(Exception, "no tasks directory configured") raise newException(Exception, "no tasks directory configured")
if not existsDir(result.tasksDir): if not existsDir(result.tasksDir):

View File

@ -1 +1 @@
const PIT_VERSION* = "4.5.0" const PIT_VERSION* = "4.9.3"