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 }