Initial implementation.
This commit is contained in:
parent
0dbac771a8
commit
05d688b1b9
@ -4,10 +4,26 @@ version = "0.1.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Synchronization tool between JDB pit and Trello."
|
||||
license = "MIT"
|
||||
srcDir = "src/main/nim"
|
||||
srcDir = "src"
|
||||
bin = @["pit2trello"]
|
||||
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires "nim >= 1.6.10"
|
||||
requires @[
|
||||
"nim >= 1.6.10",
|
||||
"docopt",
|
||||
"uuids >= 0.1.10",
|
||||
"zero_functional"
|
||||
]
|
||||
|
||||
# Dependencies from git.jdb-software.com/jdb/nim-packages
|
||||
requires @[
|
||||
"cliutils >= 0.8.0",
|
||||
"pit >= 4.22.2",
|
||||
"timeutils",
|
||||
"update_nim_package_version"
|
||||
]
|
||||
|
||||
task updateVersion, "Update the version of this package.":
|
||||
exec "update_nim_package_version pit 'src/pit2trellopkg/cliconstants.nim'"
|
||||
|
1
src/config.nims
Normal file
1
src/config.nims
Normal file
@ -0,0 +1 @@
|
||||
switch("define", "ssl")
|
@ -1,5 +0,0 @@
|
||||
# This is just an example to get you started. A typical binary package
|
||||
# uses this file as the main entry point of the application.
|
||||
|
||||
when isMainModule:
|
||||
echo("Hello, World!")
|
228
src/pit2trello.nim
Normal file
228
src/pit2trello.nim
Normal file
@ -0,0 +1,228 @@
|
||||
## 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:
|
||||
let ex = getCurrentException()
|
||||
fatal ex.msg
|
||||
debug ex.getStackTrace
|
||||
quit(QuitFailure)
|
235
src/pit2trellopkg/api.nim
Normal file
235
src/pit2trellopkg/api.nim
Normal file
@ -0,0 +1,235 @@
|
||||
import std/[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"
|
||||
|
||||
TrelloApiCredentials* = object
|
||||
key*: string
|
||||
secret*: string
|
||||
|
||||
TrelloLabel* = object
|
||||
id*, color*, name*: string
|
||||
labelIds*: seq[string]
|
||||
|
||||
TrelloCard* = object
|
||||
id*, desc*, name*: string
|
||||
labelIds*: seq[string]
|
||||
lastUpdatedAt*: DateTime
|
||||
|
||||
TrelloList* = object
|
||||
id*, name*: string
|
||||
cards*: seq[TrelloCard]
|
||||
|
||||
TrelloBoard* = ref object
|
||||
id*, name*: string
|
||||
lists*: TableRef[TrelloListName, TrelloList]
|
||||
labels*: seq[TrelloLabel]
|
||||
|
||||
TrelloApiClient* = ref object
|
||||
http: HttpClient
|
||||
credentials: TrelloApiCredentials
|
||||
baseUrl*: string
|
||||
|
||||
func getOrFail(node: JsonNode, key: string): JsonNode =
|
||||
if not node.hasKey(key):
|
||||
raise newException(ValueError, "missing key '" & key & "'")
|
||||
return node[key]
|
||||
|
||||
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
|
||||
): seq[(string, string)] =
|
||||
|
||||
result = params.toSeq & {
|
||||
"key": creds.key,
|
||||
"token": creds.secret
|
||||
}.toSeq
|
||||
|
||||
proc qp(self: openarray[(string, string)]): string =
|
||||
let s = self.toSeq
|
||||
(s --> map(it[0] & "=" & it[1])).join("&")
|
||||
|
||||
proc qp(credentials: TrelloApiCredentials): string =
|
||||
return qp([] & credentials)
|
||||
|
||||
func `$`*(c: TrelloCard): string =
|
||||
return c.name & " (" & c.id & ")"
|
||||
|
||||
func `$`*(l: TrelloList): string =
|
||||
result = l.name & " (" & l.id & ")"
|
||||
result &= (l.cards --> map("\p" & $it)).join("").indentLines
|
||||
|
||||
func `$`*(b: TrelloBoard): string =
|
||||
result = b.name & " (" & b.id & ")"
|
||||
result &= (b.lists.values.toSeq --> map("\p" & $it)).join("").indentLines
|
||||
|
||||
proc parseTrelloLabel*(json: JsonNode): TrelloLabel =
|
||||
TrelloLabel(
|
||||
id: json.getOrFail("id").getStr,
|
||||
name: json.getOrFail("name").getStr,
|
||||
color: json.getOrFail("color").getStr)
|
||||
|
||||
proc parseTrelloCard*(json: JsonNode): TrelloCard =
|
||||
result = TrelloCard(
|
||||
id: json.getOrFail("id").getStr,
|
||||
name: json.getOrFail("name").getStr,
|
||||
desc: json["desc"].getStr(""),
|
||||
lastUpdatedAt: parseIso8601(json.getOrFail("dateLastActivity").getStr),
|
||||
labelIds: json["idLabels"].getElems() --> map(it.getStr()))
|
||||
|
||||
proc createLabel*(
|
||||
self: TrelloApiClient,
|
||||
board: TrelloBoard,
|
||||
name: string,
|
||||
color = "blue"
|
||||
): TrelloLabel =
|
||||
|
||||
let params = {
|
||||
"name": encodeUrl(name),
|
||||
"color": color
|
||||
}
|
||||
|
||||
let url = self.baseUrl & "/1/boards/" & board.id & "/labels"
|
||||
debug "Creating a new label: POST " & url & "?" & params.qp
|
||||
let resp = self.http.post(url & "?" & qp(params & self.credentials))
|
||||
if not resp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to create new label on board '" & board.id & "': " & resp.body)
|
||||
|
||||
return parseTrelloLabel(parseJson(resp.body))
|
||||
|
||||
proc loadCards*(self: TrelloApiClient, listId: string): seq[TrelloCard] =
|
||||
let cardsUrl = self.baseUrl & "/1/lists/" & listId & "/cards"
|
||||
debug "Loading cards on list: GET " & cardsUrl
|
||||
let cardsResp = self.http.get(cardsUrl & "?" & qp(self.credentials))
|
||||
if not cardsResp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to fetch cards for list '" & listId & "': " & cardsResp.body)
|
||||
|
||||
let cardsJson = parseJson(cardsResp.body)
|
||||
result = cardsJson.getElems.toSeq --> map(it.parseTrelloCard)
|
||||
|
||||
proc createCard*(
|
||||
self: TrelloApiClient,
|
||||
c: TrelloCard,
|
||||
targetList: TrelloList
|
||||
): TrelloCard =
|
||||
|
||||
let params = {
|
||||
"name": encodeUrl(c.name),
|
||||
"desc": encodeUrl(c.desc),
|
||||
"idLabels": c.labelIds.join(","),
|
||||
"idList": targetList.id
|
||||
}
|
||||
|
||||
let url = self.baseUrl & "/1/cards"
|
||||
debug "Creating a new card: POST " & url & "?" & params.qp
|
||||
let resp = self.http.post(url & "?" & qp(params & self.credentials))
|
||||
if not resp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to create a card on list '" & targetList.id & "'")
|
||||
|
||||
return parseTrelloCard(parseJson(resp.body))
|
||||
|
||||
proc updateCard*(
|
||||
self: TrelloApiClient,
|
||||
c: TrelloCard,
|
||||
targetList: TrelloList
|
||||
) =
|
||||
|
||||
let params = {
|
||||
"name": encodeUrl(c.name),
|
||||
"desc": encodeUrl(c.desc),
|
||||
"idList": targetList.id,
|
||||
"idLabels": c.labelIds.join(",")
|
||||
}
|
||||
|
||||
let url = self.baseUrl & "/1/cards/" & c.id
|
||||
debug "Updating card: PUT " & url & "?" & params.qp
|
||||
let resp = self.http.put(url & "?" & qp(params & self.credentials))
|
||||
if not resp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to update card for id '" & c.id & "': " & resp.body)
|
||||
|
||||
proc loadBoard*(self: TrelloApiClient, boardId: string): TrelloBoard =
|
||||
let boardUrl = self.baseUrl & "/1/boards/" & boardId
|
||||
debug "Loading board: GET " & boardUrl & "?" & qp(self.credentials)
|
||||
let boardResp = self.http.get(boardUrl & "?" & qp(self.credentials))
|
||||
if not boardResp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to fetch board for id '" & boardId & "': " & boardResp.body)
|
||||
|
||||
let boardJson = parseJson(boardResp.body)
|
||||
result = TrelloBoard(
|
||||
id: boardJson.getOrFail("id").getStr,
|
||||
name: boardJson.getOrFail("name").getStr,
|
||||
labels: @[])
|
||||
|
||||
let labelsUrl = self.baseUrl & "/1/boards/" & boardId & "/labels"
|
||||
debug "Loading labels: GET " & boardUrl
|
||||
let labelsResp = self.http.get(labelsUrl & "?" & qp(self.credentials))
|
||||
if labelsResp.code.is2xx:
|
||||
result.labels = parseJson(labelsResp.body).getElems.toSeq -->
|
||||
map(it.parseTrelloLabel).
|
||||
filter(not isEmptyOrWhitespace(it.name))
|
||||
else:
|
||||
warn "unable to fetch labels for board id '" & boardId & "': " & labelsResp.body
|
||||
|
||||
|
||||
let listsUrl = self.baseUrl & "/1/boards/" & boardId & "/lists"
|
||||
debug "Loading lists: GET " & boardUrl
|
||||
let listsResp = self.http.get(listsUrl & "?" & qp(self.credentials))
|
||||
if not listsResp.code.is2xx:
|
||||
raise newException(IOError,
|
||||
"unable to fetch lists for board id '" & boardId & "': " & listsResp.body)
|
||||
|
||||
let listsJson = parseJson(listsResp.body)
|
||||
let lists: seq[TrelloList] =
|
||||
listsJson.getElems.toSeq -->
|
||||
map(TrelloList(
|
||||
id: it.getOrFail("id").getStr,
|
||||
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: @[])
|
||||
|
||||
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)
|
||||
|
||||
for listName in TrelloListName:
|
||||
if not result.lists.contains(listName):
|
||||
raise newException(ValueError,
|
||||
"board '" & result.name & "' has no list named '" & $listName & "'")
|
||||
|
||||
debug $result
|
||||
|
||||
proc initTrelloApiClient*(
|
||||
cred: TrelloApiCredentials,
|
||||
baseUrl: string
|
||||
): TrelloApiClient =
|
||||
|
||||
result = TrelloApiClient(
|
||||
http: newHttpClient(),
|
||||
credentials: cred,
|
||||
baseUrl: baseUrl)
|
15
src/pit2trellopkg/cliconstants.nim
Normal file
15
src/pit2trellopkg/cliconstants.nim
Normal file
@ -0,0 +1,15 @@
|
||||
const PIT2TRELLO_VERSION* = "0.1.0"
|
||||
|
||||
const USAGE* = """
|
||||
Usage:
|
||||
pit2trello sync [options]
|
||||
|
||||
Options:
|
||||
|
||||
--config <cfgFile> Path to the pit configuration file. By default this is
|
||||
$HOME/.pitrc
|
||||
|
||||
--debug Enable verbose debug logging.
|
||||
|
||||
--help Print this help and usage information.
|
||||
"""
|
Loading…
Reference in New Issue
Block a user