This repository has no description
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}