This repository has no description
0

Configure Feed

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

core / appview / accountmigration / accountmigration.go
9.6 kB 320 lines
1package accountmigration 2 3import ( 4 "encoding/json" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "net/url" 9 "slices" 10 "strconv" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 "github.com/go-chi/chi/v5" 15 "tangled.org/core/appview/db" 16 "tangled.org/core/appview/middleware" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/oauth" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/rbac" 21 "tangled.org/core/sets" 22) 23 24type AccountMigration struct { 25 logger *slog.Logger 26 oauth *oauth.OAuth 27 pages *pages.Pages 28 db *db.DB 29 enforcer *rbac.Enforcer 30} 31 32func New( 33 logger *slog.Logger, 34 o *oauth.OAuth, 35 p *pages.Pages, 36 d *db.DB, 37 enforcer *rbac.Enforcer, 38) *AccountMigration { 39 return &AccountMigration{ 40 logger: logger, 41 oauth: o, 42 pages: p, 43 db: d, 44 enforcer: enforcer, 45 } 46} 47 48func (s *AccountMigration) Router() http.Handler { 49 r := chi.NewRouter() 50 r.Use(middleware.AuthMiddleware(s.oauth)) 51 52 r.Get("/", s.migratePage) 53 r.Post("/listGitHubRepos", s.listGitHubRepos) 54 r.Post("/start", s.startMigration) 55 r.Get("/progress/rows", s.progressRows) 56 return r 57} 58 59func (s *AccountMigration) migratePage(w http.ResponseWriter, r *http.Request) { 60 user := s.oauth.GetMultiAccountUser(r) 61 62 service := r.URL.Query().Get("service") 63 switch service { 64 case "github": 65 knots, err := s.enforcer.GetKnotsForUser(user.Did) 66 if err != nil { 67 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 68 knots = nil 69 } 70 if err := s.pages.AccountMigrateFromGitHub(w, pages.AccountMigrateFromGitHubParams{ 71 BaseParams: pages.BaseParamsFromContext(r.Context()), 72 Knots: knots, 73 Repos: []pages.RepoImportParams{ 74 { 75 SourceKind: pages.RepoImportSourceGitHub, 76 CloneUrl: "https://github.com/boltlessengineer/rest.nvim", 77 Name: "rest.nvim", 78 Description: "A very fast, powerful, extensible and asynchronous Neovim HTTP client written in Lua.", 79 Website: "https://tangled.org", 80 Topics: []string{"lua", "neovim", "curl", "http-client", "nvim", "neovim-plugin", "rest-client"}, 81 Selected: true, 82 }, 83 { 84 SourceKind: pages.RepoImportSourceGitHub, 85 CloneUrl: "https://github.com/boltlessengineer/dot", 86 Name: "super-long-repo-name-fddasfsafdsfasdfaasdfasdfasfasdfasfdasdfasdfsadfsadfasdfaasdfasfasdfdsafd00000s", 87 Description: "dotfiles", 88 Website: "https://tangled.org", 89 Topics: []string{}, 90 Selected: true, 91 }, 92 }, 93 }); err != nil { 94 s.logger.Error("failed to render", "err", err) 95 } 96 default: 97 s.pages.AccountMigrate(w, pages.AccountMigrateParams{ 98 BaseParams: pages.BaseParamsFromContext(r.Context()), 99 }) 100 } 101} 102 103type githubUserRepo struct { 104 Name string `json:"name"` 105 CloneUrl string `json:"clone_url"` 106 DefaultBranch string `json:"default_branch"` 107 Description string `json:"description"` 108 Homepage string `json:"homepage"` 109 Topics []string `json:"topics"` 110 Fork bool `json:"fork"` 111 Private bool `json:"private"` 112 Archived bool `json:"archived"` 113 Disabled bool `json:"disabled"` 114} 115 116func (s *AccountMigration) listGitHubRepos(w http.ResponseWriter, r *http.Request) { 117 user := s.oauth.GetMultiAccountUser(r) 118 119 username := strings.TrimSpace(r.FormValue("username")) 120 if username == "" { 121 s.pages.Notice(w, "migrate-error", "GitHub username is required.") 122 return 123 } 124 125 query := url.Values{} 126 query.Set("sort", "updated") 127 query.Set("per_page", "80") 128 129 endpoint := fmt.Sprintf("https://api.github.com/users/%s/repos?%s", url.PathEscape(username), query.Encode()) 130 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint, nil) 131 if err != nil { 132 s.pages.Notice(w, "migrate-error", "Failed to build GitHub request.") 133 return 134 } 135 req.Header.Set("Accept", "application/vnd.github+json") 136 137 resp, err := http.DefaultClient.Do(req) 138 if err != nil { 139 s.logger.Error("github list repos failed", "username", username, "err", err) 140 s.pages.Notice(w, "migrate-error", "Failed to reach GitHub. Try again.") 141 return 142 } 143 defer resp.Body.Close() 144 145 if resp.StatusCode != http.StatusOK { 146 s.pages.Notice(w, "migrate-error", fmt.Sprintf("GitHub returned %d. Check the username.", resp.StatusCode)) 147 return 148 } 149 150 var githubRepos []githubUserRepo 151 if err := json.NewDecoder(resp.Body).Decode(&githubRepos); err != nil { 152 s.logger.Error("decode github response failed", "err", err) 153 s.pages.Notice(w, "migrate-error", "Failed to parse GitHub response.") 154 return 155 } 156 157 enqueued, err := db.ListEnqueuedGitRepoNames(r.Context(), s.db, user.Did) 158 if err != nil { 159 s.logger.Error("list enqueued names failed", "err", err) 160 enqueued = map[string]struct{}{} 161 } 162 163 knots, err := s.enforcer.GetKnotsForUser(user.Did) 164 if err != nil { 165 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 166 knots = nil 167 } 168 169 importRepos := make([]pages.RepoImportParams, 0, len(githubRepos)) 170 for _, ghrepo := range githubRepos { 171 if ghrepo.Fork || ghrepo.Private || ghrepo.Archived || ghrepo.Disabled { 172 continue 173 } 174 name := strings.ToLower(ghrepo.Name) 175 _, migrated := enqueued[name] 176 importRepos = append(importRepos, pages.RepoImportParams{ 177 SourceKind: pages.RepoImportSourceGitHub, 178 CloneUrl: ghrepo.CloneUrl, 179 Name: name, 180 Description: ghrepo.Description, 181 Website: ghrepo.Homepage, 182 Topics: ghrepo.Topics, 183 Selected: !migrated, 184 }) 185 } 186 187 if err := s.pages.AccountMigrateRepoListFragment(w, pages.AccountMigrateRepoListParams{ 188 Repos: importRepos, 189 Knots: knots, 190 }); err != nil { 191 s.logger.Error("render repo-list fragment failed", "err", err) 192 } 193} 194 195func (s *AccountMigration) startMigration(w http.ResponseWriter, r *http.Request) { 196 user := s.oauth.GetMultiAccountUser(r) 197 198 if err := r.ParseForm(); err != nil { 199 s.pages.Notice(w, "migrate-error", "Invalid form submission.") 200 return 201 } 202 203 knots, err := s.enforcer.GetKnotsForUser(user.Did) 204 if err != nil { 205 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 206 s.pages.Notice(w, "migrate-error", "Failed to look up your knots.") 207 return 208 } 209 allowed := sets.Collect(slices.Values(knots)) 210 211 sessionId := s.oauth.GetSessIdFromCookie(r) 212 if sessionId == "" { 213 s.pages.Notice(w, "migrate-error", "Session expired. Log in again.") 214 return 215 } 216 217 count, err := strconv.Atoi(r.FormValue("count")) 218 if err != nil || count <= 0 { 219 s.pages.Notice(w, "migrate-error", "Invalid form submission.") 220 return 221 } 222 223 s.logger.Debug("migrating repos", "count", count) 224 225 rows := make([]db.GitRepoMigration, 0, count) 226 seen := sets.New[string]() 227 for i := range count { 228 if r.FormValue(fmt.Sprintf("selected_%d", i)) == "" { 229 s.logger.Warn("can't find selected repo", "i", i) 230 continue 231 } 232 s.logger.Info("found selected repo", "i", i) 233 234 cloneUrl := strings.TrimSpace(r.FormValue(fmt.Sprintf("clone_url_%d", i))) 235 knot := strings.TrimSpace(r.FormValue(fmt.Sprintf("knot_%d", i))) 236 name := strings.ToLower(strings.TrimSpace(r.FormValue(fmt.Sprintf("name_%d", i)))) 237 desc := strings.TrimSpace(r.FormValue(fmt.Sprintf("description_%d", i))) 238 website := strings.TrimSpace(r.FormValue(fmt.Sprintf("website_%d", i))) 239 topics := r.FormValue(fmt.Sprintf("topics_%d", i)) 240 241 if cloneUrl == "" || knot == "" || name == "" { 242 s.pages.Notice(w, "migrate-error", "Each selected row needs a name, clone URL, and knot.") 243 return 244 } 245 if err := models.ValidateRepoName(name); err != nil { 246 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: %s", i+1, err.Error())) 247 return 248 } 249 if len([]rune(desc)) > 140 { 250 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: description must be 140 characters or fewer.", i+1)) 251 return 252 } 253 if !allowed.Contains(knot) { 254 s.pages.Notice(w, "migrate-error", fmt.Sprintf("You are not a member of knot %q.", knot)) 255 return 256 } 257 if seen.Contains(name) { 258 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Duplicate repository name %q in selection.", name)) 259 return 260 } 261 seen.Insert(name) 262 263 rows = append(rows, db.GitRepoMigration{ 264 OwnerDid: syntax.DID(user.Did), 265 SourceKind: db.GitRepoMigrationSourceGitHub, 266 CloneUrl: cloneUrl, 267 Name: name, 268 Knot: knot, 269 Description: desc, 270 Website: website, 271 SessionID: sessionId, 272 Topics: func(s string) []string { 273 if s == "" { 274 return nil 275 } 276 parts := strings.Split(s, ",") 277 out := parts[:0] 278 for _, p := range parts { 279 p = strings.TrimSpace(p) 280 if p != "" { 281 out = append(out, p) 282 } 283 } 284 return out 285 }(topics), 286 }) 287 } 288 289 if len(rows) == 0 { 290 s.pages.Notice(w, "migrate-error", "Pick at least one repo.") 291 return 292 } 293 294 s.logger.Info("inserting migrations", "len", len(rows)) 295 296 if err := db.InsertGitRepoMigrations(r.Context(), s.db, rows); err != nil { 297 s.logger.Error("insert migrations failed", "err", err) 298 s.pages.Notice(w, "migrate-error", "Failed to enqueue migrations.") 299 return 300 } 301 302 w.Header().Set("HX-Redirect", "/settings/migration") 303 http.Redirect(w, r, "/settings/migration", http.StatusSeeOther) 304} 305 306func (s *AccountMigration) progressRows(w http.ResponseWriter, r *http.Request) { 307 user := s.oauth.GetMultiAccountUser(r) 308 309 migrations, err := db.ListGitRepoMigrationsForOwner(r.Context(), s.db, user.Did) 310 if err != nil { 311 s.logger.Error("list migrations failed", "did", user.Did, "err", err) 312 } 313 314 if err := s.pages.AccountMigrateProgressRowsFragment(w, pages.AccountMigrateProgressParams{ 315 BaseParams: pages.BaseParamsFromContext(r.Context()), 316 Migrations: migrations, 317 }); err != nil { 318 s.logger.Error("render progress rows failed", "err", err) 319 } 320}