gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8d9450fd8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre8d9450fd/tickets.hl

12.6 KB

  1. // tickets.hl — THE TICKETS OF A REPO (gitoria#10, gitoria#18; CONCEPT "/tickets"). The tickets are NOT stored in
  2. // gitoria: they live in a tickets.worldapi.org project the repo's OWNER connected in the repo's settings
  3. // (docs: antcolony-docs/docs/tickets-connect-apps.md). The connect flow:
  4. // 1. /settings/connect (project.hl) sends the owner's browser to <tickets>/connect?app=gitoria&label=<slug>&return=…&state=…
  5. // 2. tickets brings them back to /settings/connected?code=…&state=… (the state is the nonce of step 1, kept in the session)
  6. // 3. exchangeConnect(code): POST <tickets>/api/connect/exchange { code } → { key, project, title, api }, from THIS server.
  7. // The key is stored per repo (repos.hl, mpackdb) and never sent to a page.
  8. // Every write then carries `Authorization: Bearer <key>` and `X-Tickets-Identity: <ident id of the person>`: the person
  9. // (not gitoria) is the author, under the project's roles. Reads need no key.
  10. // Links both ways: "#12" in a commit subject or a pull request title links ticket 12 (refParts); a push posts
  11. // "mentioned in commit …" on that ticket, and a merged `|||PR` whose title says "fixes #12" sets ticket 12 to review (notifyPush).
  12. // Config (environment, or `.env` beside project.hl — never printed): GITORIA_TICKETS_URL, default https://tickets.worldapi.org
  13. import { fetch } from 'hl:fetch'
  14. import { MPackDB } from 'hl:mpackdb'
  15. import { now } from 'hl:time'
  16. import { envOr, plainError, storageDir, firstOf, countOfList, userRecord, publicUrl, repoOrigin } from './users.hl'
  17. import { repoBySlug } from './repos.hl'
  18. import { gitRaw, tabs, isHexId, pullsNow } from './git.hl'
  19. static NL = "
  20. "
  21. static ticketsRaw = envOr('GITORIA_TICKETS_URL', 'https://tickets.worldapi.org')
  22. static ticketsUrl = ticketsRaw.endsWith('/') ? ticketsRaw.slice(0, ticketsRaw.length - 1) : ticketsRaw
  23. static maxRows = 200
  24. static agent = 'gitoria.worldapi.org (tickets)'
  25. // what was already told to tickets (a push arrives again with the same commits): one row per `<slug> <what> <sha> <n>`
  26. static seenTable = new MPackDB(file = storageDir + '/ticketlinks.db', primaryKey = '@id', indexes = ['!key'])
  27. // ---- the connect flow ------------------------------------------------------------------------------
  28. static connectHref = (slug, state) => {
  29. let back = repoOrigin(slug) + '/settings/connected'
  30. return ticketsUrl + '/connect?app=gitoria&label=' + encodeURIComponent(slug) + '&return=' + encodeURIComponent(back) + '&state=' + encodeURIComponent(state)
  31. }
  32. static isCode = (s) => {
  33. if (s == null || hlTypeName(s) != 'String' || s.length == 0 || s.length > 200) { return false }
  34. let i = 0
  35. while (i < s.length) {
  36. let c = s.charCodeAt(i)
  37. if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c == 45 || c == 95)) { return false }
  38. i = i + 1
  39. }
  40. return true
  41. }
  42. // the code tickets sent back → { key, project, title, api } | { error }
  43. static exchangeConnect = (code) => {
  44. if (!isCode(code)) { return { error = 'that is not a code from tickets' } }
  45. let r = fetch(ticketsUrl + '/api/connect/exchange', { method = 'POST' json = { code = code } headers = { 'user-agent' = agent } timeoutMs = 10000 })
  46. if (r == null || r.status == null || r.status == 0) { return { error = 'tickets.worldapi.org did not answer' } }
  47. let j = r.json()
  48. if (r.status != 200 || j == null || j.key == null || j.project == null || j.api == null) {
  49. return { error = 'tickets.worldapi.org refused the connection (' + r.status + (j != null && j.error != null ? ': ' + j.error : '') + ')' }
  50. }
  51. if (hlTypeName(j.key) != 'String' || !j.key.startsWith('tktc_') || hlTypeName(j.api) != 'String' || !j.api.startsWith(ticketsUrl + '/api/projects/')) { return { error = 'tickets.worldapi.org sent an answer gitoria does not understand' } }
  52. return { key = j.key project = '' + j.project title = j.title != null ? '' + j.title : '' + j.project api = j.api }
  53. }
  54. // ---- reading ---------------------------------------------------------------------------------------
  55. // the project of a connected repo (the server-side record has the key): { key, api, project } or null
  56. static connectionOf = (slug) => {
  57. let r = repoBySlug(slug)
  58. if (r == null || r.tktKey == null || r.tktKey == '' || r.tktApi == null || r.tktApi == '') { return null }
  59. return { key = r.tktKey api = r.tktApi project = r.tktProject title = r.tktTitle }
  60. }
  61. static ticketHref = (project, n) => { return ticketsUrl + '/projects/' + project + '/' + n }
  62. // one row for the page (a state's words are shown as tickets writes them)
  63. static viewOf = (t) => {
  64. return { number = t.number subject = t.subject state = t.state stateSlug = t.stateSlug created = t.created updated = t.updated url = ticketsUrl + t.href }
  65. }
  66. // the repo's tickets, newest update first (tickets' order): { tickets, count, truncated, project, url } or { error }
  67. static loadTickets = (slug) => {
  68. let c = connectionOf(slug)
  69. if (c == null) { return { error = 'not connected' } }
  70. let r = fetch(c.api + '/tickets', { method = 'GET' headers = { 'user-agent' = agent } timeoutMs = 10000 })
  71. if (r == null || r.status == null || r.status == 0) { return { error = 'tickets.worldapi.org did not answer' } }
  72. let base = { project = c.project url = ticketsUrl + '/projects/' + c.project }
  73. let j = r.status == 200 ? r.json() : null
  74. if (j == null || j.tickets == null) { return { error = 'tickets.worldapi.org answered ' + r.status } }
  75. let out = []
  76. for (t of j.tickets) { if (out.length < maxRows) { out.push(viewOf(t)) } }
  77. base.tickets = out
  78. base.count = j.tickets.length
  79. base.truncated = j.tickets.length > out.length
  80. return base
  81. }
  82. // ---- writing (as a person) ---------------------------------------------------------------------------
  83. static writeHeaders = (c, identity) => {
  84. return { 'user-agent' = agent 'authorization' = 'Bearer ' + c.key 'x-tickets-identity' = identity }
  85. }
  86. static whyOf = (r) => {
  87. let j = r.json()
  88. return j != null && j.error != null ? ': ' + j.error : ''
  89. }
  90. // open a ticket for a repo as the person with this ident id: { ticket } or { error, field }
  91. static openTicket = (slug, identity, subject, summary) => {
  92. let s = subject == null ? '' : ('' + subject).trim()
  93. if (s == '') { return { error = 'the ticket needs a subject' field = 'subject' } }
  94. let bad = plainError(s, 200, 'the subject')
  95. if (bad != null) { return { error = bad field = 'subject' } }
  96. let body = summary == null ? '' : ('' + summary).trim()
  97. if (body.length > 20000) { return { error = 'the text is too long (at most 20000 characters)' field = 'summary' } }
  98. let c = connectionOf(slug)
  99. if (c == null) { return { error = 'this repository is not connected to a tickets project (see Settings)' field = '' } }
  100. let r = fetch(c.api + '/tickets', { method = 'POST' json = { subject = s summary = body } headers = writeHeaders(c, identity) timeoutMs = 10000 })
  101. if (r == null || r.status == null || r.status == 0) { return { error = 'tickets.worldapi.org did not answer' field = '' } }
  102. let j = r.json()
  103. if ((r.status != 201 && r.status != 200) || j == null || j.ticket == null) {
  104. if (r.status == 403) { return { error = 'tickets refused: log in to tickets.worldapi.org once, choose a display name there, and check you may edit this project' + whyOf(r) field = '' } }
  105. return { error = 'tickets.worldapi.org refused the ticket (' + r.status + whyOf(r).slice(0, 200) + ')' field = '' }
  106. }
  107. return { ticket = viewOf(j.ticket) }
  108. }
  109. // a comment on ticket n as a person → true when tickets took it, or answered it with a refusal (no retry then)
  110. static commentTicket = (c, identity, n, text) => {
  111. let r = fetch(c.api + '/tickets/' + n + '/comments', { method = 'POST' json = { text = text } headers = writeHeaders(c, identity) timeoutMs = 10000 })
  112. return r != null && r.status != null && r.status > 0 && r.status < 500
  113. }
  114. static stateTicket = (c, identity, n, state, text) => {
  115. let r = fetch(c.api + '/tickets/' + n + '/state', { method = 'POST' json = { state = state text = text } headers = writeHeaders(c, identity) timeoutMs = 10000 })
  116. return r != null && r.status != null && r.status > 0 && r.status < 500
  117. }
  118. // ---- "#12" in a text ---------------------------------------------------------------------------------
  119. static isDigitCode = (c) => { return c >= 48 && c <= 57 }
  120. static isWordCode = (c) => { return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 95 }
  121. // every `#N` of a text (N = 1–9 digits, not glued to a word before it): [{ at, len, n }]
  122. static refsOf = (text) => {
  123. let out = []
  124. if (text == null || hlTypeName(text) != 'String') { return out }
  125. let i = 0
  126. while (i < text.length) {
  127. if (text[i] == '#' && (i == 0 || !isWordCode(text.charCodeAt(i - 1)))) {
  128. let j = i + 1
  129. while (j < text.length && j - i - 1 < 9 && isDigitCode(text.charCodeAt(j))) { j = j + 1 }
  130. let stop = j >= text.length || !isWordCode(text.charCodeAt(j))
  131. if (j > i + 1 && stop && text.slice(i + 1, i + 2) != '0') { out.push({ at = i len = j - i n = toNumber(text.slice(i + 1, j)) }) }
  132. i = j
  133. } else {
  134. i = i + 1
  135. }
  136. }
  137. return out
  138. }
  139. // a text as pieces for a View: [{ text, href, isLink, isText }] — `#N` becomes a link when the repo is connected
  140. static refParts = (text, project) => {
  141. let out = []
  142. let t = text == null ? '' : '' + text
  143. let refs = project == null || project == '' ? [] : refsOf(t)
  144. let pos = 0
  145. for (x of refs) {
  146. if (x.at > pos) { out.push({ text = t.slice(pos, x.at) href = '' isLink = false isText = true }) }
  147. out.push({ text = t.slice(x.at, x.at + x.len) href = ticketHref(project, x.n) isLink = true isText = false })
  148. pos = x.at + x.len
  149. }
  150. if (pos < t.length || out.length == 0) { out.push({ text = t.slice(pos) href = '' isLink = false isText = true }) }
  151. return out
  152. }
  153. // the numbers a text says it fixes: "fixes #3", "closes #4, #5"? — only the word directly before `#N` counts
  154. static fixedBy = (text) => {
  155. let out = []
  156. let t = text == null ? '' : ('' + text).toLowerCase()
  157. for (x of refsOf(t)) {
  158. let before = t.slice(0, x.at).trimEnd()
  159. let ok = false
  160. for (w of ['fix', 'fixes', 'fixed', 'close', 'closes', 'closed', 'resolve', 'resolves', 'resolved']) {
  161. if (before.endsWith(w) && (before.length == w.length || !isWordCode(before.charCodeAt(before.length - w.length - 1)))) { ok = true }
  162. }
  163. if (ok && !out.includes(x.n)) { out.push(x.n) }
  164. }
  165. return out
  166. }
  167. // the commit list of a code page with pieces for the subjects (the View draws links from `parts`)
  168. static withRefParts = (data, project) => {
  169. if (data == null || data.isMessage) { return data }
  170. let d = data
  171. d.subjectParts = refParts(d.subject, project)
  172. let cs = []
  173. for (c of d.commits) {
  174. let q = c
  175. q.parts = refParts(c.subject, project)
  176. cs.push(q)
  177. }
  178. d.commits = cs
  179. return d
  180. }
  181. static withPullParts = (list, project) => {
  182. let out = []
  183. for (p of list) {
  184. let q = p
  185. q.parts = refParts(p.title, project)
  186. out.push(q)
  187. }
  188. return out
  189. }
  190. // ---- after a push or a merge: tell tickets ---------------------------------------------------------------
  191. static seen = (key) => { return countOfList(seenTable.find('key', key)) > 0 }
  192. static markSeen = (key) => { seenTable.put({ key = key created = now() }) }
  193. // The recent commits of every branch that name a ticket: "mentioned in commit …" as a comment on it, once per
  194. // commit and ticket. `quiet` only marks them (the moment of connecting: old history is not announced).
  195. // Then every MERGED pull request whose title says "fixes #N" sets ticket N to review, once. The person is the repo's
  196. // owner (pushes and merges are the owner's). Failures are silent (the push has succeeded); a refusal by tickets is not retried.
  197. static notifyPush = (slug, quiet) => {
  198. let c = connectionOf(slug)
  199. let repo = repoBySlug(slug)
  200. if (c == null || repo == null) { return null }
  201. let owner = userRecord(repo.owner)
  202. if (owner == null || owner.identity == null) { return null }
  203. let lg = gitRaw(slug, ['log', '-100', '--branches', '--format=%H%x09%s'])
  204. if (lg != null && lg.exit == 0) {
  205. for (l of lg.lines) {
  206. let f = tabs(l)
  207. if (f.length >= 2 && isHexId(f[0])) {
  208. let sub = f.slice(1).join(' ')
  209. for (x of refsOf(sub)) {
  210. let key = slug + ' commit ' + f[0] + ' ' + x.n
  211. if (!seen(key)) {
  212. if (quiet || commentTicket(c, owner.identity, x.n, 'Mentioned in commit [' + f[0].slice(0, 8) + '](' + repoOrigin(slug) + '/commit/' + f[0] + ') of ' + slug + ':' + NL + NL + '> ' + sub)) { markSeen(key) }
  213. }
  214. }
  215. }
  216. }
  217. }
  218. let got = pullsNow(slug, repo.branch != null ? repo.branch : '')
  219. if (got != null && !got.isMessage) {
  220. for (p of got.pulls) {
  221. if (p.isMerged) {
  222. for (n of fixedBy(p.title)) {
  223. let key = slug + ' pr ' + p.sha + ' ' + n
  224. if (!seen(key)) {
  225. if (quiet || stateTicket(c, owner.identity, n, 'review', 'Fixed by the merged pull request [' + p.title + '](' + repoOrigin(slug) + '/commit/' + p.sha + ') of ' + slug + '.')) { markSeen(key) }
  226. }
  227. }
  228. }
  229. }
  230. }
  231. return null
  232. }

Branches

Latest commits

  • 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