Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview,knotserver: new PR create flow

Lewis: May this revision serve well! <lewis@tangled.org>

Lewis b458079b d41fb1ea

+2518 -597
+36 -47
appview/pages/pages.go
··· 1225 1225 } 1226 1226 1227 1227 type RepoNewPullParams struct { 1228 - LoggedInUser *oauth.MultiAccountUser 1229 - RepoInfo repoinfo.RepoInfo 1230 - Branches []types.Branch 1231 - Strategy string 1232 - SourceBranch string 1233 - TargetBranch string 1234 - Title string 1235 - Body string 1236 - Active string 1228 + LoggedInUser *oauth.MultiAccountUser 1229 + RepoInfo repoinfo.RepoInfo 1230 + Branches []types.Branch 1231 + SourceBranches []types.Branch 1232 + ForkBranches []types.Branch 1233 + Forks []models.Repo 1234 + Source Source 1235 + SourceBranch string 1236 + TargetBranch string 1237 + Fork string 1238 + Patch string 1239 + Title string 1240 + Body string 1241 + IsStacked bool 1242 + Comparison *types.RepoFormatPatchResponse 1243 + Diff *types.NiceDiff 1244 + PerCommitDiffs []*types.NiceDiff 1245 + DiffOpts types.DiffOpts 1246 + StackDiffOpts []types.DiffOpts 1247 + EmailToDid map[string]string 1248 + MergeCheck *types.MergeCheckResponse 1249 + StackTitles map[string]string 1250 + StackBodies map[string]string 1251 + PrefillError string 1252 + Active string 1253 + LabelDefs map[string]*models.LabelDefinition 1254 + LabelState models.LabelState 1255 + StackLabelStates map[string]models.LabelState 1237 1256 } 1238 1257 1239 1258 func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 1240 1259 params.Active = "pulls" 1241 1260 return p.executeRepo("repo/pulls/new", w, params) 1261 + } 1262 + 1263 + func (p *Pages) PullWizardHostFragment(w io.Writer, params RepoNewPullParams) error { 1264 + return p.executePlain("repo/pulls/fragments/pullWizardHost", w, params) 1265 + } 1266 + 1267 + func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error { 1268 + return p.executePlain("fragments/markdownPreview", w, body) 1242 1269 } 1243 1270 1244 1271 type RepoPullsParams struct { ··· 1333 1360 // this name is a mouthful 1334 1361 func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 1335 1362 return p.execute("repo/pulls/interdiff", w, params) 1336 - } 1337 - 1338 - type PullPatchUploadParams struct { 1339 - RepoInfo repoinfo.RepoInfo 1340 - } 1341 - 1342 - func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 1343 - return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 1344 - } 1345 - 1346 - type PullCompareBranchesParams struct { 1347 - RepoInfo repoinfo.RepoInfo 1348 - Branches []types.Branch 1349 - SourceBranch string 1350 - } 1351 - 1352 - func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 1353 - return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 1354 - } 1355 - 1356 - type PullCompareForkParams struct { 1357 - RepoInfo repoinfo.RepoInfo 1358 - Forks []models.Repo 1359 - Selected string 1360 - } 1361 - 1362 - func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 1363 - return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 1364 - } 1365 - 1366 - type PullCompareForkBranchesParams struct { 1367 - RepoInfo repoinfo.RepoInfo 1368 - SourceBranches []types.Branch 1369 - TargetBranches []types.Branch 1370 - } 1371 - 1372 - func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 1373 - return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 1374 1363 } 1375 1364 1376 1365 type PullResubmitParams struct {
+24
appview/pages/pulls_wizard.go
··· 1 + package pages 2 + 3 + import "strings" 4 + 5 + type Source string 6 + 7 + const ( 8 + SourcePatch Source = "patch" 9 + SourceBranch Source = "branch" 10 + SourceFork Source = "fork" 11 + ) 12 + 13 + func ParseSource(s string) (Source, bool) { 14 + switch strings.ToLower(s) { 15 + case string(SourcePatch): 16 + return SourcePatch, true 17 + case string(SourceFork): 18 + return SourceFork, true 19 + case string(SourceBranch): 20 + return SourceBranch, true 21 + default: 22 + return "", false 23 + } 24 + }
+9
appview/pages/templates/fragments/markdownPreview.html
··· 1 + {{ define "fragments/markdownPreview" }} 2 + {{ if . }} 3 + <div class="prose dark:prose-invert max-w-none"> 4 + {{ . | markdown }} 5 + </div> 6 + {{ else }} 7 + <div class="text-gray-400 dark:text-gray-500 italic">Nothing to preview.</div> 8 + {{ end }} 9 + {{ end }}
+1 -1
appview/pages/templates/repo/fragments/compareAllowPull.html
··· 9 9 discussed. 10 10 </p> 11 11 12 - {{ $newPullUrl := printf "/%s/pulls/new?strategy=branch&targetBranch=%s&sourceBranch=%s" .RepoInfo.FullName .Base .Head }} 12 + {{ $newPullUrl := printf "/%s/pulls/new?source=branch&sourceBranch=%s&targetBranch=%s" .RepoInfo.FullName (urlquery .Head) (urlquery .Base) }} 13 13 14 14 15 15 <div class="flex justify-start items-center gap-2 mt-2">
+4 -3
appview/pages/templates/repo/fragments/diff.html
··· 101 101 {{ define "diffLayout" }} 102 102 {{ $diff := index . 0 }} 103 103 {{ $opts := index . 1 }} 104 + {{ $padBottom := gt (len .) 2 }} 104 105 105 106 <div class="flex col-span-full flex-grow"> 106 107 <!-- left panel --> 107 - <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 108 + <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto {{ if $padBottom }}pb-12{{ end }}"> 108 109 <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 109 110 {{ template "repo/fragments/fileTree" $diff.FileTree }} 110 111 </section> ··· 113 114 {{ template "resize-grip" (list "resize-files" "files" "before") }} 114 115 115 116 <!-- main content --> 116 - <div id="diff-files" class="flex-1 min-w-0 sticky top-12 pb-12"> 117 + <div id="diff-files" class="flex-1 min-w-0 sticky top-12 {{ if $padBottom }}pb-12{{ end }}"> 117 118 {{ template "diffFiles" (list $diff $opts) }} 118 119 </div> 119 120 ··· 214 215 {{ end }} 215 216 216 217 {{ define "filesCheckbox" }} 217 - <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 218 + <input type="checkbox" id="filesToggle" class="peer/files hidden" {{ if gt (len .) 2 }}checked{{ end }}/> 218 219 {{ end }} 219 220 220 221 {{ define "filesToggle" }}
+29 -10
appview/pages/templates/repo/fragments/diffOpts.html
··· 4 4 {{ $active = "split" }} 5 5 {{ end }} 6 6 7 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 7 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm cursor-default" }} 8 8 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 + {{ $usePost := ne .RefreshUrl "" }} 10 + {{ $target := .Target }} 11 + {{ if eq $target "" }}{{ $target = "#diff-files" }}{{ end }} 12 + {{ $field := .Field }} 13 + {{ if eq $field "" }}{{ $field = "diff" }}{{ end }} 9 14 10 15 <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden" 11 - hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault(); this.querySelectorAll('button').forEach(b => { const active=b===t; b.classList.toggle('bg-white',active); b.classList.toggle('dark:bg-gray-700',active); b.classList.toggle('shadow-sm',active); b.classList.toggle('bg-gray-100',!active); b.classList.toggle('dark:bg-gray-800',!active); b.classList.toggle('shadow-inner',!active); })"> 16 + hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault(); this.querySelectorAll('button').forEach(b => { const active=b===t; b.classList.toggle('bg-white',active); b.classList.toggle('dark:bg-gray-700',active); b.classList.toggle('shadow-sm',active); b.classList.toggle('cursor-default',active); b.classList.toggle('bg-gray-100',!active); b.classList.toggle('dark:bg-gray-800',!active); b.classList.toggle('shadow-inner',!active); })"> 12 17 <button 13 - hx-get="?diff=unified" 14 - hx-target="#diff-files" 15 - hx-select="#diff-files" 18 + {{ if $usePost }} 19 + hx-post="{{ .RefreshUrl }}" 20 + hx-vals='{"{{ $field }}":"unified"}' 21 + hx-include="closest form" 22 + {{ else }} 23 + hx-get="?{{ $field }}=unified" 24 + hx-push-url="true" 25 + {{ end }} 26 + hx-target="{{ $target }}" 27 + hx-select="{{ $target }}" 16 28 hx-swap="outerHTML" 17 - hx-push-url="true" 29 + hx-indicator="this" 18 30 class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "unified" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 19 31 {{ i "square-split-vertical" "size-4 inline group-[.htmx-request]:hidden" }} 20 32 {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 33 unified 22 34 </button> 23 35 <button 24 - hx-get="?diff=split" 25 - hx-target="#diff-files" 26 - hx-select="#diff-files" 36 + {{ if $usePost }} 37 + hx-post="{{ .RefreshUrl }}" 38 + hx-vals='{"{{ $field }}":"split"}' 39 + hx-include="closest form" 40 + {{ else }} 41 + hx-get="?{{ $field }}=split" 42 + hx-push-url="true" 43 + {{ end }} 44 + hx-target="{{ $target }}" 45 + hx-select="{{ $target }}" 27 46 hx-swap="outerHTML" 28 - hx-push-url="true" 47 + hx-indicator="this" 29 48 class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "split" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 30 49 {{ i "square-split-horizontal" "size-4 inline group-[.htmx-request]:hidden" }} 31 50 {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }}
+96 -49
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 20 20 {{ $defs := .Defs }} 21 21 {{ $subject := .Subject }} 22 22 {{ $state := .State }} 23 + {{ $prefix := .Prefix }} 23 24 {{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }} 24 - <div> 25 - {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} 25 + {{ $hasNullDefs := false }} 26 + {{ range $k, $d := $defs }} 27 + {{ if $d.ValueType.IsNull }}{{ $hasNullDefs = true }}{{ end }} 28 + {{ end }} 29 + {{ if or $hasNullDefs (not $defs) }} 30 + <div> 31 + {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} 26 32 27 - <div class="flex gap-1 items-center flex-wrap"> 28 - {{ range $k, $d := $defs }} 29 - {{ $isChecked := $state.ContainsLabel $k }} 30 - {{ if $d.ValueType.IsNull }} 31 - {{ $fieldName := $d.AtUri }} 32 - <label class="{{$labelStyle}}"> 33 - <input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}> 34 - {{ template "labels/fragments/labelDef" $d }} 35 - </label> 33 + <div class="flex flex-col gap-1 items-start"> 34 + {{ range $k, $d := $defs }} 35 + {{ $isChecked := $state.ContainsLabel $k }} 36 + {{ if $d.ValueType.IsNull }} 37 + {{ $fieldName := $d.AtUri.String }} 38 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $d.AtUri }}{{ end }} 39 + <label class="{{$labelStyle}}"> 40 + <input type="checkbox" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}> 41 + {{ template "labels/fragments/labelDef" $d }} 42 + </label> 43 + {{ end }} 44 + {{ else }} 45 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1"> 46 + No labels defined yet. You can choose default labels or define custom 47 + labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>. 48 + </p> 36 49 {{ end }} 37 - {{ else }} 38 - <p class="text-gray-500 dark:text-gray-400 text-sm py-1"> 39 - No labels defined yet. You can choose default labels or define custom 40 - labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>. 41 - </p> 42 - {{ end }} 50 + </div> 43 51 </div> 44 - </div> 52 + {{ end }} 45 53 {{ end }} 46 54 47 55 {{ define "editKvLabels" }} 48 56 {{ $defs := .Defs }} 49 57 {{ $subject := .Subject }} 50 58 {{ $state := .State }} 59 + {{ $prefix := .Prefix }} 60 + {{ $groupSuffix := "" }} 61 + {{ if $prefix }}{{ $groupSuffix = printf "-%s" $prefix }}{{ end }} 51 62 {{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }} 52 63 53 64 {{ range $k, $d := $defs }} 54 65 {{ if (not $d.ValueType.IsNull) }} 55 - {{ $fieldName := $d.AtUri }} 66 + {{ $fieldName := $d.AtUri.String }} 67 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $d.AtUri }}{{ end }} 56 68 {{ $valset := $state.GetValSet $k }} 57 - <div id="label-{{$d.Id}}" class="flex flex-col gap-1"> 58 - {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} 69 + <div id="label-{{$d.Id}}{{ $groupSuffix }}" class="flex flex-col gap-1"> 70 + {{ if not $d.ValueType.IsBool }} 71 + {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} 72 + {{ end }} 59 73 {{ if (and $d.Multiple $d.ValueType.IsEnum) }} 60 74 <!-- checkbox --> 61 75 {{ range $variant := $d.ValueType.Enum }} ··· 67 81 {{ else if $d.Multiple }} 68 82 <!-- dynamically growing input fields --> 69 83 {{ range $v, $s := $valset }} 70 - {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }} 84 + {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k "prefix" $prefix) }} 71 85 {{ else }} 72 - {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }} 86 + {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k "prefix" $prefix) }} 73 87 {{ end }} 74 - {{ template "addFieldButton" $d }} 88 + {{ template "addFieldButton" (dict "def" $d "prefix" $prefix "groupSuffix" $groupSuffix) }} 89 + {{ if and $.LoggedInUser $d.ValueType.IsString $d.ValueType.IsDidFormat }} 90 + {{ template "assignToMeButton" (dict "def" $d "user" $.LoggedInUser "groupSuffix" $groupSuffix) }} 91 + {{ end }} 75 92 {{ else if $d.ValueType.IsEnum }} 76 93 <!-- radio buttons --> 77 94 {{ $isUsed := $state.ContainsLabel $k }} ··· 88 105 {{ else }} 89 106 <!-- single input field based on value type --> 90 107 {{ range $v, $s := $valset }} 91 - {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }} 108 + {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k "prefix" $prefix) }} 92 109 {{ else }} 93 - {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }} 110 + {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k "prefix" $prefix) }} 94 111 {{ end }} 95 112 {{ end }} 96 113 </div> ··· 100 117 101 118 {{ define "multipleInputField" }} 102 119 <div class="flex gap-1 items-stretch"> 103 - {{ template "valueTypeInput" . }} 120 + <div class="flex-1 min-w-0"> 121 + {{ template "valueTypeInput" . }} 122 + </div> 104 123 {{ template "removeFieldButton" }} 105 124 </div> 106 125 {{ end }} 107 126 127 + {{ define "assignToMeButton" }} 128 + {{ $def := .def }} 129 + {{ $user := .user }} 130 + {{ $groupSuffix := .groupSuffix }} 131 + {{ $handle := trimPrefix (resolve $user.Did) "@" }} 132 + <button type="button" 133 + data-assign-to-me="{{ $def.Id }}{{ $groupSuffix }}" 134 + data-handle="{{ $handle }}" 135 + onclick="(() => { 136 + const group = document.getElementById('label-' + this.dataset.assignToMe); 137 + const handle = this.dataset.handle; 138 + const inputs = group.querySelectorAll('input[type=text]'); 139 + const empty = Array.from(inputs).find(i => !i.value.trim()); 140 + if (empty) { empty.value = handle; return; } 141 + if (Array.from(inputs).some(i => i.value.trim() === handle)) return; 142 + const tpl = document.getElementById('tpl-' + this.dataset.assignToMe); 143 + if (!tpl) return; 144 + const addBtn = tpl.nextElementSibling; 145 + addBtn.insertAdjacentHTML('beforebegin', tpl.innerHTML); 146 + const newInput = addBtn.previousElementSibling.querySelector('input[type=text]'); 147 + if (newInput) newInput.value = handle; 148 + })()" 149 + class="text-xs text-gray-500 dark:text-gray-400 hover:underline self-end"> 150 + assign to me 151 + </button> 152 + {{ end }} 153 + 108 154 {{ define "addFieldButton" }} 109 - <div style="display:none" id="tpl-{{ .Id }}"> 110 - {{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }} 155 + {{ $def := .def }} 156 + {{ $prefix := .prefix }} 157 + {{ $groupSuffix := .groupSuffix }} 158 + <div style="display:none" id="tpl-{{ $def.Id }}{{ $groupSuffix }}"> 159 + {{ template "multipleInputField" (dict "def" $def "value" "" "key" $def.AtUri.String "prefix" $prefix) }} 111 160 </div> 112 - <button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2"> 161 + <button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ $def.Id }}{{ $groupSuffix }}').innerHTML)" class="w-full btn flex items-center gap-2"> 113 162 {{ i "plus" "size-4" }} add 114 163 </button> 115 164 {{ end }} ··· 139 188 140 189 {{ define "boolTypeInput" }} 141 190 {{ $def := .def }} 142 - {{ $fieldName := $def.AtUri }} 191 + {{ $prefix := .prefix }} 192 + {{ $fieldName := $def.AtUri.String }} 193 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $def.AtUri }}{{ end }} 143 194 {{ $value := .value }} 144 - {{ $labelStyle = "font-normal normal-case flex items-center gap-2" }} 145 - <div class="flex flex-col gap-1"> 146 - <label class="{{$labelStyle}}"> 147 - <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 148 - None 149 - </label> 150 - <label class="{{$labelStyle}}"> 151 - <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 152 - None 153 - </label> 154 - <label class="{{$labelStyle}}"> 155 - <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 156 - None 157 - </label> 158 - </div> 195 + {{ $isOn := eq $value "true" }} 196 + <label class="font-normal normal-case flex items-center gap-2"> 197 + <input type="checkbox" name="{{ $fieldName }}" value="true" {{ if $isOn }}checked{{ end }}> 198 + {{ $def.Name }} 199 + </label> 159 200 {{ end }} 160 201 161 202 {{ define "intTypeInput" }} 162 203 {{ $def := .def }} 163 - {{ $fieldName := $def.AtUri }} 204 + {{ $prefix := .prefix }} 205 + {{ $fieldName := $def.AtUri.String }} 206 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $def.AtUri }}{{ end }} 164 207 {{ $value := .value }} 165 208 <input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}"> 166 209 {{ end }} 167 210 168 211 {{ define "stringTypeInput" }} 169 212 {{ $def := .def }} 170 - {{ $fieldName := $def.AtUri }} 213 + {{ $prefix := .prefix }} 214 + {{ $fieldName := $def.AtUri.String }} 215 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $def.AtUri }}{{ end }} 171 216 {{ $valueType := $def.ValueType }} 172 217 {{ $value := .value }} 173 218 ··· 192 237 193 238 {{ define "nullTypeInput" }} 194 239 {{ $def := .def }} 195 - {{ $fieldName := $def.AtUri }} 240 + {{ $prefix := .prefix }} 241 + {{ $fieldName := $def.AtUri.String }} 242 + {{ if $prefix }}{{ $fieldName = printf "%s[%s]" $prefix $def.AtUri }}{{ end }} 196 243 <input class="p-1" type="hidden" name="{{$fieldName}}" value="null"> 197 244 {{ end }} 198 245
+23 -11
appview/pages/templates/repo/fragments/fileTree.html
··· 1 1 {{ define "repo/fragments/fileTree" }} 2 + {{ template "fileTreeImpl" (dict "Node" . "Prefix" "file-") }} 3 + {{ end }} 4 + 5 + {{ define "repo/fragments/fileTreePrefixed" }} 6 + {{ $prefix := .Prefix }} 7 + {{ if eq $prefix "" }}{{ $prefix = "file-" }}{{ end }} 8 + {{ template "fileTreeImpl" (dict "Node" .Tree "Prefix" $prefix) }} 9 + {{ end }} 10 + 11 + {{ define "fileTreeImpl" }} 12 + {{ $n := .Node }} 13 + {{ $prefix := .Prefix }} 2 14 {{/* tailwind safelist: 3 15 group/level-1 group/level-2 group/level-3 group/level-4 group/level-5 group/level-6 4 16 group/level-7 group/level-8 group/level-9 group/level-10 group/level-11 group/level-12 ··· 7 19 group-open/level-1:block group-open/level-2:block group-open/level-3:block group-open/level-4:block group-open/level-5:block group-open/level-6:block 8 20 group-open/level-7:block group-open/level-8:block group-open/level-9:block group-open/level-10:block group-open/level-11:block group-open/level-12:block 9 21 */}} 10 - {{ if and .Name .IsDirectory }} 11 - <details open class="group/level-{{ .Level }}"> 22 + {{ if and $n.Name $n.IsDirectory }} 23 + <details open class="group/level-{{ $n.Level }}"> 12 24 <summary class="cursor-pointer list-none pt-1"> 13 25 <span class="tree-directory inline-flex items-center gap-2"> 14 - {{ i "folder" (printf "flex-shrink-0 size-4 group-open/level-%d:hidden" .Level)}} 15 - {{ i "folder-open" (printf "flex-shrink-0 size-4 hidden group-open/level-%d:block" .Level)}} 16 - <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 26 + {{ i "folder" (printf "flex-shrink-0 size-4 group-open/level-%d:hidden" $n.Level)}} 27 + {{ i "folder-open" (printf "flex-shrink-0 size-4 hidden group-open/level-%d:block" $n.Level)}} 28 + <span class="filename truncate text-black dark:text-white">{{ $n.Name }}</span> 17 29 </span> 18 30 </summary> 19 31 <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> 20 - {{ range $child := .Children }} 21 - {{ template "repo/fragments/fileTree" $child }} 32 + {{ range $child := $n.Children }} 33 + {{ template "fileTreeImpl" (dict "Node" $child "Prefix" $prefix) }} 22 34 {{ end }} 23 35 </div> 24 36 </details> 25 - {{ else if .Name }} 37 + {{ else if $n.Name }} 26 38 <div class="tree-file flex items-center gap-2 pt-1"> 27 39 {{ i "file" "flex-shrink-0 size-4" }} 28 - <a href="#file-{{ .Path }}" data-path="{{ .Path }}" class="filetree-link filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 40 + <a href="#{{ $prefix }}{{ $n.Path }}" data-path="{{ $n.Path }}" class="filetree-link filename truncate text-black dark:text-white no-underline hover:underline">{{ $n.Name }}</a> 29 41 </div> 30 42 {{ else }} 31 - {{ range $child := .Children }} 32 - {{ template "repo/fragments/fileTree" $child }} 43 + {{ range $child := $n.Children }} 44 + {{ template "fileTreeImpl" (dict "Node" $child "Prefix" $prefix) }} 33 45 {{ end }} 34 46 {{ end }} 35 47 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
··· 1 1 {{ define "repo/fragments/labelSectionHeaderText" }} 2 - <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 2 + <span class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">{{ . }}</span> 3 3 {{ end }}
+37 -39
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
··· 1 1 {{ define "repo/pulls/fragments/pullCompareBranches" }} 2 - <div id="patch-upload"> 3 - <label for="targetBranch" class="dark:text-white">select a source branch</label> 4 - <div class="flex flex-wrap gap-2 items-center"> 5 - <select 6 - name="sourceBranch" 7 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 8 - > 9 - <option disabled selected>source branch</option> 2 + <div id="patch-upload" class="flex flex-wrap gap-2 items-center"> 3 + <select 4 + id="sourceBranch" 5 + name="sourceBranch" 6 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 7 + hx-include="closest form" 8 + hx-target="#wizard-host" 9 + hx-swap="outerHTML" 10 + hx-trigger="change" 11 + hx-indicator="this" 12 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 13 + > 14 + <option disabled {{ if not .SourceBranch }}selected{{ end }}>Source branch</option> 10 15 11 - {{ $recent := index .Branches 0 }} 12 - {{ range .Branches }} 13 - {{ $isRecent := eq .Reference.Name $recent.Reference.Name }} 14 - {{ $preset := false }} 15 - {{ if $.SourceBranch }} 16 - {{ $preset = eq .Reference.Name $.SourceBranch }} 17 - {{ else }} 18 - {{ $preset = $isRecent }} 19 - {{ end }} 20 - 21 - <option 22 - value="{{ .Reference.Name }}" 23 - {{ if $preset }} 24 - selected 25 - {{ end }} 26 - class="py-1" 27 - > 28 - {{ .Reference.Name }} 29 - {{ if $isRecent }}(new){{ end }} 30 - </option> 16 + {{ $recent := "" }} 17 + {{ if .SourceBranches }} 18 + {{ $recent = (index .SourceBranches 0).Reference.Name }} 19 + {{ end }} 20 + {{ range .SourceBranches }} 21 + {{ $isRecent := eq .Reference.Name $recent }} 22 + {{ $preset := false }} 23 + {{ if $.SourceBranch }} 24 + {{ $preset = eq .Reference.Name $.SourceBranch }} 25 + {{ else }} 26 + {{ $preset = $isRecent }} 31 27 {{ end }} 32 - </select> 33 - </div> 34 - </div> 35 28 36 - <div class="flex items-center gap-2"> 37 - <input type="checkbox" id="isStacked" name="isStacked" value="on"> 38 - <label for="isStacked" class="my-0 py-0 normal-case font-normal">Submit as stacked PRs</label> 29 + <option 30 + value="{{ .Reference.Name }}" 31 + {{ if $preset }}selected{{ end }} 32 + {{ if and $.TargetBranch (eq .Reference.Name $.TargetBranch) }}disabled{{ end }} 33 + class="py-1" 34 + > 35 + {{ .Reference.Name }} 36 + {{ if $isRecent }}(new){{ end }} 37 + {{ if and $.TargetBranch (eq .Reference.Name $.TargetBranch) }}(target){{ end }} 38 + </option> 39 + {{ end }} 40 + </select> 41 + {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 39 42 </div> 40 - 41 - <p class="mt-4"> 42 - Title and description are optional; if left out, they will be extracted 43 - from the first commit. 44 - </p> 45 43 {{ end }}
+29 -45
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 1 1 {{ define "repo/pulls/fragments/pullCompareForks" }} 2 - <div id="patch-upload"> 3 - <label for="forkSelect" class="dark:text-white" 4 - >select a fork to compare</label 5 - > 6 - selected: {{ .Selected }} 7 - <div class="flex flex-wrap gap-4 items-center"> 8 - <div class="flex flex-wrap gap-2 items-center"> 9 - <select 10 - id="forkSelect" 11 - name="fork" 12 - required 13 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 14 - hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches" 15 - hx-target="#branch-selection" 16 - hx-vals='{"fork": this.value}' 17 - hx-swap="innerHTML" 18 - onchange="document.getElementById('hiddenForkInput').value = this.value;" 19 - > 20 - <option disabled selected>select a fork</option> 21 - {{ range .Forks }} 22 - <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Did | resolve }}/{{ .Name }} 24 - </option> 25 - {{ end }} 26 - </select> 27 - 28 - <input 29 - type="hidden" 30 - id="hiddenForkInput" 31 - name="fork" 32 - value="" 33 - /> 34 - </div> 2 + <div id="patch-upload" class="flex flex-wrap items-center gap-2"> 3 + <div class="flex flex-wrap gap-2 items-center"> 4 + <select 5 + id="forkSelect" 6 + name="fork" 7 + required 8 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 9 + hx-post="/{{ $.RepoInfo.FullName }}/pulls/new/refresh" 10 + hx-include="closest form" 11 + hx-target="#wizard-host" 12 + hx-swap="outerHTML" 13 + hx-trigger="change" 14 + hx-indicator="this" 15 + > 16 + <option disabled {{ if not $.Fork }}selected{{ end }}>Select a fork</option> 17 + {{ range .Forks }} 18 + {{ $ident := printf "%s/%s" .Did .Name }} 19 + <option value="{{ $ident }}" {{ if eq $ident $.Fork }}selected{{ end }} class="py-1"> 20 + {{ .Did | resolve }}/{{ .Name }} 21 + </option> 22 + {{ end }} 23 + </select> 24 + {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 25 + </div> 35 26 36 - <div id="branch-selection"> 27 + <div id="branch-selection"> 28 + {{ if and .Fork .ForkBranches }} 29 + {{ template "repo/pulls/fragments/pullCompareForksBranches" (dict "SourceBranches" .ForkBranches "SourceBranch" .SourceBranch "RepoInfo" .RepoInfo) }} 30 + {{ else }} 37 31 <div class="text-sm text-gray-500 dark:text-gray-400"> 38 32 Select a fork first to view available branches 39 33 </div> 40 - </div> 34 + {{ end }} 41 35 </div> 42 36 </div> 43 - 44 - <div class="flex items-center gap-2"> 45 - <input type="checkbox" id="isStacked" name="isStacked" value="on"> 46 - <label for="isStacked" class="my-0 py-0 normal-case font-normal">Submit as stacked PRs</label> 47 - </div> 48 - 49 - <p class="mt-4"> 50 - Title and description are optional; if left out, they will be extracted 51 - from the first commit. 52 - </p> 53 37 {{ end }}
+21 -7
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
··· 2 2 <div class="flex flex-wrap gap-2 items-center"> 3 3 <select 4 4 name="sourceBranch" 5 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 5 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 6 + hx-include="closest form" 7 + hx-target="#wizard-host" 8 + hx-swap="outerHTML" 9 + hx-trigger="change" 10 + hx-indicator="this" 11 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 6 12 > 7 - <option disabled selected>source branch</option> 13 + <option disabled {{ if not .SourceBranch }}selected{{ end }}>Source branch</option> 8 14 9 - {{ $recent := index .SourceBranches 0 }} 15 + {{ $recent := "" }} 16 + {{ if .SourceBranches }} 17 + {{ $recent = (index .SourceBranches 0).Reference.Name }} 18 + {{ end }} 10 19 {{ range .SourceBranches }} 11 - {{ $isRecent := eq .Reference.Name $recent.Reference.Name }} 20 + {{ $isRecent := eq .Reference.Name $recent }} 21 + {{ $preset := false }} 22 + {{ if $.SourceBranch }} 23 + {{ $preset = eq .Reference.Name $.SourceBranch }} 24 + {{ else }} 25 + {{ $preset = $isRecent }} 26 + {{ end }} 12 27 <option 13 28 value="{{ .Reference.Name }}" 14 - {{ if $isRecent }} 15 - selected 16 - {{ end }} 29 + {{ if $preset }}selected{{ end }} 17 30 class="py-1" 18 31 > 19 32 {{ .Reference.Name }} ··· 21 34 </option> 22 35 {{ end }} 23 36 </select> 37 + {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 24 38 </div> 25 39 {{ end }}
+16 -8
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
··· 1 1 {{ define "repo/pulls/fragments/pullPatchUpload" }} 2 2 <div id="patch-upload"> 3 - <p> 4 - You can paste a <code>git diff</code> or a 5 - <code>git format-patch</code> patch series here. 6 - </p> 3 + <div class="flex items-center justify-between gap-2"> 4 + <p class="my-0"> 5 + You can paste a <code>git diff</code> or a 6 + <code>git format-patch</code> patch series here. 7 + </p> 8 + <span id="patch-spinner" class="group"> 9 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 10 + </span> 11 + </div> 7 12 <textarea 8 - hx-trigger="keyup changed delay:500ms, paste delay:500ms" 9 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch" 10 - hx-swap="none" 13 + hx-trigger="paste delay:100ms, change" 14 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 15 + hx-include="closest form" 16 + hx-target="#wizard-host" 17 + hx-swap="outerHTML" 18 + hx-indicator="#patch-spinner" 11 19 name="patch" 12 20 id="patch" 13 21 rows="12" ··· 16 24 index 1234567..abcdefg 100644 17 25 --- a/file.txt 18 26 +++ b/file.txt" 19 - ></textarea> 27 + >{{ .Patch }}</textarea> 20 28 </div> 21 29 {{ end }}
+146
appview/pages/templates/repo/pulls/fragments/pullStepDetails.html
··· 1 + {{ define "repo/pulls/fragments/pullStepDetails" }} 2 + {{ $hasSidePanel := and .LabelDefs .RepoInfo.Roles.IsPushAllowed }} 3 + {{ $previewUrl := printf "/%s/pulls/new/preview" .RepoInfo.FullName }} 4 + {{ $labelCtx := dict "Defs" .LabelDefs "State" .LabelState "RepoInfo" .RepoInfo "Subject" "" "LoggedInUser" .LoggedInUser }} 5 + 6 + <section class="flex flex-col md:flex-row gap-6"> 7 + <div class="flex-1 min-w-0 flex flex-col gap-4"> 8 + {{ template "pullStepDetailsSingle" (dict "Root" . "PreviewUrl" $previewUrl) }} 9 + {{ template "pullSubmitRow" . }} 10 + </div> 11 + 12 + {{ if $hasSidePanel }} 13 + <aside class="w-full md:w-72 md:flex-shrink-0 flex flex-col gap-6"> 14 + {{ template "editBasicLabels" $labelCtx }} 15 + {{ template "editKvLabels" $labelCtx }} 16 + </aside> 17 + {{ end }} 18 + </section> 19 + 20 + {{ template "markdownEditorScript" }} 21 + {{ end }} 22 + 23 + {{ define "pullStepDetailsSingle" }} 24 + {{ $root := .Root }} 25 + {{ $previewUrl := .PreviewUrl }} 26 + <div class="flex flex-col gap-1"> 27 + <label for="title" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">title</label> 28 + <input 29 + type="text" 30 + name="title" 31 + id="title" 32 + value="{{ $root.Title }}" 33 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 34 + placeholder="One-line summary of your change." 35 + /> 36 + </div> 37 + 38 + {{ template "markdownEditor" (dict 39 + "Id" "pull-body" 40 + "Name" "body" 41 + "Value" $root.Body 42 + "Rows" 6 43 + "Placeholder" "Describe your change. Markdown is supported." 44 + "PreviewUrl" $previewUrl 45 + ) }} 46 + {{ end }} 47 + 48 + {{ define "markdownEditor" }} 49 + {{ $id := .Id }} 50 + {{ $name := .Name }} 51 + {{ $value := .Value }} 52 + {{ $rows := .Rows }} 53 + {{ $placeholder := .Placeholder }} 54 + {{ $previewUrl := .PreviewUrl }} 55 + <div class="flex flex-col gap-2" data-md-editor="{{ $id }}"> 56 + {{ $tabClasses := "group flex items-center gap-2 px-3 py-1.5 text-sm whitespace-nowrap hover:no-underline data-[active=true]:bg-white data-[active=true]:dark:bg-gray-700 data-[active=true]:shadow-sm data-[active=true]:cursor-default data-[active=false]:bg-gray-100 data-[active=false]:dark:bg-gray-800 data-[active=false]:shadow-inner" }} 57 + <div class="flex divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden self-start"> 58 + <button type="button" data-md-mode="write" 59 + data-active="true" 60 + class="{{ $tabClasses }}"> 61 + {{ i "pencil" "w-3.5 h-3.5 inline group-[.htmx-request]:hidden" }} 62 + Write 63 + </button> 64 + <button type="button" data-md-mode="preview" 65 + data-active="false" 66 + hx-post="{{ $previewUrl }}" 67 + hx-vals='js:{body: document.querySelector("[data-md-editor=\"{{ $id }}\"] textarea").value}' 68 + hx-params="body" 69 + hx-target="[data-md-editor='{{ $id }}'] [data-md-preview]" 70 + hx-swap="innerHTML" 71 + hx-indicator="this" 72 + class="{{ $tabClasses }}"> 73 + {{ i "eye" "w-3.5 h-3.5 inline group-[.htmx-request]:hidden" }} 74 + {{ i "loader-circle" "w-3.5 h-3.5 animate-spin hidden group-[.htmx-request]:inline" }} 75 + Preview 76 + </button> 77 + </div> 78 + <div data-md-panel="write"> 79 + <textarea 80 + id="{{ $id }}" 81 + name="{{ $name }}" 82 + rows="{{ $rows }}" 83 + class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 84 + placeholder="{{ $placeholder }}" 85 + >{{ $value }}</textarea> 86 + </div> 87 + <div data-md-panel="preview" class="hidden"> 88 + <div data-md-preview class="min-h-[6rem] p-3 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900/30"> 89 + <span class="text-gray-400 dark:text-gray-500 italic">Loading preview...</span> 90 + </div> 91 + </div> 92 + </div> 93 + {{ end }} 94 + 95 + {{ define "pullSubmitRow" }} 96 + <div class="flex items-center justify-end gap-4 mt-auto"> 97 + {{ if and .MergeCheck .MergeCheck.IsConflicted }} 98 + <div class="flex items-center gap-2 text-sm"> 99 + <span class="inline-flex items-center gap-1 text-red-600 dark:text-red-400"> 100 + {{ i "x" "w-4 h-4" }} 101 + Can't automatically merge 102 + </span> 103 + <span class="text-gray-500 dark:text-gray-400">You can still create the pull request</span> 104 + </div> 105 + {{ else if and .MergeCheck .MergeCheck.Error }} 106 + <div class="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"> 107 + {{ i "triangle-alert" "w-4 h-4" }} 108 + Merge check failed 109 + </div> 110 + {{ end }} 111 + 112 + <button 113 + type="submit" 114 + class="btn-create flex items-center gap-2" 115 + hx-indicator="#create-pull-spinner" 116 + > 117 + {{ i "git-pull-request-create" "w-4 h-4" }} 118 + Create pull request 119 + <span id="create-pull-spinner" class="group"> 120 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 121 + </span> 122 + </button> 123 + </div> 124 + {{ end }} 125 + 126 + {{ define "markdownEditorScript" }} 127 + <script> 128 + (() => { 129 + if (window.__mdEditorWired) return; 130 + window.__mdEditorWired = true; 131 + document.body.addEventListener('click', (e) => { 132 + const btn = e.target.closest('[data-md-mode]'); 133 + if (!btn) return; 134 + const editor = btn.closest('[data-md-editor]'); 135 + if (!editor) return; 136 + const mode = btn.dataset.mdMode; 137 + editor.querySelectorAll('[data-md-panel]').forEach(p => { 138 + p.classList.toggle('hidden', p.dataset.mdPanel !== mode); 139 + }); 140 + editor.querySelectorAll('[data-md-mode]').forEach(b => { 141 + b.dataset.active = (b === btn) ? 'true' : 'false'; 142 + }); 143 + }); 144 + })(); 145 + </script> 146 + {{ end }}
+435
appview/pages/templates/repo/pulls/fragments/pullStepReview.html
··· 1 + {{ define "repo/pulls/fragments/pullStepReview" }} 2 + <section class="flex flex-col gap-3"> 3 + {{ if not .Comparison }} 4 + <div class="p-4 border border-yellow-200 dark:border-yellow-800 rounded bg-yellow-50 dark:bg-yellow-900/30 text-sm"> 5 + {{ if eq .Source "patch" }} 6 + Paste a patch above to see a comparison. 7 + {{ else }} 8 + Pick a source and target above to see a comparison. 9 + {{ end }} 10 + </div> 11 + {{ else }} 12 + {{ $commits := .Comparison.FormatPatch }} 13 + {{ if $commits }} 14 + <div class="flex flex-col gap-2"> 15 + <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 16 + <span class="flex-shrink-0">{{ len $commits }} commit{{ if ne (len $commits) 1 }}s{{ end }}</span> 17 + {{ if and .SourceBranch .TargetBranch }} 18 + <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 19 + <span>{{ .TargetBranch }}</span> 20 + {{ i "arrow-left-right" "w-4 h-4 flex-shrink-0" }} 21 + <span>{{ .SourceBranch }}</span> 22 + </span> 23 + {{ end }} 24 + </div> 25 + {{ if .IsStacked }} 26 + {{ template "pullReviewStackedCommits" . }} 27 + {{ else }} 28 + {{ template "pullReviewFlatCommits" . }} 29 + {{ end }} 30 + </div> 31 + {{ else if ne .Source "patch" }} 32 + <div class="p-4 border border-yellow-200 dark:border-yellow-800 rounded bg-yellow-50 dark:bg-yellow-900/30 text-sm"> 33 + {{ if and .SourceBranch .TargetBranch (eq .SourceBranch .TargetBranch) }} 34 + Source and target are the same branch, nothing to merge. 35 + {{ else }} 36 + No commits between target and source. Make sure your source branch has commits not on the target. 37 + {{ end }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ if and .Diff (not .IsStacked) }} 42 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 43 + {{ end }} 44 + 45 + {{ if and .IsStacked $commits }} 46 + {{ template "pullSubmitRow" . }} 47 + {{ template "pullStackApplyAllScript" }} 48 + {{ end }} 49 + {{ end }} 50 + </section> 51 + {{ end }} 52 + 53 + {{ define "pullStackApplyAllScript" }} 54 + <script> 55 + (() => { 56 + if (window.__pullStackApplyAllWired) return; 57 + window.__pullStackApplyAllWired = true; 58 + 59 + const rowSelector = ':scope > .flex.gap-1.items-stretch'; 60 + 61 + const syncRowCount = (srcGroup, tgtGroup) => { 62 + const tpl = tgtGroup.querySelector(':scope > [id^="tpl-"]'); 63 + if (!tpl) return; 64 + const addBtn = tpl.nextElementSibling; 65 + if (!addBtn) return; 66 + const desired = srcGroup.querySelectorAll(rowSelector).length; 67 + let rows = tgtGroup.querySelectorAll(rowSelector); 68 + while (rows.length < desired) { 69 + addBtn.insertAdjacentHTML('beforebegin', tpl.innerHTML); 70 + rows = tgtGroup.querySelectorAll(rowSelector); 71 + } 72 + while (rows.length > desired) { 73 + rows[rows.length - 1].remove(); 74 + rows = tgtGroup.querySelectorAll(rowSelector); 75 + } 76 + }; 77 + 78 + document.body.addEventListener('click', (e) => { 79 + const btn = e.target.closest('[data-stack-apply-all]'); 80 + if (!btn) return; 81 + const source = btn.closest('[data-stack-labels]'); 82 + if (!source) return; 83 + e.preventDefault(); 84 + 85 + const targets = Array.from(document.querySelectorAll('[data-stack-labels]')) 86 + .filter(t => t !== source); 87 + 88 + const srcGroups = source.querySelectorAll('[id^="label-"]'); 89 + targets.forEach(target => { 90 + const tgtGroups = target.querySelectorAll('[id^="label-"]'); 91 + srcGroups.forEach((srcGroup, idx) => { 92 + const tgtGroup = tgtGroups[idx]; 93 + if (tgtGroup) syncRowCount(srcGroup, tgtGroup); 94 + }); 95 + }); 96 + 97 + const sourceInputs = source.querySelectorAll('input, select, textarea'); 98 + const sourceState = Array.from(sourceInputs).map(i => ({ 99 + checked: i.checked, 100 + value: i.value, 101 + })); 102 + targets.forEach(target => { 103 + const targetInputs = target.querySelectorAll('input, select, textarea'); 104 + targetInputs.forEach((t, idx) => { 105 + const s = sourceState[idx]; 106 + if (!s) return; 107 + if (t.type === 'checkbox' || t.type === 'radio') { 108 + t.checked = s.checked; 109 + } else { 110 + t.value = s.value; 111 + } 112 + t.dispatchEvent(new Event('change', { bubbles: true })); 113 + }); 114 + }); 115 + }); 116 + })(); 117 + </script> 118 + {{ end }} 119 + 120 + {{ define "pullReviewFlatCommits" }} 121 + {{ $commits := .Comparison.FormatPatch }} 122 + <ul class="flex flex-col gap-2"> 123 + {{ range $commits }} 124 + {{ $email := "" }} 125 + {{ if .Author }}{{ $email = .Author.Email }}{{ end }} 126 + {{ $did := "" }} 127 + {{ if $.EmailToDid }}{{ $did = index $.EmailToDid $email }}{{ end }} 128 + <li class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 px-3 py-2 flex items-center gap-3"> 129 + {{ template "pullReviewCommitAuthor" (dict "Did" $did "Patch" .) }} 130 + <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ .Title }}</span> 131 + {{ template "pullReviewCommitMeta" (dict "Patch" . "RepoInfo" $.RepoInfo) }} 132 + </li> 133 + {{ end }} 134 + </ul> 135 + {{ end }} 136 + 137 + {{ define "pullReviewStackedCommits" }} 138 + {{ $root := . }} 139 + {{ $commits := .Comparison.FormatPatch }} 140 + {{ $previewUrl := printf "/%s/pulls/new/preview" .RepoInfo.FullName }} 141 + <ul class="flex flex-col gap-2"> 142 + {{ range $idx, $p := $commits }} 143 + {{ $cid := $p.ChangeIdOrEmpty }} 144 + {{ $email := "" }} 145 + {{ if $p.Author }}{{ $email = $p.Author.Email }}{{ end }} 146 + {{ $did := "" }} 147 + {{ if $root.EmailToDid }}{{ $did = index $root.EmailToDid $email }}{{ end }} 148 + {{ $titleOverride := index $root.StackTitles $cid }} 149 + {{ $bodyOverride := index $root.StackBodies $cid }} 150 + {{ $displayTitle := $p.Title }} 151 + {{ if $titleOverride }}{{ $displayTitle = $titleOverride }}{{ end }} 152 + {{ $bodyValue := $p.Body }} 153 + {{ if $bodyOverride }}{{ $bodyValue = $bodyOverride }}{{ end }} 154 + {{ $perDiff := "" }} 155 + {{ if lt $idx (len $root.PerCommitDiffs) }} 156 + {{ $perDiff = index $root.PerCommitDiffs $idx }} 157 + {{ end }} 158 + {{ $perOpts := dict }} 159 + {{ if lt $idx (len $root.StackDiffOpts) }} 160 + {{ $perOpts = index $root.StackDiffOpts $idx }} 161 + {{ end }} 162 + <li> 163 + <details class="group/stacked border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 164 + <summary class="p-3 cursor-pointer flex items-center gap-3 list-none"> 165 + <span class="text-gray-400 dark:text-gray-500 flex-shrink-0"> 166 + {{ i "chevron-right" "w-4 h-4 group-open/stacked:hidden inline" }} 167 + {{ i "chevron-down" "w-4 h-4 hidden group-open/stacked:inline" }} 168 + </span> 169 + {{ template "pullReviewCommitAuthor" (dict "Did" $did "Patch" $p) }} 170 + <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{{ $displayTitle }}</span> 171 + {{ template "pullReviewCommitMeta" (dict "Patch" $p "RepoInfo" $root.RepoInfo) }} 172 + </summary> 173 + {{ if $cid }} 174 + <div class="px-3 pb-3 pt-1 flex flex-col gap-3 border-t border-gray-100 dark:border-gray-700"> 175 + {{ if $perDiff }} 176 + <div id="stack-diff-{{ $cid }}"> 177 + <input type="hidden" name="stackSplit[{{ $cid }}]" value="{{ if $perOpts.Split }}split{{ else }}unified{{ end }}" /> 178 + {{ template "pullStackedDiffArea" (dict "Diff" $perDiff "DiffOpts" $perOpts "Cid" $cid) }} 179 + </div> 180 + {{ end }} 181 + {{ $titleName := printf "stackTitle[%s]" $cid }} 182 + {{ $bodyName := printf "stackBody[%s]" $cid }} 183 + {{ $hasSidePanel := and $root.LabelDefs $root.RepoInfo.Roles.IsPushAllowed }} 184 + <div class="flex flex-col md:flex-row gap-6"> 185 + <div class="flex-1 min-w-0 flex flex-col gap-3"> 186 + <div class="flex flex-col gap-1"> 187 + <label class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200">title</label> 188 + <input 189 + type="text" 190 + name="{{ $titleName }}" 191 + value="{{ $displayTitle }}" 192 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 193 + placeholder="{{ $p.Title }}" 194 + /> 195 + </div> 196 + {{ template "markdownEditor" (dict 197 + "Id" (printf "stack-body-%s" $cid) 198 + "Name" $bodyName 199 + "Value" $bodyValue 200 + "Rows" 4 201 + "Placeholder" "Describe this pull request. Markdown is supported." 202 + "LabelText" "description" 203 + "PreviewUrl" $previewUrl 204 + ) }} 205 + </div> 206 + {{ if $hasSidePanel }} 207 + <aside data-stack-labels="{{ $cid }}" class="w-full md:w-72 md:flex-shrink-0 flex flex-col gap-4"> 208 + {{ $labelState := $root.LabelState }} 209 + {{ if $root.StackLabelStates }} 210 + {{ $perCid := index $root.StackLabelStates $cid }} 211 + {{ if $perCid }}{{ $labelState = $perCid }}{{ end }} 212 + {{ end }} 213 + {{ $labelCtx := dict "Defs" $root.LabelDefs "State" $labelState "RepoInfo" $root.RepoInfo "Subject" "" "LoggedInUser" $root.LoggedInUser "Prefix" (printf "stackLabel[%s]" $cid) }} 214 + {{ template "editBasicLabels" $labelCtx }} 215 + {{ template "editKvLabels" $labelCtx }} 216 + <button 217 + type="button" 218 + data-stack-apply-all 219 + class="text-xs text-gray-500 dark:text-gray-400 hover:underline self-end" 220 + title="Copy these labels and assignees to every PR in the stack" 221 + > 222 + Apply labels &amp; assignees to all PRs in stack 223 + </button> 224 + </aside> 225 + {{ end }} 226 + </div> 227 + </div> 228 + {{ else }} 229 + <div class="px-3 pb-3 pt-1 text-sm text-yellow-700 dark:text-yellow-300 border-t border-gray-100 dark:border-gray-700"> 230 + This commit has no <span class="font-mono">Change-Id</span> header and can't be stacked. Set one on the commit and re-push. 231 + </div> 232 + {{ end }} 233 + </details> 234 + </li> 235 + {{ end }} 236 + </ul> 237 + {{ end }} 238 + 239 + {{ define "pullReviewCommitAuthor" }} 240 + {{ $did := .Did }} 241 + {{ $p := .Patch }} 242 + {{ if $did }} 243 + <a href="/{{ $did }}" class="flex items-center gap-2 no-underline hover:underline flex-shrink-0"> 244 + <img src="{{ tinyAvatar $did }}" alt="" class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" /> 245 + <span class="text-sm dark:text-white">{{ resolve $did }}</span> 246 + </a> 247 + {{ else }} 248 + <span class="flex items-center gap-2 flex-shrink-0"> 249 + {{ placeholderAvatar "tiny" }} 250 + {{ if $p.Author }} 251 + <a href="mailto:{{ $p.Author.Email }}" class="text-sm dark:text-white no-underline hover:underline">{{ $p.Author.Name }}</a> 252 + {{ end }} 253 + </span> 254 + {{ end }} 255 + {{ end }} 256 + 257 + {{ define "pullReviewCommitMeta" }} 258 + {{ $p := .Patch }} 259 + {{ $repoInfo := .RepoInfo }} 260 + <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 261 + {{ if not $p.AuthorDate.IsZero }} 262 + {{ template "repo/fragments/shortTimeAgo" $p.AuthorDate }} 263 + {{ end }} 264 + {{ if $p.SHA }} 265 + <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded">{{ slice $p.SHA 0 8 }}</span> 266 + <button type="button" 267 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 268 + title="Copy SHA" 269 + onclick="event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText('{{ $p.SHA }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 270 + {{ i "copy" "w-4 h-4" }} 271 + </button> 272 + <a href="/{{ $repoInfo.FullName }}/tree/{{ $p.SHA }}" 273 + onclick="event.stopPropagation()" 274 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 275 + title="Browse repository at this commit"> 276 + {{ i "folder-code" "w-4 h-4" }} 277 + </a> 278 + {{ end }} 279 + </span> 280 + {{ end }} 281 + 282 + {{ define "pullStackedDiffArea" }} 283 + {{ $diff := .Diff }} 284 + {{ $opts := .DiffOpts }} 285 + {{ $cid := .Cid }} 286 + {{ $togId := printf "stack-%s-filesToggle" $cid }} 287 + {{ $colId := printf "stack-%s-collapseToggle" $cid }} 288 + {{ $filesId := printf "stack-%s-files" $cid }} 289 + {{ $diffAreaId := printf "stack-%s-diff-area" $cid }} 290 + {{ $filePrefix := printf "stack-%s-file-" $cid }} 291 + {{ $stat := $diff.Stats }} 292 + {{ $count := len $diff.ChangedFiles }} 293 + 294 + <style> 295 + #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .show-text { display: none; } 296 + #{{ $togId }}:checked ~ * label[for="{{ $togId }}"] .hide-text { display: inline; } 297 + #{{ $togId }}:not(:checked) ~ * label[for="{{ $togId }}"] .hide-text { display: none; } 298 + #{{ $togId }}:checked ~ * div#{{ $filesId }} { width: fit-content; max-width: 15vw; } 299 + #{{ $togId }}:not(:checked) ~ * div#{{ $filesId }} { width: 0; display: none; margin-right: 0; } 300 + </style> 301 + 302 + <input type="checkbox" id="{{ $togId }}" class="hidden"/> 303 + 304 + <div id="{{ $diffAreaId }}"> 305 + <div class="bg-slate-100 dark:bg-gray-900 flex items-center gap-2 h-12 p-2 rounded-t border border-b-0 border-gray-200 dark:border-gray-700"> 306 + <label title="Toggle filetree panel" for="{{ $togId }}" class="hidden md:inline-flex items-center justify-center rounded cursor-pointer text-normal font-normal normal-case"> 307 + <span class="show-text">{{ i "panel-left-open" "size-4" }}</span> 308 + <span class="hide-text">{{ i "panel-left-close" "size-4" }}</span> 309 + </label> 310 + 311 + {{ template "repo/fragments/diffStatPill" $stat }} 312 + <span class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 313 + 314 + <div class="flex-grow"></div> 315 + 316 + <label title="Expand/Collapse diffs" for="{{ $colId }}" class="btn font-normal normal-case p-2"> 317 + <input type="checkbox" id="{{ $colId }}" class="peer/collapse hidden" checked/> 318 + <span class="peer-checked/collapse:hidden inline-flex items-center gap-2"> 319 + {{ i "fold-vertical" "w-4 h-4" }} 320 + <span class="hidden md:inline">Expand all</span> 321 + </span> 322 + <span class="peer-checked/collapse:inline-flex hidden flex items-center gap-2"> 323 + {{ i "unfold-vertical" "w-4 h-4" }} 324 + <span class="hidden md:inline">Collapse all</span> 325 + </span> 326 + </label> 327 + 328 + <div class="w-44"> 329 + {{ template "repo/fragments/diffOpts" $opts }} 330 + </div> 331 + </div> 332 + 333 + <div class="flex border border-gray-200 dark:border-gray-700 rounded-b"> 334 + <div id="{{ $filesId }}" class="hidden md:block overflow-hidden max-h-[60vh] overflow-y-auto"> 335 + <section class="overflow-x-auto text-sm px-3 py-2 w-full mx-auto"> 336 + {{ template "repo/fragments/fileTreePrefixed" (dict "Tree" $diff.FileTree "Prefix" $filePrefix) }} 337 + </section> 338 + </div> 339 + 340 + <div class="flex-1 min-w-0 p-2 border-l border-gray-200 dark:border-gray-700"> 341 + <div class="flex flex-col gap-2"> 342 + {{ if eq $count 0 }} 343 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 344 + <p>No differences found.</p> 345 + </div> 346 + {{ else }} 347 + {{ range $idx, $file := $diff.ChangedFiles }} 348 + {{ template "stackedDiffFile" (dict "Idx" $idx "File" $file "IsSplit" $opts.Split "Prefix" $filePrefix) }} 349 + {{ end }} 350 + {{ end }} 351 + </div> 352 + </div> 353 + </div> 354 + </div> 355 + 356 + <script> 357 + (() => { 358 + const cb = document.getElementById('{{ $colId }}'); 359 + const area = document.getElementById('{{ $diffAreaId }}'); 360 + if (!cb || !area) return; 361 + const all = () => area.querySelectorAll('details[id^="{{ $filePrefix }}"]'); 362 + cb.addEventListener('change', () => { 363 + all().forEach(d => { d.open = cb.checked; }); 364 + }); 365 + area.addEventListener('toggle', (e) => { 366 + if (!e.target.matches('details[id^="{{ $filePrefix }}"]')) return; 367 + const dets = Array.from(all()); 368 + const allOpen = dets.every(d => d.open); 369 + const allClosed = dets.every(d => !d.open); 370 + if (allOpen) cb.checked = true; 371 + else if (allClosed) cb.checked = false; 372 + }, true); 373 + })(); 374 + </script> 375 + {{ end }} 376 + 377 + {{ define "stackedDiffFile" }} 378 + {{ $idx := .Idx }} 379 + {{ $file := .File }} 380 + {{ $isSplit := .IsSplit }} 381 + {{ $prefix := .Prefix }} 382 + {{ $isGenerated := false }} 383 + {{ $isDeleted := false }} 384 + {{ with $file }} 385 + {{ $n := .Names }} 386 + {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }} 387 + {{ if $n.New }} 388 + {{ $isGenerated = isGenerated $n.New }} 389 + {{ else if $n.Old }} 390 + {{ $isGenerated = isGenerated $n.Old }} 391 + {{ end }} 392 + <details {{ if and (not $isGenerated) (not $isDeleted) }}open{{ end }} id="{{ $prefix }}{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 393 + <summary class="list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700"> 394 + <div class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 395 + <div class="p-2 flex gap-2 items-center overflow-x-auto"> 396 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 397 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 398 + {{ template "repo/fragments/diffStatPill" .Stats }} 399 + <div class="flex gap-2 items-center overflow-x-auto"> 400 + {{ if and $n.New $n.Old (ne $n.New $n.Old)}} 401 + {{ $n.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ $n.New }} 402 + {{ else if $n.New }} 403 + {{ $n.New }} 404 + {{ else }} 405 + {{ $n.Old }} 406 + {{ end }} 407 + {{ if $isDeleted }} 408 + <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default"> 409 + {{ i "circle-question-mark" "size-4" }} 410 + </span> 411 + {{ else if $isGenerated }} 412 + <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 413 + {{ i "circle-question-mark" "size-4" }} 414 + </span> 415 + {{ end }} 416 + </div> 417 + </div> 418 + </div> 419 + </summary> 420 + 421 + <div class="transition-all duration-700 ease-in-out"> 422 + {{ $reason := .CanRender }} 423 + {{ if $reason }} 424 + <p class="text-center text-gray-400 dark:text-gray-500 p-4">{{ $reason }}</p> 425 + {{ else }} 426 + {{ if $isSplit }} 427 + {{- template "repo/fragments/splitDiff" .Split -}} 428 + {{ else }} 429 + {{- template "repo/fragments/unifiedDiff" . -}} 430 + {{ end }} 431 + {{- end -}} 432 + </div> 433 + </details> 434 + {{ end }} 435 + {{ end }}
+156
appview/pages/templates/repo/pulls/fragments/pullStepSource.html
··· 1 + {{ define "repo/pulls/fragments/pullStepSource" }} 2 + <section class="flex flex-col gap-3"> 3 + <input type="hidden" name="source" value="{{ .Source }}"> 4 + 5 + {{ template "pullSourceTabs" . }} 6 + 7 + {{ if eq .Source "patch" }} 8 + <div class="flex flex-col gap-1"> 9 + {{ template "repo/fragments/labelSectionHeaderText" "Merge into" }} 10 + {{ template "pullTargetBranchSelect" . }} 11 + </div> 12 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 13 + {{ else }} 14 + <div class="flex flex-col md:flex-row md:flex-wrap gap-3"> 15 + <div class="flex flex-col gap-1"> 16 + {{ template "repo/fragments/labelSectionHeaderText" "Merge into" }} 17 + {{ template "pullTargetBranchSelect" . }} 18 + </div> 19 + <div id="patch-strategy" class="flex flex-col gap-1"> 20 + {{ template "repo/fragments/labelSectionHeaderText" "Pull from" }} 21 + {{ if eq .Source "fork" }} 22 + {{ template "repo/pulls/fragments/pullCompareForks" . }} 23 + {{ else }} 24 + {{ template "repo/pulls/fragments/pullCompareBranches" . }} 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + <div id="patch-error" class="error dark:text-red-300 empty:hidden"></div> 31 + 32 + {{ if ne .Source "patch" }} 33 + <div class="flex items-center gap-2"> 34 + <input type="checkbox" id="mode-stack" name="mode" value="stack" autocomplete="off" {{ if .IsStacked }}checked{{ end }} 35 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 36 + hx-include="closest form" 37 + hx-target="#wizard-host" 38 + hx-swap="outerHTML" 39 + hx-trigger="change" 40 + hx-indicator="this" 41 + class="peer"> 42 + <label for="mode-stack" class="my-0 py-0 normal-case font-normal dark:text-white"> 43 + Submit as stacked PRs 44 + </label> 45 + <button 46 + type="button" 47 + popovertarget="stacked-explainer" 48 + aria-label="What are stacked PRs?" 49 + class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white peer-[.htmx-request]:hidden" 50 + > 51 + {{ i "circle-question-mark" "size-4" }} 52 + </button> 53 + {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 54 + </div> 55 + {{ template "repo/pulls/fragments/stackedExplainer" . }} 56 + {{ end }} 57 + </section> 58 + {{ end }} 59 + 60 + {{ define "pullTargetBranchSelect" }} 61 + <div class="flex flex-wrap gap-2 items-center"> 62 + <select 63 + id="targetBranch" 64 + name="targetBranch" 65 + required 66 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/refresh" 67 + hx-include="closest form" 68 + hx-target="#wizard-host" 69 + hx-swap="outerHTML" 70 + hx-trigger="change" 71 + hx-indicator="this" 72 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 73 + > 74 + <option disabled {{ if not .TargetBranch }}selected{{ end }}>Target branch</option> 75 + {{ $disableSource := and (eq .Source "branch") $.SourceBranch }} 76 + {{ range .Branches }} 77 + {{ $preset := false }} 78 + {{ if $.TargetBranch }} 79 + {{ $preset = eq .Reference.Name $.TargetBranch }} 80 + {{ else }} 81 + {{ $preset = .IsDefault }} 82 + {{ end }} 83 + {{ $isSource := and $disableSource (eq .Reference.Name $.SourceBranch) }} 84 + <option value="{{ .Reference.Name }}" class="py-1" 85 + {{ if $preset }}selected{{ end }} 86 + {{ if $isSource }}disabled{{ end }}> 87 + {{ .Reference.Name }} 88 + {{ if $isSource }}(source){{ end }} 89 + </option> 90 + {{ end }} 91 + </select> 92 + {{ i "loader-circle" "size-4 animate-spin hidden peer-[.htmx-request]:inline text-gray-500 dark:text-gray-400" }} 93 + </div> 94 + {{ end }} 95 + 96 + {{ define "pullSourceTabs" }} 97 + {{ $active := "bg-white dark:bg-gray-700 shadow-sm cursor-default" }} 98 + {{ $inactive := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 99 + {{ $shared := "group flex-1 p-3 text-left hover:no-underline flex flex-col gap-1" }} 100 + {{ $titleCls := "font-medium text-sm dark:text-white flex items-center gap-2" }} 101 + {{ $descCls := "text-xs text-gray-500 dark:text-gray-400" }} 102 + {{ $fullName := .RepoInfo.FullName }} 103 + <div class="flex divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden" 104 + hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault();"> 105 + {{ if .RepoInfo.Roles.IsPushAllowed }} 106 + <button 107 + type="button" 108 + class="{{ $shared }} {{ if eq .Source "branch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 109 + hx-post="/{{ $fullName }}/pulls/new/refresh" 110 + hx-vals='{"source": "branch"}' 111 + hx-include="closest form" 112 + hx-target="#wizard-host" 113 + hx-swap="outerHTML" 114 + hx-indicator="this" 115 + > 116 + <span class="{{ $titleCls }}"> 117 + Compare branches 118 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 119 + </span> 120 + <span class="{{ $descCls }}">Select a source branch</span> 121 + </button> 122 + {{ end }} 123 + <button 124 + type="button" 125 + class="{{ $shared }} {{ if eq .Source "fork" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 126 + hx-post="/{{ $fullName }}/pulls/new/refresh" 127 + hx-vals='{"source": "fork"}' 128 + hx-include="closest form" 129 + hx-target="#wizard-host" 130 + hx-swap="outerHTML" 131 + hx-indicator="this" 132 + > 133 + <span class="{{ $titleCls }}"> 134 + Compare forks 135 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 136 + </span> 137 + <span class="{{ $descCls }}">Select a fork and branch as the source</span> 138 + </button> 139 + <button 140 + type="button" 141 + class="{{ $shared }} {{ if eq .Source "patch" }}{{ $active }}{{ else }}{{ $inactive }}{{ end }}" 142 + hx-post="/{{ $fullName }}/pulls/new/refresh" 143 + hx-vals='{"source": "patch"}' 144 + hx-include="closest form" 145 + hx-target="#wizard-host" 146 + hx-swap="outerHTML" 147 + hx-indicator="this" 148 + > 149 + <span class="{{ $titleCls }}"> 150 + Paste patch 151 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 152 + </span> 153 + <span class="{{ $descCls }}">Paste a <code class="font-mono">git diff</code> or <code class="font-mono">git format-patch</code></span> 154 + </button> 155 + </div> 156 + {{ end }}
+74
appview/pages/templates/repo/pulls/fragments/pullWizardHost.html
··· 1 + {{ define "repo/pulls/fragments/pullWizardHost" }} 2 + <div id="wizard-host" class="flex flex-col"> 3 + {{ if .PrefillError }} 4 + <div class="mb-4 p-3 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-red-900/30 text-sm text-red-800 dark:text-red-200 flex items-center gap-2"> 5 + {{ i "triangle-alert" "w-4 h-4 flex-shrink-0" }} 6 + <span>{{ .PrefillError }}</span> 7 + </div> 8 + {{ end }} 9 + 10 + {{ $hasCommits := and .Comparison .Comparison.FormatPatch }} 11 + {{ $hasDiff := false }} 12 + {{ if .Diff }}{{ if .Diff.Diff }}{{ $hasDiff = true }}{{ end }}{{ end }} 13 + {{ $showDetails := and (or $hasCommits $hasDiff) (not .IsStacked) }} 14 + 15 + <form 16 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 17 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#patch,#title,#body)" 18 + hx-indicator="#create-pull-spinner" 19 + hx-swap="none" 20 + class="flex flex-col gap-6" 21 + > 22 + <section class="relative flex flex-col gap-3"> 23 + <div class="absolute left-3 -translate-x-1/2 top-3 -bottom-6 w-px bg-gray-200 dark:bg-gray-700"></div> 24 + <div class="flex items-center gap-4 relative"> 25 + <div class="flex-shrink-0 relative z-10"> 26 + {{ template "pullWizardSectionNumber" 1 }} 27 + </div> 28 + <h3 class="uppercase text-sm tracking-wide font-bold my-0 dark:text-white">source</h3> 29 + </div> 30 + <div class="ml-10 flex flex-col gap-3"> 31 + {{ template "repo/pulls/fragments/pullStepSource" . }} 32 + </div> 33 + </section> 34 + 35 + <section class="relative flex flex-col gap-3"> 36 + {{ if $showDetails }} 37 + <div class="absolute left-3 -translate-x-1/2 -top-6 -bottom-6 w-px bg-gray-200 dark:bg-gray-700"></div> 38 + {{ else }} 39 + <div class="absolute left-3 -translate-x-1/2 -top-6 -bottom-12 w-px bg-gray-200 dark:bg-gray-700 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)]"></div> 40 + {{ end }} 41 + <div class="flex items-center gap-4 relative"> 42 + <div class="flex-shrink-0 relative z-10"> 43 + {{ template "pullWizardSectionNumber" 2 }} 44 + </div> 45 + <h3 class="uppercase text-sm tracking-wide font-bold my-0 dark:text-white">review</h3> 46 + </div> 47 + <div class="ml-10 flex flex-col gap-3"> 48 + {{ template "repo/pulls/fragments/pullStepReview" . }} 49 + </div> 50 + </section> 51 + 52 + {{ if $showDetails }} 53 + <section class="relative flex flex-col gap-3"> 54 + <div class="absolute left-3 -translate-x-1/2 -top-6 -bottom-12 w-px bg-gray-200 dark:bg-gray-700 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)]"></div> 55 + <div class="flex items-center gap-4 relative"> 56 + <div class="flex-shrink-0 relative z-10"> 57 + {{ template "pullWizardSectionNumber" 3 }} 58 + </div> 59 + <h3 class="uppercase text-sm tracking-wide font-bold my-0 dark:text-white">details</h3> 60 + </div> 61 + <div class="ml-10 flex flex-col gap-3"> 62 + {{ template "repo/pulls/fragments/pullStepDetails" . }} 63 + </div> 64 + </section> 65 + {{ end }} 66 + </form> 67 + 68 + <div id="pull" class="error dark:text-red-300 mt-4 ml-10"></div> 69 + </div> 70 + {{ end }} 71 + 72 + {{ define "pullWizardSectionNumber" }} 73 + <span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 text-xs font-bold">{{ . }}</span> 74 + {{ end }}
+51
appview/pages/templates/repo/pulls/fragments/stackedExplainer.html
··· 1 + {{ define "repo/pulls/fragments/stackedExplainer" }} 2 + <div 3 + id="stacked-explainer" 4 + popover 5 + class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 6 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 7 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible" 8 + > 9 + <div class="flex items-start justify-between gap-2 mb-2"> 10 + <h3 class="text-base font-semibold text-gray-900 dark:text-white my-0"> 11 + Stacked PRs 12 + </h3> 13 + <button 14 + type="button" 15 + popovertarget="stacked-explainer" 16 + popovertargetaction="hide" 17 + aria-label="Close" 18 + class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white -mt-0.5 -mr-1" 19 + > 20 + {{ i "x" "size-4" }} 21 + </button> 22 + </div> 23 + 24 + <p class="text-sm text-gray-700 dark:text-gray-200 mb-2"> 25 + Each commit on your branch becomes its own pull request. Reviewers can 26 + comment per commit, and Tangled tracks each PR across rewrites using 27 + jujutsu change-ids. You can <code>jj edit</code> an old commit, 28 + push, and the right PR advances to the next round. 29 + </p> 30 + 31 + <p class="text-sm text-gray-700 dark:text-gray-200 mb-2"> 32 + Without stacking, all changes land as one PR. Force-pushes turn opaque 33 + and <code>git blame</code> clobbers across rounds. 34 + </p> 35 + 36 + <p class="text-sm text-gray-700 dark:text-gray-200 mb-3"> 37 + With stacking, edit/split/squash old commits freely, and descendants 38 + will auto-rebase! Each PR shows an "interdiff" between rounds so 39 + reviewers will see exactly what changed. 40 + </p> 41 + 42 + <a 43 + href="https://blog.tangled.org/stacking" 44 + target="_blank" 45 + rel="noopener noreferrer" 46 + class="text-sm text-blue-600 dark:text-blue-400 hover:underline" 47 + > 48 + Full write-up 49 + </a> 50 + </div> 51 + {{ end }}
+1 -152
appview/pages/templates/repo/pulls/new.html
··· 1 1 {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 5 - Create new pull request 6 - </h2> 7 - 8 - <form 9 - hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 10 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:(#patch,#title,#body)" 11 - hx-indicator="#create-pull-spinner" 12 - hx-swap="none" 13 - > 14 - <div class="flex flex-col gap-6"> 15 - <div class="flex gap-2 items-center"> 16 - <p>First, choose a target branch on {{ .RepoInfo.FullName }}:</p> 17 - <div> 18 - <select 19 - required 20 - name="targetBranch" 21 - class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 22 - > 23 - <option disabled selected>target branch</option> 24 - 25 - 26 - {{ range .Branches }} 27 - 28 - {{ $preset := false }} 29 - {{ if $.TargetBranch }} 30 - {{ $preset = eq .Reference.Name $.TargetBranch }} 31 - {{ else }} 32 - {{ $preset = .IsDefault }} 33 - {{ end }} 34 - 35 - <option value="{{ .Reference.Name }}" class="py-1" {{if $preset}}selected{{end}}> 36 - {{ .Reference.Name }} 37 - </option> 38 - {{ end }} 39 - </select> 40 - </div> 41 - </div> 42 - 43 - <div class="flex flex-col gap-2"> 44 - <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 45 - Choose pull strategy 46 - </h2> 47 - <nav class="flex space-x-4 items-center"> 48 - <button 49 - type="button" 50 - class="btn" 51 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 52 - hx-target="#patch-strategy" 53 - hx-swap="innerHTML" 54 - > 55 - paste patch 56 - </button> 57 - 58 - {{ if .RepoInfo.Roles.IsPushAllowed }} 59 - <span class="text-sm text-gray-500 dark:text-gray-400"> 60 - or 61 - </span> 62 - <button 63 - type="button" 64 - class="btn" 65 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 66 - hx-target="#patch-strategy" 67 - hx-swap="innerHTML" 68 - > 69 - compare branches 70 - </button> 71 - {{ end }} 72 - 73 - 74 - <span class="text-sm text-gray-500 dark:text-gray-400"> 75 - or 76 - </span> 77 - <script> 78 - function getQueryParams() { 79 - return Object.fromEntries(new URLSearchParams(window.location.search)); 80 - } 81 - </script> 82 - <!-- 83 - since compare-forks need the server to load forks, we 84 - hx-get this button; unlike simply loading the pullCompareForks template 85 - as we do for the rest of the gang below. the hx-vals thing just populates 86 - the query params so the forks page gets it. 87 - --> 88 - <button 89 - type="button" 90 - class="btn" 91 - hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 92 - hx-target="#patch-strategy" 93 - hx-swap="innerHTML" 94 - {{ if eq .Strategy "fork" }} 95 - hx-trigger="click, load" 96 - hx-vals='js:{...getQueryParams()}' 97 - {{ end }} 98 - > 99 - compare forks 100 - </button> 101 - 102 - 103 - </nav> 104 - <section id="patch-strategy" class="flex flex-col gap-2"> 105 - {{ if eq .Strategy "patch" }} 106 - {{ template "repo/pulls/fragments/pullPatchUpload" . }} 107 - {{ else if eq .Strategy "branch" }} 108 - {{ template "repo/pulls/fragments/pullCompareBranches" . }} 109 - {{ else }} 110 - {{ template "repo/pulls/fragments/pullPatchUpload" . }} 111 - {{ end }} 112 - </section> 113 - 114 - <div id="patch-error" class="error dark:text-red-300"></div> 115 - </div> 116 - 117 - <div> 118 - <label for="title" class="dark:text-white">write a title</label> 119 - 120 - <input 121 - type="text" 122 - name="title" 123 - id="title" 124 - value="{{ .Title }}" 125 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 126 - placeholder="One-line summary of your change." 127 - /> 128 - </div> 129 - 130 - <div> 131 - <label for="body" class="dark:text-white" 132 - >add a description</label 133 - > 134 - 135 - <textarea 136 - name="body" 137 - id="body" 138 - rows="6" 139 - class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 140 - placeholder="Describe your change. Markdown is supported." 141 - >{{ .Body }}</textarea> 142 - </div> 143 - 144 - <div class="flex justify-start items-center gap-2 mt-4"> 145 - <button type="submit" class="btn-create flex items-center gap-2"> 146 - {{ i "git-pull-request-create" "w-4 h-4" }} 147 - create pull 148 - <span id="create-pull-spinner" class="group"> 149 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 150 - </span> 151 - </button> 152 - </div> 153 - </div> 154 - <div id="pull" class="error dark:text-red-300"></div> 155 - </form> 4 + {{ template "repo/pulls/fragments/pullWizardHost" . }} 156 5 {{ end }}
+315
appview/pages/wizard_parse_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "bytes" 5 + "io" 6 + "log/slog" 7 + "strings" 8 + "testing" 9 + 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pages/repoinfo" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/types" 15 + ) 16 + 17 + func TestPullWizardTemplatesParse(t *testing.T) { 18 + cfg := &config.Config{} 19 + p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 20 + 21 + cases := []struct { 22 + name string 23 + stack []string 24 + }{ 25 + {"new.html via repo base", []string{"layouts/base", "layouts/repobase", "repo/pulls/new"}}, 26 + {"pullWizardHost", []string{"repo/pulls/fragments/pullWizardHost"}}, 27 + {"pullStepSource", []string{"repo/pulls/fragments/pullStepSource"}}, 28 + {"pullStepReview", []string{"repo/pulls/fragments/pullStepReview"}}, 29 + {"pullStepDetails", []string{"repo/pulls/fragments/pullStepDetails"}}, 30 + {"pullCompareForks", []string{"repo/pulls/fragments/pullCompareForks"}}, 31 + {"pullCompareBranches", []string{"repo/pulls/fragments/pullCompareBranches"}}, 32 + {"pullCompareForksBranches", []string{"repo/pulls/fragments/pullCompareForksBranches"}}, 33 + {"stackedExplainer", []string{"repo/pulls/fragments/stackedExplainer"}}, 34 + } 35 + 36 + for _, c := range cases { 37 + t.Run(c.name, func(t *testing.T) { 38 + if _, err := p.rawParse(c.stack...); err != nil { 39 + t.Fatalf("parse %v: %v", c.stack, err) 40 + } 41 + }) 42 + } 43 + } 44 + 45 + func TestPullWizardHostRender(t *testing.T) { 46 + cfg := &config.Config{} 47 + p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 48 + 49 + base := RepoNewPullParams{ 50 + RepoInfo: repoinfo.RepoInfo{ 51 + OwnerDid: "did:plc:test", 52 + Name: "test-repo", 53 + }, 54 + } 55 + 56 + for _, source := range []Source{"", SourceBranch, SourceFork, SourcePatch} { 57 + for _, stacked := range []bool{false, true} { 58 + if source == SourcePatch && stacked { 59 + continue 60 + } 61 + params := base 62 + params.Source = source 63 + params.IsStacked = stacked 64 + name := string(source) 65 + if name == "" { 66 + name = "default" 67 + } 68 + if stacked { 69 + name += "-stacked" 70 + } 71 + t.Run(name, func(t *testing.T) { 72 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 73 + t.Fatalf("render source=%q stacked=%v: %v", source, stacked, err) 74 + } 75 + }) 76 + } 77 + } 78 + } 79 + 80 + func TestPullWizardHostRenderWithData(t *testing.T) { 81 + cfg := &config.Config{} 82 + p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 83 + 84 + sampleBranches := []types.Branch{ 85 + {Reference: types.Reference{Name: "feature"}}, 86 + {Reference: types.Reference{Name: "main"}, IsDefault: true}, 87 + } 88 + 89 + formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 90 + From: Test <test@best.fest> 91 + Date: Tue, 1 Jan 2020 00:00:00 +0000 92 + Subject: [PATCH] example commit 93 + 94 + --- 95 + a.txt | 1 + 96 + 1 file changed, 1 insertion(+) 97 + 98 + diff --git a/a.txt b/a.txt 99 + index 0000000..1111111 100644 100 + --- a/a.txt 101 + +++ b/a.txt 102 + @@ -0,0 +1 @@ 103 + +hello 104 + ` 105 + patches, err := patchutil.ExtractPatches(formatPatch) 106 + if err != nil { 107 + t.Fatalf("extract patches: %v", err) 108 + } 109 + comparison := &types.RepoFormatPatchResponse{ 110 + FormatPatchRaw: formatPatch, 111 + FormatPatch: patches, 112 + } 113 + diff := patchutil.AsNiceDiff(formatPatch, "main") 114 + 115 + params := RepoNewPullParams{ 116 + RepoInfo: repoinfo.RepoInfo{ 117 + OwnerDid: "did:plc:test", 118 + Name: "test-repo", 119 + }, 120 + Branches: sampleBranches, 121 + SourceBranches: []types.Branch{sampleBranches[0]}, 122 + ForkBranches: []types.Branch{sampleBranches[0]}, 123 + Source: SourceBranch, 124 + SourceBranch: "feature", 125 + TargetBranch: "main", 126 + Comparison: comparison, 127 + Diff: &diff, 128 + } 129 + 130 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 131 + t.Fatalf("render with data: %v", err) 132 + } 133 + 134 + params.IsStacked = true 135 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 136 + t.Fatalf("render stacked: %v", err) 137 + } 138 + 139 + params.PrefillError = "branch not found" 140 + params.Comparison = nil 141 + params.Diff = nil 142 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 143 + t.Fatalf("render with prefill error: %v", err) 144 + } 145 + 146 + bugDef := &models.LabelDefinition{ 147 + Did: "did:plc:test", 148 + Rkey: "bug", 149 + Name: "bug", 150 + ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 151 + Scope: []string{"sh.tangled.repo.pull"}, 152 + } 153 + priorityDef := &models.LabelDefinition{ 154 + Did: "did:plc:test", 155 + Rkey: "priority", 156 + Name: "priority", 157 + ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 158 + Scope: []string{"sh.tangled.repo.pull"}, 159 + } 160 + assigneeDef := &models.LabelDefinition{ 161 + Did: "did:plc:test", 162 + Rkey: "assignee", 163 + Name: "assignee", 164 + ValueType: models.ValueType{Type: models.ConcreteTypeString, Format: models.ValueTypeFormatDid}, 165 + Scope: []string{"sh.tangled.repo.pull"}, 166 + Multiple: true, 167 + } 168 + labelDefs := map[string]*models.LabelDefinition{ 169 + bugDef.AtUri().String(): bugDef, 170 + priorityDef.AtUri().String(): priorityDef, 171 + assigneeDef.AtUri().String(): assigneeDef, 172 + } 173 + 174 + pushRepoInfo := repoinfo.RepoInfo{ 175 + OwnerDid: "did:plc:test", 176 + Name: "test-repo", 177 + Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 178 + } 179 + params = RepoNewPullParams{ 180 + RepoInfo: pushRepoInfo, 181 + Branches: sampleBranches, 182 + SourceBranches: []types.Branch{sampleBranches[0]}, 183 + Source: SourceBranch, 184 + SourceBranch: "feature", 185 + TargetBranch: "main", 186 + Comparison: comparison, 187 + Diff: &diff, 188 + LabelDefs: labelDefs, 189 + LabelState: models.NewLabelState(), 190 + } 191 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 192 + t.Fatalf("render with labels: %v", err) 193 + } 194 + 195 + params.IsStacked = true 196 + if err := p.PullWizardHostFragment(io.Discard, params); err != nil { 197 + t.Fatalf("render stacked with labels: %v", err) 198 + } 199 + } 200 + 201 + func TestPullWizardLabelStateRoundTrip(t *testing.T) { 202 + cfg := &config.Config{} 203 + p := NewPages(cfg, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) 204 + 205 + sampleBranches := []types.Branch{ 206 + {Reference: types.Reference{Name: "feature"}}, 207 + {Reference: types.Reference{Name: "main"}, IsDefault: true}, 208 + } 209 + 210 + bugDef := &models.LabelDefinition{ 211 + Did: "did:plc:test", Rkey: "bug", Name: "bug", 212 + ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 213 + Scope: []string{"sh.tangled.repo.pull"}, 214 + } 215 + priorityDef := &models.LabelDefinition{ 216 + Did: "did:plc:test", Rkey: "priority", Name: "priority", 217 + ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 218 + Scope: []string{"sh.tangled.repo.pull"}, 219 + } 220 + bugKey := bugDef.AtUri().String() 221 + priorityKey := priorityDef.AtUri().String() 222 + labelDefs := map[string]*models.LabelDefinition{ 223 + bugKey: bugDef, 224 + priorityKey: priorityDef, 225 + } 226 + 227 + state := models.NewLabelState() 228 + actx := &models.LabelApplicationCtx{Defs: labelDefs} 229 + for _, op := range []models.LabelOp{ 230 + {OperandKey: bugKey, OperandValue: "null", Operation: models.LabelOperationAdd}, 231 + {OperandKey: priorityKey, OperandValue: "high", Operation: models.LabelOperationAdd}, 232 + } { 233 + if err := actx.ApplyLabelOp(state, op); err != nil { 234 + t.Fatalf("seed state: %v", err) 235 + } 236 + } 237 + 238 + formatPatch := `From 1111111111111111111111111111111111111111 Mon Sep 11 00:00:00 2001 239 + From: Test <test@best.fest> 240 + Date: Tue, 1 Jan 2020 00:00:00 +0000 241 + Subject: [PATCH] example commit 242 + 243 + --- 244 + a.txt | 1 + 245 + 1 file changed, 1 insertion(+) 246 + 247 + diff --git a/a.txt b/a.txt 248 + index 0000000..1111111 100644 249 + --- a/a.txt 250 + +++ b/a.txt 251 + @@ -0,0 +1 @@ 252 + +hello 253 + ` 254 + patches, err := patchutil.ExtractPatches(formatPatch) 255 + if err != nil { 256 + t.Fatalf("extract patches: %v", err) 257 + } 258 + comparison := &types.RepoFormatPatchResponse{ 259 + FormatPatchRaw: formatPatch, 260 + FormatPatch: patches, 261 + } 262 + 263 + params := RepoNewPullParams{ 264 + RepoInfo: repoinfo.RepoInfo{ 265 + OwnerDid: "did:plc:test", 266 + Name: "test-repo", 267 + Roles: repoinfo.RolesInRepo{Roles: []string{"repo:push"}}, 268 + }, 269 + Branches: sampleBranches, 270 + SourceBranches: []types.Branch{sampleBranches[0]}, 271 + Source: SourceBranch, 272 + SourceBranch: "feature", 273 + TargetBranch: "main", 274 + Comparison: comparison, 275 + LabelDefs: labelDefs, 276 + LabelState: state, 277 + } 278 + 279 + var buf bytes.Buffer 280 + if err := p.PullWizardHostFragment(&buf, params); err != nil { 281 + t.Fatalf("render: %v", err) 282 + } 283 + out := buf.String() 284 + for _, want := range []string{ 285 + `value="null" checked`, 286 + `value="high" checked`, 287 + } { 288 + if !strings.Contains(out, want) { 289 + t.Errorf("missing pre-selection %q", want) 290 + } 291 + } 292 + } 293 + 294 + func TestParseSource(t *testing.T) { 295 + cases := []struct { 296 + in string 297 + want Source 298 + wantOk bool 299 + }{ 300 + {"branch", SourceBranch, true}, 301 + {"BRANCH", SourceBranch, true}, 302 + {"fork", SourceFork, true}, 303 + {"patch", SourcePatch, true}, 304 + {"", "", false}, 305 + {"method", "", false}, 306 + {"strategy", "", false}, 307 + {"unknown", "", false}, 308 + } 309 + for _, c := range cases { 310 + got, ok := ParseSource(c.in) 311 + if got != c.want || ok != c.wantOk { 312 + t.Errorf("ParseSource(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.wantOk) 313 + } 314 + } 315 + }
+699 -203
appview/pulls/pulls.go
··· 9 9 "errors" 10 10 "fmt" 11 11 "io" 12 + "iter" 12 13 "log/slog" 14 + "maps" 13 15 "net/http" 16 + "net/url" 14 17 "slices" 15 18 "sort" 16 19 "strconv" ··· 43 46 "tangled.org/core/xrpc" 44 47 45 48 comatproto "github.com/bluesky-social/indigo/api/atproto" 49 + "github.com/bluesky-social/indigo/atproto/atclient" 46 50 "github.com/bluesky-social/indigo/atproto/syntax" 47 51 lexutil "github.com/bluesky-social/indigo/lex/util" 48 52 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 96 100 indexer: indexer, 97 101 ogreClient: ogre.NewClient(config.Ogre.Host), 98 102 } 103 + } 104 + 105 + func (s *Pulls) knotClient(host string) *indigoxrpc.Client { 106 + scheme := "https" 107 + if s.config.Core.Dev { 108 + scheme = "http" 109 + } 110 + return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 99 111 } 100 112 101 113 // htmx fragment ··· 328 340 return types.MergeCheckResponse{} 329 341 } 330 342 331 - scheme := "https" 332 - if s.config.Core.Dev { 333 - scheme = "http" 334 - } 335 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 336 - 337 - xrpcc := indigoxrpc.Client{ 338 - Host: host, 339 - } 343 + xrpcc := s.knotClient(f.Knot) 340 344 341 345 // combine patches of substack 342 346 subStack := stack.Below(pull) ··· 347 351 348 352 resp, err := tangled.RepoMergeCheck( 349 353 r.Context(), 350 - &xrpcc, 354 + xrpcc, 351 355 &tangled.RepoMergeCheck_Input{ 352 356 Did: f.Did, 353 357 Name: f.Name, ··· 362 366 } 363 367 } 364 368 365 - // convert xrpc response to internal types 369 + return mergeCheckResponseFrom(resp) 370 + } 371 + 372 + func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 366 373 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 367 - for i, conflict := range resp.Conflicts { 368 - conflicts[i] = types.ConflictInfo{ 369 - Filename: conflict.Filename, 370 - Reason: conflict.Reason, 371 - } 374 + for i, c := range resp.Conflicts { 375 + conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 372 376 } 373 - 374 - result := types.MergeCheckResponse{ 377 + out := types.MergeCheckResponse{ 375 378 IsConflicted: resp.Is_conflicted, 376 379 Conflicts: conflicts, 377 380 } 378 - 379 381 if resp.Message != nil { 380 - result.Message = *resp.Message 382 + out.Message = *resp.Message 381 383 } 382 - 383 384 if resp.Error != nil { 384 - result.Error = *resp.Error 385 + out.Error = *resp.Error 385 386 } 386 - 387 - return result 387 + return out 388 388 } 389 389 390 390 func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { ··· 930 930 931 931 switch r.Method { 932 932 case http.MethodGet: 933 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 934 - 935 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 933 + params, err := s.wizardParams(r, f) 936 934 if err != nil { 937 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 938 - l.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err) 939 - s.pages.Error503(w) 940 - return 941 - } 942 - l.Error("failed to fetch branches", "err", err) 943 - return 944 - } 945 - 946 - var result types.RepoBranchesResponse 947 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 948 - l.Error("failed to decode XRPC response", "err", err) 935 + l.Error("failed to build wizard params", "err", err) 949 936 s.pages.Error503(w) 950 937 return 951 938 } 952 - 953 - // can be one of "patch", "branch" or "fork" 954 - strategy := r.URL.Query().Get("strategy") 955 - // ignored if strategy is "patch" 956 - sourceBranch := r.URL.Query().Get("sourceBranch") 957 - targetBranch := r.URL.Query().Get("targetBranch") 958 - 959 - s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 960 - LoggedInUser: user, 961 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 962 - Branches: result.Branches, 963 - Strategy: strategy, 964 - SourceBranch: sourceBranch, 965 - TargetBranch: targetBranch, 966 - Title: r.URL.Query().Get("title"), 967 - Body: r.URL.Query().Get("body"), 968 - }) 939 + s.pages.RepoNewPull(w, params) 969 940 970 941 case http.MethodPost: 971 942 title := r.FormValue("title") ··· 987 958 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 988 959 isForkBased := fromFork != "" && sourceBranch != "" 989 960 isPatchBased := patch != "" && !isBranchBased && !isForkBased 990 - isStacked := r.FormValue("isStacked") == "on" 961 + isStacked := r.FormValue("mode") == "stack" && !isPatchBased 991 962 992 963 if isPatchBased && !patchutil.IsFormatPatch(patch) { 993 964 if title == "" { ··· 1013 984 return 1014 985 } 1015 986 987 + if isBranchBased && sourceBranch == targetBranch { 988 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 989 + return 990 + } 991 + 1016 992 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1017 993 // if err != nil { 1018 994 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) ··· 1054 1030 return 1055 1031 } 1056 1032 1033 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1034 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1035 + 1057 1036 // Handle the PR creation based on the type 1058 1037 if isBranchBased { 1059 1038 if !caps.PullRequests.BranchSubmissions { 1060 1039 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1061 1040 return 1062 1041 } 1063 - s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked) 1042 + s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1064 1043 } else if isForkBased { 1065 1044 if !caps.PullRequests.ForkSubmissions { 1066 1045 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1067 1046 return 1068 1047 } 1069 - s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked) 1048 + s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1070 1049 } else if isPatchBased { 1071 1050 if !caps.PullRequests.PatchSubmissions { 1072 1051 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1073 1052 return 1074 1053 } 1075 - s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked) 1054 + s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 1076 1055 } 1077 1056 return 1078 1057 } ··· 1088 1067 targetBranch, 1089 1068 sourceBranch string, 1090 1069 isStacked bool, 1070 + stackTitles, stackBodies map[string]string, 1091 1071 ) { 1092 1072 l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1093 1073 1094 - scheme := "http" 1095 - if !s.config.Core.Dev { 1096 - scheme = "https" 1097 - } 1098 - host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 1099 - xrpcc := &indigoxrpc.Client{ 1100 - Host: host, 1101 - } 1074 + xrpcc := s.knotClient(repo.Knot) 1102 1075 1103 1076 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1104 1077 if err != nil { ··· 1119 1092 return 1120 1093 } 1121 1094 1095 + if len(comparison.FormatPatch) == 0 { 1096 + s.pages.Notice(w, "pull", "No commits between target and source.") 1097 + return 1098 + } 1099 + 1122 1100 sourceRev := comparison.Rev2 1123 1101 patch := comparison.FormatPatchRaw 1124 1102 combined := comparison.CombinedPatchRaw ··· 1133 1111 Branch: sourceBranch, 1134 1112 } 1135 1113 1136 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked) 1114 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1137 1115 } 1138 1116 1139 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool) { 1117 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1140 1118 if err := s.validator.ValidatePatch(&patch); err != nil { 1141 1119 s.logger.Error("patch validation failed", "err", err) 1142 1120 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1143 1121 return 1144 1122 } 1145 1123 1146 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked) 1124 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 1147 1125 } 1148 1126 1149 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1127 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1150 1128 l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1151 1129 1152 1130 repoString := strings.SplitN(forkRepo, "/", 2) ··· 1199 1177 // hiddenRef: hidden/feature-1/main (on repo-fork) 1200 1178 // targetBranch: main (on repo-1) 1201 1179 // sourceBranch: feature-1 (on repo-fork) 1202 - forkScheme := "http" 1203 - if !s.config.Core.Dev { 1204 - forkScheme = "https" 1205 - } 1206 - forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1207 - forkXrpcc := &indigoxrpc.Client{ 1208 - Host: forkHost, 1209 - } 1180 + forkXrpcc := s.knotClient(fork.Knot) 1210 1181 1211 1182 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1212 1183 if err != nil { ··· 1227 1198 return 1228 1199 } 1229 1200 1201 + if len(comparison.FormatPatch) == 0 { 1202 + s.pages.Notice(w, "pull", "No commits between target and source.") 1203 + return 1204 + } 1205 + 1230 1206 sourceRev := comparison.Rev2 1231 1207 patch := comparison.FormatPatchRaw 1232 1208 combined := comparison.CombinedPatchRaw ··· 1250 1226 RepoDid: forkDid, 1251 1227 } 1252 1228 1253 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked) 1229 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1254 1230 } 1255 1231 1256 1232 func (s *Pulls) createPullRequest( ··· 1264 1240 sourceRev string, 1265 1241 pullSource *models.PullSource, 1266 1242 isStacked bool, 1243 + stackTitles, stackBodies map[string]string, 1267 1244 ) { 1268 1245 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 1269 1246 ··· 1278 1255 patch, 1279 1256 sourceRev, 1280 1257 pullSource, 1258 + stackTitles, 1259 + stackBodies, 1281 1260 ) 1282 1261 return 1283 1262 } ··· 1390 1369 1391 1370 s.notifier.NewPull(r.Context(), pull) 1392 1371 1372 + s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 1373 + 1393 1374 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1394 1375 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1395 1376 } ··· 1403 1384 patch string, 1404 1385 sourceRev string, 1405 1386 pullSource *models.PullSource, 1387 + stackTitles, stackBodies map[string]string, 1406 1388 ) { 1407 1389 l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 1408 1390 1409 1391 // run some necessary checks for stacked-prs first 1410 1392 1411 - // must be branch or fork based 1412 - if sourceRev == "" { 1413 - l.Error("stacked PR from patch-based pull") 1414 - s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1415 - return 1416 - } 1417 - 1418 1393 formatPatches, err := patchutil.ExtractPatches(patch) 1419 1394 if err != nil { 1420 1395 l.Error("failed to extract patches", "err", err) ··· 1450 1425 } 1451 1426 1452 1427 // build a stack out of this patch 1453 - stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs) 1428 + stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 1454 1429 if err != nil { 1455 1430 l.Error("failed to create stack", "err", err) 1456 1431 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1513 1488 s.notifier.NewPull(r.Context(), p) 1514 1489 } 1515 1490 1491 + s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 1492 + 1516 1493 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1517 1494 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1518 1495 } ··· 1545 1522 } 1546 1523 } 1547 1524 1548 - func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1549 - user := s.oauth.GetMultiAccountUser(r) 1550 - 1551 - s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1552 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1553 - }) 1525 + func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 1526 + body := r.FormValue("body") 1527 + s.pages.MarkdownPreviewFragment(w, body) 1554 1528 } 1555 1529 1556 - func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1557 - l := s.logger.With("handler", "CompareBranchesFragment") 1530 + func (s *Pulls) RefreshWizard(w http.ResponseWriter, r *http.Request) { 1531 + l := s.logger.With("handler", "RefreshWizard") 1558 1532 1559 - user := s.oauth.GetMultiAccountUser(r) 1560 1533 f, err := s.repoResolver.Resolve(r) 1561 1534 if err != nil { 1562 - l.Error("failed to get repo and knot", "err", err) 1535 + l.Error("failed to resolve repo", "err", err) 1536 + s.pages.Error503(w) 1563 1537 return 1564 1538 } 1565 1539 1566 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1567 - 1568 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1540 + params, err := s.wizardParams(r, f) 1569 1541 if err != nil { 1570 - l.Error("failed to fetch branches", "err", err) 1542 + l.Error("failed to build wizard params", "err", err) 1571 1543 s.pages.Error503(w) 1572 1544 return 1573 1545 } 1546 + w.Header().Set("HX-Replace-Url", wizardCanonicalURL(params)) 1547 + s.pages.PullWizardHostFragment(w, params) 1548 + } 1574 1549 1575 - var result types.RepoBranchesResponse 1576 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1577 - l.Error("failed to decode XRPC response", "err", err) 1578 - s.pages.Error503(w) 1579 - return 1550 + func wizardCanonicalURL(params pages.RepoNewPullParams) string { 1551 + base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 1552 + q := url.Values{} 1553 + if params.IsStacked { 1554 + q.Set("mode", "stack") 1555 + } 1556 + if params.Source != "" && params.Source != pages.SourceBranch { 1557 + q.Set("source", string(params.Source)) 1558 + } 1559 + if params.SourceBranch != "" { 1560 + q.Set("sourceBranch", params.SourceBranch) 1561 + } 1562 + if params.TargetBranch != "" { 1563 + q.Set("targetBranch", params.TargetBranch) 1564 + } 1565 + if params.Source == pages.SourceFork && params.Fork != "" { 1566 + q.Set("fork", params.Fork) 1567 + } 1568 + if len(q) == 0 { 1569 + return base 1570 + } 1571 + return base + "?" + q.Encode() 1572 + } 1573 + 1574 + func (s *Pulls) wizardParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 1575 + l := s.logger.With("handler", "wizardParams") 1576 + user := s.oauth.GetMultiAccountUser(r) 1577 + 1578 + branches, err := s.listBranches(r.Context(), repo) 1579 + if err != nil { 1580 + return pages.RepoNewPullParams{}, err 1581 + } 1582 + 1583 + var forks []models.Repo 1584 + if user != nil { 1585 + forks, err = db.GetForksByDid(s.db, user.Did) 1586 + if err != nil { 1587 + l.Warn("failed to list user forks", "err", err, "user", user.Did) 1588 + } 1589 + } 1590 + 1591 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 1592 + source, ok := pages.ParseSource(r.FormValue("source")) 1593 + if !ok { 1594 + source = pages.SourceBranch 1595 + if !repoInfo.Roles.IsPushAllowed() { 1596 + source = pages.SourceFork 1597 + } 1598 + } 1599 + 1600 + sourceBranch := r.FormValue("sourceBranch") 1601 + targetBranch := r.FormValue("targetBranch") 1602 + fork := r.FormValue("fork") 1603 + patch := r.FormValue("patch") 1604 + 1605 + if source == pages.SourceFork && fork == "" && len(forks) == 1 { 1606 + fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 1607 + } 1608 + 1609 + var forkBranches []types.Branch 1610 + if source == pages.SourceFork && fork != "" { 1611 + forkBranches, err = s.listForkBranches(r.Context(), fork) 1612 + if err != nil { 1613 + l.Warn("failed to list fork branches", "err", err, "fork", fork) 1614 + } 1615 + } 1616 + 1617 + sourceBranchList := sourceBranchChoices(branches) 1618 + targetBranch = defaultTargetBranch(branches, targetBranch) 1619 + sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 1620 + 1621 + comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 1622 + var prefillErr string 1623 + if prefetchErr != nil { 1624 + prefillErr = prefetchErr.Error() 1625 + } 1626 + 1627 + emailToDid, err := s.comparisonEmailToDid(comparison) 1628 + if err != nil { 1629 + l.Warn("failed to map commit emails to dids", "err", err) 1630 + emailToDid = make(map[string]string) 1631 + } 1632 + 1633 + mergeCheck := s.wizardMergeCheck(r.Context(), repo, targetBranch, comparison) 1634 + 1635 + refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 1636 + var diffOpts types.DiffOpts 1637 + if r.FormValue("diff") == "split" { 1638 + diffOpts.Split = true 1639 + } 1640 + diffOpts.RefreshUrl = refreshUrl 1641 + 1642 + labelDefs, err := s.pullLabelDefs(repo) 1643 + if err != nil { 1644 + l.Warn("failed to load label definitions", "err", err) 1645 + } 1646 + labelState := labelStateFromForm(r.Form, labelDefs) 1647 + perCidLabelForms := parseStackLabelForms(r.Form) 1648 + stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 1649 + for cid, perForm := range perCidLabelForms { 1650 + stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 1651 + } 1652 + 1653 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1654 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1655 + stackSplits := parseBracketedForm(r.Form, "stackSplit") 1656 + 1657 + title := r.FormValue("title") 1658 + body := r.FormValue("body") 1659 + if comparison != nil && len(comparison.FormatPatch) > 0 { 1660 + first := comparison.FormatPatch[0] 1661 + if title == "" && first.PatchHeader != nil { 1662 + title = first.Title 1663 + } 1664 + if body == "" && first.PatchHeader != nil { 1665 + body = first.Body 1666 + } 1580 1667 } 1581 1668 1582 - branches := result.Branches 1583 - sort.Slice(branches, func(i int, j int) bool { 1584 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1585 - }) 1669 + isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 1670 + var perCommitDiffs []*types.NiceDiff 1671 + var stackDiffOpts []types.DiffOpts 1672 + if isStacked { 1673 + perCommitDiffs, stackDiffOpts = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 1674 + } 1675 + 1676 + return pages.RepoNewPullParams{ 1677 + LoggedInUser: user, 1678 + RepoInfo: repoInfo, 1679 + Branches: branches, 1680 + SourceBranches: sourceBranchList, 1681 + ForkBranches: forkBranches, 1682 + Forks: forks, 1683 + Source: source, 1684 + SourceBranch: sourceBranch, 1685 + TargetBranch: targetBranch, 1686 + Fork: fork, 1687 + Patch: patch, 1688 + Title: title, 1689 + Body: body, 1690 + IsStacked: isStacked, 1691 + Comparison: comparison, 1692 + Diff: diff, 1693 + PerCommitDiffs: perCommitDiffs, 1694 + DiffOpts: diffOpts, 1695 + StackDiffOpts: stackDiffOpts, 1696 + EmailToDid: emailToDid, 1697 + MergeCheck: mergeCheck, 1698 + StackTitles: stackTitles, 1699 + StackBodies: stackBodies, 1700 + PrefillError: prefillErr, 1701 + LabelDefs: labelDefs, 1702 + LabelState: labelState, 1703 + StackLabelStates: stackLabelStates, 1704 + }, nil 1705 + } 1586 1706 1587 - withoutDefault := []types.Branch{} 1588 - for _, b := range branches { 1589 - if b.IsDefault { 1707 + func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 1708 + defs, err := db.GetLabelDefinitions( 1709 + s.db, 1710 + orm.FilterIn("at_uri", repo.Labels), 1711 + orm.FilterContains("scope", tangled.RepoPullNSID), 1712 + ) 1713 + if err != nil { 1714 + return nil, err 1715 + } 1716 + 1717 + out := make(map[string]*models.LabelDefinition, len(defs)) 1718 + for i := range defs { 1719 + d := defs[i] 1720 + if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 1590 1721 continue 1591 1722 } 1592 - withoutDefault = append(withoutDefault, b) 1723 + out[d.AtUri().String()] = &d 1593 1724 } 1725 + return out, nil 1726 + } 1594 1727 1595 - s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1596 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1597 - Branches: withoutDefault, 1598 - }) 1728 + func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 1729 + return func(yield func(string, string) bool) { 1730 + for key := range defs { 1731 + for _, v := range form[key] { 1732 + if v == "" { 1733 + continue 1734 + } 1735 + if !yield(key, v) { 1736 + return 1737 + } 1738 + } 1739 + } 1740 + } 1599 1741 } 1600 1742 1601 - func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1602 - l := s.logger.With("handler", "CompareForksFragment") 1743 + func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 1744 + state := models.NewLabelState() 1745 + actx := &models.LabelApplicationCtx{Defs: defs} 1746 + for key, val := range formLabelEntries(form, defs) { 1747 + _ = actx.ApplyLabelOp(state, models.LabelOp{ 1748 + Operation: models.LabelOperationAdd, 1749 + OperandKey: key, 1750 + OperandValue: val, 1751 + }) 1752 + } 1753 + return state 1754 + } 1603 1755 1604 - user := s.oauth.GetMultiAccountUser(r) 1605 - if user != nil { 1606 - l = l.With("user", user.Did) 1756 + func buildCreationLabelOps( 1757 + userDid syntax.DID, 1758 + subject syntax.ATURI, 1759 + rkey string, 1760 + form url.Values, 1761 + defs map[string]*models.LabelDefinition, 1762 + performedAt time.Time, 1763 + ) []models.LabelOp { 1764 + var ops []models.LabelOp 1765 + for key, val := range formLabelEntries(form, defs) { 1766 + ops = append(ops, models.LabelOp{ 1767 + Did: userDid.String(), 1768 + Rkey: rkey, 1769 + Subject: subject, 1770 + Operation: models.LabelOperationAdd, 1771 + OperandKey: key, 1772 + OperandValue: val, 1773 + PerformedAt: performedAt, 1774 + }) 1607 1775 } 1776 + return ops 1777 + } 1608 1778 1609 - forks, err := db.GetForksByDid(s.db, user.Did) 1779 + func (s *Pulls) applyCreationLabels( 1780 + ctx context.Context, 1781 + client *atclient.APIClient, 1782 + userDid syntax.DID, 1783 + pulls []*models.Pull, 1784 + form url.Values, 1785 + repo *models.Repo, 1786 + ) { 1787 + l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 1788 + 1789 + defs, err := s.pullLabelDefs(repo) 1610 1790 if err != nil { 1611 - l.Error("failed to get forks", "err", err) 1791 + l.Warn("failed to fetch label defs", "err", err) 1792 + return 1793 + } 1794 + if len(defs) == 0 { 1612 1795 return 1613 1796 } 1614 1797 1615 - s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1616 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1617 - Forks: forks, 1618 - Selected: r.URL.Query().Get("fork"), 1619 - }) 1620 - } 1798 + perCidForms := parseStackLabelForms(form) 1621 1799 1622 - func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1623 - l := s.logger.With("handler", "CompareForksBranchesFragment") 1800 + performedAt := time.Now() 1801 + for _, pull := range pulls { 1802 + labelForm := form 1803 + if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 1804 + if cid := pull.Submissions[0].ChangeId(); cid != "" { 1805 + if perForm, ok := perCidForms[cid]; ok { 1806 + labelForm = perForm 1807 + } 1808 + } 1809 + } 1810 + rkey := tid.TID() 1811 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 1624 1812 1625 - user := s.oauth.GetMultiAccountUser(r) 1626 - if user != nil { 1627 - l = l.With("user", user.Did) 1813 + valid := make([]models.LabelOp, 0, len(raw)) 1814 + for _, op := range raw { 1815 + def := defs[op.OperandKey] 1816 + if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 1817 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 1818 + continue 1819 + } 1820 + valid = append(valid, op) 1821 + } 1822 + if len(valid) == 0 { 1823 + continue 1824 + } 1825 + 1826 + record := models.LabelOpsAsRecord(valid) 1827 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1828 + Collection: tangled.LabelOpNSID, 1829 + Repo: userDid.String(), 1830 + Rkey: rkey, 1831 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 1832 + }); err != nil { 1833 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 1834 + continue 1835 + } 1836 + 1837 + if err := s.indexLabelOps(ctx, valid); err != nil { 1838 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 1839 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 1840 + Collection: tangled.LabelOpNSID, 1841 + Repo: userDid.String(), 1842 + Rkey: rkey, 1843 + }); err != nil { 1844 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 1845 + } 1846 + continue 1847 + } 1848 + 1849 + s.notifier.NewPullLabelOp(ctx, pull) 1628 1850 } 1851 + } 1629 1852 1630 - f, err := s.repoResolver.Resolve(r) 1853 + func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 1854 + tx, err := s.db.BeginTx(ctx, nil) 1631 1855 if err != nil { 1632 - l.Error("failed to get repo and knot", "err", err) 1633 - return 1856 + return err 1857 + } 1858 + defer tx.Rollback() 1859 + for _, op := range ops { 1860 + if _, err := db.AddLabelOp(tx, &op); err != nil { 1861 + return err 1862 + } 1634 1863 } 1864 + return tx.Commit() 1865 + } 1635 1866 1867 + func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 1636 1868 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1869 + xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 1870 + if err != nil { 1871 + return nil, err 1872 + } 1873 + var result types.RepoBranchesResponse 1874 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1875 + return nil, err 1876 + } 1877 + return result.Branches, nil 1878 + } 1637 1879 1638 - forkVal := r.URL.Query().Get("fork") 1639 - repoString := strings.SplitN(forkVal, "/", 2) 1640 - forkOwnerDid := repoString[0] 1641 - forkName := repoString[1] 1642 - // fork repo 1643 - repo, err := db.GetRepo( 1644 - s.db, 1645 - orm.FilterEq("did", forkOwnerDid), 1646 - orm.FilterEq("name", forkName), 1880 + func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 1881 + parts := strings.SplitN(forkIdent, "/", 2) 1882 + if len(parts) != 2 { 1883 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 1884 + } 1885 + forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 1886 + if err != nil { 1887 + return nil, err 1888 + } 1889 + branches, err := s.listBranches(ctx, forkRepo) 1890 + if err != nil { 1891 + return nil, err 1892 + } 1893 + return sortBranchesByRecency(branches), nil 1894 + } 1895 + 1896 + func sourceBranchChoices(branches []types.Branch) []types.Branch { 1897 + withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 1898 + return b.IsDefault 1899 + }) 1900 + return sortBranchesByRecency(withoutDefault) 1901 + } 1902 + 1903 + func defaultTargetBranch(branches []types.Branch, current string) string { 1904 + if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 1905 + return current 1906 + } 1907 + if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 1908 + return branches[idx].Reference.Name 1909 + } 1910 + return "" 1911 + } 1912 + 1913 + func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 1914 + var candidates []types.Branch 1915 + switch source { 1916 + case pages.SourceFork: 1917 + candidates = forkBranches 1918 + case pages.SourceBranch: 1919 + candidates = branchChoices 1920 + default: 1921 + return current 1922 + } 1923 + if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 1924 + return current 1925 + } 1926 + if len(candidates) == 0 { 1927 + return "" 1928 + } 1929 + return candidates[0].Reference.Name 1930 + } 1931 + 1932 + func sortBranchesByRecency(branches []types.Branch) []types.Branch { 1933 + out := slices.Clone(branches) 1934 + sort.SliceStable(out, func(i, j int) bool { 1935 + if out[i].Commit == nil || out[j].Commit == nil { 1936 + return out[i].Commit != nil 1937 + } 1938 + return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 1939 + }) 1940 + return out 1941 + } 1942 + 1943 + func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 1944 + var ( 1945 + comparison *types.RepoFormatPatchResponse 1946 + err error 1647 1947 ) 1948 + switch source { 1949 + case pages.SourcePatch: 1950 + if strings.TrimSpace(patch) == "" { 1951 + return nil, nil, nil 1952 + } 1953 + if verr := s.validator.ValidatePatch(&patch); verr != nil { 1954 + return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 1955 + } 1956 + comparison = parsePastedPatch(patch) 1957 + case pages.SourceBranch: 1958 + if targetBranch == "" || sourceBranch == "" { 1959 + return nil, nil, nil 1960 + } 1961 + comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 1962 + case pages.SourceFork: 1963 + if fork == "" || targetBranch == "" || sourceBranch == "" { 1964 + return nil, nil, nil 1965 + } 1966 + comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 1967 + default: 1968 + return nil, nil, nil 1969 + } 1648 1970 if err != nil { 1649 - l.Error("failed to get repo", "fork_owner_did", forkOwnerDid, "fork_name", forkName, "err", err) 1650 - return 1971 + s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 1972 + return nil, nil, err 1973 + } 1974 + 1975 + return comparison, deriveDiff(comparison, targetBranch), nil 1976 + } 1977 + 1978 + func (s *Pulls) wizardMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 1979 + if comparison == nil || targetBranch == "" { 1980 + return nil 1981 + } 1982 + patch := comparison.CombinedPatchRaw 1983 + if patch == "" { 1984 + patch = comparison.FormatPatchRaw 1985 + } 1986 + if patch == "" { 1987 + return nil 1988 + } 1989 + 1990 + xrpcc := s.knotClient(repo.Knot) 1991 + 1992 + resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 1993 + Did: repo.Did, 1994 + Name: repo.Name, 1995 + Branch: targetBranch, 1996 + Patch: patch, 1997 + }) 1998 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1999 + s.logger.With("handler", "wizardMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 2000 + return &types.MergeCheckResponse{Error: xrpcerr.Error()} 2001 + } 2002 + 2003 + out := mergeCheckResponseFrom(resp) 2004 + return &out 2005 + } 2006 + 2007 + func bracketComponents(key, prefix string) ([]string, bool) { 2008 + if !strings.HasPrefix(key, prefix) { 2009 + return nil, false 2010 + } 2011 + rest := key[len(prefix):] 2012 + var parts []string 2013 + for len(rest) > 0 { 2014 + if !strings.HasPrefix(rest, "[") { 2015 + return nil, false 2016 + } 2017 + end := strings.Index(rest, "]") 2018 + if end <= 0 { 2019 + return nil, false 2020 + } 2021 + parts = append(parts, rest[1:end]) 2022 + rest = rest[end+1:] 2023 + } 2024 + if len(parts) == 0 { 2025 + return nil, false 2026 + } 2027 + return parts, true 2028 + } 2029 + 2030 + func parseBracketedForm(form url.Values, prefix string) map[string]string { 2031 + out := make(map[string]string) 2032 + for key, vals := range form { 2033 + parts, ok := bracketComponents(key, prefix) 2034 + if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 2035 + continue 2036 + } 2037 + out[parts[0]] = vals[0] 2038 + } 2039 + return out 2040 + } 2041 + 2042 + func parseStackLabelForms(form url.Values) map[string]url.Values { 2043 + out := make(map[string]url.Values) 2044 + for key, vals := range form { 2045 + parts, ok := bracketComponents(key, "stackLabel") 2046 + if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 2047 + continue 2048 + } 2049 + cid, atUri := parts[0], parts[1] 2050 + if _, ok := out[cid]; !ok { 2051 + out[cid] = make(url.Values) 2052 + } 2053 + out[cid][atUri] = append(out[cid][atUri], vals...) 2054 + } 2055 + return out 2056 + } 2057 + 2058 + func (s *Pulls) comparisonEmailToDid(comparison *types.RepoFormatPatchResponse) (map[string]string, error) { 2059 + if comparison == nil { 2060 + return make(map[string]string), nil 2061 + } 2062 + seen := make(map[string]struct{}) 2063 + for _, p := range comparison.FormatPatch { 2064 + if p.PatchHeader == nil { 2065 + continue 2066 + } 2067 + if p.Author != nil && p.Author.Email != "" { 2068 + seen[p.Author.Email] = struct{}{} 2069 + } 2070 + if p.Committer != nil && p.Committer.Email != "" { 2071 + seen[p.Committer.Email] = struct{}{} 2072 + } 2073 + } 2074 + if len(seen) == 0 { 2075 + return make(map[string]string), nil 2076 + } 2077 + emails := slices.Collect(maps.Keys(seen)) 2078 + return db.GetEmailToDid(s.db, emails, true) 2079 + } 2080 + 2081 + func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 2082 + if patch == "" { 2083 + return nil 1651 2084 } 2085 + response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 2086 + if patchutil.IsFormatPatch(patch) { 2087 + if patches, err := patchutil.ExtractPatches(patch); err == nil { 2088 + response.FormatPatch = patches 2089 + } 2090 + } 2091 + return response 2092 + } 1652 2093 1653 - sourceXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, repo.RepoAt().String()) 2094 + func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2095 + xrpcc := s.knotClient(repo.Knot) 2096 + 2097 + xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1654 2098 if err != nil { 1655 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1656 - l.Error("failed to call XRPC repo.branches for source", "xrpcerr", xrpcerr, "err", err) 1657 - s.pages.Error503(w) 1658 - return 1659 - } 1660 - l.Error("failed to fetch source branches", "err", err) 1661 - return 2099 + return nil, err 1662 2100 } 1663 2101 1664 - // Decode source branches 1665 - var sourceBranches types.RepoBranchesResponse 1666 - if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1667 - l.Error("failed to decode source branches XRPC response", "err", err) 1668 - s.pages.Error503(w) 1669 - return 2102 + var comparison types.RepoFormatPatchResponse 2103 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2104 + return nil, err 1670 2105 } 2106 + return &comparison, nil 2107 + } 1671 2108 1672 - targetXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 2109 + func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2110 + parts := strings.SplitN(forkIdent, "/", 2) 2111 + if len(parts) != 2 { 2112 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 2113 + } 2114 + fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 1673 2115 if err != nil { 1674 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1675 - l.Error("failed to call XRPC repo.branches for target", "xrpcerr", xrpcerr, "err", err) 1676 - s.pages.Error503(w) 1677 - return 2116 + return nil, err 2117 + } 2118 + 2119 + client, err := s.oauth.ServiceClient( 2120 + r, 2121 + oauth.WithService(fork.Knot), 2122 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 2123 + oauth.WithDev(s.config.Core.Dev), 2124 + ) 2125 + if err != nil { 2126 + return nil, err 2127 + } 2128 + 2129 + resp, err := tangled.RepoHiddenRef( 2130 + r.Context(), 2131 + client, 2132 + &tangled.RepoHiddenRef_Input{ 2133 + ForkRef: sourceBranch, 2134 + RemoteRef: targetBranch, 2135 + Repo: fork.RepoAt().String(), 2136 + }, 2137 + ) 2138 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2139 + return nil, xrpcerr 2140 + } 2141 + if !resp.Success { 2142 + if resp.Error != nil { 2143 + return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 1678 2144 } 1679 - l.Error("failed to fetch target branches", "err", err) 1680 - return 2145 + return nil, fmt.Errorf("hidden ref failed") 1681 2146 } 1682 2147 1683 - // Decode target branches 1684 - var targetBranches types.RepoBranchesResponse 1685 - if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1686 - l.Error("failed to decode target branches XRPC response", "err", err) 1687 - s.pages.Error503(w) 1688 - return 2148 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 2149 + forkXrpcc := s.knotClient(fork.Knot) 2150 + 2151 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 2152 + if err != nil { 2153 + return nil, err 1689 2154 } 1690 2155 1691 - sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1692 - return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1693 - }) 2156 + var comparison types.RepoFormatPatchResponse 2157 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 2158 + return nil, err 2159 + } 2160 + return &comparison, nil 2161 + } 2162 + 2163 + func stackPerCommitDiffs( 2164 + comparison *types.RepoFormatPatchResponse, 2165 + targetBranch, refreshUrl string, 2166 + stackSplits map[string]string, 2167 + ) ([]*types.NiceDiff, []types.DiffOpts) { 2168 + if comparison == nil { 2169 + return nil, nil 2170 + } 2171 + n := len(comparison.FormatPatch) 2172 + diffs := make([]*types.NiceDiff, n) 2173 + opts := make([]types.DiffOpts, n) 2174 + for i, p := range comparison.FormatPatch { 2175 + nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 2176 + diffs[i] = &nd 2177 + cid := p.ChangeIdOrEmpty() 2178 + if cid == "" { 2179 + continue 2180 + } 2181 + opts[i] = types.DiffOpts{ 2182 + Split: stackSplits[cid] == "split", 2183 + RefreshUrl: refreshUrl, 2184 + Target: fmt.Sprintf("#stack-diff-%s", cid), 2185 + Field: fmt.Sprintf("stackSplit[%s]", cid), 2186 + } 2187 + } 2188 + return diffs, opts 2189 + } 1694 2190 1695 - s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1696 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1697 - SourceBranches: sourceBranches.Branches, 1698 - TargetBranches: targetBranches.Branches, 1699 - }) 2191 + func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 2192 + if comparison == nil { 2193 + return nil 2194 + } 2195 + raw := comparison.CombinedPatchRaw 2196 + if raw == "" { 2197 + raw = comparison.FormatPatchRaw 2198 + } 2199 + d := patchutil.AsNiceDiff(raw, targetBranch) 2200 + return &d 1700 2201 } 1701 2202 1702 2203 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { ··· 1804 2305 return 1805 2306 } 1806 2307 1807 - scheme := "http" 1808 - if !s.config.Core.Dev { 1809 - scheme = "https" 1810 - } 1811 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1812 - xrpcc := &indigoxrpc.Client{ 1813 - Host: host, 1814 - } 2308 + xrpcc := s.knotClient(f.Knot) 1815 2309 1816 2310 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 1817 2311 if err != nil { ··· 1908 2402 1909 2403 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1910 2404 // extract patch by performing compare 1911 - forkScheme := "http" 1912 - if !s.config.Core.Dev { 1913 - forkScheme = "https" 1914 - } 1915 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1916 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 2405 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 1917 2406 if err != nil { 1918 2407 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1919 2408 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) ··· 2086 2575 blobs[i] = blob.Blob 2087 2576 } 2088 2577 2089 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs) 2578 + newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 2090 2579 if err != nil { 2091 2580 l.Error("failed to create resubmitted stack", "err", err) 2092 2581 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2583 3072 pullSource *models.PullSource, 2584 3073 formatPatches []types.FormatPatch, 2585 3074 blobs []*lexutil.LexBlob, 3075 + stackTitles, stackBodies map[string]string, 2586 3076 ) (models.Stack, error) { 2587 3077 var stack models.Stack 2588 3078 var parentAtUri *syntax.ATURI 2589 3079 for i, fp := range formatPatches { 2590 3080 // all patches must have a jj change-id 2591 - _, err := fp.ChangeId() 3081 + cid, err := fp.ChangeId() 2592 3082 if err != nil { 2593 3083 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2594 3084 } 2595 3085 2596 3086 title := fp.Title 2597 3087 body := fp.Body 3088 + if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 3089 + title = override 3090 + } 3091 + if override, ok := stackBodies[cid]; ok { 3092 + body = override 3093 + } 2598 3094 rkey := tid.TID() 2599 3095 2600 3096 mentions, references := s.mentionsResolver.Resolve(ctx, body)
+3 -4
appview/pulls/router.go
··· 12 12 r.With(middleware.Paginate).Get("/", s.RepoPulls) 13 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 14 r.Get("/", s.NewPull) 15 - r.Get("/patch-upload", s.PatchUploadFragment) 16 15 r.Post("/validate-patch", s.ValidatePatch) 17 - r.Get("/compare-branches", s.CompareBranchesFragment) 18 - r.Get("/compare-forks", s.CompareForksFragment) 19 - r.Get("/fork-branches", s.CompareForksBranchesFragment) 16 + r.Get("/refresh", s.RefreshWizard) 17 + r.Post("/refresh", s.RefreshWizard) 18 + r.Post("/preview", s.MarkdownPreview) 20 19 r.Post("/", s.NewPull) 21 20 }) 22 21
+269
appview/pulls/wizard_helpers_test.go
··· 1 + package pulls 2 + 3 + import ( 4 + "net/url" 5 + "reflect" 6 + "testing" 7 + "time" 8 + 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/pages/repoinfo" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + func TestBracketComponents(t *testing.T) { 17 + cases := []struct { 18 + key, prefix string 19 + want []string 20 + ok bool 21 + }{ 22 + {"foo[a]", "foo", []string{"a"}, true}, 23 + {"foo[a][b]", "foo", []string{"a", "b"}, true}, 24 + {"foo[a][b][c]", "foo", []string{"a", "b", "c"}, true}, 25 + {"foo[]", "foo", []string{""}, true}, 26 + {"foo[a][]", "foo", []string{"a", ""}, true}, 27 + {"foo", "foo", nil, false}, 28 + {"bar[a]", "foo", nil, false}, 29 + {"foo[a", "foo", nil, false}, 30 + {"fooa]", "foo", nil, false}, 31 + {"foo[a]extra", "foo", nil, false}, 32 + {"", "foo", nil, false}, 33 + } 34 + for _, c := range cases { 35 + got, ok := bracketComponents(c.key, c.prefix) 36 + if ok != c.ok || !reflect.DeepEqual(got, c.want) { 37 + t.Errorf("bracketComponents(%q, %q) = %v, %v; want %v, %v", c.key, c.prefix, got, ok, c.want, c.ok) 38 + } 39 + } 40 + } 41 + 42 + func TestParseBracketedForm(t *testing.T) { 43 + form := url.Values{ 44 + "stackTitle[abc]": {"hello"}, 45 + "stackTitle[xyz]": {"world", "ignored"}, 46 + "stackTitle[]": {"empty-id"}, 47 + "stackTitle[a][b]": {"too-deep"}, 48 + "stackTitle": {"no-bracket"}, 49 + "unrelated[abc]": {"skip"}, 50 + "stackTitle[noval]": {}, 51 + } 52 + got := parseBracketedForm(form, "stackTitle") 53 + want := map[string]string{ 54 + "abc": "hello", 55 + "xyz": "world", 56 + } 57 + if !reflect.DeepEqual(got, want) { 58 + t.Errorf("parseBracketedForm = %v; want %v", got, want) 59 + } 60 + } 61 + 62 + func TestParseStackLabelForms(t *testing.T) { 63 + form := url.Values{ 64 + "stackLabel[c1][at://uri/a]": {"v1"}, 65 + "stackLabel[c1][at://uri/b]": {"v2"}, 66 + "stackLabel[c2][at://uri/a]": {"v3", "v4"}, 67 + "stackLabel[c1][]": {"empty-uri"}, 68 + "stackLabel[][at://uri/a]": {"empty-cid"}, 69 + "stackLabel[c1]": {"missing-second-bracket"}, 70 + "stackLabel[c1][a][b]": {"too-deep"}, 71 + "stackTitle[c1]": {"wrong-prefix"}, 72 + } 73 + got := parseStackLabelForms(form) 74 + want := map[string]url.Values{ 75 + "c1": { 76 + "at://uri/a": {"v1"}, 77 + "at://uri/b": {"v2"}, 78 + }, 79 + "c2": { 80 + "at://uri/a": {"v3", "v4"}, 81 + }, 82 + } 83 + if !reflect.DeepEqual(got, want) { 84 + t.Errorf("parseStackLabelForms = %v; want %v", got, want) 85 + } 86 + } 87 + 88 + func TestDefaultTargetBranch(t *testing.T) { 89 + branches := []types.Branch{ 90 + {Reference: types.Reference{Name: "feature"}}, 91 + {Reference: types.Reference{Name: "main"}, IsDefault: true}, 92 + } 93 + cases := []struct { 94 + name string 95 + branches []types.Branch 96 + current string 97 + want string 98 + }{ 99 + {"current is valid", branches, "feature", "feature"}, 100 + {"current is default", branches, "main", "main"}, 101 + {"current invalid, falls to default", branches, "ghost", "main"}, 102 + {"current empty, falls to default", branches, "", "main"}, 103 + {"no default, no match returns empty", []types.Branch{{Reference: types.Reference{Name: "only"}}}, "ghost", ""}, 104 + {"empty branches returns empty", nil, "anything", ""}, 105 + } 106 + for _, c := range cases { 107 + t.Run(c.name, func(t *testing.T) { 108 + if got := defaultTargetBranch(c.branches, c.current); got != c.want { 109 + t.Errorf("defaultTargetBranch = %q; want %q", got, c.want) 110 + } 111 + }) 112 + } 113 + } 114 + 115 + func TestDefaultSourceBranch(t *testing.T) { 116 + choices := []types.Branch{ 117 + {Reference: types.Reference{Name: "feature"}}, 118 + {Reference: types.Reference{Name: "wip"}}, 119 + } 120 + forks := []types.Branch{ 121 + {Reference: types.Reference{Name: "fork-feature"}}, 122 + } 123 + cases := []struct { 124 + name string 125 + source pages.Source 126 + current string 127 + want string 128 + }{ 129 + {"branch source, valid current", pages.SourceBranch, "feature", "feature"}, 130 + {"branch source, invalid falls to first", pages.SourceBranch, "ghost", "feature"}, 131 + {"branch source, empty falls to first", pages.SourceBranch, "", "feature"}, 132 + {"fork source, valid current", pages.SourceFork, "fork-feature", "fork-feature"}, 133 + {"fork source, invalid falls to first fork", pages.SourceFork, "ghost", "fork-feature"}, 134 + {"patch source preserves current", pages.SourcePatch, "anything", "anything"}, 135 + } 136 + for _, c := range cases { 137 + t.Run(c.name, func(t *testing.T) { 138 + if got := defaultSourceBranch(c.source, c.current, choices, forks); got != c.want { 139 + t.Errorf("defaultSourceBranch = %q; want %q", got, c.want) 140 + } 141 + }) 142 + } 143 + if got := defaultSourceBranch(pages.SourceBranch, "", nil, nil); got != "" { 144 + t.Errorf("empty choices should return empty, got %q", got) 145 + } 146 + } 147 + 148 + func TestSortBranchesByRecency(t *testing.T) { 149 + mk := func(name string, when *time.Time) types.Branch { 150 + b := types.Branch{Reference: types.Reference{Name: name}} 151 + if when != nil { 152 + b.Commit = &object.Commit{Committer: object.Signature{When: *when}} 153 + } 154 + return b 155 + } 156 + t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 157 + t2 := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) 158 + t3 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) 159 + 160 + in := []types.Branch{ 161 + mk("oldest", &t1), 162 + mk("newest", &t3), 163 + mk("nil-commit", nil), 164 + mk("middle", &t2), 165 + } 166 + got := sortBranchesByRecency(in) 167 + wantNames := []string{"newest", "middle", "oldest", "nil-commit"} 168 + for i, want := range wantNames { 169 + if got[i].Reference.Name != want { 170 + t.Errorf("position %d: got %q, want %q", i, got[i].Reference.Name, want) 171 + } 172 + } 173 + 174 + if &got[0] == &in[0] { 175 + t.Error("expected new slice, got aliased input") 176 + } 177 + } 178 + 179 + func TestWizardCanonicalURL(t *testing.T) { 180 + repo := repoinfo.RepoInfo{OwnerDid: "did:plc:abc", Name: "demo"} 181 + cases := []struct { 182 + name string 183 + p pages.RepoNewPullParams 184 + want string 185 + }{ 186 + { 187 + "defaults", 188 + pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch}, 189 + "/did:plc:abc/demo/pulls/new", 190 + }, 191 + { 192 + "stacked", 193 + pages.RepoNewPullParams{RepoInfo: repo, Source: pages.SourceBranch, IsStacked: true}, 194 + "/did:plc:abc/demo/pulls/new?mode=stack", 195 + }, 196 + { 197 + "fork with selection", 198 + pages.RepoNewPullParams{ 199 + RepoInfo: repo, 200 + Source: pages.SourceFork, 201 + Fork: "did:plc:other/repo", 202 + SourceBranch: "feature", 203 + TargetBranch: "main", 204 + }, 205 + "/did:plc:abc/demo/pulls/new?fork=did%3Aplc%3Aother%2Frepo&source=fork&sourceBranch=feature&targetBranch=main", 206 + }, 207 + { 208 + "branch with selection drops source param", 209 + pages.RepoNewPullParams{ 210 + RepoInfo: repo, 211 + Source: pages.SourceBranch, 212 + SourceBranch: "feature", 213 + TargetBranch: "main", 214 + }, 215 + "/did:plc:abc/demo/pulls/new?sourceBranch=feature&targetBranch=main", 216 + }, 217 + { 218 + "fork field skipped when source != fork", 219 + pages.RepoNewPullParams{ 220 + RepoInfo: repo, 221 + Source: pages.SourceBranch, 222 + Fork: "stale", 223 + }, 224 + "/did:plc:abc/demo/pulls/new", 225 + }, 226 + } 227 + for _, c := range cases { 228 + t.Run(c.name, func(t *testing.T) { 229 + if got := wizardCanonicalURL(c.p); got != c.want { 230 + t.Errorf("wizardCanonicalURL = %q; want %q", got, c.want) 231 + } 232 + }) 233 + } 234 + } 235 + 236 + func TestLabelStateFromForm(t *testing.T) { 237 + bug := &models.LabelDefinition{ 238 + Did: "did:plc:test", Rkey: "bug", Name: "bug", 239 + ValueType: models.ValueType{Type: models.ConcreteTypeNull}, 240 + Scope: []string{"sh.tangled.repo.pull"}, 241 + } 242 + priority := &models.LabelDefinition{ 243 + Did: "did:plc:test", Rkey: "priority", Name: "priority", 244 + ValueType: models.ValueType{Type: models.ConcreteTypeString, Enum: []string{"low", "med", "high"}}, 245 + Scope: []string{"sh.tangled.repo.pull"}, 246 + } 247 + defs := map[string]*models.LabelDefinition{ 248 + bug.AtUri().String(): bug, 249 + priority.AtUri().String(): priority, 250 + } 251 + 252 + form := url.Values{ 253 + bug.AtUri().String(): {"null"}, 254 + priority.AtUri().String(): {"high", ""}, 255 + "unrelated": {"ignored"}, 256 + } 257 + state := labelStateFromForm(form, defs) 258 + if !state.ContainsLabel(bug.AtUri().String()) { 259 + t.Error("expected bug label in state") 260 + } 261 + if !state.ContainsLabel(priority.AtUri().String()) { 262 + t.Error("expected priority label in state") 263 + } 264 + 265 + emptyState := labelStateFromForm(url.Values{}, defs) 266 + if emptyState.ContainsLabel(bug.AtUri().String()) { 267 + t.Error("empty form should produce empty state") 268 + } 269 + }
+31 -16
knotserver/internal.go
··· 6 6 "fmt" 7 7 "log/slog" 8 8 "net/http" 9 + "net/url" 9 10 "os" 10 11 "path/filepath" 11 12 "strings" ··· 257 258 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 258 259 } 259 260 260 - err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid) 261 + err = h.emitPullRequestLink(&resp.Messages, line, ownerDid, repoName, repoDid) 261 262 if err != nil { 262 - l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 263 + l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 263 264 } 264 265 265 266 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) ··· 415 416 return h.db.InsertEvent(event, h.n) 416 417 } 417 418 418 - func (h *InternalHandle) emitCompareLink( 419 + func (h *InternalHandle) emitPullRequestLink( 419 420 clientMsgs *[]string, 420 421 line git.PostReceiveLine, 421 422 ownerDid string, 422 423 repoName string, 423 424 repoDid string, 424 425 ) error { 425 - // this is a second push to a branch, don't reply with the link again 426 - if !line.OldSha.IsZero() { 426 + if line.NewSha.IsZero() { 427 427 return nil 428 428 } 429 429 430 430 // the ref was not updated to a new hash, don't reply with the link 431 431 // 432 432 // NOTE: do we need this? 433 - if line.NewSha.String() == line.OldSha.String() { 433 + if line.NewSha == line.OldSha { 434 434 return nil 435 435 } 436 436 437 437 pushedRef := plumbing.ReferenceName(line.Ref) 438 + if !pushedRef.IsBranch() { 439 + return nil 440 + } 438 441 439 - userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 440 - user := ownerDid 441 - if err == nil { 442 - user = userIdent.Handle.String() 442 + if !line.OldSha.IsZero() { 443 + return nil 443 444 } 444 445 445 446 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) ··· 457 458 return err 458 459 } 459 460 461 + pushedBranch := pushedRef.Short() 462 + 460 463 // pushing to default branch 461 - if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 464 + if pushedBranch == defaultBranch { 462 465 return nil 463 466 } 464 467 465 - // pushing a tag, don't prompt the user the open a PR 466 - if pushedRef.IsTag() { 467 - return nil 468 + userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 469 + user := ownerDid 470 + if err == nil { 471 + user = userIdent.Handle.String() 472 + } 473 + 474 + query := url.Values{} 475 + query.Set("source", "branch") 476 + query.Set("sourceBranch", pushedBranch) 477 + query.Set("targetBranch", defaultBranch) 478 + 479 + basePath, err := url.JoinPath(h.c.AppViewEndpoint, user, repoName, "pulls", "new") 480 + if err != nil { 481 + return err 468 482 } 483 + pullURL := basePath + "?" + query.Encode() 469 484 470 485 ZWS := "\u200B" 471 486 *clientMsgs = append(*clientMsgs, ZWS) 472 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 473 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 487 + *clientMsgs = append(*clientMsgs, "→ Open pull request:") 488 + *clientMsgs = append(*clientMsgs, " "+pullURL) 474 489 *clientMsgs = append(*clientMsgs, ZWS) 475 490 return nil 476 491 }
+4 -1
types/diff.go
··· 8 8 ) 9 9 10 10 type DiffOpts struct { 11 - Split bool `json:"split"` 11 + Split bool `json:"split"` 12 + RefreshUrl string `json:"refresh_url,omitempty"` 13 + Target string `json:"target,omitempty"` 14 + Field string `json:"field,omitempty"` 12 15 } 13 16 14 17 func (d DiffOpts) Encode() string {
+8
types/patch.go
··· 18 18 } 19 19 return "", fmt.Errorf("no change-id found") 20 20 } 21 + 22 + func (f FormatPatch) ChangeIdOrEmpty() string { 23 + id, err := f.ChangeId() 24 + if err != nil { 25 + return "" 26 + } 27 + return id 28 + }