antcolony
All repositories: gitoria
31.0 KB
// report.hl — `report <file> [--post]`: validate a worker's JSON report (docs/scheduler-agent.md §4)// and, with --post, file it.//// VALIDATION (prints every problem, one line each, with its JSON path):// the file is one JSON object; exactly the keys ticket, session, result, complete, test, done, verified, open, issues,// questions, running, refused — all present (lists may be empty), no others;// (mission 026) complete = true | false: true only if the ticket's goal is reached AND what the creator should test is// really in place (e.g. copied into the app, not only a work copy);// (mission 023, README "Ticket texts are written for the creator") result = ONE plain sentence, one line,// ≤ 200 characters; test = 1–4 steps, each one line ≤ 200 characters — they ARE the ticket comment;// ticket = "<project>#<n>"; session a non-empty string; done/open/questions/refused lists of// non-empty strings; verified[] = { claim, command, output } all non-empty strings (no output →// refused); issues[] = { project, subject, repro, observed, expected: non-empty strings, blocks:// boolean }; running[] = { what: non-empty, url, log: strings, pid: number }; no unknown keys// anywhere. An issues[].project that is not in the registry is NOT a refusal: it is reported// and becomes a creator question on --post (concept: "else → creator's inbox").//// --post (idempotent per report; the key is the session, the report's sha256 proves "the same"):// 1. the ticket must exist; if it already has a comment carrying `colony-report: <session> ·`// with the SAME sha → the comment and the state are not touched again; a DIFFERENT sha →// refused (one session, one report);// 2. every issue → a ticket in its project (source `colony-report:<session>:issue:<i>`, so a// re-post answers the existing ticket and writes nothing), summary links back;// an unknown project → a creator question (project antcolony, `awaiting creator`, source// `colony-report:<session>:question:<i>`);// 3. ONE short comment on the ticket (mission 023): result · **Test:** steps · ≤ 3 detail bullets (questions,// filed problems, not done, running, refused — what matters most first) · ONE machine line// `colony-report: <session> · sha256 <12 hex>[ · controller pass]`; the full report stays in runs/<session>/;// 2b. (mission 022) an issue with `blocks: true` → the ticket gets it as a blocker (`blocked-by`, once);// every `questions` entry → its own ticket in the TICKET's project, `awaiting creator`, parent = the// ticket (source `colony-report:<session>:questions:<i>`); the comment links them;// 4. the ticket → `awaiting creator` (never `confirmed`), only when the comment was new in this// run and the ticket is not already there / closed.// (mission 027) result ≤ 120 characters; test = 2–4 steps of ≤ 90 characters, no work copy (`.scratch`), on a project with// `live` no local address; questions ≤ 3, each ≤ 3 lines / 300 characters; `decided` (optional key, one line ≤ 120 each)// = small choices the worker made itself → ONE detail bullet "Decided: …". The Test steps are a numbered list.// DEPLOY GATE: a complete report on a project with `live` → "Built — goes live with the next deploy." + marker// `· ready to deploy`, the ticket stays `in progress` (deploy.hl: `./colony ready`, `./colony deployed`).// (mission 026) `complete: false` = HALF DONE: the comment is "Half done: <result>; the next worker continues." + what is// still open (no Test steps; machine line ends `· half done`), and the ticket goes back to `open` (only from// `in progress`, i.e. the lease) — never `awaiting creator`. Questions / issues are filed as usual.import { sha256 } from 'hl:crypto'import { loadRegistry } from './registry.hl'import { getJson, postJson, readToken, ticketPath, pageUrl, fileQuestion, fileQuestionIn } from './tickets.hl'import { NL, readJson, fromCwd, isList, isObject, isString, nonEmpty, positionals } from './util.hl'static TOP = ['ticket' 'session' 'result' 'complete' 'test' 'done' 'verified' 'open' 'issues' 'questions' 'decided' 'running' 'refused']// mission 027: `decided` may be missing (reports written before it) — it counts as []static OPTIONAL = ['decided']// mission 027 (first live batch: run-on test lines, work-copy paths, too many / too long questions)static RESULT_MAX = 120static STEP_MAX = 90static TEST_MIN = 2static TEST_MAX = 4static DECIDED_MAX = 120static QUESTION_LINES = 3static QUESTION_CHARS = 300static QUESTION_TITLE_MAX = 80static STRING_LISTS = ['done' 'open' 'questions' 'decided' 'refused']static VERIFIED = ['claim' 'command' 'output']static ISSUE = ['project' 'subject' 'repro' 'observed' 'expected' 'blocks']static ISSUE_TEXT = ['project' 'subject' 'repro' 'observed' 'expected']static RUNNING = ['what' 'url' 'pid' 'log']static typeOf = (v) => {if (v == null) { return 'null' }if (isList(v)) { return 'list' }if (isObject(v)) { return 'object' }return hlTypeName(v).toLowerCase()}// "<project>#<n>" → { project, number } or nullstatic parseRef = (s) => {let h = s.indexOf('#')if (h <= 0 || h == s.length - 1) { return null }let p = s.slice(0, h)let n = s.slice(h + 1)let i = 0while (i < n.length) {let c = n.charCodeAt(i)if (c < 48 || c > 57) { return null }i = i + 1}if (n.startsWith('0')) { return null }return { project = p number = n }}// unknown keys of an object entry → problemsstatic unknownKeys = (obj, allowed, path, problems) => {for (k of obj.keys()) {if (!allowed.includes(k)) { problems.push(path + ": unknown field '" + k + "' (allowed: " + allowed.join(', ') + ')') }}}// a one-line text for the creator (at most `max` characters) → the problem or nullstatic lineProblem = (v, max) => {if (!isString(v)) { return 'must be a string, not a ' + typeOf(v) }if (v.trim() == '') { return 'must not be empty' }if (v.includes(NL)) { return 'must be ONE line (no line breaks)' }if (v.length > max) { return 'is ' + v.length + ' characters long, at most ' + max }return null}// mission 027: the registry entry of the report's project (null when unknown / no ticket)static projectOf = (report, reg) => {if (!isString(report.ticket)) { return null }let pr = parseRef(report.ticket)if (pr == null) { return null }return reg.projects[pr.project]}// mission 027: a test step is for the creator on the REAL thing — never a work copy; on a project with a live site// never a local address → the problem or nullstatic stepPlaceProblem = (v, m) => {let low = v.toLowerCase()if (low.includes('.scratch')) { return 'names a work copy (.scratch) — the creator tests the real thing' + (m != null && m.live != null && m.live.url != null ? ' on ' + m.live.url : '') }if (m != null && m.live != null && (low.includes('localhost') || low.includes('127.0.0.1') || low.includes('0.0.0.0'))) { return 'names a local address — write the step for the live site ' + (m.live.url != null ? m.live.url : '') + ' (the creator tests after the deploy)' }return null}// → { problems: [text], notes: [text], report }static validate = (report, reg) => {let problems = []let notes = []if (!isObject(report)) {problems.push('the report must be one JSON object, not a ' + typeOf(report))return { problems = problems notes = notes }}unknownKeys(report, TOP, '(top)', problems)for (k of TOP) {if (report.keys().includes(k) == false) { if (!OPTIONAL.includes(k)) { problems.push("(top): field '" + k + "' is missing (every field is required; use [] for an empty list)") } }else if (report[k] == null) { problems.push(k + ': must not be null' + (k == 'ticket' || k == 'session' ? '' : ' (use [] for an empty list)')) }}// ticket, sessionif (report.ticket != null) {if (!isString(report.ticket)) { problems.push('ticket: must be a string "<project>#<number>", not a ' + typeOf(report.ticket)) }else if (parseRef(report.ticket) == null) { problems.push('ticket: "' + report.ticket + '" is not "<project>#<number>" (e.g. "tickets.worldapi.org#9")') }}if (report.session != null && !nonEmpty(report.session)) { problems.push('session: must be a non-empty string') }// mission 026: finished or notif (report.complete != null && hlTypeName(report.complete) != 'Boolean') { problems.push('complete: must be true or false, not a ' + typeOf(report.complete) + ' (true only if the goal is reached AND what the creator should test is really in place)') }// mission 023: result + test are what the creator reads — short, plain, one line each// mission 027: result ≤ 120 characters; test = 2–4 steps of ≤ 90 characters each, for the creator on the live siteif (report.result != null) {let lp = lineProblem(report.result, RESULT_MAX)if (lp != null) { problems.push('result: ' + lp + ' (ONE plain sentence: what is done)') }}let pm = projectOf(report, reg)let tst = report.testif (tst != null) {if (!isList(tst)) { problems.push('test: must be a list of ' + TEST_MIN + '–' + TEST_MAX + ' short steps, not a ' + typeOf(tst)) }else if (tst.length < TEST_MIN || tst.length > TEST_MAX) { problems.push('test: must have ' + TEST_MIN + '–' + TEST_MAX + ' steps, has ' + tst.length) }else {let ti = 0while (ti < tst.length) {let tp = lineProblem(tst[ti], STEP_MAX)if (tp == null) { tp = stepPlaceProblem(tst[ti], pm) }if (tp != null) { problems.push('test[' + ti + ']: ' + tp) }ti = ti + 1}}}// mission 027: short questions (antcolony#24: no count limit — the check after the job rules on each); small choices go to `decided` (one line each)let qv = report.questionsif (qv != null && isList(qv)) {let qi = 0while (qi < qv.length) {// antcolony#23: an entry is { title, text } (title = the short subject the worker writes); a plain string (old reports) still counts as the textlet qe = qv[qi]let qx = qeif (isObject(qe)) {unknownKeys(qe, ['title' 'text'], 'questions[' + qi + ']', problems)qx = qe.textif (!nonEmpty(qe.title)) { problems.push('questions[' + qi + ']: title must be a non-empty string (a short subject, e.g. "how long should a login last?")') }else {let tp = lineProblem(qe.title, QUESTION_TITLE_MAX)if (tp != null) { problems.push('questions[' + qi + '].title: ' + tp) }}}if (nonEmpty(qx)) {let nl = 0for (l of qx.trim().split(NL)) { if (l.trim() != '') { nl = nl + 1 } }if (nl > QUESTION_LINES) { problems.push('questions[' + qi + ']: has ' + nl + ' lines, at most ' + QUESTION_LINES + ' (the question, then the options)') }if (qx.length > QUESTION_CHARS) { problems.push('questions[' + qi + ']: is ' + qx.length + ' characters long, at most ' + QUESTION_CHARS) }} else if (isObject(qe)) { problems.push('questions[' + qi + ']: text must be a non-empty string') }qi = qi + 1}}let dv = report.decidedif (dv != null && isList(dv)) {let di = 0while (di < dv.length) {if (isString(dv[di]) && dv[di].trim() != '') {let dp = lineProblem(dv[di], DECIDED_MAX)if (dp != null) { problems.push('decided[' + di + ']: ' + dp) }}di = di + 1}}// lists of stringsfor (k of STRING_LISTS) {let v = report[k]if (v != null) {if (!isList(v)) { problems.push(k + ': must be a list of strings, not a ' + typeOf(v)) } else {let i = 0while (i < v.length) {if (k == 'questions' && isObject(v[i])) { } else if (!nonEmpty(v[i])) { problems.push(k + '[' + i + ']: must be a non-empty string, not ' + (isString(v[i]) ? 'an empty one' : 'a ' + typeOf(v[i]))) }i = i + 1}}}}// verifiedlet v = report.verifiedif (v != null) {if (!isList(v)) { problems.push('verified: must be a list of { claim, command, output }, not a ' + typeOf(v)) } else {let i = 0while (i < v.length) {let e = v[i]let at = 'verified[' + i + ']'if (!isObject(e)) { problems.push(at + ': must be an object { claim, command, output }, not a ' + typeOf(e)) } else {unknownKeys(e, VERIFIED, at, problems)for (f of VERIFIED) {if (e[f] == null) { problems.push(at + ": '" + f + "' is missing" + (f == 'claim' ? '' : ' (every verified entry needs command AND output)')) }else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }else if (e[f].trim() == '') { problems.push(at + ": '" + f + "' is empty" + (f == 'claim' ? '' : ' (every verified entry needs command AND output)')) }}}i = i + 1}}}// issueslet is = report.issuesif (is != null) {if (!isList(is)) { problems.push('issues: must be a list of { project, subject, repro, observed, expected, blocks }, not a ' + typeOf(is)) } else {let i = 0while (i < is.length) {let e = is[i]let at = 'issues[' + i + ']'if (!isObject(e)) { problems.push(at + ': must be an object, not a ' + typeOf(e)) } else {unknownKeys(e, ISSUE, at, problems)for (f of ISSUE_TEXT) {if (e[f] == null) { problems.push(at + ": '" + f + "' is missing") }else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }else if (e[f].trim() == '') { problems.push(at + ": '" + f + "' is empty") }}if (e.blocks == null) { problems.push(at + ": 'blocks' is missing (true or false)") }else if (hlTypeName(e.blocks) != 'Boolean') { problems.push(at + ": 'blocks' must be true or false, not a " + typeOf(e.blocks)) }if (nonEmpty(e.project) && reg.projects[e.project] == null) {notes.push(at + ": project '" + e.project + "' is not in the registry (" + reg.names.join(', ') + ') → becomes a creator question on --post')}}i = i + 1}}}// runninglet rn = report.runningif (rn != null) {if (!isList(rn)) { problems.push('running: must be a list of { what, url, pid, log }, not a ' + typeOf(rn)) } else {let i = 0while (i < rn.length) {let e = rn[i]let at = 'running[' + i + ']'if (!isObject(e)) { problems.push(at + ': must be an object, not a ' + typeOf(e)) } else {unknownKeys(e, RUNNING, at, problems)if (e.what == null) { problems.push(at + ": 'what' is missing") } else if (!nonEmpty(e.what)) { problems.push(at + ": 'what' must be a non-empty string") }for (f of ['url' 'log']) {if (e[f] == null) { problems.push(at + ": '" + f + "' is missing (\"\" when there is none)") } else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }}if (e.pid == null) { problems.push(at + ": 'pid' is missing") } else if (hlTypeName(e.pid) != 'Number') { problems.push(at + ": 'pid' must be a number, not a " + typeOf(e.pid)) }}i = i + 1}}}return { problems = problems notes = notes }}// ---- the texts for the creator (mission 023, README "Ticket texts are written for the creator") ----------// the first line of a text, at most n charactersstatic oneLine = (s, n) => {let t = s.trim()let nl = t.indexOf(NL)if (nl > 0) { t = t.slice(0, nl).trim() }if (t.length > n) { t = t.slice(0, n - 1).trim() + '…' }return t}// `alpha#3` → `alpha #3` (the way tickets shows a ticket, mission 023: one link style everywhere)static refLabel = (ref) => { return ref.replaceAll('#', ' #') }// a ticket row → `[alpha #7](url)`static linkOf = (t) => { return '[' + t.project + ' ' + t.ref + '](' + pageUrl(t) + ')' }// the report comment: result · **Test:** steps · ≤ 3 detail bullets · the machine line (hash null → none).// filed = [{ link, blocks, unknown }] (the report's issues, filed), asked = [link] (the question tickets),// note = text for the end of the machine line (e.g. 'controller pass') or null// (mission 026) a HALF DONE report (`complete: false`): "Half done: <result>; the next worker continues." + what is still// open first, no Test steps (nothing is ready to test), machine line ending `· half done`static isHalfDone = (r) => { return r.complete == false }static withoutDot = (s) => {let t = s.trim()while (t.endsWith('.')) { t = t.slice(0, t.length - 1).trim() }return t}// mission 027: the small choices the worker made itself (`decided`, may be missing in older reports)static decidedOf = (r) => { return r.decided == null ? [] : r.decided }// mission 027: the Test steps as a numbered Markdown list (was one run-on line "1) … 2) …")static testLines = (r) => {let o = ['**Test:**']let i = 0while (i < r.test.length) {o.push((i + 1) + '. ' + r.test[i].trim())i = i + 1}return o}// the detail bullets, what matters most first (the caller keeps ≤ 3): [half: still open] · questions · problems filed// elsewhere · (mission 027) "Decided: …" · [not half: not done] · still running · refusedstatic detailsOf = (r, filed, asked, half) => {let d = []if (half && r.open.length > 0) { d.push('Still open: ' + oneLine(r.open[0], 90) + (r.open.length > 1 ? ' (and ' + (r.open.length - 1) + ' more)' : '')) }if (asked.length > 0) { d.push((asked.length == 1 ? 'A question for you: ' : 'Questions for you: ') + asked.join(', ')) }if (filed.length > 0) {let parts = []for (f of filed) { parts.push(f.link + (f.blocks ? ' (blocks this ticket)' : '') + (f.unknown ? ' (where should it go?)' : '')) }d.push((filed.length == 1 ? 'A problem found elsewhere: ' : 'Problems found elsewhere: ') + parts.join(', '))}let dc = decidedOf(r)if (dc.length > 0) {let ds = []for (x of dc) { ds.push(withoutDot(oneLine(x, 120))) }d.push(oneLine('Decided: ' + ds.join('; ') + '.', 200))}if (!half && r.open.length > 0) { d.push('Not done: ' + oneLine(r.open[0], 90) + (r.open.length > 1 ? ' (and ' + (r.open.length - 1) + ' more)' : '')) }for (x of r.running) { d.push('Still running: ' + oneLine(x.what, 60) + (x.url != '' ? ' — ' + x.url : '')) }if (r.refused.length > 0) { d.push('The safety check refused ' + r.refused.length + ' action' + (r.refused.length == 1 ? '' : 's') + '.') }return d}static pushDetails = (o, d) => {if (d.length == 0) { return o }o.push('')let k = 0while (k < d.length && k < 3) {o.push('- ' + d[k])k = k + 1}return o}static commentOf = (r, filed, asked, hash, note) => {let o = []let half = isHalfDone(r)if (half) {o.push('Half done: ' + withoutDot(r.result) + '; the next worker continues.')} else {o.push(r.result.trim())o.push('')for (l of testLines(r)) { o.push(l) }}pushDetails(o, detailsOf(r, filed, asked, half))if (hash != null) {o.push('')o.push('colony-report: ' + r.session + ' · sha256 ' + hash + (note == null ? '' : ' · ' + note) + (half ? ' · half done' : ''))}return o.join(NL)}// ---- mission 027: the deploy gate ---------------------------------------------------------------------------------// A project whose registry entry has `live` is tested by the creator ON THE LIVE SITE, so a complete + passed report is// not "ready for you to test" before the deploy: ONE line "Built — goes live with the next deploy." + the machine line// (marker ` · ready to deploy`), the ticket stays `in progress` (out of `next`; lease.hl readyOf: never expires).// `./colony ready` lists them; `./colony deployed <project> <n>` (deploy.hl) posts the readable comment (deployedCommentOf)// and sets `awaiting creator`. Projects without `live` (e.g. research tickets) → `awaiting creator` at once, as before.static READY_NOTE = 'ready to deploy'static deployGate = (r, reg) => {if (isHalfDone(r)) { return false }let m = projectOf(r, reg)return m != null && m.live != null}static readyCommentOf = (r, hash, note) => {return 'Built — goes live with the next deploy.' + NL + NL + 'colony-report: ' + r.session + ' · sha256 ' + hash + (note == null ? '' : ' · ' + note) + ' · ' + READY_NOTE}// the site's name for the creator: `https://ident.worldapi.org/x` → `ident.worldapi.org`static siteName = (url) => {let u = urllet i = u.indexOf('://')if (i >= 0) { u = u.slice(i + 3) }let j = u.indexOf('/')if (j >= 0) { u = u.slice(0, j) }return u}// after the deploy: "Now live on <site> — <result>" · **Test:** 1. … · ≤ 3 details · `colony-deployed: S · sha256 H`static deployedCommentOf = (r, filed, asked, site, hash) => {let o = []o.push('Now live on ' + site + ' — ' + r.result.trim())o.push('')for (l of testLines(r)) { o.push(l) }pushDetails(o, detailsOf(r, filed, asked, false))o.push('')o.push('colony-deployed: ' + r.session + ' · sha256 ' + hash)return o.join(NL)}// an issue filed in another projectstatic issueSummary = (e, ticketRef, ticketUrl) => {let o = []o.push('Found while working on [' + refLabel(ticketRef) + '](' + ticketUrl + ')' + (e.blocks ? ' — it blocks that ticket.' : '.'))o.push('')o.push('**Repro:** ' + e.repro.trim())o.push('')o.push('**Observed:** ' + e.observed.trim())o.push('')o.push('**Expected:** ' + e.expected.trim())return o.join(NL)}// a creator question's subject: `Question (alpha#3): <its first line, ≤ 100 characters>`// antcolony#23: the worker's own short title (`{ title, text }`); an old plain-string entry falls back to its first linestatic questionSubject = (ticketRef, q) => {if (isObject(q)) { return 'Question (' + ticketRef + '): ' + oneLine(q.title, 100) }return 'Question (' + ticketRef + '): ' + oneLine(q, 100)}static questionText = (q) => {return isObject(q) ? q.text : q}static questionSummary = (q, r, ticketUrl) => {return q.trim() + NL + NL + 'Asked while working on [' + refLabel(r.ticket) + '](' + ticketUrl + '). **Answer** with a comment here — the next worker reads it.'}// ---- the command ----------------------------------------------------------------------------static runReport = (argv) => {let pos = positionals(argv)if (pos.length != 2) {console.log('usage: report <file> [--post]')return false}return reportFile(fromCwd(pos[1]), argv.includes('--post'), null).valid}// validate (and with `post` file) one report file — shared by `report` and `work`.// `expect` = { ticket, session } (from `work`: the report must be for THAT ticket and session) or null.// → { valid, posted, problems } (posted: --post ran to POSTED)static reportFile = (file, post, expect) => {let reg = loadRegistry()for (e of reg.errors) { console.log('registry: ' + e) }let rj = readJson(file)if (rj.error != null) {console.log('REPORT REFUSED — ' + rj.error)return { valid = false posted = false problems = [rj.error] }}let r = rj.valuelet v = validate(r, reg)if (expect != null && v.problems.length == 0) {if (r.ticket != expect.ticket) { v.problems.push('ticket: "' + r.ticket + '" but this session works on ' + expect.ticket) }if (r.session != expect.session) { v.problems.push('session: "' + r.session + '" but this session is ' + expect.session) }}for (n of v.notes) { console.log('note: ' + n) }if (v.problems.length > 0) {console.log('REPORT REFUSED — ' + v.problems.length + ' problem' + (v.problems.length == 1 ? '' : 's') + ':')for (p of v.problems) { console.log(' ' + p) }return { valid = false posted = false problems = v.problems }}console.log('REPORT OK — ' + r.ticket + ', session ' + r.session + ': ' + (r.complete ? 'complete' : 'NOT complete (half done)') + ', ' + r.test.length + ' test steps, ' + r.done.length + ' done, ' + r.verified.length + ' verified, ' + r.open.length + ' open, ' + r.issues.length + ' issues, ' + r.questions.length + ' questions, ' + decidedOf(r).length + ' decided')if (!post) { return { valid = true posted = false problems = [] } }let ok = postReport(r, reg, null)return { valid = true posted = ok problems = [] }}// --post of a VALID report → true when POSTED. `note` = text for the end of the machine line ('controller pass',// missions 019/023) or nullstatic postReport = (r, reg, note) => {// ---- --postlet tk = readToken()if (tk.error != null) {console.log('POST REFUSED — ' + tk.error)return false}let token = tk.tokenlet ref = parseRef(r.ticket)let got = getJson(ticketPath(ref.project, ref.number))if (got.status != 200) {console.log('POST REFUSED — ticket ' + r.ticket + ' → ' + got.status)return false}let ticket = got.json.ticketlet ticketUrl = pageUrl(ticket)// the marker carries 12 hex of the report's sha256 (mission 023: one short line); an older comment with all// 64 starts with the same 12, so it still counts as "the same report"let hash = sha256(JSON.stringify(r)).slice(0, 12)let marker = 'colony-report: ' + r.session + ' · sha256 'let posted = falsefor (e of got.json.events) {if (e.kind == 'comment' && e.text != null && e.text.includes(marker)) {if (e.text.includes(marker + hash)) { posted = true } else {console.log('POST REFUSED — session ' + r.session + ' already has a DIFFERENT report on ' + r.ticket + ' (event ' + e.seq + '); one session, one report. Nothing written.')return false}}}// relations (tickets#4): the ticket's current blockers — null-safe for a tickets without relationslet hasRel = ticket.keys().includes('children')let blockedKeys = []if (hasRel) { for (b of ticket.blockedBy) { blockedKeys.push(b.key) } }// issues → tickets / creator questions (idempotent by source)let filed = []let i = 0while (i < r.issues.length) {let e = r.issues[i]let src = 'colony-report:' + r.session + ':issue:' + iif (reg.projects[e.project] != null) {let res = postJson('/api/projects/' + e.project + '/tickets', { subject = e.subject summary = issueSummary(e, r.ticket, ticketUrl) source = src }, token)if (res.status == 201 || res.status == 200) {let t = res.json.ticketconsole.log((res.status == 201 ? ' FILED ' : ' HAVE ') + 'issue ' + i + ' → ' + t.project + t.ref + ' ' + pageUrl(t))filed.push({ link = linkOf(t) blocks = e.blocks unknown = false })// mission 022: `blocks: true` → the ticket is blocked by the issue (tickets#4; the scheduler authored the// issue, so it may link) — once: a blocker already there is not added againif (e.blocks && hasRel) {let key = t.project + '#' + t.numberif (blockedKeys.includes(key)) { console.log(' HAVE ' + r.ticket + ' blocked by ' + key) } else {let bl = postJson(ticketPath(ref.project, ref.number) + '/blocked-by', { add = key }, token)if (bl.status != 201) {console.log('POST FAILED — marking ' + r.ticket + ' blocked by ' + key + ': ' + bl.status + ' ' + bl.text + ' — re-run to continue (idempotent)')return false}blockedKeys.push(key)console.log(' LINKED ' + r.ticket + ' blocked by ' + key)}}} else {console.log('POST FAILED — issue ' + i + ' (' + e.project + '): ' + res.status + ' ' + res.text + ' — re-run to continue (idempotent)')return false}} else {let uq = 'A problem found while working on [' + refLabel(r.ticket) + '](' + ticketUrl + ') belongs to "' + e.project + '", a project the colony does not know.' + NL + NL + '**Answer:** which project should get it (or "drop it")?' + NL + NL + '**Repro:** ' + e.repro.trim() + NL + NL + '**Observed:** ' + e.observed.trim() + NL + NL + '**Expected:** ' + e.expected.trim()let q = fileQuestion(token, 'colony-report:' + r.session + ':question:' + i, 'Question (' + r.ticket + '): where should "' + oneLine(e.subject, 60) + '" go?', uq, r.ticket)if (q.error != null) {console.log('POST FAILED — issue ' + i + ' (unknown project ' + e.project + '): ' + q.error + ' — re-run to continue (idempotent)')return false}let t = q.ticketconsole.log((q.created ? ' FILED ' : ' HAVE ') + 'issue ' + i + ' (unknown project ' + e.project + ') → creator question ' + t.project + t.ref + ' ' + pageUrl(t))filed.push({ link = linkOf(t) blocks = false unknown = true })}i = i + 1}// mission 022 "Creator questions are tickets": every `questions` entry → its own ticket in the ticket's project,// `awaiting creator`, parent = the ticket; idempotent by source `colony-report:<session>:questions:<i>`let asked = []i = 0while (i < r.questions.length) {let qt = r.questions[i]let q = fileQuestionIn(token, ref.project, 'colony-report:' + r.session + ':questions:' + i, questionSubject(r.ticket, qt), questionSummary(questionText(qt), r, ticketUrl), r.ticket)if (q.error != null) {console.log('POST FAILED — question ' + i + ': ' + q.error + ' — re-run to continue (idempotent)')return false}let t = q.ticketconsole.log((q.created ? ' FILED ' : ' HAVE ') + 'question ' + i + ' → ' + t.project + t.ref + ' (awaiting creator, parent ' + r.ticket + ') ' + pageUrl(t))asked.push(linkOf(t))i = i + 1}if (posted) {console.log(' HAVE the report comment on ' + r.ticket + ' (same session, same sha256) — comment and state untouched')console.log('POSTED — nothing new (report already filed)')return true}// mission 027: the deploy gate — a project with a live site: "Built — goes live with the next deploy." and the ticket// stays `in progress` (out of `next`) until `./colony deployed <project> <n>` posts the readable commentlet gate = deployGate(r, reg)let c = postJson(ticketPath(ref.project, ref.number) + '/comments', { text = gate ? readyCommentOf(r, hash, note) : commentOf(r, filed, asked, hash, note) }, token)if (c.status != 201) {console.log('POST FAILED — comment on ' + r.ticket + ': ' + c.status + ' ' + c.text)return false}console.log(' POSTED the report comment on ' + r.ticket + ' ' + ticketUrl)let st = c.json.ticket.stateif (gate) {if (st == 'open' || st == 'rejected') {let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'in progress' text = 'Built — waiting for the next deploy.' }, token)if (s.status != 201) {console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)return false}console.log(' STATE ' + r.ticket + ': ' + st + ' → in progress (built, waiting for the deploy)')} else {console.log(' STATE left as "' + st + '"')}console.log(' READY ' + r.ticket + ': built — goes live with the next deploy (list: ./colony ready; after the deploy: ./colony deployed ' + ref.project + ' ' + ref.number + ')')} else if (isHalfDone(r)) {// mission 026: half done → back to open (only from the lease), never "awaiting creator"if (st != 'in progress') {console.log(' STATE left as "' + st + '" (half done)')} else {let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'open' text = 'Back to open: half done — the next worker continues.' }, token)if (s.status != 201) {console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)return false}console.log(' STATE ' + r.ticket + ': ' + st + ' → open (half done — the next worker continues)')}} else if (st == 'awaiting creator' || st == 'confirmed' || st == 'rejected') {console.log(' STATE left as "' + st + '"')} else {let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'awaiting creator' text = 'Ready for you to test — see the last comment.' }, token)if (s.status != 201) {console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)return false}console.log(' STATE ' + r.ticket + ': ' + st + ' → awaiting creator')}console.log('POSTED')return true}
Branches
- mainmain branch
Latest commits
- 7f9660eeState of 2026-09-27, before the move to gitoriamre