## 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)