| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473 |
- using Invercargill;
- using Invercargill.DataStructures;
- using Invercargill.Expressions;
- using Spry;
- using Spry.Authentication;
- using Statum;
- using ClankerSaddle.Data;
- using ClankerSaddle.Services;
- namespace ClankerSaddle {
- /** A project row on the projects list, with task counts and a link to its page. */
- public class ProjectRowView : Object {
- public int64 id { get; set; }
- public string name { get; set; default = ""; }
- public string git_url { get; set; default = ""; }
- public string default_branch { get; set; default = ""; }
- public string summary { get; set; default = ""; }
- public int64 open_tasks { get; set; }
- public int64 done_tasks { get; set; }
- }
- /** Public data of the `projects` slot. */
- public class ProjectsPublic : Object {
- public Statum.Model.ActionDto create { get; set; }
- }
- /**
- * Entrypoint for /projects: lists every project with open/done task
- * counts and authors the create-project action (login required).
- */
- public class ProjectsEntrypoint : StatumEntrypoint {
- private ProjectService project_service = Inversion.inject<ProjectService>();
- private TaskService task_service = Inversion.inject<TaskService>();
- private UserService users = Inversion.inject<UserService>();
- public override async DirectiveBuilder handle() throws GLib.Error {
- var guard = yield SpryAuth.require_login(directives(), request, users);
- if (guard != null) {
- return (!)guard;
- }
- var rows = new Vector<ProjectRowView>();
- var tasks = yield task_service.list_tasks(null, null);
- foreach (var project in yield project_service.list_projects()) {
- var view = new ProjectRowView() {
- id = project.id,
- name = project.name,
- git_url = project.git_url,
- default_branch = project.default_branch,
- summary = project.summary ?? ""
- };
- foreach (var task in tasks) {
- if (task.project_id != project.id) {
- continue;
- }
- // Goal-agent runs are internal tasks — they never
- // count against the project's task tallies.
- if (task.kind == TASK_KIND_GOAL_RUN) {
- continue;
- }
- if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
- view.done_tasks++;
- } else {
- view.open_tasks++;
- }
- }
- rows.add(view);
- }
- var pub = new ProjectsPublic();
- pub.create = action_registry.author<CreateProjectAction>();
- var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
- props["projects"] = StateJson.list<ProjectRowView>(rows.to_immutable_buffer());
- var slot = state_service.new_slot(Scope.PAGE, new State() {
- type_name = "projects",
- public_data = props,
- private_data = new PropertyDictionary()
- });
- return directives().set(slot);
- }
- }
- /** A linked project on the project detail page, with a remove action. */
- public class ProjectLinkView : Object {
- public int64 link_id { get; set; }
- public int64 project_id { get; set; }
- public string name { get; set; default = ""; }
- public string git_url { get; set; default = ""; }
- public string relationship { get; set; default = ""; }
- public bool co_developed { get; set; }
- public Statum.Model.ActionDto? remove { get; set; }
- }
- /** A task row on the project detail page. */
- public class ProjectTaskRowView : Object {
- public int64 id { get; set; }
- public string title { get; set; default = ""; }
- public string status { get; set; default = ""; }
- public string display { get; set; default = ""; }
- public int64 priority { get; set; }
- }
- /**
- * A goal row on the project detail page (goal 41 T9): the missing
- * reverse cross-link from ux-notes — the project's own goals plus
- * the ones linked to it as co-developed, so "what is being built
- * here" is answerable from the project page itself.
- */
- public class ProjectGoalRowView : Object {
- public int64 id { get; set; }
- public string title { get; set; default = ""; }
- public string status { get; set; default = ""; }
- /** The status with underscores replaced (matches goal pages). */
- public string display { get; set; default = ""; }
- /** "primary" for the project's own goals, else the link's relation. */
- public string relationship { get; set; default = ""; }
- public string branch_name { get; set; default = ""; }
- }
- /** A rule attached to the project, with its detach action. */
- public class ProjectRuleRowView : Object {
- public int64 link_id { get; set; }
- public int64 rule_id { get; set; }
- public string name { get; set; default = ""; }
- public string body { get; set; default = ""; }
- public Statum.Model.ActionDto? detach { get; set; }
- }
- /** A stack option in the apply select (project and goal pages). */
- public class StackOptionView : Object {
- public int64 id { get; set; }
- public string name { get; set; default = ""; }
- public int64 member_count { get; set; }
- }
- /** A rule option in the attach selects (project and task pages). */
- public class RuleOptionView : Object {
- public int64 id { get; set; }
- public string name { get; set; default = ""; }
- }
- /**
- * A project note on the project page: LLM-authored via `saddle
- * note`, with its date, source task link and a delete action (no
- * manual create — notes are user-curated, not user-written).
- */
- public class ProjectNoteRowView : Object {
- public int64 id { get; set; }
- public string project_name { get; set; default = ""; }
- public int64 source_task_id { get; set; }
- public string body { get; set; default = ""; }
- public string created_display { get; set; default = ""; }
- public Statum.Model.ActionDto? delete { get; set; }
- }
- /**
- * One understanding revision on the project page. The current row
- * is rendered above; the history rows below it are the OLDER
- * revisions (newest first). An absent current understanding is an
- * all-empty row (bodies are never empty when stored), which the
- * page's empty state keys off.
- */
- public class UnderstandingRowView : Object {
- public int64 id { get; set; }
- public string body { get; set; default = ""; }
- public string commit_hash { get; set; default = ""; }
- public bool has_commit { get; set; }
- public int64 source_task_id { get; set; }
- public string created_display { get; set; default = ""; }
- }
- /** Public data of the `project` slot. */
- public class ProjectDetailPublic : Object {
- public bool exists { get; set; }
- public int64 id { get; set; }
- public string name { get; set; default = ""; }
- public string git_url { get; set; default = ""; }
- public string default_branch { get; set; default = ""; }
- public string summary { get; set; default = ""; }
- public string created_display { get; set; default = ""; }
- public Statum.Model.ActionDto? update { get; set; }
- public Statum.Model.ActionDto? add_link { get; set; }
- public Statum.Model.ActionDto? attach_rule { get; set; }
- public Statum.Model.ActionDto? apply_stack { get; set; }
- public Statum.Model.ActionDto? refresh_understanding { get; set; }
- public Statum.Model.ActionDto? delete { get; set; }
- /** True when the viewer may see the delete control (admin). */
- public bool is_admin { get; set; }
- /** What the delete would remove ("3 goals, 9 tasks"). */
- public string delete_counts { get; set; default = ""; }
- /** How many tasks are still live (the delete guard's refusal). */
- public int64 active_tasks { get; set; }
- }
- /**
- * Entrypoint for /projects/{project_id}: the project's editable card,
- * its co-developed/reference links (with remove actions), its rules
- * (with detach actions and the attach form), the other projects
- * available for linking, and its task list.
- */
- public class ProjectDetailEntrypoint : StatumEntrypoint {
- private ProjectService project_service = Inversion.inject<ProjectService>();
- private TaskService task_service = Inversion.inject<TaskService>();
- private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
- private RuleService rule_service = Inversion.inject<RuleService>();
- private StackService stack_service = Inversion.inject<StackService>();
- private GoalService goal_service = Inversion.inject<GoalService>();
- private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
- private UserService users = Inversion.inject<UserService>();
- public override async DirectiveBuilder handle() throws GLib.Error {
- var guard = yield SpryAuth.require_login(directives(), request, users);
- if (guard != null) {
- return (!)guard;
- }
- int64 project_id = int64.parse(request.route("project_id") ?? "0");
- var project = yield project_service.get_project(project_id);
- if (project == null) {
- return directives().set_typed<ProjectDetailPublic>("project", Scope.PAGE,
- new ProjectDetailPublic() { exists = false });
- }
- var sealed = new ProjectActionPrivate() { project_id = project_id };
- // The admin-only delete: authored only for admins (the
- // dialog below stays hidden for everyone else), with the
- // counts the confirmation shows.
- bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
- string delete_counts = "";
- int64 active_tasks = 0;
- if (is_admin) {
- var counts = yield project_service.deletion_counts(project_id);
- if (counts != null) {
- delete_counts = @"$(plural(((!)counts).goal_count, "goal"))"
- + @", $(plural(((!)counts).task_count, "task"))";
- active_tasks = ((!)counts).active_task_count;
- }
- }
- var pub = new ProjectDetailPublic() {
- exists = true,
- id = ((!)project).id,
- name = ((!)project).name,
- git_url = ((!)project).git_url,
- default_branch = ((!)project).default_branch,
- summary = ((!)project).summary ?? "",
- created_display = format_stamp(((!)project).created_at),
- update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
- add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
- attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
- apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed),
- refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed),
- delete = is_admin
- ? action_registry.author_private<DeleteProjectAction, ProjectActionPrivate>(sealed)
- : null,
- is_admin = is_admin,
- delete_counts = delete_counts,
- active_tasks = active_tasks
- };
- var links = new Vector<ProjectLinkView>();
- foreach (var link in yield project_service.list_links(project_id)) {
- var linked = yield project_service.get_project(link.linked_project_id);
- if (linked == null) {
- continue;
- }
- var private_blob = new ProjectLinkActionPrivate() {
- project_id = project_id,
- link_id = link.id
- };
- links.add(new ProjectLinkView() {
- link_id = link.id,
- project_id = link.linked_project_id,
- name = ((!)linked).name,
- git_url = ((!)linked).git_url,
- relationship = link.relationship,
- co_developed = link.relationship == LINK_CO_DEVELOPED,
- remove = action_registry.author_private<RemoveProjectLinkAction, ProjectLinkActionPrivate>(private_blob)
- });
- }
- var linkable = new Vector<ProjectRowView>();
- foreach (var other in yield project_service.list_projects()) {
- if (other.id != project_id) {
- linkable.add(new ProjectRowView() {
- id = other.id,
- name = other.name,
- git_url = other.git_url,
- default_branch = other.default_branch,
- summary = other.summary ?? ""
- });
- }
- }
- var project_tasks = yield task_service.list_tasks(null, project_id);
- var task_display = yield task_service.display_map_for(project_tasks);
- var task_rows = new Vector<ProjectTaskRowView>();
- foreach (var task in project_tasks) {
- // Goal-agent runs are internal — the goal page owns
- // their visibility.
- if (task.kind == TASK_KIND_GOAL_RUN) {
- continue;
- }
- var display = task_display.get_or_default(task.id.to_string());
- task_rows.add(new ProjectTaskRowView() {
- id = task.id,
- title = task.title,
- status = task.status,
- display = display != null ? ((!)display).status : task.status,
- priority = task.priority,
- });
- }
- var rule_rows = new Vector<ProjectRuleRowView>();
- foreach (var link in yield rule_service.list_project_attachments(project_id)) {
- var rule = yield rule_service.get_rule(link.rule_id);
- if (rule == null) {
- continue;
- }
- var private_blob = new ProjectRuleActionPrivate() {
- project_id = project_id,
- link_id = link.id
- };
- rule_rows.add(new ProjectRuleRowView() {
- link_id = link.id,
- rule_id = link.rule_id,
- name = ((!)rule).name,
- body = ((!)rule).body,
- detach = action_registry.author_private<DetachProjectRuleAction, ProjectRuleActionPrivate>(private_blob)
- });
- }
- var rule_options = new Vector<RuleOptionView>();
- foreach (var rule in yield rule_service.list_rules()) {
- rule_options.add(new RuleOptionView() {
- id = rule.id,
- name = rule.name
- });
- }
- // Goal 41 T9: the goals reverse link (ux-notes navigation).
- // The project's own goals lead; goals linked as co-developed
- // follow with their relationship, so the page answers "what
- // is being built here" without a detour through /goals.
- // Reference links are deliberately omitted — a reference
- // repo is context, not work happening in this project.
- var goal_rows = new Vector<ProjectGoalRowView>();
- var seen_goals = new Invercargill.DataStructures.HashSet<string>();
- foreach (var goal in yield goal_service.list_goals()) {
- if (goal.project_id == project_id) {
- seen_goals.add(goal.id.to_string());
- goal_rows.add(new ProjectGoalRowView() {
- id = goal.id,
- title = goal.title,
- status = goal.status,
- display = goal.status.replace("_", " "),
- relationship = "primary",
- branch_name = goal.branch_name
- });
- }
- }
- foreach (var link in yield db.query<GoalLink>()
- .where(expr("project_id == $0", elem<int64?>(project_id)))
- .materialise_async()) {
- if (link.relationship != "co-developed"
- || seen_goals.contains(link.goal_id.to_string())) {
- continue;
- }
- var goal = yield goal_service.get_goal(link.goal_id);
- if (goal == null) {
- continue;
- }
- goal_rows.add(new ProjectGoalRowView() {
- id = ((!)goal).id,
- title = ((!)goal).title,
- status = ((!)goal).status,
- display = ((!)goal).status.replace("_", " "),
- relationship = link.relationship,
- branch_name = ((!)goal).branch_name
- });
- }
- var note_rows = new Vector<ProjectNoteRowView>();
- foreach (var note in yield project_service.list_notes(project_id)) {
- var note_private = new ProjectNoteActionPrivate() {
- project_id = project_id,
- note_id = note.id
- };
- note_rows.add(new ProjectNoteRowView() {
- id = note.id,
- project_name = ((!)project).name,
- source_task_id = note.source_task_id,
- body = note.body,
- created_display = format_stamp(note.created_at),
- delete = action_registry.author_private<DeleteProjectNoteAction, ProjectNoteActionPrivate>(note_private)
- });
- }
- // The understanding ledger read: the current (newest)
- // revision renders above; the history below it lists the
- // OLDER revisions newest-first, so the current row is not
- // shown twice. No understanding yet is an empty row — the
- // page's empty state keys off it.
- var understanding_rows = new Vector<UnderstandingRowView>();
- bool first_understanding = true;
- foreach (var revision in yield understanding_service.history(project_id)) {
- if (first_understanding) {
- first_understanding = false;
- continue;
- }
- understanding_rows.add(new UnderstandingRowView() {
- id = revision.id,
- body = revision.body,
- commit_hash = revision.commit_hash,
- has_commit = revision.commit_hash.length > 0,
- source_task_id = revision.task_id,
- created_display = format_stamp(revision.created_at)
- });
- }
- var current_understanding_row = new UnderstandingRowView();
- var current_revision = yield understanding_service.current_understanding(project_id);
- if (current_revision != null) {
- current_understanding_row = new UnderstandingRowView() {
- id = ((!)current_revision).id,
- body = ((!)current_revision).body,
- commit_hash = ((!)current_revision).commit_hash,
- has_commit = ((!)current_revision).commit_hash.length > 0,
- source_task_id = ((!)current_revision).task_id,
- created_display = format_stamp(((!)current_revision).created_at)
- };
- }
- var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
- props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
- props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
- props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
- props["goals"] = StateJson.list<ProjectGoalRowView>(goal_rows.to_immutable_buffer());
- props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
- props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
- props["understanding_current"] = StateJson.element(current_understanding_row);
- props["understanding_history"] = StateJson.list<UnderstandingRowView>(understanding_rows.to_immutable_buffer());
- var stack_options = new Vector<StackOptionView>();
- foreach (var stack in yield stack_service.list_stacks()) {
- stack_options.add(new StackOptionView() {
- id = stack.id,
- name = stack.name,
- member_count = (yield stack_service.list_members(stack.id)).length
- });
- }
- props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
- props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
- // Goal 41 T9: the breadcrumb trail (Dashboard / Projects /
- // <name>) — the shared partial renders it, replacing the
- // hand-drawn "All projects" anchor.
- var crumbs = new Vector<CrumbView>();
- crumbs.add(new CrumbView() { href = "/", label = "Dashboard" });
- crumbs.add(new CrumbView() { href = "/projects", label = "Projects" });
- crumbs.add(new CrumbView() { label = ((!)project).name });
- props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
- var slot = state_service.new_slot(Scope.PAGE, new State() {
- type_name = "project",
- public_data = props,
- private_data = new PropertyDictionary()
- });
- return directives().set(slot);
- }
- }
- }
|