229 lines
7.1 KiB
Nim
229 lines
7.1 KiB
Nim
## Sync Personal Issue Tracker to Trello
|
|
|
|
import std/[algorithm, json, jsonutils, logging, options, sets, times, tables]
|
|
import cliutils, docopt, pit, uuids, zero_functional
|
|
|
|
import pit2trellopkg/[api, cliconstants]
|
|
|
|
from sequtils import toSeq
|
|
|
|
type
|
|
CliConfig = ref object
|
|
pit*: PitConfig
|
|
apiBaseUrl*: string
|
|
apiKey*: TrelloApiCredentials
|
|
contexts*: seq[TrelloContext]
|
|
|
|
TrelloContext = object
|
|
context*: string
|
|
boardId*: string
|
|
|
|
SyncState = ref object
|
|
pit*: PitConfig
|
|
boards*: TableRef[string, TrelloBoard]
|
|
contexts*: seq[TrelloContext]
|
|
issues*: TableRef[IssueState, seq[Issue]]
|
|
|
|
func distribute[K, V](a: K, s: seq[V]): seq[(K, V)] =
|
|
s --> map((a, it))
|
|
|
|
proc loadBoards(
|
|
self: TrelloApiClient,
|
|
contexts: seq[TrelloContext]
|
|
): TableRef[string, TrelloBoard] =
|
|
|
|
let boards: seq[tuple[id: string, board: TrelloBoard]] = contexts -->
|
|
map((it.boardId, self.loadBoard(it.boardId)))
|
|
return newTable[string, TrelloBoard](boards)
|
|
|
|
proc loadCliConfig(args: Table[string, Value]): CliConfig =
|
|
let pitCfg = loadConfig(args)
|
|
|
|
let trelloJson = pitCfg.cfg.getJson("trello")
|
|
let trelloCfg = CombinedConfig(docopt: args, json: trelloJson)
|
|
|
|
result = CliConfig(
|
|
apiBaseUrl: trelloCfg.getVal("api-base-url", "https://api.trello.com"),
|
|
pit: pitCfg
|
|
)
|
|
|
|
fromJson(result.apiKey, trelloCfg.getJson("apiKey"))
|
|
fromJson(result.contexts, trelloCfg.getJson("contexts"))
|
|
|
|
proc loadIssuesAndBoards(cfg: CliConfig, trello: TrelloApiClient): SyncState =
|
|
SyncState(
|
|
pit: cfg.pit,
|
|
boards: trello.loadBoards(cfg.contexts),
|
|
contexts: cfg.contexts,
|
|
issues: cfg.pit.tasksDir.loadAllIssues)
|
|
|
|
proc listNameForIssueState(issState: IssueState): TrelloListName =
|
|
return
|
|
case issState:
|
|
of Current, TodoToday, Pending: tlnInProgress
|
|
of Dormant, Todo: tlnTodo
|
|
of Done: tlnDone
|
|
|
|
proc issueStateForListName(listName: TrelloListName): IssueState =
|
|
return
|
|
case listName:
|
|
of tlnTodo: Todo
|
|
of tlnInProgress: TodoToday
|
|
of tlnDone, tlnUnknown: Done
|
|
|
|
proc tagToTrelloLabel(trello: TrelloApiClient, board: TrelloBoard, tag: string): TrelloLabel =
|
|
let foundLabel = board.labels --> find(it.name == tag)
|
|
if foundLabel.isSome: return foundLabel.get
|
|
else: return trello.createLabel(board, tag)
|
|
|
|
proc trelloLabelIdToTag(board: TrelloBoard, labelId: string): Option[string] =
|
|
let foundLabel = board.labels --> find(it.id == labelId)
|
|
if foundLabel.isSome: return some(foundLabel.get.name)
|
|
else: return none[string]()
|
|
|
|
proc equiv(board: TrelloBoard, tc: TrelloCard, iss: Issue): bool =
|
|
let pitTags = sorted(iss.tags)
|
|
let trelloTags = sorted(tc.labelIds -->
|
|
map(board.trelloLabelIdToTag(it)).
|
|
filter(it.isSome).
|
|
map(it.get))
|
|
|
|
return
|
|
tc.name == iss.summary and
|
|
tc.desc == iss.details and
|
|
sorted(iss.tags) == sorted(tc.labelIds -->
|
|
map(board.trelloLabelIdToTag(it)).
|
|
filter(it.isSome).
|
|
map(it.get))
|
|
|
|
proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
|
|
if not s.boards.contains(ctx.boardId):
|
|
raise newException(ValueError, "board not loaded for context\t" & $ctx)
|
|
|
|
let board = s.boards[ctx.boardId]
|
|
|
|
let issues = s.issues.find(
|
|
propsFilter(newTable([("context", ctx.context)])))
|
|
|
|
let cards: seq[tuple[listName: TrelloListName, card: TrelloCard]] =
|
|
board.lists.pairs.toSeq -->
|
|
map(distribute(it[0], it[1].cards)).
|
|
flatten()
|
|
|
|
var unmatchedCardIds = toHashSet(cards --> map(it[1].id))
|
|
var unmatchedIssueIds = toHashSet(issues --> map($it.id))
|
|
var matched = newSeq[tuple[issue: Issue, card: TrelloCard, listName: TrelloListName]]()
|
|
|
|
for i in issues:
|
|
if i.hasProp("trello-card-id"):
|
|
let cardId = i["trello-card-id"]
|
|
let foundCard = cards --> find(it.card.id == cardId)
|
|
|
|
if foundCard.isNone:
|
|
raise newException(Exception,
|
|
"pit issue " & ($i.id)[0..6] & " references a Trello card with id " &
|
|
cardId & " but that card is not any of the lists of the " &
|
|
board.name & " board.")
|
|
|
|
else:
|
|
let c = foundCard.get
|
|
matched.add((issue: i, card: c.card, listName: c.listName))
|
|
unmatchedCardIds.excl(c.card.id)
|
|
unmatchedIssueIds.excl($i.id)
|
|
|
|
for (lentIssue, lentCard, lentListName) in matched:
|
|
let (issue, card, listName) = (lentIssue, lentCard, lentListName)
|
|
let targetListName = listNameForIssueState(issue.state)
|
|
|
|
if board.equiv(card, issue) and targetListName == listName: continue
|
|
|
|
if issue.hasProp("last-updated") and
|
|
issue.getDateTime("last-updated") > card.lastUpdatedAt:
|
|
|
|
# The PIT issue is newer
|
|
let newCard = TrelloCard(
|
|
id: card.id,
|
|
desc: issue.details,
|
|
name: issue.summary,
|
|
labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id))
|
|
|
|
let list = board.lists[listNameForIssueState(issue.state)]
|
|
trello.updateCard(newCard, list)
|
|
info "Trello updated: " & formatSectionIssue(issue, width = 64)
|
|
|
|
else:
|
|
# The Trello issue is newer
|
|
issue.summary = card.name
|
|
issue.details = card.desc
|
|
issue.tags = card.labelIds -->
|
|
map(board.trelloLabelIdToTag(it)).
|
|
filter(it.isSome).
|
|
map(it.get)
|
|
|
|
if targetListName != listName:
|
|
issue.changeState(s.pit.tasksDir, issueStateForListName(listName))
|
|
else: issue.store
|
|
|
|
info "pit updated: " & formatSectionIssue(issue, width = 64)
|
|
|
|
let unmatchedCards = cards --> filter(unmatchedCardIds.contains(it.card.id))
|
|
let unmatchedIssues = issues --> filter(unmatchedIssueIds.contains($it.id))
|
|
|
|
for (lentListName, lentCard) in unmatchedCards:
|
|
let (listName, card) = (lentListName, lentCard)
|
|
let issue = Issue(
|
|
id: genUUID(),
|
|
summary: card.name,
|
|
details: card.desc,
|
|
properties: newTable({
|
|
"context": ctx.context,
|
|
"trello-card-id": card.id
|
|
}),
|
|
tags: card.labelIds -->
|
|
map(board.trelloLabelIdToTag(it)).
|
|
filter(it.isSome).
|
|
map(it.get))
|
|
|
|
info "pit created: " & formatSectionIssue(issue, width = 64)
|
|
s.pit.tasksDir.store(issue, issueStateForListName(listName))
|
|
|
|
for lentIssue in unmatchedIssues:
|
|
let issue = lentIssue
|
|
let newCard = TrelloCard(
|
|
name: issue.summary,
|
|
desc: issue.details,
|
|
labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id))
|
|
|
|
let list = board.lists[listNameForIssueState(issue.state)]
|
|
let createdCard = trello.createCard(newCard, list)
|
|
issue["trello-card-id"] = createdCard.id
|
|
issue.store
|
|
info "Trello created: " & formatSectionIssue(issue, width = 64)
|
|
|
|
when isMainModule:
|
|
try:
|
|
|
|
let consoleLogger = newConsoleLogger(
|
|
levelThreshold = lvlInfo,
|
|
fmtStr = "pit2trello - $levelname: ")
|
|
logging.addHandler(consoleLogger)
|
|
|
|
let args = docopt(USAGE, version = PIT2TRELLO_VERSION)
|
|
|
|
if args["--debug"]:
|
|
consoleLogger.levelThreshold = lvlDebug
|
|
|
|
var cfg = loadCliConfig(args)
|
|
|
|
if args["sync"]:
|
|
let trello = initTrelloApiClient(cfg.apiKey, cfg.apiBaseUrl)
|
|
let state = loadIssuesAndBoards(cfg, trello)
|
|
|
|
for ctx in cfg.contexts: trello.syncContext(state, ctx)
|
|
|
|
except CatchableError:
|
|
let ex = getCurrentException()
|
|
fatal ex.msg
|
|
debug ex.getStackTrace
|
|
quit(QuitFailure)
|