gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmainbb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmremain/lib/tickets.hl

27.3 KB

  1. // lib/tickets.hl — TICKETS AND THEIR RELATIONS: the central logic of tickets.worldapi.org. Statics only, the server realm.
  2. // A face / an API route (components/, lib/api.hl) checks the session or token and calls one function here; every
  3. // write answers the rows the push carries.
  4. //
  5. // CREATOR'S CONVENTIONS (2026-09-24, ticket #38): every primary key is the mpackdb UUID
  6. // (`@id`, a string like '0mufbkwhlpjq'), never a `*id` counter; the files live in
  7. // storage/mpackdb/. A NUMBER A PERSON SEES is a separate field: tickets are numbered PER
  8. // PROJECT from 1 (`number`); tickets migrated from the old global store keep their old
  9. // global number as `oldNumber` (docs and comments say "#38"). Order never comes from key
  10. // order: lists sort by the stored times, a history by (created, seq).
  11. //
  12. // ticketsTable pk @id index project state source !path *oldNumber
  13. // { project (project @id — NOT the name), number, path ('<project @id>/<number>', unique),
  14. // oldNumber?, subject, summary, state, source, created, updated, events,
  15. // createdBy (users @id, '' = opened before the login), assignee (users @id, '') }
  16. // linksTable pk @id index @ticket @other (ticket #4, mission 017) { kind, ticket, other, user, created }
  17. // kind 'parent': `ticket` is a child of `other` (at most one per ticket)
  18. // kind 'blockedBy': `ticket` is blocked by `other` (waits on it)
  19. // A removed relation is deleted here; the history keeps it ('link' events, lib/events.hl).
  20. // Every write appends its event to the history (events.hl putEvent) and stores the ticket with the new
  21. // `events` count / `updated`. Projects, members and roles: lib/projects.hl; state names and page shapes:
  22. // lib/tickets-helpers.hl.
  23. //
  24. // WHO MAY is the role in the ticket's project (projects.hl): a refusal for a missing right has
  25. // `forbidden = true` (the API answers 403). EVERY WRITE HAS A USER (ticket #7): the logged-in user of the
  26. // web session or of the API token (users.hl). `source` is the import key (the ticket file's name) — an
  27. // import with a known source is answered with the existing ticket, never a second one.
  28. //
  29. // reading ticketRowOf, ticketRecord / ticketAt / ticketByRef / ticketByKey, ticketRows, inboxRows,
  30. // eventRows, ticketWithEvents, stateChoices, ticketRights
  31. // writing createTicket, addComment, changeState, assignTicket, editTicket, removeMember
  32. // relations relationsOf, relatedViews, setParent, changeBlocker
  33. import { MPackDB } from 'hl:mpackdb'
  34. import { now } from 'hl:time'
  35. import { storageDir, countOf, first, merged, clean, isDigits, localStamp, newestFirst, oldestFirst, ticketHref, ticketApiHref, projectHrefOf } from './util.hl'
  36. import { nameOfUser } from './users.hl'
  37. import { projectRecord, projectByAnySlug, projectByRef, projectSlugOf, roleOf, rankOf, isMember, mayEditIn, memberRecord, memberRows, userByRef, assignChoices, userOk, authorMissing, removeMembership } from './projects.hl'
  38. import { states, inboxStates, stateOf, slugOf, refOf, keyOf, relationViewOf } from './tickets-helpers.hl'
  39. import { putEvent, eventsError, eventRowAt, historyOf, creatorNameOf } from './events.hl'
  40. static ticketsTable = new MPackDB(file = storageDir + '/tickets.db', primaryKey = '@id', indexes = ['project', 'state', 'source', '!path', '*oldNumber'])
  41. static linksTable = new MPackDB(file = storageDir + '/links.db', primaryKey = '@id', indexes = ['@ticket', '@other'])
  42. // ---- the shapes a page and the API show ---------------------------------------------
  43. // A ROW IS A PLAIN HYBRID: what the View reads is what the browser receives.
  44. // `id` is the UUID; `number` + `project` (the slug) are what a person uses; `oldNumber` / `oldRef`
  45. // only on tickets migrated from the global numbering.
  46. static ticketRowOf = (t) => {
  47. n = t.events != null ? t.events : 0
  48. p = projectRecord(t.project)
  49. slug = p != null ? p.slug : ''
  50. byId = t.createdBy != null ? t.createdBy : ''
  51. toId = t.assignee != null ? t.assignee : ''
  52. row = {
  53. id = t.id
  54. project = slug
  55. projectId = t.project
  56. projectTitle = p != null ? p.title : ''
  57. number = t.number
  58. ref = '#' + t.number
  59. subject = t.subject
  60. summary = t.summary
  61. state = t.state
  62. stateSlug = slugOf(t.state)
  63. source = t.source != null ? t.source : ''
  64. created = localStamp(t.created)
  65. createdMs = t.created
  66. updated = localStamp(t.updated)
  67. updatedMs = t.updated
  68. events = n
  69. href = ticketHref(slug, t.number)
  70. apiHref = ticketApiHref(slug, t.number)
  71. projectHref = projectHrefOf(slug)
  72. hasOld = t.oldNumber != null
  73. oldRef = t.oldNumber != null ? '#' + t.oldNumber : ''
  74. createdById = byId
  75. createdBy = byId != '' ? nameOfUser(byId) : creatorNameOf(t)
  76. assigneeId = toId
  77. assignee = toId != '' ? nameOfUser(toId) : ''
  78. hasAssignee = toId != ''
  79. }
  80. if (t.oldNumber != null) { row.oldNumber = t.oldNumber }
  81. // ticket #4 (mission 017): the relations, as refs with their states (relationsOf below)
  82. rel = relationsOf(t)
  83. row.parent = rel.parent
  84. row.children = rel.children
  85. row.allChildrenConfirmed = rel.allChildrenConfirmed
  86. row.blockedBy = rel.blockedBy
  87. row.blocks = rel.blocks
  88. return row
  89. }
  90. // ---- finding a ticket -------------------------------------------------------------------
  91. static ticketRecord = (uuid) => { return uuid == null || uuid == '' ? null : ticketsTable.fetch('' + uuid) }
  92. // `project` is a slug (a former one too)
  93. static ticketAt = (project, number) => {
  94. if (project == null || !isDigits(number)) { return null }
  95. p = projectByAnySlug(project)
  96. if (p == null) { return null }
  97. return first(ticketsTable.find('path', p.id + '/' + toNumber('' + number)))
  98. }
  99. static ticketByOld = (oldNumber) => {
  100. if (!isDigits(oldNumber)) { return null }
  101. return first(ticketsTable.find('oldNumber', toNumber('' + oldNumber)))
  102. }
  103. // what `/api/tickets/:ref` and `/tickets/:ref` accept: the OLD global number (digits)
  104. // or the ticket's UUID
  105. static ticketByRef = (ref) => {
  106. if (ref == null) { return null }
  107. if (isDigits(ref)) { return ticketByOld(ref) }
  108. return ticketRecord(ref)
  109. }
  110. // ---- reads -------------------------------------------------------------------------
  111. // newest update first; both filters optional (null = all); `project` is a slug
  112. static ticketRows = (project, state) => {
  113. rows = []
  114. let all = null
  115. if (project != null) {
  116. p = projectByAnySlug(project)
  117. if (p == null) { return rows }
  118. all = ticketsTable.find('project', p.id)
  119. } else {
  120. all = ticketsTable.find(null, null)
  121. }
  122. if (countOf(all) == 0) { return rows }
  123. for (t of all) {
  124. if (state == null || t.state == state) { rows.push(ticketRowOf(t)) }
  125. }
  126. return newestFirst(rows, 'updatedMs')
  127. }
  128. // THE INBOX of a user: the tickets ASSIGNED TO THEM in pending and in review, two lists
  129. static inboxRows = (user) => {
  130. out = { pending = [] review = [] }
  131. if (user == null || user.id == null) { return out }
  132. a = []
  133. b = []
  134. ps = ticketsTable.find('state', 'pending')
  135. if (countOf(ps) > 0) { for (t of ps) { if (t.assignee == user.id) { a.push(ticketRowOf(t)) } } }
  136. rs = ticketsTable.find('state', 'review')
  137. if (countOf(rs) > 0) { for (t of rs) { if (t.assignee == user.id) { b.push(ticketRowOf(t)) } } }
  138. out.pending = newestFirst(a, 'updatedMs')
  139. out.review = newestFirst(b, 'updatedMs')
  140. return out
  141. }
  142. static inboxCountOf = (user) => {
  143. x = inboxRows(user)
  144. return x.pending.length + x.review.length
  145. }
  146. // the page's row of `/<project>/<number>` (ticket #25), or null
  147. static ticketRowAt = (project, number) => {
  148. t = ticketAt(project, number)
  149. return t == null ? null : ticketRowOf(t)
  150. }
  151. // the event history of a ticket (UUID), oldest first (events.hl historyOf)
  152. static eventRows = (ticketId) => {
  153. t = ticketRecord(ticketId)
  154. if (t == null) { return [] }
  155. return historyOf(t)
  156. }
  157. // the options of a state select: [{ value, label }] — the states THIS user may move the ticket to
  158. // (a `use` member: only open ⇄ review; `edit` / `admin`: every other state)
  159. static stateChoices = (t, user) => {
  160. out = []
  161. if (t == null || user == null) { return out }
  162. role = roleOf(t.project, user)
  163. for (s of states) {
  164. if (s != t.state && mayMoveTo(role, t.state, s)) { out.push({ value = s label = s }) }
  165. }
  166. return out
  167. }
  168. static mayMoveTo = (role, current, target) => {
  169. if (rankOf(role) >= 2) { return true }
  170. if (rankOf(role) == 1) { return (current == 'open' && target == 'review') || (current == 'review' && target == 'open') }
  171. return false
  172. }
  173. // what a user may do on a ticket (a record): comment (a member), edit / assign (edit, admin), the states they
  174. // may move it to, and the members it can be assigned to — the ticket page asks after a login or a change
  175. static ticketRights = (t, user) => {
  176. return { comment = isMember(t.project, user) edit = mayEdit(t, user) options = stateChoices(t, user) members = assignChoices(t.project) }
  177. }
  178. // ---- writes: each answers what the push carries --------------------------------------
  179. // The author is the user — there is no free author field any more.
  180. // the next per-project number: the highest one in the project + 1 (first = 1)
  181. static nextNumber = (projectId) => {
  182. let top = 0
  183. all = ticketsTable.find('project', projectId)
  184. if (countOf(all) > 0) {
  185. for (t of all) { if (t.number != null && t.number > top) { top = t.number } }
  186. }
  187. return top + 1
  188. }
  189. // answers { error, field, forbidden? } or { ticket (row), event (row), existed }
  190. // `project` = the slug (or id) of an existing project; `assignee` optional (a member's name / id)
  191. static createTicket = (project, subject, summary, user, source, assignee) => {
  192. s = clean(subject)
  193. if (clean(project) == '') { return { error = 'project is required' field = 'project' } }
  194. if (s == '') { return { error = 'subject is required' field = 'subject' } }
  195. if (!userOk(user)) { return authorMissing }
  196. p = projectByRef(clean(project))
  197. if (p == null) { return { error = 'no such project: ' + clean(project) field = 'project' } }
  198. if (!mayEditIn(p.id, user)) { return { error = 'you need the role edit or admin in ' + p.slug + ' to open a ticket' field = 'project' forbidden = true } }
  199. let who = ''
  200. if (assignee != null && clean(assignee) != '') {
  201. r = userByRef(assignee, p.id)
  202. if (r.error != null) { return { error = r.error field = 'assignee' } }
  203. who = r.user.id
  204. }
  205. src = clean(source)
  206. if (src != '') {
  207. known = ticketsTable.find('source', src)
  208. if (countOf(known) > 0) { return { ticket = ticketRowOf(known[0]) event = null existed = true } }
  209. }
  210. t0 = now()
  211. n = nextNumber(p.id)
  212. id = ticketsTable.put({ project = p.id number = n path = p.id + '/' + n subject = s summary = clean(summary) state = 'open' source = src created = t0 updated = t0 events = 1 createdBy = user.id assignee = who })
  213. if (id == null) { return { error = 'could not store the ticket: ' + ticketsTable.lastError() field = '' } }
  214. t = ticketsTable.fetch(id)
  215. eid = putEvent({ ticket = id seq = 1 kind = 'created' author = user.name user = user.id text = src != '' ? 'imported from ' + src : '' created = t0 })
  216. out = { ticket = ticketRowOf(t) event = eventRowAt(eid, t) existed = false }
  217. if (who != '') {
  218. let seq = 2
  219. aid = putEvent({ ticket = id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = '' created = t0 })
  220. ticketsTable.update(id, merged(t, { events = seq }))
  221. t2 = ticketsTable.fetch(id)
  222. out.ticket = ticketRowOf(t2)
  223. out.assignEvent = eventRowAt(aid, t2)
  224. }
  225. return out
  226. }
  227. // answers { error } or { ticket, event }; `ticketId` is the UUID. Any member may comment.
  228. static addComment = (ticketId, user, text) => {
  229. t = ticketRecord(ticketId)
  230. if (t == null) { return { error = 'no such ticket' } }
  231. body = clean(text)
  232. if (body == '') { return { error = 'a comment needs text' field = 'text' } }
  233. if (!userOk(user)) { return authorMissing }
  234. if (!isMember(t.project, user)) { return { error = 'only a member of the project can comment' field = '' forbidden = true } }
  235. t0 = now()
  236. let seq = (t.events != null ? t.events : 0) + 1
  237. eid = putEvent({ ticket = t.id seq = seq kind = 'comment' author = user.name user = user.id text = body created = t0 })
  238. ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))
  239. t2 = ticketsTable.fetch(t.id)
  240. return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }
  241. }
  242. // the admin who gets a ticket that lands in the inbox with nobody assigned: the oldest admin
  243. static defaultAssignee = (projectId) => {
  244. for (m of memberRows(projectId)) { if (m.role == 'admin') { return m.user } }
  245. return ''
  246. }
  247. // answers { error } or { ticket, event, assignEvent? }; an optional note rides on the state event.
  248. // `forbidden = true` on a refusal the user may not overcome (the role does not allow that move).
  249. // A ticket that goes to review / pending with nobody assigned is assigned to the project's first admin
  250. // (so it reaches an inbox) — a second, 'assign' event.
  251. static changeState = (ticketId, state, user, note) => {
  252. t = ticketRecord(ticketId)
  253. if (t == null) { return { error = 'no such ticket' } }
  254. s = stateOf(state)
  255. if (s == null) { return { error = 'unknown state — one of: ' + states.join(', ') field = 'state' } }
  256. if (s == t.state) { return { error = 'the ticket is already ' + s field = 'state' } }
  257. if (!userOk(user)) { return authorMissing }
  258. role = roleOf(t.project, user)
  259. if (role == null) { return { error = 'only a member of the project can change the state' field = 'state' forbidden = true } }
  260. if (!mayMoveTo(role, t.state, s)) { return { error = 'the role ' + role + ' can only move a ticket between open and review' field = 'state' forbidden = true } }
  261. t0 = now()
  262. let seq = (t.events != null ? t.events : 0) + 1
  263. eid = putEvent({ ticket = t.id seq = seq kind = 'state' author = user.name user = user.id text = clean(note) from = t.state to = s created = t0 })
  264. changes = { state = s updated = t0 events = seq }
  265. let aid = null
  266. let auto = ''
  267. if (inboxStates.includes(s) && (t.assignee == null || t.assignee == '')) { auto = defaultAssignee(t.project) }
  268. if (auto != '') {
  269. seq = seq + 1
  270. aid = putEvent({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = auto previous = '' created = t0 })
  271. changes.assignee = auto
  272. changes.events = seq
  273. }
  274. ticketsTable.update(t.id, merged(t, changes))
  275. t2 = ticketsTable.fetch(t.id)
  276. out = { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }
  277. if (aid != null) { out.assignEvent = eventRowAt(aid, t2) }
  278. return out
  279. }
  280. // answers { error, forbidden? } or { ticket, event }; `assignee` = a member's users @id, '' = nobody
  281. static assignTicket = (ticketId, user, assignee) => {
  282. t = ticketRecord(ticketId)
  283. if (t == null) { return { error = 'no such ticket' } }
  284. if (!userOk(user)) { return authorMissing }
  285. if (!mayEditIn(t.project, user)) { return { error = 'you need the role edit or admin to assign a ticket' field = 'assignee' forbidden = true } }
  286. who = assignee == null ? '' : clean(assignee)
  287. if (who != '' && memberRecord(t.project, who) == null) { return { error = 'the assignee must be a member of the project' field = 'assignee' } }
  288. was = t.assignee != null ? t.assignee : ''
  289. if (who == was) { return { error = who == '' ? 'the ticket is not assigned' : 'the ticket is already assigned to that person' field = 'assignee' } }
  290. t0 = now()
  291. let seq = (t.events != null ? t.events : 0) + 1
  292. eid = putEvent({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = was created = t0 })
  293. ticketsTable.update(t.id, merged(t, { assignee = who updated = t0 events = seq }))
  294. t2 = ticketsTable.fetch(t.id)
  295. return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }
  296. }
  297. // ---- editing (ticket #8, mission 014; ticket #20: by the role) --------------------------------
  298. // THE AUTHOR of a ticket is `createdBy` (users @id), or null for a ticket opened before the login
  299. // existed. MAY THIS USER EDIT the ticket (a record)? Any member with the role edit or admin of
  300. // its project (`use` members only comment and move it between open and review).
  301. static mayEdit = (t, user) => {
  302. if (t == null || !userOk(user)) { return false }
  303. return mayEditIn(t.project, user)
  304. }
  305. static mayEditId = (ticketId, user) => { return mayEdit(ticketRecord(ticketId), user) }
  306. // answers { error, field?, forbidden? } or { ticket, event }. `subject` / `summary` null = keep
  307. // that one. The previous values stay in the 'edit' event (append-only history).
  308. static editTicket = (ticketId, user, subject, summary) => {
  309. t = ticketRecord(ticketId)
  310. if (t == null) { return { error = 'no such ticket' } }
  311. if (!userOk(user)) { return authorMissing }
  312. if (!mayEdit(t, user)) { return { error = 'you need the role edit or admin in the project to edit a ticket' field = '' forbidden = true } }
  313. if (subject == null && summary == null) { return { error = 'nothing to change: give subject and/or summary' field = '' } }
  314. let oldSummary = t.summary != null ? t.summary : ''
  315. s = subject != null ? clean(subject) : t.subject
  316. if (s == '') { return { error = 'subject is required' field = 'subject' } }
  317. sm = summary != null ? clean(summary) : oldSummary
  318. if (s == t.subject && sm == oldSummary) { return { error = 'nothing changed' field = subject != null ? 'subject' : 'summary' } }
  319. t0 = now()
  320. let seq = (t.events != null ? t.events : 0) + 1
  321. eid = putEvent({ ticket = t.id seq = seq kind = 'edit' author = user.name user = user.id text = '' oldSubject = t.subject oldSummary = oldSummary subject = s summary = sm created = t0 })
  322. if (eid == null) { return { error = 'could not store the edit: ' + eventsError() field = '' } }
  323. ticketsTable.update(t.id, merged(t, { subject = s summary = sm updated = t0 events = seq }))
  324. t2 = ticketsTable.fetch(t.id)
  325. return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }
  326. }
  327. // an admin removes a member (projects.hl removeMembership); the tickets assigned to the person are no longer
  328. // assigned to anybody. Answers { members } or { error, forbidden? }
  329. static removeMember = (projectId, actor, userId) => {
  330. r = removeMembership(projectId, actor, userId)
  331. if (r.error != null) { return r }
  332. all = ticketsTable.find('project', projectId)
  333. if (countOf(all) > 0) {
  334. for (t of all) {
  335. if (t.assignee == userId) { ticketsTable.update(t.id, merged(t, { assignee = '' })) }
  336. }
  337. }
  338. return r
  339. }
  340. // the API's view of one ticket (a record): the row and its whole history
  341. static ticketWithEvents = (t) => {
  342. if (t == null) { return null }
  343. return { ticket = ticketRowOf(t) events = eventRows(t.id) }
  344. }
  345. // ---- relations: parent / child and blocked-by (ticket #4, mission 017) ----------------------
  346. // A ticket has at most ONE PARENT (any project); a parent lists its CHILDREN with their states.
  347. // A ticket can be BLOCKED BY other tickets (any project) — it waits on them; the other side
  348. // shows what it BLOCKS. The scheduler reads them from the JSON rows (`parent`, `children`,
  349. // `blockedBy`, `blocks`, each a ref with the state) and the Markdown view.
  350. // PARENT STATE: `allChildrenConfirmed` (the key kept for the clients; it now means DONE) is true when
  351. // a ticket has children and every one of them is `done`. It is only SHOWN ("all children done") — the
  352. // parent's own state is never changed by it.
  353. // WHO MAY set / remove a relation (ticket #20): a member with the role edit or admin in the project of
  354. // EITHER of the two tickets. Replacing a parent needs the
  355. // right for the old pair AND the new pair. No loops: a ticket cannot be its own ancestor, and
  356. // cannot (transitively) wait on itself.
  357. // Tickets are named as `<project>#<number>` (spaces around `#` allowed) or by their UUID.
  358. // the link records of one side, oldest first (stored time): field 'ticket' = this ticket's own
  359. // relations (its parent, its blockers), field 'other' = the ones pointing at it (children, blocked)
  360. static linkRecords = (field, ticketId, kind) => {
  361. out = []
  362. if (ticketId == null || ticketId == '') { return out }
  363. all = linksTable.find(field, '' + ticketId)
  364. if (countOf(all) == 0) { return out }
  365. for (l of all) { if (l.kind == kind) { out.push(l) } }
  366. return oldestFirst(out, 'created')
  367. }
  368. static parentLinkOf = (ticketId) => { return first(linkRecords('ticket', ticketId, 'parent')) }
  369. static parentIdOf = (ticketId) => {
  370. l = parentLinkOf(ticketId)
  371. return l == null ? null : l.other
  372. }
  373. static idsOfLinks = (links, field) => {
  374. out = []
  375. for (l of links) { out.push(l[field]) }
  376. return out
  377. }
  378. static childIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'parent'), 'ticket') }
  379. static blockerIdsOf = (ticketId) => { return idsOfLinks(linkRecords('ticket', ticketId, 'blockedBy'), 'other') }
  380. static blockedIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'blockedBy'), 'ticket') }
  381. static refsOfIds = (ids) => {
  382. out = []
  383. for (i of ids) {
  384. let t = ticketRecord(i)
  385. if (t != null) { out.push(refOf(t)) }
  386. }
  387. return out
  388. }
  389. // { parent (ref or null), children, blockedBy, blocks (lists of refs), allChildrenConfirmed }
  390. static relationsOf = (t) => {
  391. pid = parentIdOf(t.id)
  392. p = pid != null ? ticketRecord(pid) : null
  393. kids = refsOfIds(childIdsOf(t.id))
  394. let all = kids.length > 0
  395. for (c of kids) { if (c.state != 'done') { all = false } }
  396. return { parent = p != null ? refOf(p) : null children = kids allChildrenConfirmed = all blockedBy = refsOfIds(blockerIdsOf(t.id)) blocks = refsOfIds(blockedIdsOf(t.id)) }
  397. }
  398. // the relation views of a ticket AND every ticket related to it (+ `extra` ids: a parent or
  399. // blocker just removed) — what a write pushes, so every open page of them follows
  400. static relatedViews = (ticketId, extra) => {
  401. ids = ['' + ticketId]
  402. seen = {}
  403. seen['' + ticketId] = true
  404. more = []
  405. pid = parentIdOf(ticketId)
  406. if (pid != null) { more.push(pid) }
  407. for (i of childIdsOf(ticketId)) { more.push(i) }
  408. for (i of blockerIdsOf(ticketId)) { more.push(i) }
  409. for (i of blockedIdsOf(ticketId)) { more.push(i) }
  410. for (i of extra) { if (i != null) { more.push(i) } }
  411. for (i of more) {
  412. if (seen['' + i] != true) {
  413. seen['' + i] = true
  414. ids.push('' + i)
  415. }
  416. }
  417. out = []
  418. for (i of ids) {
  419. let t = ticketRecord(i)
  420. if (t != null) { out.push(relationViewOf(ticketRowOf(t))) }
  421. }
  422. return out
  423. }
  424. // `<project>#<number>` (spaces around '#' allowed) or a UUID → the ticket record, or null
  425. static ticketByKey = (key) => {
  426. k = clean(key)
  427. if (k == '') { return null }
  428. at = k.indexOf('#')
  429. if (at < 0) { return isDigits(k) ? null : ticketRecord(k) }
  430. p = k.slice(0, at).trim()
  431. n = k.slice(at + 1).trim()
  432. if (p == '') { return null }
  433. return ticketAt(p, n)
  434. }
  435. static mayLink = (a, b, user) => {
  436. if (!userOk(user)) { return false }
  437. if (a != null && mayEditIn(a.project, user)) { return true }
  438. if (b != null && mayEditIn(b.project, user)) { return true }
  439. return false
  440. }
  441. static linkForbidden = { error = 'you need the role edit or admin in the project of either ticket to change this relation' field = '' forbidden = true }
  442. // the history event of a relation change, on ticket `t`; answers { ticket, event, related }
  443. static linkEvent = (t, user, linkKind, action, other, previous, extra) => {
  444. t0 = now()
  445. let seq = (t.events != null ? t.events : 0) + 1
  446. // the values BEFORE the literal: an entry named `other` / `user` would shadow the variable
  447. // for the entries after it (mission 011 lesson)
  448. uname = user.name
  449. uid = user.id
  450. oid = other.id
  451. oproject = projectSlugOf(other)
  452. onumber = other.number
  453. rec = { ticket = t.id seq = seq kind = 'link' author = uname user = uid text = '' linkKind = linkKind action = action other = oid otherProject = oproject otherNumber = onumber created = t0 }
  454. if (previous != null) {
  455. rec.previous = previous.id
  456. rec.previousProject = projectSlugOf(previous)
  457. rec.previousNumber = previous.number
  458. }
  459. eid = putEvent(rec)
  460. if (eid == null) { return { error = 'could not store the event: ' + eventsError() field = '' } }
  461. ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))
  462. t2 = ticketsTable.fetch(t.id)
  463. return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) related = relatedViews(t.id, extra) }
  464. }
  465. // SET (key = `<project>#<n>` / UUID) or REMOVE (key = '') the parent of a ticket (UUID).
  466. // Answers { error, field, forbidden? } or { ticket, event, related }.
  467. static setParent = (ticketId, user, key) => {
  468. t = ticketRecord(ticketId)
  469. if (t == null) { return { error = 'no such ticket' } }
  470. if (!userOk(user)) { return authorMissing }
  471. k = clean(key)
  472. cur = parentLinkOf(t.id)
  473. curT = cur != null ? ticketRecord(cur.other) : null
  474. if (k == '') {
  475. if (cur == null || curT == null) { return { error = 'the ticket has no parent' field = 'parent' } }
  476. if (!mayLink(t, curT, user)) { return linkForbidden }
  477. linksTable.delete(cur.id)
  478. return linkEvent(t, user, 'parent', 'removed', curT, null, [curT.id])
  479. }
  480. p = ticketByKey(k)
  481. if (p == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = 'parent' } }
  482. if (p.id == t.id) { return { error = 'a ticket cannot be its own parent' field = 'parent' } }
  483. if (curT != null && curT.id == p.id) { return { error = 'the parent is already ' + keyOf(p) field = 'parent' } }
  484. // no loop: the new parent must not be this ticket's child, grandchild, …
  485. let up = p.id
  486. let steps = 0
  487. while (up != null && steps < 1000) {
  488. if (up == t.id) { return { error = 'a loop: ' + keyOf(p) + ' is below this ticket' field = 'parent' } }
  489. up = parentIdOf(up)
  490. steps = steps + 1
  491. }
  492. if (!mayLink(t, p, user)) { return linkForbidden }
  493. if (curT != null && !mayLink(t, curT, user)) { return linkForbidden }
  494. lid = linksTable.put({ kind = 'parent' ticket = t.id other = p.id user = user.id created = now() })
  495. if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }
  496. if (cur != null) { linksTable.delete(cur.id) }
  497. return linkEvent(t, user, 'parent', 'set', p, curT, [curT != null ? curT.id : null])
  498. }
  499. // does ticket `start` (transitively) wait on ticket `target`? (blocked-by links)
  500. static waitsOn = (start, target) => {
  501. stack = [start]
  502. seen = {}
  503. let steps = 0
  504. while (stack.length > 0 && steps < 10000) {
  505. let cur = stack.pop()
  506. steps = steps + 1
  507. if (cur == target) { return true }
  508. if (seen['' + cur] != true) {
  509. seen['' + cur] = true
  510. for (i of blockerIdsOf(cur)) { stack.push(i) }
  511. }
  512. }
  513. return false
  514. }
  515. // ADD (adding = true) or REMOVE a blocker of a ticket (UUID): ticket `key` blocks it.
  516. // `field` names the API field ('add' / 'remove'). Answers as setParent.
  517. static changeBlocker = (ticketId, user, key, adding, field) => {
  518. t = ticketRecord(ticketId)
  519. if (t == null) { return { error = 'no such ticket' } }
  520. if (!userOk(user)) { return authorMissing }
  521. k = clean(key)
  522. if (k == '') { return { error = 'name the blocking ticket as <project>#<number>' field = field } }
  523. o = ticketByKey(k)
  524. if (o == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = field } }
  525. let existing = null
  526. for (l of linkRecords('ticket', t.id, 'blockedBy')) { if (l.other == o.id) { existing = l } }
  527. if (adding) {
  528. if (o.id == t.id) { return { error = 'a ticket cannot block itself' field = field } }
  529. if (existing != null) { return { error = 'already blocked by ' + keyOf(o) field = field } }
  530. if (waitsOn(o.id, t.id)) { return { error = 'a loop: ' + keyOf(o) + ' already waits on this ticket' field = field } }
  531. if (!mayLink(t, o, user)) { return linkForbidden }
  532. lid = linksTable.put({ kind = 'blockedBy' ticket = t.id other = o.id user = user.id created = now() })
  533. if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }
  534. return linkEvent(t, user, 'blockedBy', 'set', o, null, [])
  535. }
  536. if (existing == null) { return { error = 'not blocked by ' + keyOf(o) field = field } }
  537. if (!mayLink(t, o, user)) { return linkForbidden }
  538. linksTable.delete(existing.id)
  539. return linkEvent(t, user, 'blockedBy', 'removed', o, null, [o.id])
  540. }

Branches

Latest commits

  • bb64d57cmission 012 (ticket #25 API part): every /api/projects/<x>/… route takes the project's id (12-char record id) as well as its slug, same answers byte for byte; tickets stay under their project, no global /api/tickets/<x>. gate 315/0 (old code: the 13 id checks fail), connect 60/0, live-data copy old vs new 1,484 identical, slug vs id 772 identicalmre
  • 7538b034mission 011 (ticket #25 web part): short page URLs /<slug> and /<slug>/<number>; /projects/<slug>, /projects/<slug>/<n>, /projects/<slug>/tickets/<n> and /tickets/<ref> answer 301 (current slug); slug pages moved behind hl:web's own routes after construction (they answered /__hl/* and /components/*.hl); reserved slugs refused; API paths unchanged. gate 293/0, connect 60/0, live-data copy 187/0, API old vs new: only page links differmre
  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre