gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitbb64d57cbb64d57cmission 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 identicalmrebb64d57c/components/ticket.hl

20.7 KB

  1. // components/ticket.hl — `/:projectSlug/:ticketNumber` (ticket #25; was /projects/…): one ticket, its
  2. // append-only event history, a comment form and the state change. (Old URLs `/tickets/<n>`,
  3. // `/projects/<slug>/<n>`, `/projects/<slug>/tickets/<n>` are 301-redirected here by lib/api.hl.) Live: an event on THIS ticket (from any browser or
  4. // the API) is appended here and the state badge follows, without a reload.
  5. // Ticket #6 (mission 014): the summary, comments and state notes are MARKDOWN, rendered by the
  6. // child component Markdown (components/markdown.hl) from markdown.hl's blocks — safe: no HTML
  7. // string, links checked. Ticket #8: the ticket's AUTHOR (a ticket from before the login: the
  8. // creator) gets "Edit" — subject + summary; the edit is a history event that keeps the previous
  9. // values ("edited the ticket", the previous summary folded under it), pushed live like a comment.
  10. // Ticket #4 (mission 017): RELATIONS — "Part of <parent>", the children with their states and
  11. // "all children confirmed" / "n of m children confirmed", "Blocked by" and "Blocks" (each with
  12. // states); logged-in users get the forms "Parent" (set / remove) and "Blocked by" (add / remove per
  13. // row) — the store decides who may (the author of either ticket, or the creator). Live: the
  14. // `ticketsRelated` push carries the relation view of every ticket involved in a write.
  15. // Ticket #12 (mission 024): the Markdown fields (edit summary, comment, state note) are
  16. // `<md-editor>`s around their textareas (shared/md-editor.js): edited visually, the textarea keeps
  17. // the plain Markdown and fires `input` as before, so the members and faces are unchanged. The state
  18. // note became a textarea (rows 1) because the editor writes line breaks. Ctrl/Cmd+Enter in the
  19. // editor submits the form (the textarea's `keyed` handler only runs without the editor).
  20. // Ticket #20 (roles): a member of the project may comment; `use` also moves the ticket between open and
  21. // review; `edit` / `admin` edit it, assign it (a member; "Assigned to …" is shown) and set any state; a
  22. // visitor without a role only reads. The server decides in every case; the page only shows what it may.
  23. // A ticket opened through a FORMER slug of its project shows a note with the current address.
  24. parent './main.hl'
  25. import { ticketRowAt, ticketRecord, eventRows, stateChoices, addComment, changeState, assignTicket, editTicket, mayEditId, ticketRights, relatedViews, setParent, changeBlocker } from '../lib/tickets.hl'
  26. import { assignChoices, isMember } from '../lib/projects.hl'
  27. import { relationViewOf, noRef } from '../lib/tickets-helpers.hl'
  28. import { pageEventsOf, pageEventOf } from '../lib/events.hl'
  29. import { markdownBlocks } from '../lib/markdown.hl'
  30. import { siteName } from '../project.hl'
  31. import { canWriteSession, writerOfSession, userOfSession } from '../lib/users.hl'
  32. import Markdown from './markdown.hl'
  33. projectSlug = null
  34. ticketNumber = null
  35. row = ticketRowAt(projectSlug, ticketNumber)
  36. present = row != null
  37. gone = row == null
  38. ticketId = row != null ? row.id : ''
  39. ref = row != null ? row.ref : ''
  40. movedNote = row != null && projectSlug != row.project
  41. currentHref = row != null ? row.href : ''
  42. assignee = row != null ? row.assignee : ''
  43. hasAssignee = row != null && row.hasAssignee
  44. createdBy = row != null ? row.createdBy : ''
  45. hasOld = row != null && row.hasOld
  46. oldRef = row != null ? row.oldRef : ''
  47. subject = row != null ? row.subject : 'No such ticket'
  48. summary = row != null ? row.summary : ''
  49. hasSummary = row != null && row.summary != ''
  50. summaryMd = row != null ? markdownBlocks(row.summary) : []
  51. project = row != null ? row.project : ''
  52. projectHref = row != null ? row.projectHref : '/'
  53. state = row != null ? row.state : ''
  54. stateSlug = row != null ? row.stateSlug : ''
  55. created = row != null ? row.created : ''
  56. updated = row != null ? row.updated : ''
  57. events = row != null ? pageEventsOf(eventRows(row.id)) : []
  58. __title = siteName + ' | ' + (row != null ? row.project + ' ' + row.ref + ' ' + row.subject : 'no such ticket')
  59. // the forms: only for a logged-in user with a display name (ticket #7); the author is that
  60. // user (the faces read the session). Confirm / reject: the server refuses a non-creator.
  61. session = null
  62. me = userOfSession(session)
  63. canWrite = row != null && me != null && isMember(row.projectId, me)
  64. mustLogIn = me == null
  65. notMember = me != null && !canWrite
  66. options = row != null ? stateChoices(ticketRecord(row.id), me) : []
  67. hasOptions = options.length > 0
  68. memberOptions = row != null ? assignChoices(row.projectId) : []
  69. commentText = ''
  70. newState = options.length > 0 ? options[0].value : ''
  71. stateNote = ''
  72. message = ''
  73. // editing (ticket #8): only the author (the server decides; the face checks again)
  74. canEdit = row != null && mayEditId(row.id, me)
  75. assignDraft = row != null ? row.assigneeId : ''
  76. assignMessage = ''
  77. editing = false
  78. showEdit = canEdit
  79. showEdit2 = canEdit
  80. editSubject = ''
  81. editSummary = ''
  82. editMessage = ''
  83. // relations (ticket #4): the view of lib/tickets-helpers.hl relationViewOf, flat members for the View
  84. rel = row != null ? relationViewOf(row) : null
  85. hasRelations = rel != null && rel.hasRelations
  86. hasParent = rel != null && rel.hasParent
  87. parentRef = rel != null ? rel.parent : noRef
  88. hasChildren = rel != null && rel.hasChildren
  89. children = rel != null ? rel.children : []
  90. childNote = rel != null ? rel.childNote : ''
  91. childClass = rel != null ? rel.childClass : ''
  92. hasBlockedBy = rel != null && rel.hasBlockedBy
  93. blockedBy = rel != null ? rel.blockedBy : []
  94. hasBlocks = rel != null && rel.hasBlocks
  95. blocks = rel != null ? rel.blocks : []
  96. parentDraft = ''
  97. blockerDraft = ''
  98. linkMessage = ''
  99. View {
  100. ticketView {
  101. if (gone) { p { id = "gone" class = "empty" "There is no such ticket." } }
  102. if (present) {
  103. header {
  104. p { a { id = "project" href = projectHref project } " " ticketRef { id = "ref" ref }
  105. if (hasOld) { formerRef { id = "oldref" "formerly " oldRef } }
  106. }
  107. h1 { id = "subject" subject }
  108. ticketState { id = "state" class = stateSlug state }
  109. p { class = "people" "opened by " strong { id = "createdby" createdBy }
  110. if (hasAssignee) { " · assigned to " strong { id = "assignee" assignee } }
  111. }
  112. if (movedNote) { p { id = "movednote" class = "hint" "This project has a new address: " a { id = "movedlink" href = currentHref currentHref } } }
  113. p { class = "dates" "opened " time { created } " · updated " time { id = "updated" updated } }
  114. if (showEdit) { button { id = "editbutton" type = "button" class = "quiet" "Edit" on click(e) { emit startEdit(e) } } }
  115. }
  116. if (editing) {
  117. form { id = "editform"
  118. on submit(e) {
  119. e.preventDefault()
  120. emit saveEdit(e)
  121. }
  122. label { "Subject"
  123. input { id = "editsubject" name = "subject" required = "required" maxlength = "200" value = editSubject on input(e) { emit setEditSubject(e.target.value) } }
  124. }
  125. label { "Summary"
  126. mdEditor { textarea { id = "editsummary" name = "summary" rows = "8" value = editSummary on input(e) { emit setEditSummary(e.target.value) } } }
  127. }
  128. formButtons {
  129. button { id = "editsave" type = "submit" "Save" }
  130. button { id = "editcancel" type = "button" class = "quiet" "Cancel" on click(e) { emit cancelEdit(e) } }
  131. }
  132. p { id = "editmessage" class = "message" editMessage }
  133. }
  134. }
  135. if (hasSummary) { ticketSummary { id = "summary" Markdown { blocks = summaryMd } } }
  136. if (hasRelations) { ticketRelations { id = "relations"
  137. if (hasParent) { p { id = "parent" "Part of "
  138. a { href = parentRef.href parentRef.project " " parentRef.ref }
  139. " " span { class = "refsubject" parentRef.subject }
  140. " " ticketState { class = parentRef.stateSlug parentRef.state }
  141. } }
  142. if (hasChildren) { div { id = "childrenbox"
  143. h2 { "Children" }
  144. p { id = "childnote" class = childClass childNote }
  145. ul { id = "children" class = "refs"
  146. for (c of children) { li {
  147. ticketState { class = c.stateSlug c.state }
  148. a { href = c.href c.project " " c.ref }
  149. span { class = "refsubject" c.subject }
  150. } }
  151. }
  152. } }
  153. if (hasBlockedBy) { div { id = "blockedbybox"
  154. h2 { "Blocked by" }
  155. ul { id = "blockedby" class = "refs"
  156. for (c of blockedBy) { li {
  157. ticketState { class = c.stateSlug c.state }
  158. a { href = c.href c.project " " c.ref }
  159. span { class = "refsubject" c.subject }
  160. if (showEdit2) { button { type = "button" class = "quiet unblock" value = c.key "Remove" on click(e) { emit removeBlocker(e.target.value) } } }
  161. } }
  162. }
  163. } }
  164. if (hasBlocks) { div { id = "blocksbox"
  165. h2 { "Blocks" }
  166. ul { id = "blocks" class = "refs"
  167. for (c of blocks) { li {
  168. ticketState { class = c.stateSlug c.state }
  169. a { href = c.href c.project " " c.ref }
  170. span { class = "refsubject" c.subject }
  171. } }
  172. }
  173. } }
  174. } }
  175. section {
  176. h2 { "History" }
  177. ol { id = "events"
  178. for (ev of events) {
  179. li { class = ev.kind
  180. eventHead {
  181. strong { ev.author }
  182. " " ev.label
  183. if (ev.isState) { " " ticketState { class = ev.toSlug ev.to } }
  184. " " time { ev.when }
  185. }
  186. if (ev.isEdit) { eventText { class = "edit"
  187. if (ev.subjectChanged) { p { class = "was" "Subject before: " s { ev.oldSubject } } }
  188. if (ev.summaryChanged) { details { class = "was"
  189. summary { "Summary before" }
  190. Markdown { blocks = ev.oldSummaryMd }
  191. } }
  192. } }
  193. if (ev.hasText) { eventText { Markdown { blocks = ev.md } } }
  194. }
  195. }
  196. }
  197. }
  198. if (mustLogIn) {
  199. p { id = "loginhint" class = "hint" "Reading is open to everyone. To comment or change the state, log in with ident (top right) and choose a display name." }
  200. }
  201. if (notMember) {
  202. p { id = "memberhint" class = "hint" "You are not a member of this project — ask an admin to invite you to comment." }
  203. }
  204. if (canWrite) { section {
  205. h2 { "Respond" }
  206. form { id = "commentform"
  207. on submit(e) {
  208. e.preventDefault()
  209. emit sendComment(e)
  210. }
  211. label { "Comment"
  212. mdEditor { textarea { id = "commenttext" name = "comment" rows = "3" required = "required" placeholder = "Write a comment" value = commentText
  213. on input(e) { emit setComment(e.target.value) }
  214. on keydown(e) { emit keyed(e) }
  215. } }
  216. }
  217. button { id = "commentsend" type = "submit" "Comment" }
  218. }
  219. if (hasOptions) { form { id = "stateform"
  220. on submit(e) {
  221. e.preventDefault()
  222. emit sendState(e)
  223. }
  224. label { "State"
  225. select { id = "newstate" name = "state" value = newState
  226. on change(e) { emit setNewState(e.target.value) }
  227. for (o of options) { option { value = o.value o.label } }
  228. }
  229. }
  230. label { "Note (optional)"
  231. mdEditor { textarea { id = "statenote" name = "note" rows = "1" value = stateNote on input(e) { emit setStateNote(e.target.value) } } }
  232. }
  233. button { id = "statesend" type = "submit" "Change state" }
  234. } }
  235. p { id = "message" class = "message" message }
  236. } }
  237. if (showEdit2) { section {
  238. h2 { "Manage" }
  239. form { id = "assignform"
  240. on submit(e) {
  241. e.preventDefault()
  242. emit sendAssign(e)
  243. }
  244. label { "Assigned to"
  245. select { id = "assignselect" name = "assignee" value = assignDraft on change(e) { emit setAssignDraft(e.target.value) }
  246. option { value = "" "nobody" }
  247. for (o of memberOptions) { option { value = o.value o.label } }
  248. }
  249. }
  250. button { id = "assignsave" type = "submit" "Assign" }
  251. }
  252. p { id = "assignmessage" class = "message" assignMessage }
  253. form { id = "parentform"
  254. on submit(e) {
  255. e.preventDefault()
  256. emit sendParent(e)
  257. }
  258. label { "Parent ticket (project#number)"
  259. input { id = "parentkey" name = "parent" placeholder = "e.g. ident.worldapi.org#1" autocomplete = "off" value = parentDraft on input(e) { emit setParentDraft(e.target.value) } }
  260. }
  261. formButtons {
  262. button { id = "parentsave" type = "submit" "Set parent" }
  263. if (hasParent) { button { id = "parentremove" type = "button" class = "quiet" "Remove parent" on click(e) { emit removeParent(e) } } }
  264. }
  265. }
  266. form { id = "blockerform"
  267. on submit(e) {
  268. e.preventDefault()
  269. emit sendBlocker(e)
  270. }
  271. label { "Blocked by (project#number)"
  272. input { id = "blockerkey" name = "blocker" placeholder = "e.g. hybriel#12" autocomplete = "off" value = blockerDraft on input(e) { emit setBlockerDraft(e.target.value) } }
  273. }
  274. button { id = "blockeradd" type = "submit" "Add blocker" }
  275. }
  276. p { id = "linkmessage" class = "message" linkMessage }
  277. } }
  278. }
  279. }
  280. }
  281. on setComment(v) { commentText = v }
  282. on setEditSubject(v) { editSubject = v }
  283. on setEditSummary(v) { editSummary = v }
  284. on startEdit(e) {
  285. editSubject = subject
  286. editSummary = summary
  287. editMessage = ''
  288. editing = true
  289. showEdit = false
  290. }
  291. on cancelEdit(e) {
  292. editing = false
  293. showEdit = canEdit
  294. editMessage = ''
  295. }
  296. on saveEdit(e) {
  297. r = emit server ticketEdit(ticketId, editSubject, editSummary)
  298. if (r != null && r.error != null) {
  299. editMessage = r.error
  300. return null
  301. }
  302. editing = false
  303. showEdit = canEdit
  304. editMessage = ''
  305. }
  306. on setNewState(v) { newState = v }
  307. on setAssignDraft(v) { assignDraft = v }
  308. on sendAssign(e) {
  309. r = emit server ticketAssign(ticketId, assignDraft)
  310. if (r != null && r.error != null) { assignMessage = r.error } else { assignMessage = '' }
  311. }
  312. on setParentDraft(v) { parentDraft = v }
  313. on setBlockerDraft(v) { blockerDraft = v }
  314. // relations (ticket #4): the answer is only ok / error — the page follows the push
  315. on sendParent(e) {
  316. if (parentDraft.trim() == '') {
  317. linkMessage = 'name the parent as project#number'
  318. return null
  319. }
  320. r = emit server ticketSetParent(ticketId, parentDraft)
  321. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' parentDraft = '' }
  322. }
  323. on removeParent(e) {
  324. r = emit server ticketSetParent(ticketId, '')
  325. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }
  326. }
  327. on sendBlocker(e) {
  328. if (blockerDraft.trim() == '') {
  329. linkMessage = 'name the blocking ticket as project#number'
  330. return null
  331. }
  332. r = emit server ticketAddBlocker(ticketId, blockerDraft)
  333. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' blockerDraft = '' }
  334. }
  335. on removeBlocker(key) {
  336. r = emit server ticketRemoveBlocker(ticketId, key)
  337. if (r != null && r.error != null) { linkMessage = r.error } else { linkMessage = '' }
  338. }
  339. on setStateNote(v) { stateNote = v }
  340. on keyed(e) {
  341. if (e.key == 'Enter' && (e.ctrlKey || e.metaKey)) {
  342. e.preventDefault()
  343. e.target.form.requestSubmit()
  344. }
  345. }
  346. on sendComment(e) {
  347. if (commentText.trim() == '') { return null }
  348. r = emit server commentOn(ticketId, commentText)
  349. if (r != null && r.error != null) { message = r.error } else { message = '' commentText = '' }
  350. }
  351. on sendState(e) {
  352. r = emit server setTicketState(ticketId, newState, stateNote)
  353. if (r != null && r.error != null) { message = r.error } else { message = '' stateNote = '' }
  354. }
  355. // ---- what the server pushes: an event on some ticket -----------------------------------
  356. on client ticketEvent(evTicket, ev, changed, count) {
  357. if ('' + evTicket == '' + ticketId) {
  358. next = []
  359. for (x of events) { next.push(x) }
  360. next.push(ev)
  361. events = next
  362. state = changed.state
  363. stateSlug = changed.stateSlug
  364. updated = changed.updated
  365. assignee = changed.assignee
  366. hasAssignee = changed.hasAssignee
  367. assignDraft = changed.assigneeId
  368. rights = emit server ticketRights(ticketId)
  369. takeRights(rights)
  370. if (ev.isEdit == true) {
  371. subject = changed.subject
  372. summary = changed.summary
  373. hasSummary = changed.summary != ''
  374. summaryMd = ev.summaryMd
  375. }
  376. }
  377. }
  378. // relations changed (a write on this ticket or on a related one): take over this ticket's view
  379. on client ticketsRelated(views) {
  380. for (v of views) {
  381. if ('' + v.id == '' + ticketId) {
  382. hasRelations = v.hasRelations
  383. hasParent = v.hasParent
  384. parentRef = v.parent
  385. hasChildren = v.hasChildren
  386. children = v.children
  387. childNote = v.childNote
  388. childClass = v.childClass
  389. hasBlockedBy = v.hasBlockedBy
  390. blockedBy = v.blockedBy
  391. hasBlocks = v.hasBlocks
  392. blocks = v.blocks
  393. }
  394. }
  395. }
  396. // login / logout in the shell (this session's tabs): the forms come and go without a reload
  397. // what this session's user may do on this ticket now (the face answers from their role)
  398. takeRights = (r) => {
  399. if (r == null || r.error != null) { return null }
  400. canWrite = r.comment == true
  401. mustLogIn = false
  402. notMember = !canWrite
  403. canEdit = r.edit == true
  404. showEdit = canEdit && !editing
  405. showEdit2 = canEdit
  406. options = r.options
  407. hasOptions = r.options.length > 0
  408. memberOptions = r.members
  409. newState = r.options.length > 0 ? r.options[0].value : ''
  410. }
  411. on client signedIn(tag, info) {
  412. r = emit server ticketRights(ticketId)
  413. takeRights(r)
  414. }
  415. on client signedOut(tag) {
  416. canWrite = false
  417. mustLogIn = true
  418. notMember = false
  419. canEdit = false
  420. showEdit = false
  421. showEdit2 = false
  422. hasOptions = false
  423. editing = false
  424. }
  425. // ---- the faces: the writer is the session's user (#31: a forged trailing argument is not
  426. // a real session → refused) ---------------------------------------------------------------
  427. on server commentOn(ticketId, text, session) {
  428. w = writerOfSession(session)
  429. if (w.error != null) { return { error = w.error } }
  430. r = addComment('' + ticketId, w.user, text)
  431. if (r.error != null) { return { error = r.error } }
  432. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  433. return { ok = true }
  434. }
  435. on server setTicketState(ticketId, state, note, session) {
  436. w = writerOfSession(session)
  437. if (w.error != null) { return { error = w.error } }
  438. r = changeState('' + ticketId, state, w.user, note)
  439. if (r.error != null) { return { error = r.error } }
  440. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  441. if (r.assignEvent != null) { emit client ticketEvent(r.ticket.id, pageEventOf(r.assignEvent), r.ticket) }
  442. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  443. return { ok = true }
  444. }
  445. // ticket #20: assign the ticket to a member ('' = nobody); the store wants the role edit or admin
  446. on server ticketAssign(ticketId, userId, session) {
  447. w = writerOfSession(session)
  448. if (w.error != null) { return { error = w.error } }
  449. if (hlTypeName(userId) != 'String') { return { error = 'the assignee must be text' } }
  450. r = assignTicket('' + ticketId, w.user, userId)
  451. if (r.error != null) { return { error = r.error } }
  452. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  453. return { ok = true }
  454. }
  455. // ticket #8: the author edits subject + summary (the store refuses anyone else)
  456. on server ticketEdit(ticketId, subject, summary, session) {
  457. w = writerOfSession(session)
  458. if (w.error != null) { return { error = w.error } }
  459. if (hlTypeName(subject) != 'String' || hlTypeName(summary) != 'String') { return { error = 'subject and summary must be text' } }
  460. r = editTicket('' + ticketId, w.user, subject, summary)
  461. if (r.error != null) { return { error = r.error } }
  462. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  463. emit client ticketsRelated(relatedViews(r.ticket.id, []))
  464. return { ok = true }
  465. }
  466. // ticket #4: relations — the store refuses a user who may not (author of either ticket / creator)
  467. on server ticketSetParent(ticketId, key, session) {
  468. w = writerOfSession(session)
  469. if (w.error != null) { return { error = w.error } }
  470. if (hlTypeName(key) != 'String') { return { error = 'the parent must be text' } }
  471. r = setParent('' + ticketId, w.user, key)
  472. if (r.error != null) { return { error = r.error } }
  473. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  474. emit client ticketsRelated(r.related)
  475. return { ok = true }
  476. }
  477. on server ticketAddBlocker(ticketId, key, session) {
  478. w = writerOfSession(session)
  479. if (w.error != null) { return { error = w.error } }
  480. if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }
  481. r = changeBlocker('' + ticketId, w.user, key, true, 'add')
  482. if (r.error != null) { return { error = r.error } }
  483. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  484. emit client ticketsRelated(r.related)
  485. return { ok = true }
  486. }
  487. on server ticketRemoveBlocker(ticketId, key, session) {
  488. w = writerOfSession(session)
  489. if (w.error != null) { return { error = w.error } }
  490. if (hlTypeName(key) != 'String') { return { error = 'the blocker must be text' } }
  491. r = changeBlocker('' + ticketId, w.user, key, false, 'remove')
  492. if (r.error != null) { return { error = r.error } }
  493. emit client ticketEvent(r.ticket.id, pageEventOf(r.event), r.ticket)
  494. emit client ticketsRelated(r.related)
  495. return { ok = true }
  496. }
  497. // what the session's user may do on this ticket: comment (a member), edit / assign (edit, admin), the states
  498. // they may move it to, and the members it can be assigned to
  499. on server ticketRights(ticketId, session) {
  500. u = userOfSession(session)
  501. if (u == null) { return { error = 'log in first' } }
  502. t = ticketRecord('' + ticketId)
  503. if (t == null) { return { error = 'no such ticket' } }
  504. return ticketRights(t, u)
  505. }

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