Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview: retry pipeline / retry workflow buttons

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jul 2, 2026, 3:47 PM +0300) 4dec9295 a7fe4409

+206 -20
+1
appview/oauth/scopes.go
··· 29 29 30 30 "rpc:sh.tangled.knot.addMember?aud=*", 31 31 "rpc:sh.tangled.knot.removeMember?aud=*", 32 + "rpc:sh.tangled.ci.pipeline.triggerPipeline?aud=*", 32 33 "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", 33 34 "rpc:sh.tangled.repo.addCollaborator?aud=*", 34 35 "rpc:sh.tangled.repo.addSecret?aud=*",
+5
appview/pages/templates/repo/pipelines/pipelines.html
··· 116 116 <span class="font-semibold dark:text-white">{{ $target }}</span> 117 117 {{ i "arrow-left" "size-3 text-gray-500 dark:text-gray-400" }} 118 118 <span class="font-semibold dark:text-white">{{ .Trigger.PRSourceBranch }}</span> 119 + {{ else if .Trigger.IsManual }} 120 + {{ i "circle-play" "size-4 text-gray-500 dark:text-gray-400 shrink-0" }} 121 + <span class="text-sm text-gray-600 dark:text-gray-400">Manual dispatch</span> 119 122 {{ end }} 120 123 {{ if .IsResponding }} 121 124 </a> ··· 126 129 <div class="flex flex-col gap-2 items-end md:contents"> 127 130 <!-- Commit SHA --> 128 131 <div class="font-mono text-xs text-gray-500 dark:text-gray-400"> 132 + {{ if .Sha }} 129 133 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ .Sha }}" class="text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900 no-underline hover:underline px-2 py-1 rounded"> 130 134 {{ slice .Sha 0 8 }} 131 135 </a> 136 + {{ end }} 132 137 </div> 133 138 134 139 <!-- Time -->
+37 -6
appview/pages/templates/repo/pipelines/workflow.html
··· 14 14 <div class="col-span-1 md:col-span-3"> 15 15 <!-- TODO(boltless): explicitly check for pipeline cancel permission --> 16 16 {{ if $.RepoInfo.Roles.IsOwner }} 17 - <div class="flex justify-between mb-2"> 18 - <div id="workflow-error" class="text-red-500 dark:text-red-400"></div> 17 + {{ $status := (index .Pipeline.Statuses .Workflow).Latest.Status }} 18 + <div class="flex justify-between items-center mb-2"> 19 19 <button 20 - class="btn" 21 - hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 20 + class="btn group" 21 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/retry" 22 22 hx-swap="none" 23 - {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 23 + hx-disabled-elt="this" 24 + {{ if .Pipeline.InProgress -}} 24 25 disabled 25 26 {{- end }} 26 - >Cancel</button> 27 + > 28 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + <span class="group-[.htmx-request]:hidden">Retry pipeline</span> 30 + </button> 31 + <div class="flex items-center gap-2"> 32 + <div id="workflow-error" class="text-red-500 dark:text-red-400"></div> 33 + <button 34 + class="btn group" 35 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/retry" 36 + hx-swap="none" 37 + hx-disabled-elt="this" 38 + {{ if not $status.IsFinish -}} 39 + disabled 40 + {{- end }} 41 + > 42 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + <span class="group-[.htmx-request]:hidden">Retry workflow</span> 44 + </button> 45 + <button 46 + class="btn group" 47 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 48 + hx-swap="none" 49 + hx-disabled-elt="this" 50 + {{ if $status.IsFinish -}} 51 + disabled 52 + {{- end }} 53 + > 54 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 55 + <span class="group-[.htmx-request]:hidden">Cancel</span> 56 + </button> 57 + </div> 27 58 </div> 28 59 {{ end }} 29 60 {{ with (index .Pipeline.Statuses .Workflow).Latest }}
+147 -14
appview/pipelines/pipelines.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "fmt" 6 7 "log/slog" 7 8 "net/http" 8 9 "sync" ··· 44 45 r.Get("/", p.Index) 45 46 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 46 47 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 47 - r. 48 - With(mw.RepoPermissionMiddleware("repo:owner")). 49 - Post("/{pipeline}/workflow/{workflow}/cancel", p.CancelWorkflow) 48 + r.Group(func(r chi.Router) { 49 + r.Use(mw.RepoPermissionMiddleware("repo:owner")) 50 + r.Post("/{pipeline}/workflow/{workflow}/cancel", p.CancelWorkflow) 51 + r.Post("/{pipeline}/retry", p.RetryPipeline) 52 + r.Post("/{pipeline}/workflow/{workflow}/retry", p.RetryWorkflow) 53 + }) 50 54 51 55 return r 52 56 } ··· 462 466 463 467 l = l.With("pipeline", pipelineId, "workflow", workflowName) 464 468 465 - hostname, noTLS, err := hostutil.ParseHostname(f.Spindle) 469 + spindleClient, err := p.spindleServiceClient(r, f.Spindle, tangled.CiPipelineCancelPipelineNSID) 466 470 if err != nil { 467 - http.Error(w, "invalid spindle hostname", http.StatusBadRequest) 471 + l.Error("failed to prepare spindle client", "err", err) 472 + p.pages.Notice(w, errorId, "Failed to cancel workflow") 468 473 return 469 474 } 470 475 471 - spindleClient, err := p.oauth.ServiceClient( 472 - r, 473 - oauth.WithService(hostname), 474 - oauth.WithLxm(tangled.CiPipelineCancelPipelineNSID), 475 - oauth.WithDev(noTLS), 476 - oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 477 - ) 478 - 476 + pipelineAtUri := fmt.Sprintf("at://did:web:%s/%s/%s", f.Knot, tangled.PipelineNSID, pipelineId.String()) 479 477 if err := tangled.CiPipelineCancelPipeline( 480 478 r.Context(), 481 479 spindleClient, 482 480 &tangled.CiPipelineCancelPipeline_Input{ 483 481 Repo: string(f.RepoAt()), 484 - Pipeline: pipelineId.String(), 482 + Pipeline: pipelineAtUri, 485 483 Workflows: []string{workflowName}, 486 484 }, 487 485 ); err != nil { ··· 491 489 } 492 490 l.Debug("canceled workflow") 493 491 } 492 + 493 + // RetryPipeline retries all workflows in a pipeline 494 + func (p *Pipelines) RetryPipeline(w http.ResponseWriter, r *http.Request) { 495 + p.retry(w, r, "") 496 + } 497 + 498 + // RetryWorkflow retries a single workflow in a pipeline 499 + func (p *Pipelines) RetryWorkflow(w http.ResponseWriter, r *http.Request) { 500 + p.retry(w, r, chi.URLParam(r, "workflow")) 501 + } 502 + 503 + // retry triggers a new pipeline run for the original commit, either for all or a single workflow 504 + func (p *Pipelines) retry(w http.ResponseWriter, r *http.Request, only string) { 505 + user := p.oauth.GetMultiAccountUser(r) 506 + l := p.logger.With("handler", "retry", "only", only) 507 + errorId := "workflow-error" 508 + 509 + // fail logs the error and shows a notice to the user 510 + fail := func(msg string, err error) { 511 + if err != nil { 512 + l.Error(msg, "err", err) 513 + p.pages.Notice(w, errorId, fmt.Sprintf("%s: %v", msg, err)) 514 + } else { 515 + l.Error(msg) 516 + p.pages.Notice(w, errorId, msg) 517 + } 518 + } 519 + 520 + f, err := p.repoResolver.Resolve(r) 521 + if err != nil { 522 + fail("failed to resolve repository", err) 523 + return 524 + } 525 + l = l.With("repo", f.RepoDid) 526 + 527 + if f.Spindle == "" { 528 + fail("this repository has no spindle configured", nil) 529 + return 530 + } 531 + 532 + pipelineId, err := syntax.ParseTID(chi.URLParam(r, "pipeline")) 533 + if err != nil { 534 + l.Debug("invalid pipeline id", "id", pipelineId) 535 + p.pages.Error404(w) 536 + return 537 + } 538 + l = l.With("pipeline", pipelineId) 539 + 540 + spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 541 + if err != nil { 542 + fail("invalid spindle host", err) 543 + return 544 + } 545 + 546 + // fetch the original pipeline to replay the same commit and workflows 547 + queryClient := &indigoxrpc.Client{Host: spindleUrl} 548 + orig, err := tangled.CiGetPipeline(r.Context(), queryClient, pipelineId.String()) 549 + if err != nil { 550 + fail("failed to load the original pipeline", err) 551 + return 552 + } 553 + if orig.Commit == "" { 554 + fail("cannot retry: the original pipeline has no commit", nil) 555 + return 556 + } 557 + 558 + // figure out which workflows to run and where to redirect 559 + var workflows []string 560 + if only != "" { 561 + workflows = []string{only} 562 + } else { 563 + for _, wf := range orig.Workflows { 564 + workflows = append(workflows, wf.Name) 565 + } 566 + } 567 + if len(workflows) == 0 { 568 + fail("cannot retry: the original pipeline has no workflows", nil) 569 + return 570 + } 571 + redirectWf := workflows[0] 572 + 573 + spindleClient, err := p.spindleServiceClient(r, f.Spindle, tangled.CiPipelineTriggerPipelineNSID) 574 + if err != nil { 575 + fail("failed to authorize with spindle", err) 576 + return 577 + } 578 + 579 + out, err := tangled.CiPipelineTriggerPipeline( 580 + r.Context(), 581 + spindleClient, 582 + &tangled.CiPipelineTriggerPipeline_Input{ 583 + Repo: string(f.RepoAt()), 584 + Sha: orig.Commit, 585 + Workflows: workflows, 586 + }, 587 + ) 588 + if err != nil { 589 + fail("spindle rejected the trigger", err) 590 + return 591 + } 592 + 593 + newAt, err := syntax.ParseATURI(out.Pipeline) 594 + if err != nil { 595 + fail("pipeline triggered, but the response was malformed", err) 596 + return 597 + } 598 + newId := newAt.RecordKey().String() 599 + l = l.With("new", newId) 600 + l.Info("pipeline retried") 601 + 602 + repoInfo := p.repoResolver.GetRepoInfo(r, user) 603 + dest := fmt.Sprintf("/%s/pipelines/%s/workflow/%s", repoInfo.FullName(), newId, redirectWf) 604 + 605 + if r.Header.Get("HX-Request") == "true" { 606 + w.Header().Set("HX-Redirect", dest) 607 + w.WriteHeader(http.StatusOK) 608 + return 609 + } 610 + http.Redirect(w, r, dest, http.StatusSeeOther) 611 + } 612 + 613 + // spindleServiceClient builds an authed spindle xrpc client 614 + func (p *Pipelines) spindleServiceClient(r *http.Request, spindle, lxm string) (*indigoxrpc.Client, error) { 615 + hostname, noTLS, err := hostutil.ParseHostname(spindle) 616 + if err != nil { 617 + return nil, err 618 + } 619 + return p.oauth.ServiceClient( 620 + r, 621 + oauth.WithService(hostname), 622 + oauth.WithLxm(lxm), 623 + oauth.WithDev(noTLS), 624 + oauth.WithTimeout(time.Second*30), 625 + ) 626 + }
+5
input.css
··· 228 228 active:before:shadow-[inset_0_2px_2px_0_theme(colors.gray.200)] dark:active:before:shadow-[inset_0_2px_2px_0_theme(colors.gray.900)] 229 229 active:translate-y-0.5 230 230 disabled:cursor-not-allowed disabled:opacity-50; 231 + 232 + @apply disabled:hover:bg-white dark:disabled:hover:bg-gray-800 233 + disabled:hover:before:shadow-[inset_0_-2px_0_0_theme(colors.gray.200)] dark:disabled:hover:before:shadow-[inset_0_-2px_0_0_theme(colors.gray.900)] 234 + disabled:active:before:shadow-[inset_0_-2px_0_0_theme(colors.gray.200)] dark:disabled:active:before:shadow-[inset_0_-2px_0_0_theme(colors.gray.900)] 235 + disabled:active:translate-y-0; 231 236 } 232 237 233 238 .btn-flat {
+11
types/pipeline.go
··· 96 96 return t.CiDefs_Pipeline_Trigger != nil && t.CiDefs_Pipeline_Trigger.CiTrigger_PullRequest != nil 97 97 } 98 98 99 + func (t Trigger) IsManual() bool { 100 + return t.CiDefs_Pipeline_Trigger != nil && t.CiDefs_Pipeline_Trigger.CiTrigger_Manual != nil 101 + } 102 + 99 103 func (t Trigger) TargetRef() string { 100 104 if t.CiDefs_Pipeline_Trigger == nil { 101 105 return "" ··· 163 167 } 164 168 } 165 169 return m 170 + } 171 + 172 + // InProgress reports whether any workflow in the pipeline is still pending or 173 + // running (i.e. the pipeline has not fully settled). 174 + func (p Pipeline) InProgress() bool { 175 + counts := p.Counts() 176 + return counts["pending"] > 0 || counts["running"] > 0 166 177 } 167 178 168 179 func (p Pipeline) ShortStatusSummary() string {