diff --git a/submitqueue/extension/changeprovider/phabricator/BUILD.bazel b/submitqueue/extension/changeprovider/phabricator/BUILD.bazel new file mode 100644 index 00000000..3c7ed7d3 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/BUILD.bazel @@ -0,0 +1,40 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "phabricator", + srcs = [ + "conduit.go", + "convert.go", + "provider.go", + ], + importpath = "github.com/uber/submitqueue/submitqueue/extension/changeprovider/phabricator", + visibility = ["//visibility:public"], + deps = [ + "//platform/base/change/phabricator", + "//platform/metrics", + "//submitqueue/entity", + "//submitqueue/extension/changeprovider", + "@com_github_uber_go_tally//:tally", + "@org_uber_go_zap//:zap", + ], +) + +go_test( + name = "phabricator_test", + srcs = [ + "conduit_test.go", + "convert_test.go", + "provider_test.go", + ], + embed = [":phabricator"], + deps = [ + "//platform/base/change", + "//platform/base/change/phabricator", + "//submitqueue/entity", + "//submitqueue/extension/changeprovider", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@com_github_uber_go_tally//:tally", + "@org_uber_go_zap//zaptest", + ], +) diff --git a/submitqueue/extension/changeprovider/phabricator/conduit.go b/submitqueue/extension/changeprovider/phabricator/conduit.go new file mode 100644 index 00000000..38c9aef4 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/conduit.go @@ -0,0 +1,101 @@ +package phabricator + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +// conduitResponse stores a response from the Conduit API. Every Conduit endpoint wraps its +// payload in this shape; a non-nil ErrorCode signals a server-side failure. +type conduitResponse struct { + Result json.RawMessage `json:"result"` + ErrorCode *string `json:"error_code"` + ErrorInfo *string `json:"error_info"` +} + +// diffResult represents a single diff from the differential.querydiffs response. +type diffResult struct { + Changes []fileChange `json:"changes"` + AuthorName string `json:"authorName"` + AuthorEmail string `json:"authorEmail"` +} + +// fileChange represents a single file modification in a Phabricator diff. +type fileChange struct { + CurrentPath string `json:"currentPath"` + AddLines string `json:"addLines"` + DelLines string `json:"delLines"` +} + +// buildQueryDiffsRequest builds query parameters for a differential.querydiffs call. +func buildQueryDiffsRequest(diffIDs []int) url.Values { + form := url.Values{} + for _, id := range diffIDs { + form.Add("ids[]", strconv.Itoa(id)) + } + return form +} + +// doConduitRequest executes a Conduit HTTP request. +// The path is relative — transport layers on the client resolve it to the full URL. +// Authentication (e.g. Bearer token) is handled by the client's transport. +func doConduitRequest(ctx context.Context, httpClient *http.Client, form url.Values) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/api/differential.querydiffs", nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + req.URL.RawQuery = form.Encode() + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + return resp, nil +} + +// parseConduitResponse reads the HTTP response, unwraps the Conduit response, +// and extracts the diffResults for the requested diff IDs. +func parseConduitResponse(resp *http.Response, diffIDs []int) (map[int]*diffResult, error) { + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("Conduit API returned status %d and failed to read body: %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("Conduit API returned status %d: %s", resp.StatusCode, string(body)) + } + + var phabResponse conduitResponse + if err := json.NewDecoder(resp.Body).Decode(&phabResponse); err != nil { + return nil, fmt.Errorf("failed to decode Conduit response: %w", err) + } + + if phabResponse.ErrorCode != nil { + info := "" + if phabResponse.ErrorInfo != nil { + info = *phabResponse.ErrorInfo + } + return nil, fmt.Errorf("Conduit error %s: %s", *phabResponse.ErrorCode, info) + } + + var rawMap map[string]*diffResult + if err := json.Unmarshal(phabResponse.Result, &rawMap); err != nil { + return nil, fmt.Errorf("failed to decode querydiffs result: %w", err) + } + + results := make(map[int]*diffResult, len(diffIDs)) + for _, id := range diffIDs { + diff, ok := rawMap[strconv.Itoa(id)] + if !ok { + return nil, fmt.Errorf("diff %d not found in querydiffs response", id) + } + results[id] = diff + } + + return results, nil +} diff --git a/submitqueue/extension/changeprovider/phabricator/conduit_test.go b/submitqueue/extension/changeprovider/phabricator/conduit_test.go new file mode 100644 index 00000000..f61740a3 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/conduit_test.go @@ -0,0 +1,211 @@ +package phabricator + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildQueryDiffsRequest(t *testing.T) { + testCases := []struct { + name string + diffIDs []int + wantAll []string + }{ + { + name: "single ID", + diffIDs: []int{42}, + wantAll: []string{"ids%5B%5D=42"}, + }, + { + name: "multiple IDs", + diffIDs: []int{42, 43, 44}, + wantAll: []string{"ids%5B%5D=42", "ids%5B%5D=43", "ids%5B%5D=44"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + form := buildQueryDiffsRequest(tc.diffIDs) + encoded := form.Encode() + for _, want := range tc.wantAll { + assert.Contains(t, encoded, want) + } + }) + } +} + +func TestDoConduitRequest(t *testing.T) { + var capturedPath string + var capturedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":{}}`)) + })) + defer server.Close() + + client := &http.Client{Transport: &testTransport{baseURL: server.URL}} + form := buildQueryDiffsRequest([]int{100, 200}) + + resp, err := doConduitRequest(t.Context(), client, form) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "/api/differential.querydiffs", capturedPath) + assert.Contains(t, capturedQuery, "ids%5B%5D=100") + assert.Contains(t, capturedQuery, "ids%5B%5D=200") +} + +func TestDoConduitRequest_ConnectionError(t *testing.T) { + client := &http.Client{Transport: &testTransport{baseURL: "http://127.0.0.1:0"}} + form := buildQueryDiffsRequest([]int{1}) + + _, err := doConduitRequest(t.Context(), client, form) + + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP request failed") +} + +func TestParseConduitResponse(t *testing.T) { + validResult := map[string]*diffResult{ + "100": {AuthorName: "Alice", Changes: []fileChange{{CurrentPath: "a.go"}}}, + "101": {AuthorName: "Bob", Changes: []fileChange{{CurrentPath: "b.go"}}}, + } + + testCases := []struct { + name string + resp *http.Response + diffIDs []int + wantErr string + }{ + { + name: "success", + resp: newConduitHTTPResponse(t, validResult, http.StatusOK), + diffIDs: []int{100, 101}, + }, + { + name: "HTTP error", + resp: newPlainHTTPResponse("server error", http.StatusInternalServerError), + diffIDs: []int{100}, + wantErr: "Conduit API returned status 500", + }, + { + name: "HTTP error with unreadable body", + resp: &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(&errReader{}), + }, + diffIDs: []int{100}, + wantErr: "failed to read body", + }, + { + name: "conduit error", + resp: newConduitErrorHTTPResponse(t, "ERR-CONDUIT-CORE", "Invalid diff ID"), + diffIDs: []int{100}, + wantErr: "Conduit error", + }, + { + name: "diff not found in response", + resp: newConduitHTTPResponse(t, map[string]*diffResult{"999": {}}, http.StatusOK), + diffIDs: []int{100}, + wantErr: "diff 100 not found", + }, + { + name: "malformed JSON", + resp: newPlainHTTPResponse("{not json", http.StatusOK), + diffIDs: []int{100}, + wantErr: "failed to decode Conduit response", + }, + { + name: "malformed result payload", + resp: newPlainHTTPResponse(`{"result":"not a map"}`, http.StatusOK), + diffIDs: []int{100}, + wantErr: "failed to decode querydiffs result", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer tc.resp.Body.Close() + results, err := parseConduitResponse(tc.resp, tc.diffIDs) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Len(t, results, len(tc.diffIDs)) + }) + } +} + +// newConduitHTTPResponse builds an http.Response with a successful Conduit envelope. +func newConduitHTTPResponse(t *testing.T, result any, statusCode int) *http.Response { + t.Helper() + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + envelope := conduitResponse{Result: resultBytes} + body, err := json.Marshal(envelope) + require.NoError(t, err) + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(string(body))), + } +} + +// newConduitErrorHTTPResponse builds an http.Response with a Conduit error envelope. +func newConduitErrorHTTPResponse(t *testing.T, code, info string) *http.Response { + t.Helper() + envelope := conduitResponse{ErrorCode: &code, ErrorInfo: &info} + body, err := json.Marshal(envelope) + require.NoError(t, err) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + } +} + +// newPlainHTTPResponse builds an http.Response with a plain string body. +func newPlainHTTPResponse(body string, statusCode int) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +// serveConduit writes a successful Conduit response envelope to an HTTP handler. +func serveConduit(t *testing.T, w http.ResponseWriter, result any) { + t.Helper() + resultBytes, err := json.Marshal(result) + require.NoError(t, err) + resp := conduitResponse{Result: resultBytes} + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) +} + +// testTransport rewrites request URLs to point at the test server. +type testTransport struct { + baseURL string +} + +func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = t.baseURL[len("http://"):] + return http.DefaultTransport.RoundTrip(req) +} + +type errReader struct{} + +func (e *errReader) Read([]byte) (int, error) { + return 0, fmt.Errorf("simulated read error") +} diff --git a/submitqueue/extension/changeprovider/phabricator/convert.go b/submitqueue/extension/changeprovider/phabricator/convert.go new file mode 100644 index 00000000..14598dc3 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/convert.go @@ -0,0 +1,40 @@ +package phabricator + +import ( + "strconv" + + changephab "github.com/uber/submitqueue/platform/base/change/phabricator" + "github.com/uber/submitqueue/submitqueue/entity" +) + +// convertToChangeInfo converts a Phabricator diff result to an entity.ChangeInfo. +func convertToChangeInfo(parsed changephab.ChangeID, diff *diffResult) entity.ChangeInfo { + return entity.ChangeInfo{ + URI: parsed.String(), + Details: entity.ChangeDetails{ + Author: entity.Author{ + Name: diff.AuthorName, + Email: diff.AuthorEmail, + }, + ChangedFiles: convertFiles(diff.Changes), + }, + } +} + +// convertFiles converts Phabricator file changes to entity.ChangedFile structs. +// Phabricator reports addLines and delLines as strings; parsing failures default +// to zero. The Conduit API does not return a separate modified-lines count, so +// LinesModified is left at its zero value. +func convertFiles(changes []fileChange) []entity.ChangedFile { + files := make([]entity.ChangedFile, 0, len(changes)) + for _, c := range changes { + added, _ := strconv.Atoi(c.AddLines) + deleted, _ := strconv.Atoi(c.DelLines) + files = append(files, entity.ChangedFile{ + Path: c.CurrentPath, + LinesAdded: added, + LinesDeleted: deleted, + }) + } + return files +} diff --git a/submitqueue/extension/changeprovider/phabricator/convert_test.go b/submitqueue/extension/changeprovider/phabricator/convert_test.go new file mode 100644 index 00000000..de4bb6a0 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/convert_test.go @@ -0,0 +1,78 @@ +package phabricator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + changephab "github.com/uber/submitqueue/platform/base/change/phabricator" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestConvertToChangeInfo(t *testing.T) { + parsed := changephab.ChangeID{Scheme: "phab", RevisionID: 12345, DiffID: 100} + diff := &diffResult{ + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + Changes: []fileChange{ + {CurrentPath: "main.go", AddLines: "10", DelLines: "3"}, + {CurrentPath: "test.go", AddLines: "20", DelLines: "0"}, + }, + } + + info := convertToChangeInfo(parsed, diff) + + assert.Equal(t, "phab://D12345/100", info.URI) + assert.Equal(t, entity.Author{Name: "Alice", Email: "alice@example.com"}, info.Details.Author) + assert.Len(t, info.Details.ChangedFiles, 2) +} + +func TestConvertFiles(t *testing.T) { + testCases := []struct { + name string + changes []fileChange + expected []entity.ChangedFile + }{ + { + name: "normal files", + changes: []fileChange{ + {CurrentPath: "a.go", AddLines: "10", DelLines: "5"}, + {CurrentPath: "b.go", AddLines: "0", DelLines: "3"}, + }, + expected: []entity.ChangedFile{ + {Path: "a.go", LinesAdded: 10, LinesDeleted: 5}, + {Path: "b.go", LinesAdded: 0, LinesDeleted: 3}, + }, + }, + { + name: "empty changes", + changes: []fileChange{}, + expected: []entity.ChangedFile{}, + }, + { + name: "unparseable line counts default to zero", + changes: []fileChange{ + {CurrentPath: "c.go", AddLines: "not_a_number", DelLines: ""}, + }, + expected: []entity.ChangedFile{ + {Path: "c.go"}, + }, + }, + { + name: "add-only file", + changes: []fileChange{ + {CurrentPath: "new.go", AddLines: "52", DelLines: "0"}, + }, + expected: []entity.ChangedFile{ + {Path: "new.go", LinesAdded: 52}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := convertFiles(tc.changes) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/submitqueue/extension/changeprovider/phabricator/provider.go b/submitqueue/extension/changeprovider/phabricator/provider.go new file mode 100644 index 00000000..89d68a79 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/provider.go @@ -0,0 +1,133 @@ +package phabricator + +import ( + "context" + "fmt" + "net/http" + + "github.com/uber-go/tally" + "go.uber.org/zap" + + changephab "github.com/uber/submitqueue/platform/base/change/phabricator" + coremetrics "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/changeprovider" +) + +// queryDiffsBatchSize is the maximum number of diff IDs sent in a single +// differential.querydiffs request. +const queryDiffsBatchSize = 10 + +// Params holds the dependencies for the Phabricator ChangeProvider. +type Params struct { + // HTTPClient is a pre-configured HTTP client. The caller is responsible for + // configuring the base URL (e.g. via httpclient.NewClient) and authentication + // (e.g. via a RoundTripper that injects credentials) via transport layers. + HTTPClient *http.Client + // Logger is the structured logger. + Logger *zap.SugaredLogger + // MetricsScope is the metrics scope for instrumentation. + MetricsScope tally.Scope +} + +type provider struct { + httpClient *http.Client + logger *zap.SugaredLogger + metricsScope tally.Scope +} + +// NewProvider creates a new Phabricator ChangeProvider. +func NewProvider(params Params) changeprovider.ChangeProvider { + return &provider{ + httpClient: params.HTTPClient, + logger: params.Logger.Named("phabricator_changeprovider"), + metricsScope: params.MetricsScope.SubScope("phabricator_changeprovider"), + } +} + +// Get retrieves change information from Phabricator for the request's change. +// Returns one ChangeInfo per URI. +func (p *provider) Get(ctx context.Context, request entity.Request) (_ []entity.ChangeInfo, retErr error) { + op := coremetrics.Begin(p.metricsScope, "get") + defer func() { op.Complete(retErr) }() + + change := request.Change + + changeIDs := make([]changephab.ChangeID, 0, len(change.URIs)) + for _, uri := range change.URIs { + parsed, err := changephab.ParseChangeID(uri) + if err != nil { + return nil, fmt.Errorf("failed to parse Phabricator change ID %q: %w", uri, err) + } + changeIDs = append(changeIDs, parsed) + } + + p.logger.Debugw("fetching diff data from Phabricator", + "diff_count", len(changeIDs), + "uris", change.URIs, + ) + + diffs, err := p.fetchAllDiffs(ctx, changeIDs) + if err != nil { + return nil, err + } + + changeInfos := make([]entity.ChangeInfo, 0, len(changeIDs)) + for _, cid := range changeIDs { + diff := diffs[cid.DiffID] + changeInfo := convertToChangeInfo(cid, diff) + changeInfos = append(changeInfos, changeInfo) + + p.logger.Debugw("fetched diff data", + "revision", cid.Revision(), + "diff_id", cid.DiffID, + "files_count", len(changeInfo.Details.ChangedFiles), + ) + } + + p.logger.Debugw("successfully fetched diff data", + "diff_count", len(changeIDs), + ) + + return changeInfos, nil +} + +// fetchAllDiffs fetches diff data for all change IDs in batches of queryDiffsBatchSize. +func (p *provider) fetchAllDiffs(ctx context.Context, changeIDs []changephab.ChangeID) (map[int]*diffResult, error) { + diffIDs := make([]int, 0, len(changeIDs)) + for _, cid := range changeIDs { + diffIDs = append(diffIDs, cid.DiffID) + } + + results := make(map[int]*diffResult, len(diffIDs)) + + for start := 0; start < len(diffIDs); start += queryDiffsBatchSize { + end := start + queryDiffsBatchSize + if end > len(diffIDs) { + end = len(diffIDs) + } + + batch, err := p.fetchDiffBatch(ctx, diffIDs[start:end]) + if err != nil { + return nil, fmt.Errorf("failed to fetch diffs: %w", err) + } + for id, diff := range batch { + results[id] = diff + } + } + + return results, nil +} + +// fetchDiffBatch fetches a single batch of diffs from Phabricator. +func (p *provider) fetchDiffBatch(ctx context.Context, diffIDs []int) (map[int]*diffResult, error) { + form := buildQueryDiffsRequest(diffIDs) + + resp, err := doConduitRequest(ctx, p.httpClient, form) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return parseConduitResponse(resp, diffIDs) +} diff --git a/submitqueue/extension/changeprovider/phabricator/provider_test.go b/submitqueue/extension/changeprovider/phabricator/provider_test.go new file mode 100644 index 00000000..743b18f8 --- /dev/null +++ b/submitqueue/extension/changeprovider/phabricator/provider_test.go @@ -0,0 +1,257 @@ +package phabricator + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + "go.uber.org/zap/zaptest" + + "github.com/uber/submitqueue/platform/base/change" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/changeprovider" +) + +func newTestProvider(t *testing.T, client *http.Client) changeprovider.ChangeProvider { + t.Helper() + return NewProvider(Params{ + HTTPClient: client, + Logger: zaptest.NewLogger(t).Sugar(), + MetricsScope: tally.NoopScope, + }) +} + +func validDiffResponse() map[string]*diffResult { + return map[string]*diffResult{ + "100": { + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Changes: []fileChange{ + {CurrentPath: "main.go", AddLines: "10", DelLines: "3"}, + {CurrentPath: "test.go", AddLines: "20", DelLines: "0"}, + }, + }, + } +} + +func TestProvider_Get(t *testing.T) { + testCases := []struct { + name string + handler http.HandlerFunc + uris []string + wantErr string + }{ + { + name: "returns result for valid diff", + handler: func(w http.ResponseWriter, r *http.Request) { + serveConduit(t, w, validDiffResponse()) + }, + uris: []string{"phab://D200/100"}, + }, + { + name: "invalid URI returns error", + uris: []string{"invalid://uri"}, + wantErr: "failed to parse Phabricator change ID", + }, + { + name: "HTTP error returns error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + uris: []string{"phab://D200/100"}, + wantErr: "Conduit API returned status 500", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var client *http.Client + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + client = &http.Client{Transport: &testTransport{baseURL: server.URL}} + } else { + client = &http.Client{Transport: &testTransport{baseURL: "http://localhost"}} + } + + p := newTestProvider(t, client) + infos, err := p.Get(context.Background(), entity.Request{Change: change.Change{URIs: tc.uris}}) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + require.Len(t, infos, 1) + assert.Equal(t, "phab://D200/100", infos[0].URI) + assert.Equal(t, "Test Author", infos[0].Details.Author.Name) + assert.Len(t, infos[0].Details.ChangedFiles, 2) + }) + } +} + +func TestProvider_Get_MultipleDiffs(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + serveConduit(t, w, map[string]*diffResult{ + "100": { + AuthorName: "Author A", + AuthorEmail: "a@example.com", + Changes: []fileChange{{CurrentPath: "file1.go", AddLines: "5", DelLines: "1"}}, + }, + "101": { + AuthorName: "Author B", + AuthorEmail: "b@example.com", + Changes: []fileChange{{CurrentPath: "file2.go", AddLines: "8", DelLines: "2"}}, + }, + }) + })) + defer server.Close() + + client := &http.Client{Transport: &testTransport{baseURL: server.URL}} + p := newTestProvider(t, client) + infos, err := p.Get(context.Background(), entity.Request{Change: change.Change{ + URIs: []string{"phab://D200/100", "phab://D201/101"}, + }}) + + require.NoError(t, err) + assert.Equal(t, 1, callCount) + require.Len(t, infos, 2) + assert.Equal(t, "phab://D200/100", infos[0].URI) + assert.Equal(t, "phab://D201/101", infos[1].URI) +} + +func TestProvider_Get_ConnectionError(t *testing.T) { + client := &http.Client{Transport: &testTransport{baseURL: "http://127.0.0.1:0"}} + p := newTestProvider(t, client) + _, err := p.Get(context.Background(), entity.Request{Change: change.Change{ + URIs: []string{"phab://D200/100"}, + }}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch diffs") +} + +func TestProvider_Get_FileDetails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result := map[string]*diffResult{ + "100": { + AuthorName: "Author", + AuthorEmail: "author@example.com", + Changes: []fileChange{ + {CurrentPath: "added.go", AddLines: "52", DelLines: "0"}, + {CurrentPath: "modified.go", AddLines: "10", DelLines: "5"}, + {CurrentPath: "deleted.go", AddLines: "0", DelLines: "30"}, + }, + }, + } + resultBytes, _ := json.Marshal(result) + resp := conduitResponse{Result: resultBytes} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := &http.Client{Transport: &testTransport{baseURL: server.URL}} + p := newTestProvider(t, client) + infos, err := p.Get(context.Background(), entity.Request{Change: change.Change{ + URIs: []string{"phab://D200/100"}, + }}) + + require.NoError(t, err) + require.Len(t, infos, 1) + + files := infos[0].Details.ChangedFiles + require.Len(t, files, 3) + + assert.Equal(t, "added.go", files[0].Path) + assert.Equal(t, 52, files[0].LinesAdded) + assert.Equal(t, 0, files[0].LinesDeleted) + assert.Equal(t, 0, files[0].LinesModified) + + assert.Equal(t, "modified.go", files[1].Path) + assert.Equal(t, 10, files[1].LinesAdded) + assert.Equal(t, 5, files[1].LinesDeleted) + assert.Equal(t, 0, files[1].LinesModified) + + assert.Equal(t, "deleted.go", files[2].Path) + assert.Equal(t, 0, files[2].LinesAdded) + assert.Equal(t, 30, files[2].LinesDeleted) + assert.Equal(t, 0, files[2].LinesModified) +} + +func TestProvider_Get_Batching(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + query := r.URL.Query() + ids := query["ids[]"] + result := make(map[string]*diffResult, len(ids)) + for _, id := range ids { + result[id] = &diffResult{ + AuthorName: "Author " + id, + Changes: []fileChange{{CurrentPath: id + ".go", AddLines: "1", DelLines: "0"}}, + } + } + serveConduit(t, w, result) + })) + defer server.Close() + + uris := make([]string, 25) + for i := range uris { + diffID := i + 1 + revisionID := 1000 + i + uris[i] = fmt.Sprintf("phab://D%d/%d", revisionID, diffID) + } + + client := &http.Client{Transport: &testTransport{baseURL: server.URL}} + p := newTestProvider(t, client) + infos, err := p.Get(context.Background(), entity.Request{Change: change.Change{URIs: uris}}) + + require.NoError(t, err) + assert.Equal(t, 3, callCount) + assert.Len(t, infos, 25) +} + +func TestProvider_Get_BatchingStopsOnError(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + query := r.URL.Query() + ids := query["ids[]"] + result := make(map[string]*diffResult, len(ids)) + for _, id := range ids { + result[id] = &diffResult{ + Changes: []fileChange{{CurrentPath: id + ".go", AddLines: "1", DelLines: "0"}}, + } + } + serveConduit(t, w, result) + })) + defer server.Close() + + uris := make([]string, 25) + for i := range uris { + diffID := i + 1 + revisionID := 1000 + i + uris[i] = fmt.Sprintf("phab://D%d/%d", revisionID, diffID) + } + + client := &http.Client{Transport: &testTransport{baseURL: server.URL}} + p := newTestProvider(t, client) + _, err := p.Get(context.Background(), entity.Request{Change: change.Change{URIs: uris}}) + + require.Error(t, err) + assert.Equal(t, 2, callCount) +}