package accountmigration import ( "encoding/json" "fmt" "log/slog" "net/http" "net/url" "slices" "strconv" "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "tangled.org/core/appview/db" "tangled.org/core/appview/middleware" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/rbac" "tangled.org/core/sets" ) type AccountMigration struct { logger *slog.Logger oauth *oauth.OAuth pages *pages.Pages db *db.DB enforcer *rbac.Enforcer } func New( logger *slog.Logger, o *oauth.OAuth, p *pages.Pages, d *db.DB, enforcer *rbac.Enforcer, ) *AccountMigration { return &AccountMigration{ logger: logger, oauth: o, pages: p, db: d, enforcer: enforcer, } } func (s *AccountMigration) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.AuthMiddleware(s.oauth)) r.Get("/", s.migratePage) r.Post("/listGitHubRepos", s.listGitHubRepos) r.Post("/start", s.startMigration) r.Get("/progress/rows", s.progressRows) return r } func (s *AccountMigration) migratePage(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetMultiAccountUser(r) service := r.URL.Query().Get("service") switch service { case "github": knots, err := s.enforcer.GetKnotsForUser(user.Did) if err != nil { s.logger.Error("knots lookup failed", "did", user.Did, "err", err) knots = nil } if err := s.pages.AccountMigrateFromGitHub(w, pages.AccountMigrateFromGitHubParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Knots: knots, }); err != nil { s.logger.Error("failed to render", "err", err) } default: s.pages.AccountMigrate(w, pages.AccountMigrateParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), }) } } type githubUserRepo struct { Name string `json:"name"` CloneUrl string `json:"clone_url"` Stars int `json:"stargazers_count"` PushedAt time.Time `json:"pushed_at"` DefaultBranch string `json:"default_branch"` Description string `json:"description"` Homepage string `json:"homepage"` Topics []string `json:"topics"` Fork bool `json:"fork"` Private bool `json:"private"` Archived bool `json:"archived"` Disabled bool `json:"disabled"` } func (s *AccountMigration) listGitHubRepos(w http.ResponseWriter, r *http.Request) { var notice string var params pages.AccountMigrateRepoListParams defer func() { s.pages.Notice(w, "listghrepos-error", notice) s.pages.AccountMigrateRepoListFragment(w, params) }() user := s.oauth.GetMultiAccountUser(r) username := strings.TrimSpace(r.FormValue("username")) if username == "" { notice = "GitHub username is required." return } query := url.Values{} query.Set("sort", "updated") query.Set("per_page", "80") endpoint := fmt.Sprintf("https://api.github.com/users/%s/repos?%s", url.PathEscape(username), query.Encode()) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint, nil) if err != nil { notice = "Failed to build GitHub request." return } req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { s.logger.Error("github list repos request failed", "username", username, "err", err) notice = "Failed to reach GitHub. Try again." return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { notice = fmt.Sprintf("GitHub returned %d. Check the username.", resp.StatusCode) return } var githubRepos []githubUserRepo if err := json.NewDecoder(resp.Body).Decode(&githubRepos); err != nil { s.logger.Error("decode github response failed", "err", err) notice = "Failed to parse GitHub response." return } enqueued, err := db.ListEnqueuedGitRepoNames(r.Context(), s.db, user.Did) if err != nil { s.logger.Error("list enqueued names failed", "err", err) enqueued = map[string]struct{}{} } knots, err := s.enforcer.GetKnotsForUser(user.Did) if err != nil { s.logger.Error("knots lookup failed", "did", user.Did, "err", err) knots = nil } importRepos := make([]pages.RepoImportParams, 0, len(githubRepos)) for _, ghrepo := range githubRepos { if ghrepo.Fork || ghrepo.Private || ghrepo.Archived || ghrepo.Disabled { continue } name := strings.ToLower(ghrepo.Name) _, migrated := enqueued[name] importRepos = append(importRepos, pages.RepoImportParams{ SourceKind: pages.RepoImportSourceGitHub, CloneUrl: ghrepo.CloneUrl, Name: name, Description: ghrepo.Description, Website: ghrepo.Homepage, Topics: ghrepo.Topics, Stars: ghrepo.Stars, PushedAt: ghrepo.PushedAt, Selected: !migrated, }) } // sort by stars desc, then most recently pushed slices.SortFunc(importRepos, func(a, b pages.RepoImportParams) int { if a.Stars != b.Stars { return b.Stars - a.Stars } return b.PushedAt.Compare(a.PushedAt) }) params = pages.AccountMigrateRepoListParams{ Repos: importRepos, Knots: knots, } // render is deferred } func (s *AccountMigration) startMigration(w http.ResponseWriter, r *http.Request) { var notice string defer func() { s.pages.Notice(w, "migration-error", notice) }() user := s.oauth.GetMultiAccountUser(r) if err := r.ParseForm(); err != nil { notice = "Invalid form submission." return } knots, err := s.enforcer.GetKnotsForUser(user.Did) if err != nil { s.logger.Error("knots lookup failed", "did", user.Did, "err", err) notice = "Failed to look up your knots." return } allowed := sets.Collect(slices.Values(knots)) sessionId := s.oauth.GetSessIdFromCookie(r) if sessionId == "" { notice = "Session expired. Log in again." return } count, err := strconv.Atoi(r.FormValue("count")) if err != nil || count <= 0 { notice = "Invalid form submission." return } s.logger.Debug("migrating repos", "count", count) rows := make([]db.GitRepoMigration, 0, count) seen := sets.New[string]() for i := range count { if r.FormValue(fmt.Sprintf("selected_%d", i)) == "" { s.logger.Warn("can't find selected repo", "i", i) continue } s.logger.Info("found selected repo", "i", i) cloneUrl := strings.TrimSpace(r.FormValue(fmt.Sprintf("clone_url_%d", i))) knot := strings.TrimSpace(r.FormValue(fmt.Sprintf("knot_%d", i))) name := strings.ToLower(strings.TrimSpace(r.FormValue(fmt.Sprintf("name_%d", i)))) desc := strings.TrimSpace(r.FormValue(fmt.Sprintf("description_%d", i))) website := strings.TrimSpace(r.FormValue(fmt.Sprintf("website_%d", i))) topics := r.FormValue(fmt.Sprintf("topics_%d", i)) if cloneUrl == "" { notice = fmt.Sprintf("Row %d: clone URL is missing.", i+1) return } if knot == "" { notice = fmt.Sprintf("Row %d: knot is missing.", i+1) return } if name == "" { notice = fmt.Sprintf("Row %d: name is missing.", i+1) return } if err := models.ValidateRepoName(name); err != nil { notice = fmt.Sprintf("Row %d: %s", i+1, err.Error()) return } if len([]rune(desc)) > 140 { notice = fmt.Sprintf("Row %d: description must be 140 characters or fewer.", i+1) return } if !allowed.Contains(knot) { notice = fmt.Sprintf("Row %d: You are not a member of knot %q.", i+1, knot) return } if seen.Contains(name) { notice = fmt.Sprintf("Row %d: Duplicate repository name %q in selection.", i+1, name) return } seen.Insert(name) rows = append(rows, db.GitRepoMigration{ OwnerDid: syntax.DID(user.Did), SourceKind: db.GitRepoMigrationSourceGitHub, CloneUrl: cloneUrl, Name: name, Knot: knot, Description: desc, Website: website, SessionID: sessionId, Topics: func(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := parts[:0] for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out }(topics), }) } if len(rows) == 0 { notice = "Pick at least one repo." return } s.logger.Info("inserting migrations", "len", len(rows)) if err := db.InsertGitRepoMigrations(r.Context(), s.db, rows); err != nil { s.logger.Error("insert migrations failed", "err", err) notice = "Failed to enqueue migrations." return } s.pages.HxRedirect(w, "/settings/migration") } func (s *AccountMigration) progressRows(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetMultiAccountUser(r) migrations, err := db.ListGitRepoMigrationsForOwner(r.Context(), s.db, user.Did) if err != nil { s.logger.Error("list migrations failed", "did", user.Did, "err", err) } if err := s.pages.AccountMigrateProgressRowsFragment(w, pages.AccountMigrateProgressParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), Migrations: migrations, }); err != nil { s.logger.Error("render progress rows failed", "err", err) } }