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