antcolony
All repositories: gitoria
9.9 KB
// tickets.hl — the scheduler's ONLY way to state: the tickets.worldapi.org JSON API.// Reads are public; writes need `Authorization: Bearer <token>` (token file from COLONY_TOKEN_FILE).// Every request sends a user-agent (Cloudflare refuses default script agents).import { fetch } from 'hl:fetch'import { env } from 'hl:proc'import { readFile, exists } from 'hl:fs'import { fromCwd, isList, isObject, isString } from './util.hl'import { now } from 'hl:time'static setting = (name, fallback) => {let v = env(name)if (v == null || v == '') { return fallback }return v}static baseUrl = () => {let b = setting('COLONY_TICKETS_URL', 'https://tickets.worldapi.org')while (b.endsWith('/')) { b = b.slice(0, b.length - 1) }return b}static userAgent = () => { return setting('COLONY_USER_AGENT', 'antcolony-scheduler/0') }static inboxProject = () => { return setting('COLONY_INBOX_PROJECT', 'antcolony') }// the API token: { token } or { error } — never printedstatic readToken = () => {let f = env('COLONY_TOKEN_FILE')if (f == null || f == '') { return { error = 'COLONY_TOKEN_FILE is not set (a file holding a tickets API token, tkt_…)' } }let p = fromCwd(f)if (!exists(p)) { return { error = 'COLONY_TOKEN_FILE: no such file: ' + p } }let t = readFile(p).trim()if (t == '') { return { error = 'COLONY_TOKEN_FILE is empty: ' + p } }return { token = t }}// mission 035 (antcolony#14): the secret the scheduler and its agents share (COLONY_AGENT_TOKEN_FILE): { token } or { error }static readAgentToken = () => {let f = env('COLONY_AGENT_TOKEN_FILE')if (f == null || f == '') { return { error = 'COLONY_AGENT_TOKEN_FILE is not set (a file holding the secret the scheduler and its agents share)' } }let p = fromCwd(f)if (!exists(p)) { return { error = 'COLONY_AGENT_TOKEN_FILE: no such file: ' + p } }let t = readFile(p).trim()if (t == '') { return { error = 'COLONY_AGENT_TOKEN_FILE is empty: ' + p } }return { token = t }}// GET → the fetch response (status, headers, text(), json())static getRaw = (path, accept) => {return fetch(baseUrl() + path, { headers = { 'user-agent' = userAgent() accept = accept } timeoutMs = 20000 })}// ---- the states (antcolony#27, tickets#20) -------------------------------------------------------------------------// tickets#20 renamed the states: awaiting creator → review (a question → pending), confirmed → done, rejected → reopened,// in progress → progress, on hold → pending. The scheduler's logic keeps ONE vocabulary — the old names — and this file// is the only place that knows both: everything READ is turned into the old names (a server that still speaks them is// left as it is), everything WRITTEN is turned into what the server speaks (`serverIsNew`).static TO_OLD = { 'progress' = 'in progress' 'review' = 'awaiting creator' 'pending' = 'on hold' 'done' = 'confirmed' 'reopened' = 'rejected' }static TO_NEW = { 'in progress' = 'progress' 'awaiting creator' = 'review' 'on hold' = 'pending' 'confirmed' = 'done' 'rejected' = 'reopened' }static STATE_KEYS = ['state', 'to', 'from']static oldStateName = (s) => {let o = TO_OLD[s]return o == null ? s : o}// the tickets answer, read in the old names → a rebuilt copy (Hybriel copies a value it hands over, so nothing is changed in place)static normalizeStates = (j) => {if (isList(j)) {let out = []for (x of j) { out.push(normalizeStates(x)) }return out}if (isObject(j)) {let out = {}for (k of j.keys()) {let v = j[k]if (isString(v)) { out[k] = STATE_KEYS.includes(k) ? oldStateName(v) : v }else { out[k] = normalizeStates(v) }}return out}return j}// does the tickets server speak the new states? (its /api/projects lists them) — decided once it answered, then keptstatic vocab = { known = false modern = false }static serverIsNew = () => {if (vocab.known) { return vocab.modern }let r = getRaw('/api/projects', 'application/json')if (r == null || r.status != 200) { return false }let j = r.json()vocab.modern = j != null && j.states != null && j.states.includes('review')vocab.known = truereturn vocab.modern}// the name to WRITE for a state of the scheduler's vocabulary; `question` = a creator question (new server: pending)static writeState = (name, question) => {if (!serverIsNew()) { return name }if (question == true && name == 'awaiting creator') { return 'pending' }let n = TO_NEW[name]return n == null ? name : n}// ---- the creator = an admin of the project (antcolony#27) ------------------------------------------------------------// The creator is the project's admin, not a name: the members of the project (GET /api/projects/<p>) with the role admin.// Events carry the author's display name and — once tickets shows it — the user's id (`userId`); an id is matched when the// event has one, else the name. A server without members (the old tickets) → null: the caller falls back to// COLONY_CREATOR_NAMES. Kept 5 minutes per project.static adminCache = {}static adminsOf = (project) => {let c = adminCache[project]if (c != null && now() - c.at < 300000) { return c.admins }let r = getJson('/api/projects/' + project)let admins = nullif (r.status == 200 && r.json != null && r.json.members != null) {admins = { ids = [] names = [] }for (m of r.json.members) {if (m.role == 'admin') {if (m.user != null) { admins.ids.push(m.user) }if (m.name != null && m.name != '') { admins.names.push(m.name) }}}}if (r.status == 200 || r.status == 404) { adminCache[project] = { at = now() admins = admins } }return admins}// was this event written by the creator of `project`? `fallback` = the configured names (old tickets, no members)static isCreatorEvent = (project, e, fallback) => {let a = adminsOf(project)if (a == null) { return fallback.includes(e.author) }let uid = e.userId != null ? e.userId : e.userif (isString(uid) && uid != '' && a.ids.length > 0) { return a.ids.includes(uid) }return a.names.includes(e.author)}// GET JSON → { status, json } (json null unless 200); status 0 = no answer (connection refused, timeout — the// global `on Error` in scheduler.hl absorbs the fetch error, fetch then yields null; mission 020: `run` survives it)static getJson = (path) => {let r = getRaw(path, 'application/json')if (r == null) { return { status = 0 json = null text = 'no answer from ' + baseUrl() } }if (r.status != 200) { return { status = r.status json = null text = r.text() } }return { status = r.status json = normalizeStates(r.json()) }}// POST JSON with the token → { status, json, text }; a `state` in the body is written in the server's names, the answer// is read in the old onesstatic postJson = (path, body, token) => {if (body != null && isString(body.state)) { body.state = writeState(body.state, false) }let r = fetch(baseUrl() + path, { method = 'POST' headers = { 'user-agent' = userAgent() authorization = 'Bearer ' + token } json = body timeoutMs = 20000 })if (r == null) { return { status = 0 json = null text = 'no answer from ' + baseUrl() } }let t = r.text()let j = nullif (t.startsWith('{')) { j = r.json() }return { status = r.status json = normalizeStates(j) text = t }}static ticketPath = (project, number) => { return '/api/projects/' + project + '/tickets/' + number }// the public page of a ticket rowstatic pageUrl = (row) => { return baseUrl() + row.href }// every ticket (rows, no events)static allTickets = () => {let r = getJson('/api/tickets')if (r.status != 200) { return { error = 'GET /api/tickets → ' + r.status } }return { tickets = r.json.tickets }}// a ticket in the creator's inbox (project COLONY_INBOX_PROJECT, state `awaiting creator`),// idempotent by `source`: a known source answers the existing ticket and nothing is written.// parentKey = `<project>#<n>` of the ticket the question belongs to (mission 022: "linked (parent) to the// ticket it belongs to"), or null. → { ticket, created } or { error }static fileQuestion = (token, source, subject, summary, parentKey) => {return fileQuestionIn(token, inboxProject(), source, subject, summary, parentKey)}// the same in any project (mission 022: a report's `questions` go to the ticket's own project).// Idempotent and repairable: a known source creates nothing; the state is set only on a new ticket or on one// that never had a state event (a run that stopped right after creating it) — a question the creator already// moved is never touched; the parent is set only while the question has none (tickets without relations,// i.e. no `children` key in the row, are left alone).static fileQuestionIn = (token, project, source, subject, summary, parentKey) => {let r = postJson('/api/projects/' + project + '/tickets', { subject = subject summary = summary source = source }, token)if (r.status != 200 && r.status != 201) { return { error = 'filing the question failed: ' + r.status + ' ' + r.text } }let t = r.json.ticketlet created = r.status == 201let needState = createdif (!created && t.state == 'open') {let d = getJson(ticketPath(t.project, t.number))if (d.status == 200) {needState = truefor (e of d.json.events) { if (e.kind == 'state') { needState = false } }}}if (needState) {let s = postJson(ticketPath(t.project, t.number) + '/state', { state = writeState('awaiting creator', true) text = 'Waiting for your answer.' }, token)if (s.status != 201) { return { error = 'question ' + t.project + t.ref + ' filed, but setting it to awaiting creator failed: ' + s.status + ' ' + s.text } }t = s.json.ticket}if (parentKey != null && t.keys().includes('children') && t.parent == null) {let p = postJson(ticketPath(t.project, t.number) + '/parent', { parent = parentKey }, token)if (p.status != 201) { return { error = 'question ' + t.project + t.ref + ' filed, but linking it to its parent ' + parentKey + ' failed: ' + p.status + ' ' + p.text } }t = p.json.ticket}return { ticket = t created = created }}
Branches
- mainmain branch