1package jira
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "time"
9
10 "github.com/pkg/errors"
11
12 "github.com/MichaelMure/git-bug/bridge/core"
13 "github.com/MichaelMure/git-bug/bug"
14 "github.com/MichaelMure/git-bug/cache"
15 "github.com/MichaelMure/git-bug/entity"
16)
17
18// jiraExporter implement the Exporter interface
19type jiraExporter struct {
20 conf core.Configuration
21
22 // cache identities clients
23 identityClient map[entity.Id]*Client
24
25 // cache identifiers used to speed up exporting operations
26 // cleared for each bug
27 cachedOperationIDs map[entity.Id]string
28
29 // cache labels used to speed up exporting labels events
30 cachedLabels map[string]string
31
32 // store JIRA project information
33 project *Project
34}
35
36// Init .
37func (self *jiraExporter) Init(conf core.Configuration) error {
38 self.conf = conf
39 //TODO: initialize with multiple tokens
40 self.identityClient = make(map[entity.Id]*Client)
41 self.cachedOperationIDs = make(map[entity.Id]string)
42 self.cachedLabels = make(map[string]string)
43 return nil
44}
45
46// getIdentityClient return an API client configured with the credentials
47// of the given identity. If no client were found it will initialize it from
48// the known credentials map and cache it for next use
49func (self *jiraExporter) getIdentityClient(
50 ctx *context.Context, id entity.Id) (*Client, error) {
51 client, ok := self.identityClient[id]
52 if ok {
53 return client, nil
54 }
55
56 // TODO(josh)[]: The github exporter appears to contain code that will
57 // allow it to export bugs owned by other people as long as we have a token
58 // for that identity. I guess the equivalent for us will be as long as we
59 // have a credentials pair for that identity.
60 return nil, fmt.Errorf("Not implemented")
61}
62
63// ExportAll export all event made by the current user to Jira
64func (self *jiraExporter) ExportAll(
65 ctx context.Context, repo *cache.RepoCache, since time.Time) (
66 <-chan core.ExportResult, error) {
67
68 out := make(chan core.ExportResult)
69
70 user, err := repo.GetUserIdentity()
71 if err != nil {
72 return nil, err
73 }
74
75 // TODO(josh)[]: The github exporter appears to contain code that will
76 // allow it to export bugs owned by other people as long as we have a token
77 // for that identity. I guess the equivalent for us will be as long as we
78 // have a credentials pair for that identity.
79 client := NewClient(self.conf[keyServer], &ctx)
80 err = client.Login(self.conf)
81 self.identityClient[user.Id()] = client
82
83 if err != nil {
84 return nil, err
85 }
86
87 client, err = self.getIdentityClient(&ctx, user.Id())
88 if err != nil {
89 return nil, err
90 }
91
92 self.project, err = client.GetProject(self.conf[keyProject])
93 if err != nil {
94 return nil, err
95 }
96
97 go func() {
98 defer close(out)
99
100 var allIdentitiesIds []entity.Id
101 for id := range self.identityClient {
102 allIdentitiesIds = append(allIdentitiesIds, id)
103 }
104
105 allBugsIds := repo.AllBugsIds()
106
107 for _, id := range allBugsIds {
108 b, err := repo.ResolveBug(id)
109 if err != nil {
110 out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
111 return
112 }
113
114 select {
115
116 case <-ctx.Done():
117 // stop iterating if context cancel function is called
118 return
119
120 default:
121 snapshot := b.Snapshot()
122
123 // ignore issues whose last modification date is before the query date
124 // TODO: compare the Lamport time instead of using the unix time
125 if snapshot.CreatedAt.Before(since) {
126 out <- core.NewExportNothing(b.Id(), "bug created before the since date")
127 continue
128 }
129
130 if snapshot.HasAnyActor(allIdentitiesIds...) {
131 // try to export the bug and it associated events
132 self.exportBug(ctx, b, since, out)
133 } else {
134 out <- core.NewExportNothing(id, "not an actor")
135 }
136 }
137 }
138 }()
139
140 return out, nil
141}
142
143// exportBug publish bugs and related events
144func (self *jiraExporter) exportBug(
145 ctx context.Context, b *cache.BugCache, since time.Time,
146 out chan<- core.ExportResult) {
147 snapshot := b.Snapshot()
148
149 var bugJiraID string
150
151 // Special case:
152 // if a user try to export a bug that is not already exported to jira (or
153 // imported from jira) and we do not have the token of the bug author,
154 // there is nothing we can do.
155
156 // first operation is always createOp
157 createOp := snapshot.Operations[0].(*bug.CreateOperation)
158 author := snapshot.Author
159
160 // skip bug if it was imported from some other bug system
161 origin, ok := snapshot.GetCreateMetadata(keyOrigin)
162 if ok && origin != target {
163 out <- core.NewExportNothing(
164 b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
165 return
166 }
167
168 // skip bug if it is a jira bug but is associated with another project
169 // (one bridge per JIRA project)
170 project, ok := snapshot.GetCreateMetadata(keyJiraProject)
171 if ok && !stringInSlice(project, []string{self.project.ID, self.project.Key}) {
172 out <- core.NewExportNothing(
173 b.Id(), fmt.Sprintf("issue tagged with project: %s", project))
174 return
175 }
176
177 // get jira bug ID
178 jiraID, ok := snapshot.GetCreateMetadata(keyJiraID)
179 if ok {
180 out <- core.NewExportNothing(b.Id(), "bug creation already exported")
181 // will be used to mark operation related to a bug as exported
182 bugJiraID = jiraID
183 } else {
184 // check that we have credentials for operation author
185 client, err := self.getIdentityClient(&ctx, author.Id())
186 if err != nil {
187 // if bug is not yet exported and we do not have the author's credentials
188 // then there is nothing we can do, so just skip this bug
189 out <- core.NewExportNothing(
190 b.Id(), fmt.Sprintf("missing author token for user %.8s",
191 author.Id().String()))
192 return
193 }
194
195 // Load any custom fields required to create an issue from the git
196 // config file.
197 fields := make(map[string]interface{})
198 defaultFields, hasConf := self.conf[keyCreateDefaults]
199 if hasConf {
200 err = json.Unmarshal([]byte(defaultFields), &fields)
201 if err != nil {
202 panic("Invalid JSON in config")
203 }
204 } else {
205 // If there is no configuration provided, at the very least the
206 // "issueType" field is always required. 10001 is "story" which I'm
207 // pretty sure is standard/default on all JIRA instances.
208 fields["issueType"] = "10001"
209 }
210 bugIDField, hasConf := self.conf[keyCreateGitBug]
211 if hasConf {
212 // If the git configuration also indicates it, we can assign the git-bug
213 // id to a custom field to assist in integrations
214 fields[bugIDField] = b.Id().String()
215 }
216
217 // create bug
218 result, err := client.CreateIssue(
219 self.project.ID, createOp.Title, createOp.Message, fields)
220 if err != nil {
221 err := errors.Wrap(err, "exporting jira issue")
222 out <- core.NewExportError(err, b.Id())
223 return
224 }
225
226 id := result.ID
227 out <- core.NewExportBug(b.Id())
228 // mark bug creation operation as exported
229 err = markOperationAsExported(
230 b, createOp.Id(), id, self.project.Key, time.Time{})
231 if err != nil {
232 err := errors.Wrap(err, "marking operation as exported")
233 out <- core.NewExportError(err, b.Id())
234 return
235 }
236
237 // commit operation to avoid creating multiple issues with multiple pushes
238 err = b.CommitAsNeeded()
239 if err != nil {
240 err := errors.Wrap(err, "bug commit")
241 out <- core.NewExportError(err, b.Id())
242 return
243 }
244
245 // cache bug jira ID
246 bugJiraID = id
247 }
248
249 // cache operation jira id
250 self.cachedOperationIDs[createOp.Id()] = bugJiraID
251
252 // lookup the mapping from git-bug "status" to JIRA "status" id
253 statusMap := getStatusMap(self.conf)
254
255 for _, op := range snapshot.Operations[1:] {
256 // ignore SetMetadata operations
257 if _, ok := op.(*bug.SetMetadataOperation); ok {
258 continue
259 }
260
261 // ignore operations already existing in jira (due to import or export)
262 // cache the ID of already exported or imported issues and events from
263 // Jira
264 if id, ok := op.GetMetadata(keyJiraID); ok {
265 self.cachedOperationIDs[op.Id()] = id
266 out <- core.NewExportNothing(op.Id(), "already exported operation")
267 continue
268 }
269
270 opAuthor := op.GetAuthor()
271 client, err := self.getIdentityClient(&ctx, opAuthor.Id())
272 if err != nil {
273 out <- core.NewExportNothing(
274 op.Id(), fmt.Sprintf(
275 "missing operation author credentials for user %.8s",
276 author.Id().String()))
277 continue
278 }
279
280 var id string
281 var exportTime time.Time
282 switch opr := op.(type) {
283 case *bug.AddCommentOperation:
284 comment, err := client.AddComment(bugJiraID, opr.Message)
285 if err != nil {
286 err := errors.Wrap(err, "adding comment")
287 out <- core.NewExportError(err, b.Id())
288 return
289 }
290 id = comment.ID
291 out <- core.NewExportComment(op.Id())
292
293 // cache comment id
294 self.cachedOperationIDs[op.Id()] = id
295
296 case *bug.EditCommentOperation:
297 if opr.Target == createOp.Id() {
298 // An EditCommentOpreation with the Target set to the create operation
299 // encodes a modification to the long-description/summary.
300 exportTime, err = client.UpdateIssueBody(bugJiraID, opr.Message)
301 if err != nil {
302 err := errors.Wrap(err, "editing issue")
303 out <- core.NewExportError(err, b.Id())
304 return
305 }
306 out <- core.NewExportCommentEdition(op.Id())
307 id = bugJiraID
308 } else {
309 // Otherwise it's an edit to an actual comment. A comment cannot be
310 // edited before it was created, so it must be the case that we have
311 // already observed and cached the AddCommentOperation.
312 commentID, ok := self.cachedOperationIDs[opr.Target]
313 if !ok {
314 // Since an edit has to come after the creation, we expect we would
315 // have cached the creation id.
316 panic("unexpected error: comment id not found")
317 }
318 comment, err := client.UpdateComment(bugJiraID, commentID, opr.Message)
319 if err != nil {
320 err := errors.Wrap(err, "editing comment")
321 out <- core.NewExportError(err, b.Id())
322 return
323 }
324 out <- core.NewExportCommentEdition(op.Id())
325 // JIRA doesn't track all comment edits, they will only tell us about
326 // the most recent one. We must invent a consistent id for the operation
327 // so we use the comment ID plus the timestamp of the update, as
328 // reported by JIRA. Note that this must be consistent with the importer
329 // during ensureComment()
330 id = fmt.Sprintf("%s-%d", comment.ID, comment.Updated.Unix())
331 }
332
333 case *bug.SetStatusOperation:
334 jiraStatus, hasStatus := statusMap[opr.Status.String()]
335 if hasStatus {
336 exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus)
337 if err != nil {
338 err := errors.Wrap(err, "editing status")
339 out <- core.NewExportError(err, b.Id())
340 // Failure to update status isn't necessarily a big error. It's
341 // possible that we just don't have enough information to make that
342 // update. In this case, just don't export the operation.
343 continue
344 }
345 out <- core.NewExportStatusChange(op.Id())
346 // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
347 // we don't re-import the same change.
348 id = bugJiraID
349 } else {
350 out <- core.NewExportNothing(
351 op.Id(), fmt.Sprintf(
352 "No jira status mapped for %.8s", opr.Status.String()))
353 }
354
355 case *bug.SetTitleOperation:
356 exportTime, err = client.UpdateIssueTitle(bugJiraID, opr.Title)
357 if err != nil {
358 err := errors.Wrap(err, "editing title")
359 out <- core.NewExportError(err, b.Id())
360 return
361 }
362 out <- core.NewExportTitleEdition(op.Id())
363 // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
364 // we don't re-import the same change.
365 id = bugJiraID
366
367 case *bug.LabelChangeOperation:
368 exportTime, err = client.UpdateLabels(
369 bugJiraID, opr.Added, opr.Removed)
370 if err != nil {
371 err := errors.Wrap(err, "updating labels")
372 out <- core.NewExportError(err, b.Id())
373 return
374 }
375 out <- core.NewExportLabelChange(op.Id())
376 // TODO(josh)[c2c6767]: query changelog to get the changelog-id so that
377 // we don't re-import the same change.
378 id = bugJiraID
379
380 default:
381 panic("unhandled operation type case")
382 }
383
384 // mark operation as exported
385 // TODO(josh)[c2c6767]: Should we query the changelog after we export?
386 // Some of the operations above don't record an ID... so we are bound to
387 // re-import them. It shouldn't cause too much of an issue but we will have
388 // duplicate edit entries for everything and it would be nice to avoid that.
389 err = markOperationAsExported(
390 b, op.Id(), id, self.project.Key, exportTime)
391 if err != nil {
392 err := errors.Wrap(err, "marking operation as exported")
393 out <- core.NewExportError(err, b.Id())
394 return
395 }
396
397 // commit at each operation export to avoid exporting same events multiple
398 // times
399 err = b.CommitAsNeeded()
400 if err != nil {
401 err := errors.Wrap(err, "bug commit")
402 out <- core.NewExportError(err, b.Id())
403 return
404 }
405 }
406}
407
408func markOperationAsExported(
409 b *cache.BugCache, target entity.Id, jiraID, jiraProject string,
410 exportTime time.Time) error {
411
412 newMetadata := map[string]string{
413 keyJiraID: jiraID,
414 keyJiraProject: jiraProject,
415 }
416 if !exportTime.IsZero() {
417 newMetadata[keyJiraExportTime] = exportTime.Format(http.TimeFormat)
418 }
419
420 _, err := b.SetMetadata(target, newMetadata)
421 return err
422}
423
424// UpdateIssueStatus attempts to change the "status" field by finding a
425// transition which achieves the desired state and then performing that
426// transition
427func UpdateIssueStatus(
428 client *Client, issueKeyOrID string, desiredStateNameOrID string) (
429 time.Time, error) {
430
431 var responseTime time.Time
432
433 tlist, err := client.GetTransitions(issueKeyOrID)
434 if err != nil {
435 return responseTime, err
436 }
437
438 transition := getTransitionTo(tlist, desiredStateNameOrID)
439 if transition == nil {
440 return responseTime, errTransitionNotFound
441 }
442
443 responseTime, err = client.DoTransition(issueKeyOrID, transition.ID)
444 if err != nil {
445 return responseTime, err
446 }
447
448 return responseTime, nil
449}