diff --git a/pit2trello.nimble b/pit2trello.nimble index 2789203..112b739 100644 --- a/pit2trello.nimble +++ b/pit2trello.nimble @@ -1,6 +1,6 @@ # Package -version = "0.1.1" +version = "0.2.0" author = "Jonathan Bernard" description = "Synchronization tool between JDB pit and Trello." license = "MIT" @@ -26,4 +26,4 @@ requires @[ ] 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'" \ No newline at end of file diff --git a/src/pit2trello.nim b/src/pit2trello.nim index 1f2955a..3dbc807 100644 --- a/src/pit2trello.nim +++ b/src/pit2trello.nim @@ -57,19 +57,19 @@ proc loadIssuesAndBoards(cfg: CliConfig, trello: TrelloApiClient): SyncState = contexts: cfg.contexts, issues: cfg.pit.tasksDir.loadAllIssues) -proc listNameForIssueState(issState: IssueState): TrelloListName = +proc listCategoryForIssueState(issState: IssueState): TrelloListCategory = return case issState: - of Current, TodoToday, Pending: tlnInProgress - of Dormant, Todo: tlnTodo - of Done: tlnDone + of Current, TodoToday, Pending: tlcInProgress + of Dormant, Todo: tlcTodo + of Done: tlcDone -proc issueStateForListName(listName: TrelloListName): IssueState = +proc issueStateForListCategory(listCategory: TrelloListCategory): IssueState = return - case listName: - of tlnTodo: Todo - of tlnInProgress: TodoToday - of tlnDone, tlnUnknown: Done + case listCategory: + of tlcTodo: Todo + of tlcInProgress: TodoToday + of tlcDone, tlcUnknown: Done proc tagToTrelloLabel(trello: TrelloApiClient, board: TrelloBoard, tag: string): TrelloLabel = let foundLabel = board.labels --> find(it.name == tag) @@ -82,12 +82,6 @@ proc trelloLabelIdToTag(board: TrelloBoard, labelId: string): Option[string] = 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 @@ -96,7 +90,13 @@ proc equiv(board: TrelloBoard, tc: TrelloCard, iss: Issue): bool = filter(it.isSome). 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): 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( 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 --> - map(distribute(it[0], it[1].cards)). - flatten() + map(distribute(it[0], it[1])).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 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: if i.hasProp("trello-card-id"): let cardId = i["trello-card-id"] @@ -127,15 +133,18 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) = else: 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) unmatchedIssueIds.excl($i.id) - for (lentIssue, lentCard, lentListName) in matched: - let (issue, card, listName) = (lentIssue, lentCard, lentListName) - let targetListName = listNameForIssueState(issue.state) + ## 2. Process updates to the matched cards + for (lentIssue, lentCard, lentList) in matched: + 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 issue.getDateTime("last-updated") > card.lastUpdatedAt: @@ -147,9 +156,16 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) = 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) + if not dryRun: + var targetListId: string + 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: # The Trello issue is newer @@ -160,17 +176,19 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) = filter(it.isSome). map(it.get) - if targetListName != listName: - issue.changeState(s.pit.tasksDir, issueStateForListName(listName)) - else: issue.store + if not dryRun: + if pitListCategory != list.category: + issue.changeState(s.pit.tasksDir, issueStateForListCategory(list.category)) + 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 unmatchedIssues = issues --> filter(unmatchedIssueIds.contains($it.id)) - for (lentListName, lentCard) in unmatchedCards: - let (listName, card) = (lentListName, lentCard) + for (list, lentCard) in unmatchedCards: + let (list, card) = (list, lentCard) let issue = Issue( id: genUUID(), summary: card.name, @@ -184,21 +202,26 @@ proc syncContext(trello: TrelloApiClient, s: SyncState, ctx: TrelloContext) = filter(it.isSome). map(it.get)) - info "pit created: " & formatSectionIssue(issue, width = 64) - s.pit.tasksDir.store(issue, issueStateForListName(listName)) + let issueState = issueStateForListCategory(list.category) + if not dryRun: s.pit.tasksDir.store(issue, issueState) + info "pit created: " & formatSectionIssue(issue, width = 64) & " " & + $issueState for lentIssue in unmatchedIssues: let issue = lentIssue + let list = board.lists[listCategoryForIssueState(issue.state)][0] 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) + if not dryRun: + let createdCard = trello.createCard(newCard, list) + issue["trello-card-id"] = createdCard.id + issue.store + + info "Trello created: " & formatSectionIssue(issue, width = 64) & " " & + list.name when isMainModule: try: @@ -219,7 +242,7 @@ when isMainModule: let trello = initTrelloApiClient(cfg.apiKey, cfg.apiBaseUrl) 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: let ex = getCurrentException() diff --git a/src/pit2trellopkg/api.nim b/src/pit2trellopkg/api.nim index 24f29b1..2d6b7c1 100644 --- a/src/pit2trellopkg/api.nim +++ b/src/pit2trellopkg/api.nim @@ -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 from sequtils import toSeq type - TrelloListName* = enum - tlnTodo = "Todo" - tlnInProgress = "In Progress" - tlnDone = "Done" - tlnUnknown = "UNKNOWN" + TrelloListCategory* = enum + tlcTodo = "Todo" + tlcInProgress = "In Progress" + tlcDone = "Done" + tlcUnknown = "UNKNOWN" TrelloApiCredentials* = object key*: string @@ -29,7 +29,7 @@ type TrelloBoard* = ref object id*, name*: string - lists*: TableRef[TrelloListName, TrelloList] + lists*: TableRef[TrelloListCategory, seq[TrelloList]] labels*: seq[TrelloLabel] TrelloApiClient* = ref object @@ -37,6 +37,16 @@ type credentials: TrelloApiCredentials 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 = if not node.hasKey(key): raise newException(ValueError, "missing key '" & key & "'") @@ -45,12 +55,6 @@ func getOrFail(node: JsonNode, key: string): JsonNode = func indentLines(s: string, indent = " "): string = (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 `&`( params: openArray[(string, string)], creds: TrelloApiCredentials @@ -150,13 +154,13 @@ proc createCard*( proc updateCard*( self: TrelloApiClient, c: TrelloCard, - targetList: TrelloList + targetListId: string ) = let params = { "name": encodeUrl(c.name), "desc": encodeUrl(c.desc), - "idList": targetList.id, + "idList": targetListId, "idLabels": c.labelIds.join(",") } @@ -167,6 +171,12 @@ proc updateCard*( raise newException(IOError, "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 = let boardUrl = self.baseUrl & "/1/boards/" & boardId debug "Loading board: GET " & boardUrl & "?" & qp(self.credentials) @@ -207,20 +217,20 @@ proc loadBoard*(self: TrelloApiClient, boardId: string): TrelloBoard = name: it.getOrFail("name").getStr, cards: self.loadCards(it.getOrFail("id").getStr))) - result.lists = newTable[TrelloListName, TrelloList]() - result.lists[tlnUnknown] = TrelloList(id: "", name: "Unknown", cards: @[]) + result.lists = newTable[TrelloListCategory, seq[TrelloList]]() for lentList in lists: let l = lentList - let knownList = TrelloListName.items.toSeq --> find($it == l.name) - if knownList.isSome: result.lists[knownList.get] = l - else: - result.lists[tlnUnknown] = mergeCards(result.lists[tlnUnknown], l.cards) + let knownList = TrelloListCategory.items.toSeq --> find(l.name.startsWith($it)) + if knownList.isSome: result.lists.add(knownList.get, l) + else: result.lists.add(tlcUnknown, l) - for listName in TrelloListName: - if not result.lists.contains(listName): + for listCategory in TrelloListCategory: + if result.lists.contains(listCategory): + result.lists[listCategory] = sorted(result.lists[listCategory], api.cmp) + elif listCategory != tlcUnknown: raise newException(ValueError, - "board '" & result.name & "' has no list named '" & $listName & "'") + "board '" & result.name & "' has no lists for category '" & $listCategory & "'") debug $result diff --git a/src/pit2trellopkg/cliconstants.nim b/src/pit2trellopkg/cliconstants.nim index 5617b64..647a9b0 100644 --- a/src/pit2trellopkg/cliconstants.nim +++ b/src/pit2trellopkg/cliconstants.nim @@ -1,4 +1,4 @@ -const PIT2TRELLO_VERSION* = "0.1.0" +const PIT2TRELLO_VERSION* = "0.2.0" const USAGE* = """ Usage: @@ -9,6 +9,9 @@ Options: --config Path to the pit configuration file. By default this is $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. --help Print this help and usage information.