Add --dry-run, support multiple lists per category (Done in particular).

This commit is contained in:
Jonathan Bernard 2023-11-04 11:54:37 -05:00
parent d59a91401c
commit 214ac9f1b3
4 changed files with 105 additions and 69 deletions

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.1.1" version = "0.2.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Synchronization tool between JDB pit and Trello." description = "Synchronization tool between JDB pit and Trello."
license = "MIT" license = "MIT"
@ -26,4 +26,4 @@ requires @[
] ]
task updateVersion, "Update the version of this package.": task updateVersion, "Update the version of this package.":
exec "update_nim_package_version pit 'src/pit2trellopkg/cliconstants.nim'" exec "update_nim_package_version pit2trello 'src/pit2trellopkg/cliconstants.nim'"

View File

@ -57,19 +57,19 @@ proc loadIssuesAndBoards(cfg: CliConfig, trello: TrelloApiClient): SyncState =
contexts: cfg.contexts, contexts: cfg.contexts,
issues: cfg.pit.tasksDir.loadAllIssues) issues: cfg.pit.tasksDir.loadAllIssues)
proc listNameForIssueState(issState: IssueState): TrelloListName = proc listCategoryForIssueState(issState: IssueState): TrelloListCategory =
return return
case issState: case issState:
of Current, TodoToday, Pending: tlnInProgress of Current, TodoToday, Pending: tlcInProgress
of Dormant, Todo: tlnTodo of Dormant, Todo: tlcTodo
of Done: tlnDone of Done: tlcDone
proc issueStateForListName(listName: TrelloListName): IssueState = proc issueStateForListCategory(listCategory: TrelloListCategory): IssueState =
return return
case listName: case listCategory:
of tlnTodo: Todo of tlcTodo: Todo
of tlnInProgress: TodoToday of tlcInProgress: TodoToday
of tlnDone, tlnUnknown: Done of tlcDone, tlcUnknown: Done
proc tagToTrelloLabel(trello: TrelloApiClient, board: TrelloBoard, tag: string): TrelloLabel = proc tagToTrelloLabel(trello: TrelloApiClient, board: TrelloBoard, tag: string): TrelloLabel =
let foundLabel = board.labels --> find(it.name == tag) let foundLabel = board.labels --> find(it.name == tag)
@ -82,12 +82,6 @@ proc trelloLabelIdToTag(board: TrelloBoard, labelId: string): Option[string] =
else: return none[string]() else: return none[string]()
proc equiv(board: TrelloBoard, tc: TrelloCard, iss: Issue): bool = 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 return
tc.name == iss.summary and tc.name == iss.summary and
tc.desc == iss.details and tc.desc == iss.details and
@ -96,7 +90,13 @@ proc equiv(board: TrelloBoard, tc: TrelloCard, iss: Issue): bool =
filter(it.isSome). filter(it.isSome).
map(it.get)) map(it.get))
proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) = type TrelloListSummary = tuple[category: TrelloListCategory, id: string]
proc syncContext(
trello: TrelloApiClient,
s: SyncState,
ctx: TrelloContext,
dryRun = true) =
if not s.boards.contains(ctx.boardId): if not s.boards.contains(ctx.boardId):
raise newException(ValueError, "board not loaded for context\t" & $ctx) raise newException(ValueError, "board not loaded for context\t" & $ctx)
@ -105,15 +105,21 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
let issues = s.issues.find( let issues = s.issues.find(
propsFilter(newTable([("context", ctx.context)]))) propsFilter(newTable([("context", ctx.context)])))
let cards: seq[tuple[listName: TrelloListName, card: TrelloCard]] = let cards: seq[tuple[list: TrelloListSummary, card: TrelloCard]] =
board.lists.pairs.toSeq --> board.lists.pairs.toSeq -->
map(distribute(it[0], it[1].cards)). map(distribute(it[0], it[1])).flatten().
flatten() map(distribute((category: it[0], id: it[1].id), it[1].cards)).flatten()
## We're going to do a bi-direction sync. First we will iterate through the
## issues and cards identifying matches.
var unmatchedCardIds = toHashSet(cards --> map(it[1].id)) var unmatchedCardIds = toHashSet(cards --> map(it[1].id))
var unmatchedIssueIds = toHashSet(issues --> map($it.id)) var unmatchedIssueIds = toHashSet(issues --> map($it.id))
var matched = newSeq[tuple[issue: Issue, card: TrelloCard, listName: TrelloListName]]() var matched = newSeq[tuple[
issue: Issue,
card: TrelloCard,
list: TrelloListSummary]]()
## 1. Look for issues that already have a `trello-card-id` property.
for i in issues: for i in issues:
if i.hasProp("trello-card-id"): if i.hasProp("trello-card-id"):
let cardId = i["trello-card-id"] let cardId = i["trello-card-id"]
@ -127,15 +133,18 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
else: else:
let c = foundCard.get let c = foundCard.get
matched.add((issue: i, card: c.card, listName: c.listName)) matched.add((issue: i, card: c.card, list: c.list))
unmatchedCardIds.excl(c.card.id) unmatchedCardIds.excl(c.card.id)
unmatchedIssueIds.excl($i.id) unmatchedIssueIds.excl($i.id)
for (lentIssue, lentCard, lentListName) in matched: ## 2. Process updates to the matched cards
let (issue, card, listName) = (lentIssue, lentCard, lentListName) for (lentIssue, lentCard, lentList) in matched:
let targetListName = listNameForIssueState(issue.state) let (issue, card, list) = (lentIssue, lentCard, lentList)
let pitListCategory = listCategoryForIssueState(issue.state)
if board.equiv(card, issue) and targetListName == listName: continue ## 2.a Ignore cards that have no differences (have the same contents and
## are in the same list category)
if board.equiv(card, issue) and pitListCategory == list.category: continue
if issue.hasProp("last-updated") and if issue.hasProp("last-updated") and
issue.getDateTime("last-updated") > card.lastUpdatedAt: issue.getDateTime("last-updated") > card.lastUpdatedAt:
@ -147,9 +156,16 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
name: issue.summary, name: issue.summary,
labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id)) labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id))
let list = board.lists[listNameForIssueState(issue.state)] if not dryRun:
trello.updateCard(newCard, list) var targetListId: string
info "Trello updated: " & formatSectionIssue(issue, width = 64) if pitListCategory != list.category:
targetListId =
board.lists[listCategoryForIssueState(issue.state)][0].id
else: targetListId = list.id
trello.updateCard(newCard, targetListId)
info "Trello updated: " & formatSectionIssue(issue, width = 64) &
" " & $list.category & " --> " & $pitListCategory
else: else:
# The Trello issue is newer # The Trello issue is newer
@ -160,17 +176,19 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
filter(it.isSome). filter(it.isSome).
map(it.get) map(it.get)
if targetListName != listName: if not dryRun:
issue.changeState(s.pit.tasksDir, issueStateForListName(listName)) if pitListCategory != list.category:
issue.changeState(s.pit.tasksDir, issueStateForListCategory(list.category))
else: issue.store else: issue.store
info "pit updated: " & formatSectionIssue(issue, width = 64) info "pit updated: " & formatSectionIssue(issue, width = 64) &
" " & $pitListCategory & " --> " & $list.category
let unmatchedCards = cards --> filter(unmatchedCardIds.contains(it.card.id)) let unmatchedCards = cards --> filter(unmatchedCardIds.contains(it.card.id))
let unmatchedIssues = issues --> filter(unmatchedIssueIds.contains($it.id)) let unmatchedIssues = issues --> filter(unmatchedIssueIds.contains($it.id))
for (lentListName, lentCard) in unmatchedCards: for (list, lentCard) in unmatchedCards:
let (listName, card) = (lentListName, lentCard) let (list, card) = (list, lentCard)
let issue = Issue( let issue = Issue(
id: genUUID(), id: genUUID(),
summary: card.name, summary: card.name,
@ -184,21 +202,26 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) =
filter(it.isSome). filter(it.isSome).
map(it.get)) map(it.get))
info "pit created: " & formatSectionIssue(issue, width = 64) let issueState = issueStateForListCategory(list.category)
s.pit.tasksDir.store(issue, issueStateForListName(listName)) if not dryRun: s.pit.tasksDir.store(issue, issueState)
info "pit created: " & formatSectionIssue(issue, width = 64) & " " &
$issueState
for lentIssue in unmatchedIssues: for lentIssue in unmatchedIssues:
let issue = lentIssue let issue = lentIssue
let list = board.lists[listCategoryForIssueState(issue.state)][0]
let newCard = TrelloCard( let newCard = TrelloCard(
name: issue.summary, name: issue.summary,
desc: issue.details, desc: issue.details,
labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id)) labelIds: issue.tags --> map(trello.tagToTrelloLabel(board, it).id))
let list = board.lists[listNameForIssueState(issue.state)] if not dryRun:
let createdCard = trello.createCard(newCard, list) let createdCard = trello.createCard(newCard, list)
issue["trello-card-id"] = createdCard.id issue["trello-card-id"] = createdCard.id
issue.store issue.store
info "Trello created: " & formatSectionIssue(issue, width = 64)
info "Trello created: " & formatSectionIssue(issue, width = 64) & " " &
list.name
when isMainModule: when isMainModule:
try: try:
@ -219,7 +242,7 @@ when isMainModule:
let trello = initTrelloApiClient(cfg.apiKey, cfg.apiBaseUrl) let trello = initTrelloApiClient(cfg.apiKey, cfg.apiBaseUrl)
let state = loadIssuesAndBoards(cfg, trello) let state = loadIssuesAndBoards(cfg, trello)
for ctx in cfg.contexts: trello.syncContext(state, ctx) for ctx in cfg.contexts: trello.syncContext(state, ctx, args["--dry-run"])
except CatchableError: except CatchableError:
let ex = getCurrentException() let ex = getCurrentException()

View File

@ -1,14 +1,14 @@
import std/[httpclient, json, logging, options, strutils, tables, times, uri] import std/[algorithm, httpclient, json, logging, options, strutils, tables, times, uri]
import timeutils, zero_functional import timeutils, zero_functional
from sequtils import toSeq from sequtils import toSeq
type type
TrelloListName* = enum TrelloListCategory* = enum
tlnTodo = "Todo" tlcTodo = "Todo"
tlnInProgress = "In Progress" tlcInProgress = "In Progress"
tlnDone = "Done" tlcDone = "Done"
tlnUnknown = "UNKNOWN" tlcUnknown = "UNKNOWN"
TrelloApiCredentials* = object TrelloApiCredentials* = object
key*: string key*: string
@ -29,7 +29,7 @@ type
TrelloBoard* = ref object TrelloBoard* = ref object
id*, name*: string id*, name*: string
lists*: TableRef[TrelloListName, TrelloList] lists*: TableRef[TrelloListCategory, seq[TrelloList]]
labels*: seq[TrelloLabel] labels*: seq[TrelloLabel]
TrelloApiClient* = ref object TrelloApiClient* = ref object
@ -37,6 +37,16 @@ type
credentials: TrelloApiCredentials credentials: TrelloApiCredentials
baseUrl*: string baseUrl*: string
func add(
t: TableRef[TrelloListCategory, seq[TrelloList]],
c: TrelloListCategory,
l: TrelloList) =
if t.hasKey(c): t[c].add(l)
else: t[c] = @[l]
func cmp(a, b: TrelloList): int = cmp(a.name, b.name)
func getOrFail(node: JsonNode, key: string): JsonNode = func getOrFail(node: JsonNode, key: string): JsonNode =
if not node.hasKey(key): if not node.hasKey(key):
raise newException(ValueError, "missing key '" & key & "'") raise newException(ValueError, "missing key '" & key & "'")
@ -45,12 +55,6 @@ func getOrFail(node: JsonNode, key: string): JsonNode =
func indentLines(s: string, indent = " "): string = func indentLines(s: string, indent = " "): string =
(s.splitLines --> map(indent & it)).join("\p") (s.splitLines --> map(indent & it)).join("\p")
func mergeCards(l: TrelloList, c: seq[TrelloCard]): TrelloList =
return TrelloList(
id: l.id,
name: l.name,
cards: l.cards & c)
proc `&`( proc `&`(
params: openArray[(string, string)], params: openArray[(string, string)],
creds: TrelloApiCredentials creds: TrelloApiCredentials
@ -150,13 +154,13 @@ proc createCard*(
proc updateCard*( proc updateCard*(
self: TrelloApiClient, self: TrelloApiClient,
c: TrelloCard, c: TrelloCard,
targetList: TrelloList targetListId: string
) = ) =
let params = { let params = {
"name": encodeUrl(c.name), "name": encodeUrl(c.name),
"desc": encodeUrl(c.desc), "desc": encodeUrl(c.desc),
"idList": targetList.id, "idList": targetListId,
"idLabels": c.labelIds.join(",") "idLabels": c.labelIds.join(",")
} }
@ -167,6 +171,12 @@ proc updateCard*(
raise newException(IOError, raise newException(IOError,
"unable to update card for id '" & c.id & "': " & resp.body) "unable to update card for id '" & c.id & "': " & resp.body)
proc updateCard*(
self: TrelloApiClient,
c: TrelloCard,
targetList: TrelloList
) = updateCard(self, c, targetList.id)
proc loadBoard*(self: TrelloApiClient, boardId: string): TrelloBoard = proc loadBoard*(self: TrelloApiClient, boardId: string): TrelloBoard =
let boardUrl = self.baseUrl & "/1/boards/" & boardId let boardUrl = self.baseUrl & "/1/boards/" & boardId
debug "Loading board: GET " & boardUrl & "?" & qp(self.credentials) debug "Loading board: GET " & boardUrl & "?" & qp(self.credentials)
@ -207,20 +217,20 @@ proc loadBoard*(self: TrelloApiClient, boardId: string): TrelloBoard =
name: it.getOrFail("name").getStr, name: it.getOrFail("name").getStr,
cards: self.loadCards(it.getOrFail("id").getStr))) cards: self.loadCards(it.getOrFail("id").getStr)))
result.lists = newTable[TrelloListName, TrelloList]() result.lists = newTable[TrelloListCategory, seq[TrelloList]]()
result.lists[tlnUnknown] = TrelloList(id: "", name: "Unknown", cards: @[])
for lentList in lists: for lentList in lists:
let l = lentList let l = lentList
let knownList = TrelloListName.items.toSeq --> find($it == l.name) let knownList = TrelloListCategory.items.toSeq --> find(l.name.startsWith($it))
if knownList.isSome: result.lists[knownList.get] = l if knownList.isSome: result.lists.add(knownList.get, l)
else: else: result.lists.add(tlcUnknown, l)
result.lists[tlnUnknown] = mergeCards(result.lists[tlnUnknown], l.cards)
for listName in TrelloListName: for listCategory in TrelloListCategory:
if not result.lists.contains(listName): if result.lists.contains(listCategory):
result.lists[listCategory] = sorted(result.lists[listCategory], api.cmp)
elif listCategory != tlcUnknown:
raise newException(ValueError, raise newException(ValueError,
"board '" & result.name & "' has no list named '" & $listName & "'") "board '" & result.name & "' has no lists for category '" & $listCategory & "'")
debug $result debug $result

View File

@ -1,4 +1,4 @@
const PIT2TRELLO_VERSION* = "0.1.0" const PIT2TRELLO_VERSION* = "0.2.0"
const USAGE* = """ const USAGE* = """
Usage: Usage:
@ -9,6 +9,9 @@ Options:
--config <cfgFile> Path to the pit configuration file. By default this is --config <cfgFile> Path to the pit configuration file. By default this is
$HOME/.pitrc $HOME/.pitrc
--dry-run Do a dry run: show the changes that would be performed
but don't actually make any updates.
--debug Enable verbose debug logging. --debug Enable verbose debug logging.
--help Print this help and usage information. --help Print this help and usage information.