Commit Diff


commit - 544f91b1a634777e37327f09f162916807bbfea2
commit + ff42d81ed53b26b9adf21df9618b77f17429c26a
blob - /dev/null
blob + 66fd13c903cac02eb9657cd53fb227823484401d (mode 644)
--- /dev/null
+++ backends/.gitignore
@@ -0,0 +1,15 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
blob - /dev/null
blob + 7052f6db01b4e33bcaa870e1217606ac878a3386 (mode 644)
--- /dev/null
+++ backends/README.md
@@ -0,0 +1,10 @@
+# testres-backends
+
+is a library to import test results.
+
+## Building
+
+```
+$ go get ./...
+$ go test -v ./...
+```
blob - /dev/null
blob + 120c4a3c6414f3596d492fc3ad6b2013b039c6a6 (mode 644)
--- /dev/null
+++ backends/TODO.md
@@ -0,0 +1,154 @@
+### TODO
+
+- конверторы для форматов в `formats/common.go`
+- SubUnit V2
+  - https://github.com/msgpack/msgpack-go/blob/master/unpack.go
+  - https://github.com/hashicorp/go-msgpack/blob/master/codec/decode.go
+- импорт результатов только старше даты последнего результата из базы
+- добавлять кастомные сертификаты для http клиента (`ca.go`)
+- сохранять в структуре бранч, название пайплайна, коммиты
+- поддержка Lava
+- поддержка Cirrus CI
+- опция `-limit`
+
+### GitHub Actions
+
+- https://help.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts
+- https://developer.github.com/v3/actions/
+- ? https://github.com/google/go-github
+- Documentation: https://docs.github.com/en/rest/reference/actions#artifacts
+
+### Zuul
+
+- Example: https://zuul.opendev.org/t/openstack/builds?project=openstack/glance
+- Example: https://zuul.opendev.org/t/openstack/builds?project=openstack/ceilometer
+- Example: https://zuul.opendev.org/t/openstack/builds?project=openstack/heat
+- https://zuul.opendev.org/openapi
+
+### BitBucket
+
+- https://github.com/ktrysmt/go-bitbucket
+
+### Lava
+
+- https://staging.validation.linaro.org/api/help/
+- XML RPC Client https://github.com/kolo/xmlrpc
+
+### Kernel CI
+
+- https://api.kernelci.org
+- https://github.com/kernelci/kernelci-backend
+- Where is a Golang API?
+- token is required
+
+### Codefresh
+
+- https://github.com/codefresh-io/go-sdk
+- Example: https://github.com/nemequ/portable-snippets
+- test results unavailable via API
+
+### Patchwork/Patchew
+
+- https://patchwork-freedesktop.readthedocs.io/en/latest/rest.html
+- https://github.com/patchew-project/patchew
+- Where is Golang API?
+
+### TestRail
+
+- http://docs.gurock.com/testrail-api2/start
+- https://github.com/gurock/testrail-api
+- Go: https://github.com/educlos/testrail
+- Go: https://godoc.org/github.com/Etienne42/testrail
+- https://secure.gurock.com/customers/testrail/trial/
+- github.com/educlos/testrail
+
+### Beaker
+
+- https://beaker-project.org/docs/server-api/
+- Where is Golang API?
+
+### BuildBot
+
+- REST API: https://github.com/buildbot/buildbot/blob/master/master/docs/developer/rest.rst
+- Example: https://github.com/buildbot/buildbot/wiki/SuccessStories
+- Example: http://buildbot.suricata-ids.org
+- Example: https://buildbot.python.org/
+- Example: http://212.201.121.110:38010/
+- Example: https://buildbot.openinfosecfoundation.org/
+- Example: https://ci.chromium.org/p/chromium/g/main/console
+- Example https://chromium.googlesource.com/infra/luci/luci-go/+/master/grpc/prpc/talk/buildbot/client/main.go
+- LUCI: http://bit.ly/2kgyE9U
+- https://docs.buildbot.net/latest/developer/rest.html
+- "go.chromium.org/luci/grpc/prpc/talk/buildbot/proto"
+- "go.chromium.org/luci/milo/buildsource/buildbot"
+- "go.chromium.org/luci/milo/buildsource/buildbot/buildbotapi"
+- "go.chromium.org/luci/milo/buildsource/buildbot/buildstore"
+
+### Drone CI
+
+- Publish test results in artifacts: https://github.com/drone/docs.drone.io/blob/master/artifacts.markdown
+- Enterprise only: https://0-8-0.docs.drone.io/publish-unit-test-results/
+- API: https://docs.drone.io/api/endpoints/builds/build_list/
+- https://github.com/drone/drone/issues/239
+- Golang API: https://github.com/drone/drone-go
+
+### CDash
+
+- https://my.cdash.org/viewProjects.php
+- https://www.paraview.org/Wiki/CDash:API
+- https://open.cdash.org/viewProjects.php
+- https://open.cdash.org/viewTest.php?buildid=6227968
+- https://open.cdash.org/viewTest.php?buildid=6227571
+- https://my.cdash.org/viewTest.php?buildid=1735823
+- Where is Golang API?
+
+### AWS CodePipeline
+
+- https://docs.aws.amazon.com/en_us/codepipeline/latest/userguide/welcome.html
+- GoDoc: https://docs.aws.amazon.com/sdk-for-go/api/service/codepipeline/
+- GoDoc: https://docs.aws.amazon.com/sdk-for-go/api/service/codepipeline/#Artifact
+
+### Appveyor
+
+- API: https://www.appveyor.com/docs/api/projects-builds/
+- Can't access to test results via REST API https://github.com/appveyor/ci/issues/3226
+- Where is a Golang API? https://github.com/appveyor/ci/issues/3225
+- Example: https://ci.appveyor.com/project/rpcs3/rpcs3/branch/master/tests
+- Example: https://ci.appveyor.com/project/dignifiedquire/deltachat-core-rust/branch/master/tests
+- Example: https://ci.appveyor.com/project/quixdb/portable-snippets/branch/master
+
+### CodeShip CI
+
+- https://apidocs.codeship.com/v2/introduction/basic-vs-pro
+- GoDoc: https://godoc.org/github.com/codeship/codeship-go#Build
+- How to get a testing results?
+
+### Atlassian Bamboo
+
+- https://developer.atlassian.com/server/bamboo/rest-apis/
+- https://github.com/rcarmstrong/go-bamboo
+- TODO: How to get test results?
+- https://community.atlassian.com/t5/Answers-Developer-Questions/How-to-Get-Bamboo-Test-Results-via-REST/qaq-p/475715
+
+### Concourse CI
+
+- https://ci.spearow.io/teams/main/pipelines/oregano/jobs/make-release/builds/30
+
+### Report Portal
+
+- Where is an API documentation? https://github.com/reportportal/service-api/issues/1094
+- Go API https://github.com/avarabyeu/goRP
+- Example: https://rp.epam.com/ui/
+- Example: http://web.demo.reportportal.io/ui/
+- https://github.com/ihar-kahadouski/dev-guide/blob/master/reporting.md
+
+### JetBrains Space
+
+- Golang API?
+- Example?
+
+### Bitrise
+
+- https://api-docs.bitrise.io/
+- https://devcenter.bitrise.io/testing/test-reports/
+- https://devcenter.bitrise.io/testing/exporting-to-test-reports-from-custom-script-steps/
blob - /dev/null
blob + 79e8b10a05801db5d59d6b5720e1f783c877e0bd (mode 644)
--- /dev/null
+++ backends/backend_azure_devops.go
@@ -0,0 +1,180 @@
+package backends
+
+import (
+	"context"
+	"github.com/microsoft/azure-devops-go-api/azuredevops"
+	"github.com/microsoft/azure-devops-go-api/azuredevops/build"
+	"github.com/microsoft/azure-devops-go-api/azuredevops/core"
+	"github.com/microsoft/azure-devops-go-api/azuredevops/pipelines"
+	"github.com/microsoft/azure-devops-go-api/azuredevops/testresults"
+	"github.com/ligurio/testres-db/formats"
+	"log"
+	"net/http"
+)
+
+func getBuilds(ctx context.Context, connection *azuredevops.Connection, ProjectName *string, BranchName *string) (*[]build.Build, error) {
+	buildClient, err := build.NewClient(ctx, connection)
+	if err != nil {
+		return nil, err
+	}
+
+	buildsArgs := build.GetBuildsArgs{Project: ProjectName}
+	if *BranchName != "" {
+		buildsArgs.BranchName = BranchName
+	}
+	responseValue, err := buildClient.GetBuilds(ctx, buildsArgs)
+	if err != nil {
+		return nil, err
+	}
+
+	var builds *[]build.Build = nil
+	builds = &(*responseValue).Value
+	for responseValue != nil {
+		// FIXME: builds = append(*builds, &(*responseValue).Value)
+		for _, teamBuildReference := range (*responseValue).Value {
+			log.Printf("Build %v, %s", *teamBuildReference.BuildNumber, *teamBuildReference.SourceBranch)
+		}
+
+		if responseValue.ContinuationToken != "" {
+			buildsArgs := build.GetBuildsArgs{
+				ContinuationToken: &responseValue.ContinuationToken,
+			}
+			buildsArgs.ContinuationToken = &responseValue.ContinuationToken
+			responseValue, err = buildClient.GetBuilds(ctx, buildsArgs)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			responseValue = nil
+		}
+	}
+
+	testresultsClient := testresults.NewClient(ctx, connection)
+	for _, bld := range *builds {
+		log.Println("Build Num", *bld.Id)
+		TestResultDetailsForBuildArgs := testresults.GetTestResultDetailsForBuildArgs{Project: ProjectName, BuildId: bld.Id}
+		TestResults, err := testresultsClient.GetTestResultDetailsForBuild(ctx, TestResultDetailsForBuildArgs)
+		if err != nil {
+			log.Println("Unsupported?", err)
+			continue
+		}
+		if TestResults != nil {
+			log.Println("TestResults", (*TestResults).GroupByField)
+			// log.Println("TestResults #%v", (*TestResults).ResultsForGroup)
+		}
+	}
+
+	return builds, err
+}
+
+func getPipelineRef(ctx context.Context, connection *azuredevops.Connection, Project *string, PipelineName *string) (*pipelines.Pipeline, error) {
+	pipelineClient := pipelines.NewClient(ctx, connection)
+	responseValue, err := pipelineClient.ListPipelines(ctx, pipelines.ListPipelinesArgs{Project: Project})
+	if err != nil {
+		return nil, err
+	}
+
+	var PipelineRef *pipelines.Pipeline = nil
+	for _, teamPipelineReference := range (*responseValue).Value {
+		// log.Printf("Pipeline = %v", *teamPipelineReference.Name)
+		if *teamPipelineReference.Name == *PipelineName {
+			PipelineRef = &teamPipelineReference
+			break
+		}
+	}
+
+	return PipelineRef, nil
+}
+
+func getProjectRef(ctx context.Context, connection *azuredevops.Connection, ProjectName *string) (*core.TeamProjectReference, error) {
+	coreClient, err := core.NewClient(ctx, connection)
+	if err != nil {
+		return nil, err
+	}
+
+	responseValue, err := coreClient.GetProjects(ctx, core.GetProjectsArgs{})
+	if err != nil {
+		return nil, err
+	}
+
+	index := 0
+	var Project *core.TeamProjectReference = nil
+	for responseValue != nil {
+		for _, teamProjectReference := range (*responseValue).Value {
+			// log.Printf("Name[%v] = %v", index, *teamProjectReference.Name)
+			if *teamProjectReference.Name == *ProjectName {
+				Project = &teamProjectReference
+				break
+			}
+			index++
+		}
+
+		if responseValue.ContinuationToken != "" {
+			projectArgs := core.GetProjectsArgs{
+				ContinuationToken: &responseValue.ContinuationToken,
+			}
+			responseValue, err = coreClient.GetProjects(ctx, projectArgs)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			responseValue = nil
+		}
+	}
+
+	return Project, nil
+
+}
+
+// Using custom http client: https://github.com/microsoft/azure-devops-go-api/issues/52
+func SyncAzureDevOps(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	if b.Username != "" {
+		log.Println("Username is specified but unused", b.Username)
+	}
+
+	connection := azuredevops.NewPatConnection(b.Base, b.Secret)
+	ctx := context.Background()
+
+	project, err := getProjectRef(ctx, connection, &b.Project)
+	if err != nil {
+		return nil, err
+	}
+	if project.Url != nil {
+		log.Println("URL:", *project.Url)
+	}
+	log.Println("Last Update Time:", project.LastUpdateTime)
+
+	if project.Abbreviation != nil {
+		log.Println(*project.Abbreviation)
+	}
+
+	pipeline, err := getPipelineRef(ctx, connection, &b.Project, &b.Pipeline)
+	if err != nil {
+		return nil, err
+	}
+	log.Println("Pipeline:", *pipeline.Name)
+	builds, err := getBuilds(ctx, connection, project.Name, &b.Branch)
+	if err != nil {
+		return nil, err
+	}
+
+	if builds == nil {
+		log.Println("list of builds is empty")
+		return nil, err
+	}
+	for _, bld := range *builds {
+		log.Println("Build", *bld.BuildNumber, *bld.SourceBranch)
+	}
+
+	return nil, nil
+}
+
+/*
+http://localhost:6060/pkg/github.com/microsoft/azure-devops-go-api/azuredevops/testplan/
+http://localhost:6060/pkg/github.com/microsoft/azure-devops-go-api/azuredevops/test/
+http://localhost:6060/pkg/github.com/microsoft/azure-devops-go-api/azuredevops/testresults/
+func AzureDevopsTMS_fn(b *Backend) ([]*formats.TestReport, error) {
+	log.Println("not implemented")
+	return nil, nil
+}
+*/
blob - /dev/null
blob + 34bd3fdcf20c351369628222c8df79b84e535e49 (mode 644)
--- /dev/null
+++ backends/backend_azure_devops_test.go
@@ -0,0 +1,10 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+
+package backends
+
+import "testing"
+
+func TestSyncAzureDevOps(t *testing.T) {
+	t.Log("TestSyncAzureDevOps")
+}
blob - /dev/null
blob + 2a33f857ce99c99d81cbb498d987ab8d6b61fac5 (mode 644)
--- /dev/null
+++ backends/backend_circleci.go
@@ -0,0 +1,48 @@
+package backends
+
+// Documentation: https://circleci.com/docs/2.0/artifacts/
+
+import (
+	"github.com/jszwedko/go-circleci"
+	"github.com/ligurio/testres-db/formats"
+	"log"
+	"net/http"
+	"strings"
+)
+
+func SyncCircleCI(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	project_path := strings.Split(b.Project, "/")
+	if len(project_path) != 2 {
+		log.Println("Perhaps wrong project name specified")
+	}
+
+	account := project_path[0]
+	repo := project_path[1]
+
+	connection := &circleci.Client{Token: b.Secret, HTTPClient: client, Debug: true}
+	builds, err := connection.ListRecentBuildsForProject(account, repo, b.Branch, "", -1, 0)
+	if err != nil {
+		log.Println(err)
+		return nil, err
+	}
+
+	for _, build := range builds {
+		log.Printf("Found build: %d, status %s\n", build.BuildNum, build.Status)
+		metadata, err := connection.ListTestMetadata(account, repo, build.BuildNum)
+		if err != nil {
+			log.Println(err)
+			return nil, err
+		}
+
+		artifacts, err := connection.ListBuildArtifacts(account, repo, build.BuildNum)
+		for _, artifact := range artifacts {
+			log.Println("Found artifact:", artifact.URL)
+		}
+
+		for _, test := range metadata {
+			log.Println("Found test:", test.Result, test.Name, test.RunTime)
+		}
+	}
+
+	return nil, nil
+}
blob - /dev/null
blob + 036bd24a3d6f4d2c4daf823e22c49e5c090194fe (mode 644)
--- /dev/null
+++ backends/backend_circleci_test.go
@@ -0,0 +1,10 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+
+package backends
+
+import "testing"
+
+func TestSyncCircleCI(t *testing.T) {
+	t.Log("TestSyncCircleCI")
+}
blob - /dev/null
blob + b8a4f49dd4208ff17e87a571d3c1476e4fef540e (mode 644)
--- /dev/null
+++ backends/backend_cirrusci.go
@@ -0,0 +1,129 @@
+// https://cirrus-ci.org/api/
+// https://github.com/cirruslabs/cirrus-ci-web/blob/master/schema.graphql
+
+/*
+
+#!/bin/sh
+
+# https://github.com/cirruslabs/cirrus-ci-web/blob/master/schema.graphql
+
+curl -s -X POST --data \
+'{
+  "query": "query BuildBySHAQuery($owner: String!, $name: String!, $SHA: String) { searchBuilds(repositoryOwner: $owner, repositoryName: $name, SHA: $SHA) { id } }",
+  "variables": {
+    "owner": "qemu",
+    "name": "qemu",
+    "SHA": "43d1455cf84283466e5c22a217db5ef4b8197b14"
+  }
+}' \
+https://api.cirrus-ci.com/graphql | python -m json.tool
+*/
+
+package backends
+
+import (
+	"github.com/machinebox/graphql"
+	"github.com/ligurio/testres-db/formats"
+	"golang.org/x/net/context"
+	"net/http"
+)
+
+func SyncCirrusCI(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	graphql_scheme := "https://api.cirrus-ci.com/graphql"
+	ClientOption := graphql.WithHTTPClient(client)
+	connection := graphql.NewClient(graphql_scheme, ClientOption)
+	request := ""
+	req := graphql.NewRequest(request)
+
+	type response struct {
+		Name  string
+		Items struct {
+			Records []struct {
+				Title string
+			}
+		}
+	}
+
+	var respData response
+	ctx := context.Background()
+	if err := connection.Run(ctx, req, &respData); err != nil {
+		return nil, err
+	}
+
+	return nil, nil
+}
+
+/*
+type Root {
+  viewer: User
+  repository(id: ID!): Repository
+  githubRepository(owner: String!, name: String!): Repository
+  githubRepositories(owner: String!): [Repository]
+  githubOrganizationInfo(organization: String!): GitHubOrganizationInfo
+  build(id: ID!): Build
+  searchBuilds(repositoryOwner: String!, repositoryName: String!, SHA: String): [Build]
+  task(id: ID!): Task
+  webhookDelivery(id: String!): WebHookDelivery
+}
+
+type Build {
+  id: ID!
+  repositoryId: ID!
+  branch: String!
+  changeIdInRepo: String!
+  changeMessageTitle: String
+  changeMessage: String
+  durationInSeconds: Int
+  clockDurationInSeconds: Int
+  pullRequest: Int
+  checkSuiteId: Int
+  isSenderUserCollaborator: Boolean
+  senderUserPermissions: String
+  changeTimestamp: Int!
+  buildCreatedTimestamp: Int!
+  status: BuildStatus
+  notifications: [Notification]
+  tasks: [Task]
+  taskGroupsAmount: Int
+  latestGroupTasks: [Task]
+  repository: Repository!
+  viewerPermission: PermissionType!
+}
+
+type Task {
+  id: ID!
+  buildId: ID!
+  repositoryId: ID!
+  name: String!
+  status: TaskStatus
+  notifications: [Notification]
+  commands: [TaskCommand]
+  artifacts: [Artifacts]
+  commandLogsTail(name: String!): [String]
+  statusTimestamp: Int!
+  creationTimestamp: Int!
+  scheduledTimestamp: Int!
+  executingTimestamp: Int!
+  finalStatusTimestamp: Int!
+  durationInSeconds: Int!
+  labels: [String]
+  uniqueLabels: [String]
+  requiredPRLabels: [String]
+  optional: Boolean
+  statusDurations: [TaskStatusDuration]
+  repository: Repository!
+  build: Build!
+  previousRuns: [Task]
+  allOtherRuns: [Task]
+  dependencies: [Task]
+  automaticReRun: Boolean!
+  useComputeCredits: Boolean!
+  usedComputeCredits: Boolean!
+  transaction: AccountTransaction
+  triggerType: TaskTriggerType!
+  instanceResources: InstanceResources
+}
+
+
+
+*/
blob - /dev/null
blob + 500df8262601f0e5a8f62822b1c6e6a77789f59d (mode 644)
--- /dev/null
+++ backends/backend_cirrusci_test.go
@@ -0,0 +1,10 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+
+package backends
+
+import "testing"
+
+func TestSyncCirrusCI(t *testing.T) {
+	t.Log("TestSyncCirrusCI")
+}
blob - /dev/null
blob + 0e2d89eb711612206f1df7348fa41e333d7aad21 (mode 644)
--- /dev/null
+++ backends/backend_common.go
@@ -0,0 +1,85 @@
+package backends
+
+import (
+	"crypto/tls"
+	"errors"
+	"github.com/ligurio/testres-db/formats"
+	"io"
+	"log"
+	"net/http"
+	"os"
+)
+
+type fnSyncBackend func(client *http.Client, b *Backend) (*[]formats.TestResult, error)
+
+var backend = map[string]fnSyncBackend{
+	"azure_devops": SyncAzureDevOps,
+	"circleci":     SyncCircleCI,
+	"cirrusci":     SyncCirrusCI,
+	"gitlab":       SyncGitLab,
+	"jenkins":      SyncJenkins,
+	"teamcity":     SyncTeamCity,
+	"travisci":     SyncTravisCI,
+}
+
+type Backend struct {
+	Name      string
+	Base      string
+	Project   string
+	Branch    string
+	Username  string
+	Secret    string
+	Pipeline  string
+	Type      string
+	Artifacts []Artifact
+}
+
+type Artifact struct {
+	Path string
+}
+
+var (
+	errUnknownBackend = errors.New("Unknown backend")
+)
+
+// https://stackoverflow.com/questions/38822764/how-to-send-a-https-request-with-a-certificate-golang/38825553#38825553
+func NewAPIClient() *http.Client {
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	}
+	client := &http.Client{Transport: tr}
+
+	return client
+}
+
+func (b *Backend) GetTestResults() (*[]formats.TestResult, error) {
+	log.Println("Backend:", b.Type)
+	fn := backend[b.Type]
+	if fn == nil {
+		return nil, errUnknownBackend
+	}
+	client := NewAPIClient()
+	result, err := fn(client, b)
+	if err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+func DownloadFile(filename string, url string) error {
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	out, err := os.Create(filename)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	_, err = io.Copy(out, resp.Body)
+	return err
+}
blob - /dev/null
blob + 8cf732270551d2c04e4c75558243d22f4efac9fa (mode 644)
--- /dev/null
+++ backends/backend_gitlab.go
@@ -0,0 +1,94 @@
+package backends
+
+import (
+	"fmt"
+	"github.com/ligurio/testres-db/formats"
+	gitlab "github.com/xanzy/go-gitlab"
+	"log"
+	"net/http"
+	"path/filepath"
+)
+
+func SyncGitLab(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	if b.Pipeline != "" {
+		log.Println("Option pipeline is specified, but unused")
+	}
+
+	gl := gitlab.NewClient(client, b.Secret)
+	gl.SetBaseURL(b.Base)
+
+	projOpt := &gitlab.GetProjectOptions{
+		Statistics:           gitlab.Bool(false),
+		License:              gitlab.Bool(false),
+		WithCustomAttributes: gitlab.Bool(false),
+	}
+
+	p, _, err := gl.Projects.GetProject(b.Project, projOpt, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	/*
+
+		const (
+		    Pending  BuildStateValue = "pending"
+		    Running  BuildStateValue = "running"
+		    Success  BuildStateValue = "success"
+		    Failed   BuildStateValue = "failed"
+		    Canceled BuildStateValue = "canceled"
+		    Skipped  BuildStateValue = "skipped"
+		    Manual   BuildStateValue = "manual"
+		)
+	*/
+
+	projectOpt := &gitlab.ListProjectPipelinesOptions{
+		Scope:   gitlab.String("finished"),
+		Status:  gitlab.BuildState(gitlab.Success), // FIXME: use at least failed status
+		Ref:     gitlab.String(b.Branch),
+		OrderBy: gitlab.String("updated_at"),
+		Sort:    gitlab.String("asc"),
+	}
+
+	pipelines, _, err := gl.Pipelines.ListProjectPipelines(p.ID, projectOpt)
+	if err != nil {
+		return nil, err
+	}
+
+	jobsOpt := &gitlab.ListJobsOptions{
+		ListOptions: gitlab.ListOptions{Page: 1, PerPage: 10},
+		Scope:       []gitlab.BuildStateValue{"created", "pending", "running", "failed", "success", "canceled", "skipped"},
+	}
+	for _, pipeline := range pipelines {
+		log.Printf("Found pipeline: %d, status %s", pipeline.ID, pipeline.Status)
+		log.Printf("SHA %s, Ref %s", pipeline.SHA, pipeline.Ref)
+		jobs, _, err := gl.Jobs.ListPipelineJobs(p.ID, pipeline.ID, jobsOpt, nil)
+		if err != nil {
+			log.Println(err)
+			continue
+		}
+		for _, job := range jobs {
+			log.Println("Found job", job.ID, job.Name, job.Status)
+			for _, artifact := range job.Artifacts {
+				log.Printf("Found file %s (%d)", artifact.Filename, artifact.Size)
+				fnParse := formats.Parser[filepath.Ext(artifact.Filename)]
+				if fnParse == nil {
+					continue
+				}
+				fileUrl := artifact.Filename
+				if err := DownloadFile(artifact.Filename, fileUrl); err != nil {
+					log.Println(err)
+					continue
+				}
+				report, err := fnParse(artifact.Filename)
+				if err != nil {
+					log.Println(err)
+					continue
+				}
+				report.Name = fmt.Sprintf("%d", job.ID)
+				/* FIXME: report.CreatedAt = job.CreatedAt */
+			}
+		}
+	}
+
+	return nil, nil
+}
blob - /dev/null
blob + 59625003d2687755f49cb819f727e227e253567c (mode 644)
--- /dev/null
+++ backends/backend_gitlab_test.go
@@ -0,0 +1,9 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+package backends
+
+import "testing"
+
+func TestSyncGitLab(t *testing.T) {
+	t.Log("TestSyncGitLab")
+}
blob - /dev/null
blob + efaa13f34f77727574619f5b1c99af0208c27adc (mode 644)
--- /dev/null
+++ backends/backend_jenkins.go
@@ -0,0 +1,48 @@
+package backends
+
+import (
+	"github.com/bndr/gojenkins"
+	"github.com/ligurio/testres-db/formats"
+	"log"
+	"net/http"
+)
+
+func SyncJenkins(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	var jenkins *gojenkins.Jenkins
+	jenkins = gojenkins.CreateJenkins(client, b.Base, b.Username, b.Secret)
+	_, err := jenkins.Init()
+	if err != nil {
+		return nil, err
+	}
+
+	jobBuilds, err := jenkins.GetAllBuildIds(b.Pipeline)
+	if err != nil {
+		return nil, err
+	}
+
+	results := make([]formats.TestResult, len(jobBuilds))
+	for _, jobBuild := range jobBuilds {
+		buildNum, err := jenkins.GetBuild(b.Pipeline, jobBuild.Number)
+		if err != nil {
+			return &results, err
+		}
+		log.Println(jobBuild.URL, buildNum.GetResult())
+		TestResult, err := buildNum.GetResultSet()
+		if err != nil {
+			return &results, err
+		}
+		var testcases []formats.TestCase
+		for _, suite := range TestResult.Suites {
+			var testcase formats.TestCase
+			for _, test := range suite.Cases {
+				testcase = formats.TestCase{Name: test.Name}
+			}
+			testcases = append(testcases, testcase)
+		}
+		buildInfo := buildNum.Info()
+		var result = formats.TestResult{Name: buildInfo.ID, TestCases: testcases}
+		results = append(results, result)
+	}
+
+	return &results, nil
+}
blob - /dev/null
blob + 2cd221cd08cb22dae115b87846d232691ab88dad (mode 644)
--- /dev/null
+++ backends/backend_jenkins_test.go
@@ -0,0 +1,9 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+package backends
+
+import "testing"
+
+func TestSyncJenkins(t *testing.T) {
+	t.Log("TestSyncJenkins")
+}
blob - /dev/null
blob + c4e4e043f581325d356cf695e7824fe7d5f735a5 (mode 644)
--- /dev/null
+++ backends/backend_teamcity.go
@@ -0,0 +1,27 @@
+// https://confluence.jetbrains.com/display/TCD10/REST+API
+// https://www.jetbrains.com/help/teamcity/rest-api.html
+
+package backends
+
+import (
+	teamcity "github.com/cvbarros/go-teamcity/teamcity"
+	"github.com/ligurio/testres-db/formats"
+	"log"
+	"net/http"
+)
+
+func SyncTeamCity(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	connection, err := teamcity.NewWithAddress(b.Username, b.Secret, b.Base, client)
+	if err != nil {
+		return nil, err
+	}
+	project, _ := connection.Projects.GetByID("TestNG_BuildTestsWithGradle")
+	if err != nil {
+		return nil, err
+	}
+	log.Println(project)
+
+	// https://godoc.org/github.com/abourget/teamcity#TestOccurrence
+
+	return nil, nil
+}
blob - /dev/null
blob + e1efe0e5634409fbcdfcc6180be7e6e71a6e23d1 (mode 644)
--- /dev/null
+++ backends/backend_teamcity_test.go
@@ -0,0 +1,9 @@
+// https://github.com/drone/drone-go/blob/master/drone/client_test.go
+// https://github.com/ktrysmt/go-bitbucket/blob/master/tests/repository_test.go
+package backends
+
+import "testing"
+
+func TestSyncTeamCityCI(t *testing.T) {
+	t.Log("TestSyncTeamCityCI")
+}
blob - /dev/null
blob + 695ea07b5ddc4358ce4b62f515533e69bda9e3d7 (mode 644)
--- /dev/null
+++ backends/backend_travisci.go
@@ -0,0 +1,51 @@
+package backends
+
+import (
+	"context"
+	"fmt"
+	"github.com/ligurio/testres-db/formats"
+	travisci "github.com/shuheiktgw/go-travis"
+	"log"
+	"net/http"
+	"path"
+)
+
+func SyncTravisCI(client *http.Client, b *Backend) (*[]formats.TestResult, error) {
+	connection := travisci.NewClient(b.Base, b.Secret)
+	connection.HTTPClient = client
+	build_service := connection.Builds
+
+	ctx := context.Background()
+	var options travisci.BuildsOption
+	builds, _, err := build_service.List(ctx, &options)
+	if err != nil {
+		return nil, err
+	}
+
+	results := make([]formats.TestResult, len(builds))
+	baseURL := "https://travis-ci.org/"
+	for _, build := range builds {
+		// https://godoc.org/github.com/shuheiktgw/go-travis#Build
+		metadata := *build.Metadata
+		log.Printf("Found build: %s, status %s\n", path.Join(baseURL, b.Pipeline, *metadata.Href), *build.State)
+		buildId := fmt.Sprintf("%d", build.Id)
+		var testcases []formats.TestCase
+		var result = formats.TestResult{Name: buildId}
+		log.Println("BuildOn", *build.FinishedAt)
+		//var result = formats.TestResult{Name: buildId, CreatedAt: *build.FinishedAt}
+		var testcase formats.TestCase
+		for _, job := range build.Jobs {
+			// https://godoc.org/github.com/shuheiktgw/go-travis#Job
+			if job.State == nil {
+				continue
+			}
+			log.Println("Job ID: ", *job.Id)
+			testcase = formats.TestCase{Name: "XXX"}
+		}
+		testcases = append(testcases, testcase)
+		result.TestCases = testcases
+		results = append(results, result)
+	}
+
+	return nil, nil
+}
blob - /dev/null
blob + 742a81166b6218919691604be82fc1f745955785 (mode 644)
--- /dev/null
+++ backends/client_test.go_
@@ -0,0 +1,879 @@
+package drone
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+//
+// user tests.
+//
+
+func TestSelf(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.Self()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/user.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(User)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestUser(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.User("octocat")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/user.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(User)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestUserList(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.UserList()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/users.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*User{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestUserDelete(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.UserDelete("octocat")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
+
+func TestUserCreate(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.UserCreate(&User{})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/user.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(User)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestUserUpdate(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.UserUpdate("octocat", &UserPatch{})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/user.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(User)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+//
+// repos
+//
+
+func TestRepo(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.Repo("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repo.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Repo)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestRepoList(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.RepoList()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repos.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*Repo{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestRepoListSync(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.RepoListSync()
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repos.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*Repo{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestRepoEnable(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.RepoEnable("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repo.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Repo)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestRepoDisable(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.RepoDisable("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
+
+func TestRepoRepair(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.RepoRepair("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
+
+func TestRepoChown(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.RepoChown("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repo.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Repo)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestRepoUpdate(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.RepoUpdate("octocat", "hello-world", &RepoPatch{})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/repo.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Repo)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+//
+// cron jobs
+//
+
+func TestCron(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.Cron("octocat", "hello-world", "nightly")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/cron.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Cron)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestCronList(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.CronList("octocat", "hello-world")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/crons.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*Cron{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+// func TestCronDisable(t *testing.T) {
+// 	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+// 	defer ts.Close()
+
+// 	client := New(ts.URL)
+// 	err := client.CronDisable("octocat", "hello-world", "nightly")
+// 	if err != nil {
+// 		t.Error(err)
+// 		return
+// 	}
+// }
+
+// func TestCronEnable(t *testing.T) {
+// 	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+// 	defer ts.Close()
+
+// 	client := New(ts.URL)
+// 	err := client.CronEnable("octocat", "hello-world", "nightly")
+// 	if err != nil {
+// 		t.Error(err)
+// 		return
+// 	}
+// }
+
+//
+// builds
+//
+
+func TestBuild(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.Build("octocat", "hello-world", 1)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/build.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Build)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestBuildLast(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.BuildLast("octocat", "hello-world", "master")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/build.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Build)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestBuildList(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.BuildList("octocat", "hello-world", ListOptions{})
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/builds.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*Build{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+// func TestBuildQueue(t *testing.T) {
+// 	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+// 	defer ts.Close()
+
+// 	client := New(ts.URL)
+// 	got, err := client.BuildQueue()
+// 	if err != nil {
+// 		t.Error(err)
+// 		return
+// 	}
+
+// 	in, err := ioutil.ReadFile("testdata/builds.json.golden")
+// 	if err != nil {
+// 		t.Error(err)
+// 		return
+// 	}
+// 	want := []*Build{}
+// 	err = json.Unmarshal(in, &want)
+// 	if err != nil {
+// 		t.Error(err)
+// 		return
+// 	}
+// 	if diff := cmp.Diff(got, want); diff != "" {
+// 		t.Errorf("Unexpected response")
+// 		t.Log(diff)
+// 	}
+// }
+
+func TestBuildRestart(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.BuildRestart("octocat", "hello-world", 99, nil)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/build.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := new(Build)
+	err = json.Unmarshal(in, want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestBuildCancel(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.BuildCancel("octocat", "hello-world", 1)
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestApprove(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.Approve("octocat", "hello-world", 1, 2)
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func TestDecline(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.Decline("octocat", "hello-world", 1, 3)
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+//
+// logs
+//
+
+func TestLogs(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	got, err := client.Logs("octocat", "hello-world", 1, 2, 3)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	in, err := ioutil.ReadFile("testdata/logs.json.golden")
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	want := []*Line{}
+	err = json.Unmarshal(in, &want)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+	if diff := cmp.Diff(got, want); diff != "" {
+		t.Errorf("Unexpected response")
+		t.Log(diff)
+	}
+}
+
+func TestLogsPurge(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(mockHandler))
+	defer ts.Close()
+
+	client := New(ts.URL)
+	err := client.LogsPurge("octocat", "hello-world", 1, 2, 3)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+}
+
+//
+// mock server and testdata.
+//
+
+func mockHandler(w http.ResponseWriter, r *http.Request) {
+	routes := []struct {
+		verb string
+		path string
+		body string
+		code int
+	}{
+		//
+		// users
+		//
+		{
+			verb: "GET",
+			path: "/api/user",
+			body: "testdata/user.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/users/octocat",
+			body: "testdata/user.json",
+			code: 200,
+		},
+		{
+			verb: "DELETE",
+			path: "/api/users/octocat",
+			code: 204,
+		},
+		{
+			verb: "POST",
+			path: "/api/users",
+			body: "testdata/user.json",
+			code: 200,
+		},
+		{
+			verb: "PATCH",
+			path: "/api/users/octocat",
+			body: "testdata/user.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/users",
+			body: "testdata/users.json",
+			code: 200,
+		},
+		//
+		// repos
+		//
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world",
+			body: "testdata/repo.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/user/repos",
+			body: "testdata/repos.json",
+			code: 200,
+		},
+		{
+			verb: "POST",
+			path: "/api/user/repos",
+			body: "testdata/repos.json",
+			code: 200,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/repair",
+			code: 204,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/chown",
+			body: "testdata/repo.json",
+			code: 200,
+		},
+		{
+			verb: "PATCH",
+			path: "/api/repos/octocat/hello-world",
+			body: "testdata/repo.json",
+			code: 200,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world",
+			body: "testdata/repo.json",
+			code: 200,
+		},
+		{
+			verb: "DELETE",
+			path: "/api/repos/octocat/hello-world",
+			code: 204,
+		},
+		//
+		// crons
+		//
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/cron/nightly",
+			body: "testdata/cron.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/cron",
+			body: "testdata/crons.json",
+			code: 200,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/cron/nightly",
+			code: 204,
+		},
+		{
+			verb: "DELETE",
+			path: "/api/repos/octocat/hello-world/cron/nightly",
+			code: 204,
+		},
+		//
+		// builds
+		//
+		{
+			verb: "GET",
+			path: "/api/system/builds",
+			body: "testdata/builds.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/builds",
+			body: "testdata/builds.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/builds/1",
+			body: "testdata/build.json",
+			code: 200,
+		},
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/builds/latest",
+			body: "testdata/build.json",
+			code: 200,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/builds/99",
+			body: "testdata/build.json",
+			code: 200,
+		},
+		{
+			verb: "DELETE",
+			path: "/api/repos/octocat/hello-world/builds/1",
+			code: 204,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/builds/1/approve/2",
+			code: 204,
+		},
+		{
+			verb: "POST",
+			path: "/api/repos/octocat/hello-world/builds/1/decline/3",
+			code: 204,
+		},
+		//
+		// logs
+		//
+		{
+			verb: "GET",
+			path: "/api/repos/octocat/hello-world/builds/1/logs/2/3",
+			body: "testdata/logs.json",
+			code: 200,
+		},
+		{
+			verb: "DELETE",
+			path: "/api/repos/octocat/hello-world/builds/1/logs/2/3",
+			code: 204,
+		},
+	}
+
+	path := r.URL.Path
+	verb := r.Method
+	for _, route := range routes {
+		if route.verb != verb {
+			continue
+		}
+		if route.path != path {
+			continue
+		}
+		if route.code == 204 {
+			w.WriteHeader(204)
+			return
+		}
+		body, err := ioutil.ReadFile(route.body)
+		if err != nil {
+			break
+		}
+		w.WriteHeader(route.code)
+		w.Write(body)
+		return
+	}
+	w.WriteHeader(404)
+}