This repository has no description
0

Configure Feed

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

slop: appview: rewrite spindle selector

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 30, 2026, 2:07 AM +0900) commit cf8f7bf2 parent 593b6ff2 change-id nouokoxl
+319 -148
+34 -3
appview/db/spindle.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "fmt" 7 + "slices" 7 8 "strings" 8 9 "time" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 11 12 "tangled.org/core/appview/models" 13 + "tangled.org/core/consts" 12 14 "tangled.org/core/orm" 13 15 ) 14 16 15 - // RecentSpindles lists spindles user recently used. 17 + // RecentSpindles suggests spindles for the spindle picker: ones this user already 18 + // pointed a repo at, most recently created first, plus the default spindle. 16 19 func RecentSpindles(ctx context.Context, e Execer, user syntax.DID) ([]string, error) { 17 - // NOTE: should I use redis instead..? 18 - panic("unimplemented") 20 + rows, err := e.QueryContext(ctx, ` 21 + select spindle from repos 22 + where did = ? and coalesce(spindle, '') != '' 23 + group by spindle 24 + order by max(id) desc 25 + limit 5 26 + `, user) 27 + if err != nil { 28 + return nil, err 29 + } 30 + defer rows.Close() 31 + 32 + var spindles []string 33 + for rows.Next() { 34 + var spindle string 35 + if err := rows.Scan(&spindle); err != nil { 36 + return nil, err 37 + } 38 + spindles = append(spindles, spindle) 39 + } 40 + if err := rows.Err(); err != nil { 41 + return nil, err 42 + } 43 + 44 + // a fresh account owns no repos, and a bare text input gives it nothing to go on 45 + if !slices.Contains(spindles, consts.DefaultSpindle) { 46 + spindles = append(spindles, consts.DefaultSpindle) 47 + } 48 + 49 + return spindles, nil 19 50 } 20 51 21 52 func GetSpindles(ctx context.Context, e Execer, filters ...orm.Filter) ([]models.Spindle, error) {
+63
appview/db/spindle_test.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/consts" 11 + ) 12 + 13 + func insertRepoWithSpindle(t *testing.T, d *DB, did, name, spindle string) { 14 + t.Helper() 15 + if _, err := d.Exec( 16 + `insert into repos (did, name, knot, rkey, at_uri, spindle) values (?, ?, 'knot.test', ?, ?, ?)`, 17 + did, name, name, fmt.Sprintf("at://%s/sh.tangled.repo/%s", did, name), spindle, 18 + ); err != nil { 19 + t.Fatalf("insert repo %q: %v", name, err) 20 + } 21 + } 22 + 23 + func TestRecentSpindles(t *testing.T) { 24 + d := newTestDB(t) 25 + const user = "did:plc:akshay" 26 + 27 + // oldest first; "one" and "three" share a spindle 28 + insertRepoWithSpindle(t, d, user, "one", "a.spindle.test") 29 + insertRepoWithSpindle(t, d, user, "two", "b.spindle.test") 30 + insertRepoWithSpindle(t, d, user, "three", "a.spindle.test") 31 + insertRepoWithSpindle(t, d, user, "no-spindle", "") 32 + if _, err := d.Exec( 33 + `insert into repos (did, name, knot, rkey, at_uri) values (?, 'null-spindle', 'knot.test', 'null-spindle', ?)`, 34 + user, fmt.Sprintf("at://%s/sh.tangled.repo/null-spindle", user), 35 + ); err != nil { 36 + t.Fatalf("insert null-spindle repo: %v", err) 37 + } 38 + insertRepoWithSpindle(t, d, "did:plc:someone-else", "theirs", "other.spindle.test") 39 + 40 + got, err := RecentSpindles(context.Background(), d, syntax.DID(user)) 41 + if err != nil { 42 + t.Fatalf("RecentSpindles: %v", err) 43 + } 44 + 45 + want := []string{"a.spindle.test", "b.spindle.test", consts.DefaultSpindle} 46 + if !slices.Equal(got, want) { 47 + t.Errorf("RecentSpindles = %v, want %v", got, want) 48 + } 49 + } 50 + 51 + func TestRecentSpindlesNoRepos(t *testing.T) { 52 + d := newTestDB(t) 53 + 54 + got, err := RecentSpindles(context.Background(), d, syntax.DID("did:plc:akshay")) 55 + if err != nil { 56 + t.Fatalf("RecentSpindles: %v", err) 57 + } 58 + 59 + // a fresh account still gets something to pick 60 + if !slices.Equal(got, []string{consts.DefaultSpindle}) { 61 + t.Errorf("RecentSpindles = %v, want [%s]", got, consts.DefaultSpindle) 62 + } 63 + }
+24
appview/models/repo.go
··· 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 enry "github.com/go-enry/go-enry/v2" 11 11 "tangled.org/core/api/tangled" 12 + "tangled.org/core/hostutil" 12 13 ) 13 14 14 15 type Repo struct { ··· 196 197 197 198 func StripGitExt(name string) string { 198 199 return strings.TrimSuffix(name, ".git") 200 + } 201 + 202 + // ValidateSpindle normalizes a user-typed spindle host. Empty means "no spindle". 203 + // 204 + // Membership is enforced by the spindle itself, so this only checks that the value 205 + // is a host the appview can safely send service-auth requests to. 206 + func ValidateSpindle(raw string, dev bool) (string, error) { 207 + raw = strings.TrimSpace(raw) 208 + if raw == "" { 209 + return "", nil 210 + } 211 + 212 + host, noTLS, err := hostutil.ParseHostname(raw) 213 + if err != nil { 214 + return "", fmt.Errorf("%q is not a valid spindle host", raw) 215 + } 216 + 217 + // ParseHostname allows localhost:PORT, which would make the appview dial itself 218 + if noTLS && !dev { 219 + return "", fmt.Errorf("spindle must be a public https host") 220 + } 221 + 222 + return host, nil 199 223 } 200 224 201 225 type RepoGroup struct {
+33
appview/models/repo_test.go
··· 104 104 }) 105 105 } 106 106 } 107 + 108 + func TestValidateSpindle(t *testing.T) { 109 + cases := []struct { 110 + name string 111 + raw string 112 + dev bool 113 + want string 114 + wantErr bool 115 + }{ 116 + {"empty means no spindle", "", false, "", false}, 117 + {"whitespace only", " ", false, "", false}, 118 + {"bare hostname", "spindle.example.com", false, "spindle.example.com", false}, 119 + {"scheme and trailing slash stripped", " https://Spindle.Example.com/ ", false, "spindle.example.com", false}, 120 + {"localhost in dev", "localhost:6555", true, "localhost:6555", false}, 121 + {"localhost in prod", "localhost:6555", false, "", true}, 122 + {"plain http in prod", "http://spindle.example.com", false, "", true}, 123 + {"link-local ip", "169.254.169.254", false, "", true}, 124 + {"port on public host", "spindle.example.com:8443", false, "", true}, 125 + {"single word", "spindle", false, "", true}, 126 + {"not a host", "not a host", false, "", true}, 127 + } 128 + for _, c := range cases { 129 + t.Run(c.name, func(t *testing.T) { 130 + got, err := ValidateSpindle(c.raw, c.dev) 131 + if (err != nil) != c.wantErr { 132 + t.Fatalf("ValidateSpindle(%q, %v) error = %v, wantErr %v", c.raw, c.dev, err, c.wantErr) 133 + } 134 + if got != c.want { 135 + t.Errorf("ValidateSpindle(%q, %v) = %q, want %q", c.raw, c.dev, got, c.want) 136 + } 137 + }) 138 + } 139 + }
+5 -4
appview/pages/compose_parse_test.go
··· 10 10 "tangled.org/core/appview/config" 11 11 "tangled.org/core/appview/models" 12 12 "tangled.org/core/appview/pages/repoinfo" 13 + "tangled.org/core/idresolver" 13 14 "tangled.org/core/patchutil" 14 15 "tangled.org/core/types" 15 16 ) 16 17 17 18 func TestPullComposeTemplatesParse(t *testing.T) { 18 19 cfg := &config.Config{} 19 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 20 + p := NewPages(cfg, idresolver.DefaultResolver("https://plc.test"), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 20 21 21 22 cases := []struct { 22 23 name string ··· 45 46 46 47 func TestPullComposeHostRender(t *testing.T) { 47 48 cfg := &config.Config{} 48 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 + p := NewPages(cfg, idresolver.DefaultResolver("https://plc.test"), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 49 50 50 51 base := RepoNewPullParams{ 51 52 RepoInfo: repoinfo.RepoInfo{ ··· 80 81 81 82 func TestPullComposeHostRenderWithData(t *testing.T) { 82 83 cfg := &config.Config{} 83 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 84 + p := NewPages(cfg, idresolver.DefaultResolver("https://plc.test"), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 84 85 85 86 sampleBranches := []types.Branch{ 86 87 {Reference: types.Reference{Name: "feature"}}, ··· 209 210 210 211 func TestPullComposeLabelStateRoundTrip(t *testing.T) { 211 212 cfg := &config.Config{} 212 - p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 213 + p := NewPages(cfg, idresolver.DefaultResolver("https://plc.test"), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 213 214 214 215 sampleBranches := []types.Branch{ 215 216 {Reference: types.Reference{Name: "feature"}},
-1
appview/pages/ratchet_test.go
··· 44 44 var bareDidAllowlist = map[string]bool{ 45 45 "templates/strings/string.html": true, 46 46 "templates/strings/fragments/form.html": true, 47 - "templates/spindles/dashboard.html": true, 48 47 } 49 48 50 49 var didCloseAsUrlSegment = regexp.MustCompile(`\.(?:Did|OwnerDid)\s*\}\}\s*/`)
+88
appview/pages/spindle_input_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "bytes" 5 + "io" 6 + "log/slog" 7 + "strings" 8 + "testing" 9 + 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages/repoinfo" 13 + "tangled.org/core/idresolver" 14 + ) 15 + 16 + // the spindle picker is free text with a datalist of recently used spindles, and 17 + // is shared by repo-new, repo-fork and repo-settings/pipelines. Rendering catches 18 + // what parsing can't: a mistyped fragment name or dict key only fails on execute. 19 + func TestSpindleInputRenders(t *testing.T) { 20 + cfg := &config.Config{} 21 + p := NewPages(cfg, idresolver.DefaultResolver("https://plc.test"), nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 22 + 23 + cases := []struct { 24 + name string 25 + stack []string 26 + define string 27 + params any 28 + want []string 29 + }{ 30 + { 31 + name: "repo/new", 32 + stack: []string{"repo/new"}, 33 + define: "spindle", 34 + params: NewRepoParams{Spindles: []string{"spindle.example.com"}}, 35 + want: []string{`list="spindle-options"`, `<option value="spindle.example.com">`, `value=""`}, 36 + }, 37 + { 38 + name: "repo/fork", 39 + stack: []string{"repo/fork"}, 40 + define: "content", 41 + params: ForkRepoParams{ 42 + BaseParams: BaseParams{ 43 + LoggedInUser: &oauth.MultiAccountUser{Did: "did:plc:test"}, 44 + }, 45 + Spindles: []string{"spindle.example.com"}, 46 + RepoInfo: repoinfo.RepoInfo{OwnerDid: "did:plc:test", Name: "test-repo"}, 47 + }, 48 + want: []string{`list="spindle-options"`, `<option value="spindle.example.com">`}, 49 + }, 50 + { 51 + name: "repo/settings/pipelines", 52 + stack: []string{"repo/settings/pipelines"}, 53 + define: "spindleSettings", 54 + params: RepoPipelineSettingsParams{ 55 + Spindles: []string{"spindle.example.com"}, 56 + CurrentSpindle: "spindle.tangled.sh", 57 + RepoInfo: repoinfo.RepoInfo{ 58 + OwnerDid: "did:plc:test", 59 + Name: "test-repo", 60 + Roles: repoinfo.RolesInRepo{Roles: []string{"repo:owner"}}, 61 + }, 62 + }, 63 + // the current spindle is prefilled, and clearing the field removes it 64 + want: []string{`value="spindle.tangled.sh"`, `<option value="spindle.example.com">`}, 65 + }, 66 + } 67 + 68 + for _, c := range cases { 69 + t.Run(c.name, func(t *testing.T) { 70 + tpl, err := p.rawParse(c.stack...) 71 + if err != nil { 72 + t.Fatalf("parse %v: %v", c.stack, err) 73 + } 74 + 75 + var buf bytes.Buffer 76 + if err := tpl.ExecuteTemplate(&buf, c.define, c.params); err != nil { 77 + t.Fatalf("execute %q: %v", c.define, err) 78 + } 79 + 80 + got := buf.String() 81 + for _, want := range c.want { 82 + if !strings.Contains(got, want) { 83 + t.Errorf("output missing %q:\n%s", want, got) 84 + } 85 + } 86 + }) 87 + } 88 + }
+7 -29
appview/pages/templates/repo/fork.html
··· 141 141 <div class="flex-1 flex flex-col gap-2 pt-4"> 142 142 <div class="space-y-4"> 143 143 144 - {{/* Spindle list */}} 144 + {{/* Spindle */}} 145 145 <div> 146 - <label class="block text-sm font-bold dark:text-white mb-1">Select a spindle</label> 147 - <div class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700 space-y-2"> 148 - <div class="flex items-center"> 149 - <input 150 - type="radio" 151 - name="spindle" 152 - value="" 153 - id="spindle-none" 154 - checked 155 - class="mr-2" 156 - /> 157 - <label for="spindle-none" class="dark:text-white">No spindle</label> 158 - </div> 159 - {{ range .Spindles }} 160 - <div class="flex items-center"> 161 - <input 162 - type="radio" 163 - name="spindle" 164 - value="{{ . }}" 165 - id="spindle-{{ . }}" 166 - class="mr-2" 167 - /> 168 - <label for="spindle-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 169 - </div> 170 - {{ end }} 171 - </div> 146 + <label for="spindle" class="block text-sm font-bold dark:text-white mb-1">Spindle</label> 147 + {{ template "repo/fragments/spindleInput" (dict "Spindles" .Spindles "Current" "") }} 172 148 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 173 - A spindle runs your CI workflows. 174 - You can also <a href="/settings/spindles" class="underline">register your own spindle</a>. 149 + Optional. A spindle runs your CI workflows; leave this empty for none. 150 + Its operator has to have allowed you as a member, otherwise it will 151 + ignore this repository. You can also 152 + <a href="https://docs.tangled.org/spindles.html#self-hosting-guide" class="underline">run your own spindle</a>. 175 153 </p> 176 154 </div> 177 155
+20
appview/pages/templates/repo/fragments/spindleInput.html
··· 1 + {{ define "repo/fragments/spindleInput" }} 2 + {{/* free text: membership lives on the spindle, so the appview can't offer a 3 + closed list. .Spindles is only a type-ahead of recently used spindles. */}} 4 + <input 5 + type="text" 6 + id="spindle" 7 + name="spindle" 8 + list="spindle-options" 9 + value="{{ .Current }}" 10 + placeholder="spindle.example.com" 11 + autocomplete="off" 12 + spellcheck="false" 13 + class="{{ or .Class "w-full py-2" }}" 14 + /> 15 + <datalist id="spindle-options"> 16 + {{ range .Spindles }} 17 + <option value="{{ . }}"></option> 18 + {{ end }} 19 + </datalist> 20 + {{ end }}
+6 -28
appview/pages/templates/repo/new.html
··· 199 199 {{ define "spindle" }} 200 200 <!-- Spindle Selection --> 201 201 <div> 202 - <label class="block text-sm font-bold dark:text-white mb-1"> 203 - Select a spindle 202 + <label for="spindle" class="block text-sm font-bold dark:text-white mb-1"> 203 + Spindle 204 204 </label> 205 - <div class="w-full space-y-2"> 206 - <div class="flex items-center"> 207 - <input 208 - type="radio" 209 - name="spindle" 210 - value="" 211 - class="mr-2" 212 - id="spindle-none" 213 - checked 214 - /> 215 - <label for="spindle-none" class="dark:text-white">No spindle</label> 216 - </div> 217 - {{ range .Spindles }} 218 - <div class="flex items-center"> 219 - <input 220 - type="radio" 221 - name="spindle" 222 - value="{{ . }}" 223 - class="mr-2" 224 - id="spindle-{{ . }}" 225 - /> 226 - <label for="spindle-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 227 - </div> 228 - {{ end }} 229 - </div> 205 + {{ template "repo/fragments/spindleInput" (dict "Spindles" .Spindles "Current" "") }} 230 206 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 231 - A spindle runs your CI workflows. 207 + Optional. A spindle runs your CI workflows; leave this empty for none. 208 + Its operator has to have allowed you as a member, otherwise it will 209 + ignore this repository. 232 210 </p> 233 211 </div> 234 212 {{ end }}
+6 -21
appview/pages/templates/repo/settings/pipelines.html
··· 20 20 <div class="col-span-1 md:col-span-2"> 21 21 <h2 class="text-lg pb-2 font-medium">Spindle</h2> 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 - Choose a spindle to execute your workflows on. Only repository owners 24 - can configure spindles. Spindles can be selfhosted, 23 + The spindle to execute your workflows on; leave it empty to disable 24 + pipelines. Its operator has to have allowed you as a member, otherwise 25 + it will ignore this repository. Only repository owners can configure 26 + spindles. Spindles can be selfhosted, 25 27 <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 28 click to learn more. 27 29 </a> ··· 33 35 </div> 34 36 {{ else }} 35 37 <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 - <select 37 - id="spindle" 38 - name="spindle" 39 - required 40 - class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 - {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 - <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 - {{ if not $.CurrentSpindle }} 44 - Choose a spindle 45 - {{ else }} 46 - Disable pipelines 47 - {{ end }} 48 - </option> 49 - {{ range $.Spindles }} 50 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 - {{ . }} 52 - </option> 53 - {{ end }} 54 - </select> 38 + {{ template "repo/fragments/spindleInput" 39 + (dict "Spindles" $.Spindles "Current" $.CurrentSpindle "Class" "p-1 max-w-64") }} 55 40 <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 41 {{ i "check" "size-4" }} 57 42 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-16
appview/pages/templates/spindles/index.html
··· 39 39 </div> 40 40 </section> 41 41 {{ end }} 42 - 43 - {{ define "list" }} 44 - <section class="rounded w-full flex flex-col gap-2"> 45 - <h2 class="text-lg font-medium py-2 dark:text-gray-300">Your spindles</h2> 46 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 47 - {{ range $spindle := .Spindles }} 48 - {{ template "spindles/fragments/spindleListing" . }} 49 - {{ else }} 50 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 51 - No spindles registered yet 52 - </div> 53 - {{ end }} 54 - </div> 55 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 56 - </section> 57 - {{ end }}
+15 -29
appview/repo/repo.go
··· 114 114 return 115 115 } 116 116 117 - newSpindle := r.FormValue("spindle") 118 - removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 117 + // an empty field removes the spindle; membership is the spindle's call, we 118 + // only check that the value is a host we can talk to 119 + newSpindle, err := models.ValidateSpindle(r.FormValue("spindle"), rp.config.Core.Dev) 120 + if err != nil { 121 + rp.pages.Notice(w, errorId, err.Error()) 122 + return 123 + } 124 + removingSpindle := newSpindle == "" 125 + 119 126 client, err := rp.oauth.AuthorizedClient(r) 120 127 if err != nil { 121 128 fail("Failed to authorize. Try again later.", err) 122 129 return 123 - } 124 - 125 - if !removingSpindle { 126 - // ensure that this is a valid spindle for this user 127 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 128 - if err != nil { 129 - fail("Failed to find spindles. Try again later.", err) 130 - return 131 - } 132 - 133 - if !slices.Contains(validSpindles, newSpindle) { 134 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 135 - return 136 - } 137 130 } 138 131 139 132 newRepo := *f ··· 1445 1438 return 1446 1439 } 1447 1440 1448 - // optional spindle selection; validate the user is a member if provided 1449 - spindle := r.FormValue("spindle") 1450 - if spindle != "" { 1451 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 1452 - if err != nil { 1453 - l.Error("failed to fetch spindles", "err", err) 1454 - rp.pages.Notice(w, "repo", "Failed to configure spindle. Try again later.") 1455 - return 1456 - } 1457 - if !slices.Contains(validSpindles, spindle) { 1458 - rp.pages.Notice(w, "repo", "Invalid spindle selection.") 1459 - return 1460 - } 1441 + // optional spindle selection; the spindle itself decides whether to accept 1442 + // this repo, we only check that the value is a host we can talk to 1443 + spindle, err := models.ValidateSpindle(r.FormValue("spindle"), rp.config.Core.Dev) 1444 + if err != nil { 1445 + rp.pages.Notice(w, "repo", err.Error()) 1446 + return 1461 1447 } 1462 1448 1463 1449 // choose a name for a fork
+6 -14
appview/state/state.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 - "slices" 11 10 "strings" 12 11 "time" 13 12 ··· 501 500 return 502 501 } 503 502 504 - // optional spindle selection; validate the user is a member if provided 505 - spindle := r.FormValue("spindle") 506 - if spindle != "" { 507 - validSpindles, err := s.enforcer.GetSpindlesForUser(user.Did) 508 - if err != nil { 509 - l.Error("failed to fetch spindles", "err", err) 510 - s.pages.Notice(w, "repo", "Failed to configure spindle. Try again later.") 511 - return 512 - } 513 - if !slices.Contains(validSpindles, spindle) { 514 - s.pages.Notice(w, "repo", "Invalid spindle selection.") 515 - return 516 - } 503 + // optional spindle selection; the spindle itself decides whether to accept 504 + // this repo, we only check that the value is a host we can talk to 505 + spindle, err := models.ValidateSpindle(r.FormValue("spindle"), s.config.Core.Dev) 506 + if err != nil { 507 + s.pages.Notice(w, "repo", err.Error()) 508 + return 517 509 } 518 510 l = l.With("spindle", spindle) 519 511
+12 -3
docs/DOCS.md
··· 1542 1542 1543 1543 Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 1544 1544 1545 + Spindles are not registered with the appview. To point a repository at 1546 + yours, type its hostname into the spindle field under the repository's 1547 + pipeline settings (or when creating or forking a repo); recently used 1548 + spindles are offered as suggestions. The spindle picks the repo up from 1549 + the network and runs its pipelines if its owner is a member. 1550 + 1545 1551 ### Managing members 1546 1552 1547 1553 An invite-only spindle (the default, see `SPINDLE_SERVER_INVITE_ONLY`) only ··· 2692 2698 ``` 2693 2699 2694 2700 The above VM should already be running a spindle on 2695 - `localhost:6555`. Head to http://localhost:3000/settings/spindles and 2696 - hit "Verify". You can then configure each repository to use 2697 - this spindle and run CI jobs. 2701 + `localhost:6555`. Spindles aren't registered with the appview: 2702 + type `localhost:6555` into the spindle field on a repository's 2703 + pipeline settings (or when creating the repo) and it will run 2704 + that repo's CI jobs, as long as the spindle allows you as a 2705 + member (see [Managing members](#managing-members), or run it 2706 + with `SPINDLE_SERVER_INVITE_ONLY=false`). 2698 2707 2699 2708 Of interest when debugging spindles: 2700 2709