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