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