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