ProjectsEntrypoint.vala 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using Spry;
  5. using Spry.Authentication;
  6. using Statum;
  7. using ClankerSaddle.Data;
  8. using ClankerSaddle.Services;
  9. namespace ClankerSaddle {
  10. /** A project row on the projects list, with task counts and a link to its page. */
  11. public class ProjectRowView : Object {
  12. public int64 id { get; set; }
  13. public string name { get; set; default = ""; }
  14. public string git_url { get; set; default = ""; }
  15. public string default_branch { get; set; default = ""; }
  16. public string summary { get; set; default = ""; }
  17. public int64 open_tasks { get; set; }
  18. public int64 done_tasks { get; set; }
  19. }
  20. /** Public data of the `projects` slot. */
  21. public class ProjectsPublic : Object {
  22. public Statum.Model.ActionDto create { get; set; }
  23. }
  24. /**
  25. * Entrypoint for /projects: lists every project with open/done task
  26. * counts and authors the create-project action (login required).
  27. */
  28. public class ProjectsEntrypoint : StatumEntrypoint {
  29. private ProjectService project_service = Inversion.inject<ProjectService>();
  30. private TaskService task_service = Inversion.inject<TaskService>();
  31. private UserService users = Inversion.inject<UserService>();
  32. public override async DirectiveBuilder handle() throws GLib.Error {
  33. var guard = yield SpryAuth.require_login(directives(), request, users);
  34. if (guard != null) {
  35. return (!)guard;
  36. }
  37. var rows = new Vector<ProjectRowView>();
  38. var tasks = yield task_service.list_tasks(null, null);
  39. foreach (var project in yield project_service.list_projects()) {
  40. var view = new ProjectRowView() {
  41. id = project.id,
  42. name = project.name,
  43. git_url = project.git_url,
  44. default_branch = project.default_branch,
  45. summary = project.summary ?? ""
  46. };
  47. foreach (var task in tasks) {
  48. if (task.project_id != project.id) {
  49. continue;
  50. }
  51. // Goal-agent runs are internal tasks — they never
  52. // count against the project's task tallies.
  53. if (task.kind == TASK_KIND_GOAL_RUN) {
  54. continue;
  55. }
  56. if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
  57. view.done_tasks++;
  58. } else {
  59. view.open_tasks++;
  60. }
  61. }
  62. rows.add(view);
  63. }
  64. var pub = new ProjectsPublic();
  65. pub.create = action_registry.author<CreateProjectAction>();
  66. var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  67. props["projects"] = StateJson.list<ProjectRowView>(rows.to_immutable_buffer());
  68. var slot = state_service.new_slot(Scope.PAGE, new State() {
  69. type_name = "projects",
  70. public_data = props,
  71. private_data = new PropertyDictionary()
  72. });
  73. return directives().set(slot);
  74. }
  75. }
  76. /** A linked project on the project detail page, with a remove action. */
  77. public class ProjectLinkView : Object {
  78. public int64 link_id { get; set; }
  79. public int64 project_id { get; set; }
  80. public string name { get; set; default = ""; }
  81. public string git_url { get; set; default = ""; }
  82. public string relationship { get; set; default = ""; }
  83. public bool co_developed { get; set; }
  84. public Statum.Model.ActionDto? remove { get; set; }
  85. }
  86. /** A task row on the project detail page. */
  87. public class ProjectTaskRowView : Object {
  88. public int64 id { get; set; }
  89. public string title { get; set; default = ""; }
  90. public string status { get; set; default = ""; }
  91. public string display { get; set; default = ""; }
  92. public int64 priority { get; set; }
  93. }
  94. /**
  95. * A goal row on the project detail page (goal 41 T9): the missing
  96. * reverse cross-link from ux-notes — the project's own goals plus
  97. * the ones linked to it as co-developed, so "what is being built
  98. * here" is answerable from the project page itself.
  99. */
  100. public class ProjectGoalRowView : Object {
  101. public int64 id { get; set; }
  102. public string title { get; set; default = ""; }
  103. public string status { get; set; default = ""; }
  104. /** The status with underscores replaced (matches goal pages). */
  105. public string display { get; set; default = ""; }
  106. /** "primary" for the project's own goals, else the link's relation. */
  107. public string relationship { get; set; default = ""; }
  108. public string branch_name { get; set; default = ""; }
  109. }
  110. /** A rule attached to the project, with its detach action. */
  111. public class ProjectRuleRowView : Object {
  112. public int64 link_id { get; set; }
  113. public int64 rule_id { get; set; }
  114. public string name { get; set; default = ""; }
  115. public string body { get; set; default = ""; }
  116. public Statum.Model.ActionDto? detach { get; set; }
  117. }
  118. /** A stack option in the apply select (project and goal pages). */
  119. public class StackOptionView : Object {
  120. public int64 id { get; set; }
  121. public string name { get; set; default = ""; }
  122. public int64 member_count { get; set; }
  123. }
  124. /** A rule option in the attach selects (project and task pages). */
  125. public class RuleOptionView : Object {
  126. public int64 id { get; set; }
  127. public string name { get; set; default = ""; }
  128. }
  129. /**
  130. * A project note on the project page: LLM-authored via `saddle
  131. * note`, with its date, source task link and a delete action (no
  132. * manual create — notes are user-curated, not user-written).
  133. */
  134. public class ProjectNoteRowView : Object {
  135. public int64 id { get; set; }
  136. public string project_name { get; set; default = ""; }
  137. public int64 source_task_id { get; set; }
  138. public string body { get; set; default = ""; }
  139. public string created_display { get; set; default = ""; }
  140. public Statum.Model.ActionDto? delete { get; set; }
  141. }
  142. /**
  143. * One understanding revision on the project page. The current row
  144. * is rendered above; the history rows below it are the OLDER
  145. * revisions (newest first). An absent current understanding is an
  146. * all-empty row (bodies are never empty when stored), which the
  147. * page's empty state keys off.
  148. */
  149. public class UnderstandingRowView : Object {
  150. public int64 id { get; set; }
  151. public string body { get; set; default = ""; }
  152. public string commit_hash { get; set; default = ""; }
  153. public bool has_commit { get; set; }
  154. public int64 source_task_id { get; set; }
  155. public string created_display { get; set; default = ""; }
  156. }
  157. /** Public data of the `project` slot. */
  158. public class ProjectDetailPublic : Object {
  159. public bool exists { get; set; }
  160. public int64 id { get; set; }
  161. public string name { get; set; default = ""; }
  162. public string git_url { get; set; default = ""; }
  163. public string default_branch { get; set; default = ""; }
  164. public string summary { get; set; default = ""; }
  165. public string created_display { get; set; default = ""; }
  166. public Statum.Model.ActionDto? update { get; set; }
  167. public Statum.Model.ActionDto? add_link { get; set; }
  168. public Statum.Model.ActionDto? attach_rule { get; set; }
  169. public Statum.Model.ActionDto? apply_stack { get; set; }
  170. public Statum.Model.ActionDto? refresh_understanding { get; set; }
  171. public Statum.Model.ActionDto? delete { get; set; }
  172. /** True when the viewer may see the delete control (admin). */
  173. public bool is_admin { get; set; }
  174. /** What the delete would remove ("3 goals, 9 tasks"). */
  175. public string delete_counts { get; set; default = ""; }
  176. /** How many tasks are still live (the delete guard's refusal). */
  177. public int64 active_tasks { get; set; }
  178. }
  179. /**
  180. * Entrypoint for /projects/{project_id}: the project's editable card,
  181. * its co-developed/reference links (with remove actions), its rules
  182. * (with detach actions and the attach form), the other projects
  183. * available for linking, and its task list.
  184. */
  185. public class ProjectDetailEntrypoint : StatumEntrypoint {
  186. private ProjectService project_service = Inversion.inject<ProjectService>();
  187. private TaskService task_service = Inversion.inject<TaskService>();
  188. private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
  189. private RuleService rule_service = Inversion.inject<RuleService>();
  190. private StackService stack_service = Inversion.inject<StackService>();
  191. private GoalService goal_service = Inversion.inject<GoalService>();
  192. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  193. private UserService users = Inversion.inject<UserService>();
  194. public override async DirectiveBuilder handle() throws GLib.Error {
  195. var guard = yield SpryAuth.require_login(directives(), request, users);
  196. if (guard != null) {
  197. return (!)guard;
  198. }
  199. int64 project_id = int64.parse(request.route("project_id") ?? "0");
  200. var project = yield project_service.get_project(project_id);
  201. if (project == null) {
  202. return directives().set_typed<ProjectDetailPublic>("project", Scope.PAGE,
  203. new ProjectDetailPublic() { exists = false });
  204. }
  205. var sealed = new ProjectActionPrivate() { project_id = project_id };
  206. // The admin-only delete: authored only for admins (the
  207. // dialog below stays hidden for everyone else), with the
  208. // counts the confirmation shows.
  209. bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
  210. string delete_counts = "";
  211. int64 active_tasks = 0;
  212. if (is_admin) {
  213. var counts = yield project_service.deletion_counts(project_id);
  214. if (counts != null) {
  215. delete_counts = @"$(plural(((!)counts).goal_count, "goal"))"
  216. + @", $(plural(((!)counts).task_count, "task"))";
  217. active_tasks = ((!)counts).active_task_count;
  218. }
  219. }
  220. var pub = new ProjectDetailPublic() {
  221. exists = true,
  222. id = ((!)project).id,
  223. name = ((!)project).name,
  224. git_url = ((!)project).git_url,
  225. default_branch = ((!)project).default_branch,
  226. summary = ((!)project).summary ?? "",
  227. created_display = format_stamp(((!)project).created_at),
  228. update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
  229. add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
  230. attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
  231. apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed),
  232. refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed),
  233. delete = is_admin
  234. ? action_registry.author_private<DeleteProjectAction, ProjectActionPrivate>(sealed)
  235. : null,
  236. is_admin = is_admin,
  237. delete_counts = delete_counts,
  238. active_tasks = active_tasks
  239. };
  240. var links = new Vector<ProjectLinkView>();
  241. foreach (var link in yield project_service.list_links(project_id)) {
  242. var linked = yield project_service.get_project(link.linked_project_id);
  243. if (linked == null) {
  244. continue;
  245. }
  246. var private_blob = new ProjectLinkActionPrivate() {
  247. project_id = project_id,
  248. link_id = link.id
  249. };
  250. links.add(new ProjectLinkView() {
  251. link_id = link.id,
  252. project_id = link.linked_project_id,
  253. name = ((!)linked).name,
  254. git_url = ((!)linked).git_url,
  255. relationship = link.relationship,
  256. co_developed = link.relationship == LINK_CO_DEVELOPED,
  257. remove = action_registry.author_private<RemoveProjectLinkAction, ProjectLinkActionPrivate>(private_blob)
  258. });
  259. }
  260. var linkable = new Vector<ProjectRowView>();
  261. foreach (var other in yield project_service.list_projects()) {
  262. if (other.id != project_id) {
  263. linkable.add(new ProjectRowView() {
  264. id = other.id,
  265. name = other.name,
  266. git_url = other.git_url,
  267. default_branch = other.default_branch,
  268. summary = other.summary ?? ""
  269. });
  270. }
  271. }
  272. var project_tasks = yield task_service.list_tasks(null, project_id);
  273. var task_display = yield task_service.display_map_for(project_tasks);
  274. var task_rows = new Vector<ProjectTaskRowView>();
  275. foreach (var task in project_tasks) {
  276. // Goal-agent runs are internal — the goal page owns
  277. // their visibility.
  278. if (task.kind == TASK_KIND_GOAL_RUN) {
  279. continue;
  280. }
  281. var display = task_display.get_or_default(task.id.to_string());
  282. task_rows.add(new ProjectTaskRowView() {
  283. id = task.id,
  284. title = task.title,
  285. status = task.status,
  286. display = display != null ? ((!)display).status : task.status,
  287. priority = task.priority,
  288. });
  289. }
  290. var rule_rows = new Vector<ProjectRuleRowView>();
  291. foreach (var link in yield rule_service.list_project_attachments(project_id)) {
  292. var rule = yield rule_service.get_rule(link.rule_id);
  293. if (rule == null) {
  294. continue;
  295. }
  296. var private_blob = new ProjectRuleActionPrivate() {
  297. project_id = project_id,
  298. link_id = link.id
  299. };
  300. rule_rows.add(new ProjectRuleRowView() {
  301. link_id = link.id,
  302. rule_id = link.rule_id,
  303. name = ((!)rule).name,
  304. body = ((!)rule).body,
  305. detach = action_registry.author_private<DetachProjectRuleAction, ProjectRuleActionPrivate>(private_blob)
  306. });
  307. }
  308. var rule_options = new Vector<RuleOptionView>();
  309. foreach (var rule in yield rule_service.list_rules()) {
  310. rule_options.add(new RuleOptionView() {
  311. id = rule.id,
  312. name = rule.name
  313. });
  314. }
  315. // Goal 41 T9: the goals reverse link (ux-notes navigation).
  316. // The project's own goals lead; goals linked as co-developed
  317. // follow with their relationship, so the page answers "what
  318. // is being built here" without a detour through /goals.
  319. // Reference links are deliberately omitted — a reference
  320. // repo is context, not work happening in this project.
  321. var goal_rows = new Vector<ProjectGoalRowView>();
  322. var seen_goals = new Invercargill.DataStructures.HashSet<string>();
  323. foreach (var goal in yield goal_service.list_goals()) {
  324. if (goal.project_id == project_id) {
  325. seen_goals.add(goal.id.to_string());
  326. goal_rows.add(new ProjectGoalRowView() {
  327. id = goal.id,
  328. title = goal.title,
  329. status = goal.status,
  330. display = goal.status.replace("_", " "),
  331. relationship = "primary",
  332. branch_name = goal.branch_name
  333. });
  334. }
  335. }
  336. foreach (var link in yield db.query<GoalLink>()
  337. .where(expr("project_id == $0", elem<int64?>(project_id)))
  338. .materialise_async()) {
  339. if (link.relationship != "co-developed"
  340. || seen_goals.contains(link.goal_id.to_string())) {
  341. continue;
  342. }
  343. var goal = yield goal_service.get_goal(link.goal_id);
  344. if (goal == null) {
  345. continue;
  346. }
  347. goal_rows.add(new ProjectGoalRowView() {
  348. id = ((!)goal).id,
  349. title = ((!)goal).title,
  350. status = ((!)goal).status,
  351. display = ((!)goal).status.replace("_", " "),
  352. relationship = link.relationship,
  353. branch_name = ((!)goal).branch_name
  354. });
  355. }
  356. var note_rows = new Vector<ProjectNoteRowView>();
  357. foreach (var note in yield project_service.list_notes(project_id)) {
  358. var note_private = new ProjectNoteActionPrivate() {
  359. project_id = project_id,
  360. note_id = note.id
  361. };
  362. note_rows.add(new ProjectNoteRowView() {
  363. id = note.id,
  364. project_name = ((!)project).name,
  365. source_task_id = note.source_task_id,
  366. body = note.body,
  367. created_display = format_stamp(note.created_at),
  368. delete = action_registry.author_private<DeleteProjectNoteAction, ProjectNoteActionPrivate>(note_private)
  369. });
  370. }
  371. // The understanding ledger read: the current (newest)
  372. // revision renders above; the history below it lists the
  373. // OLDER revisions newest-first, so the current row is not
  374. // shown twice. No understanding yet is an empty row — the
  375. // page's empty state keys off it.
  376. var understanding_rows = new Vector<UnderstandingRowView>();
  377. bool first_understanding = true;
  378. foreach (var revision in yield understanding_service.history(project_id)) {
  379. if (first_understanding) {
  380. first_understanding = false;
  381. continue;
  382. }
  383. understanding_rows.add(new UnderstandingRowView() {
  384. id = revision.id,
  385. body = revision.body,
  386. commit_hash = revision.commit_hash,
  387. has_commit = revision.commit_hash.length > 0,
  388. source_task_id = revision.task_id,
  389. created_display = format_stamp(revision.created_at)
  390. });
  391. }
  392. var current_understanding_row = new UnderstandingRowView();
  393. var current_revision = yield understanding_service.current_understanding(project_id);
  394. if (current_revision != null) {
  395. current_understanding_row = new UnderstandingRowView() {
  396. id = ((!)current_revision).id,
  397. body = ((!)current_revision).body,
  398. commit_hash = ((!)current_revision).commit_hash,
  399. has_commit = ((!)current_revision).commit_hash.length > 0,
  400. source_task_id = ((!)current_revision).task_id,
  401. created_display = format_stamp(((!)current_revision).created_at)
  402. };
  403. }
  404. var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  405. props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
  406. props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
  407. props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
  408. props["goals"] = StateJson.list<ProjectGoalRowView>(goal_rows.to_immutable_buffer());
  409. props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
  410. props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
  411. props["understanding_current"] = StateJson.element(current_understanding_row);
  412. props["understanding_history"] = StateJson.list<UnderstandingRowView>(understanding_rows.to_immutable_buffer());
  413. var stack_options = new Vector<StackOptionView>();
  414. foreach (var stack in yield stack_service.list_stacks()) {
  415. stack_options.add(new StackOptionView() {
  416. id = stack.id,
  417. name = stack.name,
  418. member_count = (yield stack_service.list_members(stack.id)).length
  419. });
  420. }
  421. props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
  422. props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
  423. // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
  424. // <name>) — the shared partial renders it, replacing the
  425. // hand-drawn "All projects" anchor.
  426. var crumbs = new Vector<CrumbView>();
  427. crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
  428. crumbs.add(new CrumbView() { href = "/projects", label = "Projects" });
  429. crumbs.add(new CrumbView() { label = ((!)project).name });
  430. props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
  431. var slot = state_service.new_slot(Scope.PAGE, new State() {
  432. type_name = "project",
  433. public_data = props,
  434. private_data = new PropertyDictionary()
  435. });
  436. return directives().set(slot);
  437. }
  438. }
  439. }