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
8.7 kB 298 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 s.pages.AccountMigrateFromGitHub(w, pages.AccountMigrateFromGitHubParams{ 71 BaseParams: pages.BaseParamsFromContext(r.Context()), 72 Knots: knots, 73 }) 74 default: 75 s.pages.AccountMigrate(w, pages.AccountMigrateParams{ 76 BaseParams: pages.BaseParamsFromContext(r.Context()), 77 }) 78 } 79} 80 81type githubUserRepo struct { 82 Name string `json:"name"` 83 CloneUrl string `json:"clone_url"` 84 DefaultBranch string `json:"default_branch"` 85 Description string `json:"description"` 86 Homepage string `json:"homepage"` 87 Topics []string `json:"topics"` 88 Fork bool `json:"fork"` 89 Private bool `json:"private"` 90 Archived bool `json:"archived"` 91 Disabled bool `json:"disabled"` 92} 93 94func (s *AccountMigration) listGitHubRepos(w http.ResponseWriter, r *http.Request) { 95 user := s.oauth.GetMultiAccountUser(r) 96 97 username := strings.TrimSpace(r.FormValue("username")) 98 if username == "" { 99 s.pages.Notice(w, "migrate-error", "GitHub username is required.") 100 return 101 } 102 103 query := url.Values{} 104 query.Set("sort", "updated") 105 query.Set("per_page", "80") 106 107 endpoint := fmt.Sprintf("https://api.github.com/users/%s/repos?%s", url.PathEscape(username), query.Encode()) 108 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint, nil) 109 if err != nil { 110 s.pages.Notice(w, "migrate-error", "Failed to build GitHub request.") 111 return 112 } 113 req.Header.Set("Accept", "application/vnd.github+json") 114 115 resp, err := http.DefaultClient.Do(req) 116 if err != nil { 117 s.logger.Error("github list repos failed", "username", username, "err", err) 118 s.pages.Notice(w, "migrate-error", "Failed to reach GitHub. Try again.") 119 return 120 } 121 defer resp.Body.Close() 122 123 if resp.StatusCode != http.StatusOK { 124 s.pages.Notice(w, "migrate-error", fmt.Sprintf("GitHub returned %d. Check the username.", resp.StatusCode)) 125 return 126 } 127 128 var githubRepos []githubUserRepo 129 if err := json.NewDecoder(resp.Body).Decode(&githubRepos); err != nil { 130 s.logger.Error("decode github response failed", "err", err) 131 s.pages.Notice(w, "migrate-error", "Failed to parse GitHub response.") 132 return 133 } 134 135 enqueued, err := db.ListEnqueuedGitRepoNames(r.Context(), s.db, user.Did) 136 if err != nil { 137 s.logger.Error("list enqueued names failed", "err", err) 138 enqueued = map[string]struct{}{} 139 } 140 141 knots, err := s.enforcer.GetKnotsForUser(user.Did) 142 if err != nil { 143 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 144 knots = nil 145 } 146 147 importRepos := make([]pages.RepoImportParams, 0, len(githubRepos)) 148 for _, ghrepo := range githubRepos { 149 if ghrepo.Fork || ghrepo.Private || ghrepo.Archived || ghrepo.Disabled { 150 continue 151 } 152 name := strings.ToLower(ghrepo.Name) 153 _, migrated := enqueued[name] 154 importRepos = append(importRepos, pages.RepoImportParams{ 155 SourceKind: pages.RepoImportSourceGitHub, 156 CloneUrl: ghrepo.CloneUrl, 157 Name: name, 158 Description: ghrepo.Description, 159 Website: ghrepo.Homepage, 160 Topics: ghrepo.Topics, 161 Selected: !migrated, 162 }) 163 } 164 165 if err := s.pages.AccountMigrateRepoListFragment(w, pages.AccountMigrateRepoListParams{ 166 Repos: importRepos, 167 Knots: knots, 168 }); err != nil { 169 s.logger.Error("render repo-list fragment failed", "err", err) 170 } 171} 172 173func (s *AccountMigration) startMigration(w http.ResponseWriter, r *http.Request) { 174 user := s.oauth.GetMultiAccountUser(r) 175 176 if err := r.ParseForm(); err != nil { 177 s.pages.Notice(w, "migrate-error", "Invalid form submission.") 178 return 179 } 180 181 knots, err := s.enforcer.GetKnotsForUser(user.Did) 182 if err != nil { 183 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 184 s.pages.Notice(w, "migrate-error", "Failed to look up your knots.") 185 return 186 } 187 allowed := sets.Collect(slices.Values(knots)) 188 189 sessionId := s.oauth.GetSessIdFromCookie(r) 190 if sessionId == "" { 191 s.pages.Notice(w, "migrate-error", "Session expired. Log in again.") 192 return 193 } 194 195 count, err := strconv.Atoi(r.FormValue("count")) 196 if err != nil || count <= 0 { 197 s.pages.Notice(w, "migrate-error", "Invalid form submission.") 198 return 199 } 200 201 s.logger.Debug("migrating repos", "count", count) 202 203 rows := make([]db.GitRepoMigration, 0, count) 204 seen := sets.New[string]() 205 for i := range count { 206 if r.FormValue(fmt.Sprintf("selected_%d", i)) == "" { 207 s.logger.Warn("can't find selected repo", "i", i) 208 continue 209 } 210 s.logger.Info("found selected repo", "i", i) 211 212 cloneUrl := strings.TrimSpace(r.FormValue(fmt.Sprintf("clone_url_%d", i))) 213 knot := strings.TrimSpace(r.FormValue(fmt.Sprintf("knot_%d", i))) 214 name := strings.ToLower(strings.TrimSpace(r.FormValue(fmt.Sprintf("name_%d", i)))) 215 desc := strings.TrimSpace(r.FormValue(fmt.Sprintf("description_%d", i))) 216 website := strings.TrimSpace(r.FormValue(fmt.Sprintf("website_%d", i))) 217 topics := r.FormValue(fmt.Sprintf("topics_%d", i)) 218 219 if cloneUrl == "" || knot == "" || name == "" { 220 s.pages.Notice(w, "migrate-error", "Each selected row needs a name, clone URL, and knot.") 221 return 222 } 223 if err := models.ValidateRepoName(name); err != nil { 224 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: %s", i+1, err.Error())) 225 return 226 } 227 if len([]rune(desc)) > 140 { 228 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: description must be 140 characters or fewer.", i+1)) 229 return 230 } 231 if !allowed.Contains(knot) { 232 s.pages.Notice(w, "migrate-error", fmt.Sprintf("You are not a member of knot %q.", knot)) 233 return 234 } 235 if seen.Contains(name) { 236 s.pages.Notice(w, "migrate-error", fmt.Sprintf("Duplicate repository name %q in selection.", name)) 237 return 238 } 239 seen.Insert(name) 240 241 rows = append(rows, db.GitRepoMigration{ 242 OwnerDid: syntax.DID(user.Did), 243 SourceKind: db.GitRepoMigrationSourceGitHub, 244 CloneUrl: cloneUrl, 245 Name: name, 246 Knot: knot, 247 Description: desc, 248 Website: website, 249 SessionID: sessionId, 250 Topics: func(s string) []string { 251 if s == "" { 252 return nil 253 } 254 parts := strings.Split(s, ",") 255 out := parts[:0] 256 for _, p := range parts { 257 p = strings.TrimSpace(p) 258 if p != "" { 259 out = append(out, p) 260 } 261 } 262 return out 263 }(topics), 264 }) 265 } 266 267 if len(rows) == 0 { 268 s.pages.Notice(w, "migrate-error", "Pick at least one repo.") 269 return 270 } 271 272 s.logger.Info("inserting migrations", "len", len(rows)) 273 274 if err := db.InsertGitRepoMigrations(r.Context(), s.db, rows); err != nil { 275 s.logger.Error("insert migrations failed", "err", err) 276 s.pages.Notice(w, "migrate-error", "Failed to enqueue migrations.") 277 return 278 } 279 280 w.Header().Set("HX-Redirect", "/settings/migration") 281 http.Redirect(w, r, "/settings/migration", http.StatusSeeOther) 282} 283 284func (s *AccountMigration) progressRows(w http.ResponseWriter, r *http.Request) { 285 user := s.oauth.GetMultiAccountUser(r) 286 287 migrations, err := db.ListGitRepoMigrationsForOwner(r.Context(), s.db, user.Did) 288 if err != nil { 289 s.logger.Error("list migrations failed", "did", user.Did, "err", err) 290 } 291 292 if err := s.pages.AccountMigrateProgressRowsFragment(w, pages.AccountMigrateProgressParams{ 293 BaseParams: pages.BaseParamsFromContext(r.Context()), 294 Migrations: migrations, 295 }); err != nil { 296 s.logger.Error("render progress rows failed", "err", err) 297 } 298}