Monorepo for Tangled tangled.org

appview/repo: display pipeline status on repo index

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li d1be77b4 3a36b3f4

verified
Changed files
+247 -21
appview
+47
appview/pages/funcmap.go
··· 49 49 "sub": func(a, b int) int { 50 50 return a - b 51 51 }, 52 + "f64": func(a int) float64 { 53 + return float64(a) 54 + }, 55 + "addf64": func(a, b float64) float64 { 56 + return a + b 57 + }, 58 + "subf64": func(a, b float64) float64 { 59 + return a - b 60 + }, 61 + "mulf64": func(a, b float64) float64 { 62 + return a * b 63 + }, 64 + "divf64": func(a, b float64) float64 { 65 + if b == 0 { 66 + return 0 67 + } 68 + return a / b 69 + }, 70 + "negf64": func(a float64) float64 { 71 + return -a 72 + }, 52 73 "cond": func(cond interface{}, a, b string) string { 53 74 if cond == nil { 54 75 return b ··· 104 125 {humanize.LongTime, "%dy %s", humanize.Year}, 105 126 {math.MaxInt64, "a long while %s", 1}, 106 127 }) 128 + }, 129 + "durationFmt": func(duration time.Duration) string { 130 + days := int64(duration.Hours() / 24) 131 + hours := int64(math.Mod(duration.Hours(), 24)) 132 + minutes := int64(math.Mod(duration.Minutes(), 60)) 133 + seconds := int64(math.Mod(duration.Seconds(), 60)) 134 + 135 + chunks := []struct { 136 + name string 137 + amount int64 138 + }{ 139 + {"d", days}, 140 + {"hr", hours}, 141 + {"min", minutes}, 142 + {"s", seconds}, 143 + } 144 + 145 + parts := []string{} 146 + 147 + for _, chunk := range chunks { 148 + if chunk.amount != 0 { 149 + parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 150 + } 151 + } 152 + 153 + return strings.Join(parts, " ") 107 154 }, 108 155 "byteFmt": humanize.Bytes, 109 156 "length": func(slice any) int {
+1
appview/pages/pages.go
··· 436 436 EmailToDidOrHandle map[string]string 437 437 VerifiedCommits commitverify.VerifiedCommits 438 438 Languages *types.RepoLanguageResponse 439 + Pipelines map[plumbing.Hash]db.Pipeline 439 440 types.RepoIndexResponse 440 441 } 441 442
+118
appview/pages/templates/repo/fragments/pipelineStatusSymbol.html
··· 1 + {{ define "repo/fragments/pipelineStatusSymbol" }} 2 + <div class="group relative inline-block"> 3 + {{ block "icon" $ }} {{ end }} 4 + {{ block "tooltip" $ }} {{ end }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "icon" }} 9 + <div class="cursor-pointer"> 10 + {{ $c := .Counts }} 11 + {{ $statuses := .Statuses }} 12 + {{ $total := len $statuses }} 13 + {{ $success := index $c "success" }} 14 + {{ $allPass := eq $success $total }} 15 + 16 + {{ if $allPass }} 17 + <div class="flex gap-1 items-center"> 18 + {{ i "check" "size-4 text-green-600 dark:text-green-400 " }} {{ $total }}/{{ $total }} 19 + </div> 20 + {{ else }} 21 + {{ $radius := f64 8 }} 22 + {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 23 + {{ $offset := 0.0 }} 24 + <div class="flex gap-1 items-center"> 25 + <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 26 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 27 + 28 + {{ range $kind, $count := $c }} 29 + {{ $color := "" }} 30 + {{ if or (eq $kind "pending") (eq $kind "running") }} 31 + {{ $color = "#eab308" }} 32 + {{ else if eq $kind "success" }} 33 + {{ $color = "#10b981" }} 34 + {{ else if eq $kind "cancelled" }} 35 + {{ $color = "#6b7280" }} 36 + {{ else }} 37 + {{ $color = "#ef4444" }} 38 + {{ end }} 39 + 40 + {{ $percent := divf64 (f64 $count) (f64 $total) }} 41 + {{ $length := mulf64 $percent $circumference }} 42 + 43 + <circle 44 + cx="10" cy="10" r="{{ $radius }}" 45 + fill="none" 46 + stroke="{{ $color }}" 47 + stroke-width="2" 48 + stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 49 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 50 + /> 51 + {{ $offset = addf64 $offset $length }} 52 + {{ end }} 53 + </svg> 54 + <span>{{$success}}/{{ $total }}</span> 55 + </div> 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + 60 + {{ define "tooltip" }} 61 + <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"> 62 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 63 + {{ range $name, $all := .Statuses }} 64 + <div class="flex items-center justify-between p-1"> 65 + {{ $lastStatus := $all.Latest }} 66 + {{ $kind := $lastStatus.Status.String }} 67 + 68 + {{ $icon := "dot" }} 69 + {{ $color := "text-gray-600 dark:text-gray-500" }} 70 + {{ $text := "Failed" }} 71 + {{ $time := "" }} 72 + 73 + {{ if eq $kind "pending" }} 74 + {{ $icon = "circle-dashed" }} 75 + {{ $color = "text-yellow-600 dark:text-yellow-500" }} 76 + {{ $text = "Queued" }} 77 + {{ $time = timeFmt $lastStatus.Created }} 78 + {{ else if eq $kind "running" }} 79 + {{ $icon = "circle-dashed" }} 80 + {{ $color = "text-yellow-600 dark:text-yellow-500" }} 81 + {{ $text = "Running" }} 82 + {{ $time = timeFmt $lastStatus.Created }} 83 + {{ else if eq $kind "success" }} 84 + {{ $icon = "check" }} 85 + {{ $color = "text-green-600 dark:text-green-500" }} 86 + {{ $text = "Success" }} 87 + {{ with $all.TimeTaken }} 88 + {{ $time = durationFmt . }} 89 + {{ end }} 90 + {{ else if eq $kind "cancelled" }} 91 + {{ $icon = "circle-slash" }} 92 + {{ $color = "text-gray-600 dark:text-gray-500" }} 93 + {{ $text = "Cancelled" }} 94 + {{ with $all.TimeTaken }} 95 + {{ $time = durationFmt . }} 96 + {{ end }} 97 + {{ else }} 98 + {{ $icon = "x" }} 99 + {{ $color = "text-red-600 dark:text-red-500" }} 100 + {{ $text = "Failed" }} 101 + {{ with $all.TimeTaken }} 102 + {{ $time = durationFmt . }} 103 + {{ end }} 104 + {{ end }} 105 + 106 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 107 + {{ i $icon "size-4" $color }} 108 + {{ $name }} 109 + </div> 110 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 111 + <span class="font-bold">{{ $text }}</span> 112 + <time class="text-gray-400 dark:text-gray-600">{{ $time }}</time> 113 + </div> 114 + </div> 115 + {{ end }} 116 + </div> 117 + </div> 118 + {{ end }}
+22 -18
appview/pages/templates/repo/index.html
··· 222 222 </div> 223 223 </div> 224 224 225 + <!-- commit info bar --> 225 226 <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 226 227 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 227 228 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} ··· 238 239 </a> 239 240 </span> 240 241 <span 241 - class="mx-2 before:content-['·'] before:select-none" 242 + class="mx-1 before:content-['·'] before:select-none" 242 243 ></span> 243 244 <span> 244 245 {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} ··· 256 257 {{ end }}</a 257 258 > 258 259 </span> 259 - <div 260 - class="inline-block px-1 select-none after:content-['·']" 261 - ></div> 262 - <span>{{ timeFmt .Committer.When }}</span> 263 - {{ $tagsForCommit := index $.TagMap .Hash.String }} 264 - {{ if gt (len $tagsForCommit) 0 }} 265 - <div 266 - class="inline-block px-1 select-none after:content-['·']" 267 - ></div> 268 - {{ end }} 269 - {{ range $tagsForCommit }} 270 - <span 271 - 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" 272 - > 273 - {{ . }} 274 - </span> 275 - {{ end }} 260 + <div class="inline-block px-1 select-none after:content-['·']"></div> 261 + <span>{{ timeFmt .Committer.When }}</span> 262 + 263 + <!-- tags/branches --> 264 + {{ $tagsForCommit := index $.TagMap .Hash.String }} 265 + {{ if gt (len $tagsForCommit) 0 }} 266 + <div class="inline-block px-1 select-none after:content-['·']"></div> 267 + {{ end }} 268 + {{ range $tagsForCommit }} 269 + <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"> 270 + {{ . }} 271 + </span> 272 + {{ end }} 273 + 274 + <!-- ci status --> 275 + {{ $pipeline := index $.Pipelines .Hash }} 276 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 277 + <div class="inline-block px-1 select-none after:content-['·']"></div> 278 + {{ template "repo/fragments/pipelineStatusSymbol" $pipeline }} 279 + {{ end }} 276 280 </div> 277 281 </div> 278 282 {{ end }}
+7
appview/repo/index.go
··· 127 127 // non-fatal 128 128 } 129 129 130 + pipelines, err := rp.getPipelineStatuses(repoInfo, commitsTrunc) 131 + if err != nil { 132 + log.Printf("failed to fetch pipeline statuses: %s", err) 133 + // non-fatal 134 + } 135 + 130 136 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 131 137 LoggedInUser: user, 132 138 RepoInfo: repoInfo, ··· 139 145 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 140 146 VerifiedCommits: vc, 141 147 Languages: repoLanguages, 148 + Pipelines: pipelines, 142 149 }) 143 150 return 144 151 }
+41
appview/repo/repo_util.go
··· 6 6 "fmt" 7 7 "math/big" 8 8 9 + "tangled.sh/tangled.sh/core/appview/db" 10 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 11 + 12 + "github.com/go-git/go-git/v5/plumbing" 9 13 "github.com/go-git/go-git/v5/plumbing/object" 10 14 ) 11 15 ··· 98 102 99 103 return string(result) 100 104 } 105 + 106 + // grab pipelines from DB and munge that into a hashmap with commit sha as key 107 + // 108 + // golang is so blessed that it requires 35 lines of imperative code for this 109 + func (rp *Repo) getPipelineStatuses( 110 + repoInfo repoinfo.RepoInfo, 111 + commits []*object.Commit, 112 + ) (map[plumbing.Hash]db.Pipeline, error) { 113 + m := make(map[plumbing.Hash]db.Pipeline) 114 + 115 + if len(commits) == 0 { 116 + return m, nil 117 + } 118 + 119 + shas := make([]string, len(commits)) 120 + for _, c := range commits { 121 + shas = append(shas, c.Hash.String()) 122 + } 123 + 124 + ps, err := db.GetPipelineStatuses( 125 + rp.db, 126 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 127 + db.FilterEq("repo_name", repoInfo.Name), 128 + db.FilterEq("knot", repoInfo.Knot), 129 + db.FilterIn("sha", shas), 130 + ) 131 + if err != nil { 132 + return nil, err 133 + } 134 + 135 + for _, p := range ps { 136 + hash := plumbing.NewHash(p.Sha) 137 + m[hash] = p 138 + } 139 + 140 + return m, nil 141 + }
+11 -3
appview/state/spindlestream.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "log/slog" 7 + "strings" 7 8 "time" 8 9 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 16 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 16 17 "tangled.sh/tangled.sh/core/log" 17 18 "tangled.sh/tangled.sh/core/rbac" 19 + spindle "tangled.sh/tangled.sh/core/spindle/models" 18 20 ) 19 21 20 22 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 77 79 exitCode = int(*record.ExitCode) 78 80 } 79 81 82 + // pick the record creation time if possible, or use time.Now 83 + created := time.Now() 84 + if t, err := time.Parse(time.RFC3339, record.CreatedAt); err == nil && created.After(t) { 85 + created = t 86 + } 87 + 80 88 status := db.PipelineStatus{ 81 89 Spindle: source.Key(), 82 90 Rkey: msg.Rkey, 83 - PipelineKnot: pipelineUri.Authority().String(), 91 + PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"), 84 92 PipelineRkey: pipelineUri.RecordKey().String(), 85 - Created: time.Now(), 93 + Created: created, 86 94 Workflow: record.Workflow, 87 - Status: record.Status, 95 + Status: spindle.StatusKind(record.Status), 88 96 Error: record.Error, 89 97 ExitCode: exitCode, 90 98 }