package main
/*
This file is part of taler-dashboard
Copyright (C) 2023 Özgür Kesim
taler-dashboard is free software; you can redistribute it and/or modify it
under the terms of the GNU Affero General Public License as published by the
Free Software Foundation; either version 3, or (at your option) any later
version.
taler-dashboard is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
License for more details.
You can receive a copy of the GNU Affero General Public License from
@author Özgür Kesim
*/
import (
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"runtime/debug"
"sort"
"strings"
"sync"
"time"
)
type Data struct {
mux sync.RWMutex
url string
token string
num int
projectId int
minimumVersion string
tmpl *template.Template
ctx context.Context
Issues Issues
Features Issues
Project Project
Timestamp time.Time
Freq time.Duration
Lasterror error
}
func NewData(ctx context.Context, url, token string, num int) *Data {
data := &Data{
url: url,
token: token,
ctx: ctx,
num: num,
}
fm := map[string]any{
"shorten": func(max int, s string) string {
if len(s) <= max {
return s
}
return s[:max] + "⋯"
},
}
data.tmpl = template.Must(template.New("index").Funcs(fm).ParseFS(content, "*.tmpl"))
return data
}
func (d *Data) getProject() {
url := fmt.Sprintf("%s/projects/%d", d.url, d.projectId)
req, e := http.NewRequestWithContext(d.ctx, "GET", url, nil)
if nil != e {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
return
}
req.Header.Add("Authorization", d.token)
r, e := http.DefaultClient.Do(req)
if nil != e {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
return
} else if 200 != r.StatusCode {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = fmt.Errorf("Got unexpected status %s\n", r.Status)
return
}
var project Project
p := struct{ Projects []Project }{}
e = json.NewDecoder(r.Body).Decode(&p)
if len(p.Projects) < 1 {
e = fmt.Errorf("no project found with id", d.projectId)
} else {
// filter out obsolete versions
project = p.Projects[0]
var versions Versions
for _, v := range project.Versions {
v := v
if !v.Obsolete && !v.Released {
versions = append(versions, v)
}
}
project.Versions = versions
}
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
if nil != e {
return
}
d.Timestamp = time.Now()
d.Project = project
log.Println("Got project details for id", d.projectId, "with", len(d.Project.Versions), "version entries")
}
const statusFilter = `status%5B%5D=10&status%5B%5D=20&status%5B%5D=30&status%5B%5D=40&status%5B%5D=50&severity%5B%5D=20`
var fields = []string{"id",
"description",
"summary",
"category",
"target_version",
"status",
"reporter",
"handler",
"resolution",
"priority",
"severity",
"created_at",
"updated_at",
"relationships",
"tags",
}
func (d *Data) getIssues() {
url := fmt.Sprintf("%s/issues?project_id=%d&page_size=%d&%s&select=%s",
d.url, d.projectId, d.num, statusFilter,
strings.Join(fields, ","))
req, e := http.NewRequestWithContext(d.ctx, "GET", url, nil)
if nil != e {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
return
}
req.Header.Add("Authorization", d.token)
r, e := http.DefaultClient.Do(req)
if nil != e {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
return
} else if 200 != r.StatusCode {
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = fmt.Errorf("Got unexpected status %s\n", r.Status)
return
}
iss := struct{ Issues Issues }{}
e = json.NewDecoder(r.Body).Decode(&iss)
d.mux.Lock()
defer d.mux.Unlock()
d.Lasterror = e
if nil != e {
return
}
d.Timestamp = time.Now()
// Filter issues with old target versions out
var issues = Issues{}
var features = Issues{}
var open = 0
for _, issue := range iss.Issues {
if issue.Resolution.Name == "open" &&
strings.Compare(d.minimumVersion, issue.TargetVersion.Name) < 0 {
open++
if issue.Severity.Name == "feature" {
features = append(features, issue)
} else {
issues = append(issues, issue)
}
}
}
d.Issues = issues
d.Features = features
log.Println("Got", len(iss.Issues), "entries, of which", open, "are open and relevant:", len(features), "features and", len(issues), "issues")
}
func (d *Data) Loop() {
d.getProject()
d.getIssues()
go func() {
var ticker = time.NewTicker(d.Freq)
for range ticker.C {
select {
case <-d.ctx.Done():
return
default:
log.Println("Updating data")
d.getProject()
d.getIssues()
}
}
}()
}
func (d *Data) printJSON(w io.Writer) {
d.mux.RLock()
defer d.mux.RUnlock()
if nil == d.Issues {
fmt.Fprintln(w, "{}")
return
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(d.Issues)
}
func (d *Data) printTemplate(w io.Writer, name string) {
d.mux.RLock()
defer d.mux.RUnlock()
e := d.tmpl.ExecuteTemplate(w, name, d)
if nil != e {
log.Println(e)
}
}
func (d *Data) TargetVersions() (tv []string) {
d.mux.RLock()
defer d.mux.RUnlock()
var m = map[string]bool{}
for _, s := range d.Issues.TargetVersions() {
m[s] = true
}
for _, s := range d.Features.TargetVersions() {
m[s] = true
}
for s := range m {
tv = append(tv, s)
}
sort.Strings(tv)
return
}
func (d *Data) VersionsByDate() VersionsByDate {
return VersionsByDate(d.Project.Versions)
}
func (d *Data) Categories() (cs []string) {
d.mux.RLock()
defer d.mux.RUnlock()
var m = map[string]bool{}
for _, s := range d.Issues.Categories() {
m[s] = true
}
for _, s := range d.Features.Categories() {
m[s] = true
}
for s := range m {
cs = append(cs, s)
}
sort.Strings(cs)
return
}
func (d *Data) Tags() (ts []string) {
d.mux.RLock()
defer d.mux.RUnlock()
var m = map[string]bool{}
for _, s := range d.Issues.Tags() {
m[s] = true
}
for _, s := range d.Features.Tags() {
m[s] = true
}
for s := range m {
ts = append(ts, s)
}
sort.Strings(ts)
return
}
func (d *Data) Commit() string {
var version, timestamp = "", ""
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
n := len(setting.Value)
if n > 8 {
n = 8
}
version = setting.Value[:n]
case "vcs.time":
timestamp = setting.Value
}
}
}
return version + " from " + timestamp
}