a8n: Implement GraphQL API for Burndown Chart (#5895)

* a8n: Add ChangesetCountsOverTime to GraphQL schema

* a8n: Add first version of ChangesetCountsOverTime

* a8n: Add a separate test for ChangesetCountsOverTime

* Change tests for ChangesetCountsOverTime

* a8n: Implement counting Open/Closed/Merged for GitHub PRs

* a8n: Add a8n.ChangesetCounts type

* Move ChangesetCounts and their calculation to enterprise/a8n

* Create separate unit test for a8n.CalcCounts

* Change structure of TestCalcCounts

* Remove database heavy "unit" test

* fixup! Rename variables in test

* Fix import paths after rebase

* Change condition in CalcCounts

* a8n: Change interface of CalcCount to accept Events

* Implement a8n.CalcCounts for open/closed/reopened/merged events

* a8n: Ignore Closed event when Merged

* a8n: Reformat CalcCounts tests

* a8n: Add helper method for CalcCount tests

* Add test to make sure sorting works

* a8n: Count OpenApproved/OpenPending/OpenChangesRequested in CalcCounts

* CalcCount: add tests for multiple changesets

* a8n: Add test for CalcCount with multiple changesets in different stages

* fixup! Rename helper function

* Extract helper methods

* Remove now unneeded test fixture

* a8n: Add ChangesetReviewStateCommented and ignore it in counts

* a8n: CalcCount add tests for time slicing

* a8n: Add tests for CalcCounts to count reviews of same type once

* a8n: Add another test for CalcCounts

* Add more tests for CalcCount

* a8n: Bring back "integration" test for ChangesetCountsOverTime

* a8n: Extend integration test for ChangesetCountsOverTime

* a8n: Use max(from, campaign.CreatedAt) in ChangesetCountsOverTime

As discussed here: https://github.com/sourcegraph/sourcegraph/pull/5847/files#r332142729

* a8n: Do not use max(from, campaign.CreatedAt) in ChangesetCountsOverTime"

If you create a campaign and add already existing changesets to it, you
can't look at their progression over time.

* Remove unused methods WasMergedAt and WasClosedAt

* Do not panic when timestamp for ChangesetEvent could not be constructed

* Remove unused methods

* Remove comment

See 104edc61fc

* a8n: Fix loading of ChangesetEvents by using pagination

* a8n: Strip all timezones in ChangesetCountsOverTime resolver

* fixup! Cherry pick store changes

* a8n: Change CalcCounts so OpenPending means "without review"

Previously it meant: "gotten a review on Github with state 'pending'"

Now it means: every changeset that hasn't been reviewed yet, counts
towards "OpenPending"

* a8n: Use zero time instead of error for Event timestamp

* a8n: Extract computing of historical changeset state to function

* fixup! Update test description

* a8n: Specify author of github code reviews in tests

* a8n: Take overall-review-state per person and per changeset into account

* Clean up code and comments

* a8n: Handle closing/reopening of reviewed changesets in CalcCounts

* a8n: Replace booleans with switch on previous review state

* a8n: Fix changesets that have been reviewed, closed and merged

* a8n: Treat "merged" as final state in CalcCounts

* a8n: Add early exit to CalcCounts

* fixup! Simplify loop in generateTimestamps

* Use zero timestamp instead of error handling

* Update enterprise/pkg/a8n/counts.go

Co-Authored-By: Tomás Senart <tsenart@gmail.com>

* Update comment

* Remove unused flag from tests

* a8n: Refactor CalcCounts code to get rid of duplicated switch statements

* a8n: Make recomputing new review state counts more efficient

* a8n: Add GoDocs

* a8n: Switch listing ChangesetEvents by CampaignID to using ChangesetIDs

* fixup! Small fixes in CalcCounts

* a8n: Add AddReviewState to ChangesetCounts

* a8n: Get rid of unnecessary inner loops

* fixup! Change GoDoc for CalcCounts

* fixup! Run gofmt on a8n resolvers

* fixup! Fix golangci-lint errors

* fixup! Fix golangci-lint complaint
This commit is contained in:
Thorsten Ball 2019-10-14 16:06:12 +02:00 committed by GitHub
parent 0abe3dad51
commit cc3c1030af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1670 additions and 65 deletions

View File

@ -111,6 +111,11 @@ func (r *schemaResolver) Changesets(ctx context.Context, args *graphqlutil.Conne
return r.a8nResolver.Changesets(ctx, args)
}
type ChangesetCountsArgs struct {
From *DateTime
To *DateTime
}
type CampaignResolver interface {
ID() graphql.ID
Name() string
@ -121,6 +126,7 @@ type CampaignResolver interface {
CreatedAt() DateTime
UpdatedAt() DateTime
Changesets(ctx context.Context, args struct{ graphqlutil.ConnectionArgs }) ChangesetsConnectionResolver
ChangesetCountsOverTime(ctx context.Context, args *ChangesetCountsArgs) ([]ChangesetCountsResolver, error)
}
type CampaignsConnectionResolver interface {
@ -160,3 +166,14 @@ type ChangesetEventResolver interface {
Changeset(ctx context.Context) (ChangesetResolver, error)
CreatedAt() DateTime
}
type ChangesetCountsResolver interface {
Date() DateTime
Total() int32
Merged() int32
Closed() int32
Open() int32
OpenApproved() int32
OpenChangesRequested() int32
OpenPending() int32
}

View File

@ -418,6 +418,36 @@ type Campaign implements Node {
# The changesets in this campaign.
changesets(first: Int): ChangesetConnection!
# The changeset counts over time, in 1 day intervals backwards from the point in time given in 'to'.
changesetCountsOverTime(
# Only include changeset counts up to this point in time (inclusive).
# Defaults to createdAt.
from: DateTime
# Only include changeset counts up to this point in time (inclusive).
# Defaults to now.
to: DateTime
): [ChangesetCounts!]!
}
# The counts of changesets in certain states at a specific point in time.
type ChangesetCounts {
# The point in time these counts were recorded.
date: DateTime!
# The total number of changesets.
total: Int!
# The number of merged changesets.
merged: Int!
# The number of closed changesets.
closed: Int!
# The number of open changesets (independent of review state).
open: Int!
# The number of changesets that are both open and approved.
openApproved: Int!
# The number of changesets that are both open and have requested changes.
openChangesRequested: Int!
# The number of changesets that are both open and are pending review.
openPending: Int!
}
# A list of campaigns.

View File

@ -425,6 +425,36 @@ type Campaign implements Node {
# The changesets in this campaign.
changesets(first: Int): ChangesetConnection!
# The changeset counts over time, in 1 day intervals backwards from the point in time given in 'to'.
changesetCountsOverTime(
# Only include changeset counts up to this point in time (inclusive).
# Defaults to createdAt.
from: DateTime
# Only include changeset counts up to this point in time (inclusive).
# Defaults to now.
to: DateTime
): [ChangesetCounts!]!
}
# The counts of changesets in certain states at a specific point in time.
type ChangesetCounts {
# The point in time these counts were recorded.
date: DateTime!
# The total number of changesets.
total: Int!
# The number of merged changesets.
merged: Int!
# The number of closed changesets.
closed: Int!
# The number of open changesets (independent of review state).
open: Int!
# The number of changesets that are both open and approved.
openApproved: Int!
# The number of changesets that are both open and have requested changes.
openChangesRequested: Int!
# The number of changesets that are both open and are pending review.
openPending: Int!
}
# A list of campaigns.

View File

@ -0,0 +1,286 @@
package a8n
import (
"fmt"
"sort"
"time"
"github.com/pkg/errors"
"github.com/sourcegraph/sourcegraph/internal/a8n"
"github.com/sourcegraph/sourcegraph/internal/extsvc/github"
)
// ChangesetCounts represents the states in which a given set of Changesets was
// at a given point in time
type ChangesetCounts struct {
Time time.Time
Total int32
Merged int32
Closed int32
Open int32
OpenApproved int32
OpenChangesRequested int32
OpenPending int32
}
// AddReviewState adds n to the corresponding counter for a given
// ChangesetReviewState
func (c *ChangesetCounts) AddReviewState(s a8n.ChangesetReviewState, n int32) {
switch s {
case a8n.ChangesetReviewStatePending:
c.OpenPending += n
case a8n.ChangesetReviewStateApproved:
c.OpenApproved += n
case a8n.ChangesetReviewStateChangesRequested:
c.OpenChangesRequested += n
}
}
func (cc *ChangesetCounts) String() string {
return fmt.Sprintf("%s (Total: %d, Merged: %d, Closed: %d, Open: %d, OpenApproved: %d, OpenChangesRequested: %d, OpenPending: %d)",
cc.Time.String(),
cc.Total,
cc.Merged,
cc.Closed,
cc.Open,
cc.OpenApproved,
cc.OpenChangesRequested,
cc.OpenPending,
)
}
// Event is a single event that happened in the lifetime of a single Changeset,
// for example a review or a merge.
type Event interface {
Timestamp() time.Time
Type() a8n.ChangesetEventKind
Changeset() int64
}
// Events is a collection of Events that can be sorted by their Timestamps
type Events []Event
func (es Events) Len() int { return len(es) }
func (es Events) Swap(i, j int) { es[i], es[j] = es[j], es[i] }
// Less sorts events by their timestamps
func (es Events) Less(i, j int) bool {
return es[i].Timestamp().Before(es[j].Timestamp())
}
// CalcCounts calculates ChangesetCounts for the given Changesets and their
// Events in the timeframe specified by the start and end parameters. The
// number of ChangesetCounts returned is the number of 1 day intervals between
// start and end, with each ChangesetCounts representing a point in time at the
// boundary of each 24h interval.
func CalcCounts(start, end time.Time, cs []*a8n.Changeset, es ...Event) ([]*ChangesetCounts, error) {
ts := generateTimestamps(start, end)
counts := make([]*ChangesetCounts, len(ts))
for i, t := range ts {
counts[i] = &ChangesetCounts{Time: t}
}
// Sort all events once by their timestamps
events := Events(es)
sort.Sort(events)
// Grouping Events by their Changeset ID
byChangesetID := make(map[int64]Events)
for _, e := range events {
id := e.Changeset()
byChangesetID[id] = append(byChangesetID[id], e)
}
// Map Events to their Changeset
byChangeset := make(map[*a8n.Changeset]Events)
for _, c := range cs {
byChangeset[c] = byChangesetID[c.ID]
}
for changeset, csEvents := range byChangeset {
// We don't have an event for "open", so we check when it was
// created on codehost
openedAt := changeset.ExternalCreatedAt()
if openedAt.IsZero() {
continue
}
// For each changeset and its events, go through every point in time we
// want to record and reconstruct the state of the changeset at that
// point in time
for _, c := range counts {
if openedAt.After(c.Time) {
// No need to look at events if changeset was not created yet
continue
}
err := computeCounts(c, csEvents)
if err != nil {
return counts, err
}
}
}
return counts, nil
}
func computeCounts(c *ChangesetCounts, csEvents Events) error {
var (
// Since "Merged" and "Closed" are exclusive events and cancel each others
// effects on ChangesetCounts out, we need to keep track of when a
// changeset was closed, so we can undo the effect of the "Closed" event
// when we come across a "Merge" (since, on GitHub, a PR can be closed AND
// merged)
closed = false
lastReviewByAuthor = map[string]a8n.ChangesetReviewState{}
)
c.Total++
c.Open++
c.OpenPending++
for _, e := range csEvents {
et := e.Timestamp()
if et.IsZero() {
continue
}
// Event happened after point in time we're looking at, no need to look
// at the events in future
if et.After(c.Time) {
return nil
}
// Compute current overall review state
currentReviewState := computeReviewState(lastReviewByAuthor)
switch e.Type() {
case a8n.ChangesetEventKindGitHubClosed:
c.Open--
c.Closed++
closed = true
c.AddReviewState(currentReviewState, -1)
case a8n.ChangesetEventKindGitHubReopened:
c.Open++
c.Closed--
closed = false
c.AddReviewState(currentReviewState, 1)
case a8n.ChangesetEventKindGitHubMerged:
// If it was closed, all "review counts" have been updated by the
// closed events and we just need to reverse these two counts
if closed {
c.Closed--
c.Merged++
return nil
}
c.AddReviewState(currentReviewState, -1)
c.Merged++
c.Open--
// Merged is a final state, we return here and don't need to look at
// other events
return nil
case a8n.ChangesetEventKindGitHubReviewed:
s, err := reviewState(e)
if err != nil {
return err
}
// We only care about "Approved" or "ChangesRequested" reviews
if s != a8n.ChangesetReviewStateApproved && s != a8n.ChangesetReviewStateChangesRequested {
continue
}
author, err := reviewAuthor(e)
if err != nil {
return err
}
// Save current review state, then insert new review and recompute
// overall review state
oldReviewState := currentReviewState
lastReviewByAuthor[author] = s
newReviewState := computeReviewState(lastReviewByAuthor)
if newReviewState != oldReviewState {
// Decrement the counts increased by old review state
c.AddReviewState(oldReviewState, -1)
// Increase the counts for new review state
c.AddReviewState(newReviewState, 1)
}
}
}
return nil
}
func generateTimestamps(start, end time.Time) []time.Time {
// Walk backwards from `end` to >= `start` in 1 day intervals
// Backwards so we always end exactly on `end`
ts := []time.Time{}
for t := end; !t.Before(start); t = t.AddDate(0, 0, -1) {
ts = append(ts, t)
}
// Now reverse so we go from oldest to newest in slice
for i := len(ts)/2 - 1; i >= 0; i-- {
opp := len(ts) - 1 - i
ts[i], ts[opp] = ts[opp], ts[i]
}
return ts
}
func reviewState(e Event) (a8n.ChangesetReviewState, error) {
var s a8n.ChangesetReviewState
changesetEvent, ok := e.(*a8n.ChangesetEvent)
if !ok {
return s, errors.New("Reviewed event not ChangesetEvent")
}
review, ok := changesetEvent.Metadata.(*github.PullRequestReview)
if !ok {
return s, errors.New("ChangesetEvent metadata event not PullRequestReview")
}
s = a8n.ChangesetReviewState(review.State)
if !s.Valid() {
return s, fmt.Errorf("invalid review state: %s", review.State)
}
return s, nil
}
func reviewAuthor(e Event) (string, error) {
changesetEvent, ok := e.(*a8n.ChangesetEvent)
if !ok {
return "", errors.New("Reviewed event not ChangesetEvent")
}
review, ok := changesetEvent.Metadata.(*github.PullRequestReview)
if !ok {
return "", errors.New("ChangesetEvent metadata event not PullRequestReview")
}
login := review.Author.Login
if login == "" {
return "", errors.New("review author is blank")
}
return login, nil
}
func computeReviewState(statesByAuthor map[string]a8n.ChangesetReviewState) a8n.ChangesetReviewState {
states := make(map[a8n.ChangesetReviewState]bool)
for _, s := range statesByAuthor {
states[s] = true
}
return a8n.SelectReviewState(states)
}

View File

@ -0,0 +1,572 @@
package a8n
import (
"reflect"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/sourcegraph/sourcegraph/internal/a8n"
"github.com/sourcegraph/sourcegraph/internal/extsvc/github"
)
func TestCalcCounts(t *testing.T) {
now := time.Now().Truncate(time.Microsecond)
daysAgo := func(days int) time.Time { return now.AddDate(0, 0, -days) }
tests := []struct {
name string
changesets []*a8n.Changeset
start time.Time
end time.Time
events []Event
want []*ChangesetCounts
}{
{
name: "single changeset open merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "start end time on subset of events",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(4),
end: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 0, Open: 0},
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
},
},
{
name: "single changeset created and closed before start time",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(8)),
},
start: daysAgo(4),
end: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(7), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 1, Merged: 1},
{Time: daysAgo(3), Total: 1, Merged: 1},
{Time: daysAgo(2), Total: 1, Merged: 1},
},
},
{
name: "start time not even x*24hours before end time",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(3),
end: now.Add(-18 * time.Hour),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(2).Add(-18 * time.Hour), Total: 0, Merged: 0},
{Time: daysAgo(1).Add(-18 * time.Hour), Total: 1, Open: 1, OpenPending: 1},
{Time: now.Add(-18 * time.Hour), Total: 1, Merged: 1},
},
},
{
name: "multiple changesets open merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
ghChangeset(2, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 2},
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 2, Open: 2, OpenPending: 2},
{Time: daysAgo(1), Total: 2, Merged: 2},
{Time: daysAgo(0), Total: 2, Merged: 2},
},
},
{
name: "multiple changesets open merged different times",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
ghChangeset(2, daysAgo(2)),
},
start: daysAgo(4),
events: []Event{
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 2},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 0, Open: 0},
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 2, Open: 1, OpenPending: 1, Merged: 1},
{Time: daysAgo(1), Total: 2, Merged: 2},
{Time: daysAgo(0), Total: 2, Merged: 2},
},
},
{
name: "changeset merged and closed at same time",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "changeset merged and closed at same time, reversed order in slice",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset open closed reopened merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(4)),
},
start: daysAgo(5),
events: []Event{
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(5), Total: 0, Open: 0},
{Time: daysAgo(4), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(3), Total: 1, Open: 0, Closed: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "multiple changesets open closed reopened merged different times",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(5)),
ghChangeset(2, daysAgo(4)),
},
start: daysAgo(6),
events: []Event{
fakeEvent{t: daysAgo(4), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubClosed, id: 2},
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubReopened, id: 2},
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubMerged, id: 2},
},
want: []*ChangesetCounts{
{Time: daysAgo(6), Total: 0, Open: 0},
{Time: daysAgo(5), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(4), Total: 2, Open: 1, OpenPending: 1, Closed: 1},
{Time: daysAgo(3), Total: 2, Open: 1, OpenPending: 1, Closed: 1},
{Time: daysAgo(2), Total: 2, Open: 2, OpenPending: 2},
{Time: daysAgo(1), Total: 2, Open: 1, OpenPending: 1, Merged: 1},
{Time: daysAgo(0), Total: 2, Merged: 2},
},
},
{
name: "single changeset open closed reopened merged, unsorted events",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(4)),
},
start: daysAgo(5),
events: []Event{
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(5), Total: 0, Open: 0},
{Time: daysAgo(4), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(3), Total: 1, Open: 0, Closed: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset open, approved, merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(4),
events: []Event{
ghReview(1, daysAgo(2), "user1", "APPROVED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 0, Open: 0},
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 0, OpenApproved: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset open, approved, closed, reopened",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(3),
events: []Event{
ghReview(1, daysAgo(2), "user1", "APPROVED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenApproved: 1},
{Time: daysAgo(1), Total: 1, Closed: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenApproved: 1},
},
},
{
name: "single changeset open, approved, closed, merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(3),
events: []Event{
ghReview(1, daysAgo(2), "user1", "APPROVED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenApproved: 1},
{Time: daysAgo(1), Total: 1, Closed: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset open, changes-requested, closed, reopened",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(3),
events: []Event{
ghReview(1, daysAgo(2), "user1", "CHANGES_REQUESTED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubReopened, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(1), Total: 1, Closed: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenChangesRequested: 1},
},
},
{
name: "single changeset open, changes-requested, closed, merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(3),
events: []Event{
ghReview(1, daysAgo(2), "user1", "CHANGES_REQUESTED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubClosed, id: 1},
fakeEvent{t: daysAgo(0), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(1), Total: 1, Closed: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset open, comment review, approved, merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(4),
events: []Event{
ghReview(1, daysAgo(3), "user1", "COMMENTED"),
ghReview(1, daysAgo(2), "user2", "APPROVED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 0, Open: 0},
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 0, OpenApproved: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "single changeset multiple approvals counting once",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "APPROVED"),
ghReview(1, daysAgo(0), "user2", "APPROVED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenPending: 0, OpenApproved: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenPending: 0, OpenApproved: 1},
},
},
{
name: "single changeset multiple changes-requested reviews counting once",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "CHANGES_REQUESTED"),
ghReview(1, daysAgo(0), "user2", "CHANGES_REQUESTED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenPending: 0, OpenChangesRequested: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenPending: 0, OpenChangesRequested: 1},
},
},
{
name: "single changeset open, changes-requested, merged",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(3)),
},
start: daysAgo(4),
events: []Event{
ghReview(1, daysAgo(2), "user1", "CHANGES_REQUESTED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 0, Open: 0},
{Time: daysAgo(3), Total: 1, Open: 1, OpenPending: 1},
{Time: daysAgo(2), Total: 1, Open: 1, OpenPending: 0, OpenChangesRequested: 1},
{Time: daysAgo(1), Total: 1, Merged: 1},
{Time: daysAgo(0), Total: 1, Merged: 1},
},
},
{
name: "multiple changesets open different review stages before merge",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(6)),
ghChangeset(2, daysAgo(6)),
ghChangeset(3, daysAgo(6)),
},
start: daysAgo(7),
events: []Event{
ghReview(1, daysAgo(5), "user1", "APPROVED"),
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
ghReview(2, daysAgo(4), "user1", "APPROVED"),
ghReview(2, daysAgo(3), "user2", "APPROVED"),
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubMerged, id: 2},
ghReview(3, daysAgo(2), "user1", "CHANGES_REQUESTED"),
ghReview(3, daysAgo(1), "user2", "CHANGES_REQUESTED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 3},
},
want: []*ChangesetCounts{
{Time: daysAgo(7), Total: 0, Open: 0},
{Time: daysAgo(6), Total: 3, Open: 3, OpenPending: 3},
{Time: daysAgo(5), Total: 3, Open: 3, OpenPending: 2, OpenApproved: 1},
{Time: daysAgo(4), Total: 3, Open: 3, OpenPending: 1, OpenApproved: 2},
{Time: daysAgo(3), Total: 3, Open: 2, OpenPending: 1, OpenApproved: 1, Merged: 1},
{Time: daysAgo(2), Total: 3, Open: 1, OpenPending: 0, OpenChangesRequested: 1, Merged: 2},
{Time: daysAgo(1), Total: 3, Merged: 3},
{Time: daysAgo(0), Total: 3, Merged: 3},
},
},
{
name: "time slice of multiple changesets in different stages before merge",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(6)),
ghChangeset(2, daysAgo(6)),
ghChangeset(3, daysAgo(6)),
},
// Same test as above, except we only look at 3 days in the middle
start: daysAgo(4),
end: daysAgo(2),
events: []Event{
ghReview(1, daysAgo(5), "user1", "APPROVED"),
fakeEvent{t: daysAgo(3), kind: a8n.ChangesetEventKindGitHubMerged, id: 1},
ghReview(2, daysAgo(4), "user1", "APPROVED"),
fakeEvent{t: daysAgo(2), kind: a8n.ChangesetEventKindGitHubMerged, id: 2},
ghReview(3, daysAgo(2), "user1", "CHANGES_REQUESTED"),
fakeEvent{t: daysAgo(1), kind: a8n.ChangesetEventKindGitHubMerged, id: 3},
},
want: []*ChangesetCounts{
{Time: daysAgo(4), Total: 3, Open: 3, OpenPending: 1, OpenApproved: 2},
{Time: daysAgo(3), Total: 3, Open: 2, OpenPending: 1, OpenApproved: 1, Merged: 1},
{Time: daysAgo(2), Total: 3, Open: 1, OpenPending: 0, OpenChangesRequested: 1, Merged: 2},
},
},
{
name: "single changeset with changes-requested then approved by same person",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "CHANGES_REQUESTED"),
ghReview(1, daysAgo(0), "user1", "APPROVED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenApproved: 1},
},
},
{
name: "single changeset with approved then changes-requested by same person",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "APPROVED"),
ghReview(1, daysAgo(0), "user1", "CHANGES_REQUESTED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenApproved: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenChangesRequested: 1},
},
},
{
name: "single changeset with approval by one person then changes-requested by another",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "APPROVED"),
ghReview(1, daysAgo(0), "user2", "CHANGES_REQUESTED"), // This has higher precedence
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenApproved: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenChangesRequested: 1},
},
},
{
name: "single changeset with changes-requested by one person then approval by another",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(1)),
},
start: daysAgo(1),
events: []Event{
ghReview(1, daysAgo(1), "user1", "CHANGES_REQUESTED"),
ghReview(1, daysAgo(0), "user2", "APPROVED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(1), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenChangesRequested: 1},
},
},
{
name: "single changeset with changes-requested by one person, approval by another, then approval by first person",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
ghReview(1, daysAgo(2), "user1", "CHANGES_REQUESTED"),
ghReview(1, daysAgo(1), "user2", "APPROVED"),
ghReview(1, daysAgo(0), "user1", "APPROVED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(1), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenApproved: 1},
},
},
{
name: "single changeset with approval by one person, changes-requested by another, then changes-requested by first person",
changesets: []*a8n.Changeset{
ghChangeset(1, daysAgo(2)),
},
start: daysAgo(2),
events: []Event{
ghReview(1, daysAgo(2), "user1", "APPROVED"),
ghReview(1, daysAgo(1), "user2", "CHANGES_REQUESTED"),
ghReview(1, daysAgo(0), "user1", "CHANGES_REQUESTED"),
},
want: []*ChangesetCounts{
{Time: daysAgo(2), Total: 1, Open: 1, OpenApproved: 1},
{Time: daysAgo(1), Total: 1, Open: 1, OpenChangesRequested: 1},
{Time: daysAgo(0), Total: 1, Open: 1, OpenChangesRequested: 1},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.end.IsZero() {
tc.end = now
}
have, err := CalcCounts(tc.start, tc.end, tc.changesets, tc.events...)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(have, tc.want) {
t.Errorf("wrong counts calculated. diff=%s", cmp.Diff(have, tc.want))
}
})
}
}
type fakeEvent struct {
t time.Time
kind a8n.ChangesetEventKind
id int64
}
func (e fakeEvent) Timestamp() time.Time { return e.t }
func (e fakeEvent) Type() a8n.ChangesetEventKind { return e.kind }
func (e fakeEvent) Changeset() int64 { return e.id }
func ghChangeset(id int64, t time.Time) *a8n.Changeset {
return &a8n.Changeset{ID: id, Metadata: &github.PullRequest{CreatedAt: t}}
}
func ghReview(id int64, t time.Time, login, state string) *a8n.ChangesetEvent {
return &a8n.ChangesetEvent{
ChangesetID: id,
Kind: a8n.ChangesetEventKindGitHubReviewed,
Metadata: &github.PullRequestReview{
UpdatedAt: t,
State: state,
Author: github.Actor{
Login: login,
},
},
}
}

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"path"
"sync"
"time"
"github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/relay"
@ -353,6 +354,73 @@ func (r *campaignResolver) Changesets(ctx context.Context, args struct {
}
}
func (r *campaignResolver) ChangesetCountsOverTime(
ctx context.Context,
args *graphqlbackend.ChangesetCountsArgs,
) ([]graphqlbackend.ChangesetCountsResolver, error) {
// 🚨 SECURITY: Only site admins may access the counts for now
if err := backend.CheckCurrentUserIsSiteAdmin(ctx); err != nil {
return nil, err
}
resolvers := []graphqlbackend.ChangesetCountsResolver{}
opts := ee.ListChangesetsOpts{CampaignID: r.Campaign.ID}
cs, _, err := r.store.ListChangesets(ctx, opts)
if err != nil {
return resolvers, err
}
start := r.Campaign.CreatedAt.UTC()
if args.From != nil {
start = args.From.Time.UTC()
}
end := time.Now().UTC()
if args.To != nil && args.To.Time.Before(end) {
end = args.To.Time.UTC()
}
changesetIDs := make([]int64, len(cs))
for i, c := range cs {
changesetIDs[i] = c.ID
}
var (
events []ee.Event
eventsOpts = ee.ListChangesetEventsOpts{
ChangesetIDs: changesetIDs,
Limit: 1000,
}
)
for {
es, next, err := r.store.ListChangesetEvents(ctx, eventsOpts)
if err != nil {
return resolvers, err
}
for _, e := range es {
events = append(events, e)
}
if next == 0 {
break
}
eventsOpts.Cursor = next
}
counts, err := ee.CalcCounts(start, end, cs, events...)
if err != nil {
return resolvers, err
}
for _, c := range counts {
resolvers = append(resolvers, &changesetCountsResolver{counts: c})
}
return resolvers, nil
}
func (r *Resolver) CreateChangesets(ctx context.Context, args *graphqlbackend.CreateChangesetsArgs) (_ []graphqlbackend.ChangesetResolver, err error) {
// 🚨 SECURITY: Only site admins may create changesets for now
if err := backend.CheckCurrentUserIsSiteAdmin(ctx); err != nil {
@ -587,8 +655,8 @@ func (r *changesetResolver) Events(ctx context.Context, args *struct {
store: r.store,
changeset: r.Changeset,
opts: ee.ListChangesetEventsOpts{
ChangesetID: r.Changeset.ID,
Limit: int(args.ConnectionArgs.GetFirst()),
ChangesetIDs: []int64{r.Changeset.ID},
Limit: int(args.ConnectionArgs.GetFirst()),
},
}, nil
}
@ -618,7 +686,7 @@ func (r *changesetEventsConnectionResolver) Nodes(ctx context.Context) ([]graphq
}
func (r *changesetEventsConnectionResolver) TotalCount(ctx context.Context) (int32, error) {
opts := ee.CountChangesetEventsOpts{ChangesetID: r.opts.ChangesetID}
opts := ee.CountChangesetEventsOpts{ChangesetID: r.opts.ChangesetIDs[0]}
count, err := r.store.CountChangesetEvents(ctx, opts)
return int32(count), err
}
@ -667,3 +735,18 @@ func unmarshalRepositoryID(id graphql.ID) (repo api.RepoID, err error) {
err = relay.UnmarshalSpec(id, &repo)
return
}
type changesetCountsResolver struct {
counts *ee.ChangesetCounts
}
func (r *changesetCountsResolver) Date() graphqlbackend.DateTime {
return graphqlbackend.DateTime{Time: r.counts.Time}
}
func (r *changesetCountsResolver) Total() int32 { return r.counts.Total }
func (r *changesetCountsResolver) Merged() int32 { return r.counts.Merged }
func (r *changesetCountsResolver) Closed() int32 { return r.counts.Closed }
func (r *changesetCountsResolver) Open() int32 { return r.counts.Open }
func (r *changesetCountsResolver) OpenApproved() int32 { return r.counts.OpenApproved }
func (r *changesetCountsResolver) OpenChangesRequested() int32 { return r.counts.OpenChangesRequested }
func (r *changesetCountsResolver) OpenPending() int32 { return r.counts.OpenPending }

View File

@ -19,9 +19,11 @@ import (
graphql "github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/errors"
"github.com/sourcegraph/sourcegraph/cmd/frontend/backend"
"github.com/sourcegraph/sourcegraph/cmd/frontend/db"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend"
"github.com/sourcegraph/sourcegraph/cmd/repo-updater/repos"
"github.com/sourcegraph/sourcegraph/enterprise/pkg/a8n"
ee "github.com/sourcegraph/sourcegraph/enterprise/pkg/a8n"
"github.com/sourcegraph/sourcegraph/internal/a8n"
"github.com/sourcegraph/sourcegraph/internal/actor"
"github.com/sourcegraph/sourcegraph/internal/api"
"github.com/sourcegraph/sourcegraph/internal/db/dbconn"
@ -57,7 +59,7 @@ func TestCampaigns(t *testing.T) {
}
sr := &Resolver{
store: a8n.NewStoreWithClock(dbconn.Global, clock),
store: ee.NewStoreWithClock(dbconn.Global, clock),
httpFactory: cf,
}
@ -433,15 +435,27 @@ func TestCampaigns(t *testing.T) {
}
}
type ChangesetCounts struct {
Date graphqlbackend.DateTime
Total int32
Merged int32
Closed int32
Open int32
OpenApproved int32
OpenChangesRequested int32
OpenPending int32
}
type CampaignWithChangesets struct {
ID string
Name string
Description string
Author User
CreatedAt string
UpdatedAt string
Namespace UserOrg
Changesets ChangesetConnection
ID string
Name string
Description string
Author User
CreatedAt string
UpdatedAt string
Namespace UserOrg
Changesets ChangesetConnection
ChangesetCountsOverTime []ChangesetCounts
}
var addChangesetsResult struct{ Campaign CampaignWithChangesets }
@ -451,6 +465,11 @@ func TestCampaigns(t *testing.T) {
changesetIDs = append(changesetIDs, c.ID)
}
// Date when PR #999 from above was created
countsFrom := parseJSONTime(t, "2018-11-14T22:07:45Z")
// Date when PR #999 from above was merged
countsTo := parseJSONTime(t, "2018-12-04T08:10:07Z")
mustExec(ctx, t, s, nil, &addChangesetsResult, fmt.Sprintf(`
fragment u on User { id, databaseID, siteAdmin }
fragment o on Org { id, name }
@ -483,13 +502,28 @@ func TestCampaigns(t *testing.T) {
totalCount
pageInfo { hasNextPage }
}
changesetCountsOverTime(from: %s, to: %s) {
date
total
merged
closed
open
openApproved
openChangesRequested
openPending
}
}
mutation() {
campaign: addChangesetsToCampaign(campaign: %q, changesets: %s) {
...c
}
}
`, campaigns.Admin.ID, marshalJSON(t, changesetIDs)))
`,
marshalDateTime(t, countsFrom),
marshalDateTime(t, countsTo),
campaigns.Admin.ID,
marshalJSON(t, changesetIDs),
))
{
have := addChangesetsResult.Campaign.Changesets.TotalCount
@ -527,6 +561,21 @@ func TestCampaigns(t *testing.T) {
}
}
{
counts := addChangesetsResult.Campaign.ChangesetCountsOverTime
// There's 20 1-day intervals between countsFrom and including countsTo
if have, want := len(counts), 20; have != want {
t.Errorf("wrong changeset counts length %d, have=%d", want, have)
}
for _, c := range counts {
if have, want := c.Total, int32(1); have != want {
t.Errorf("wrong changeset counts total %d, have=%d", want, have)
}
}
}
deleteInput := map[string]interface{}{"id": campaigns.Admin.ID}
mustExec(ctx, t, s, deleteInput, &struct{}{}, `
mutation($id: ID!){
@ -551,6 +600,157 @@ func TestCampaigns(t *testing.T) {
}
}
func TestChangesetCountsOverTime(t *testing.T) {
if testing.Short() {
t.Skip()
}
ctx := backend.WithAuthzBypass(context.Background())
dbtesting.SetupGlobalTestDB(t)
rcache.SetupForTest(t)
cf, save := newGithubClientFactory(t, "test-changeset-counts-over-time")
defer save()
now := time.Now().UTC().Truncate(time.Microsecond)
clock := func() time.Time {
return now.UTC().Truncate(time.Microsecond)
}
u, err := db.Users.Create(ctx, db.NewUser{
Email: "thorsten@sourcegraph.com",
Username: "thorsten",
DisplayName: "thorsten",
Password: "1234",
EmailVerificationCode: "foobar",
})
if err != nil {
t.Fatal(err)
}
repoStore := repos.NewDBStore(dbconn.Global, sql.TxOptions{})
githubExtSvc := &repos.ExternalService{
Kind: "GITHUB",
DisplayName: "GitHub",
Config: marshalJSON(t, &schema.GitHubConnection{
Url: "https://github.com",
Token: os.Getenv("GITHUB_TOKEN"),
Repos: []string{"sourcegraph/sourcegraph"},
}),
}
err = repoStore.UpsertExternalServices(ctx, githubExtSvc)
if err != nil {
t.Fatal(t)
}
githubSrc, err := repos.NewGithubSource(githubExtSvc, cf)
if err != nil {
t.Fatal(t)
}
githubRepo, err := githubSrc.GetRepo(ctx, "sourcegraph/sourcegraph")
if err != nil {
t.Fatal(err)
}
err = repoStore.UpsertRepos(ctx, githubRepo)
if err != nil {
t.Fatal(err)
}
store := ee.NewStoreWithClock(dbconn.Global, clock)
campaign := &a8n.Campaign{
Name: "Test campaign",
Description: "Testing changeset counts",
AuthorID: u.ID,
NamespaceUserID: u.ID,
}
err = store.CreateCampaign(ctx, campaign)
if err != nil {
t.Fatal(err)
}
changesets := []*a8n.Changeset{
{
RepoID: int32(githubRepo.ID),
ExternalID: "5834",
ExternalServiceType: githubRepo.ExternalRepo.ServiceType,
CampaignIDs: []int64{campaign.ID},
},
{
RepoID: int32(githubRepo.ID),
ExternalID: "5849",
ExternalServiceType: githubRepo.ExternalRepo.ServiceType,
CampaignIDs: []int64{campaign.ID},
},
}
err = store.CreateChangesets(ctx, changesets...)
if err != nil {
t.Fatal(err)
}
syncer := ee.ChangesetSyncer{
ReposStore: repoStore,
Store: store,
HTTPFactory: cf,
}
err = syncer.SyncChangesets(ctx, changesets...)
if err != nil {
t.Fatal(err)
}
for _, c := range changesets {
campaign.ChangesetIDs = append(campaign.ChangesetIDs, c.ID)
}
err = store.UpdateCampaign(ctx, campaign)
if err != nil {
t.Fatal(err)
}
// Date when PR #5834 was created: "2019-10-02T14:49:31Z"
// We start exactly one day earlier
// Date when PR #5849 was created: "2019-10-03T15:03:21Z"
start := parseJSONTime(t, "2019-10-01T14:49:31Z")
// Date when PR #5834 was merged: "2019-10-07T13:13:45Z"
// Date when PR #5849 was merged: "2019-10-04T08:55:21Z"
end := parseJSONTime(t, "2019-10-07T13:13:45Z")
daysBeforeEnd := func(days int) time.Time {
return end.AddDate(0, 0, -days)
}
r := &campaignResolver{store: store, Campaign: campaign}
rs, err := r.ChangesetCountsOverTime(ctx, &graphqlbackend.ChangesetCountsArgs{
From: &graphqlbackend.DateTime{Time: start},
To: &graphqlbackend.DateTime{Time: end},
})
if err != nil {
t.Fatalf("ChangsetCountsOverTime failed with error: %s", err)
}
have := make([]*ee.ChangesetCounts, 0, len(rs))
for _, cr := range rs {
r := cr.(*changesetCountsResolver)
have = append(have, r.counts)
}
want := []*ee.ChangesetCounts{
{Time: daysBeforeEnd(5), Total: 0, Open: 0},
{Time: daysBeforeEnd(4), Total: 1, Open: 1, OpenPending: 1},
{Time: daysBeforeEnd(3), Total: 2, Open: 1, OpenPending: 1, Merged: 1},
{Time: daysBeforeEnd(2), Total: 2, Open: 1, OpenPending: 1, Merged: 1},
{Time: daysBeforeEnd(1), Total: 2, Open: 1, OpenPending: 1, Merged: 1},
{Time: end, Total: 2, Merged: 2},
}
if !reflect.DeepEqual(have, want) {
t.Errorf("wrong counts listed. diff=%s", cmp.Diff(have, want))
}
}
func mustExec(
ctx context.Context,
t testing.TB,
@ -651,6 +851,30 @@ func marshalJSON(t testing.TB, v interface{}) string {
return string(bs)
}
func marshalDateTime(t testing.TB, ts time.Time) string {
t.Helper()
dt := graphqlbackend.DateTime{Time: ts}
bs, err := dt.MarshalJSON()
if err != nil {
t.Fatal(err)
}
return string(bs)
}
func parseJSONTime(t testing.TB, ts string) time.Time {
t.Helper()
timestamp, err := time.Parse(time.RFC3339, ts)
if err != nil {
t.Fatal(err)
}
return timestamp
}
func getBitbucketServerRepos(t testing.TB, ctx context.Context, src *repos.BitbucketServerSource) []*repos.Repo {
results := make(chan repos.SourceResult)

File diff suppressed because one or more lines are too long

View File

@ -502,9 +502,9 @@ func getChangesetEventQuery(opts *GetChangesetEventOpts) *sqlf.Query {
// ListChangesetEventsOpts captures the query options needed for
// listing changeset events.
type ListChangesetEventsOpts struct {
ChangesetID int64
Cursor int64
Limit int
ChangesetIDs []int64
Cursor int64
Limit int
}
// ListChangesetEvents lists ChangesetEvents with the given filters.
@ -555,8 +555,15 @@ func listChangesetEventsQuery(opts *ListChangesetEventsOpts) *sqlf.Query {
sqlf.Sprintf("id >= %s", opts.Cursor),
}
if opts.ChangesetID != 0 {
preds = append(preds, sqlf.Sprintf("changeset_id = %s", opts.ChangesetID))
if len(opts.ChangesetIDs) != 0 {
ids := make([]*sqlf.Query, 0, len(opts.ChangesetIDs))
for _, id := range opts.ChangesetIDs {
if id != 0 {
ids = append(ids, sqlf.Sprintf("%d", id))
}
}
preds = append(preds,
sqlf.Sprintf("changeset_id IN (%s)", sqlf.Join(ids, ",")))
}
return sqlf.Sprintf(

View File

@ -682,58 +682,84 @@ func testStore(db *sql.DB) func(*testing.T) {
})
t.Run("List", func(t *testing.T) {
for i := 1; i <= len(events); i++ {
opts := ListChangesetEventsOpts{ChangesetID: int64(i)}
t.Run("ByChangesetIDs", func(t *testing.T) {
for i := 1; i <= len(events); i++ {
opts := ListChangesetEventsOpts{ChangesetIDs: []int64{int64(i)}}
ts, next, err := s.ListChangesetEvents(ctx, opts)
if err != nil {
t.Fatal(err)
}
if have, want := next, int64(0); have != want {
t.Fatalf("opts: %+v: have next %v, want %v", opts, have, want)
}
have, want := ts, events[i-1:i]
if len(have) != len(want) {
t.Fatalf("listed %d events, want: %d", len(have), len(want))
}
if diff := cmp.Diff(have, want); diff != "" {
t.Fatalf("opts: %+v, diff: %s", opts, diff)
}
}
for i := 1; i <= len(events); i++ {
cs, next, err := s.ListChangesetEvents(ctx, ListChangesetEventsOpts{Limit: i})
if err != nil {
t.Fatal(err)
}
{
have, want := next, int64(0)
if i < len(events) {
want = events[i].ID
ts, next, err := s.ListChangesetEvents(ctx, opts)
if err != nil {
t.Fatal(err)
}
if have != want {
t.Fatalf("limit: %v: have next %v, want %v", i, have, want)
if have, want := next, int64(0); have != want {
t.Fatalf("opts: %+v: have next %v, want %v", opts, have, want)
}
}
{
have, want := cs, events[:i]
have, want := ts, events[i-1:i]
if len(have) != len(want) {
t.Fatalf("listed %d events, want: %d", len(have), len(want))
}
if diff := cmp.Diff(have, want); diff != "" {
t.Fatal(diff)
t.Fatalf("opts: %+v, diff: %s", opts, diff)
}
}
}
{
{
opts := ListChangesetEventsOpts{ChangesetIDs: []int64{}}
for i := 1; i <= len(events); i++ {
opts.ChangesetIDs = append(opts.ChangesetIDs, int64(i))
}
ts, next, err := s.ListChangesetEvents(ctx, opts)
if err != nil {
t.Fatal(err)
}
if have, want := next, int64(0); have != want {
t.Fatalf("opts: %+v: have next %v, want %v", opts, have, want)
}
have, want := ts, events
if len(have) != len(want) {
t.Fatalf("listed %d events, want: %d", len(have), len(want))
}
}
})
t.Run("WithLimit", func(t *testing.T) {
for i := 1; i <= len(events); i++ {
cs, next, err := s.ListChangesetEvents(ctx, ListChangesetEventsOpts{Limit: i})
if err != nil {
t.Fatal(err)
}
{
have, want := next, int64(0)
if i < len(events) {
want = events[i].ID
}
if have != want {
t.Fatalf("limit: %v: have next %v, want %v", i, have, want)
}
}
{
have, want := cs, events[:i]
if len(have) != len(want) {
t.Fatalf("listed %d events, want: %d", len(have), len(want))
}
if diff := cmp.Diff(have, want); diff != "" {
t.Fatal(diff)
}
}
}
})
t.Run("WithCursor", func(t *testing.T) {
var cursor int64
for i := 1; i <= len(events); i++ {
opts := ListChangesetEventsOpts{Cursor: cursor, Limit: 1}
@ -749,7 +775,7 @@ func testStore(db *sql.DB) func(*testing.T) {
cursor = next
}
}
})
})
})
}

View File

@ -241,7 +241,7 @@ func testGitHubWebhook(db *sql.DB) func(*testing.T) {
}
req.Header.Set("X-Github-Event", tc.event.name)
req.Header.Set("X-Hub-Signature", sign(body, []byte(tc.secret)))
req.Header.Set("X-Hub-Signature", sign(t, body, []byte(tc.secret)))
rec := httptest.NewRecorder()
hook.ServeHTTP(rec, req)
@ -309,9 +309,16 @@ func loadFixtures(t testing.TB) map[string]event {
return fs
}
func sign(message, secret []byte) string {
func sign(t *testing.T, message, secret []byte) string {
t.Helper()
mac := hmac.New(sha256.New, secret)
mac.Write(message)
_, err := mac.Write(message)
if err != nil {
t.Fatalf("writing hmac message failed: %s", err)
}
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

View File

@ -58,6 +58,7 @@ const (
ChangesetReviewStateApproved ChangesetReviewState = "APPROVED"
ChangesetReviewStateChangesRequested ChangesetReviewState = "CHANGES_REQUESTED"
ChangesetReviewStatePending ChangesetReviewState = "PENDING"
ChangesetReviewStateCommented ChangesetReviewState = "COMMENTED"
)
// Valid returns true if the given Changeset is valid.
@ -65,7 +66,8 @@ func (s ChangesetReviewState) Valid() bool {
switch s {
case ChangesetReviewStateApproved,
ChangesetReviewStateChangesRequested,
ChangesetReviewStatePending:
ChangesetReviewStatePending,
ChangesetReviewStateCommented:
return true
default:
return false
@ -104,6 +106,20 @@ func (t *Changeset) Title() (string, error) {
}
}
// ExternalCreatedAt is when the Changeset was created on the codehost. When it
// cannot be determined when the changeset was created, a zero-value timestamp
// is returned.
func (t *Changeset) ExternalCreatedAt() time.Time {
switch m := t.Metadata.(type) {
case *github.PullRequest:
return m.CreatedAt
case *bitbucketserver.PullRequest:
return unixMilliToTime(int64(m.CreatedDate))
default:
return time.Time{}
}
}
// Body of the Changeset.
func (t *Changeset) Body() (string, error) {
switch m := t.Metadata.(type) {
@ -176,7 +192,7 @@ func (t *Changeset) ReviewState() (s ChangesetReviewState, err error) {
return "", errors.New("unknown changeset type")
}
return selectReviewState(states), nil
return SelectReviewState(states), nil
}
// Events returns the list of ChangesetEvents from the Changeset's metadata.
@ -207,7 +223,11 @@ func (t *Changeset) Events() (events []*ChangesetEvent) {
return events
}
func selectReviewState(states map[ChangesetReviewState]bool) ChangesetReviewState {
// SelectReviewState computes the single review state for a given set of
// ChangesetReviewStates. Since a pull request, for example, can have multiple
// reviews with different states, we need a function to determine what the
// state for the pull request is.
func SelectReviewState(states map[ChangesetReviewState]bool) ChangesetReviewState {
// If any review requested changes, that state takes precedence over all
// other review states, followed by explicit approval. Everything else is
// considered pending.
@ -241,6 +261,51 @@ func (e *ChangesetEvent) Clone() *ChangesetEvent {
return &ee
}
// Type returns the ChangesetEventKind of the ChangesetEvent.
func (e *ChangesetEvent) Type() ChangesetEventKind {
return e.Kind
}
// Changeset returns the changeset ID of the ChangesetEvent.
func (e *ChangesetEvent) Changeset() int64 {
return e.ChangesetID
}
// Timestamp returns the time when the ChangesetEvent happened (or was updated)
// on the codehost, not when it was created in Sourcegraph's database.
func (e *ChangesetEvent) Timestamp() time.Time {
var t time.Time
switch e := e.Metadata.(type) {
case *github.AssignedEvent:
t = e.CreatedAt
case *github.ClosedEvent:
t = e.CreatedAt
case *github.IssueComment:
t = e.UpdatedAt
case *github.RenamedTitleEvent:
t = e.CreatedAt
case *github.MergedEvent:
t = e.CreatedAt
case *github.PullRequestReview:
t = e.UpdatedAt
case *github.PullRequestReviewComment:
t = e.UpdatedAt
case *github.ReopenedEvent:
t = e.CreatedAt
case *github.ReviewDismissedEvent:
t = e.CreatedAt
case *github.ReviewRequestRemovedEvent:
t = e.CreatedAt
case *github.ReviewRequestedEvent:
t = e.CreatedAt
case *github.UnassignedEvent:
t = e.CreatedAt
}
return t
}
// Update updates the metadata of e with new metadata in o.
func (e *ChangesetEvent) Update(o *ChangesetEvent) {
if e.ChangesetID != o.ChangesetID || e.Kind != o.Kind || e.Key != o.Key {
@ -621,3 +686,7 @@ const (
// - UNAPPROVED
// - UPDATED
)
func unixMilliToTime(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}