Compare commits

...

15 Commits
4.2.0 ... 4.7.0

Author SHA1 Message Date
4127fbe41c Better PTK integration.
- Now includes the context as a PTK tag (if present).
- Add the PIT ID to the PTK notes.
2020-03-16 09:39:17 -05:00
0671d7728e Add helper to update version easily. 2020-02-16 00:57:58 -06:00
7b5f26f24a Update dependency references to use full URLs to non-central libs. 2020-02-14 11:21:54 -06:00
db3e648d47 Add tag and untag commands. 2019-04-18 07:43:08 -05:00
476a94c679 Add property removal behavior: specifying a property with no value removes it. 2019-01-24 22:30:20 -06:00
65edc56e08 Add delegate command. 2019-01-18 18:51:51 -06:00
d4db66a71e Updates to compile on Nim 0.19 2019-01-17 13:18:25 -06:00
f8ccc831ef WIP Updates to compile on Nim 0.19. 2019-01-17 11:02:46 -06:00
93a0a15f12 Refactored to move HTTP query params to CLI arguments translation into the cliutils package. 2018-10-01 21:39:35 -05:00
dc31d590a0 Add GET /issue/<issueId> API endpoint. 2018-10-01 11:22:48 -04:00
8b46cc19d8 Rename variable to avoid overloading the name. 2018-10-01 11:22:31 -04:00
567c2d2178 Fix a bug when asking to move an issue to the state it's already in. 2018-06-25 11:40:25 -05:00
08dfbde57f Add the ability to order issues. 2018-06-11 12:11:26 -05:00
a924d7b649 Add filters for text-matching on issue summary or details. 2018-06-11 10:19:10 -05:00
2404f6a3d1 Add the ability to edit all issues in a given state. 2018-06-06 09:43:31 -05:00
6 changed files with 256 additions and 95 deletions

View File

@ -1,8 +1,6 @@
# Package
include "src/pitpkg/private/version.nim"
version = PIT_VERSION
version = "4.7.0"
author = "Jonathan Bernard"
description = "Personal issue tracker."
license = "MIT"
@ -11,5 +9,17 @@ bin = @["pit", "pit_api"]
# Dependencies
requires @[ "nim >= 0.18.0", "cliutils 0.4.1", "docopt 0.6.5", "jester 0.2.0",
"timeutils 0.3.0", "uuids 0.1.9" ]
requires @[
"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.1",
"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,14 +1,15 @@
## Personal Issue Tracker CLI interface
## ====================================
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
import cliutils, docopt, json, logging, options, os, sequtils,
tables, terminal, times, timeutils, unicode, uuids
import strutils except capitalize, toUpper, toLower
from nre import re
import strutils except capitalize, strip, toUpper, toLower
import pitpkg/private/libpit
export libpit
include "pitpkg/private/version.nim"
include "pitpkg/version.nim"
type
CliContext = ref object
@ -20,6 +21,11 @@ type
termWidth*: int
triggerPtk*, verbose*: bool
let EDITOR =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
proc initContext(args: Table[string, Value]): CliContext =
let pitCfg = loadConfig(args)
@ -70,11 +76,21 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
var showDetails = not issue.details.isNilOrWhitespace and verbose
# Wrap and write the summary.
var wrappedSummary = (" ".repeat(5) & issue.summary).wordWrap(width - 2).indent(2 + indent.len)
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
var prefixLen = 0
var summaryIndentLen = indent.len + 7
if issue.hasProp("delegated-to"): prefixLen += issue["delegated-to"].len + 2 # space for the ':' and ' '
# Wrap and write the summary.
var wrappedSummary = ("+".repeat(prefixLen) & issue.summary).wordWrap(width - summaryIndentLen).indent(summaryIndentLen)
wrappedSummary = wrappedSummary[(prefixLen + summaryIndentLen)..^1]
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true) & " "
if issue.hasProp("delegated-to"):
result &= (issue["delegated-to"] & ": ").withColor(fgGreen)
result = (indent & ($issue.id)[0..<6]).withColor(fgBlack, true)
result &= wrappedSummary.withColor(fgWhite)
if issue.tags.len > 0:
@ -86,9 +102,9 @@ proc formatSectionIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
if issue.hasProp("pending"):
let startIdx = "Pending: ".len
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
var pendingText = issue["pending"].wordWrap(width - startIdx - summaryIndentLen)
.indent(startIdx)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(summaryIndentLen)
result &= "\n" & pendingText.withColor(fgCyan)
if showDetails:
@ -100,16 +116,9 @@ proc formatSectionIssueList(ctx: CliContext, issues: seq[Issue], width: int,
indent: string, verbose: bool): string =
result = ""
var topPadded = true
for i in issues:
var issueText = ctx.formatSectionIssue(i, width, indent, verbose)
if issueText.splitLines.len > 1:
if topPadded: result &= issueText & "\n\n"
else: result &= "\n" & issueText & "\n\n"
topPadded = true
else:
result &= issueText & "\n"
topPadded = false
result &= issueText & "\n"
proc formatSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
indent = "", verbose = false): string =
@ -162,16 +171,18 @@ proc writeHeader(ctx: CliContext, header: string) =
stdout.writeLine('~'.repeat(ctx.termWidth))
stdout.resetAttributes
proc reorder(ctx: CliContext, state: IssueState) =
# load the issues to make sure the order file contains all issues in the state.
ctx.loadIssues(state)
discard os.execShellCmd(EDITOR & " '" & (ctx.tasksDir / $state / "order.txt") & "' </dev/tty >/dev/tty")
proc edit(issue: Issue) =
# Write format comments (to help when editing)
writeFile(issue.filepath, toStorageFormat(issue, true))
let editor =
if existsEnv("EDITOR"): getEnv("EDITOR")
else: "vi"
discard os.execShellCmd(editor & " " & issue.filepath & " </dev/tty >/dev/tty")
discard os.execShellCmd(EDITOR & " '" & issue.filepath & "' </dev/tty >/dev/tty")
try:
# Try to parse the newly-edited issue to make sure it was successful.
@ -182,7 +193,7 @@ proc edit(issue: Issue) =
getCurrentExceptionMsg()
issue.store()
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], today, future, verbose: bool) =
proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState], showToday, showFuture, verbose: bool) =
if state.isSome:
ctx.loadIssues(state.get)
@ -193,6 +204,12 @@ proc list(ctx: CliContext, filter: Option[IssueFilter], state: Option[IssueState
ctx.loadAllIssues()
if filter.isSome: ctx.filterIssues(filter.get)
let today = showToday and [Current, TodoToday].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
let future = showFuture and [Pending, Todo].anyIt(
ctx.issues.hasKey(it) and ctx.issues[it].len > 0)
let indent = if today and future: " " else: ""
# Today's items
@ -226,7 +243,11 @@ Usage:
pit ( new | add) <summary> [<state>] [options]
pit list [<listable>] [options]
pit ( start | done | pending | todo-today | todo | suspend ) <id>... [options]
pit edit <id>...
pit edit <ref>...
pit tag <id>... [options]
pit untag <id>... [options]
pit reorder <state>
pit delegate <id> <delegated-to>
pit ( delete | rm ) <id>...
Options:
@ -246,6 +267,12 @@ Options:
-F, --future Limit to future issues.
-m, --match <pattern> Limit to issues whose summaries match the given
pattern (PCRE regex supported).
-M, --match-all <pat> Limit to the issues whose summaries or details
match the given pattern (PCRE regex supported).
-v, --verbose Show issue details when listing issues.
-q, --quiet Suppress verbose output.
@ -314,9 +341,46 @@ Options:
stdout.writeLine ctx.formatIssue(issue)
elif args["reorder"]:
ctx.reorder(parseEnum[IssueState]($args["<state>"]))
elif args["edit"]:
for editRef in @(args["<ref>"]):
var stateOption = none(IssueState)
try: stateOption = some(parseEnum[IssueState](editRef))
except: discard
if stateOption.isSome:
let state = stateOption.get
ctx.loadIssues(state)
for issue in ctx.issues[state]: edit(issue)
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>"]):
edit(ctx.tasksDir.loadIssueById(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
args["pending"] or args["todo"] or args["suspend"]:
@ -340,13 +404,27 @@ Options:
if ctx.triggerPtk:
if targetState == Current:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"][0]))
var cmd = "ptk start "
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
var cmd = "ptk start"
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 & "\""
discard execShellCmd(cmd)
elif targetState == Done or targetState == Pending:
discard execShellCmd("ptk stop")
elif args["delegate"]:
let issue = ctx.tasksDir.loadIssueById($(args["<id>"]))
issue["delegated-to"] = $args["<delegated-to>"]
issue.store()
elif args["delete"] or args["rm"]:
for id in @(args["<id>"]):
@ -369,6 +447,15 @@ Options:
filter.properties = propertiesOption.get
filterOption = some(filter)
# If they supplied text matches, add that to the filter.
if args["--match"]:
filter.summaryMatch = some(re("(?i)" & $args["--match"]))
filterOption = some(filter)
if args["--match-all"]:
filter.fullMatch = some(re("(?i)" & $args["--match-all"]))
filterOption = some(filter)
# If no "context" property is given, use the default (if we have one)
if ctx.defaultContext.isSome and not filter.properties.hasKey("context"):
stderr.writeLine("Limiting to default context: " & ctx.defaultContext.get)

View File

@ -1,12 +1,12 @@
## Personal Issue Tracker API Interface
## ====================================
import asyncdispatch, cliutils, docopt, jester, json, logging, sequtils, strutils
import asyncdispatch, cliutils, docopt, jester, json, logging, options, sequtils, strutils
import nre except toSeq
import pitpkg/private/libpit
include "pitpkg/private/version.nim"
include "pitpkg/version.nim"
type
PitApiCfg* = object
@ -18,6 +18,20 @@ const TXT = "text/plain"
proc raiseEx(reason: string): void = raise newException(Exception, reason)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = some(headers)
result[3] = content
result.matched = true
break allRoutes
template checkAuth(cfg: PitApiCfg) =
## Check this request for authentication and authorization information.
## If the request is not authorized, this template sets up the 401 response
@ -40,30 +54,10 @@ template checkAuth(cfg: PitApiCfg) =
except:
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
response.data[0] = CallbackAction.TCActionSend
response.data[1] = Http401
response.data[2]["WWW-Authenticate"] = "Bearer"
response.data[2]["Content-Type"] = TXT
response.data[3] = getCurrentExceptionMsg()
proc paramsToArgs(params: StringTableRef): tuple[stripAnsi: bool, args: seq[string]] =
result = (false, @[])
if params.hasKey("color"):
if params["color"] != "true":
result[0] = true
for k,v in params:
if k == "color": continue
elif k.startsWith("arg"): result[1].add(v) # support ?arg1=val1&arg2=val2 -> cmd val1 val2
else :
result[1].add("--" & k)
if v != "true": result[1].add(v) # support things like ?verbose=true -> cmd --verbose
let STRIP_ANSI_REGEX = re"\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]"
proc stripAnsi(str: string): string =
return str.replace(STRIP_ANSI_REGEX, "")
halt(
Http401,
@{"Content-Type": TXT},
getCurrentExceptionMsg())
proc start*(cfg: PitApiCfg) =
@ -79,20 +73,29 @@ proc start*(cfg: PitApiCfg) =
resp("pong", TXT)
get "/issues":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
var (stripAnsi, args) = paramsToArgs(request.params)
var args = queryParamsToCliArgs(request.params)
args = @["list"] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else:
if stripAnsi: resp(stripAnsi(execResult[0]), TXT)
else: resp(execResult[0], TXT)
else: resp(stripAnsi(execResult[0]), TXT)
post "/issues":
checkAuth(cfg); if not authed: return true
checkAuth(cfg)
get "/issue/@issueId":
checkAuth(cfg)
var args = queryParamsToCliArgs(request.params)
args = @["list", @"issueId"] & args
info "args: \n" & args.join(" ")
let execResult = execWithOutput("pit", ".", args)
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
else: resp(stripAnsi(execResult[0]), TXT)
waitFor(stopFuture)

View File

@ -1,7 +1,8 @@
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
strutils, tables, times, timeutils, uuids
import cliutils, docopt, json, logging, langutils, options, os, ospaths,
sequtils, strutils, tables, times, timeutils, uuids
from nre import find, match, re, Regex
from nre import re, match
type
Issue* = ref object
id*: UUID
@ -19,8 +20,9 @@ type
Dormant = "dormant"
IssueFilter* = ref object
completedRange*: Option[tuple[b, e: DateTime]]
fullMatch*, summaryMatch*: Option[Regex]
properties*: TableRef[string, string]
completedRange*: tuple[b, e: DateTime]
PitConfig* = ref object
tasksDir*: string
@ -62,22 +64,38 @@ proc setDateTime*(issue: Issue, key: string, dt: DateTime) =
proc initFilter*(): IssueFilter =
result = IssueFilter(
properties: newTable[string,string](),
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
completedRange: none(tuple[b, e: DateTime]),
fullMatch: none(Regex),
summaryMatch: none(Regex),
properties: newTable[string, string]())
proc initFilter*(props: TableRef[string, string]): IssueFilter =
proc propsFilter*(props: TableRef[string, string]): IssueFilter =
if isNil(props):
raise newException(ValueError,
"cannot initialize property filter without properties")
result = IssueFilter(
properties: props,
completedRange: (fromUnix(0).local, fromUnix(253400659199).local))
result = initFilter()
result.properties = props
proc dateFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = initFilter()
result.completedRange = some(range)
proc summaryMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.summaryMatch = some(re("(?i)" & pattern))
proc fullMatchFilter*(pattern: string): IssueFilter =
result = initFilter()
result.fullMatch = some(re("(?i)" & pattern))
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc initFilter*(range: tuple[b, e: DateTime]): IssueFilter =
result = IssueFilter(
properties: newTable[string, string](),
completedRange: range)
## Parse and format issues
proc fromStorageFormat*(id: string, issueTxt: string): Issue =
@ -128,7 +146,8 @@ proc toStorageFormat*(issue: Issue, withComments = false): string =
if withComments: lines.add("# Summary (one line):")
lines.add(issue.summary)
if withComments: lines.add("# Properties (\"key:value\" per line):")
for key, val in issue.properties: lines.add(key & ": " & val)
for key, val in issue.properties:
if not val.isNilOrWhitespace: lines.add(key & ": " & val)
if issue.tags.len > 0: lines.add("tags: " & issue.tags.join(","))
if not isNilOrWhitespace(issue.details) or withComments:
if withComments: lines.add("# Details go below the \"--------\"")
@ -160,40 +179,82 @@ proc store*(tasksDir: string, issue: Issue, state: IssueState, withComments = fa
else:
issue.filepath = stateDir / filename
issue.store()
issue.store(withComments)
proc storeOrder*(issues: seq[Issue], path: string) =
var orderLines = newSeq[string]()
for context, issues in issues.groupBy("context"):
orderLines.add("> " & context)
for issue in issues: orderLines.add($issue.id & " " & issue.summary)
orderLines.add("")
let orderFile = path / "order.txt"
orderFile.writeFile(orderLines.join("\n"))
proc loadIssues*(path: string): seq[Issue] =
result = @[]
let orderFile = path / "order.txt"
let orderedIds =
if fileExists(orderFile):
toSeq(orderFile.lines)
.mapIt(it.split(' ')[0])
.deduplicate
.filterIt(not it.startsWith("> ") and not it.isNilOrWhitespace)
else: newSeq[string]()
type TaggedIssue = tuple[issue: Issue, ordered: bool]
var unorderedIssues: seq[TaggedIssue] = @[]
for path in walkDirRec(path):
if extractFilename(path).match(ISSUE_FILE_PATTERN).isSome():
result.add(loadIssue(path))
unorderedIssues.add((loadIssue(path), false))
result = @[]
# Add all ordered issues in order
for id in orderedIds:
let idx = unorderedIssues.indexOf(($it.issue.id).startsWith(id))
if idx > 0:
result.add(unorderedIssues[idx].issue)
unorderedIssues[idx].ordered = true
# Add all remaining, unordered issues in the order they were loaded
for taggedIssue in unorderedIssues:
if taggedIssue.ordered: continue
result.add(taggedIssue.issue)
# Finally, save current order
result.storeOrder(path)
proc changeState*(issue: Issue, tasksDir: string, newState: IssueState) =
let oldFilepath = issue.filepath
if newState == Done: issue.setDateTime("completed", getTime().local)
tasksDir.store(issue, newState)
removeFile(oldFilepath)
if oldFilePath != issue.filepath: removeFile(oldFilepath)
proc delete*(issue: Issue) = removeFile(issue.filepath)
## Utilities for working with issue collections.
proc groupBy*(issues: seq[Issue], propertyKey: string): TableRef[string, seq[Issue]] =
result = newTable[string, seq[Issue]]()
for i in issues:
let key = if i.hasProp(propertyKey): i[propertyKey] else: ""
if not result.hasKey(key): result[key] = newSeq[Issue]()
result[key].add(i)
proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
result = issues
for k,v in filter.properties:
result = result.filterIt(it.hasProp(k) and it[k] == v)
result = result.filterIt(not it.hasProp("completed") or
it.getDateTime("completed").between(
filter.completedRange.b,
filter.completedRange.e))
if filter.completedRange.isSome:
let range = filter.completedRange.get
result = result.filterIt(
not it.hasProp("completed") or
it.getDateTime("completed").between(range.b, range.e))
if filter.summaryMatch.isSome:
let p = filter.summaryMatch.get
result = result.filterIt(it.summary.find(p).isSome)
if filter.fullMatch.isSome:
let p = filter.fullMatch.get
result = result.filterIt( it.summary.find(p).isSome or it.details.find(p).isSome)
### Configuration utilities
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =

View File

@ -1 +0,0 @@
const PIT_VERSION = "4.2.0"

1
src/pitpkg/version.nim Normal file
View File

@ -0,0 +1 @@
const PIT_VERSION* = "4.7.0"