Skip to content

Commit 7ff9693

Browse files
Initial commit
0 parents  commit 7ff9693

7 files changed

Lines changed: 314 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build
2+
vendor

Makefile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
release: release-linux release-osx
2+
3+
release-linux:
4+
$(call release-base,linux)
5+
6+
release-osx:
7+
$(call release-base,darwin)
8+
9+
10+
release-base = \
11+
mkdir -p build/$(1); \
12+
GOOS=$(1) GOARCH=amd64 go build -ldflags="-s -w" -o build/$(1)/split_tests; \
13+
tar czf build/split_tests-$(1)-$(shell git rev-parse --short HEAD).tar.gz --directory=build/$(1) .
14+
15+
clean:
16+
rm -rf build

circleci.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"path"
8+
)
9+
10+
func getCircleAPIJSON(url string, destination interface{}) {
11+
client := &http.Client{}
12+
req, err := http.NewRequest("GET", url, nil)
13+
req.Header.Add("Accept", "application/json")
14+
resp, err := client.Do(req)
15+
if err != nil {
16+
fatalMsg("error calling CircleCI API at %v: %v", url, err)
17+
}
18+
defer resp.Body.Close()
19+
decoder := json.NewDecoder(resp.Body)
20+
err = decoder.Decode(destination)
21+
if err != nil {
22+
fatalMsg("error parsing CircleCI JSON at %v: %v", url, err)
23+
}
24+
}
25+
26+
type circleCIBranchList []struct {
27+
BuildNum int `json:"build_num"`
28+
}
29+
30+
type circleCITestResults struct {
31+
Tests []struct {
32+
File string `json:"file"`
33+
RunTime float64 `json:"run_time"`
34+
} `json:"tests"`
35+
}
36+
37+
func circleCIAPIURL() string {
38+
return fmt.Sprintf("https://circleci.com/api/v1.1/project/%s", circleCIProjectPrefix)
39+
}
40+
41+
func getCircleCIBranchBuilds(branchName string) circleCIBranchList {
42+
buildsURL := fmt.Sprintf("%s/tree/%s?filter=successful&circle-token=%s", circleCIAPIURL(), branchName, circleCIAPIKey)
43+
var branchList circleCIBranchList
44+
getCircleAPIJSON(buildsURL, &branchList)
45+
return branchList
46+
}
47+
48+
func getCircleCITestResults(buildNum int) circleCITestResults {
49+
testResultsURL := fmt.Sprintf("%s/%d/tests?circle-token=%s", circleCIAPIURL(), buildNum, circleCIAPIKey)
50+
var testResults circleCITestResults
51+
getCircleAPIJSON(testResultsURL, &testResults)
52+
return testResults
53+
}
54+
55+
func getFileTimesFromCircleCI(fileTimes map[string]float64) {
56+
builds := getCircleCIBranchBuilds(circleCIBranchName)
57+
if len(builds) == 0 {
58+
builds = getCircleCIBranchBuilds("master")
59+
}
60+
if len(builds) > 0 {
61+
buildNum := builds[0].BuildNum
62+
printMsg("using test timings from CircleCI build %d\n", buildNum)
63+
64+
testResults := getCircleCITestResults(buildNum)
65+
66+
for _, test := range testResults.Tests {
67+
filePath := path.Clean(test.File)
68+
fileTimes[filePath] += test.RunTime
69+
}
70+
}
71+
}

glide.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

glide.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package: github.com/leonid-shevtsov/circleci-test-balancer
2+
import:
3+
- package: github.com/mattn/go-zglob

junit.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package main
2+
3+
import (
4+
"encoding/xml"
5+
"io"
6+
"os"
7+
"path"
8+
)
9+
10+
type junitXML struct {
11+
TestCases []struct {
12+
File string `xml:"file,attr"`
13+
Time float64 `xml:"time,attr"`
14+
} `xml:"testcase"`
15+
}
16+
17+
func loadJUnitXML(reader io.Reader) *junitXML {
18+
var junitXML junitXML
19+
20+
decoder := xml.NewDecoder(reader)
21+
err := decoder.Decode(&junitXML)
22+
if err != nil {
23+
fatalMsg("failed to parse junit xml: %v\n", err)
24+
}
25+
26+
return &junitXML
27+
}
28+
29+
func getFileTimesFromJUnitXML(fileTimes map[string]float64) {
30+
var source io.Reader
31+
if junitXMLPath != "" {
32+
file, err := os.Open(junitXMLPath)
33+
if err != nil {
34+
fatalMsg("failed to open junit xml: %v\n", err)
35+
}
36+
defer file.Close()
37+
printMsg("using test times from JUnit report %s\n", junitXMLPath)
38+
source = file
39+
} else {
40+
printMsg("using test times from JUnit report at stdin\n")
41+
source = os.Stdin
42+
}
43+
junitXML := loadJUnitXML(source)
44+
for _, testCase := range junitXML.TestCases {
45+
filePath := path.Clean(testCase.File)
46+
fileTimes[filePath] += testCase.Time
47+
}
48+
}

split_tests.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
10+
zglob "github.com/mattn/go-zglob"
11+
)
12+
13+
var useCircleCI bool
14+
var useJUnitXML bool
15+
var junitXMLPath string
16+
var testFilePattern = ""
17+
var circleCIProjectPrefix = ""
18+
var circleCIBranchName = os.Getenv("CIRCLE_BRANCH")
19+
var splitIndex, _ = strconv.Atoi(os.Getenv("CIRCLE_NODE_INDEX"))
20+
var splitTotal, _ = strconv.Atoi(os.Getenv("CIRCLE_NODE_TOTAL"))
21+
var circleCIAPIKey = os.Getenv("CIRCLECI_API_KEY")
22+
23+
func printMsg(msg string, args ...interface{}) {
24+
if len(args) == 0 {
25+
fmt.Fprint(os.Stderr, msg)
26+
} else {
27+
fmt.Fprintf(os.Stderr, msg, args...)
28+
}
29+
}
30+
31+
func fatalMsg(msg string, args ...interface{}) {
32+
printMsg(msg, args...)
33+
os.Exit(1)
34+
}
35+
36+
func removeDeletedFiles(fileTimes map[string]float64, currentFileSet map[string]bool) {
37+
for file := range fileTimes {
38+
if !currentFileSet[file] {
39+
delete(fileTimes, file)
40+
}
41+
}
42+
}
43+
44+
func addNewFiles(fileTimes map[string]float64, currentFileSet map[string]bool) {
45+
averageFileTime := 0.0
46+
if len(fileTimes) > 0 {
47+
for _, time := range fileTimes {
48+
averageFileTime += time
49+
}
50+
averageFileTime /= float64(len(fileTimes))
51+
} else {
52+
averageFileTime = 1.0
53+
}
54+
55+
for file := range currentFileSet {
56+
if _, isSet := fileTimes[file]; isSet {
57+
continue
58+
}
59+
printMsg("missing file time for %s\n", file)
60+
fileTimes[file] = averageFileTime
61+
}
62+
}
63+
64+
func splitFiles(fileTimes map[string]float64, splitTotal int) ([][]string, []float64) {
65+
buckets := make([][]string, splitTotal)
66+
bucketTimes := make([]float64, splitTotal)
67+
for len(fileTimes) > 0 {
68+
// find file with max weight
69+
maxFile := ""
70+
for file, time := range fileTimes {
71+
if len(maxFile) == 0 || time > fileTimes[maxFile] {
72+
maxFile = file
73+
}
74+
}
75+
// find bucket with min weight
76+
minBucket := 0
77+
for bucket := 1; bucket < splitTotal; bucket++ {
78+
if bucketTimes[bucket] < bucketTimes[minBucket] {
79+
minBucket = bucket
80+
}
81+
}
82+
buckets[minBucket] = append(buckets[minBucket], maxFile)
83+
bucketTimes[minBucket] += fileTimes[maxFile]
84+
delete(fileTimes, maxFile)
85+
}
86+
87+
return buckets, bucketTimes
88+
}
89+
90+
func parseFlags() {
91+
flag.StringVar(&testFilePattern, "glob", "spec/**/*_spec.rb", "Glob pattern to find test files")
92+
93+
flag.IntVar(&splitIndex, "split-index", -1, "This test container's index (or set CIRCLE_NODE_INDEX)")
94+
flag.IntVar(&splitTotal, "split-total", -1, "Total number of containers (or set CIRCLE_NODE_TOTAL)")
95+
96+
flag.StringVar(&circleCIAPIKey, "circleci-key", "", "CircleCI API key (or set CIRCLECI_API_KEY environment variable) - required to use CircleCI")
97+
flag.StringVar(&circleCIProjectPrefix, "circleci-project", "", "CircleCI project name (e.g. github/leonid-shevtsov/circleci-test-balancer) - required to use CircleCI")
98+
flag.StringVar(&circleCIBranchName, "circleci-branch", "", "Current branch for CircleCI (or set CIRCLE_BRANCH) - required to use CircleCI")
99+
100+
flag.BoolVar(&useJUnitXML, "junit", false, "Use a JUnit XML report for test times")
101+
flag.StringVar(&junitXMLPath, "junit-path", "", "Path to a JUnit XML report (leave empty to read from stdin)")
102+
103+
var showHelp bool
104+
flag.BoolVar(&showHelp, "help", false, "Show this help text")
105+
106+
flag.Parse()
107+
108+
var err error
109+
if circleCIAPIKey == "" {
110+
circleCIAPIKey = os.Getenv("CIRCLECI_API_KEY")
111+
}
112+
if circleCIBranchName == "" {
113+
circleCIBranchName = os.Getenv("CIRCLE_BRANCH")
114+
}
115+
if splitTotal == -1 {
116+
splitTotal, err = strconv.Atoi(os.Getenv("CIRCLE_NODE_TOTAL"))
117+
if err != nil {
118+
splitIndex = -1
119+
}
120+
}
121+
if splitIndex == -1 {
122+
splitIndex, err = strconv.Atoi(os.Getenv("CIRCLE_NODE_INDEX"))
123+
if err != nil {
124+
splitIndex = -1
125+
}
126+
}
127+
128+
useCircleCI = circleCIAPIKey != ""
129+
showHelp = showHelp || !useCircleCI && !useJUnitXML
130+
131+
if showHelp {
132+
printMsg("Splits test files into containers of even duration\n\n")
133+
flag.PrintDefaults()
134+
os.Exit(1)
135+
}
136+
if useCircleCI && (circleCIProjectPrefix == "" || circleCIBranchName == "") {
137+
fatalMsg("Incomplete CircleCI configuration (set -circleci-key, -circleci-project, and -circleci-branch\n")
138+
}
139+
if splitTotal == 0 || splitIndex < 0 || splitIndex > splitTotal {
140+
fatalMsg("-split-index and -split-total (and environment variables) are missing or invalid\n")
141+
}
142+
}
143+
144+
func main() {
145+
parseFlags()
146+
147+
fileTimes := make(map[string]float64)
148+
if useJUnitXML {
149+
getFileTimesFromJUnitXML(fileTimes)
150+
} else if useCircleCI {
151+
getFileTimesFromCircleCI(fileTimes)
152+
}
153+
154+
currentFiles, _ := zglob.Glob(testFilePattern)
155+
currentFileSet := make(map[string]bool)
156+
157+
for _, file := range currentFiles {
158+
currentFileSet[file] = true
159+
}
160+
161+
removeDeletedFiles(fileTimes, currentFileSet)
162+
addNewFiles(fileTimes, currentFileSet)
163+
164+
buckets, bucketTimes := splitFiles(fileTimes, splitTotal)
165+
printMsg("expected test time: %0.1fs\n", bucketTimes[splitIndex])
166+
167+
fmt.Println(strings.Join(buckets[splitIndex], " "))
168+
}

0 commit comments

Comments
 (0)