gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7f9660ee7f9660eeState of 2026-09-27, before the move to gitoriamre7f9660ee/lib/tickets.hl

9.9 KB

  1. // tickets.hl — the scheduler's ONLY way to state: the tickets.worldapi.org JSON API.
  2. // Reads are public; writes need `Authorization: Bearer <token>` (token file from COLONY_TOKEN_FILE).
  3. // Every request sends a user-agent (Cloudflare refuses default script agents).
  4. import { fetch } from 'hl:fetch'
  5. import { env } from 'hl:proc'
  6. import { readFile, exists } from 'hl:fs'
  7. import { fromCwd, isList, isObject, isString } from './util.hl'
  8. import { now } from 'hl:time'
  9. static setting = (name, fallback) => {
  10. let v = env(name)
  11. if (v == null || v == '') { return fallback }
  12. return v
  13. }
  14. static baseUrl = () => {
  15. let b = setting('COLONY_TICKETS_URL', 'https://tickets.worldapi.org')
  16. while (b.endsWith('/')) { b = b.slice(0, b.length - 1) }
  17. return b
  18. }
  19. static userAgent = () => { return setting('COLONY_USER_AGENT', 'antcolony-scheduler/0') }
  20. static inboxProject = () => { return setting('COLONY_INBOX_PROJECT', 'antcolony') }
  21. // the API token: { token } or { error } — never printed
  22. static readToken = () => {
  23. let f = env('COLONY_TOKEN_FILE')
  24. if (f == null || f == '') { return { error = 'COLONY_TOKEN_FILE is not set (a file holding a tickets API token, tkt_…)' } }
  25. let p = fromCwd(f)
  26. if (!exists(p)) { return { error = 'COLONY_TOKEN_FILE: no such file: ' + p } }
  27. let t = readFile(p).trim()
  28. if (t == '') { return { error = 'COLONY_TOKEN_FILE is empty: ' + p } }
  29. return { token = t }
  30. }
  31. // mission 035 (antcolony#14): the secret the scheduler and its agents share (COLONY_AGENT_TOKEN_FILE): { token } or { error }
  32. static readAgentToken = () => {
  33. let f = env('COLONY_AGENT_TOKEN_FILE')
  34. if (f == null || f == '') { return { error = 'COLONY_AGENT_TOKEN_FILE is not set (a file holding the secret the scheduler and its agents share)' } }
  35. let p = fromCwd(f)
  36. if (!exists(p)) { return { error = 'COLONY_AGENT_TOKEN_FILE: no such file: ' + p } }
  37. let t = readFile(p).trim()
  38. if (t == '') { return { error = 'COLONY_AGENT_TOKEN_FILE is empty: ' + p } }
  39. return { token = t }
  40. }
  41. // GET → the fetch response (status, headers, text(), json())
  42. static getRaw = (path, accept) => {
  43. return fetch(baseUrl() + path, { headers = { 'user-agent' = userAgent() accept = accept } timeoutMs = 20000 })
  44. }
  45. // ---- the states (antcolony#27, tickets#20) -------------------------------------------------------------------------
  46. // tickets#20 renamed the states: awaiting creator → review (a question → pending), confirmed → done, rejected → reopened,
  47. // in progress → progress, on hold → pending. The scheduler's logic keeps ONE vocabulary — the old names — and this file
  48. // is the only place that knows both: everything READ is turned into the old names (a server that still speaks them is
  49. // left as it is), everything WRITTEN is turned into what the server speaks (`serverIsNew`).
  50. static TO_OLD = { 'progress' = 'in progress' 'review' = 'awaiting creator' 'pending' = 'on hold' 'done' = 'confirmed' 'reopened' = 'rejected' }
  51. static TO_NEW = { 'in progress' = 'progress' 'awaiting creator' = 'review' 'on hold' = 'pending' 'confirmed' = 'done' 'rejected' = 'reopened' }
  52. static STATE_KEYS = ['state', 'to', 'from']
  53. static oldStateName = (s) => {
  54. let o = TO_OLD[s]
  55. return o == null ? s : o
  56. }
  57. // the tickets answer, read in the old names → a rebuilt copy (Hybriel copies a value it hands over, so nothing is changed in place)
  58. static normalizeStates = (j) => {
  59. if (isList(j)) {
  60. let out = []
  61. for (x of j) { out.push(normalizeStates(x)) }
  62. return out
  63. }
  64. if (isObject(j)) {
  65. let out = {}
  66. for (k of j.keys()) {
  67. let v = j[k]
  68. if (isString(v)) { out[k] = STATE_KEYS.includes(k) ? oldStateName(v) : v }
  69. else { out[k] = normalizeStates(v) }
  70. }
  71. return out
  72. }
  73. return j
  74. }
  75. // does the tickets server speak the new states? (its /api/projects lists them) — decided once it answered, then kept
  76. static vocab = { known = false modern = false }
  77. static serverIsNew = () => {
  78. if (vocab.known) { return vocab.modern }
  79. let r = getRaw('/api/projects', 'application/json')
  80. if (r == null || r.status != 200) { return false }
  81. let j = r.json()
  82. vocab.modern = j != null && j.states != null && j.states.includes('review')
  83. vocab.known = true
  84. return vocab.modern
  85. }
  86. // the name to WRITE for a state of the scheduler's vocabulary; `question` = a creator question (new server: pending)
  87. static writeState = (name, question) => {
  88. if (!serverIsNew()) { return name }
  89. if (question == true && name == 'awaiting creator') { return 'pending' }
  90. let n = TO_NEW[name]
  91. return n == null ? name : n
  92. }
  93. // ---- the creator = an admin of the project (antcolony#27) ------------------------------------------------------------
  94. // The creator is the project's admin, not a name: the members of the project (GET /api/projects/<p>) with the role admin.
  95. // Events carry the author's display name and — once tickets shows it — the user's id (`userId`); an id is matched when the
  96. // event has one, else the name. A server without members (the old tickets) → null: the caller falls back to
  97. // COLONY_CREATOR_NAMES. Kept 5 minutes per project.
  98. static adminCache = {}
  99. static adminsOf = (project) => {
  100. let c = adminCache[project]
  101. if (c != null && now() - c.at < 300000) { return c.admins }
  102. let r = getJson('/api/projects/' + project)
  103. let admins = null
  104. if (r.status == 200 && r.json != null && r.json.members != null) {
  105. admins = { ids = [] names = [] }
  106. for (m of r.json.members) {
  107. if (m.role == 'admin') {
  108. if (m.user != null) { admins.ids.push(m.user) }
  109. if (m.name != null && m.name != '') { admins.names.push(m.name) }
  110. }
  111. }
  112. }
  113. if (r.status == 200 || r.status == 404) { adminCache[project] = { at = now() admins = admins } }
  114. return admins
  115. }
  116. // was this event written by the creator of `project`? `fallback` = the configured names (old tickets, no members)
  117. static isCreatorEvent = (project, e, fallback) => {
  118. let a = adminsOf(project)
  119. if (a == null) { return fallback.includes(e.author) }
  120. let uid = e.userId != null ? e.userId : e.user
  121. if (isString(uid) && uid != '' && a.ids.length > 0) { return a.ids.includes(uid) }
  122. return a.names.includes(e.author)
  123. }
  124. // GET JSON → { status, json } (json null unless 200); status 0 = no answer (connection refused, timeout — the
  125. // global `on Error` in scheduler.hl absorbs the fetch error, fetch then yields null; mission 020: `run` survives it)
  126. static getJson = (path) => {
  127. let r = getRaw(path, 'application/json')
  128. if (r == null) { return { status = 0 json = null text = 'no answer from ' + baseUrl() } }
  129. if (r.status != 200) { return { status = r.status json = null text = r.text() } }
  130. return { status = r.status json = normalizeStates(r.json()) }
  131. }
  132. // POST JSON with the token → { status, json, text }; a `state` in the body is written in the server's names, the answer
  133. // is read in the old ones
  134. static postJson = (path, body, token) => {
  135. if (body != null && isString(body.state)) { body.state = writeState(body.state, false) }
  136. let r = fetch(baseUrl() + path, { method = 'POST' headers = { 'user-agent' = userAgent() authorization = 'Bearer ' + token } json = body timeoutMs = 20000 })
  137. if (r == null) { return { status = 0 json = null text = 'no answer from ' + baseUrl() } }
  138. let t = r.text()
  139. let j = null
  140. if (t.startsWith('{')) { j = r.json() }
  141. return { status = r.status json = normalizeStates(j) text = t }
  142. }
  143. static ticketPath = (project, number) => { return '/api/projects/' + project + '/tickets/' + number }
  144. // the public page of a ticket row
  145. static pageUrl = (row) => { return baseUrl() + row.href }
  146. // every ticket (rows, no events)
  147. static allTickets = () => {
  148. let r = getJson('/api/tickets')
  149. if (r.status != 200) { return { error = 'GET /api/tickets → ' + r.status } }
  150. return { tickets = r.json.tickets }
  151. }
  152. // a ticket in the creator's inbox (project COLONY_INBOX_PROJECT, state `awaiting creator`),
  153. // idempotent by `source`: a known source answers the existing ticket and nothing is written.
  154. // parentKey = `<project>#<n>` of the ticket the question belongs to (mission 022: "linked (parent) to the
  155. // ticket it belongs to"), or null. → { ticket, created } or { error }
  156. static fileQuestion = (token, source, subject, summary, parentKey) => {
  157. return fileQuestionIn(token, inboxProject(), source, subject, summary, parentKey)
  158. }
  159. // the same in any project (mission 022: a report's `questions` go to the ticket's own project).
  160. // Idempotent and repairable: a known source creates nothing; the state is set only on a new ticket or on one
  161. // that never had a state event (a run that stopped right after creating it) — a question the creator already
  162. // moved is never touched; the parent is set only while the question has none (tickets without relations,
  163. // i.e. no `children` key in the row, are left alone).
  164. static fileQuestionIn = (token, project, source, subject, summary, parentKey) => {
  165. let r = postJson('/api/projects/' + project + '/tickets', { subject = subject summary = summary source = source }, token)
  166. if (r.status != 200 && r.status != 201) { return { error = 'filing the question failed: ' + r.status + ' ' + r.text } }
  167. let t = r.json.ticket
  168. let created = r.status == 201
  169. let needState = created
  170. if (!created && t.state == 'open') {
  171. let d = getJson(ticketPath(t.project, t.number))
  172. if (d.status == 200) {
  173. needState = true
  174. for (e of d.json.events) { if (e.kind == 'state') { needState = false } }
  175. }
  176. }
  177. if (needState) {
  178. let s = postJson(ticketPath(t.project, t.number) + '/state', { state = writeState('awaiting creator', true) text = 'Waiting for your answer.' }, token)
  179. if (s.status != 201) { return { error = 'question ' + t.project + t.ref + ' filed, but setting it to awaiting creator failed: ' + s.status + ' ' + s.text } }
  180. t = s.json.ticket
  181. }
  182. if (parentKey != null && t.keys().includes('children') && t.parent == null) {
  183. let p = postJson(ticketPath(t.project, t.number) + '/parent', { parent = parentKey }, token)
  184. if (p.status != 201) { return { error = 'question ' + t.project + t.ref + ' filed, but linking it to its parent ' + parentKey + ' failed: ' + p.status + ' ' + p.text } }
  185. t = p.json.ticket
  186. }
  187. return { ticket = t created = created }
  188. }

Branches

Latest commits

  • 7f9660eeState of 2026-09-27, before the move to gitoriamre