gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mremain/lib/pulls.hl

7.7 KB

  1. // lib/pulls.hl — PULL REQUESTS (ticket gitoria#11): /pulls of a repo address.
  2. // A pull request is a COMMIT whose subject starts `|||PR ` (target = the repo's main branch) or
  3. // `|||PR|<branch>] ` (target = <branch>). Nothing is stored: the list is read from git each time.
  4. // Title = the text after the marker (else the source branch's name). Source = the branch holding the
  5. // commit that is not the target. State: `merged` when the commit is in the target branch, else `open`.
  6. // One request per source branch (its newest marker commit). Merging happens with git itself (push to the target).
  7. import { localStamp } from './util.hl'
  8. import { git, gitRaw, branchNames, pickDefault } from './git.hl'
  9. import { enc, isHexId, tabs, safeName, parsePull } from './git-helpers.hl'
  10. import { withPullParts } from './tickets-helpers.hl'
  11. static maxPulls = 50
  12. static pullScan = 500
  13. // the list, SYNCHRONOUSLY (git() calls back before it returns): what the pulls page's root uses
  14. static pullsNow = (slug, setting) => {
  15. let out = null
  16. pulls(slug, setting, (result) => {
  17. out = result
  18. return null
  19. })
  20. return out
  21. }
  22. static pullShape = (state, message) => {
  23. return { state = state isMessage = message != '' message = message pulls = [] hasPulls = false noPulls = false defaultName = '' openCount = 0 mergedCount = 0 }
  24. }
  25. // every pull request of a repo → done(result). setting = the repo's saved main branch ('' = none)
  26. static pulls = (slug, setting, done) => {
  27. branchNames(slug, (names) => {
  28. if (names.length == 0) {
  29. done(pullShape('empty', 'This repository has no commits yet.'))
  30. return null
  31. }
  32. dflt = pickDefault(names, setting)
  33. git(slug, ['log', '-' + pullScan, '--branches', '--format=%H%x09%ct%x09%an%x09%s'], pullScan, (lg) => {
  34. found = []
  35. for (l of lg.ok ? lg.lines : []) {
  36. let f = tabs(l)
  37. if (f.length >= 4) {
  38. let sub = f.slice(3).join(' ')
  39. let pr = parsePull(sub)
  40. if (pr != null && isHexId(f[0]) && found.length < maxPulls) {
  41. found.push({ sha = f[0] short = f[0].slice(0, 8) date = localStamp(toNumber(f[1]) * 1000) author = f[2] target = pr.target == '' ? dflt : pr.target title = pr.title })
  42. }
  43. }
  44. }
  45. out = pullShape('ok', '')
  46. out.defaultName = dflt
  47. seen = []
  48. next = (i) => {
  49. if (i >= found.length) {
  50. out.hasPulls = out.pulls.length > 0
  51. out.noPulls = out.pulls.length == 0
  52. done(out)
  53. return null
  54. }
  55. c = found[i]
  56. git(slug, ['for-each-ref', '--contains', c.sha, '--format=%(refname:short)', 'refs/heads'], 500, (ct) => {
  57. let source = ''
  58. for (n of ct.ok ? ct.lines : []) { if (source == '' && safeName(n) && n != c.target) { source = n } }
  59. if (source != '' && seen.includes(source)) {
  60. next(i + 1)
  61. return null
  62. }
  63. if (source != '') { seen.push(source) }
  64. finish = (merged, targetMissing) => {
  65. let title = c.title != '' ? c.title : (source != '' ? source : c.short)
  66. out.pulls.push({ sha = c.sha short = c.short href = '/commit/' + c.sha title = title source = source hasSource = source != '' noSource = source == '' sourceHref = source != '' ? '/branch/' + enc(source) : '' target = c.target targetHref = targetMissing ? '' : '/branch/' + enc(c.target) targetLinked = !targetMissing state = merged ? 'merged' : 'open' isMerged = merged isOpen = !merged mergeable = !merged && source != '' && !targetMissing targetMissing = targetMissing author = c.author date = c.date })
  67. if (merged) { out.mergedCount = out.mergedCount + 1 } else { out.openCount = out.openCount + 1 }
  68. next(i + 1)
  69. return null
  70. }
  71. if (!names.includes(c.target)) { return finish(false, true) }
  72. git(slug, ['merge-base', '--is-ancestor', c.sha, c.target], 1, (mb) => {
  73. return finish(mb.ok, false)
  74. })
  75. return null
  76. })
  77. return null
  78. }
  79. next(0)
  80. return null
  81. })
  82. })
  83. }
  84. // the rows with `canMerge` (the owner, an open request whose branches exist) and the button's class: an `if` inside a
  85. // `for` row is not re-evaluated when the list is reassigned (the old button stayed), an attribute is — so the button
  86. // is always there and `.nomerge` hides it (styles.hl)
  87. static withMerge = (list, owner) => {
  88. out = []
  89. for (p of list) {
  90. let q = p
  91. q.canMerge = owner && p.mergeable
  92. q.mergeClass = q.canMerge ? 'quiet mergepr' : 'nomerge'
  93. out.push(q)
  94. }
  95. return out
  96. }
  97. // ---- MERGE a pull request (ticket gitoria#17): only when the owner clicks the button ---------------------------
  98. // The repo is BARE (no work tree), so the merge is made with plumbing: `merge-tree --write-tree` merges the two
  99. // branches into a tree (exit 1 = conflict: nothing is written to any branch), `commit-tree` makes the merge commit
  100. // (parents: target, source), `update-ref` moves the target — with the old value given, so a push that arrived meanwhile
  101. // makes it fail instead of being overwritten. Always a merge commit (like GitHub's "Create a merge commit").
  102. // The pull request is found again on the SERVER by the sha of its `|||PR` commit (the client only names that).
  103. // → { ok } | { error }. who = the user's name (author of the merge commit).
  104. static mergePullNow = (slug, setting, sha, who) => {
  105. let found = null
  106. if (sha == null || hlTypeName(sha) != 'String' || !isHexId(sha) || sha.length != 40) { return { error = 'no such pull request' } }
  107. got = pullsNow(slug, setting)
  108. if (got == null || got.isMessage) { return { error = 'no such pull request' } }
  109. for (p of got.pulls) { if (p.sha == sha) { found = p } }
  110. if (found == null) { return { error = 'no such pull request' } }
  111. if (found.isMerged) { return { error = 'this pull request is already merged' } }
  112. if (found.noSource) { return { error = 'the branch of this pull request is gone' } }
  113. if (found.targetMissing) { return { error = 'the target branch ' + found.target + ' does not exist' } }
  114. target = 'refs/heads/' + found.target
  115. old = gitRaw(slug, ['rev-parse', '--verify', '-q', target])
  116. tip = gitRaw(slug, ['rev-parse', '--verify', '-q', 'refs/heads/' + found.source])
  117. if (old == null || old.exit != 0 || old.lines.length == 0 || tip == null || tip.exit != 0 || tip.lines.length == 0) { return { error = 'could not read the branches' } }
  118. oldSha = old.lines[0]
  119. tipSha = tip.lines[0]
  120. if (!isHexId(oldSha) || !isHexId(tipSha)) { return { error = 'could not read the branches' } }
  121. m = gitRaw(slug, ['merge-tree', '--write-tree', '--name-only', '--no-messages', oldSha, tipSha])
  122. if (m == null) { return { error = 'could not merge (git did not answer)' } }
  123. if (m.exit == 1) {
  124. files = []
  125. let i = 1
  126. while (i < m.lines.length && m.lines[i] != '') {
  127. if (files.length < 5) { files.push(m.lines[i]) }
  128. i = i + 1
  129. }
  130. return { error = 'Not merged: ' + found.source + ' conflicts with ' + found.target + (files.length > 0 ? ' in ' + files.join(', ') : '') + '. Resolve it with git, then push.' }
  131. }
  132. if (m.exit != 0 || m.lines.length == 0 || !isHexId(m.lines[0])) { return { error = 'could not merge' } }
  133. name = who != null && who != '' ? who : 'gitoria'
  134. msg = "Merge branch '" + found.source + "' into " + found.target
  135. c = gitRaw(slug, ['-c', 'user.name=' + name, '-c', 'user.email=noreply@gitoria', 'commit-tree', m.lines[0], '-p', oldSha, '-p', tipSha, '-m', msg])
  136. if (c == null || c.exit != 0 || c.lines.length == 0 || !isHexId(c.lines[0])) { return { error = 'could not write the merge commit' } }
  137. u = gitRaw(slug, ['update-ref', '-m', msg, target, c.lines[0], oldSha])
  138. if (u == null || u.exit != 0) { return { error = 'The branch ' + found.target + ' changed meanwhile — nothing merged, try again.' } }
  139. return { ok = true }
  140. }
  141. // the list read again, with the Merge buttons and the ticket links: the answer of the Merge face (components/pulls.hl)
  142. static pullsView = (slug, setting, owner, project) => {
  143. got = pullsNow(slug, setting)
  144. return { result = got list = withPullParts(withMerge(got.pulls, owner), project) }
  145. }

Branches

Latest commits

  • 4f47843egate: ticket links use the tickets short URL (/<slug>/<n>, tickets#25)mre
  • 86605446mission 002 (code order) 4/4: README file map + import order + 'Same output' test + gate run with a tickets HEAD copy, STATUS (entry, lessons), LOG, report; tests/realdata-baseline.mjs + realdata-compare.py (a cleanup answers the same on live data: pages, modules, API, git over HTTPS and SSH, faces), tests/letcount.pymre
  • cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
  • 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
  • 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
  • fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
  • 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
  • 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
  • e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
  • 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
  • 3dc43108antcolony#40: mission references point to the moved missionsmre
  • 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
  • 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
  • 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
  • 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
  • fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
  • 4a2d7125initial commitmre