Monorepo for Tangled tangled.org
4

Configure Feed

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

appview: canonicalize repo urls to handle/slug

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (May 14, 2026, 12:07 PM +0300) commit 347194a2 parent ce7bd915 change-id voxrzlqs
+572 -101
+136
appview/middleware/canonicalize_test.go
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/go-chi/chi/v5" 12 + "tangled.org/core/appview/models" 13 + ) 14 + 15 + func runCanonicalize(t *testing.T, method, urlPath, urlUser, urlRepo, handle string, repo *models.Repo) *httptest.ResponseRecorder { 16 + t.Helper() 17 + req := httptest.NewRequest(method, urlPath, nil) 18 + rctx := chi.NewRouteContext() 19 + rctx.URLParams.Add("user", urlUser) 20 + rctx.URLParams.Add("repo", urlRepo) 21 + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) 22 + id := identity.Identity{ 23 + DID: syntax.DID("did:plc:boltless"), 24 + Handle: syntax.Handle(handle), 25 + } 26 + ctx = context.WithValue(ctx, "resolvedId", id) 27 + ctx = context.WithValue(ctx, "repo", repo) 28 + req = req.WithContext(ctx) 29 + 30 + rec := httptest.NewRecorder() 31 + called := false 32 + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + called = true 34 + w.WriteHeader(http.StatusOK) 35 + }) 36 + mw := Middleware{} 37 + mw.CanonicalizeRepoURL()(next).ServeHTTP(rec, req) 38 + if rec.Code == http.StatusFound { 39 + if called { 40 + t.Errorf("middleware both issued 302 and invoked next handler") 41 + } 42 + } 43 + return rec 44 + } 45 + 46 + func TestCanonicalize_CanonicalUrlPassesThrough(t *testing.T) { 47 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 48 + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone/issues", "boltless.dev", "anemone", "boltless.dev", repo) 49 + if rec.Code != http.StatusOK { 50 + t.Errorf("canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) 51 + } 52 + } 53 + 54 + func TestCanonicalize_EmptyNameUsesRkeyAsSlug(t *testing.T) { 55 + repo := &models.Repo{Did: "did:plc:boltless", Name: "", Rkey: "anemone"} 56 + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone/pulls", "boltless.dev", "anemone", "boltless.dev", repo) 57 + if rec.Code != http.StatusOK { 58 + t.Errorf("rkey-as-slug canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) 59 + } 60 + } 61 + 62 + func TestCanonicalize_EmptyNameOwnerDidRedirectsToHandleRkey(t *testing.T) { 63 + repo := &models.Repo{Did: "did:plc:boltless", Name: "", Rkey: "anemone"} 64 + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/pulls", "did:plc:boltless", "anemone", "boltless.dev", repo) 65 + if rec.Code != http.StatusFound { 66 + t.Fatalf("got %d, want 302", rec.Code) 67 + } 68 + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/pulls"; got != want { 69 + t.Errorf("Location = %q, want %q", got, want) 70 + } 71 + } 72 + 73 + func TestCanonicalize_HandleSlashTIDRedirectsToName(t *testing.T) { 74 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "3kabcxyz"} 75 + rec := runCanonicalize(t, "GET", "/boltless.dev/3kabcxyz/issues", "boltless.dev", "3kabcxyz", "boltless.dev", repo) 76 + if rec.Code != http.StatusFound { 77 + t.Fatalf("got %d, want 302", rec.Code) 78 + } 79 + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/issues"; got != want { 80 + t.Errorf("Location = %q, want %q", got, want) 81 + } 82 + } 83 + 84 + func TestCanonicalize_OwnerDidRedirectsToHandle(t *testing.T) { 85 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 86 + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/pulls/3", "did:plc:boltless", "anemone", "boltless.dev", repo) 87 + if rec.Code != http.StatusFound { 88 + t.Fatalf("got %d, want 302", rec.Code) 89 + } 90 + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/pulls/3"; got != want { 91 + t.Errorf("Location = %q, want %q", got, want) 92 + } 93 + } 94 + 95 + func TestCanonicalize_OwnerDidAndTIDRedirectsToCanonical(t *testing.T) { 96 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "3kabcxyz"} 97 + rec := runCanonicalize(t, "GET", "/did:plc:boltless/3kabcxyz", "did:plc:boltless", "3kabcxyz", "boltless.dev", repo) 98 + if rec.Code != http.StatusFound { 99 + t.Fatalf("got %d, want 302", rec.Code) 100 + } 101 + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone"; got != want { 102 + t.Errorf("Location = %q, want %q", got, want) 103 + } 104 + } 105 + 106 + func TestCanonicalize_PreservesQueryString(t *testing.T) { 107 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 108 + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone/issues?state=closed&page=2", "did:plc:boltless", "anemone", "boltless.dev", repo) 109 + if got, want := rec.Header().Get("Location"), "/boltless.dev/anemone/issues?state=closed&page=2"; got != want { 110 + t.Errorf("Location = %q, want %q", got, want) 111 + } 112 + } 113 + 114 + func TestCanonicalize_PostNotRedirected(t *testing.T) { 115 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 116 + rec := runCanonicalize(t, "POST", "/did:plc:boltless/anemone/issues", "did:plc:boltless", "anemone", "boltless.dev", repo) 117 + if rec.Code != http.StatusOK { 118 + t.Errorf("POST on non-canonical URL got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) 119 + } 120 + } 121 + 122 + func TestCanonicalize_InvalidHandlePassesThrough(t *testing.T) { 123 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 124 + rec := runCanonicalize(t, "GET", "/did:plc:boltless/anemone", "did:plc:boltless", "anemone", string(syntax.HandleInvalid), repo) 125 + if rec.Code != http.StatusOK { 126 + t.Errorf("invalid handle got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) 127 + } 128 + } 129 + 130 + func TestCanonicalize_DotGitSuffixStripped(t *testing.T) { 131 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "anemone"} 132 + rec := runCanonicalize(t, "GET", "/boltless.dev/anemone.git/", "boltless.dev", "anemone.git", "boltless.dev", repo) 133 + if rec.Code != http.StatusOK { 134 + t.Errorf(".git on canonical name got %d, want 200; Location=%q", rec.Code, rec.Header().Get("Location")) 135 + } 136 + }
+87 -59
appview/middleware/middleware.go
··· 17 17 "github.com/go-chi/chi/v5" 18 18 "tangled.org/core/appview/cache" 19 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 20 21 "tangled.org/core/appview/oauth" 21 22 "tangled.org/core/appview/pages" 22 23 "tangled.org/core/appview/pagination" ··· 234 235 return func(next http.Handler) http.Handler { 235 236 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 236 237 l := mw.logger.With("middleware", "ResolveRepo") 237 - repoName := chi.URLParam(req, "repo") 238 - repoName = strings.TrimSuffix(repoName, ".git") 238 + repoName := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") 239 239 rkey := strings.ToLower(repoName) 240 240 241 241 id, ok := req.Context().Value("resolvedId").(identity.Identity) ··· 245 245 return 246 246 } 247 247 248 - repo, err := db.GetRepo( 249 - mw.db, 250 - orm.FilterEq("did", id.DID.String()), 251 - orm.FilterEq("rkey", rkey), 252 - ) 253 - if err != nil { 254 - if !errors.Is(err, sql.ErrNoRows) { 255 - l.Error("failed to resolve repo", "err", err) 256 - http.Error(w, "internal server error", http.StatusInternalServerError) 257 - return 258 - } 259 - hint, hintErr := db.LookupRepoRename(mw.db, id.DID.String(), rkey) 260 - if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) { 261 - l.Error("failed to lookup repo rename hint", "err", hintErr) 262 - } 263 - if hint != nil { 264 - parts := strings.SplitN(strings.TrimPrefix(req.URL.Path, "/"), "/", 3) 265 - target := "/" + parts[0] + "/" + hint.Rkey 266 - if len(parts) == 3 { 267 - target += "/" + parts[2] 268 - } 269 - if req.URL.RawQuery != "" { 270 - target += "?" + req.URL.RawQuery 271 - } 272 - http.Redirect(w, req, target, http.StatusMovedPermanently) 273 - return 274 - } 275 - nameRepos, nameErr := db.GetRepos( 276 - mw.db, 277 - orm.FilterEq("did", id.DID.String()), 278 - orm.FilterEq("name", repoName), 279 - ) 280 - if nameErr == nil && len(nameRepos) == 1 && nameRepos[0].RepoDid != "" { 281 - nameRepo := &nameRepos[0] 282 - if _, tidErr := syntax.ParseTID(nameRepo.Rkey); tidErr == nil { 283 - ctx := context.WithValue(req.Context(), "repo", nameRepo) 284 - next.ServeHTTP(w, req.WithContext(ctx)) 285 - return 286 - } 287 - parts := strings.SplitN(strings.TrimPrefix(req.URL.Path, "/"), "/", 3) 288 - target := "/" + nameRepo.RepoDid 289 - if len(parts) == 3 { 290 - target += "/" + parts[2] 291 - } 292 - if req.URL.RawQuery != "" { 293 - target += "?" + req.URL.RawQuery 294 - } 295 - http.Redirect(w, req, target, http.StatusFound) 296 - return 297 - } 248 + repo, isRename := resolveRepoForOwner(mw.db, id.DID.String(), repoName, rkey, l) 249 + if repo == nil { 298 250 w.WriteHeader(http.StatusNotFound) 299 251 mw.pages.ErrorKnot404(w) 300 252 return 301 253 } 254 + if isRename { 255 + handle := id.Handle.String() 256 + if id.Handle.IsInvalidHandle() || handle == "" { 257 + handle = id.DID.String() 258 + } 259 + target := reporesolver.CanonicalRedirectTarget(req, reporesolver.CanonicalRepoPath(handle, repo)) 260 + http.Redirect(w, req, target, http.StatusMovedPermanently) 261 + return 262 + } 302 263 303 264 ctx := context.WithValue(req.Context(), "repo", repo) 304 265 next.ServeHTTP(w, req.WithContext(ctx)) 266 + }) 267 + } 268 + } 269 + 270 + func resolveRepoForOwner(d db.Execer, ownerDid, repoName, rkey string, l *slog.Logger) (*models.Repo, bool) { 271 + repo, err := db.GetRepo(d, orm.FilterEq("did", ownerDid), orm.FilterEq("rkey", rkey)) 272 + if err == nil { 273 + return repo, false 274 + } 275 + if !errors.Is(err, sql.ErrNoRows) { 276 + l.Error("failed to resolve repo by rkey", "err", err) 277 + return nil, false 278 + } 279 + 280 + hint, hintErr := db.LookupRepoRename(d, ownerDid, rkey) 281 + if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) { 282 + l.Error("failed to lookup repo rename hint", "err", hintErr) 283 + } 284 + if hint != nil { 285 + return hint, true 286 + } 287 + 288 + nameRepos, nameErr := db.GetRepos(d, orm.FilterEq("did", ownerDid), orm.FilterEq("name", repoName)) 289 + if nameErr != nil { 290 + l.Error("failed to resolve repo by name", "err", nameErr) 291 + return nil, false 292 + } 293 + if len(nameRepos) == 1 { 294 + return &nameRepos[0], false 295 + } 296 + return nil, false 297 + } 298 + 299 + func (mw Middleware) CanonicalizeRepoURL() middlewareFunc { 300 + return func(next http.Handler) http.Handler { 301 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 302 + if req.Method != http.MethodGet && req.Method != http.MethodHead { 303 + next.ServeHTTP(w, req) 304 + return 305 + } 306 + id, idOk := req.Context().Value("resolvedId").(identity.Identity) 307 + repo, repoOk := req.Context().Value("repo").(*models.Repo) 308 + if !idOk || !repoOk || id.Handle.IsInvalidHandle() { 309 + next.ServeHTTP(w, req) 310 + return 311 + } 312 + handle := id.Handle.String() 313 + if handle == "" { 314 + next.ServeHTTP(w, req) 315 + return 316 + } 317 + canonical := reporesolver.CanonicalRepoPath(handle, repo) 318 + urlUser := chi.URLParam(req, "user") 319 + urlRepo := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") 320 + if urlUser+"/"+urlRepo == canonical { 321 + next.ServeHTTP(w, req) 322 + return 323 + } 324 + 325 + http.Redirect(w, req, reporesolver.CanonicalRedirectTarget(req, canonical), http.StatusFound) 305 326 }) 306 327 } 307 328 } ··· 408 429 if strings.Contains(modulePath, ":") { 409 430 modulePath = userutil.FlattenDid(f.Did) + "/" + f.Rkey 410 431 } 411 - html := fmt.Sprintf( 412 - `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/> 413 - <meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, 414 - modulePath, fullName, 415 - modulePath, fullName, 416 - ) 432 + tags := []string{ 433 + fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, modulePath, fullName), 434 + fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, modulePath, fullName), 435 + } 436 + if f.RepoDid != "" { 437 + stable := userutil.FlattenDid(f.RepoDid) 438 + if stable != modulePath { 439 + tags = append(tags, 440 + fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, stable, f.RepoDid), 441 + fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, stable, f.RepoDid), 442 + ) 443 + } 444 + } 417 445 w.Header().Set("Content-Type", "text/html") 418 - w.Write([]byte(html)) 446 + w.Write([]byte(strings.Join(tags, "\n"))) 419 447 return 420 448 } 421 449 }
+7
appview/models/repo.go
··· 80 80 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 81 81 } 82 82 83 + func (r Repo) Slug() string { 84 + if r.Name != "" { 85 + return r.Name 86 + } 87 + return r.Rkey 88 + } 89 + 83 90 func (r Repo) RepoIdentifier() string { 84 91 if r.RepoDid != "" { 85 92 return r.RepoDid
+20
appview/models/repo_test.go
··· 84 84 t.Errorf("cosmeticName = %q, want %q", *rec.Name, "MyRepo") 85 85 } 86 86 } 87 + 88 + func TestRepoSlug(t *testing.T) { 89 + cases := []struct { 90 + name string 91 + repo Repo 92 + want string 93 + }{ 94 + {"name set distinct from rkey", Repo{Name: "anemone", Rkey: "3kabc"}, "anemone"}, 95 + {"name equals rkey", Repo{Name: "scallop", Rkey: "scallop"}, "scallop"}, 96 + {"name empty falls to rkey", Repo{Name: "", Rkey: "whelk"}, "whelk"}, 97 + {"both empty", Repo{}, ""}, 98 + } 99 + for _, c := range cases { 100 + t.Run(c.name, func(t *testing.T) { 101 + if got := c.repo.Slug(); got != c.want { 102 + t.Errorf("Slug() = %q, want %q", got, c.want) 103 + } 104 + }) 105 + } 106 + }
+1 -1
appview/pages/funcmap.go
··· 88 88 } 89 89 handle := ownerId.Handle 90 90 if handle != "" && !handle.IsInvalidHandle() { 91 - return string(handle) + "/" + repo.Name 91 + return string(handle) + "/" + repo.Slug() 92 92 } 93 93 return repo.RepoIdentifier() 94 94 },
+92
appview/pages/ratchet_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "io/fs" 5 + "regexp" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + var repoRkeyAllowlist = map[string]bool{ 11 + "templates/repo/settings/sites.html": true, 12 + } 13 + 14 + var repoRkeyPattern = regexp.MustCompile(`\.(Repo|RepoInfo)\.Rkey\b`) 15 + 16 + func TestNoRepoRkeyInTemplates(t *testing.T) { 17 + err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 18 + if err != nil { 19 + return err 20 + } 21 + if d.IsDir() || !strings.HasSuffix(path, ".html") { 22 + return nil 23 + } 24 + if repoRkeyAllowlist[path] { 25 + return nil 26 + } 27 + data, err := fs.ReadFile(Files, path) 28 + if err != nil { 29 + return err 30 + } 31 + for i, line := range strings.Split(string(data), "\n") { 32 + if repoRkeyPattern.MatchString(line) { 33 + t.Errorf("%s:%d uses .Repo.Rkey or .RepoInfo.Rkey in URL position. Use .Slug to prefer Name over TID-Rkey.\n %s", 34 + path, i+1, strings.TrimSpace(line)) 35 + } 36 + } 37 + return nil 38 + }) 39 + if err != nil { 40 + t.Fatal(err) 41 + } 42 + } 43 + 44 + var bareDidAllowlist = map[string]bool{ 45 + "templates/strings/string.html": true, 46 + "templates/strings/fragments/form.html": true, 47 + "templates/spindles/dashboard.html": true, 48 + } 49 + 50 + var didCloseAsUrlSegment = regexp.MustCompile(`\.(?:Did|OwnerDid)\s*\}\}\s*/`) 51 + var printfWithUrlFormat = regexp.MustCompile(`printf\s+"[^"]*/[^"]*%s`) 52 + var didArgRef = regexp.MustCompile(`\b[\$\.]\w+(?:\.\w+)*\.(?:Did|OwnerDid)\b`) 53 + 54 + func TestNoBareDidInTemplateRepoUrls(t *testing.T) { 55 + err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 56 + if err != nil { 57 + return err 58 + } 59 + if d.IsDir() || !strings.HasSuffix(path, ".html") { 60 + return nil 61 + } 62 + if bareDidAllowlist[path] { 63 + return nil 64 + } 65 + data, err := fs.ReadFile(Files, path) 66 + if err != nil { 67 + return err 68 + } 69 + for i, line := range strings.Split(string(data), "\n") { 70 + for _, idx := range didCloseAsUrlSegment.FindAllStringIndex(line, -1) { 71 + openIdx := strings.LastIndex(line[:idx[0]], "{{") 72 + if openIdx == -1 { 73 + continue 74 + } 75 + action := line[openIdx:idx[1]] 76 + if !strings.Contains(action, "resolve") { 77 + t.Errorf("%s:%d renders raw DID as URL path segment. Wrap in `resolve` so the handle appears.\n %s", 78 + path, i+1, strings.TrimSpace(line)) 79 + break 80 + } 81 + } 82 + if printfWithUrlFormat.MatchString(line) && didArgRef.MatchString(line) && !strings.Contains(line, "resolve ") { 83 + t.Errorf("%s:%d builds a URL path via printf with a raw DID arg. Wrap the DID in `resolve` so handle appears.\n %s", 84 + path, i+1, strings.TrimSpace(line)) 85 + } 86 + } 87 + return nil 88 + }) 89 + if err != nil { 90 + t.Fatal(err) 91 + } 92 + }
+9 -2
appview/pages/repoinfo/repoinfo.go
··· 19 19 } 20 20 } 21 21 22 + func (r RepoInfo) Slug() string { 23 + if r.Name != "" { 24 + return r.Name 25 + } 26 + return r.Rkey 27 + } 28 + 22 29 func (r RepoInfo) FullName() string { 23 - return path.Join(r.owner(), r.Rkey) 30 + return path.Join(r.owner(), r.Slug()) 24 31 } 25 32 26 33 func (r RepoInfo) RepoIdentifier() string { ··· 39 46 } 40 47 41 48 func (r RepoInfo) FullNameWithoutAt() string { 42 - return path.Join(r.ownerWithoutAt(), r.Rkey) 49 + return path.Join(r.ownerWithoutAt(), r.Slug()) 43 50 } 44 51 45 52 func (r RepoInfo) GetTabs() [][]string {
+46
appview/pages/repoinfo/repoinfo_test.go
··· 1 + package repoinfo 2 + 3 + import "testing" 4 + 5 + func TestSlug(t *testing.T) { 6 + cases := []struct { 7 + name string 8 + info RepoInfo 9 + want string 10 + }{ 11 + {"name preferred over rkey", RepoInfo{Name: "barnacle", Rkey: "3kabc"}, "barnacle"}, 12 + {"name equals rkey", RepoInfo{Name: "clam", Rkey: "clam"}, "clam"}, 13 + {"name empty falls to rkey", RepoInfo{Rkey: "limpet"}, "limpet"}, 14 + } 15 + for _, c := range cases { 16 + t.Run(c.name, func(t *testing.T) { 17 + if got := c.info.Slug(); got != c.want { 18 + t.Errorf("Slug() = %q, want %q", got, c.want) 19 + } 20 + }) 21 + } 22 + } 23 + 24 + func TestFullName_PrefersNameOverRkey(t *testing.T) { 25 + info := RepoInfo{OwnerHandle: "boltless.dev", Name: "uni", Rkey: "3kabcxyz"} 26 + if got, want := info.FullName(), "boltless.dev/uni"; got != want { 27 + t.Errorf("FullName() = %q, want %q", got, want) 28 + } 29 + if got, want := info.FullNameWithoutAt(), "boltless.dev/uni"; got != want { 30 + t.Errorf("FullNameWithoutAt() = %q, want %q", got, want) 31 + } 32 + } 33 + 34 + func TestFullName_FallsBackToRkey(t *testing.T) { 35 + info := RepoInfo{OwnerHandle: "akshay.dev", Rkey: "3kabcxyz"} 36 + if got, want := info.FullName(), "akshay.dev/3kabcxyz"; got != want { 37 + t.Errorf("FullName() = %q, want %q", got, want) 38 + } 39 + } 40 + 41 + func TestFullNameWithoutAt_FlattensDid(t *testing.T) { 42 + info := RepoInfo{OwnerDid: "did:plc:boltless", Name: "nautilus", Rkey: "nautilus"} 43 + if got, want := info.FullNameWithoutAt(), "did-plc-boltless/nautilus"; got != want { 44 + t.Errorf("FullNameWithoutAt() = %q, want %q", got, want) 45 + } 46 + }
+2 -2
appview/pages/templates/goodfirstissues/index.html
··· 46 46 {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 47 {{ end }} 48 48 {{ $repoOwner := resolve .Repo.Did }} 49 - <a href="/{{ $repoOwner }}/{{ .Repo.Rkey }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Slug }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 50 </div> 51 51 </div> 52 52 ··· 90 90 {{ if gt (len .Issues) 0 }} 91 91 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 92 {{ range .Issues }} 93 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Rkey }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Slug }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 94 <div class="py-2 px-6"> 95 95 <div class="flex-grow min-w-0 w-full"> 96 96 <div class="flex text-sm items-center justify-between w-full">
+1 -1
appview/pages/templates/layouts/repobase.html
··· 78 78 <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300 mb-2 flex-wrap"> 79 79 {{ i "git-fork" "w-3 h-3 shrink-0" }} 80 80 <span>forked from</span> 81 - <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Rkey }}"> 81 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Slug }}"> 82 82 {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 83 83 </a> 84 84 </div>
+3 -3
appview/pages/templates/notifications/fragments/item.html
··· 76 76 {{ define "notificationUrl" }} 77 77 {{ $url := "" }} 78 78 {{ if eq .Type "repo_starred" }} 79 - {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Rkey}} 79 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Slug}} 80 80 {{ else if .Issue }} 81 - {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Rkey .Issue.IssueId}} 81 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Slug .Issue.IssueId}} 82 82 {{ else if .Pull }} 83 - {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Rkey .Pull.PullId}} 83 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Slug .Pull.PullId}} 84 84 {{ else if eq .Type "followed" }} 85 85 {{$url = printf "/%s" (resolve .ActorDid)}} 86 86 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 38 38 {{ template "cloneUrlItem" ( 39 39 dict 40 40 "Label" "HTTPS" 41 - "HandleUrl" (printf "https://tangled.org/%s/%s" $repoOwnerHandle .RepoInfo.Rkey) 41 + "HandleUrl" (printf "https://tangled.org/%s/%s" $repoOwnerHandle .RepoInfo.Slug) 42 42 "PermaUrl" (printf "https://tangled.org/%s" .RepoInfo.RepoDid) 43 43 ) }} 44 44 45 45 {{ template "cloneUrlItem" ( 46 46 dict 47 47 "Label" "SSH" 48 - "HandleUrl" (printf "git@%s:%s/%s" (stripPort $knot) $repoOwnerHandle .RepoInfo.Rkey) 48 + "HandleUrl" (printf "git@%s:%s/%s" (stripPort $knot) $repoOwnerHandle .RepoInfo.Slug) 49 49 "PermaUrl" (printf "git@%s:%s" (stripPort $knot) .RepoInfo.RepoDid) 50 50 ) }} 51 51
+1 -1
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 34 34 </button> 35 35 {{ if .BranchDeleteStatus }} 36 36 <button 37 - hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Rkey }}/branches" 37 + hx-delete="/{{ resolve .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Slug }}/branches" 38 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 39 hx-swap="none" 40 40 class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 30 30 {{ $repoPath := .RepoInfo.FullName }} 31 31 <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 32 32 {{ else if .Pull.PullSource.Repo }} 33 - {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Rkey }} 33 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Slug }} 34 34 <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 35 35 <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 36 36 {{ else }}
+1 -1
appview/pages/templates/repo/pulls/pull.html
··· 412 412 <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 413 413 {{ $fullRepo := "" }} 414 414 {{ if and $root.Pull.IsForkBased $root.Pull.PullSource.Repo }} 415 - {{ $fullRepo = printf "%s/%s" $root.Pull.OwnerDid $root.Pull.PullSource.Repo.Rkey }} 415 + {{ $fullRepo = printf "%s/%s" (resolve $root.Pull.PullSource.Repo.Did) $root.Pull.PullSource.Repo.Slug }} 416 416 {{ else if $root.Pull.IsBranchBased }} 417 417 {{ $fullRepo = $root.RepoInfo.FullName }} 418 418 {{ end }}
+4 -4
appview/pages/templates/timeline/fragments/preview.html
··· 121 121 {{ with $source }} 122 122 {{ $sourceDid := resolve .Did }} 123 123 forked 124 - <a href="/{{ $sourceDid }}/{{ .Rkey }}"class="no-underline hover:underline"> 124 + <a href="/{{ $sourceDid }}/{{ .Slug }}"class="no-underline hover:underline"> 125 125 {{ $sourceDid }}/{{ .Name }} 126 126 </a> 127 127 to 128 - <a href="/{{ $userHandle }}/{{ $repo.Rkey }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 128 + <a href="/{{ $userHandle }}/{{ $repo.Slug }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 129 129 {{ else }} 130 130 created 131 - <a href="/{{ $userHandle }}/{{ $repo.Rkey }}" class="no-underline hover:underline"> 131 + <a href="/{{ $userHandle }}/{{ $repo.Slug }}" class="no-underline hover:underline"> 132 132 {{ $repo.Name }} 133 133 </a> 134 134 {{ end }} ··· 159 159 starred 160 160 {{ end }} 161 161 {{ template "user/fragments/pic" (list $repoOwnerHandle "size-6") }} 162 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Rkey }}" class="no-underline hover:underline"> 162 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Slug }}" class="no-underline hover:underline"> 163 163 {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 164 164 </a> 165 165 </div>
+4 -4
appview/pages/templates/timeline/fragments/timeline.html
··· 39 39 {{ with $source }} 40 40 {{ $sourceDid := resolve .Did }} 41 41 forked 42 - <a href="/{{ $sourceDid }}/{{ .Rkey }}"class="no-underline hover:underline"> 42 + <a href="/{{ $sourceDid }}/{{ .Slug }}"class="no-underline hover:underline"> 43 43 {{ $sourceDid }}/{{ .Name }} 44 44 </a> 45 45 to 46 - <a href="/{{ $userHandle }}/{{ $repo.Rkey }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 46 + <a href="/{{ $userHandle }}/{{ $repo.Slug }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 47 47 {{ else }} 48 48 created 49 - <a href="/{{ $userHandle }}/{{ $repo.Rkey }}" class="no-underline hover:underline"> 49 + <a href="/{{ $userHandle }}/{{ $repo.Slug }}" class="no-underline hover:underline"> 50 50 {{ $repo.Name }} 51 51 </a> 52 52 {{ end }} ··· 74 74 {{ else }} 75 75 starred 76 76 {{ end }} 77 - <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Rkey }}" class="no-underline hover:underline"> 77 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Slug }}" class="no-underline hover:underline"> 78 78 {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 79 79 </a> 80 80 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
+1 -1
appview/pages/templates/user/fragments/issueEvent.html
··· 1 1 {{ define "user/fragments/issueEvent" }} 2 2 {{ $repoOwner := resolve .Repo.Did }} 3 - {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Rkey }} 3 + {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Slug }} 4 4 {{ $repoDisplay := printf "%s/%s" $repoOwner .Repo.Name }} 5 5 <div class="flex items-center gap-2 text-gray-600 dark:text-gray-300 overflow-hidden"> 6 6 {{ if .Open }}
+1 -1
appview/pages/templates/user/fragments/pullEvent.html
··· 1 1 {{ define "user/fragments/pullEvent" }} 2 2 {{ $repoOwner := resolve .Repo.Did }} 3 - {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Rkey }} 3 + {{ $repoUrl := printf "%s/%s" $repoOwner .Repo.Slug }} 4 4 {{ $repoDisplay := printf "%s/%s" $repoOwner .Repo.Name }} 5 5 <div class="flex items-center gap-2 text-gray-600 dark:text-gray-300 overflow-hidden"> 6 6 {{ if .State.IsOpen }}
+2 -2
appview/pages/templates/user/fragments/repoCard.html
··· 27 27 {{ end }} 28 28 {{ $repoOwner := resolve .Did }} 29 29 {{- if $fullName -}} 30 - <a href="/{{ $repoOwner }}/{{ .Rkey }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ $repoOwner }}/{{ .Name }}</a> 30 + <a href="/{{ $repoOwner }}/{{ .Slug }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ $repoOwner }}/{{ .Name }}</a> 31 31 {{- else -}} 32 - <a href="/{{ $repoOwner }}/{{ .Rkey }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ .Name }}</a> 32 + <a href="/{{ $repoOwner }}/{{ .Slug }}" data-nav-result class="truncate min-w-0 {{ if $compact }} focus:outline-none {{ end }}">{{ .Name }}</a> 33 33 {{- end -}} 34 34 </div> 35 35 {{ if and $starButton $root.LoggedInUser }}
+1 -1
appview/pages/templates/user/overview.html
··· 79 79 {{ i "book-plus" "w-4 h-4" }} 80 80 {{ end }} 81 81 </span> 82 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Rkey }}" class="no-underline hover:underline"> 82 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Slug }}" class="no-underline hover:underline"> 83 83 {{- .Repo.Name -}} 84 84 </a> 85 85 </span>
+1 -1
appview/repo/feed.go
··· 315 315 rp.logger.Error("failed to get resolved repo owner id") 316 316 return 317 317 } 318 - ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Rkey 318 + ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Slug() 319 319 320 320 opts := parseFeedOpts(r) 321 321 feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo, opts)
+26 -5
appview/reporesolver/resolver.go
··· 35 35 return &RepoResolver{config: config, enforcer: enforcer, execer: execer, rdb: rdb} 36 36 } 37 37 38 + func CanonicalRepoPath(handle string, repo *models.Repo) string { 39 + return path.Join(handle, repo.Slug()) 40 + } 41 + 42 + func CanonicalRedirectTarget(req *http.Request, canonical string) string { 43 + parts := strings.SplitN(strings.TrimPrefix(req.URL.Path, "/"), "/", 3) 44 + target := "/" + canonical 45 + if len(parts) == 3 { 46 + target += "/" + parts[2] 47 + } 48 + if req.URL.RawQuery != "" { 49 + target += "?" + req.URL.RawQuery 50 + } 51 + return target 52 + } 53 + 38 54 // NOTE: this... should not even be here. the entire package will be removed in future refactor 39 55 func GetBaseRepoPath(r *http.Request, repo *models.Repo) string { 40 - if repo.RepoDid != "" { 41 - return repo.RepoDid 56 + if id, ok := r.Context().Value("resolvedId").(identity.Identity); ok && !id.Handle.IsInvalidHandle() { 57 + if h := id.Handle.String(); h != "" { 58 + return CanonicalRepoPath(h, repo) 59 + } 42 60 } 43 61 var ( 44 62 user = chi.URLParam(r, "user") 45 63 name = chi.URLParam(r, "repo") 46 64 ) 47 - if user == "" || name == "" { 48 - return repo.RepoIdentifier() 65 + if user != "" && name != "" { 66 + return path.Join(user, name) 67 + } 68 + if repo.Name != "" { 69 + return path.Join(repo.Did, repo.Name) 49 70 } 50 - return path.Join(user, name) 71 + return repo.RepoIdentifier() 51 72 } 52 73 53 74 // TODO: move this out of `RepoResolver` struct
+112 -1
appview/reporesolver/resolver_test.go
··· 1 1 package reporesolver 2 2 3 - import "testing" 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/go-chi/chi/v5" 12 + "tangled.org/core/appview/models" 13 + ) 4 14 5 15 func TestExtractCurrentDir(t *testing.T) { 6 16 tests := []struct { ··· 20 30 } 21 31 } 22 32 } 33 + 34 + func TestCanonicalRepoPath(t *testing.T) { 35 + cases := []struct { 36 + name string 37 + handle string 38 + repo *models.Repo 39 + want string 40 + }{ 41 + {"name preferred", "boltless.dev", &models.Repo{Name: "anemone", Rkey: "3kabc"}, "boltless.dev/anemone"}, 42 + {"name equals rkey", "boltless.dev", &models.Repo{Name: "clam", Rkey: "clam"}, "boltless.dev/clam"}, 43 + {"empty name uses rkey", "akshay.dev", &models.Repo{Rkey: "limpet"}, "akshay.dev/limpet"}, 44 + } 45 + for _, c := range cases { 46 + t.Run(c.name, func(t *testing.T) { 47 + if got := CanonicalRepoPath(c.handle, c.repo); got != c.want { 48 + t.Errorf("CanonicalRepoPath = %q, want %q", got, c.want) 49 + } 50 + }) 51 + } 52 + } 53 + 54 + func reqWithChiParams(user, repo string) *http.Request { 55 + r := httptest.NewRequest("GET", "/", nil) 56 + rctx := chi.NewRouteContext() 57 + rctx.URLParams.Add("user", user) 58 + rctx.URLParams.Add("repo", repo) 59 + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) 60 + } 61 + 62 + func TestGetBaseRepoPath_DoesNotVoluntaryRedirectToRepoDid(t *testing.T) { 63 + r := reqWithChiParams("@boltless.dev", "anemone") 64 + repo := &models.Repo{ 65 + Did: "did:plc:boltless", 66 + Name: "anemone", 67 + Rkey: "3kabcxyz", 68 + RepoDid: "did:plc:anemone", 69 + } 70 + got := GetBaseRepoPath(r, repo) 71 + want := "@boltless.dev/anemone" 72 + if got != want { 73 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 74 + } 75 + } 76 + 77 + func TestGetBaseRepoPath_HonorsUrlParams(t *testing.T) { 78 + r := reqWithChiParams("did:plc:akshay", "limpet") 79 + repo := &models.Repo{Did: "did:plc:akshay", Name: "limpet", Rkey: "limpet"} 80 + if got, want := GetBaseRepoPath(r, repo), "did:plc:akshay/limpet"; got != want { 81 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 82 + } 83 + } 84 + 85 + func TestGetBaseRepoPath_NoParamsPrefersName(t *testing.T) { 86 + r := httptest.NewRequest("GET", "/", nil) 87 + repo := &models.Repo{Did: "did:plc:akshay", Name: "scallop", Rkey: "3koldtid"} 88 + got := GetBaseRepoPath(r, repo) 89 + want := "did:plc:akshay/scallop" 90 + if got != want { 91 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 92 + } 93 + } 94 + 95 + func TestGetBaseRepoPath_NoParamsNoNameFallsToRepoIdentifier(t *testing.T) { 96 + r := httptest.NewRequest("GET", "/", nil) 97 + repo := &models.Repo{Did: "did:plc:akshay", Rkey: "3koldtid", RepoDid: "did:plc:scallop"} 98 + if got, want := GetBaseRepoPath(r, repo), "did:plc:scallop"; got != want { 99 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 100 + } 101 + } 102 + 103 + func reqWithResolvedId(handle, did string) *http.Request { 104 + r := reqWithChiParams(did, "3koldtid") 105 + id := identity.Identity{ 106 + DID: syntax.DID(did), 107 + Handle: syntax.Handle(handle), 108 + } 109 + return r.WithContext(context.WithValue(r.Context(), "resolvedId", id)) 110 + } 111 + 112 + func TestGetBaseRepoPath_PrefersResolvedHandleOverChiDid(t *testing.T) { 113 + r := reqWithResolvedId("boltless.dev", "did:plc:boltless") 114 + repo := &models.Repo{Did: "did:plc:boltless", Name: "anemone", Rkey: "3kabcxyz"} 115 + got := GetBaseRepoPath(r, repo) 116 + want := "boltless.dev/anemone" 117 + if got != want { 118 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 119 + } 120 + } 121 + 122 + func TestGetBaseRepoPath_InvalidHandleFallsThroughToChi(t *testing.T) { 123 + r := reqWithChiParams("did:plc:boltless", "limpet") 124 + id := identity.Identity{ 125 + DID: syntax.DID("did:plc:boltless"), 126 + Handle: syntax.HandleInvalid, 127 + } 128 + r = r.WithContext(context.WithValue(r.Context(), "resolvedId", id)) 129 + repo := &models.Repo{Did: "did:plc:boltless", Name: "limpet", Rkey: "limpet"} 130 + if got, want := GetBaseRepoPath(r, repo), "did:plc:boltless/limpet"; got != want { 131 + t.Errorf("GetBaseRepoPath = %q, want %q", got, want) 132 + } 133 + }
+3 -3
appview/state/profile.go
··· 725 725 func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 726 726 return &feeds.Item{ 727 727 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 728 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Rkey, pull.PullId), Type: "text/html", Rel: "alternate"}, 728 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.BaseUrl(), owner.Handle, pull.Repo.Slug(), pull.PullId), Type: "text/html", Rel: "alternate"}, 729 729 Created: pull.Created, 730 730 Author: author, 731 731 } ··· 734 734 func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 735 735 return &feeds.Item{ 736 736 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 737 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Rkey, issue.IssueId), Type: "text/html", Rel: "alternate"}, 737 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.BaseUrl(), owner.Handle, issue.Repo.Slug(), issue.IssueId), Type: "text/html", Rel: "alternate"}, 738 738 Created: issue.Created, 739 739 Author: author, 740 740 } ··· 754 754 755 755 return &feeds.Item{ 756 756 Title: title, 757 - Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Rkey), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 757 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.BaseUrl(), author.Name[1:], repo.Repo.Slug()), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 758 758 Created: repo.Repo.Created, 759 759 Author: author, 760 760 }, nil
+8 -5
appview/state/router.go
··· 130 130 131 131 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 132 132 r.Use(mw.GoImport()) 133 - r.Mount("/", s.RepoRouter(mw)) 134 - r.Mount("/issues", s.IssuesRouter(mw)) 135 - r.Mount("/pulls", s.PullsRouter(mw)) 136 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 137 - r.Mount("/labels", s.LabelsRouter()) 138 133 139 134 // These routes get proxied to the knot 140 135 r.Get("/info/refs", s.InfoRefs) ··· 142 137 r.Post("/git-upload-pack", s.UploadPack) 143 138 r.Post("/git-receive-pack", s.ReceivePack) 144 139 140 + r.Group(func(r chi.Router) { 141 + r.Use(mw.CanonicalizeRepoURL()) 142 + r.Mount("/issues", s.IssuesRouter(mw)) 143 + r.Mount("/pulls", s.PullsRouter(mw)) 144 + r.Mount("/pipelines", s.PipelinesRouter(mw)) 145 + r.Mount("/labels", s.LabelsRouter()) 146 + r.Mount("/", s.RepoRouter(mw)) 147 + }) 145 148 }) 146 149 }) 147 150