SaddleApi.vala 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Astralis;
  4. using Inversion;
  5. using Soup;
  6. using ClankerSaddle.Data;
  7. using ClankerSaddle.Services;
  8. namespace ClankerSaddle.Api {
  9. /**
  10. * `POST /api/saddle/{id}/update` — the ingest channel for a saddle
  11. * daemon, authorised by its `Authorization: Bearer <token>` header
  12. * (see {@link SaddleService.validate_token}). The body carries a type
  13. * discriminator, the session id and a type-specific `data` object:
  14. *
  15. * ```json
  16. * {"type": "output", "session_id": 12, "data": {
  17. * "command": "ls -la", "output": "…", "exit_code": 0, "type": "command",
  18. * "started_at": "…", "completed_at": "…", "duration_ms": 41}}
  19. * ```
  20. *
  21. * Handled types: `output` (a single entry or an `entries` array; both
  22. * `data` shapes store `session_commands` rows), `comment`, `block`
  23. * (question/rfc/review with optional `options` and markdown `body`;
  24. * returns the new `block_id`), `subtask`, `plan`, `progress`,
  25. * `review`, `done` (completes the task), `failed` (fails the task
  26. * with the explanation) and `status` (working|blocked).
  27. * Conversation-level types also append a typed entry to the session's
  28. * terminal log.
  29. */
  30. public class SaddleUpdateEndpoint : Object, Endpoint {
  31. protected TaskService tasks = inject<TaskService>();
  32. protected SaddleService saddles = inject<SaddleService>();
  33. protected InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  34. public virtual async HttpResult handle_request(HttpContext http_context,
  35. RouteContext route_context) throws Error {
  36. string id_text = "";
  37. route_context.mapped_parameters.try_get("id", out id_text);
  38. int64 saddle_id = int64.parse(id_text);
  39. if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
  40. return json_error(401, "Missing or invalid saddle token");
  41. }
  42. Json.Object body;
  43. try {
  44. body = yield read_json_body(http_context);
  45. } catch (Error e) {
  46. return json_error(400, e.message);
  47. }
  48. string type = body.has_member("type") ? body.get_string_member_with_default("type", "") : "";
  49. int64 session_id = body.has_member("session_id") ? body.get_int_member("session_id") : 0;
  50. var data = body.has_member("data") && body.get_member("data").get_node_type() == Json.NodeType.OBJECT
  51. ? body.get_object_member("data") : new Json.Object();
  52. try {
  53. var response = yield handle_update(saddle_id, session_id, type, data);
  54. return json_ok(response);
  55. } catch (Error e) {
  56. return json_error(400, e.message);
  57. }
  58. }
  59. private async Json.Object handle_update(int64 saddle_id, int64 session_id,
  60. string type, Json.Object data) throws Error {
  61. var task_id = yield task_id_for_session(session_id);
  62. switch (type) {
  63. case "output":
  64. if (data.has_member("entries") && data.get_member("entries").get_node_type() == Json.NodeType.ARRAY) {
  65. foreach (var element in data.get_array_member("entries").get_elements()) {
  66. if (element.get_node_type() == Json.NodeType.OBJECT) {
  67. yield store_output(session_id, element.get_object());
  68. }
  69. }
  70. } else {
  71. yield store_output(session_id, data);
  72. }
  73. break;
  74. case "comment":
  75. string body_text = get_string_or(data, "body", get_string_or(data, "text", ""));
  76. yield tasks.add_comment(task_id, "bot", body_text);
  77. yield tasks.log_session_entry(task_id, "comment", "saddle comment", body_text);
  78. break;
  79. case "block":
  80. var block = yield tasks.add_block(task_id,
  81. get_string_or(data, "question", ""),
  82. get_string_or(data, "type", "question"),
  83. string_options(data),
  84. get_string_or(data, "body", null));
  85. yield tasks.log_session_entry(task_id, "block",
  86. @"block: $(block.question)", block.body);
  87. var block_response = new Json.Object();
  88. block_response.set_int_member("block_id", block.id);
  89. return block_response;
  90. case "subtask":
  91. // Dedup against the synchronous callback: a subtask
  92. // row already created for this parent/title (the
  93. // callback path) is not duplicated — the entry log
  94. // is the fallback, not a second source of truth.
  95. string subtask_title = get_string_or(data, "title", "Untitled subtask");
  96. var existing_subtask = yield tasks.find_subtask_by_title(task_id, subtask_title);
  97. if (existing_subtask == null) {
  98. yield tasks.create_subtask(task_id, subtask_title,
  99. get_string_or(data, "description", null));
  100. }
  101. break;
  102. case "plan":
  103. yield tasks.update_plan(task_id, get_string_or(data, "plan",
  104. get_string_or(data, "markdown", "")));
  105. yield tasks.log_session_entry(task_id, "plan", "saddle plan",
  106. get_string_or(data, "plan", get_string_or(data, "markdown", "")));
  107. break;
  108. case "progress":
  109. string summary = get_string_or(data, "summary",
  110. get_string_or(data, "text", ""));
  111. yield tasks.add_progress(task_id, summary);
  112. yield tasks.log_session_entry(task_id, "progress", "saddle progress-update", summary);
  113. break;
  114. case "review":
  115. string review_summary = get_string_or(data, "summary", "");
  116. var review_block = yield tasks.request_review(task_id, review_summary);
  117. yield tasks.log_session_entry(task_id, "review", "saddle review", review_summary);
  118. yield tasks.auto_answer_if_covered(review_block);
  119. break;
  120. case "done":
  121. yield tasks.complete_task(task_id,
  122. get_string_or(data, "summary", null),
  123. get_string_or(data, "working_branch", null), "done");
  124. break;
  125. case "failed":
  126. string failure = get_string_or(data, "summary",
  127. get_string_or(data, "text", ""));
  128. yield tasks.fail_task(task_id, failure.length > 0 ? failure : null);
  129. yield tasks.log_session_entry(task_id, "failed", "saddle failure", failure);
  130. break;
  131. case "status":
  132. string state = get_string_or(data, "state", "");
  133. if (state == "working" || state == "blocked") {
  134. yield tasks.update_task(task_id, null, null, null, state);
  135. }
  136. break;
  137. default:
  138. throw new Error(Quark.from_string("clanker-saddle"), 20,
  139. @"Unknown update type '$(type)'");
  140. }
  141. return new Json.Object();
  142. }
  143. private async void store_output(int64 session_id, Json.Object entry) throws Error {
  144. var command = new SessionCommand() {
  145. session_id = session_id,
  146. command_text = get_string_or(entry, "command", ""),
  147. output_text = get_string_or(entry, "output", null),
  148. exit_code = entry.has_member("exit_code")
  149. && entry.get_member("exit_code").get_value_type() == typeof(int64)
  150. ? entry.get_int_member("exit_code") : 0,
  151. entry_type = get_string_or(entry, "type", "command"),
  152. piped_input = get_string_or(entry, "input", null) != null
  153. && get_string_or(entry, "input", "").length > 0
  154. ? get_string_or(entry, "input", null) : null,
  155. started_at = parse_iso(entry, "started_at"),
  156. completed_at = parse_iso(entry, "completed_at"),
  157. duration_ms = entry.has_member("duration_ms")
  158. && entry.get_member("duration_ms").get_value_type() == typeof(int64)
  159. ? entry.get_int_member("duration_ms") : 0
  160. };
  161. yield db.insert_async<SessionCommand>(command);
  162. }
  163. /** The `options` member as a string array (native or JSON-encoded). */
  164. private static string[] string_options(Json.Object data) {
  165. if (!data.has_member("options")) {
  166. return new string[0];
  167. }
  168. var member = data.get_member("options");
  169. if (member.get_node_type() == Json.NodeType.ARRAY) {
  170. var values = new Vector<string>();
  171. foreach (var element in member.get_array().get_elements()) {
  172. if (element.get_node_type() == Json.NodeType.VALUE
  173. && element.get_string() != null) {
  174. values.add(element.get_string());
  175. }
  176. }
  177. return values.to_array();
  178. }
  179. string raw = get_string_or(data, "options", "");
  180. if (raw.length == 0) {
  181. return new string[0];
  182. }
  183. try {
  184. var parser = new Json.Parser();
  185. parser.load_from_data(raw);
  186. var root = parser.get_root();
  187. if (root != null && root.get_node_type() == Json.NodeType.ARRAY) {
  188. var values = new Vector<string>();
  189. foreach (var element in root.get_array().get_elements()) {
  190. if (element.get_node_type() == Json.NodeType.VALUE
  191. && element.get_string() != null) {
  192. values.add(element.get_string());
  193. }
  194. }
  195. return values.to_array();
  196. }
  197. } catch (Error e) {
  198. warning("[ClankerSaddle] Ignoring malformed block options: %s", e.message);
  199. }
  200. return new string[0];
  201. }
  202. private async int64 task_id_for_session(int64 session_id) throws Error {
  203. var session = yield db.query<ClankerSaddle.Data.Session>()
  204. .where(Invercargill.Expressions.expr("id == $0",
  205. Invercargill.Expressions.elem<int64?>(session_id)))
  206. .first_async();
  207. if (session == null) {
  208. throw new Error(Quark.from_string("clanker-saddle"), 21,
  209. @"No session exists with id $(session_id)");
  210. }
  211. return ((!)session).task_id;
  212. }
  213. /**
  214. * Reads and parses the request body as a JSON object; an empty body
  215. * parses as an empty object.
  216. */
  217. internal static async Json.Object read_json_body(HttpContext http_context) throws Error {
  218. var bytes = yield http_context.request.request_body.read_all();
  219. // to_raw_string on an empty body is not safe; guard the count.
  220. string text = "";
  221. if (bytes != null && (bytes.peek_count() ?? 0) > 0) {
  222. text = bytes.to_raw_string();
  223. }
  224. if (text.strip().length == 0) {
  225. text = "{}";
  226. }
  227. var parser = new Json.Parser();
  228. try {
  229. parser.load_from_data(text);
  230. } catch (Error e) {
  231. throw new Error(Quark.from_string("clanker-saddle"), 22,
  232. @"Request body is not valid JSON: $(e.message)");
  233. }
  234. var root = parser.get_root();
  235. if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
  236. throw new Error(Quark.from_string("clanker-saddle"), 23,
  237. "Request body must be a JSON object");
  238. }
  239. return root.get_object();
  240. }
  241. internal static HttpResult json_ok(Json.Object body) {
  242. body.set_boolean_member("ok", true);
  243. var generator = new Json.Generator();
  244. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  245. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data));
  246. result.set_header("Content-Type", "application/json");
  247. return result;
  248. }
  249. internal static HttpResult json_error(uint16 status_code, string message) {
  250. var body = new Json.Object();
  251. body.set_boolean_member("ok", false);
  252. body.set_string_member("error", message);
  253. var generator = new Json.Generator();
  254. generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
  255. var result = new HttpDataResult(new ByteBuffer.from_byte_array(generator.to_data(null).data),
  256. (StatusCode) status_code);
  257. result.set_header("Content-Type", "application/json");
  258. return result;
  259. }
  260. internal static string get_string_or(Json.Object obj, string name, string? fallback) {
  261. if (obj.has_member(name) && obj.get_member(name).get_string() != null) {
  262. return obj.get_string_member(name);
  263. }
  264. return fallback;
  265. }
  266. internal static DateTime? parse_iso(Json.Object obj, string name) {
  267. if (!obj.has_member(name)) {
  268. return null;
  269. }
  270. var value = obj.get_member(name);
  271. if (value.get_node_type() != Json.NodeType.VALUE || value.get_string() == null) {
  272. return null;
  273. }
  274. return new DateTime.from_iso8601(value.get_string(), new TimeZone.utc());
  275. }
  276. }
  277. /**
  278. * `GET /api/saddle/{id}/poll` — the outbound channel for a saddle
  279. * daemon, authorised by its `Authorization: Bearer <token>` header:
  280. * answered blocks and undelivered human comments for the saddle's
  281. * current task, each handed over exactly once.
  282. *
  283. * ```json
  284. * {"answers": [{"block_id": 4, "answer": "Use the flange tool"}],
  285. * "comments": [{"comment_id": 9, "author": "user:admin",
  286. * "body": "…", "quote": "…"}]}
  287. * ```
  288. */
  289. public class SaddlePollEndpoint : Object, Endpoint {
  290. private SaddleService saddles = inject<SaddleService>();
  291. public virtual async HttpResult handle_request(HttpContext http_context,
  292. RouteContext route_context) throws Error {
  293. string id_text = "";
  294. route_context.mapped_parameters.try_get("id", out id_text);
  295. int64 saddle_id = int64.parse(id_text);
  296. if (!(yield saddles.validate_token(saddle_id, bearer_token(http_context)))) {
  297. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
  298. }
  299. var answers = new Json.Array();
  300. var comments = new Json.Array();
  301. try {
  302. foreach (var pending in yield saddles.take_pending_answers(saddle_id)) {
  303. var element = new Json.Object();
  304. element.set_int_member("block_id", pending.block_id);
  305. element.set_string_member("answer", pending.answer);
  306. answers.add_object_element(element);
  307. }
  308. foreach (var pending in yield saddles.take_pending_comments(saddle_id)) {
  309. var element = new Json.Object();
  310. element.set_int_member("comment_id", pending.comment_id);
  311. element.set_string_member("author", pending.author);
  312. element.set_string_member("body", pending.body);
  313. element.set_string_member("quote", pending.quote);
  314. comments.add_object_element(element);
  315. }
  316. } catch (Error e) {
  317. return SaddleUpdateEndpoint.json_error(400, e.message);
  318. }
  319. var body = new Json.Object();
  320. body.set_array_member("answers", answers);
  321. body.set_array_member("comments", comments);
  322. return SaddleUpdateEndpoint.json_ok(body);
  323. }
  324. }
  325. /**
  326. * `GET /api/saddle/tasks?project_id=N&q=free-text` — the task-reading
  327. * API behind the daemon's `saddle tasks` CLI (wire contract):
  328. * authenticated by the dedicated callback token (like
  329. * `/api/saddle/note`). With `project_id` only that project's tasks;
  330. * `q` matches title, description or plan (case-insensitive
  331. * substring). Compact rows — id, title, status, parent_task_id.
  332. */
  333. public class SaddleTaskListEndpoint : Object, Endpoint {
  334. private SaddleService saddles = inject<SaddleService>();
  335. private TaskService tasks = inject<TaskService>();
  336. public virtual async HttpResult handle_request(HttpContext http_context,
  337. RouteContext route_context) throws Error {
  338. if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
  339. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
  340. }
  341. string project_text = "";
  342. int64? project_id = null;
  343. if (http_context.request.query_params.try_get_any("project_id", out project_text)
  344. && project_text != null && project_text.length > 0) {
  345. project_id = int64.parse(project_text);
  346. }
  347. string q = "";
  348. if (!http_context.request.query_params.try_get_any("q", out q) || q == null) {
  349. // try_get_any leaves the out parameter null when absent.
  350. q = "";
  351. }
  352. q = q.strip().down();
  353. try {
  354. var rows = yield tasks.list_tasks(null, project_id);
  355. var array = new Json.Array();
  356. foreach (var task in rows) {
  357. if (q.length > 0) {
  358. string haystack = (task.title + "\n"
  359. + (task.description ?? "") + "\n"
  360. + (task.plan ?? "")).down();
  361. if (!(q in haystack)) {
  362. continue;
  363. }
  364. }
  365. var element = new Json.Object();
  366. element.set_int_member("id", task.id);
  367. element.set_string_member("title", task.title);
  368. element.set_string_member("status", task.status);
  369. element.set_int_member("parent_task_id", task.parent_task_id);
  370. array.add_object_element(element);
  371. }
  372. var body = new Json.Object();
  373. body.set_array_member("tasks", array);
  374. return SaddleUpdateEndpoint.json_ok(body);
  375. } catch (Error e) {
  376. return SaddleUpdateEndpoint.json_error(400, e.message);
  377. }
  378. }
  379. }
  380. /**
  381. * `GET /api/saddle/task/{id}` — the single-task view behind the
  382. * daemon's `saddle task` CLI (wire contract): callback-token
  383. * authenticated, joining the parent's title for lineage context.
  384. * 404 when the task does not exist.
  385. */
  386. public class SaddleTaskDetailEndpoint : Object, Endpoint {
  387. private SaddleService saddles = inject<SaddleService>();
  388. private TaskService tasks = inject<TaskService>();
  389. public virtual async HttpResult handle_request(HttpContext http_context,
  390. RouteContext route_context) throws Error {
  391. if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
  392. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
  393. }
  394. string id_text = "";
  395. route_context.mapped_parameters.try_get("id", out id_text);
  396. int64 task_id = int64.parse(id_text);
  397. try {
  398. var detail = yield tasks.get_task(task_id);
  399. if (detail == null) {
  400. return SaddleUpdateEndpoint.json_error(404,
  401. @"No task exists with id $(task_id)");
  402. }
  403. var task = ((!)detail).task;
  404. string parent_title = "";
  405. if (task.parent_task_id > 0) {
  406. var parent = yield tasks.get_task(task.parent_task_id);
  407. parent_title = parent != null ? ((!)parent).task.title : "";
  408. }
  409. var element = new Json.Object();
  410. element.set_int_member("id", task.id);
  411. element.set_string_member("title", task.title);
  412. element.set_string_member("description", task.description ?? "");
  413. element.set_string_member("status", task.status);
  414. element.set_string_member("plan", task.plan ?? "");
  415. element.set_string_member("summary", task.summary ?? "");
  416. element.set_int_member("parent_task_id", task.parent_task_id);
  417. element.set_string_member("parent_title", parent_title);
  418. var body = new Json.Object();
  419. body.set_object_member("task", element);
  420. return SaddleUpdateEndpoint.json_ok(body);
  421. } catch (Error e) {
  422. return SaddleUpdateEndpoint.json_error(400, e.message);
  423. }
  424. }
  425. }
  426. /**
  427. * `POST /api/saddle/subtask` — the synchronous subtask-creation
  428. * callback a daemon's `saddle subtask` CLI posts to (wire contract):
  429. * authenticated by the dedicated saddle-callback bearer token (like
  430. * `/api/saddle/note`), creating the task row immediately — same
  431. * project, branch and priority as the parent — with
  432. * `task_dependencies` rows for every `depends_on` id and the
  433. * timeline entry on the parent's session. Idempotent on
  434. * (parent, title): a retried callback or the later entry-poll
  435. * fallback returns the existing task's id instead of duplicating.
  436. *
  437. * ```json
  438. * {"parent_task_id": 1, "title": "…", "description": "…",
  439. * "depends_on": [3, 4], "session_id": 2}
  440. * ```
  441. *
  442. * → `{"ok": true, "task_id": 7}`.
  443. */
  444. public class SaddleSubtaskEndpoint : Object, Endpoint {
  445. private SaddleService saddles = inject<SaddleService>();
  446. private ProjectService projects = inject<ProjectService>();
  447. private TaskService tasks = inject<TaskService>();
  448. public virtual async HttpResult handle_request(HttpContext http_context,
  449. RouteContext route_context) throws Error {
  450. if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
  451. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
  452. }
  453. Json.Object body;
  454. try {
  455. body = yield SaddleUpdateEndpoint.read_json_body(http_context);
  456. } catch (Error e) {
  457. return SaddleUpdateEndpoint.json_error(400, e.message);
  458. }
  459. int64 parent_task_id = body.has_member("parent_task_id")
  460. && body.get_member("parent_task_id").get_value_type() == typeof(int64)
  461. ? body.get_int_member("parent_task_id") : 0;
  462. string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
  463. if (parent_task_id <= 0 || title.length == 0) {
  464. return SaddleUpdateEndpoint.json_error(400,
  465. "parent_task_id and title are required");
  466. }
  467. var parent = yield tasks.get_task(parent_task_id);
  468. if (parent == null) {
  469. return SaddleUpdateEndpoint.json_error(400,
  470. @"No task exists with id $(parent_task_id)");
  471. }
  472. int64[] depends_on = {};
  473. if (body.has_member("depends_on")
  474. && body.get_member("depends_on").get_node_type() == Json.NodeType.ARRAY) {
  475. foreach (var element in body.get_array_member("depends_on").get_elements()) {
  476. if (element.get_node_type() == Json.NodeType.VALUE) {
  477. int64 parsed = 0;
  478. if (element.get_value_type() == typeof(int64)) {
  479. parsed = element.get_int();
  480. } else {
  481. parsed = int64.parse(element.get_string());
  482. }
  483. if (parsed > 0) {
  484. depends_on += parsed;
  485. }
  486. }
  487. }
  488. }
  489. try {
  490. var existing = yield tasks.find_subtask_by_title(parent_task_id, title);
  491. TaskEntity task;
  492. bool created = false;
  493. if (existing != null) {
  494. task = (!)existing;
  495. } else {
  496. task = yield tasks.create_subtask_with_dependencies(parent_task_id,
  497. title, SaddleUpdateEndpoint.get_string_or(body, "description", null),
  498. depends_on);
  499. created = true;
  500. }
  501. if (created) {
  502. yield tasks.log_session_entry(parent_task_id, "subtask",
  503. @"saddle subtask: $(title)", @"created task #$(task.id)");
  504. }
  505. var response = new Json.Object();
  506. response.set_int_member("task_id", task.id);
  507. return SaddleUpdateEndpoint.json_ok(response);
  508. } catch (Error e) {
  509. return SaddleUpdateEndpoint.json_error(400, e.message);
  510. }
  511. }
  512. }
  513. /**
  514. * `POST /api/saddle/note` — the callback endpoint a daemon's
  515. * `saddle note` CLI posts to (wire contract): authenticated by the
  516. * dedicated saddle-callback bearer token (see
  517. * {@link SaddleService.ensure_callback_token}), inserting
  518. *
  519. * ```json
  520. * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
  521. * ```
  522. *
  523. * `task_id` is optional (stored as the note's source task); the
  524. * project is resolved by name and must exist. Response
  525. * `{"ok": true}`.
  526. */
  527. public class SaddleNoteEndpoint : Object, Endpoint {
  528. private SaddleService saddles = inject<SaddleService>();
  529. private ProjectService projects = inject<ProjectService>();
  530. public virtual async HttpResult handle_request(HttpContext http_context,
  531. RouteContext route_context) throws Error {
  532. if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
  533. return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
  534. }
  535. Json.Object body;
  536. try {
  537. body = yield SaddleUpdateEndpoint.read_json_body(http_context);
  538. } catch (Error e) {
  539. return SaddleUpdateEndpoint.json_error(400, e.message);
  540. }
  541. string project_name = SaddleUpdateEndpoint.get_string_or(body, "project", "");
  542. string note_body = SaddleUpdateEndpoint.get_string_or(body, "body", "");
  543. int64 task_id = body.has_member("task_id")
  544. && body.get_member("task_id").get_value_type() == typeof(int64)
  545. ? body.get_int_member("task_id") : 0;
  546. if (note_body.strip().length == 0) {
  547. return SaddleUpdateEndpoint.json_error(400, "A note body is required");
  548. }
  549. try {
  550. var project = yield projects.get_project_by_name(project_name);
  551. if (project == null) {
  552. return SaddleUpdateEndpoint.json_error(400,
  553. @"No project exists named '$(project_name)'");
  554. }
  555. yield projects.add_note(((!)project).id, task_id, note_body);
  556. return SaddleUpdateEndpoint.json_ok(new Json.Object());
  557. } catch (Error e) {
  558. return SaddleUpdateEndpoint.json_error(400, e.message);
  559. }
  560. }
  561. }
  562. /**
  563. * The request's bearer token: the `Authorization` header minus its
  564. * `Bearer ` prefix, or "" when the header is missing or shaped
  565. * differently.
  566. */
  567. private static string bearer_token(HttpContext http_context) {
  568. string? header = http_context.request.get_header("Authorization");
  569. if (header == null || !((!)header).has_prefix("Bearer ")) {
  570. return "";
  571. }
  572. return ((!)header).substring("Bearer ".length).strip();
  573. }
  574. }