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