Monorepo for Tangled tangled.org

appview: implement patch updates in pulls

authored by anirudh.fi and committed by oppi.li b528e487 e6689f3c

Changed files
+185 -56
appview
db
pages
templates
repo
issues
pulls
state
+5
appview/db/pulls.go
··· 254 254 255 255 return count, nil 256 256 } 257 + 258 + func EditPatch(e Execer, repoAt syntax.ATURI, pullId int, patch string) error { 259 + _, err := e.Exec(`update pulls set patch = ? where repo_at = ? and pull_id = ?`, patch, repoAt, pullId) 260 + return err 261 + }
+4 -4
appview/pages/templates/repo/issues/issue.html
··· 1 1 {{ define "title" }} 2 - {{ .Issue.Title }} &middot; 2 + {{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; 3 3 {{ .RepoInfo.FullName }} 4 4 {{ end }} 5 5 6 6 {{ define "repoContent" }} 7 - <header> 8 - <p class="text-2xl"> 7 + <header class="pb-4"> 8 + <h1 class="text-2xl"> 9 9 {{ .Issue.Title }} 10 10 <span class="text-gray-500">#{{ .Issue.IssueId }}</span> 11 - </p> 11 + </h1> 12 12 </header> 13 13 14 14 {{ $bgColor := "bg-gray-800" }}
+135 -34
appview/pages/templates/repo/pulls/pull.html
··· 4 4 {{ end }} 5 5 6 6 {{ define "repoContent" }} 7 - <h1> 8 - {{ .Pull.Title }} 9 - <span class="text-gray-400">#{{ .Pull.PullId }}</span> 10 - </h1> 7 + 8 + <header class="pb-4"> 9 + <h1 class="text-2xl"> 10 + {{ .Pull.Title }} 11 + <span class="text-gray-500">#{{ .Pull.PullId }}</span> 12 + </h1> 13 + </header> 14 + 11 15 {{ $bgColor := "bg-gray-800" }} 12 16 {{ $icon := "ban" }} 13 17 {{ if eq .State "open" }} ··· 49 53 {{ end }} 50 54 </section> 51 55 52 - <div> 56 + <div class="flex flex-col justify-end mt-4"> 53 57 <details> 54 58 <summary 55 - class="list-none cursor-pointer sticky top-0 bg-white rounded-sm px-3 py-2 border border-gray-200 flex items-center text-gray-700 hover:bg-gray-50 transition-colors" 59 + class="list-none cursor-pointer sticky top-0 bg-white rounded-sm px-3 py-2 border border-gray-200 flex items-center text-gray-700 hover:bg-gray-50 transition-colors mt-auto" 56 60 > 57 61 <i data-lucide="code" class="w-4 h-4 mr-2"></i> 58 62 <span>patch</span> 59 63 </summary> 60 - <pre class="font-mono overflow-x-scroll bg-gray-50 p-4 rounded-b border border-gray-200 text-sm"> 64 + <div class="relative"> 65 + <pre 66 + id="patch-preview" 67 + class="font-mono overflow-x-scroll bg-gray-50 p-4 rounded-b border border-gray-200 text-sm" 68 + > 61 69 {{- .Pull.Patch -}} 62 - </pre> 70 + </pre 71 + > 72 + <form 73 + id="patch-form" 74 + hx-patch="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/patch" 75 + hx-swap="none" 76 + > 77 + <textarea 78 + id="patch" 79 + name="patch" 80 + class="font-mono w-full h-full p-4 rounded-b border border-gray-200 text-sm hidden" 81 + > 82 + {{- .Pull.Patch -}}</textarea 83 + > 84 + 85 + <div class="flex gap-2 justify-end mt-2"> 86 + <button 87 + id="edit-patch-btn" 88 + type="button" 89 + class="btn btn-sm" 90 + onclick="togglePatchEdit(true)" 91 + > 92 + <i data-lucide="edit" class="w-4 h-4 mr-1"></i>Edit 93 + </button> 94 + <button 95 + id="save-patch-btn" 96 + type="submit" 97 + class="btn btn-sm bg-green-500 hidden" 98 + > 99 + <i data-lucide="save" class="w-4 h-4 mr-1"></i>Save 100 + </button> 101 + <button 102 + id="cancel-patch-btn" 103 + type="button" 104 + class="btn btn-sm bg-gray-300 hidden" 105 + onclick="togglePatchEdit(false)" 106 + > 107 + Cancel 108 + </button> 109 + </div> 110 + </form> 111 + 112 + <div id="pull-error" class="error"></div> 113 + <div id="pull-success" class="success"></div> 114 + </div> 115 + <script> 116 + function togglePatchEdit(editMode) { 117 + const preview = document.getElementById("patch-preview"); 118 + const editor = document.getElementById("patch"); 119 + const editBtn = document.getElementById("edit-patch-btn"); 120 + const saveBtn = document.getElementById("save-patch-btn"); 121 + const cancelBtn = 122 + document.getElementById("cancel-patch-btn"); 123 + 124 + if (editMode) { 125 + preview.classList.add("hidden"); 126 + editor.classList.remove("hidden"); 127 + editBtn.classList.add("hidden"); 128 + saveBtn.classList.remove("hidden"); 129 + cancelBtn.classList.remove("hidden"); 130 + } else { 131 + preview.classList.remove("hidden"); 132 + editor.classList.add("hidden"); 133 + editBtn.classList.remove("hidden"); 134 + saveBtn.classList.add("hidden"); 135 + cancelBtn.classList.add("hidden"); 136 + } 137 + } 138 + 139 + document 140 + .getElementById("save-patch-btn") 141 + .addEventListener("click", function () { 142 + togglePatchEdit(false); 143 + }); 144 + </script> 63 145 </details> 64 146 </div> 65 - 66 - <div class="mt-4"> 67 - {{ if .MergeCheck }} 68 - <div class="rounded-sm border p-4 {{ if .MergeCheck.IsConflicted }}bg-red-50 border-red-200{{ else }}bg-green-50 border-green-200{{ end }}"> 69 - <div class="flex items-center gap-2 rounded-sm {{ if .MergeCheck.IsConflicted }}text-red-500{{ else }}text-green-500 {{ end }}"> 70 - {{ if .MergeCheck.IsConflicted }} 71 - <i data-lucide="alert-triangle" class="w-4 h-4"></i> 72 - <span class="font-medium">merge conflicts detected</span> 147 + 148 + {{ if .MergeCheck }} 149 + <div class="mt-4" id="merge-check"> 150 + <div 151 + class="rounded-sm border p-4 {{ if .MergeCheck.IsConflicted }} 152 + bg-red-50 border-red-200 73 153 {{ else }} 74 - <i data-lucide="check-circle" class="w-4 h-4"></i> 75 - <span class="font-medium">ready to merge</span> 154 + bg-green-50 border-green-200 155 + {{ end }}" 156 + > 157 + <div 158 + class="flex items-center gap-2 rounded-sm {{ if .MergeCheck.IsConflicted }} 159 + text-red-500 160 + {{ else }} 161 + text-green-500 162 + {{ end }}" 163 + > 164 + {{ if .MergeCheck.IsConflicted }} 165 + <i data-lucide="alert-triangle" class="w-4 h-4"></i> 166 + <span class="font-medium" 167 + >merge conflicts detected</span 168 + > 169 + {{ else }} 170 + <i data-lucide="check-circle" class="w-4 h-4"></i> 171 + <span class="font-medium">ready to merge</span> 172 + {{ end }} 173 + </div> 174 + 175 + {{ if .MergeCheck.IsConflicted }} 176 + <div class="mt-2"> 177 + <ul class="text-sm space-y-1"> 178 + {{ range .MergeCheck.Conflicts }} 179 + <li class="flex items-center"> 180 + <i 181 + data-lucide="file-warning" 182 + class="w-3 h-3 mr-1.5 text-red-500" 183 + ></i> 184 + <span class="font-mono" 185 + >{{ slice .Filename 0 (sub (len .Filename) 2) }}</span 186 + > 187 + </li> 188 + {{ end }} 189 + </ul> 190 + </div> 76 191 {{ end }} 77 192 </div> 78 - 79 - {{ if .MergeCheck.IsConflicted }} 80 - <div class="mt-2"> 81 - <ul class="text-sm space-y-1"> 82 - {{ range .MergeCheck.Conflicts }} 83 - <li class="flex items-center"> 84 - <i data-lucide="file-warning" class="w-3 h-3 mr-1.5 text-red-500"></i> 85 - <span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span> 86 - </li> 87 - {{ end }} 88 - </ul> 89 - </div> 90 - {{ end }} 91 193 </div> 92 - {{ end }} 93 - </div> 194 + {{ end }} 94 195 {{ end }} 95 196 96 197 {{ define "repoAfter" }} ··· 134 235 </div> 135 236 </div> 136 237 {{ end }} 238 + 137 239 </section> 138 240 139 241 {{ if .LoggedInUser }} ··· 172 274 ></i> 173 275 <span class="text-black">{{ $action }}</span> 174 276 </button> 175 - <div id="pull-action" class="error"></div> 176 277 </form> 177 278 {{ end }} 178 279 {{ end }}
+36 -14
appview/state/repo.go
··· 230 230 } 231 231 } 232 232 233 - // MergeCheck gets called async, every time the patch diff is updated in a pull. 234 - func (s *State) MergeCheck(w http.ResponseWriter, r *http.Request) { 233 + func (s *State) EditPatch(w http.ResponseWriter, r *http.Request) { 235 234 user := s.auth.GetUser(r) 236 235 f, err := fullyResolvedRepo(r) 237 236 if err != nil { 238 237 log.Println("failed to get repo and knot", err) 239 - s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") 238 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 239 + return 240 + } 241 + 242 + prId := chi.URLParam(r, "pull") 243 + prIdInt, err := strconv.Atoi(prId) 244 + if err != nil { 245 + http.Error(w, "bad pr id", http.StatusBadRequest) 246 + log.Println("failed to parse pr id", err) 240 247 return 241 248 } 242 249 243 250 patch := r.FormValue("patch") 244 - targetBranch := r.FormValue("targetBranch") 251 + if patch == "" { 252 + s.pages.Notice(w, "pull-error", "Patch is required.") 253 + return 254 + } 245 255 246 - if patch == "" || targetBranch == "" { 247 - s.pages.Notice(w, "pull", "Patch and target branch are required.") 256 + err = db.EditPatch(s.db, f.RepoAt, prIdInt, patch) 257 + if err != nil { 258 + log.Println("failed to update patch", err) 259 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 248 260 return 249 261 } 250 262 263 + // Get target branch after patch update 264 + pull, _, err := db.GetPullWithComments(s.db, f.RepoAt, prIdInt) 265 + if err != nil { 266 + log.Println("failed to get pull information", err) 267 + s.pages.Notice(w, "pull-success", "Patch updated successfully.") 268 + return 269 + } 270 + 271 + targetBranch := pull.TargetBranch 272 + 273 + // Perform merge check 251 274 secret, err := db.GetRegistrationKey(s.db, f.Knot) 252 275 if err != nil { 253 276 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 254 - s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") 277 + s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 255 278 return 256 279 } 257 280 258 281 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 259 282 if err != nil { 260 283 log.Printf("failed to create signed client for %s", f.Knot) 261 - s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") 284 + s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 262 285 return 263 286 } 264 287 265 288 resp, err := ksClient.MergeCheck([]byte(patch), user.Did, f.RepoName, targetBranch) 266 289 if err != nil { 267 290 log.Println("failed to check mergeability", err) 268 - s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.") 291 + s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 269 292 return 270 293 } 271 294 272 295 respBody, err := io.ReadAll(resp.Body) 273 296 if err != nil { 274 297 log.Println("failed to read knotserver response body") 275 - s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.") 298 + s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 276 299 return 277 300 } 278 301 ··· 280 303 err = json.Unmarshal(respBody, &mergeCheckResponse) 281 304 if err != nil { 282 305 log.Println("failed to unmarshal merge check response", err) 283 - s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") 306 + s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.") 284 307 return 285 308 } 286 309 287 - // TODO: this has to return a html fragment 288 - w.Header().Set("Content-Type", "application/json") 289 - json.NewEncoder(w).Encode(mergeCheckResponse) 310 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, prIdInt)) 311 + return 290 312 } 291 313 292 314 func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
+1
appview/state/router.go
··· 64 64 r.Use(AuthMiddleware(s)) 65 65 r.Get("/new", s.NewPull) 66 66 r.Post("/new", s.NewPull) 67 + r.Patch("/{pull}/patch", s.EditPatch) 67 68 // r.Post("/{pull}/comment", s.PullComment) 68 69 // r.Post("/{pull}/close", s.ClosePull) 69 70 // r.Post("/{pull}/reopen", s.ReopenPull)
+4 -4
input.css
··· 105 105 106 106 @layer base { 107 107 html { 108 - letter-spacing: -0.01em; 109 - word-spacing: -0.07em; 110 - font-size: 14px; 108 + letter-spacing: -0.01em; 109 + word-spacing: -0.07em; 110 + font-size: 14px; 111 111 } 112 112 a { 113 113 @apply no-underline text-black hover:underline hover:text-gray-800; ··· 147 147 @apply py-1 text-red-400; 148 148 } 149 149 .success { 150 - @apply py-1 text-black; 150 + @apply py-1 text-green-400; 151 151 } 152 152 } 153 153 }