Add --dry-run, support multiple lists per category (Done in particular).
This commit is contained in:
parent
d59a91401c
commit
214ac9f1b3
@ -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'"
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
Loading…
Reference in New Issue
Block a user