tickets
All repositories: gitoria
27.3 KB
// lib/tickets.hl — TICKETS AND THEIR RELATIONS: the central logic of tickets.worldapi.org. Statics only, the server realm.// A face / an API route (components/, lib/api.hl) checks the session or token and calls one function here; every// write answers the rows the push carries.//// CREATOR'S CONVENTIONS (2026-09-24, ticket #38): every primary key is the mpackdb UUID// (`@id`, a string like '0mufbkwhlpjq'), never a `*id` counter; the files live in// storage/mpackdb/. A NUMBER A PERSON SEES is a separate field: tickets are numbered PER// PROJECT from 1 (`number`); tickets migrated from the old global store keep their old// global number as `oldNumber` (docs and comments say "#38"). Order never comes from key// order: lists sort by the stored times, a history by (created, seq).//// ticketsTable pk @id index project state source !path *oldNumber// { project (project @id — NOT the name), number, path ('<project @id>/<number>', unique),// oldNumber?, subject, summary, state, source, created, updated, events,// createdBy (users @id, '' = opened before the login), assignee (users @id, '') }// linksTable pk @id index @ticket @other (ticket #4, mission 017) { kind, ticket, other, user, created }// kind 'parent': `ticket` is a child of `other` (at most one per ticket)// kind 'blockedBy': `ticket` is blocked by `other` (waits on it)// A removed relation is deleted here; the history keeps it ('link' events, lib/events.hl).// Every write appends its event to the history (events.hl putEvent) and stores the ticket with the new// `events` count / `updated`. Projects, members and roles: lib/projects.hl; state names and page shapes:// lib/tickets-helpers.hl.//// WHO MAY is the role in the ticket's project (projects.hl): a refusal for a missing right has// `forbidden = true` (the API answers 403). EVERY WRITE HAS A USER (ticket #7): the logged-in user of the// web session or of the API token (users.hl). `source` is the import key (the ticket file's name) — an// import with a known source is answered with the existing ticket, never a second one.//// reading ticketRowOf, ticketRecord / ticketAt / ticketByRef / ticketByKey, ticketRows, inboxRows,// eventRows, ticketWithEvents, stateChoices, ticketRights// writing createTicket, addComment, changeState, assignTicket, editTicket, removeMember// relations relationsOf, relatedViews, setParent, changeBlockerimport { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { storageDir, countOf, first, merged, clean, isDigits, localStamp, newestFirst, oldestFirst, ticketHref, ticketApiHref, projectHrefOf } from './util.hl'import { nameOfUser } from './users.hl'import { projectRecord, projectByAnySlug, projectByRef, projectSlugOf, roleOf, rankOf, isMember, mayEditIn, memberRecord, memberRows, userByRef, assignChoices, userOk, authorMissing, removeMembership } from './projects.hl'import { states, inboxStates, stateOf, slugOf, refOf, keyOf, relationViewOf } from './tickets-helpers.hl'import { putEvent, eventsError, eventRowAt, historyOf, creatorNameOf } from './events.hl'static ticketsTable = new MPackDB(file = storageDir + '/tickets.db', primaryKey = '@id', indexes = ['project', 'state', 'source', '!path', '*oldNumber'])static linksTable = new MPackDB(file = storageDir + '/links.db', primaryKey = '@id', indexes = ['@ticket', '@other'])// ---- the shapes a page and the API show ---------------------------------------------// A ROW IS A PLAIN HYBRID: what the View reads is what the browser receives.// `id` is the UUID; `number` + `project` (the slug) are what a person uses; `oldNumber` / `oldRef`// only on tickets migrated from the global numbering.static ticketRowOf = (t) => {n = t.events != null ? t.events : 0p = projectRecord(t.project)slug = p != null ? p.slug : ''byId = t.createdBy != null ? t.createdBy : ''toId = t.assignee != null ? t.assignee : ''row = {id = t.idproject = slugprojectId = t.projectprojectTitle = p != null ? p.title : ''number = t.numberref = '#' + t.numbersubject = t.subjectsummary = t.summarystate = t.statestateSlug = slugOf(t.state)source = t.source != null ? t.source : ''created = localStamp(t.created)createdMs = t.createdupdated = localStamp(t.updated)updatedMs = t.updatedevents = nhref = ticketHref(slug, t.number)apiHref = ticketApiHref(slug, t.number)projectHref = projectHrefOf(slug)hasOld = t.oldNumber != nulloldRef = t.oldNumber != null ? '#' + t.oldNumber : ''createdById = byIdcreatedBy = byId != '' ? nameOfUser(byId) : creatorNameOf(t)assigneeId = toIdassignee = toId != '' ? nameOfUser(toId) : ''hasAssignee = toId != ''}if (t.oldNumber != null) { row.oldNumber = t.oldNumber }// ticket #4 (mission 017): the relations, as refs with their states (relationsOf below)rel = relationsOf(t)row.parent = rel.parentrow.children = rel.childrenrow.allChildrenConfirmed = rel.allChildrenConfirmedrow.blockedBy = rel.blockedByrow.blocks = rel.blocksreturn row}// ---- finding a ticket -------------------------------------------------------------------static ticketRecord = (uuid) => { return uuid == null || uuid == '' ? null : ticketsTable.fetch('' + uuid) }// `project` is a slug (a former one too)static ticketAt = (project, number) => {if (project == null || !isDigits(number)) { return null }p = projectByAnySlug(project)if (p == null) { return null }return first(ticketsTable.find('path', p.id + '/' + toNumber('' + number)))}static ticketByOld = (oldNumber) => {if (!isDigits(oldNumber)) { return null }return first(ticketsTable.find('oldNumber', toNumber('' + oldNumber)))}// what `/api/tickets/:ref` and `/tickets/:ref` accept: the OLD global number (digits)// or the ticket's UUIDstatic ticketByRef = (ref) => {if (ref == null) { return null }if (isDigits(ref)) { return ticketByOld(ref) }return ticketRecord(ref)}// ---- reads -------------------------------------------------------------------------// newest update first; both filters optional (null = all); `project` is a slugstatic ticketRows = (project, state) => {rows = []let all = nullif (project != null) {p = projectByAnySlug(project)if (p == null) { return rows }all = ticketsTable.find('project', p.id)} else {all = ticketsTable.find(null, null)}if (countOf(all) == 0) { return rows }for (t of all) {if (state == null || t.state == state) { rows.push(ticketRowOf(t)) }}return newestFirst(rows, 'updatedMs')}// THE INBOX of a user: the tickets ASSIGNED TO THEM in pending and in review, two listsstatic inboxRows = (user) => {out = { pending = [] review = [] }if (user == null || user.id == null) { return out }a = []b = []ps = ticketsTable.find('state', 'pending')if (countOf(ps) > 0) { for (t of ps) { if (t.assignee == user.id) { a.push(ticketRowOf(t)) } } }rs = ticketsTable.find('state', 'review')if (countOf(rs) > 0) { for (t of rs) { if (t.assignee == user.id) { b.push(ticketRowOf(t)) } } }out.pending = newestFirst(a, 'updatedMs')out.review = newestFirst(b, 'updatedMs')return out}static inboxCountOf = (user) => {x = inboxRows(user)return x.pending.length + x.review.length}// the page's row of `/<project>/<number>` (ticket #25), or nullstatic ticketRowAt = (project, number) => {t = ticketAt(project, number)return t == null ? null : ticketRowOf(t)}// the event history of a ticket (UUID), oldest first (events.hl historyOf)static eventRows = (ticketId) => {t = ticketRecord(ticketId)if (t == null) { return [] }return historyOf(t)}// the options of a state select: [{ value, label }] — the states THIS user may move the ticket to// (a `use` member: only open ⇄ review; `edit` / `admin`: every other state)static stateChoices = (t, user) => {out = []if (t == null || user == null) { return out }role = roleOf(t.project, user)for (s of states) {if (s != t.state && mayMoveTo(role, t.state, s)) { out.push({ value = s label = s }) }}return out}static mayMoveTo = (role, current, target) => {if (rankOf(role) >= 2) { return true }if (rankOf(role) == 1) { return (current == 'open' && target == 'review') || (current == 'review' && target == 'open') }return false}// what a user may do on a ticket (a record): comment (a member), edit / assign (edit, admin), the states they// may move it to, and the members it can be assigned to — the ticket page asks after a login or a changestatic ticketRights = (t, user) => {return { comment = isMember(t.project, user) edit = mayEdit(t, user) options = stateChoices(t, user) members = assignChoices(t.project) }}// ---- writes: each answers what the push carries --------------------------------------// The author is the user — there is no free author field any more.// the next per-project number: the highest one in the project + 1 (first = 1)static nextNumber = (projectId) => {let top = 0all = ticketsTable.find('project', projectId)if (countOf(all) > 0) {for (t of all) { if (t.number != null && t.number > top) { top = t.number } }}return top + 1}// answers { error, field, forbidden? } or { ticket (row), event (row), existed }// `project` = the slug (or id) of an existing project; `assignee` optional (a member's name / id)static createTicket = (project, subject, summary, user, source, assignee) => {s = clean(subject)if (clean(project) == '') { return { error = 'project is required' field = 'project' } }if (s == '') { return { error = 'subject is required' field = 'subject' } }if (!userOk(user)) { return authorMissing }p = projectByRef(clean(project))if (p == null) { return { error = 'no such project: ' + clean(project) field = 'project' } }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 } }let who = ''if (assignee != null && clean(assignee) != '') {r = userByRef(assignee, p.id)if (r.error != null) { return { error = r.error field = 'assignee' } }who = r.user.id}src = clean(source)if (src != '') {known = ticketsTable.find('source', src)if (countOf(known) > 0) { return { ticket = ticketRowOf(known[0]) event = null existed = true } }}t0 = now()n = nextNumber(p.id)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 })if (id == null) { return { error = 'could not store the ticket: ' + ticketsTable.lastError() field = '' } }t = ticketsTable.fetch(id)eid = putEvent({ ticket = id seq = 1 kind = 'created' author = user.name user = user.id text = src != '' ? 'imported from ' + src : '' created = t0 })out = { ticket = ticketRowOf(t) event = eventRowAt(eid, t) existed = false }if (who != '') {let seq = 2aid = putEvent({ ticket = id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = '' created = t0 })ticketsTable.update(id, merged(t, { events = seq }))t2 = ticketsTable.fetch(id)out.ticket = ticketRowOf(t2)out.assignEvent = eventRowAt(aid, t2)}return out}// answers { error } or { ticket, event }; `ticketId` is the UUID. Any member may comment.static addComment = (ticketId, user, text) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }body = clean(text)if (body == '') { return { error = 'a comment needs text' field = 'text' } }if (!userOk(user)) { return authorMissing }if (!isMember(t.project, user)) { return { error = 'only a member of the project can comment' field = '' forbidden = true } }t0 = now()let seq = (t.events != null ? t.events : 0) + 1eid = putEvent({ ticket = t.id seq = seq kind = 'comment' author = user.name user = user.id text = body created = t0 })ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }}// the admin who gets a ticket that lands in the inbox with nobody assigned: the oldest adminstatic defaultAssignee = (projectId) => {for (m of memberRows(projectId)) { if (m.role == 'admin') { return m.user } }return ''}// answers { error } or { ticket, event, assignEvent? }; an optional note rides on the state event.// `forbidden = true` on a refusal the user may not overcome (the role does not allow that move).// A ticket that goes to review / pending with nobody assigned is assigned to the project's first admin// (so it reaches an inbox) — a second, 'assign' event.static changeState = (ticketId, state, user, note) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }s = stateOf(state)if (s == null) { return { error = 'unknown state — one of: ' + states.join(', ') field = 'state' } }if (s == t.state) { return { error = 'the ticket is already ' + s field = 'state' } }if (!userOk(user)) { return authorMissing }role = roleOf(t.project, user)if (role == null) { return { error = 'only a member of the project can change the state' field = 'state' forbidden = true } }if (!mayMoveTo(role, t.state, s)) { return { error = 'the role ' + role + ' can only move a ticket between open and review' field = 'state' forbidden = true } }t0 = now()let seq = (t.events != null ? t.events : 0) + 1eid = putEvent({ ticket = t.id seq = seq kind = 'state' author = user.name user = user.id text = clean(note) from = t.state to = s created = t0 })changes = { state = s updated = t0 events = seq }let aid = nulllet auto = ''if (inboxStates.includes(s) && (t.assignee == null || t.assignee == '')) { auto = defaultAssignee(t.project) }if (auto != '') {seq = seq + 1aid = putEvent({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = auto previous = '' created = t0 })changes.assignee = autochanges.events = seq}ticketsTable.update(t.id, merged(t, changes))t2 = ticketsTable.fetch(t.id)out = { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }if (aid != null) { out.assignEvent = eventRowAt(aid, t2) }return out}// answers { error, forbidden? } or { ticket, event }; `assignee` = a member's users @id, '' = nobodystatic assignTicket = (ticketId, user, assignee) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }if (!mayEditIn(t.project, user)) { return { error = 'you need the role edit or admin to assign a ticket' field = 'assignee' forbidden = true } }who = assignee == null ? '' : clean(assignee)if (who != '' && memberRecord(t.project, who) == null) { return { error = 'the assignee must be a member of the project' field = 'assignee' } }was = t.assignee != null ? t.assignee : ''if (who == was) { return { error = who == '' ? 'the ticket is not assigned' : 'the ticket is already assigned to that person' field = 'assignee' } }t0 = now()let seq = (t.events != null ? t.events : 0) + 1eid = putEvent({ ticket = t.id seq = seq kind = 'assign' author = user.name user = user.id text = '' assignee = who previous = was created = t0 })ticketsTable.update(t.id, merged(t, { assignee = who updated = t0 events = seq }))t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }}// ---- editing (ticket #8, mission 014; ticket #20: by the role) --------------------------------// THE AUTHOR of a ticket is `createdBy` (users @id), or null for a ticket opened before the login// existed. MAY THIS USER EDIT the ticket (a record)? Any member with the role edit or admin of// its project (`use` members only comment and move it between open and review).static mayEdit = (t, user) => {if (t == null || !userOk(user)) { return false }return mayEditIn(t.project, user)}static mayEditId = (ticketId, user) => { return mayEdit(ticketRecord(ticketId), user) }// answers { error, field?, forbidden? } or { ticket, event }. `subject` / `summary` null = keep// that one. The previous values stay in the 'edit' event (append-only history).static editTicket = (ticketId, user, subject, summary) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }if (!mayEdit(t, user)) { return { error = 'you need the role edit or admin in the project to edit a ticket' field = '' forbidden = true } }if (subject == null && summary == null) { return { error = 'nothing to change: give subject and/or summary' field = '' } }let oldSummary = t.summary != null ? t.summary : ''s = subject != null ? clean(subject) : t.subjectif (s == '') { return { error = 'subject is required' field = 'subject' } }sm = summary != null ? clean(summary) : oldSummaryif (s == t.subject && sm == oldSummary) { return { error = 'nothing changed' field = subject != null ? 'subject' : 'summary' } }t0 = now()let seq = (t.events != null ? t.events : 0) + 1eid = 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 })if (eid == null) { return { error = 'could not store the edit: ' + eventsError() field = '' } }ticketsTable.update(t.id, merged(t, { subject = s summary = sm updated = t0 events = seq }))t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) }}// an admin removes a member (projects.hl removeMembership); the tickets assigned to the person are no longer// assigned to anybody. Answers { members } or { error, forbidden? }static removeMember = (projectId, actor, userId) => {r = removeMembership(projectId, actor, userId)if (r.error != null) { return r }all = ticketsTable.find('project', projectId)if (countOf(all) > 0) {for (t of all) {if (t.assignee == userId) { ticketsTable.update(t.id, merged(t, { assignee = '' })) }}}return r}// the API's view of one ticket (a record): the row and its whole historystatic ticketWithEvents = (t) => {if (t == null) { return null }return { ticket = ticketRowOf(t) events = eventRows(t.id) }}// ---- relations: parent / child and blocked-by (ticket #4, mission 017) ----------------------// A ticket has at most ONE PARENT (any project); a parent lists its CHILDREN with their states.// A ticket can be BLOCKED BY other tickets (any project) — it waits on them; the other side// shows what it BLOCKS. The scheduler reads them from the JSON rows (`parent`, `children`,// `blockedBy`, `blocks`, each a ref with the state) and the Markdown view.// PARENT STATE: `allChildrenConfirmed` (the key kept for the clients; it now means DONE) is true when// a ticket has children and every one of them is `done`. It is only SHOWN ("all children done") — the// parent's own state is never changed by it.// WHO MAY set / remove a relation (ticket #20): a member with the role edit or admin in the project of// EITHER of the two tickets. Replacing a parent needs the// right for the old pair AND the new pair. No loops: a ticket cannot be its own ancestor, and// cannot (transitively) wait on itself.// Tickets are named as `<project>#<number>` (spaces around `#` allowed) or by their UUID.// the link records of one side, oldest first (stored time): field 'ticket' = this ticket's own// relations (its parent, its blockers), field 'other' = the ones pointing at it (children, blocked)static linkRecords = (field, ticketId, kind) => {out = []if (ticketId == null || ticketId == '') { return out }all = linksTable.find(field, '' + ticketId)if (countOf(all) == 0) { return out }for (l of all) { if (l.kind == kind) { out.push(l) } }return oldestFirst(out, 'created')}static parentLinkOf = (ticketId) => { return first(linkRecords('ticket', ticketId, 'parent')) }static parentIdOf = (ticketId) => {l = parentLinkOf(ticketId)return l == null ? null : l.other}static idsOfLinks = (links, field) => {out = []for (l of links) { out.push(l[field]) }return out}static childIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'parent'), 'ticket') }static blockerIdsOf = (ticketId) => { return idsOfLinks(linkRecords('ticket', ticketId, 'blockedBy'), 'other') }static blockedIdsOf = (ticketId) => { return idsOfLinks(linkRecords('other', ticketId, 'blockedBy'), 'ticket') }static refsOfIds = (ids) => {out = []for (i of ids) {let t = ticketRecord(i)if (t != null) { out.push(refOf(t)) }}return out}// { parent (ref or null), children, blockedBy, blocks (lists of refs), allChildrenConfirmed }static relationsOf = (t) => {pid = parentIdOf(t.id)p = pid != null ? ticketRecord(pid) : nullkids = refsOfIds(childIdsOf(t.id))let all = kids.length > 0for (c of kids) { if (c.state != 'done') { all = false } }return { parent = p != null ? refOf(p) : null children = kids allChildrenConfirmed = all blockedBy = refsOfIds(blockerIdsOf(t.id)) blocks = refsOfIds(blockedIdsOf(t.id)) }}// the relation views of a ticket AND every ticket related to it (+ `extra` ids: a parent or// blocker just removed) — what a write pushes, so every open page of them followsstatic relatedViews = (ticketId, extra) => {ids = ['' + ticketId]seen = {}seen['' + ticketId] = truemore = []pid = parentIdOf(ticketId)if (pid != null) { more.push(pid) }for (i of childIdsOf(ticketId)) { more.push(i) }for (i of blockerIdsOf(ticketId)) { more.push(i) }for (i of blockedIdsOf(ticketId)) { more.push(i) }for (i of extra) { if (i != null) { more.push(i) } }for (i of more) {if (seen['' + i] != true) {seen['' + i] = trueids.push('' + i)}}out = []for (i of ids) {let t = ticketRecord(i)if (t != null) { out.push(relationViewOf(ticketRowOf(t))) }}return out}// `<project>#<number>` (spaces around '#' allowed) or a UUID → the ticket record, or nullstatic ticketByKey = (key) => {k = clean(key)if (k == '') { return null }at = k.indexOf('#')if (at < 0) { return isDigits(k) ? null : ticketRecord(k) }p = k.slice(0, at).trim()n = k.slice(at + 1).trim()if (p == '') { return null }return ticketAt(p, n)}static mayLink = (a, b, user) => {if (!userOk(user)) { return false }if (a != null && mayEditIn(a.project, user)) { return true }if (b != null && mayEditIn(b.project, user)) { return true }return false}static linkForbidden = { error = 'you need the role edit or admin in the project of either ticket to change this relation' field = '' forbidden = true }// the history event of a relation change, on ticket `t`; answers { ticket, event, related }static linkEvent = (t, user, linkKind, action, other, previous, extra) => {t0 = now()let seq = (t.events != null ? t.events : 0) + 1// the values BEFORE the literal: an entry named `other` / `user` would shadow the variable// for the entries after it (mission 011 lesson)uname = user.nameuid = user.idoid = other.idoproject = projectSlugOf(other)onumber = other.numberrec = { ticket = t.id seq = seq kind = 'link' author = uname user = uid text = '' linkKind = linkKind action = action other = oid otherProject = oproject otherNumber = onumber created = t0 }if (previous != null) {rec.previous = previous.idrec.previousProject = projectSlugOf(previous)rec.previousNumber = previous.number}eid = putEvent(rec)if (eid == null) { return { error = 'could not store the event: ' + eventsError() field = '' } }ticketsTable.update(t.id, merged(t, { updated = t0 events = seq }))t2 = ticketsTable.fetch(t.id)return { ticket = ticketRowOf(t2) event = eventRowAt(eid, t2) related = relatedViews(t.id, extra) }}// SET (key = `<project>#<n>` / UUID) or REMOVE (key = '') the parent of a ticket (UUID).// Answers { error, field, forbidden? } or { ticket, event, related }.static setParent = (ticketId, user, key) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }k = clean(key)cur = parentLinkOf(t.id)curT = cur != null ? ticketRecord(cur.other) : nullif (k == '') {if (cur == null || curT == null) { return { error = 'the ticket has no parent' field = 'parent' } }if (!mayLink(t, curT, user)) { return linkForbidden }linksTable.delete(cur.id)return linkEvent(t, user, 'parent', 'removed', curT, null, [curT.id])}p = ticketByKey(k)if (p == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = 'parent' } }if (p.id == t.id) { return { error = 'a ticket cannot be its own parent' field = 'parent' } }if (curT != null && curT.id == p.id) { return { error = 'the parent is already ' + keyOf(p) field = 'parent' } }// no loop: the new parent must not be this ticket's child, grandchild, …let up = p.idlet steps = 0while (up != null && steps < 1000) {if (up == t.id) { return { error = 'a loop: ' + keyOf(p) + ' is below this ticket' field = 'parent' } }up = parentIdOf(up)steps = steps + 1}if (!mayLink(t, p, user)) { return linkForbidden }if (curT != null && !mayLink(t, curT, user)) { return linkForbidden }lid = linksTable.put({ kind = 'parent' ticket = t.id other = p.id user = user.id created = now() })if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }if (cur != null) { linksTable.delete(cur.id) }return linkEvent(t, user, 'parent', 'set', p, curT, [curT != null ? curT.id : null])}// does ticket `start` (transitively) wait on ticket `target`? (blocked-by links)static waitsOn = (start, target) => {stack = [start]seen = {}let steps = 0while (stack.length > 0 && steps < 10000) {let cur = stack.pop()steps = steps + 1if (cur == target) { return true }if (seen['' + cur] != true) {seen['' + cur] = truefor (i of blockerIdsOf(cur)) { stack.push(i) }}}return false}// ADD (adding = true) or REMOVE a blocker of a ticket (UUID): ticket `key` blocks it.// `field` names the API field ('add' / 'remove'). Answers as setParent.static changeBlocker = (ticketId, user, key, adding, field) => {t = ticketRecord(ticketId)if (t == null) { return { error = 'no such ticket' } }if (!userOk(user)) { return authorMissing }k = clean(key)if (k == '') { return { error = 'name the blocking ticket as <project>#<number>' field = field } }o = ticketByKey(k)if (o == null) { return { error = 'no such ticket: ' + k + ' (name it as <project>#<number>)' field = field } }let existing = nullfor (l of linkRecords('ticket', t.id, 'blockedBy')) { if (l.other == o.id) { existing = l } }if (adding) {if (o.id == t.id) { return { error = 'a ticket cannot block itself' field = field } }if (existing != null) { return { error = 'already blocked by ' + keyOf(o) field = field } }if (waitsOn(o.id, t.id)) { return { error = 'a loop: ' + keyOf(o) + ' already waits on this ticket' field = field } }if (!mayLink(t, o, user)) { return linkForbidden }lid = linksTable.put({ kind = 'blockedBy' ticket = t.id other = o.id user = user.id created = now() })if (lid == null) { return { error = 'could not store the relation: ' + linksTable.lastError() field = '' } }return linkEvent(t, user, 'blockedBy', 'set', o, null, [])}if (existing == null) { return { error = 'not blocked by ' + keyOf(o) field = field } }if (!mayLink(t, o, user)) { return linkForbidden }linksTable.delete(existing.id)return linkEvent(t, user, 'blockedBy', 'removed', o, null, [o.id])}
Branches
- mainmain branch
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