Browse Source

first commit

esell 1 year ago
commit
8da8894a17
7 changed files with 349 additions and 0 deletions
  1. 11
    0
      .drone.yml
  2. 1
    0
      .gitignore
  3. 9
    0
      Dockerfile
  4. 31
    0
      README.md
  5. 4
    0
      conf.json
  6. 9
    0
      docker-compose.yml
  7. 284
    0
      main.go

+ 11
- 0
.drone.yml View File

@@ -0,0 +1,11 @@
1
+pipeline:
2
+  build:
3
+    image: golang
4
+    commands:
5
+      - apt-get -y install curl
6
+      - export GOPATH=$PWD:$PWD/vendor
7
+      - go test -cover -coverprofile coverage.out
8
+      - GOOS=linux GOARCH=amd64 go build -o deb-simple_linux
9
+      - curl -XPOST 'http://esheavyindustries.com:8080/upload?repo=hoptocopter' -F "file=@coverage.out"
10
+      
11
+      

+ 1
- 0
.gitignore View File

@@ -0,0 +1 @@
1
+coverage.out

+ 9
- 0
Dockerfile View File

@@ -0,0 +1,9 @@
1
+FROM golang:1.8-alpine
2
+
3
+COPY conf.json /go/bin/
4
+
5
+RUN apk add --no-cache git && go get git.esheavyindustries.com/esell/hoptocopter
6
+
7
+CMD cd /go/bin && ./hoptocopter
8
+
9
+EXPOSE 8080

+ 31
- 0
README.md View File

@@ -0,0 +1,31 @@
1
+[![Coverage](http://esheavyindustries.com:8080/display?repo=hoptocopter)](http://esheavyindustries.com:8080/display?repo=hoptocopter)
2
+
3
+
4
+
5
+# hoptocopter
6
+
7
+
8
+Getting a test coverage badge into your README without a 3rd party SaaS tool shouldn't be that hard but sadly it is.
9
+
10
+hoptocopter will let you POST a coverage file output from `go test -coverprofile=coverage.out` and spit out the badge for you thanks to [shields.io](https://shields.io).
11
+
12
+But wait, didn't I just say you don't have to use a 3rd party tool? I did. [shields.io](https://shields.io) is nice enough to open source their stuff and beevelop was nice 
13
+enough to bundle it up in a [Docker image](https://github.com/beevelop/docker-shields). With the magic of Docker compose you can run all of this together on your own instance.
14
+
15
+Run the app anywhere you like. No need to integrate with any other 3rd party for auth or repo access.
16
+
17
+Also note that most of this code was 100% lifted from the stdlib of Go, I just wrapped an HTTP endpoint around it.
18
+
19
+
20
+# Install
21
+
22
+`git clone https://git.esheavyindustries.com/esell/hoptocopter.git`
23
+`cd hoptocopter`
24
+`docker-compose up`
25
+
26
+
27
+Now all you need to do is POST your coverage output to hoptocopter:
28
+`curl -XPOST 'http://myserver.com:8080/upload?repo=my-cool-app' -F "file=@coverage.out"`
29
+
30
+And when you want the badge? Just send a GET hoptocopter's way:
31
+`curl -XPOST 'http://localhost:8080/display?repo=deb-simple'"`

+ 4
- 0
conf.json View File

@@ -0,0 +1,4 @@
1
+{
2
+    "listenPort" : "8080",
3
+    "shieldServerURL" : "http://shields/badge"
4
+}

+ 9
- 0
docker-compose.yml View File

@@ -0,0 +1,9 @@
1
+version: '2'
2
+
3
+services:
4
+  hoptocopter:
5
+    build: .
6
+    ports:
7
+     - "8080:8080"
8
+  shields:
9
+    image: "beevelop/shields"

+ 284
- 0
main.go View File

@@ -0,0 +1,284 @@
1
+package main
2
+
3
+import (
4
+	"bufio"
5
+	"encoding/json"
6
+	"flag"
7
+	"fmt"
8
+	"io"
9
+	"io/ioutil"
10
+	"log"
11
+	"net/http"
12
+	"os"
13
+	"path/filepath"
14
+	"regexp"
15
+	"sort"
16
+	"strconv"
17
+	"strings"
18
+)
19
+
20
+var (
21
+	configFile   = flag.String("c", "conf.json", "config file location")
22
+	parsedconfig = conf{}
23
+	Random       *os.File
24
+	lineRe       = regexp.MustCompile(`^(.+):([0-9]+).([0-9]+),([0-9]+).([0-9]+) ([0-9]+) ([0-9]+)$`)
25
+)
26
+
27
+type conf struct {
28
+	ListenPort string `json:"listenPort"`
29
+	ShieldURL  string `json:"shieldServerURL"`
30
+}
31
+
32
+type CoverageResult struct {
33
+	Percent float64
34
+}
35
+
36
+type Profile struct {
37
+	FileName string
38
+	Mode     string
39
+	Blocks   []ProfileBlock
40
+}
41
+
42
+type byFileName []*Profile
43
+
44
+func (p byFileName) Len() int           { return len(p) }
45
+func (p byFileName) Less(i, j int) bool { return p[i].FileName < p[j].FileName }
46
+func (p byFileName) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
47
+
48
+// ProfileBlock represents a single block of profiling data.
49
+type ProfileBlock struct {
50
+	StartLine, StartCol int
51
+	EndLine, EndCol     int
52
+	NumStmt, Count      int
53
+}
54
+
55
+type blocksByStart []ProfileBlock
56
+
57
+func (b blocksByStart) Len() int      { return len(b) }
58
+func (b blocksByStart) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
59
+func (b blocksByStart) Less(i, j int) bool {
60
+	bi, bj := b[i], b[j]
61
+	return bi.StartLine < bj.StartLine || bi.StartLine == bj.StartLine && bi.StartCol < bj.StartCol
62
+}
63
+
64
+func init() {
65
+	f, err := os.Open("/dev/urandom")
66
+	if err != nil {
67
+		log.Fatal(err)
68
+	}
69
+	Random = f
70
+}
71
+
72
+func percentCovered(p *Profile) float64 {
73
+	var total, covered int64
74
+	for _, b := range p.Blocks {
75
+		total += int64(b.NumStmt)
76
+		if b.Count > 0 {
77
+			covered += int64(b.NumStmt)
78
+		}
79
+	}
80
+	if total == 0 {
81
+		return 0
82
+	}
83
+	return float64(covered) / float64(total) * 100
84
+}
85
+
86
+// ParseProfiles parses profile data in the specified file and returns a
87
+// Profile for each source file described therein.
88
+func ParseProfiles(fileName string) ([]*Profile, error) {
89
+	pf, err := os.Open(fileName)
90
+	if err != nil {
91
+		return nil, err
92
+	}
93
+	defer pf.Close()
94
+
95
+	files := make(map[string]*Profile)
96
+	buf := bufio.NewReader(pf)
97
+	// First line is "mode: foo", where foo is "set", "count", or "atomic".
98
+	// Rest of file is in the format
99
+	//	encoding/base64/base64.go:34.44,37.40 3 1
100
+	// where the fields are: name.go:line.column,line.column numberOfStatements count
101
+	s := bufio.NewScanner(buf)
102
+	mode := ""
103
+	for s.Scan() {
104
+		line := s.Text()
105
+		if mode == "" {
106
+			const p = "mode: "
107
+			if !strings.HasPrefix(line, p) || line == p {
108
+				return nil, fmt.Errorf("bad mode line: %v", line)
109
+			}
110
+			mode = line[len(p):]
111
+			continue
112
+		}
113
+		m := lineRe.FindStringSubmatch(line)
114
+		if m == nil {
115
+			return nil, fmt.Errorf("line %q doesn't match expected format: %v", m, lineRe)
116
+		}
117
+		fn := m[1]
118
+		p := files[fn]
119
+		if p == nil {
120
+			p = &Profile{
121
+				FileName: fn,
122
+				Mode:     mode,
123
+			}
124
+			files[fn] = p
125
+		}
126
+		p.Blocks = append(p.Blocks, ProfileBlock{
127
+			StartLine: toInt(m[2]),
128
+			StartCol:  toInt(m[3]),
129
+			EndLine:   toInt(m[4]),
130
+			EndCol:    toInt(m[5]),
131
+			NumStmt:   toInt(m[6]),
132
+			Count:     toInt(m[7]),
133
+		})
134
+	}
135
+	if err := s.Err(); err != nil {
136
+		return nil, err
137
+	}
138
+	for _, p := range files {
139
+		sort.Sort(blocksByStart(p.Blocks))
140
+	}
141
+	// Generate a sorted slice.
142
+	profiles := make([]*Profile, 0, len(files))
143
+	for _, profile := range files {
144
+		profiles = append(profiles, profile)
145
+	}
146
+	sort.Sort(byFileName(profiles))
147
+	return profiles, nil
148
+}
149
+
150
+func toInt(s string) int {
151
+	i, err := strconv.Atoi(s)
152
+	if err != nil {
153
+		panic(err)
154
+	}
155
+	return i
156
+}
157
+
158
+func uuid() string {
159
+	b := make([]byte, 16)
160
+	Random.Read(b)
161
+	return fmt.Sprintf("%x-%x-%x-%x-%x",
162
+		b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
163
+}
164
+
165
+func httpErrorf(w http.ResponseWriter, format string, a ...interface{}) {
166
+	err := fmt.Errorf(format, a...)
167
+	log.Println(err)
168
+	http.Error(w, err.Error(), http.StatusInternalServerError)
169
+}
170
+
171
+func uploadHandler(config conf) http.Handler {
172
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173
+		if r.Method != "POST" {
174
+			http.Error(w, "method not supported", http.StatusMethodNotAllowed)
175
+			return
176
+		}
177
+		repoName := r.URL.Query().Get("repo")
178
+
179
+		if _, err := os.Stat(repoName); err != nil {
180
+			if os.IsNotExist(err) {
181
+				log.Printf("Directory for %s does not exist, creating", repoName)
182
+				if err := os.MkdirAll(repoName, 0755); err != nil {
183
+					log.Printf("error creating directory for %s:  %s", repoName, err)
184
+					return
185
+				}
186
+			} else {
187
+				log.Println("error inspecting: ", err)
188
+			}
189
+		}
190
+
191
+		reader, err := r.MultipartReader()
192
+		if err != nil {
193
+			httpErrorf(w, "error creating multipart reader: %s", err)
194
+			return
195
+		}
196
+		var dst *os.File
197
+		for {
198
+			part, err := reader.NextPart()
199
+			if err == io.EOF {
200
+				break
201
+			}
202
+			if part.FileName() == "" {
203
+				continue
204
+			}
205
+
206
+			dst, err = os.Create(filepath.Join(repoName, "coverage.out"))
207
+			if err != nil {
208
+				httpErrorf(w, "error creating coverage file: %s", err)
209
+				return
210
+			}
211
+			defer dst.Close()
212
+			if _, err := io.Copy(dst, part); err != nil {
213
+				httpErrorf(w, "error writing coverage file: %s", err)
214
+				return
215
+			}
216
+		}
217
+
218
+		profs, err := ParseProfiles(dst.Name())
219
+		if err != nil {
220
+			log.Println("Error parsing profile file: ", err)
221
+		}
222
+
223
+		percentCovered := percentCovered(profs[0])
224
+
225
+		// redirect to shields API server
226
+		roundedFloat := fmt.Sprintf("%.0f", percentCovered)
227
+		log.Println("Coverage percent: ", roundedFloat)
228
+		http.Redirect(w, r, parsedconfig.ShieldURL+"/coverage-"+roundedFloat+"%-green.svg", http.StatusSeeOther)
229
+	})
230
+}
231
+
232
+func displayHandler(config conf) http.Handler {
233
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234
+		if r.Method != "GET" {
235
+			http.Error(w, "method not supported", http.StatusMethodNotAllowed)
236
+			return
237
+		}
238
+		repoName := r.URL.Query().Get("repo")
239
+		if repoName == "" {
240
+			http.Redirect(w, r, parsedconfig.ShieldURL+"/coverage-NaN-red.svg", http.StatusSeeOther)
241
+			return
242
+		}
243
+		profs, err := ParseProfiles(filepath.Join(repoName, "coverage.out"))
244
+		if err != nil {
245
+			httpErrorf(w, "Error parsing profile file: %s", err)
246
+			return
247
+		}
248
+
249
+		percentCovered := percentCovered(profs[0])
250
+
251
+		// get image from shields API server
252
+		roundedFloat := fmt.Sprintf("%.0f", percentCovered)
253
+		log.Println("Coverage percent: ", roundedFloat)
254
+		reqImg, err := http.Get(parsedconfig.ShieldURL + "/coverage-" + roundedFloat + "%25-green.svg")
255
+		if err != nil {
256
+			httpErrorf(w, "Error loading SVG from shield: %s", err)
257
+			return
258
+		}
259
+		w.Header().Set("Content-Type", reqImg.Header.Get("Content-Type"))
260
+		if _, err = io.Copy(w, reqImg.Body); err != nil {
261
+			httpErrorf(w, "Error writing SVG to ResponseWriter: %s", err)
262
+			return
263
+		}
264
+		reqImg.Body.Close()
265
+	})
266
+}
267
+
268
+func main() {
269
+	flag.Parse()
270
+	file, err := ioutil.ReadFile(*configFile)
271
+	if err != nil {
272
+		log.Fatal("unable to read config file, exiting...")
273
+	}
274
+	if err := json.Unmarshal(file, &parsedconfig); err != nil {
275
+		log.Fatal("unable to marshal config file, exiting...")
276
+	}
277
+
278
+	http.Handle("/upload", uploadHandler(parsedconfig))
279
+	http.Handle("/display", displayHandler(parsedconfig))
280
+
281
+	log.Println("running without SSL enabled")
282
+	log.Fatal(http.ListenAndServe(":"+parsedconfig.ListenPort, nil))
283
+
284
+}