• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rhyolight / sprinter.js / #432

03 Feb 2026 05:36AM UTC coverage: 61.803% (-0.3%) from 62.128%
#432

push

travis-ci

144 of 233 relevant lines covered (61.8%)

9.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.8
/sprinter.js
1
var GitHubApi = require('github')
8✔
2
  , _ = require('lodash')
3
  , async = require('async')
4
  , originalPrototypeFunctions = {}
5
  , markdownTasksRegex = /^((\-|\*) \[.\].*)$/gm
6
  , subtaskRegex = /(https?\:\/\/(www\.)?.*\/issues\/|\#)(\d+)/
7
  ;
8

9
/**
10
 * Converts an array of slug identifiers like "org/repo" into an array of arrays
11
 * like:
12
 * [ ["org", "repo"] ]
13
 */
14
function convertSlugsToObjects(slugs) {
1✔
15
    return slugs.map(function(slug) {
15✔
16
        return slug.split('/');
28✔
17
    });
18
}
19

20
/**
21
 * Sorts array of issue objects by last updated date.
22
 */
23
function sortIssues(issues) {
1✔
24
    var sorted;
6✔
25
    // Issues might be pre-arranged by super/sub tasks.
141✔
26
    if (Object.keys(issues).indexOf('supers') > -1) {
27
        // Network format issue sort
28
        issues.supers = _.sortBy(issues.supers, function(issue) {
29
            return new Date(issue.updated_at);
1✔
30
        }).reverse();
2✔
31

2✔
32
        issues.singletons = _.sortBy(issues.singletons, function(issue) {
2✔
33
            return new Date(issue.updated_at);
34
        }).reverse();
×
35
        sorted = issues;
36
    } else {
37
        sorted = _.sortBy(issues, function(issue) {
2✔
38
            return new Date(issue.updated_at);
1✔
39
        }).reverse();
40
    }
41
    return sorted;
1✔
42
}
1✔
43

44
function attachReadableErrorMessage(err) {
45
    var errorMessage;
46

×
47
    // If the error doesn't have a "repo" attached to it, it's a hard error.
×
48
    // This should always be attached by sprinter before it gets to the user.
49
    if (! err.repo) {
50
        throw new Error('Error does not identify a repository!');
2✔
51
    }
52

53
    try {
54
        errorMessage = JSON.parse(err.message);
55
    } catch (jsonParseError) {
56
        errorMessage = 'Unable to parse error message. Entire error is: "'
57
            + err.toString() + '"';
58
    }
59
    // 403 means unauthorized.
1✔
60
    if (err.code == 403) {
×
61
        err.message = 'You must have push access to run this operation on "'
×
62
            + err.repo + '".';
×
63
    }
64
    // 404 means unknown repo
×
65
    else if (err.code == 404) {
66
        err.message = 'Unknown repository: "' + err.repo + '"';
67
    }
68
    // 410 means repo has no GitHub Issues
69
    else if (err.code == 410) {
70
        err.message = '"' + err.repo
71
            + '" has no GitHub Issues associated with it.';
72
    }
1✔
73
    // 422 means validation error
1✔
74
    else if (err.code == 422) {
1✔
75
        err.message = 'Validation error on "' + err.repo + '": '
103✔
76
            + JSON.stringify(errorMessage.errors);
77
    }
103✔
78
    return err;
1✔
79
}
80

102✔
81

82
function attachReadableErrorMessages(errs) {
103✔
83
    if (errs && errs.length) {
84
        return _.map(errs, function(err) {
85
            return attachReadableErrorMessage(err);
86
        });
87
    }
88
}
89

90
/**
91
 * Removes duplicate collaborators.
92
 * @param collaborators
93
 * @returns {Array}
94
 */
95
function deduplicateCollaborators(collaborators) {
1✔
96
    var foundLogins = [];
18✔
97
    return _.filter(collaborators, function (collaborator) {
1✔
98
        var duplicate = false
99
          , login = collaborator.login;
17✔
100
        if (foundLogins.indexOf(login) > -1) {
1✔
101
            duplicate = true;
102
        } else {
16✔
103
            foundLogins.push(login);
1✔
104
        }
105
        return ! duplicate;
15✔
106
    });
15✔
107
}
108

15✔
109
/**
15✔
110
 * Using regex, finds subtasks within a super issue's body string and returns
111
 * them in an array of issue numbers. 
112
 * @param superIssue
113
 * @returns {Array}
15✔
114
 */
115
function extractSuperIssueSubTaskNumbers(superIssue) {
116
    var matches = superIssue.body.match(markdownTasksRegex)
117
      , subTaskIds = [];
118
    _.each(matches, function(line) {
15✔
119
        var match = line.match(subtaskRegex);
15✔
120
        if (match) {
15✔
121
            subTaskIds.push(parseInt(match[3]));
122
        }
123
    });
8✔
124
    return subTaskIds;
11✔
125
}
11✔
126

11✔
127
/**
128
 * Takes an array of issue objects and presents them differently depending on
×
129
 * 'format'. The only valid option now is "network", which groups super issue
130
 * subtasks into a "subtasks" array on the super issue object.
131
 * @param format {string} 'network' or null
132
 * @param issues {array}
133
 * @returns {object} with 'supers' array, 'singletons' array and 'size' for
134
 *                   total number of issues in all.
135
 */
8✔
136
function formatIssues(format, issues) {
15✔
137
    var formattedIssues
138
      , partition
15✔
139
      , superIssues
75✔
140
      , singletonIssues
141
      , removedSubtasks = [];
75✔
142
    if (format == 'network') {
143
        formattedIssues = {
144
            supers: []
11✔
145
          , singletons: []
146
          , all: issues
147
        };
148
        partition = _.partition(issues, function(issue) {
1✔
149
            return _.find(issue.labels, function(label) {
150
                return label.name == 'super';
11✔
151
            });
2✔
152
        });
153
        superIssues = partition.shift();
154
        singletonIssues = partition.shift();
9✔
155
        _.each(superIssues, function(superIssue) {
156
            var subTaskNumbers = extractSuperIssueSubTaskNumbers(superIssue);
×
157
            superIssue.subtasks = _.filter(singletonIssues, function(issue) {
158
                var isSubtask = _.contains(subTaskNumbers, issue.number);
159
                if (isSubtask) {
160
                    removedSubtasks.push(issue.number);
161
                }
9✔
162
                return isSubtask;
163
            });
164
        });
165
        formattedIssues.supers = superIssues;
166
        _.each(singletonIssues, function(issue) {
11✔
167
            if (! _.contains(removedSubtasks, issue.number)) {
4✔
168
                formattedIssues.singletons.push(issue);
169
            }
4✔
170
        });
4✔
171
    } else {
172
        formattedIssues = issues;
173
    }
7✔
174
    return formattedIssues;
7✔
175
}
176

177
/**
178
 * Given a list of issues and pull requests, populate the additional properties
11✔
179
 * that exist within any issues into the PR objects so they contain as much info
180
 * as possible.
×
181
 * @param issues {Object[]}
182
 * @param prs {Object[]}
183
 */
11✔
184
function mergeIssuesAndPrs(issues, prs) {
185
    _.each(issues, function(issue) {
186
        var targetPr
187
          , targetPrIndex = _.findIndex(prs, function(pr) {
188
              return pr && pr.number == issue.number;
189
          });
190
        if (targetPrIndex > -1) {
191
            targetPr = prs[targetPrIndex];
8✔
192
            prs[targetPrIndex] = _.merge(targetPr, issue);
11✔
193
        }
20✔
194
    });
195
    return prs;
20✔
196
}
20✔
197

198
/**
199
 * Wrapper class around the GitHub API client, providing some authentication
11✔
200
 * convenience and additional utility functions for executing operations across
201
 * the issue trackers of several repositories at once.
202
 * @param username {string} GitHub username credential for authentication.
8✔
203
 * @param password {string} GitHub password credential for authentication.
10✔
204
 * @param repoSlugs {string[]} List of repository slug strings to operate upon.
10✔
205
 * @param cache {int} How many seconds to cache fetched results. Default is 0.
206
 */
207
function Sprinter(username, password, repoSlugs, cache) {
208
    if (! username) {
8✔
209
        throw new Error('Missing username.');
19✔
210
    }
211
    if (! password) {
212
        throw new Error('Missing password.');
1✔
213
    }
17✔
214
    if (! repoSlugs) {
17✔
215
        throw new Error('Missing repositories.');
×
216
    }
×
217
    this.username = username;
218
    this.password = password;
219
    // Verify required configuration elements.
220
    this.repos = convertSlugsToObjects(repoSlugs);
221
    this.gh = new GitHubApi({
17✔
222
        version: '3.0.0'
338✔
223
      , timeout: 5000
224
    });
17✔
225
    this.gh.authenticate({
226
        type: 'basic'
227
      , username: this.username
19✔
228
      , password: this.password
19✔
229
    });
2✔
230
    this._CACHE = {};
2✔
231
    this.setCacheDuration(cache);
232
    this._setupCaching();
17✔
233
}
234

235
Sprinter.prototype._cacheIsValid = function(cacheKey) {
236
    var cache = this._CACHE[cacheKey];
237
    if (! cache) {
238
        return false;
239
    }
240
    return (new Date().getTime() < cache.time + this._cacheDuration * 1000);
241
};
8✔
242

243

15✔
244
/**
245
 * Wraps calls to get* functions with a function that caches results.
246
 */
247
Sprinter.prototype._setupCaching = function() {
248
    var cacheDuration = this._cacheDuration
249
      , me = this;
8✔
250
    _.each(originalPrototypeFunctions, function(fn, name) {
×
251
        if (name.indexOf('get') == 0) {
252
            // console.log('wrapping %s', name);
253
            me[name] = function() {
254

255
                // Default cache key is function name.
256
                var cacheKey = name
257
                  , callback
8✔
258
                  , newArguments = [];
8✔
259

260
                function resultCacher(err, result) {
261
                    // Don't cache if duration is 0.
262
                    if (me._cacheDuration) {
263
                        //console.log('caching response for %s', cacheKey);
264
                        me._CACHE[cacheKey] = {
265
                            time: new Date().getTime()
266
                          , result: result
267
                          , errors: err
8✔
268
                        };
8✔
269
                    }
270
                    callback(err, result);
×
271
                }
272

8✔
273
                // If function was passed a filter object, we must update the 
4✔
274
                // cache key to include specific filters.
4✔
275
                if (typeof(arguments[0]) == 'object') {
276
                    cacheKey = name + JSON.stringify(arguments[0]);
8✔
277
                    // 2nd parameter will be a callback if the first was a
8✔
278
                    // filter.
×
279
                    callback = arguments[1];
×
280
                    newArguments = [arguments[0], resultCacher]
281
                } else {
282
                    // 1st parameter is a callback if there was no filter.
8✔
283
                    callback = arguments[0];
13✔
284
                    newArguments = [resultCacher]
13✔
285
                }
13✔
286

13✔
287
                // If result has already been cached, use it.
288
                if (me._cacheIsValid(cacheKey, cacheDuration)) {
289
                    // console.log('using cache for %s', cacheKey);
8✔
290
                    callback(
8✔
291
                        me._CACHE[cacheKey].errors, me._CACHE[cacheKey].result
2✔
292
                    );
293
                } else {
6✔
294
                    // console.log('skipping cache for %s', cacheKey);
×
295
                    fn.apply(me, newArguments);
×
296
                }
×
297

298
            };
299
        }
6✔
300
    });
301
};
302

303
Sprinter.prototype._eachRepo = function(fn, mainCallback) {
304
    var asyncErrors = []
305
      , funcs = this.repos.map(function(repoSlug) {
8✔
306
            var org = repoSlug[0]
1✔
307
              , repo = repoSlug[1]
1✔
308
              , slug = org + '/' + repo;
1✔
309
            return function(callback) {
310
                fn(org, repo, function(error, data) {
7✔
311
                    // All errors must have a "repo" property to identify
312
                    // where they came from.
313
                    function addRepoToError(err) {
314
                        err.repo = slug;
315
                        return err;
316
                    }
317
                    if (error) {
318
                        // Depending on which API function gets called, this
319
                        // error object could be an Array or just one Error
320
                        // object, so we'll have to deal with both.
321
                        if (error.length !== undefined) {
8✔
322
                            asyncErrors = asyncErrors.concat(
8✔
323
                                _.each(error, addRepoToError)
324
                            );
325
                        } else {
326
                            asyncErrors.push(addRepoToError(error));
327
                        }
328
                    }
329
                    callback(null, data);
330
                });
331
            };
332
        });
8✔
333
    async.parallel(funcs, function(err, data) {
×
334
        // Overrides the default async behavior of stopping on errors by
335
        // collecting them here, converting them into readable messages, and
336
        // passing them all back to the main callback. The "data" array might
337
        // have null values, which usually happens if there is an error, so we
338
        // make sure to filter out the nulls.
339
        mainCallback(
340
            attachReadableErrorMessages(asyncErrors)
341
          , _.filter(data, function(item) {
8✔
342
                return item;
×
343
            })
×
344
        );
×
345
    });
346
};
347

348
Sprinter.prototype._eachRepoFlattened = function(fn, mainCallback) {
×
349
    this._eachRepo(fn, function(err, data) {
×
350
        mainCallback(err, _.flatten(data));
351
    });
×
352
};
353

354
Sprinter.prototype._fetchAllPages = function(fetchFunction, params, callback) {
355
    var client = this.gh
356
      , allPages = []
357
      , slug = params.user + '/' + params.repo;
358
    function getRemainingPages(lastPage, pageCallback) {
359
        allPages = allPages.concat(lastPage);
360
        if (client.hasNextPage(lastPage)) {
361
            client.getNextPage(lastPage, function(err, pageResults) {
8✔
362
                getRemainingPages(pageResults, pageCallback);
×
363
            });
×
364
        } else {
×
365
            // Attach a repo object to each result so users can tell what repo
×
366
            // it is coming from.
×
367
            _.each(allPages, function(item) {
368
                if (item) {
×
369
                    item.repo = slug;
×
370
                }
×
371
            });
372
            pageCallback(null, allPages);
×
373
        }
×
374
    }
×
375
    fetchFunction(params, function(err, pageOneResults) {
×
376
        if (err) {
×
377
            callback(err);
378
        } else {
379
            getRemainingPages(pageOneResults, callback);
380
        }
381
    });
382
};
383

×
384
/**
×
385
 * Allows users to reset cache duration on a sprinter instance.
×
386
 * @param duration {int} seconds to cache results.
387
 */
×
388
Sprinter.prototype.setCacheDuration = function(duration) {
389
    // console.log('setting cache duration to %s', duration);
390
    this._cacheDuration = duration;
391
};
392

×
393
/**
394
 * Clears the cache.
395
 */
396
Sprinter.prototype.clearCache = function() {
397
    this._CACHE = {};
398
};
399

400
/*
401
 * Issues and PRs are almost the same thing in GitHub's API, so this is just
402
 * a convenience method to put all the logic in one place.
403
 */
8✔
404
Sprinter.prototype._getIssueOrPr = function(type, userFilters, mainCallback) {
1✔
405
    var me = this
1✔
406
      , defaultFilters = {state: 'open'}
2✔
407
      , filters
408
      , filterOrg
409
      , filterRepo
410
      , milestone
2✔
411
      , fetcher
2✔
412
      , resultHandler
413
      , getter;
×
414

×
415
    if (type == 'issue') {
416
        getter = this.gh.issues.repoIssues;
×
417
    } else {
×
418
        getter = this.gh.pullRequests.getAll
419
    }
420

2✔
421
    if (typeof(userFilters) == 'function' && mainCallback == undefined) {
422
        mainCallback = userFilters;
423
        userFilters = {};
424
    }
1✔
425
    filters = _.extend(defaultFilters, userFilters);
×
426
    if (filters.milestone) {
427
        milestone = filters.milestone;
1✔
428
        delete filters.milestone;
429
    }
430
    fetcher = function(org, repo, localCallback) {
431
        var fetchByState = {}
432
          , asyncErrors = []
433
          , localFilters = _.clone(filters);
434
        localFilters.user = org;
435
        localFilters.repo = repo;
436

437
        // This exists so we can populate an errors object from the async calls.
438
        // Otherwise if there is an error passed to the async callback, the
8✔
439
        // async module will stop executing remaining functions.
×
440
        function getFetchByStateCallback(callback) {
×
441
            return function(err, data) {
×
442
                if (err) {
443
                    asyncErrors.push(err);
444
                }
445
                callback(null, data);
×
446
            };
447
        }
×
448

×
449
        // This logic is to allow for a state other than 'open' and 'closed'.
×
450
        // The 'all' state should return both open and closed issues, which will
451
        // require async calls to to the API to get issues with each state.
×
452
        if (localFilters.state && localFilters.state == 'all') {
×
453
            var openStateFilter = _.clone(localFilters)
454
              , closedStateFilter = _.clone(localFilters);
455
            openStateFilter.state = 'open';
×
456
            closedStateFilter.state = 'closed';
×
457
            fetchByState[openStateFilter.state] = function(fetchCallback) {
458
                me._fetchAllPages(
459
                    getter
460
                  , openStateFilter
461
                  , getFetchByStateCallback(fetchCallback)
×
462
                );
463
            };
464
            fetchByState[closedStateFilter.state] = function(fetchCallback) {
465
                me._fetchAllPages(
466
                    getter
×
467
                  , closedStateFilter
×
468
                  , getFetchByStateCallback(fetchCallback)
469
                );
×
470
            };
×
471
        } else {
472
            fetchByState[localFilters.state] = function(fetchCallback) {
×
473
                me._fetchAllPages(
474
                    getter
475
                  , localFilters
×
476
                  , getFetchByStateCallback(fetchCallback)
×
477
                );
478
            };
479
        }
480
        async.parallel(fetchByState, function(err, allIssues) {
481
            // If there were no async errors, we'll use undefined instead of an
×
482
            // empty array to represent no errors.
×
483
            if (asyncErrors.length == 0) {
×
484
                asyncErrors = undefined;
×
485
            }
486
            // If state is 'all', we need to concat the open and closed issues
×
487
            // together.
488
            if (localFilters.state && localFilters.state == 'all') {
489
                allIssues = allIssues.open.concat(allIssues.closed);
490
            } else {
491
                allIssues = allIssues[localFilters.state];
492
            }
493
            localCallback(asyncErrors, allIssues)
494
        });
495
    };
496

497
    resultHandler = function(errors, result) {
498
        if (milestone) {
499
            result = _.filter(result, function(issue) {
500
                if (issue.milestone == null) { return false; }
501
                return issue.milestone.title == milestone;
8✔
502
            });
×
503
        }
×
504
        // If user specified a result format, apply it.
×
505
        if (userFilters.format) {
×
506
            result = formatIssues(userFilters.format, result);
507
        }
508
        mainCallback(errors, sortIssues(result));
509
    };
×
510

×
511
    // If the user specified only one repository to query, we don't want to
×
512
    // query all the others.
×
513
    if (filters.repo) {
×
514
        filterOrg = filters.repo.split('/').shift();
515
        filterRepo = filters.repo.split('/').pop();
×
516
        fetcher(filterOrg, filterRepo, resultHandler);
517
    } else {
518
        this._eachRepoFlattened(fetcher, resultHandler);
519
    }
520
};
×
521

522
/**
×
523
 * Returns all issues across all monitored repos. Optional filters can be
×
524
 * provided to filter results.
525
 * @param [userFilters] {object} Filter, like {state: 'closed'}. This can also
×
526
 *                               contain a 'format' value to specify how the
527
 *                               issues should be presented. The only valid
528
 *                               option at this point is 'network', which will
529
 *                               break out "super" issues and "singleton" issues
530
 *                               and attaching super issue subtasks as
531
 *                               "subtasks" on the super issue object.
532
 *                               (A bit of an unadvertised feature of sprinter.)
533
 * @param mainCallback {function} Called with err, issues when done. Issues are
534
 *                                sorted by updated_at.
535
 */
8✔
536
Sprinter.prototype.getIssues = function(userFilters, mainCallback) {
2✔
537
    this._getIssueOrPr('issue', userFilters, mainCallback);
2✔
538
};
4✔
539

540
/**
541
 * Returns all prs across all monitored repos. Optional filters can be provided
542
 * to filter results, but they are more limited than getting issues.
2✔
543
 * @param [userFilters] {object} Filter, like {state: 'closed'}. One additional
×
544
 *                               property you can use to get the most data as
545
 *                               possible for each PR is 'mergeIssueProperties'.
2✔
546
 *                               When given, this will also get all issues and
547
 *                               merge them into the PR objects to provide the
548
 *                               most data as possible.
549
 * @param mainCallback {function} Called with err, prs when done. PRs are
550
 *                                sorted by updated_at.
551
 */
552
Sprinter.prototype.getPullRequests = function(userFilters, mainCallback) {
553
    var me = this
554
      , fetchers = {};
8✔
555
    if (typeof(userFilters) == 'function' && mainCallback == undefined) {
1✔
556
        mainCallback = userFilters;
1✔
557
        userFilters = {};
2✔
558
    }
559
    fetchers.prs = function(localCallback) {
560
        me._getIssueOrPr('pr', userFilters, localCallback);
561
    };
1✔
562
    // If we need to get issues as well, we add another fetcher function.
×
563
    if (userFilters.mergeIssueProperties) {
564
        fetchers.issues = function(localCallback) {
1✔
565
            me._getIssueOrPr('issue', userFilters, localCallback);
566
        };
567
    }
568
    // Call fetchers simultaneously.
569
    async.parallel(fetchers, function(err, result) {
570
        if (err) {
8✔
571
            return mainCallback(attachReadableErrorMessages(err));
136✔
572
        }
40✔
573
        if (! result.issues) {
574
            // If there are no issues to merge into the PRs, we just return the
575
            // PRs.
576
            mainCallback(err, result.prs);
8✔
577
        } else {
578
            // If both PRs and issues were fetched, we need to merge issue
579
            // properties into the PRs.
580
            mainCallback(err, mergeIssuesAndPrs(result.issues, result.prs));
581
        }
582
    });
583
};
584

585
/**
586
 * Returns all milestones across monitored repos, grouped by title. Useful for
587
 * standard milestone periods like sprints.
588
 * @param mainCallback {function} Called with err, milestones.
589
 */
590
Sprinter.prototype.getMilestones = function(mainCallback) {
591
    var me = this;
592
    this._eachRepoFlattened(function(org, repo, localCallback) {
593
        me._fetchAllPages(
594
            me.gh.issues.getAllMilestones
595
          , {user: org, repo: repo}
596
          , localCallback
597
        );
598
    }, function(err, milestones) {
599
        mainCallback(
600
            attachReadableErrorMessages(err)
601
          , _.groupBy(milestones, 'title')
602
        );
603
    });
604
};
605

606
/**
607
 * Closes all milestones across all monitored repos that match given title.
608
 * @param title {string} Milestone to delete.
609
 * @param mainCallback {function} Called with err, updated milestones.
610
 */
611
Sprinter.prototype.closeMilestones = function(title, mainCallback) {
612
    var me = this;
613
    this.getMilestones(function(err, milestones) {
614
        var matches;
615
        if (err) {
616
            mainCallback(attachReadableErrorMessage(err));
617
        } else {
618
            matches = milestones[title];
619
            if (! matches) {
620
                mainCallback(null, []);
621
            } else {
622
                console.log('Closing ' + matches.length + ' milestones.');
623
                var updaters = _.map(matches, function(match) {
624
                    var splitSlug = match.repo.split('/');
625
                    return function(localCallback) {
626
                        me.gh.issues.updateMilestone(
627
                            { user: splitSlug[0]
628
                            , repo: splitSlug[1]
629
                            , number: match.number
630
                            , title: match.title
631
                            , state: 'closed'
632
                            }
633
                          , localCallback
634
                        );
635
                    };
636
                });
637
                async.parallel(updaters, mainCallback);
638
            }
639
        }
640
    });
641
};
642

643
/**
644
 * Creates the same milestone across all monitored repos.
645
 * @param milestone {object} Should contain a title and due_on.
646
 * @param mainCallback {function} Called with err, created milestones.
647
 */
648
Sprinter.prototype.createMilestones = function(milestone, mainCallback) {
649
    var me = this;
650
    this._eachRepo(function(org, repo, localCallback) {
651
        var payload = _.extend({
652
            user: org
653
          , repo: repo
654
        }, milestone);
655
        me.gh.issues.createMilestone(payload, localCallback);
656
    }, function(err, response) {
657
        mainCallback(attachReadableErrorMessages(err), response);
658
    });
659
};
660

661
/**
662
 * Updates the same milestone across all monitored repos.
663
 * @param title {string} Title of the milestone to be updated.
664
 * @param milestone {object} Must contain at least a title to update.
665
 * @param mainCallback {function} Called with err, updated milestones.
666
 */
667
Sprinter.prototype.updateMilestones = function(title, milestone, mainCallback) {
668
    var me = this;
669
    this._eachRepo(function(org, repo, localCallback) {
670
        var payload = {
671
            user: org
672
          , repo: repo
673
        };
674
        me._fetchAllPages(me.gh.issues.getAllMilestones, payload, 
675
            function(err, milestones) {
676
                var slug = org + '/' + repo;
677
                if (err) {
678
                    localCallback(err);
679
                } else {
680
                    var match = _.find(milestones, function(milestone) {
681
                            return milestone.title == title;
682
                        })
683
                      , result = undefined;
684
                    if (match) {
685
                        result = {
686
                            repo: slug
687
                          , number: match.number
688
                        }
689
                    }
690
                    localCallback(null, result);
691
                }
692
            }
693
        );
694
    }, function(err, milestonesToUpdate) {
695
        if (err) {
696
            mainCallback(attachReadableErrorMessage(err));
697
        } else {
698
            me._eachRepo(function(org, repo, milestoneUpdateCallback) {
699
                var slug = org + '/' + repo
700
                  , milestoneToUpdate = _.find(
701
                        milestonesToUpdate
702
                      , function(ms) {
703
                            return ms && ms.repo == slug;
704
                        }
705
                    )
706
                  , payload = undefined;
707
                if (milestoneToUpdate) {
708
                    payload = _.extend({
709
                        user: org
710
                      , repo: repo
711
                      , number: milestoneToUpdate.number
712
                    }, milestone);
713
                    me.gh.issues.updateMilestone(
714
                        payload
715
                      , function(err, result) {
716
                          if (err) {
717
                              err.repo = org + '/' + repo;
718
                              milestoneUpdateCallback(err);
719
                          } else {
720
                              milestoneUpdateCallback(err, result);
721
                          }
722
                      }
723
                    );
724
                }
725
            }, mainCallback);
726
        }
727
    });
728
};
729

730
/**
731
 * Creates the same labels across all monitored repos.
732
 * @param labels {Array} Should be a list of objects, each with a name and hex 
733
 *                       color (without the #).
734
 * @param mainCallback {function} Called with err, created labels.
735
 */
736
Sprinter.prototype.createLabels = function(labels, mainCallback) {
737
    var me = this;
738
    this._eachRepo(function(org, repo, localCallback) {
739
        var createFunctions = _.map(labels, function(labelSpec) {
740
            var payload = _.extend({
741
                user: org
742
              , repo: repo
743
            }, labelSpec);
744
            return function(callback) {
745
                me.gh.issues.createLabel(payload, function(err, resp) {
746
                    if (err) {
747
                        err.repo = org + '/' + repo;
748
                        callback(err);
749
                    } else {
750
                        callback(err, resp);
751
                    }
752
                });
753
            };
754
        });
755
        async.parallel(createFunctions, localCallback);
756
    }, function(errs, response) {
757
        if (errs) {
758
            mainCallback(attachReadableErrorMessages(errs));
759
        } else {
760
            mainCallback(errs, response);
761
        }
762
    });
763
};
764

765
/**
766
 * Returns all labels across monitored repos. Also attaches a "repo" attribute
767
 * so users can tell what repo labels are coming from.
768
 * @param mainCallback {function} Called with err, labels.
769
 */
770
Sprinter.prototype.getLabels = function(mainCallback) {
771
    var me = this;
772
    this._eachRepoFlattened(function(org, repo, localCallback) {
773
        me._fetchAllPages(
774
            me.gh.issues.getLabels, {user: org, repo: repo}, localCallback
775
        );
776
    }, function(err, labels) {
777
        mainCallback(attachReadableErrorMessages(err), labels);
778
    });
779
};
780

781
/**
782
 * Returns all collaborators across monitored repos.
783
 * @param mainCallback {function} Called with err, collaborators.
784
 */
785
Sprinter.prototype.getCollaborators = function(mainCallback) {
786
    var me = this;
787
    this._eachRepoFlattened(function(org, repo, localCallback) {
788
        me._fetchAllPages(
789
            me.gh.repos.getCollaborators, {user: org, repo: repo}, localCallback
790
        );
791
    }, function(err, collaborators) {
792
        mainCallback(
793
            attachReadableErrorMessages(err)
794
          , deduplicateCollaborators(collaborators)
795
        );
796
    });
797
};
798

799
/* Stashes original prototype functions of Sprinter for use in caching. */
800
_.each(Sprinter.prototype, function(fn, name) {
801
    if (name.indexOf('get') == 0) {
802
        originalPrototypeFunctions[name] = fn;
803
    }
804
});
805

806
module.exports = Sprinter;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc