feat: add server side of things
commit
2783ec1d0b
@ -0,0 +1 @@
|
||||
coverage.db
|
@ -0,0 +1,10 @@
|
||||
FROM golang:1.16 AS build
|
||||
WORKDIR /root/
|
||||
COPY . .
|
||||
RUN go build -o coverage .
|
||||
|
||||
FROM golang:1.16
|
||||
WORKDIR /root/
|
||||
COPY --from=build /root/coverage .
|
||||
EXPOSE 8080
|
||||
CMD ["./coverage"]
|
@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"gitea.teamortix.com/team-ortix/coverage/db"
|
||||
"gitea.teamortix.com/team-ortix/coverage/server"
|
||||
)
|
||||
|
||||
var (
|
||||
port = os.Getenv("COVERAGE_PORT")
|
||||
dbFile = os.Getenv("DB_FILE")
|
||||
)
|
||||
|
||||
func main() {
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
if dbFile == "" {
|
||||
dbFile = "coverage.db"
|
||||
}
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt)
|
||||
|
||||
go func() {
|
||||
db.OpenDatabase(dbFile)
|
||||
server.StartServer(port)
|
||||
}()
|
||||
|
||||
log.Printf("server starting. at http://0.0.0.0:%s", port)
|
||||
|
||||
<-c
|
||||
fmt.Println("\nAborting...")
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
// Required to connect to database.
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func OpenDatabase(fileLocation string) {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", fileLocation)
|
||||
if err != nil {
|
||||
log.Fatalf("could not open database: %v", err)
|
||||
}
|
||||
|
||||
// Initializes the table.
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS badge (
|
||||
namespace VARCHAR(64) NOT NULL,
|
||||
project_name VARCHAR(64) NOT NULL,
|
||||
coverage INTEGER NOT NULL,
|
||||
html TEXT NOT NULL,
|
||||
|
||||
branch VARCHAR(64),
|
||||
pull INTEGER
|
||||
)`)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not create table: %v", err)
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var ErrNoData = errors.New("no badge found with the provided data")
|
||||
|
||||
type CoverageData struct {
|
||||
Namespace string
|
||||
Project string
|
||||
Coverage float64
|
||||
HTML string
|
||||
}
|
||||
|
||||
// QueryByBranch returns CoverageData for the provided branch for the project.
|
||||
// If there is no such data, it returns ErrNoData.
|
||||
func QueryByBranch(namespace, project, branch string) (CoverageData, error) {
|
||||
result, err := db.Query(`SELECT coverage,html FROM badge WHERE
|
||||
namespace=? AND
|
||||
project_name=? AND
|
||||
branch=?`,
|
||||
namespace, project, branch)
|
||||
if err != nil {
|
||||
return CoverageData{}, err
|
||||
}
|
||||
|
||||
return parseRows(result, namespace, project)
|
||||
}
|
||||
|
||||
// QueryByBranch returns CoverageData for the provided pull request of the project.
|
||||
// If there is no such data, it returns ErrNoData.
|
||||
func QueryByPull(namespace, project, pullStr string) (CoverageData, error) {
|
||||
pull, err := strconv.Atoi(pullStr)
|
||||
if err != nil {
|
||||
return CoverageData{}, ErrNoData
|
||||
}
|
||||
|
||||
result, err := db.Query(`SELECT coverage, html FROM badge WHERE
|
||||
namespace=? AND
|
||||
project_name=? AND
|
||||
pull=?`,
|
||||
namespace, project, pull)
|
||||
if err != nil {
|
||||
return CoverageData{}, err
|
||||
}
|
||||
|
||||
return parseRows(result, namespace, project)
|
||||
}
|
||||
|
||||
func parseRows(result *sql.Rows, namespace, project string) (CoverageData, error) {
|
||||
if !result.Next() {
|
||||
return CoverageData{}, ErrNoData
|
||||
}
|
||||
|
||||
var coverage float64
|
||||
var html string
|
||||
err := result.Scan(&coverage, &html)
|
||||
if err != nil {
|
||||
return CoverageData{}, err
|
||||
}
|
||||
|
||||
if err = result.Close(); err != nil {
|
||||
return CoverageData{}, ErrNoData
|
||||
}
|
||||
|
||||
return CoverageData{
|
||||
Namespace: namespace,
|
||||
Project: project,
|
||||
Coverage: coverage,
|
||||
HTML: html,
|
||||
}, nil
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func UploadBranchData(namespace, project, branch, coverageStr, html string) error {
|
||||
coverage, err := strconv.ParseFloat(coverageStr, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage param must be valid float, instead got %s", coverageStr)
|
||||
}
|
||||
|
||||
// Because we don't have have a primary key, we must do the upsert manually.
|
||||
_, err = QueryByBranch(namespace, project, branch)
|
||||
if err == ErrNoData {
|
||||
_, err = db.Exec("INSERT INTO badge (namespace, project_name, branch, coverage, html) VALUES (?, ?, ?, ?, ?)",
|
||||
namespace, project, branch, coverage, html)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update database: %v", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("UPDATE badge SET coverage=?, html=? WHERE namespace=? AND project_name=? AND branch=?",
|
||||
coverage, html, namespace, project, branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UploadPullData(namespace, project, pullStr, coverageStr, html string) error {
|
||||
pull, err := strconv.Atoi(pullStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pull param must be valid int, instead got %s", pullStr)
|
||||
}
|
||||
coverage, err := strconv.ParseFloat(coverageStr, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage param must be valid float, instead got %s", coverageStr)
|
||||
}
|
||||
|
||||
// Because we don't have have a primary key, we must do the upsert manually.
|
||||
_, err = QueryByPull(namespace, project, pullStr)
|
||||
if err == ErrNoData {
|
||||
_, err = db.Exec("INSERT INTO badge (namespace, project_name, pull, coverage, html) VALUES (?, ?, ?, ?, ?)",
|
||||
namespace, project, pull, coverage, html)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update database: %v", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("UPDATE badge SET coverage=?, html=? WHERE namespace=? AND project_name=? AND pull=?",
|
||||
coverage, html, namespace, project, pull)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update database: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
module gitea.teamortix.com/team-ortix/coverage
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/mattn/go-sqlite3 v1.14.6
|
||||
)
|
@ -0,0 +1,4 @@
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
@ -0,0 +1 @@
|
||||
package pill
|
@ -0,0 +1,79 @@
|
||||
package pill
|
||||
|
||||
import (
|
||||
// Used for embedding pill.
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed pill.svg
|
||||
pill string
|
||||
|
||||
tmpl = template.New("svgTemplate")
|
||||
)
|
||||
|
||||
const (
|
||||
modifier = 0.2
|
||||
max = 190
|
||||
blue = 55
|
||||
)
|
||||
|
||||
// Pill is used to execute the pill.svg template.
|
||||
// Width is dependent on whether the percentage is 2 digits or 3 digits.
|
||||
type Pill struct {
|
||||
Percentage string
|
||||
Colour string
|
||||
Width string
|
||||
}
|
||||
|
||||
// NewPill creates creates a Pill, automatically calculating the colour, width, and formatting the percentage.
|
||||
// Note: %02.0f gives us a float with 0 decimals and 0 padding for 2 numbers less than 10.
|
||||
func NewPill(percentDecimal float64) Pill {
|
||||
square := math.Pow(percentDecimal, 2)
|
||||
red := modifier + clamp(2-2*square, 0, 1)*(1-modifier)
|
||||
green := modifier + clamp(2*square, 0, 1)*(1-modifier)
|
||||
|
||||
percentage := fmt.Sprintf("%.0f", percentDecimal*100)
|
||||
colour := fmt.Sprintf("rgb(%.0f, %.0f, %d)", red*max, green*max, blue)
|
||||
width := "250"
|
||||
if len(percentage) == 1 {
|
||||
percentage = " " + percentage
|
||||
width = "220"
|
||||
}
|
||||
if len(percentage) == 3 {
|
||||
width = "300"
|
||||
}
|
||||
|
||||
return Pill{
|
||||
Percentage: percentage,
|
||||
Colour: colour,
|
||||
Width: width,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs the pill template with the data and writes the result to the writer given.
|
||||
func (p Pill) Execute(w io.Writer) error {
|
||||
var err error
|
||||
tmpl, err = tmpl.Parse(pill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tmpl.Execute(w, p)
|
||||
return err
|
||||
}
|
||||
|
||||
func clamp(n, min, max float64) float64 {
|
||||
switch {
|
||||
case n > max:
|
||||
return max
|
||||
case n < min:
|
||||
return min
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="96" height="20" role="img" aria-label="coverage: {{.Percentage}}%">
|
||||
<title>coverage: {{.Percentage}}%</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="61" height="20" fill="#555"/>
|
||||
<rect x="61" width="35" height="20" fill="{{.Colour}}"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
|
||||
<text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text>
|
||||
<text x="780" y="140" transform="scale(.1)" fill="#fff" textLength="{{.Width}}">{{.Percentage}}%</text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 735 B |
@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.teamortix.com/team-ortix/coverage/db"
|
||||
"gitea.teamortix.com/team-ortix/coverage/pill"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type endpointData struct {
|
||||
thirdParam string
|
||||
queryFunc func(string, string, string) (db.CoverageData, error)
|
||||
handle func(db.CoverageData)
|
||||
}
|
||||
|
||||
func (d endpointData) run(w http.ResponseWriter, r *http.Request) {
|
||||
params, ok := getAllParams(w, r, d.thirdParam)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := d.queryFunc(params[0], params[1], params[2])
|
||||
if err == db.ErrNoData {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not query from db: %v", err), http.StatusInternalServerError)
|
||||
log.Printf("error occurred while querying data from db: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
d.handle(data)
|
||||
}
|
||||
|
||||
func handleBranchReport(w http.ResponseWriter, r *http.Request) {
|
||||
endpointData{
|
||||
thirdParam: "branch",
|
||||
queryFunc: db.QueryByBranch,
|
||||
handle: func(cd db.CoverageData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, cd.HTML)
|
||||
},
|
||||
}.run(w, r)
|
||||
}
|
||||
|
||||
func handleBadgeBranch(w http.ResponseWriter, r *http.Request) {
|
||||
endpointData{
|
||||
thirdParam: "branch",
|
||||
queryFunc: db.QueryByBranch,
|
||||
handle: func(cd db.CoverageData) {
|
||||
p := pill.NewPill(cd.Coverage)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
err := p.Execute(w)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
},
|
||||
}.run(w, r)
|
||||
}
|
||||
|
||||
func handlePullReport(w http.ResponseWriter, r *http.Request) {
|
||||
endpointData{
|
||||
thirdParam: "pull",
|
||||
queryFunc: db.QueryByPull,
|
||||
handle: func(cd db.CoverageData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, cd.HTML)
|
||||
},
|
||||
}.run(w, r)
|
||||
}
|
||||
|
||||
func handlePullBadge(w http.ResponseWriter, r *http.Request) {
|
||||
endpointData{
|
||||
thirdParam: "pull",
|
||||
queryFunc: db.QueryByPull,
|
||||
handle: func(cd db.CoverageData) {
|
||||
p := pill.NewPill(cd.Coverage)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
err := p.Execute(w)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
},
|
||||
}.run(w, r)
|
||||
}
|
||||
|
||||
func getAllParams(w http.ResponseWriter, r *http.Request, thirdParam string) ([3]string, bool) {
|
||||
vars := mux.Vars(r)
|
||||
namespace, ok := vars["namespace"]
|
||||
if !ok {
|
||||
http.Error(w, "Path 'namespace' not found", http.StatusBadRequest)
|
||||
return [3]string{}, false
|
||||
}
|
||||
|
||||
project, ok := vars["project"]
|
||||
if !ok {
|
||||
http.Error(w, "Path 'project' not found", http.StatusBadRequest)
|
||||
return [3]string{}, false
|
||||
}
|
||||
|
||||
third, ok := vars[thirdParam]
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("Path '%s' not found", thirdParam), http.StatusBadRequest)
|
||||
return [3]string{}, false
|
||||
}
|
||||
|
||||
return [3]string{namespace, project, third}, true
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"gitea.teamortix.com/team-ortix/coverage/pill"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func StartServer(port string) {
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/badge/{percentage}", handleBadge)
|
||||
|
||||
r.HandleFunc("/report/branch/{namespace}/{project}/{branch}", handleBranchReport)
|
||||
r.HandleFunc("/report/pulls/{namespace}/{project}/{pull}", handlePullReport)
|
||||
r.HandleFunc("/badge/branch/{namespace}/{project}/{branch}", handleBadgeBranch)
|
||||
r.HandleFunc("/badge/pulls/{namespace}/{project}/{pull}", handlePullBadge)
|
||||
|
||||
r.HandleFunc("/upload/branch/{namespace}/{project}/{branch}", uploadBranch)
|
||||
r.HandleFunc("/upload/pulls/{namespace}/{project}/{branch}", uploadPull)
|
||||
|
||||
portStr := fmt.Sprintf(":%s", port)
|
||||
err := http.ListenAndServe(portStr, r)
|
||||
if err != nil {
|
||||
log.Fatalf("could not start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleBadge(w http.ResponseWriter, r *http.Request) {
|
||||
percentString, ok := mux.Vars(r)["percentage"]
|
||||
if !ok {
|
||||
http.Error(w, "could not find percentage in path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
percent, err := strconv.ParseFloat(percentString, 64)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not convert %s to a number", percentString), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p := pill.NewPill(percent / 100)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
err = p.Execute(w)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not write pill: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.teamortix.com/team-ortix/coverage/db"
|
||||
)
|
||||
|
||||
const coverageSecret = "COVERAGE_SECRET"
|
||||
|
||||
var secretEnv = os.Getenv(coverageSecret)
|
||||
|
||||
func uploadBranch(w http.ResponseWriter, r *http.Request) {
|
||||
upload(w, r, "branch", db.UploadBranchData)
|
||||
}
|
||||
|
||||
func uploadPull(w http.ResponseWriter, r *http.Request) {
|
||||
upload(w, r, "pull", db.UploadPullData)
|
||||
}
|
||||
|
||||
func upload(w http.ResponseWriter, r *http.Request,
|
||||
uploadType string, uploadFunc func(string, string, string, string, string) error) {
|
||||
if r.Method != "POST" {
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseMultipartForm(5 << 20)
|
||||
if err != nil {
|
||||
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "could not parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
secret := r.Form.Get("secret")
|
||||
namespace := r.Form.Get("namespace")
|
||||
project := r.Form.Get("project")
|
||||
uploadValue := r.Form.Get(uploadType)
|
||||
coverage := r.Form.Get("coverage")
|
||||
|
||||
if secretEnv != secret && secretEnv != "" {
|
||||
http.Error(w, "invalid secret provided", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if namespace == "" || project == "" || uploadValue == "" || coverage == "" {
|
||||
http.Error(w, fmt.Sprintf("request params [namespace project, %s, coverage] must all be present", uploadType),
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
report, _, err := r.FormFile("report")
|
||||
if err != nil {
|
||||
http.Error(w, "could not parse report parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bytes, err := io.ReadAll(report)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not read file: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = uploadFunc(namespace, project, uploadValue, coverage, string(bytes))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("could not upload data to database: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "uploaded")
|
||||
}
|
Loading…
Reference in New Issue