Import repos from source remotes
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
9b4cf0d52516a6526c33d9abeade32fff86bbf18- Parents
-
0b94e81 - Tree
adcad0b
9b4cf0d
9b4cf0d52516a6526c33d9abeade32fff86bbf180b94e81
adcad0b| Status | File | + | - |
|---|---|---|---|
| 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@@ -150,13 +150,14 @@ func (h *Handlers) newRepoForm(w http.ResponseWriter, r *http.Request) { | ||
| 150 | 150 | // formState mirrors the new-repo form so a re-render after a validation |
| 151 | 151 | // error can repopulate the user's input. |
| 152 | 152 | type formState struct { |
| 153 | - Owner string // "user:<id>" or "org:<id>" | |
| 154 | - Name string | |
| 155 | - Description string | |
| 156 | - Visibility string | |
| 157 | - InitReadme bool | |
| 158 | - License string | |
| 159 | - Gitignore string | |
| 153 | + Owner string // "user:<id>" or "org:<id>" | |
| 154 | + Name string | |
| 155 | + Description string | |
| 156 | + Visibility string | |
| 157 | + SourceRemote string | |
| 158 | + InitReadme bool | |
| 159 | + License string | |
| 160 | + Gitignore string | |
| 160 | 161 | } |
| 161 | 162 | |
| 162 | 163 | // ownerOption is one entry the new-repo owner picker shows. |
@@ -177,17 +178,32 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 177 | 178 | } |
| 178 | 179 | user := middleware.CurrentUserFromContext(r.Context()) |
| 179 | 180 | form := formState{ |
| 180 | - Owner: strings.TrimSpace(r.PostFormValue("owner")), | |
| 181 | - Name: repos.NormalizeName(r.PostFormValue("name")), | |
| 182 | - Description: strings.TrimSpace(r.PostFormValue("description")), | |
| 183 | - Visibility: strings.TrimSpace(r.PostFormValue("visibility")), | |
| 184 | - InitReadme: r.PostFormValue("init_readme") == "on", | |
| 185 | - License: strings.TrimSpace(r.PostFormValue("license")), | |
| 186 | - Gitignore: strings.TrimSpace(r.PostFormValue("gitignore")), | |
| 181 | + Owner: strings.TrimSpace(r.PostFormValue("owner")), | |
| 182 | + Name: repos.NormalizeName(r.PostFormValue("name")), | |
| 183 | + Description: strings.TrimSpace(r.PostFormValue("description")), | |
| 184 | + Visibility: strings.TrimSpace(r.PostFormValue("visibility")), | |
| 185 | + SourceRemote: strings.TrimSpace(r.PostFormValue("source_remote_url")), | |
| 186 | + InitReadme: r.PostFormValue("init_readme") == "on", | |
| 187 | + License: strings.TrimSpace(r.PostFormValue("license")), | |
| 188 | + Gitignore: strings.TrimSpace(r.PostFormValue("gitignore")), | |
| 187 | 189 | } |
| 188 | 190 | if form.Visibility == "" { |
| 189 | 191 | form.Visibility = "public" |
| 190 | 192 | } |
| 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 | + } | |
| 191 | 207 | |
| 192 | 208 | params := repos.Params{ |
| 193 | 209 | ActorUserID: user.ID, |
@@ -256,6 +272,21 @@ func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 256 | 272 | if ownerSlug == "" { |
| 257 | 273 | ownerSlug = params.OwnerSlug |
| 258 | 274 | } |
| 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 | + } | |
| 259 | 290 | http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name, http.StatusSeeOther) |
| 260 | 291 | } |
| 261 | 292 | |
internal/web/handlers/repo/settings_general.gomodified@@ -3,12 +3,14 @@ | ||
| 3 | 3 | package repo |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "context" | |
| 6 | 7 | "errors" |
| 7 | 8 | "net/http" |
| 8 | 9 | "strconv" |
| 9 | 10 | "strings" |
| 10 | 11 | |
| 11 | 12 | "github.com/go-chi/chi/v5" |
| 13 | + "github.com/jackc/pgx/v5" | |
| 12 | 14 | "github.com/jackc/pgx/v5/pgtype" |
| 13 | 15 | |
| 14 | 16 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
@@ -26,6 +28,7 @@ import ( | ||
| 26 | 28 | func (h *Handlers) MountSettingsGeneral(r chi.Router) { |
| 27 | 29 | r.Get("/{owner}/{repo}/settings/general", h.settingsGeneral) |
| 28 | 30 | r.Post("/{owner}/{repo}/settings/general", h.settingsGeneralUpdate) |
| 31 | + r.Post("/{owner}/{repo}/settings/source-remote", h.settingsSourceRemoteUpdate) | |
| 29 | 32 | r.Post("/{owner}/{repo}/settings/merges", h.settingsMergeUpdate) |
| 30 | 33 | r.Get("/{owner}/{repo}/settings/access", h.settingsAccess) |
| 31 | 34 | r.Post("/{owner}/{repo}/settings/access/collaborators", h.settingsCollabUpsert) |
@@ -44,6 +47,7 @@ func (h *Handlers) settingsGeneral(w http.ResponseWriter, r *http.Request) { | ||
| 44 | 47 | return |
| 45 | 48 | } |
| 46 | 49 | topics, _ := h.rq.ListRepoTopics(r.Context(), h.d.Pool, row.ID) |
| 50 | + sourceRemote, _ := h.repoSourceRemote(r.Context(), row.ID) | |
| 47 | 51 | notice := r.URL.Query().Get("notice") |
| 48 | 52 | h.d.Render.RenderPage(w, r, "repo/settings_general", map[string]any{ |
| 49 | 53 | "Title": "General · " + row.Name, |
@@ -52,6 +56,7 @@ func (h *Handlers) settingsGeneral(w http.ResponseWriter, r *http.Request) { | ||
| 52 | 56 | "Repo": row, |
| 53 | 57 | "Topics": topics, |
| 54 | 58 | "TopicsCSV": strings.Join(topics, ", "), |
| 59 | + "SourceRemote": sourceRemote, | |
| 55 | 60 | "SettingsActive": "general", |
| 56 | 61 | "Notice": settingsNoticeMessage(notice), |
| 57 | 62 | }) |
@@ -106,6 +111,49 @@ func (h *Handlers) settingsGeneralUpdate(w http.ResponseWriter, r *http.Request) | ||
| 106 | 111 | http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/general?notice=saved", http.StatusSeeOther) |
| 107 | 112 | } |
| 108 | 113 | |
| 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 | + | |
| 109 | 157 | // settingsMergeUpdate persists allow_*_merge + default_merge_method. |
| 110 | 158 | func (h *Handlers) settingsMergeUpdate(w http.ResponseWriter, r *http.Request) { |
| 111 | 159 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsGeneral) |
@@ -416,9 +464,28 @@ func settingsNoticeMessage(code string) string { | ||
| 416 | 464 | return "Settings saved." |
| 417 | 465 | case "deleted": |
| 418 | 466 | 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." | |
| 419 | 475 | case "": |
| 420 | 476 | return "" |
| 421 | 477 | default: |
| 422 | 478 | return "" |
| 423 | 479 | } |
| 424 | 480 | } |
| 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 @@ | ||
| 99 | 99 | {{ end }} |
| 100 | 100 | </select> |
| 101 | 101 | </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> | |
| 102 | 109 | </div> |
| 103 | 110 | </section> |
| 104 | 111 | </div> |
internal/web/templates/repo/settings_general.htmlmodified@@ -34,6 +34,26 @@ | ||
| 34 | 34 | </form> |
| 35 | 35 | </section> |
| 36 | 36 | |
| 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 | + | |
| 37 | 57 | <section class="shithub-settings-section"> |
| 38 | 58 | <h2>Pull request merges</h2> |
| 39 | 59 | <form method="POST" action="/{{ .Owner }}/{{ .Repo.Name }}/settings/merges"> |