tenseleyflow/shithub / 9b4cf0d

Browse files

Import repos from source remotes

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9b4cf0d52516a6526c33d9abeade32fff86bbf18
Parents
0b94e81
Tree
adcad0b

5 changed files

StatusFile+-
M internal/web/handlers/repo/repo.go 45 14
M internal/web/handlers/repo/settings_general.go 67 0
A internal/web/handlers/repo/source_remote.go 133 0
M internal/web/templates/repo/new.html 7 0
M internal/web/templates/repo/settings_general.html 20 0
internal/web/handlers/repo/repo.gomodified
@@ -154,6 +154,7 @@ type formState struct {
154154
 	Name         string
155155
 	Description  string
156156
 	Visibility   string
157
+	SourceRemote string
157158
 	InitReadme   bool
158159
 	License      string
159160
 	Gitignore    string
@@ -181,6 +182,7 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
181182
 		Name:         repos.NormalizeName(r.PostFormValue("name")),
182183
 		Description:  strings.TrimSpace(r.PostFormValue("description")),
183184
 		Visibility:   strings.TrimSpace(r.PostFormValue("visibility")),
185
+		SourceRemote: strings.TrimSpace(r.PostFormValue("source_remote_url")),
184186
 		InitReadme:   r.PostFormValue("init_readme") == "on",
185187
 		License:      strings.TrimSpace(r.PostFormValue("license")),
186188
 		Gitignore:    strings.TrimSpace(r.PostFormValue("gitignore")),
@@ -188,6 +190,20 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
188190
 	if form.Visibility == "" {
189191
 		form.Visibility = "public"
190192
 	}
193
+	var sourceRemoteURL string
194
+	if form.SourceRemote != "" {
195
+		if form.InitReadme || form.License != "" || form.Gitignore != "" {
196
+			h.renderNewForm(w, r, form, "Source imports can't be combined with initial README, license, or .gitignore files.")
197
+			return
198
+		}
199
+		var sourceErr error
200
+		sourceRemoteURL, sourceErr = repos.ValidateSourceRemoteURL(r.Context(), form.SourceRemote)
201
+		if sourceErr != nil {
202
+			h.renderNewForm(w, r, form, "Source remote URL must be a public http(s) git remote without credentials.")
203
+			return
204
+		}
205
+		form.SourceRemote = sourceRemoteURL
206
+	}
191207
 
192208
 	params := repos.Params{
193209
 		ActorUserID:      user.ID,
@@ -256,6 +272,21 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
256272
 	if ownerSlug == "" {
257273
 		ownerSlug = params.OwnerSlug
258274
 	}
275
+	if sourceRemoteURL != "" {
276
+		if _, err := h.rq.UpsertRepoSourceRemote(r.Context(), h.d.Pool, reposdb.UpsertRepoSourceRemoteParams{
277
+			RepoID:    res.Repo.ID,
278
+			RemoteUrl: sourceRemoteURL,
279
+		}); err != nil {
280
+			h.d.Logger.WarnContext(r.Context(), "repos: source remote save after create", "error", err, "repo_id", res.Repo.ID)
281
+			http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-save-failed", http.StatusSeeOther)
282
+			return
283
+		}
284
+		if err := h.fetchRepoSourceRemote(r.Context(), res.Repo, ownerSlug, sourceRemoteURL); err != nil {
285
+			h.d.Logger.WarnContext(r.Context(), "repos: source remote fetch after create", "error", err, "repo_id", res.Repo.ID, "remote", sourceRemoteURL)
286
+			http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-fetch-failed", http.StatusSeeOther)
287
+			return
288
+		}
289
+	}
259290
 	http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name, http.StatusSeeOther)
260291
 }
261292
 
internal/web/handlers/repo/settings_general.gomodified
@@ -3,12 +3,14 @@
33
 package repo
44
 
55
 import (
6
+	"context"
67
 	"errors"
78
 	"net/http"
89
 	"strconv"
910
 	"strings"
1011
 
1112
 	"github.com/go-chi/chi/v5"
13
+	"github.com/jackc/pgx/v5"
1214
 	"github.com/jackc/pgx/v5/pgtype"
1315
 
1416
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
@@ -26,6 +28,7 @@ import (
2628
 func (h *Handlers) MountSettingsGeneral(r chi.Router) {
2729
 	r.Get("/{owner}/{repo}/settings/general", h.settingsGeneral)
2830
 	r.Post("/{owner}/{repo}/settings/general", h.settingsGeneralUpdate)
31
+	r.Post("/{owner}/{repo}/settings/source-remote", h.settingsSourceRemoteUpdate)
2932
 	r.Post("/{owner}/{repo}/settings/merges", h.settingsMergeUpdate)
3033
 	r.Get("/{owner}/{repo}/settings/access", h.settingsAccess)
3134
 	r.Post("/{owner}/{repo}/settings/access/collaborators", h.settingsCollabUpsert)
@@ -44,6 +47,7 @@ func (h *Handlers) settingsGeneral(w http.ResponseWriter, r *http.Request) {
4447
 		return
4548
 	}
4649
 	topics, _ := h.rq.ListRepoTopics(r.Context(), h.d.Pool, row.ID)
50
+	sourceRemote, _ := h.repoSourceRemote(r.Context(), row.ID)
4751
 	notice := r.URL.Query().Get("notice")
4852
 	h.d.Render.RenderPage(w, r, "repo/settings_general", map[string]any{
4953
 		"Title":          "General · " + row.Name,
@@ -52,6 +56,7 @@ func (h *Handlers) settingsGeneral(w http.ResponseWriter, r *http.Request) {
5256
 		"Repo":           row,
5357
 		"Topics":         topics,
5458
 		"TopicsCSV":      strings.Join(topics, ", "),
59
+		"SourceRemote":   sourceRemote,
5560
 		"SettingsActive": "general",
5661
 		"Notice":         settingsNoticeMessage(notice),
5762
 	})
@@ -106,6 +111,49 @@ func (h *Handlers) settingsGeneralUpdate(w http.ResponseWriter, r *http.Request)
106111
 	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/general?notice=saved", http.StatusSeeOther)
107112
 }
108113
 
114
+// settingsSourceRemoteUpdate persists the repo's public source remote and
115
+// immediately fetches heads/tags so imported histories and submodule gitlinks
116
+// can resolve without guessing where the objects live.
117
+func (h *Handlers) settingsSourceRemoteUpdate(w http.ResponseWriter, r *http.Request) {
118
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsGeneral)
119
+	if !ok {
120
+		return
121
+	}
122
+	if err := r.ParseForm(); err != nil {
123
+		http.Error(w, "form parse", http.StatusBadRequest)
124
+		return
125
+	}
126
+	rawURL := strings.TrimSpace(r.PostFormValue("source_remote_url"))
127
+	if r.PostFormValue("clear_source_remote") == "1" {
128
+		rawURL = ""
129
+	}
130
+	if rawURL == "" {
131
+		if err := h.rq.DeleteRepoSourceRemote(r.Context(), h.d.Pool, row.ID); err != nil {
132
+			h.d.Logger.WarnContext(r.Context(), "settings: delete source remote", "error", err, "repo_id", row.ID)
133
+			http.Error(w, "save failed", http.StatusInternalServerError)
134
+			return
135
+		}
136
+		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/general?notice=source-remote-cleared", http.StatusSeeOther)
137
+		return
138
+	}
139
+	remoteURL, err := h.saveRepoSourceRemote(r.Context(), row.ID, rawURL)
140
+	if err != nil {
141
+		if isInvalidSourceRemote(err) {
142
+			http.Error(w, "source remote URL must be a public http(s) git remote without credentials", http.StatusBadRequest)
143
+			return
144
+		}
145
+		h.d.Logger.WarnContext(r.Context(), "settings: save source remote", "error", err, "repo_id", row.ID)
146
+		http.Error(w, "save failed", http.StatusInternalServerError)
147
+		return
148
+	}
149
+	if err := h.fetchRepoSourceRemote(r.Context(), row, owner.Username, remoteURL); err != nil {
150
+		h.d.Logger.WarnContext(r.Context(), "settings: fetch source remote", "error", err, "repo_id", row.ID, "remote", remoteURL)
151
+		http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/general?notice=source-remote-fetch-failed", http.StatusSeeOther)
152
+		return
153
+	}
154
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/general?notice=source-remote-imported", http.StatusSeeOther)
155
+}
156
+
109157
 // settingsMergeUpdate persists allow_*_merge + default_merge_method.
110158
 func (h *Handlers) settingsMergeUpdate(w http.ResponseWriter, r *http.Request) {
111159
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsGeneral)
@@ -416,9 +464,28 @@ func settingsNoticeMessage(code string) string {
416464
 		return "Settings saved."
417465
 	case "deleted":
418466
 		return "Deleted."
467
+	case "source-remote-cleared":
468
+		return "Source remote cleared."
469
+	case "source-remote-fetch-failed":
470
+		return "Source remote saved, but fetch failed. Check the stored error and try again."
471
+	case "source-remote-imported":
472
+		return "Source remote fetched."
473
+	case "source-remote-save-failed":
474
+		return "Repository was created, but the source remote could not be saved."
419475
 	case "":
420476
 		return ""
421477
 	default:
422478
 		return ""
423479
 	}
424480
 }
481
+
482
+func (h *Handlers) repoSourceRemote(ctx context.Context, repoID int64) (reposdb.RepoSourceRemote, bool) {
483
+	sourceRemote, err := h.rq.GetRepoSourceRemote(ctx, h.d.Pool, repoID)
484
+	if err == nil {
485
+		return sourceRemote, true
486
+	}
487
+	if !errors.Is(err, pgx.ErrNoRows) && h.d.Logger != nil {
488
+		h.d.Logger.WarnContext(ctx, "settings: source remote lookup", "error", err, "repo_id", repoID)
489
+	}
490
+	return reposdb.RepoSourceRemote{}, false
491
+}
internal/web/handlers/repo/source_remote.goadded
@@ -0,0 +1,133 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strings"
9
+	"time"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/repos"
14
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/worker"
17
+)
18
+
19
+const sourceRemoteFetchTimeout = 45 * time.Second
20
+
21
+func (h *Handlers) saveRepoSourceRemote(ctx context.Context, repoID int64, rawURL string) (string, error) {
22
+	remoteURL, err := repos.ValidateSourceRemoteURL(ctx, rawURL)
23
+	if err != nil || remoteURL == "" {
24
+		return remoteURL, err
25
+	}
26
+	_, err = h.rq.UpsertRepoSourceRemote(ctx, h.d.Pool, reposdb.UpsertRepoSourceRemoteParams{
27
+		RepoID:    repoID,
28
+		RemoteUrl: remoteURL,
29
+	})
30
+	return remoteURL, err
31
+}
32
+
33
+func (h *Handlers) fetchRepoSourceRemote(ctx context.Context, row reposdb.Repo, ownerSlug, remoteURL string) error {
34
+	remoteURL, err := repos.ValidateSourceRemoteURL(ctx, remoteURL)
35
+	if err != nil {
36
+		h.markRepoSourceRemoteFetchError(ctx, row.ID, err)
37
+		return err
38
+	}
39
+	gitDir, err := h.d.RepoFS.RepoPath(ownerSlug, row.Name)
40
+	if err != nil {
41
+		h.markRepoSourceRemoteFetchError(ctx, row.ID, err)
42
+		return err
43
+	}
44
+	fetchCtx, cancel := context.WithTimeout(ctx, sourceRemoteFetchTimeout)
45
+	defer cancel()
46
+	if err := repogit.FetchRemoteHeadsAndTags(fetchCtx, gitDir, remoteURL); err != nil {
47
+		h.markRepoSourceRemoteFetchError(ctx, row.ID, err)
48
+		return err
49
+	}
50
+	if err := h.refreshFetchedRepoState(ctx, row, gitDir); err != nil {
51
+		h.markRepoSourceRemoteFetchError(ctx, row.ID, err)
52
+		return err
53
+	}
54
+	if err := h.rq.MarkRepoSourceRemoteFetched(ctx, h.d.Pool, row.ID); err != nil && h.d.Logger != nil {
55
+		h.d.Logger.WarnContext(ctx, "source-remote: mark fetched", "error", err, "repo_id", row.ID)
56
+	}
57
+	return nil
58
+}
59
+
60
+func (h *Handlers) refreshFetchedRepoState(ctx context.Context, row reposdb.Repo, gitDir string) error {
61
+	refs, err := repogit.ListRefs(ctx, gitDir)
62
+	if err != nil {
63
+		return err
64
+	}
65
+	branch, oid := chooseFetchedDefaultBranch(row.DefaultBranch, refs.Branches)
66
+	if branch == "" {
67
+		return nil
68
+	}
69
+	if branch != row.DefaultBranch {
70
+		if err := h.rq.UpdateRepoDefaultBranch(ctx, h.d.Pool, reposdb.UpdateRepoDefaultBranchParams{
71
+			ID:            row.ID,
72
+			DefaultBranch: branch,
73
+		}); err != nil {
74
+			return err
75
+		}
76
+		if err := repogit.SetSymbolicRef(ctx, gitDir, "HEAD", "refs/heads/"+branch); err != nil && h.d.Logger != nil {
77
+			h.d.Logger.WarnContext(ctx, "source-remote: set symbolic head", "error", err, "repo_id", row.ID, "branch", branch)
78
+		}
79
+	}
80
+	if !row.DefaultBranchOid.Valid || row.DefaultBranchOid.String != oid {
81
+		if err := h.rq.UpdateRepoDefaultBranchOID(ctx, h.d.Pool, reposdb.UpdateRepoDefaultBranchOIDParams{
82
+			ID:               row.ID,
83
+			DefaultBranchOid: pgtype.Text{String: oid, Valid: true},
84
+		}); err != nil {
85
+			return err
86
+		}
87
+		if _, err := worker.Enqueue(ctx, h.d.Pool, worker.KindRepoIndexCode, map[string]any{"repo_id": row.ID}, worker.EnqueueOptions{}); err != nil && h.d.Logger != nil {
88
+			h.d.Logger.WarnContext(ctx, "source-remote: enqueue index", "error", err, "repo_id", row.ID)
89
+		}
90
+	}
91
+	if _, err := worker.Enqueue(ctx, h.d.Pool, worker.KindRepoSizeRecalc, map[string]any{"repo_id": row.ID}, worker.EnqueueOptions{}); err != nil && h.d.Logger != nil {
92
+		h.d.Logger.WarnContext(ctx, "source-remote: enqueue size", "error", err, "repo_id", row.ID)
93
+	}
94
+	_ = worker.Notify(ctx, h.d.Pool)
95
+	return nil
96
+}
97
+
98
+func chooseFetchedDefaultBranch(current string, branches []repogit.RefEntry) (name, oid string) {
99
+	if len(branches) == 0 {
100
+		return "", ""
101
+	}
102
+	for _, candidate := range []string{current, "trunk", "main", "master"} {
103
+		if candidate == "" {
104
+			continue
105
+		}
106
+		for _, branch := range branches {
107
+			if branch.Name == candidate {
108
+				return branch.Name, branch.OID
109
+			}
110
+		}
111
+	}
112
+	return branches[0].Name, branches[0].OID
113
+}
114
+
115
+func (h *Handlers) markRepoSourceRemoteFetchError(ctx context.Context, repoID int64, err error) {
116
+	if err == nil {
117
+		return
118
+	}
119
+	msg := strings.TrimSpace(err.Error())
120
+	if len(msg) > 500 {
121
+		msg = msg[:500]
122
+	}
123
+	if markErr := h.rq.MarkRepoSourceRemoteFetchError(ctx, h.d.Pool, reposdb.MarkRepoSourceRemoteFetchErrorParams{
124
+		RepoID:    repoID,
125
+		LastError: pgtype.Text{String: msg, Valid: true},
126
+	}); markErr != nil && h.d.Logger != nil {
127
+		h.d.Logger.WarnContext(ctx, "source-remote: mark fetch error", "error", markErr, "cause", err, "repo_id", repoID)
128
+	}
129
+}
130
+
131
+func isInvalidSourceRemote(err error) bool {
132
+	return errors.Is(err, repos.ErrInvalidSourceRemote)
133
+}
internal/web/templates/repo/new.htmlmodified
@@ -99,6 +99,13 @@
9999
               {{ end }}
100100
             </select>
101101
           </label>
102
+          <label class="shithub-repo-new-config-row">
103
+            <span>
104
+              <strong>Import from source remote</strong>
105
+              <small>Fetch an existing public Git repository after creation.</small>
106
+            </span>
107
+            <input type="url" name="source_remote_url" placeholder="https://github.com/OWNER/REPO.git" value="{{ .Form.SourceRemote }}">
108
+          </label>
102109
         </div>
103110
       </section>
104111
     </div>
internal/web/templates/repo/settings_general.htmlmodified
@@ -34,6 +34,26 @@
3434
       </form>
3535
     </section>
3636
 
37
+    <section class="shithub-settings-section">
38
+      <h2>Source remote</h2>
39
+      <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/source-remote">
40
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
41
+        <label>
42
+          <span>Public Git remote URL</span>
43
+          <input type="url" name="source_remote_url" maxlength="2048" placeholder="https://github.com/OWNER/REPO.git" value="{{ .SourceRemote.RemoteUrl }}">
44
+          <small>Used to fetch this repository's upstream refs and to resolve pinned submodule commits.</small>
45
+        </label>
46
+        {{ if .SourceRemote.LastFetchedAt.Valid }}
47
+        <p class="shithub-muted">Last fetched <time datetime="{{ .SourceRemote.LastFetchedAt.Time.Format "2006-01-02T15:04:05Z" }}">{{ relativeTime .SourceRemote.LastFetchedAt.Time }}</time>.</p>
48
+        {{ end }}
49
+        {{ if .SourceRemote.LastError.Valid }}
50
+        <p class="shithub-flash shithub-flash-error" role="status">{{ .SourceRemote.LastError.String }}</p>
51
+        {{ end }}
52
+        <button type="submit" class="shithub-button shithub-button-primary">Save and fetch</button>
53
+        {{ if .SourceRemote.RemoteUrl }}<button type="submit" class="shithub-button" name="clear_source_remote" value="1">Clear</button>{{ end }}
54
+      </form>
55
+    </section>
56
+
3757
     <section class="shithub-settings-section">
3858
       <h2>Pull request merges</h2>
3959
       <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/merges">