gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita6af7883a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mrea6af7883/lib/keywords.hl

8.8 KB

  1. // keywords.hl — the creator's short commands in a comment (ticket antcolony#17; concept docs/scheduler-agent.md §2
  2. // "Comment keywords (code, no LLM)"): /confirm, /reject <why>, /prio <high|normal|low|-9..9>, /hold.
  3. //
  4. // A keyword counts only when it is the FIRST word of a comment written by the creator (`creatorNames()`, the same
  5. // names the librarian uses). Nothing is stored here: what was handled is in the ticket's history.
  6. // * /hold → state `on hold` ("On hold, as you asked.")
  7. // * /confirm → state `confirmed`, /reject <why> → state `rejected` (the reason is the state text, so a rework
  8. // brief quotes it). The colony's own token may NOT confirm/reject (tickets: creator only) — these
  9. // two need the creator's token in COLONY_CREATOR_TOKEN_FILE; without it the colony answers once
  10. // in a comment ("use the button") instead of pretending.
  11. // * /prio → the last valid /prio decides (read by `next` / `run`, see prioOf); the colony answers once.
  12. // A state keyword is stale — ignored — when a state change came after it (not the tickets app's automatic `answered` after
  13. // the creator's comment), or the ticket is already in that state.
  14. // Every answer comment carries `colony-keyword: <event id> · <keyword>` (idempotence: a keyword with that marker
  15. // in the history is done); state keywords rely on the state event that follows them.
  16. import { env } from 'hl:proc'
  17. import { readFile, exists } from 'hl:fs'
  18. import { getJson, postJson, ticketPath, allTickets, readToken, pageUrl, isCreatorEvent } from './tickets.hl'
  19. import { creatorNames } from './librarian.hl'
  20. import { NL, fromCwd } from './util.hl'
  21. static MARK = 'colony-keyword: '
  22. static WORDS = ['/confirm' '/reject' '/prio' '/hold']
  23. // the creator's token for /confirm and /reject (optional): { token } or { error }
  24. static readCreatorToken = () => {
  25. let f = env('COLONY_CREATOR_TOKEN_FILE')
  26. if (f == null || f == '') { return { error = 'COLONY_CREATOR_TOKEN_FILE is not set' } }
  27. let p = fromCwd(f)
  28. if (!exists(p)) { return { error = 'COLONY_CREATOR_TOKEN_FILE: no such file: ' + p } }
  29. let t = readFile(p).trim()
  30. if (t == '') { return { error = 'COLONY_CREATOR_TOKEN_FILE is empty: ' + p } }
  31. return { token = t }
  32. }
  33. // priority word / number → a whole number in -9..9 or null. higher = sooner; normal = 0
  34. static prioValue = (arg) => {
  35. let a = arg.trim().toLowerCase()
  36. if (a == 'high') { return 1 }
  37. if (a == 'normal') { return 0 }
  38. if (a == 'low') { return -1 }
  39. let neg = a.startsWith('-')
  40. let d = neg ? a.slice(1) : a
  41. if (d.length != 1 || '0123456789'.indexOf(d) < 0) { return null }
  42. let n = '0123456789'.indexOf(d)
  43. return neg ? 0 - n : n
  44. }
  45. // a comment text → { kind: confirm|reject|prio|hold, arg } or null. The first non-empty line must start with the word
  46. // (and end or continue with a space); for /reject the reason is the rest of the line plus the following lines.
  47. static parseKeyword = (text) => {
  48. if (text == null) { return null }
  49. let t = text.trim()
  50. if (!t.startsWith('/')) { return null }
  51. for (w of WORDS) {
  52. if (t.startsWith(w)) {
  53. let rest = t.slice(w.length)
  54. if (rest == '' || rest.startsWith(' ') || rest.startsWith(NL) || rest.startsWith('\t')) {
  55. let arg = rest.trim()
  56. if (w != '/reject') {
  57. let nl = arg.indexOf(NL)
  58. if (nl >= 0) { arg = arg.slice(0, nl).trim() }
  59. }
  60. return { kind = w.slice(1) arg = arg }
  61. }
  62. }
  63. }
  64. return null
  65. }
  66. // the priority of a ticket from its history: the last valid `/prio` by the creator, else 0
  67. static prioOf = (events, project) => {
  68. let names = creatorNames()
  69. let p = 0
  70. for (e of events) {
  71. if (e.kind == 'comment' && isCreatorEvent(project, e, names)) {
  72. let k = parseKeyword(e.text)
  73. if (k != null && k.kind == 'prio') {
  74. let v = prioValue(k.arg)
  75. if (v != null) { p = v }
  76. }
  77. }
  78. }
  79. return p
  80. }
  81. static prioWord = (p) => {
  82. if (p == 1) { return 'high' }
  83. if (p == 0) { return 'normal' }
  84. if (p == -1) { return 'low' }
  85. return '' + p
  86. }
  87. static handledIn = (events, id) => {
  88. let m = MARK + id
  89. for (e of events) { if (e.text != null && e.text.indexOf(m) >= 0) { return true } }
  90. return false
  91. }
  92. // → the keywords of one ticket still to act on: [{ event, kind, arg, stale }] (oldest first)
  93. static pendingOf = (events, names, project) => {
  94. let out = []
  95. let i = 0
  96. while (i < events.length) {
  97. let e = events[i]
  98. if (e.kind == 'comment' && isCreatorEvent(project, e, names)) {
  99. let k = parseKeyword(e.text)
  100. if (k != null && !handledIn(events, e.id)) {
  101. let later = false
  102. let j = i + 1
  103. while (j < events.length) {
  104. // a state change after the keyword makes it stale — except the tickets app's own move of the creator's
  105. // answered question to `answered` (the creator's comment triggers it, it is not a decision)
  106. let ev = events[j]
  107. if (ev.kind == 'state' && !(isCreatorEvent(project, ev, names) && ev.to == 'answered')) { later = true }
  108. j = j + 1
  109. }
  110. out.push({ event = e kind = k.kind arg = k.arg stale = later })
  111. }
  112. }
  113. i = i + 1
  114. }
  115. return out
  116. }
  117. static reply = (project, number, text, e, kind, token) => {
  118. let r = postJson(ticketPath(project, number) + '/comments', { text = text + NL + NL + MARK + e.id + ' · ' + kind }, token)
  119. return r.status == 201 ? 'answered' : 'ANSWER FAILED ' + r.status + ' ' + r.text
  120. }
  121. // act on one keyword → a line of text
  122. static act = (t, p, post, tk, ck) => {
  123. let e = p.event
  124. let path = ticketPath(t.project, t.number)
  125. if (p.kind == 'prio') {
  126. let v = prioValue(p.arg)
  127. if (v == null) { return post ? reply(t.project, t.number, 'I did not understand the priority "' + p.arg + '". Write /prio high, /prio normal, /prio low or a number from -9 to 9 (higher = sooner).', e, 'prio-refused', tk.token) : 'would refuse: not a priority' }
  128. if (!post) { return 'would set priority ' + prioWord(v) }
  129. return 'priority ' + prioWord(v) + ' — ' + reply(t.project, t.number, 'Priority is now ' + prioWord(v) + ' (higher goes first).', e, 'prio', tk.token)
  130. }
  131. if (p.stale) { return 'ignored (the state changed after it)' }
  132. if (p.kind == 'hold') {
  133. if (t.state == 'on hold') { return 'ignored (already on hold)' }
  134. if (!post) { return 'would put the ticket on hold' }
  135. let s = postJson(path + '/state', { state = 'on hold' text = 'On hold, as you asked.' }, tk.token)
  136. return s.status == 201 ? 'on hold' : 'FAILED ' + s.status + ' ' + s.text
  137. }
  138. // confirm / reject: creator only
  139. let target = p.kind == 'confirm' ? 'confirmed' : 'rejected'
  140. if (t.state == target) { return 'ignored (already ' + target + ')' }
  141. if (p.kind == 'reject' && p.arg == '') {
  142. return post ? reply(t.project, t.number, 'A rejection needs a reason. Write /reject and then what is wrong.', e, 'reject-refused', tk.token) : 'would refuse: /reject without a reason'
  143. }
  144. if (ck.error != null) {
  145. return post ? reply(t.project, t.number, 'I cannot ' + p.kind + ' for you: only you may, and the colony has no permission to do it in your name. Please use the button on this page.', e, p.kind + '-refused', tk.token) : 'would refuse: no creator token (' + ck.error + ')'
  146. }
  147. if (!post) { return 'would set ' + target }
  148. let s = postJson(path + '/state', { state = target text = p.kind == 'confirm' ? 'Confirmed.' : p.arg }, ck.token)
  149. return s.status == 201 ? target : 'FAILED ' + s.status + ' ' + s.text
  150. }
  151. // one pass over every ticket. opts = { post, seen } — `seen` (an object, optional) remembers each ticket's updatedMs so an
  152. // unchanged ticket is not read again (the daemon keeps one). → { acted, error }
  153. static keywordPass = (post, seen) => {
  154. let all = allTickets()
  155. if (all.error != null) { return { error = all.error acted = 0 } }
  156. let names = creatorNames()
  157. let tk = post ? readToken() : { token = '' }
  158. if (tk.error != null) { return { error = tk.error acted = 0 } }
  159. let ck = readCreatorToken()
  160. let acted = 0
  161. for (t of all.tickets) {
  162. let upd = t.updatedMs == null ? 0 : t.updatedMs
  163. if (seen == null || seen[t.id] != upd) {
  164. let d = getJson(t.apiHref)
  165. if (d.status == 200) {
  166. let pend = pendingOf(d.json.events, names, t.project)
  167. let done = true
  168. for (p of pend) {
  169. let line = act(t, p, post, tk, ck)
  170. console.log('KEYWORD ' + t.project + t.ref + ' /' + p.kind + (p.arg == '' ? '' : ' ' + p.arg.slice(0, 40)) + ' — ' + line + ' ' + pageUrl(t))
  171. if (line.indexOf('ignored') != 0) { acted = acted + 1 }
  172. if (line.indexOf('FAILED') >= 0) { done = false }
  173. if (!post) { done = false }
  174. }
  175. // remember the ticket unless an action failed (a written answer changes updatedMs → read once more, finds it handled)
  176. if (seen != null && done) { seen[t.id] = upd }
  177. }
  178. }
  179. }
  180. return { acted = acted error = null }
  181. }
  182. static runKeywords = (argv) => {
  183. let post = argv.includes('--post')
  184. let r = keywordPass(post, null)
  185. if (r.error != null) {
  186. console.log('keywords: ' + r.error)
  187. return false
  188. }
  189. console.log('keywords: ' + r.acted + ' keyword(s) ' + (post ? 'handled' : 'pending (dry run; --post acts)'))
  190. return true
  191. }

Branches

Latest commits

  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre