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