appview/repo: display pipeline status on repo index #254

merged
opened by oppi.li targeting master from push-mwkwusmyymno
Changed files
+247 -21
appview
+47
appview/pages/funcmap.go
···
"sub": func(a, b int) int {
return a - b
},
+
"f64": func(a int) float64 {
+
return float64(a)
+
},
+
"addf64": func(a, b float64) float64 {
+
return a + b
+
},
+
"subf64": func(a, b float64) float64 {
+
return a - b
+
},
+
"mulf64": func(a, b float64) float64 {
+
return a * b
+
},
+
"divf64": func(a, b float64) float64 {
+
if b == 0 {
+
return 0
+
}
+
return a / b
+
},
+
"negf64": func(a float64) float64 {
+
return -a
+
},
"cond": func(cond interface{}, a, b string) string {
if cond == nil {
return b
···
{math.MaxInt64, "a long while %s", 1},
})
},
+
"durationFmt": func(duration time.Duration) string {
+
days := int64(duration.Hours() / 24)
+
hours := int64(math.Mod(duration.Hours(), 24))
+
minutes := int64(math.Mod(duration.Minutes(), 60))
+
seconds := int64(math.Mod(duration.Seconds(), 60))
+
+
chunks := []struct {
+
name string
+
amount int64
+
}{
+
{"d", days},
+
{"hr", hours},
+
{"min", minutes},
+
{"s", seconds},
+
}
+
+
parts := []string{}
+
+
for _, chunk := range chunks {
+
if chunk.amount != 0 {
+
parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
+
}
+
}
+
+
return strings.Join(parts, " ")
+
},
"byteFmt": humanize.Bytes,
"length": func(slice any) int {
v := reflect.ValueOf(slice)
+1
appview/pages/pages.go
···
EmailToDidOrHandle map[string]string
VerifiedCommits commitverify.VerifiedCommits
Languages *types.RepoLanguageResponse
+
Pipelines map[plumbing.Hash]db.Pipeline
types.RepoIndexResponse
}
+118
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
···
+
{{ define "repo/fragments/pipelineStatusSymbol" }}
+
<div class="group relative inline-block">
+
{{ block "icon" $ }} {{ end }}
+
{{ block "tooltip" $ }} {{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "icon" }}
+
<div class="cursor-pointer">
+
{{ $c := .Counts }}
+
{{ $statuses := .Statuses }}
+
{{ $total := len $statuses }}
+
{{ $success := index $c "success" }}
+
{{ $allPass := eq $success $total }}
+
+
{{ if $allPass }}
+
<div class="flex gap-1 items-center">
+
{{ i "check" "size-4 text-green-600 dark:text-green-400 " }} {{ $total }}/{{ $total }}
+
</div>
+
{{ else }}
+
{{ $radius := f64 8 }}
+
{{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }}
+
{{ $offset := 0.0 }}
+
<div class="flex gap-1 items-center">
+
<svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20">
+
<circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/>
+
+
{{ range $kind, $count := $c }}
+
{{ $color := "" }}
+
{{ if or (eq $kind "pending") (eq $kind "running") }}
+
{{ $color = "#eab308" }}
+
{{ else if eq $kind "success" }}
+
{{ $color = "#10b981" }}
+
{{ else if eq $kind "cancelled" }}
+
{{ $color = "#6b7280" }}
+
{{ else }}
+
{{ $color = "#ef4444" }}
+
{{ end }}
+
+
{{ $percent := divf64 (f64 $count) (f64 $total) }}
+
{{ $length := mulf64 $percent $circumference }}
+
+
<circle
+
cx="10" cy="10" r="{{ $radius }}"
+
fill="none"
+
stroke="{{ $color }}"
+
stroke-width="2"
+
stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}"
+
stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}"
+
/>
+
{{ $offset = addf64 $offset $length }}
+
{{ end }}
+
</svg>
+
<span>{{$success}}/{{ $total }}</span>
+
</div>
+
{{ end }}
+
</div>
+
{{ end }}
+
+
{{ define "tooltip" }}
+
<div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-sm text-black dark:text-white rounded-md shadow p-2 w-80 top-full mt-2">
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700">
+
{{ range $name, $all := .Statuses }}
+
<div class="flex items-center justify-between p-1">
+
{{ $lastStatus := $all.Latest }}
+
{{ $kind := $lastStatus.Status.String }}
+
+
{{ $icon := "dot" }}
+
{{ $color := "text-gray-600 dark:text-gray-500" }}
+
{{ $text := "Failed" }}
+
{{ $time := "" }}
+
+
{{ if eq $kind "pending" }}
+
{{ $icon = "circle-dashed" }}
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
+
{{ $text = "Queued" }}
+
{{ $time = timeFmt $lastStatus.Created }}
+
{{ else if eq $kind "running" }}
+
{{ $icon = "circle-dashed" }}
+
{{ $color = "text-yellow-600 dark:text-yellow-500" }}
+
{{ $text = "Running" }}
+
{{ $time = timeFmt $lastStatus.Created }}
+
{{ else if eq $kind "success" }}
+
{{ $icon = "check" }}
+
{{ $color = "text-green-600 dark:text-green-500" }}
+
{{ $text = "Success" }}
+
{{ with $all.TimeTaken }}
+
{{ $time = durationFmt . }}
+
{{ end }}
+
{{ else if eq $kind "cancelled" }}
+
{{ $icon = "circle-slash" }}
+
{{ $color = "text-gray-600 dark:text-gray-500" }}
+
{{ $text = "Cancelled" }}
+
{{ with $all.TimeTaken }}
+
{{ $time = durationFmt . }}
+
{{ end }}
+
{{ else }}
+
{{ $icon = "x" }}
+
{{ $color = "text-red-600 dark:text-red-500" }}
+
{{ $text = "Failed" }}
+
{{ with $all.TimeTaken }}
+
{{ $time = durationFmt . }}
+
{{ end }}
+
{{ end }}
+
+
<div id="left" class="flex items-center gap-2 flex-shrink-0">
+
{{ i $icon "size-4" $color }}
+
{{ $name }}
+
</div>
+
<div id="right" class="flex items-center gap-2 flex-shrink-0">
+
<span class="font-bold">{{ $text }}</span>
+
<time class="text-gray-400 dark:text-gray-600">{{ $time }}</time>
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+22 -18
appview/pages/templates/repo/index.html
···
</div>
</div>
+
<!-- commit info bar -->
<div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center">
{{ $verified := $.VerifiedCommits.IsVerified .Hash.String }}
{{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }}
···
</a>
</span>
<span
-
class="mx-2 before:content-['·'] before:select-none"
+
class="mx-1 before:content-['·'] before:select-none"
></span>
<span>
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
···
{{ end }}</a
>
</span>
-
<div
-
class="inline-block px-1 select-none after:content-['·']"
-
></div>
-
<span>{{ timeFmt .Committer.When }}</span>
-
{{ $tagsForCommit := index $.TagMap .Hash.String }}
-
{{ if gt (len $tagsForCommit) 0 }}
-
<div
-
class="inline-block px-1 select-none after:content-['·']"
-
></div>
-
{{ end }}
-
{{ range $tagsForCommit }}
-
<span
-
class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"
-
>
-
{{ . }}
-
</span>
-
{{ end }}
+
<div class="inline-block px-1 select-none after:content-['·']"></div>
+
<span>{{ timeFmt .Committer.When }}</span>
+
+
<!-- tags/branches -->
+
{{ $tagsForCommit := index $.TagMap .Hash.String }}
+
{{ if gt (len $tagsForCommit) 0 }}
+
<div class="inline-block px-1 select-none after:content-['·']"></div>
+
{{ end }}
+
{{ range $tagsForCommit }}
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-[2px] inline-flex items-center">
+
{{ . }}
+
</span>
+
{{ end }}
+
+
<!-- ci status -->
+
{{ $pipeline := index $.Pipelines .Hash }}
+
{{ if and $pipeline (gt (len $pipeline.Statuses) 0) }}
+
<div class="inline-block px-1 select-none after:content-['·']"></div>
+
{{ template "repo/fragments/pipelineStatusSymbol" $pipeline }}
+
{{ end }}
</div>
</div>
{{ end }}
+7
appview/repo/index.go
···
// non-fatal
}
+
pipelines, err := rp.getPipelineStatuses(repoInfo, commitsTrunc)
+
if err != nil {
+
log.Printf("failed to fetch pipeline statuses: %s", err)
+
// non-fatal
+
}
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
LoggedInUser: user,
RepoInfo: repoInfo,
···
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
VerifiedCommits: vc,
Languages: repoLanguages,
+
Pipelines: pipelines,
})
return
}
+41
appview/repo/repo_util.go
···
"fmt"
"math/big"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
+
+
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
···
return string(result)
}
+
+
// grab pipelines from DB and munge that into a hashmap with commit sha as key
+
//
+
// golang is so blessed that it requires 35 lines of imperative code for this
+
func (rp *Repo) getPipelineStatuses(
+
repoInfo repoinfo.RepoInfo,
+
commits []*object.Commit,
+
) (map[plumbing.Hash]db.Pipeline, error) {
+
m := make(map[plumbing.Hash]db.Pipeline)
+
+
if len(commits) == 0 {
+
return m, nil
+
}
+
+
shas := make([]string, len(commits))
+
for _, c := range commits {
+
shas = append(shas, c.Hash.String())
+
}
+
+
ps, err := db.GetPipelineStatuses(
+
rp.db,
+
db.FilterEq("repo_owner", repoInfo.OwnerDid),
+
db.FilterEq("repo_name", repoInfo.Name),
+
db.FilterEq("knot", repoInfo.Knot),
+
db.FilterIn("sha", shas),
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
for _, p := range ps {
+
hash := plumbing.NewHash(p.Sha)
+
m[hash] = p
+
}
+
+
return m, nil
+
}
+11 -3
appview/state/spindlestream.go
···
"context"
"encoding/json"
"log/slog"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
"tangled.sh/tangled.sh/core/log"
"tangled.sh/tangled.sh/core/rbac"
+
spindle "tangled.sh/tangled.sh/core/spindle/models"
)
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
exitCode = int(*record.ExitCode)
}
+
// pick the record creation time if possible, or use time.Now
+
created := time.Now()
+
if t, err := time.Parse(time.RFC3339, record.CreatedAt); err == nil && created.After(t) {
+
created = t
+
}
+
status := db.PipelineStatus{
Spindle: source.Key(),
Rkey: msg.Rkey,
-
PipelineKnot: pipelineUri.Authority().String(),
+
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
PipelineRkey: pipelineUri.RecordKey().String(),
-
Created: time.Now(),
+
Created: created,
Workflow: record.Workflow,
-
Status: record.Status,
+
Status: spindle.StatusKind(record.Status),
Error: record.Error,
ExitCode: exitCode,
}