max / goingson
12 files changed,
+908 insertions,
-0 deletions
| @@ -2279,6 +2279,7 @@ dependencies = [ | |||
| 2279 | 2279 | "notify", | |
| 2280 | 2280 | "notify-debouncer-mini", | |
| 2281 | 2281 | "open", | |
| 2282 | + | "painhours", | |
| 2282 | 2283 | "pter", | |
| 2283 | 2284 | "rand 0.10.2", | |
| 2284 | 2285 | "reqwest", |
| @@ -18,6 +18,7 @@ makeover.workspace = true | |||
| 18 | 18 | ||
| 19 | 19 | [dependencies] | |
| 20 | 20 | goingson-core = { workspace = true } | |
| 21 | + | painhours = { workspace = true } | |
| 21 | 22 | goingson-db-sqlite = { workspace = true } | |
| 22 | 23 | synckit-client = { path = "../../../synckit/synckit-client" } | |
| 23 | 24 | synckit-config = { path = "../../../synckit/synckit-config" } |
| @@ -9876,3 +9876,120 @@ body.compose-window .compose-loading { | |||
| 9876 | 9876 | color: var(--content-secondary); | |
| 9877 | 9877 | opacity: 0.7; | |
| 9878 | 9878 | } | |
| 9879 | + | ||
| 9880 | + | /* Problems Inbox | |
| 9881 | + | Candidates pulled from external sources, ranked by painhours. The score leads | |
| 9882 | + | each row because it is the reason the row sits where it does. */ | |
| 9883 | + | ||
| 9884 | + | .pill-count { | |
| 9885 | + | display: inline-block; | |
| 9886 | + | min-width: 1.25rem; | |
| 9887 | + | padding: 0 0.3rem; | |
| 9888 | + | margin-left: 0.3rem; | |
| 9889 | + | border-radius: var(--radius-xl); | |
| 9890 | + | background: var(--category-one); | |
| 9891 | + | color: var(--content-on-action); | |
| 9892 | + | font-size: var(--font-size-xxs); | |
| 9893 | + | font-weight: 700; | |
| 9894 | + | line-height: 1.4; | |
| 9895 | + | text-align: center; | |
| 9896 | + | } | |
| 9897 | + | ||
| 9898 | + | /* On the active (inverted) pill the red badge loses contrast, so flip it to the | |
| 9899 | + | pill's own background. */ | |
| 9900 | + | .ui-mode-desktop .pill.active .pill-count { | |
| 9901 | + | background: var(--surface-raised); | |
| 9902 | + | color: var(--content); | |
| 9903 | + | } | |
| 9904 | + | ||
| 9905 | + | .view-hint { | |
| 9906 | + | margin: 0 0 var(--space-4); | |
| 9907 | + | font-size: var(--font-size-sm); | |
| 9908 | + | color: var(--content-secondary); | |
| 9909 | + | max-width: 70ch; | |
| 9910 | + | } | |
| 9911 | + | ||
| 9912 | + | .problems-list { | |
| 9913 | + | display: flex; | |
| 9914 | + | flex-direction: column; | |
| 9915 | + | gap: var(--space-2); | |
| 9916 | + | } | |
| 9917 | + | ||
| 9918 | + | .problem-row { | |
| 9919 | + | display: grid; | |
| 9920 | + | /* Fixed score gutter, elastic middle, actions sized to their buttons: the | |
| 9921 | + | score column must stay aligned down the list to be scannable as a ranking. */ | |
| 9922 | + | grid-template-columns: 3.5rem minmax(0, 1fr) auto; | |
| 9923 | + | align-items: start; | |
| 9924 | + | gap: var(--space-3); | |
| 9925 | + | padding: var(--space-3) var(--space-4); | |
| 9926 | + | border: var(--border-width-sm) solid var(--border); | |
| 9927 | + | border-radius: var(--radius-md); | |
| 9928 | + | background: var(--surface-raised); | |
| 9929 | + | box-shadow: var(--shadow-brutal-xs); | |
| 9930 | + | } | |
| 9931 | + | ||
| 9932 | + | /* Settled problems stay visible but recede: they are history, not queue. */ | |
| 9933 | + | .problem-row--settled { | |
| 9934 | + | opacity: 0.65; | |
| 9935 | + | box-shadow: none; | |
| 9936 | + | } | |
| 9937 | + | ||
| 9938 | + | .problem-score { | |
| 9939 | + | display: flex; | |
| 9940 | + | justify-content: center; | |
| 9941 | + | padding-top: 0.1rem; | |
| 9942 | + | } | |
| 9943 | + | ||
| 9944 | + | .problem-title { | |
| 9945 | + | font-weight: 600; | |
| 9946 | + | line-height: 1.35; | |
| 9947 | + | } | |
| 9948 | + | ||
| 9949 | + | .problem-body { | |
| 9950 | + | margin-top: var(--space-1); | |
| 9951 | + | font-size: var(--font-size-sm); | |
| 9952 | + | color: var(--content-secondary); | |
| 9953 | + | /* Findings bodies run long; two lines is enough to recognize one, and the | |
| 9954 | + | full text is in the task once promoted. */ | |
| 9955 | + | display: -webkit-box; | |
| 9956 | + | -webkit-line-clamp: 2; | |
| 9957 | + | -webkit-box-orient: vertical; | |
| 9958 | + | overflow: hidden; | |
| 9959 | + | } | |
| 9960 | + | ||
| 9961 | + | .problem-meta { | |
| 9962 | + | display: flex; | |
| 9963 | + | flex-wrap: wrap; | |
| 9964 | + | align-items: center; | |
| 9965 | + | gap: var(--space-1); | |
| 9966 | + | margin-top: var(--space-2); | |
| 9967 | + | } | |
| 9968 | + | ||
| 9969 | + | .problem-age { | |
| 9970 | + | font-size: var(--font-size-xs); | |
| 9971 | + | color: var(--content-muted); | |
| 9972 | + | } | |
| 9973 | + | ||
| 9974 | + | .problem-actions { | |
| 9975 | + | display: flex; | |
| 9976 | + | flex-wrap: wrap; | |
| 9977 | + | gap: var(--space-1); | |
| 9978 | + | justify-content: flex-end; | |
| 9979 | + | } | |
| 9980 | + | ||
| 9981 | + | .empty-state-text--muted { | |
| 9982 | + | color: var(--content-muted); | |
| 9983 | + | font-size: var(--font-size-sm); | |
| 9984 | + | max-width: 55ch; | |
| 9985 | + | } | |
| 9986 | + | ||
| 9987 | + | /* Mobile: the actions wrap under the text rather than fighting it for width. */ | |
| 9988 | + | .ui-mode-mobile .problem-row { | |
| 9989 | + | grid-template-columns: 3rem minmax(0, 1fr); | |
| 9990 | + | } | |
| 9991 | + | ||
| 9992 | + | .ui-mode-mobile .problem-actions { | |
| 9993 | + | grid-column: 1 / -1; | |
| 9994 | + | justify-content: flex-start; | |
| 9995 | + | } |
| @@ -66,6 +66,7 @@ | |||
| 66 | 66 | <div class="pill-nav" role="tablist" aria-label="Work views"> | |
| 67 | 67 | <button class="pill active" data-subview="tasks">Tasks</button> | |
| 68 | 68 | <button class="pill" data-subview="projects">Projects</button> | |
| 69 | + | <button class="pill" data-subview="problems" title="Candidates from wam and audit runs, ranked by painhours">Problems <span id="problems-count" class="pill-count hidden"></span></button> | |
| 69 | 70 | </div> | |
| 70 | 71 | ||
| 71 | 72 | <!-- Tasks Sub-View --> | |
| @@ -188,6 +189,32 @@ | |||
| 188 | 189 | </div> | |
| 189 | 190 | </div> | |
| 190 | 191 | ||
| 192 | + | <!-- Problems Sub-View --> | |
| 193 | + | <div id="problems-view" class="subview hidden" role="tabpanel" aria-labelledby="problems-tab"> | |
| 194 | + | <div class="page-header"> | |
| 195 | + | <h2 class="page-title">Problems</h2> | |
| 196 | + | <div class="page-header-actions"> | |
| 197 | + | <select id="problems-source-filter" class="form-select form-select--compact" aria-label="Filter by source" data-change="problems.setSourceFilter"> | |
| 198 | + | <option value="">All sources</option> | |
| 199 | + | </select> | |
| 200 | + | <select id="problems-status-filter" class="form-select form-select--compact" aria-label="Filter by status" data-change="problems.setStatusFilter"> | |
| 201 | + | <option value="Open" selected>Open</option> | |
| 202 | + | <option value="Promoted">Promoted</option> | |
| 203 | + | <option value="Dismissed">Dismissed</option> | |
| 204 | + | <option value="Resolved">Resolved</option> | |
| 205 | + | <option value="all">All</option> | |
| 206 | + | </select> | |
| 207 | + | </div> | |
| 208 | + | </div> | |
| 209 | + | <p class="view-hint">Candidates, not work. Ranked by painhours, so anything left untriaged climbs on its own. Promote the ones worth doing.</p> | |
| 210 | + | <div id="problems-list" class="problems-list"> | |
| 211 | + | <div class="skeleton-shimmer" aria-label="Loading problems"> | |
| 212 | + | <div class="skeleton-row"><div class="skeleton-lines"><div class="skeleton-line long"></div><div class="skeleton-line short"></div></div></div> | |
| 213 | + | <div class="skeleton-row"><div class="skeleton-lines"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div> | |
| 214 | + | </div> | |
| 215 | + | </div> | |
| 216 | + | </div> | |
| 217 | + | ||
| 191 | 218 | <!-- Project Dashboard Sub-View --> | |
| 192 | 219 | <div id="project-dashboard-view" class="subview hidden"> | |
| 193 | 220 | <div class="page-header"> | |
| @@ -653,6 +680,7 @@ | |||
| 653 | 680 | <script src="js/monthly-review-render.js"></script> | |
| 654 | 681 | <!-- Domains --> | |
| 655 | 682 | <script src="js/projects.js"></script> | |
| 683 | + | <script src="js/problems.js"></script> | |
| 656 | 684 | <script src="js/tasks-filter.js"></script> | |
| 657 | 685 | <script src="js/task-forms.js"></script> | |
| 658 | 686 | <script src="js/task-board.js"></script> |
| @@ -88,6 +88,16 @@ const api = { | |||
| 88 | 88 | removeMember: (groupId, userId) => invoke('group_remove_member', { groupId, userId }), | |
| 89 | 89 | }, | |
| 90 | 90 | ||
| 91 | + | // Problems: the triage inbox. Read + triage only — problems arrive from | |
| 92 | + | // adapters and go-mcp's report_problems, never created here. | |
| 93 | + | problems: { | |
| 94 | + | list: (filter) => invoke('list_problems', { filter }), // Ranked by painhours; defaults to Open | |
| 95 | + | countOpen: () => invoke('count_open_problems'), // Pill badge | |
| 96 | + | promote: (id, input) => invoke('promote_problem', { id, input }), // Creates the task + backlink | |
| 97 | + | setStatus: (id, status) => invoke('set_problem_status', { id, status }), | |
| 98 | + | setProject: (id, projectId) => invoke('set_problem_project', { id, projectId }), | |
| 99 | + | }, | |
| 100 | + | ||
| 91 | 101 | // Tasks: CRUD + lifecycle (start/complete) + snooze/waiting status | |
| 92 | 102 | tasks: { | |
| 93 | 103 | list: () => invoke('list_tasks'), |
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | const TAB_GROUPS = { | |
| 12 | 12 | 'tasks': 'work', | |
| 13 | 13 | 'projects': 'work', | |
| 14 | + | 'problems': 'work', | |
| 14 | 15 | 'project-dashboard': 'work', | |
| 15 | 16 | 'task-overview': 'work', | |
| 16 | 17 | 'day-plan': 'time', | |
| @@ -230,6 +231,7 @@ async function updateWindowTitle(view) { | |||
| 230 | 231 | async function loadViewData(view) { | |
| 231 | 232 | switch (view) { | |
| 232 | 233 | case 'projects': await GoingsOn.projects.load(); break; | |
| 234 | + | case 'problems': await GoingsOn.problems.load(); break; | |
| 233 | 235 | case 'tasks': await GoingsOn.tasks.load(); break; | |
| 234 | 236 | case 'events': await GoingsOn.events.load(); break; | |
| 235 | 237 | case 'emails': await GoingsOn.emails.load(); break; |
| @@ -0,0 +1,229 @@ | |||
| 1 | + | /** | |
| 2 | + | * @fileoverview Problems inbox — the triage surface between external sources | |
| 3 | + | * and the task list. | |
| 4 | + | * | |
| 5 | + | * GoingsOn is the list of solutions. A problem is a *candidate* pulled from | |
| 6 | + | * somewhere else (wam tickets, /audit and /fuzz findings) that becomes work only | |
| 7 | + | * when promoted. Nothing here creates a problem: they arrive from adapters and | |
| 8 | + | * from go-mcp's `report_problems`, because a hand-typed problem would have no | |
| 9 | + | * source to reconcile against on the next pull. | |
| 10 | + | * | |
| 11 | + | * Rows are ranked by painhours, a 0-100 score computed from pain, scale, and | |
| 12 | + | * age. It is not stored, so an untriaged problem climbs on its own between one | |
| 13 | + | * visit and the next — that is the anti-starvation mechanism, and it is why the | |
| 14 | + | * list is worth revisiting rather than draining once. | |
| 15 | + | */ | |
| 16 | + | ||
| 17 | + | (function () { | |
| 18 | + | 'use strict'; | |
| 19 | + | const esc = GoingsOn.utils.escapeHtml; | |
| 20 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 21 | + | ||
| 22 | + | /** Current filter state, kept in the module rather than the URL: the inbox | |
| 23 | + | * is a working view, not a place you deep-link into. */ | |
| 24 | + | let filters = { status: 'Open', source: null }; | |
| 25 | + | ||
| 26 | + | /** Last loaded rows, so an action can act without a refetch. */ | |
| 27 | + | let rows = []; | |
| 28 | + | ||
| 29 | + | /** Map a painhours band onto the shared badge palette. */ | |
| 30 | + | function bandColor(band) { | |
| 31 | + | switch (band) { | |
| 32 | + | case 'critical': return 'red'; | |
| 33 | + | case 'high': return 'yellow'; | |
| 34 | + | case 'medium': return 'blue'; | |
| 35 | + | default: return 'muted'; | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | function statusColor(status) { | |
| 40 | + | switch (status) { | |
| 41 | + | case 'Promoted': return 'green'; | |
| 42 | + | case 'Dismissed': return 'muted'; | |
| 43 | + | case 'Resolved': return 'cyan'; | |
| 44 | + | default: return 'purple'; | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * Load the inbox and render it. | |
| 50 | + | */ | |
| 51 | + | async function load() { | |
| 52 | + | const grid = document.getElementById('problems-list'); | |
| 53 | + | if (!grid) return; | |
| 54 | + | ||
| 55 | + | try { | |
| 56 | + | rows = await GoingsOn.api.problems.list({ | |
| 57 | + | status: filters.status, | |
| 58 | + | source: filters.source, | |
| 59 | + | }); | |
| 60 | + | } catch (err) { | |
| 61 | + | console.error('[problems] load failed:', err); | |
| 62 | + | grid.innerHTML = `<div class="empty-state"><p class="empty-state-text">Could not load problems.</p></div>`; | |
| 63 | + | return; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | render(grid); | |
| 67 | + | renderSourceFilter(); | |
| 68 | + | await refreshCount(); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /** | |
| 72 | + | * Refresh the count badge on the Problems pill. | |
| 73 | + | * | |
| 74 | + | * Always the Open count regardless of the current filter: the badge answers | |
| 75 | + | * "is there anything waiting for me", which does not change because you are | |
| 76 | + | * currently looking at dismissed items. | |
| 77 | + | */ | |
| 78 | + | async function refreshCount() { | |
| 79 | + | const badge = document.getElementById('problems-count'); | |
| 80 | + | if (!badge) return; | |
| 81 | + | try { | |
| 82 | + | const n = await GoingsOn.api.problems.countOpen(); | |
| 83 | + | badge.textContent = n > 0 ? String(n) : ''; | |
| 84 | + | badge.classList.toggle('hidden', n === 0); | |
| 85 | + | } catch (err) { | |
| 86 | + | console.error('[problems] count failed:', err); | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | function render(grid) { | |
| 91 | + | if (!rows.length) { | |
| 92 | + | const what = filters.status === 'Open' | |
| 93 | + | ? 'Nothing waiting for triage.' | |
| 94 | + | : `No ${filters.status.toLowerCase()} problems.`; | |
| 95 | + | grid.innerHTML = `<div class="empty-state">` | |
| 96 | + | + `<p class="empty-state-text">${esc(what)}</p>` | |
| 97 | + | + `<p class="empty-state-text empty-state-text--muted">Problems arrive from wam and from audit runs. They are candidates: promote one to make it a task.</p>` | |
| 98 | + | + `</div>`; | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | grid.innerHTML = rows.map(rowHtml).join(''); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | function rowHtml(p) { | |
| 106 | + | const settled = p.status !== 'Open'; | |
| 107 | + | const tags = (p.tags || []).map(t => | |
| 108 | + | `<span class="tag badge--xs" data-color="muted">${esc(t)}</span>`).join(''); | |
| 109 | + | ||
| 110 | + | // The score leads the row: it is the reason this problem is where it is | |
| 111 | + | // in the list, so it should be readable before the title. | |
| 112 | + | return ` | |
| 113 | + | <div class="problem-row${settled ? ' problem-row--settled' : ''}" data-problem-id="${escAttr(p.id)}"> | |
| 114 | + | <div class="problem-score"> | |
| 115 | + | <span class="badge badge--filled" data-color="${bandColor(p.band)}" title="painhours: pain ${p.pain} x scale ${p.scale}, aged ${esc(p.age)}">${p.painhours}</span> | |
| 116 | + | </div> | |
| 117 | + | <div class="problem-main"> | |
| 118 | + | <div class="problem-title">${esc(p.title)}</div> | |
| 119 | + | ${p.body ? `<div class="problem-body">${esc(p.body)}</div>` : ''} | |
| 120 | + | <div class="problem-meta"> | |
| 121 | + | <span class="badge badge--xs" data-color="cyan">${esc(p.source)}</span> | |
| 122 | + | ${p.projectName ? `<span class="badge badge--xs" data-color="blue">${esc(p.projectName)}</span>` : ''} | |
| 123 | + | ${settled ? `<span class="badge badge--xs" data-color="${statusColor(p.status)}">${esc(p.status)}</span>` : ''} | |
| 124 | + | <span class="problem-age" title="${escAttr(p.sourceRef)}">${esc(p.age)}</span> | |
| 125 | + | ${tags} | |
| 126 | + | </div> | |
| 127 | + | </div> | |
| 128 | + | <div class="problem-actions"> | |
| 129 | + | ${actionsHtml(p)} | |
| 130 | + | </div> | |
| 131 | + | </div>`; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | function actionsHtml(p) { | |
| 135 | + | if (p.status === 'Promoted') { | |
| 136 | + | return `<button class="btn btn-sm btn-secondary" data-act="problems.openTask" data-a1="${escAttr(p.promotedTaskId)}">Open task</button>` | |
| 137 | + | + `<button class="btn btn-sm btn-secondary" data-act="problems.reopen" data-a1="${escAttr(p.id)}" title="Send back to triage and unlink the task">Reopen</button>`; | |
| 138 | + | } | |
| 139 | + | if (p.status === 'Open') { | |
| 140 | + | return `<button class="btn btn-sm btn-primary" data-act="problems.promote" data-a1="${escAttr(p.id)}" title="Create a task from this problem">Promote</button>` | |
| 141 | + | + `<button class="btn btn-sm btn-secondary" data-act="problems.dismiss" data-a1="${escAttr(p.id)}" title="Seen it, not acting. Survives the next pull.">Dismiss</button>`; | |
| 142 | + | } | |
| 143 | + | // Dismissed or Resolved: the only move left is putting it back. | |
| 144 | + | return `<button class="btn btn-sm btn-secondary" data-act="problems.reopen" data-a1="${escAttr(p.id)}">Reopen</button>`; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /** Populate the source filter from what is actually present. */ | |
| 148 | + | function renderSourceFilter() { | |
| 149 | + | const sel = document.getElementById('problems-source-filter'); | |
| 150 | + | if (!sel) return; | |
| 151 | + | const sources = [...new Set(rows.map(r => r.source))].sort(); | |
| 152 | + | // Keep the current selection even when this page has none of that | |
| 153 | + | // source, so filtering to an empty result doesn't silently reset. | |
| 154 | + | if (filters.source && !sources.includes(filters.source)) sources.push(filters.source); | |
| 155 | + | sel.innerHTML = `<option value="">All sources</option>` | |
| 156 | + | + sources.map(s => `<option value="${escAttr(s)}"${s === filters.source ? ' selected' : ''}>${esc(s)}</option>`).join(''); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Actions | |
| 160 | + | ||
| 161 | + | async function setStatusFilter(status) { | |
| 162 | + | filters.status = status || 'Open'; | |
| 163 | + | await load(); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | async function setSourceFilter(source) { | |
| 167 | + | filters.source = source || null; | |
| 168 | + | await load(); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /** | |
| 172 | + | * Promote a problem into a task. | |
| 173 | + | * | |
| 174 | + | * Deliberately one click with no modal: the description defaults to the | |
| 175 | + | * problem's own text and the priority to its painhours band, both of which | |
| 176 | + | * are better defaults than anything retyped at triage time. Edit the task | |
| 177 | + | * afterwards if it needs shaping. | |
| 178 | + | */ | |
| 179 | + | async function promote(id) { | |
| 180 | + | try { | |
| 181 | + | const res = await GoingsOn.api.problems.promote(id); | |
| 182 | + | GoingsOn.ui.showToast( | |
| 183 | + | res.created ? 'Promoted to a task.' : 'Already promoted; opening the existing task.', | |
| 184 | + | 'success', | |
| 185 | + | ); | |
| 186 | + | await load(); | |
| 187 | + | } catch (err) { | |
| 188 | + | console.error('[problems] promote failed:', err); | |
| 189 | + | GoingsOn.ui.showToast('Could not promote this problem.', 'error'); | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | async function dismiss(id) { | |
| 194 | + | await setStatus(id, 'Dismissed', 'Dismissed. It stays down through the next pull.'); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | async function reopen(id) { | |
| 198 | + | await setStatus(id, 'Open', 'Back in triage.'); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | async function setStatus(id, status, message) { | |
| 202 | + | try { | |
| 203 | + | await GoingsOn.api.problems.setStatus(id, status); | |
| 204 | + | GoingsOn.ui.showToast(message, 'success'); | |
| 205 | + | await load(); | |
| 206 | + | } catch (err) { | |
| 207 | + | console.error('[problems] status change failed:', err); | |
| 208 | + | GoingsOn.ui.showToast('Could not update this problem.', 'error'); | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /** Jump to the task a problem was promoted into. */ | |
| 213 | + | function openTask(taskId) { | |
| 214 | + | if (!taskId) return; | |
| 215 | + | GoingsOn.navigation.switchView('tasks'); | |
| 216 | + | if (GoingsOn.tasks?.openSubtasks) GoingsOn.tasks.openSubtasks(taskId); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | GoingsOn.problems = { | |
| 220 | + | load, | |
| 221 | + | refreshCount, | |
| 222 | + | setStatusFilter, | |
| 223 | + | setSourceFilter, | |
| 224 | + | promote, | |
| 225 | + | dismiss, | |
| 226 | + | reopen, | |
| 227 | + | openTask, | |
| 228 | + | }; | |
| 229 | + | })(); |
| @@ -35,6 +35,7 @@ mod milestone; | |||
| 35 | 35 | mod monthly_review; | |
| 36 | 36 | mod oauth; | |
| 37 | 37 | mod preferences; | |
| 38 | + | mod problem; | |
| 38 | 39 | mod project; | |
| 39 | 40 | mod saved_views; | |
| 40 | 41 | mod search; | |
| @@ -133,6 +134,7 @@ pub use monthly_review::*; | |||
| 133 | 134 | pub use oauth::*; | |
| 134 | 135 | pub use preferences::load as load_preferences; | |
| 135 | 136 | pub use preferences::*; | |
| 137 | + | pub use problem::*; | |
| 136 | 138 | pub use project::*; | |
| 137 | 139 | pub use saved_views::*; | |
| 138 | 140 | pub use search::*; |
| @@ -0,0 +1,318 @@ | |||
| 1 | + | //! Problems inbox commands. | |
| 2 | + | //! | |
| 3 | + | //! A problem is a candidate for work pulled from an external source (wam | |
| 4 | + | //! tickets, `/audit` and `/fuzz` findings). GoingsOn is the list of solutions; | |
| 5 | + | //! these commands are the triage surface that turns a candidate into one. | |
| 6 | + | //! | |
| 7 | + | //! There is deliberately no `create_problem` here. Problems arrive from | |
| 8 | + | //! adapters and from go-mcp's `report_problems`, never by hand in the UI: a | |
| 9 | + | //! problem the user typed would have no source to reconcile against, so a | |
| 10 | + | //! re-pull could not update or settle it. | |
| 11 | + | ||
| 12 | + | use serde::{Deserialize, Serialize}; | |
| 13 | + | use std::sync::Arc; | |
| 14 | + | use tauri::State; | |
| 15 | + | use tracing::instrument; | |
| 16 | + | ||
| 17 | + | use goingson_core::{ | |
| 18 | + | DbValue, NewTask, Priority, Problem, ProblemFilter, ProblemId, ProblemStatus, ProjectId, TaskId, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | use super::{ApiError, OptionNotFound}; | |
| 22 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 23 | + | ||
| 24 | + | // Types | |
| 25 | + | ||
| 26 | + | #[derive(Debug, Default, Deserialize)] | |
| 27 | + | #[serde(rename_all = "camelCase")] | |
| 28 | + | pub struct ProblemFilterInput { | |
| 29 | + | pub source: Option<String>, | |
| 30 | + | /// `Open` | `Promoted` | `Dismissed` | `Resolved`, or `all` for every state. | |
| 31 | + | /// Absent means Open, since the inbox question is what is untriaged. | |
| 32 | + | pub status: Option<String>, | |
| 33 | + | pub project_id: Option<ProjectId>, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[derive(Debug, Serialize)] | |
| 37 | + | #[serde(rename_all = "camelCase")] | |
| 38 | + | pub struct ProblemResponse { | |
| 39 | + | pub id: ProblemId, | |
| 40 | + | pub source: String, | |
| 41 | + | pub source_ref: String, | |
| 42 | + | pub title: String, | |
| 43 | + | pub body: String, | |
| 44 | + | pub pain: u8, | |
| 45 | + | pub scale: u8, | |
| 46 | + | /// The 0-100 urgency score. Computed on read, never stored: an untriaged | |
| 47 | + | /// problem climbs on its own, so this moves between one fetch and the next. | |
| 48 | + | pub painhours: u32, | |
| 49 | + | /// Color band derived from the score: low | medium | high | critical. | |
| 50 | + | pub band: String, | |
| 51 | + | /// Human-readable age, e.g. `3d`. | |
| 52 | + | pub age: String, | |
| 53 | + | pub status: String, | |
| 54 | + | pub project_id: Option<ProjectId>, | |
| 55 | + | pub project_name: Option<String>, | |
| 56 | + | pub tags: Vec<String>, | |
| 57 | + | pub promoted_task_id: Option<TaskId>, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[derive(Debug, Deserialize)] | |
| 61 | + | #[serde(rename_all = "camelCase")] | |
| 62 | + | pub struct PromoteProblemInput { | |
| 63 | + | /// Task description. Defaults to the problem's title, with its body | |
| 64 | + | /// appended when there is one. | |
| 65 | + | pub description: Option<String>, | |
| 66 | + | /// `High` | `Medium` | `Low`. Defaults from the painhours band. | |
| 67 | + | pub priority: Option<String>, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[derive(Debug, Serialize)] | |
| 71 | + | #[serde(rename_all = "camelCase")] | |
| 72 | + | pub struct PromoteProblemResponse { | |
| 73 | + | pub task_id: TaskId, | |
| 74 | + | pub problem: ProblemResponse, | |
| 75 | + | /// False when the problem was already promoted, in which case `task_id` is | |
| 76 | + | /// the task it already has. | |
| 77 | + | pub created: bool, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Project the domain type onto the wire shape, resolving the project name the | |
| 81 | + | /// frontend cannot compute for itself. | |
| 82 | + | /// | |
| 83 | + | /// `Problem::is_stale` is deliberately not surfaced yet: with no pull adapter, | |
| 84 | + | /// every problem's `last_seen_at` equals its source's newest, so the flag would | |
| 85 | + | /// always be false. It belongs here once the wam adapter lands. | |
| 86 | + | fn to_response(p: Problem, project_name: Option<String>) -> ProblemResponse { | |
| 87 | + | ProblemResponse { | |
| 88 | + | painhours: p.painhours(), | |
| 89 | + | band: p.band().to_string(), | |
| 90 | + | age: p.age(), | |
| 91 | + | status: p.status.db_value().to_string(), | |
| 92 | + | id: p.id, | |
| 93 | + | source: p.source, | |
| 94 | + | source_ref: p.source_ref, | |
| 95 | + | title: p.title, | |
| 96 | + | body: p.body, | |
| 97 | + | pain: p.pain, | |
| 98 | + | scale: p.scale, | |
| 99 | + | project_id: p.project_id, | |
| 100 | + | project_name, | |
| 101 | + | tags: p.tags, | |
| 102 | + | promoted_task_id: p.promoted_task_id, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Parse the wire status word. `all` (or absent-as-`all`) is the caller's job to | |
| 107 | + | /// map to `None`; anything else must be a real variant, since silently falling | |
| 108 | + | /// back to `Open` would show a different list than the one asked for. | |
| 109 | + | fn parse_status(raw: &str) -> Result<ProblemStatus, ApiError> { | |
| 110 | + | raw.parse::<ProblemStatus>().map_err(|_| { | |
| 111 | + | ApiError::validation("status", "Expected Open, Promoted, Dismissed, or Resolved") | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Commands | |
| 116 | + | ||
| 117 | + | /// Lists problems, most urgent first. | |
| 118 | + | #[tauri::command] | |
| 119 | + | #[instrument(skip_all)] | |
| 120 | + | pub async fn list_problems( | |
| 121 | + | state: State<'_, Arc<AppState>>, | |
| 122 | + | filter: Option<ProblemFilterInput>, | |
| 123 | + | ) -> Result<Vec<ProblemResponse>, ApiError> { | |
| 124 | + | let input = filter.unwrap_or_default(); | |
| 125 | + | ||
| 126 | + | let status = match input.status.as_deref().map(str::trim) { | |
| 127 | + | Some(s) if s.eq_ignore_ascii_case("all") => None, | |
| 128 | + | Some("") => Some(ProblemStatus::Open), | |
| 129 | + | Some(s) => Some(parse_status(s)?), | |
| 130 | + | None => Some(ProblemStatus::Open), | |
| 131 | + | }; | |
| 132 | + | ||
| 133 | + | let problems = state | |
| 134 | + | .problems | |
| 135 | + | .list( | |
| 136 | + | DESKTOP_USER_ID, | |
| 137 | + | &ProblemFilter { | |
| 138 | + | source: input.source, | |
| 139 | + | status, | |
| 140 | + | project_id: input.project_id, | |
| 141 | + | }, | |
| 142 | + | ) | |
| 143 | + | .await?; | |
| 144 | + | ||
| 145 | + | // One project lookup for the whole page rather than per row. | |
| 146 | + | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 147 | + | let name_of = |id: Option<ProjectId>| { | |
| 148 | + | id.and_then(|id| projects.iter().find(|p| p.id == id).map(|p| p.name.clone())) | |
| 149 | + | }; | |
| 150 | + | ||
| 151 | + | Ok(problems | |
| 152 | + | .into_iter() | |
| 153 | + | .map(|p| { | |
| 154 | + | let name = name_of(p.project_id); | |
| 155 | + | to_response(p, name) | |
| 156 | + | }) | |
| 157 | + | .collect()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Promotes a problem into a task, linking the two. | |
| 161 | + | /// | |
| 162 | + | /// The task is created before the backlink is written. The reverse order could | |
| 163 | + | /// leave a problem marked Promoted pointing at nothing, which nothing recovers | |
| 164 | + | /// from; this way a failure leaves a stray task and an Open problem, and | |
| 165 | + | /// retrying is safe. | |
| 166 | + | #[tauri::command] | |
| 167 | + | #[instrument(skip_all)] | |
| 168 | + | pub async fn promote_problem( | |
| 169 | + | state: State<'_, Arc<AppState>>, | |
| 170 | + | id: ProblemId, | |
| 171 | + | input: Option<PromoteProblemInput>, | |
| 172 | + | ) -> Result<PromoteProblemResponse, ApiError> { | |
| 173 | + | let input = input.unwrap_or(PromoteProblemInput { | |
| 174 | + | description: None, | |
| 175 | + | priority: None, | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | let problem = state | |
| 179 | + | .problems | |
| 180 | + | .get_by_id(id, DESKTOP_USER_ID) | |
| 181 | + | .await? | |
| 182 | + | .or_not_found("Problem", id)?; | |
| 183 | + | ||
| 184 | + | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 185 | + | let project_name = |pid: Option<ProjectId>| { | |
| 186 | + | pid.and_then(|pid| { | |
| 187 | + | projects | |
| 188 | + | .iter() | |
| 189 | + | .find(|p| p.id == pid) | |
| 190 | + | .map(|p| p.name.clone()) | |
| 191 | + | }) | |
| 192 | + | }; | |
| 193 | + | ||
| 194 | + | // Idempotent: a retried promote reports the task it already made. | |
| 195 | + | if let Some(existing) = problem.promoted_task_id { | |
| 196 | + | let name = project_name(problem.project_id); | |
| 197 | + | return Ok(PromoteProblemResponse { | |
| 198 | + | task_id: existing, | |
| 199 | + | problem: to_response(problem, name), | |
| 200 | + | created: false, | |
| 201 | + | }); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | let description = match input.description.as_deref().map(str::trim) { | |
| 205 | + | Some(d) if !d.is_empty() => d.to_string(), | |
| 206 | + | _ if problem.body.trim().is_empty() => problem.title.clone(), | |
| 207 | + | _ => format!("{}\n\n{}", problem.title, problem.body), | |
| 208 | + | }; | |
| 209 | + | ||
| 210 | + | // The band is the problem's own read on urgency, so it is the sensible | |
| 211 | + | // default. Critical and High both land on High: tasks have no fourth level. | |
| 212 | + | let priority = match input.priority.as_deref() { | |
| 213 | + | Some(p) if !p.trim().is_empty() => Priority::from_str_or_default(p), | |
| 214 | + | _ => match problem.band() { | |
| 215 | + | painhours::Priority::Critical | painhours::Priority::High => Priority::High, | |
| 216 | + | painhours::Priority::Medium => Priority::Medium, | |
| 217 | + | painhours::Priority::Low => Priority::Low, | |
| 218 | + | }, | |
| 219 | + | }; | |
| 220 | + | ||
| 221 | + | let mut tags = problem.tags.clone(); | |
| 222 | + | tags.push(format!("problem:{}:{}", problem.source, problem.source_ref)); | |
| 223 | + | ||
| 224 | + | let mut builder = NewTask::builder(description).priority(priority).tags(tags); | |
| 225 | + | if let Some(pid) = problem.project_id { | |
| 226 | + | builder = builder.project_id(pid); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | let task = state.tasks.create(DESKTOP_USER_ID, builder.build()).await?; | |
| 230 | + | ||
| 231 | + | let promoted = state | |
| 232 | + | .problems | |
| 233 | + | .promote(id, DESKTOP_USER_ID, task.id) | |
| 234 | + | .await? | |
| 235 | + | .or_not_found("Problem", id)?; | |
| 236 | + | ||
| 237 | + | let name = project_name(promoted.project_id); | |
| 238 | + | Ok(PromoteProblemResponse { | |
| 239 | + | task_id: task.id, | |
| 240 | + | problem: to_response(promoted, name), | |
| 241 | + | created: true, | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// Sets a problem's triage state. | |
| 246 | + | /// | |
| 247 | + | /// `Dismissed` is the "seen it, not acting" verdict and is the right way to | |
| 248 | + | /// clear something from the inbox: a deleted problem comes straight back on the | |
| 249 | + | /// next pull, while a dismissed one stays down. `Open` sends a settled problem | |
| 250 | + | /// back to triage, clearing any task backlink. | |
| 251 | + | #[tauri::command] | |
| 252 | + | #[instrument(skip_all)] | |
| 253 | + | pub async fn set_problem_status( | |
| 254 | + | state: State<'_, Arc<AppState>>, | |
| 255 | + | id: ProblemId, | |
| 256 | + | status: String, | |
| 257 | + | ) -> Result<ProblemResponse, ApiError> { | |
| 258 | + | let status = parse_status(&status)?; | |
| 259 | + | ||
| 260 | + | let updated = state | |
| 261 | + | .problems | |
| 262 | + | .set_status(id, DESKTOP_USER_ID, status) | |
| 263 | + | .await? | |
| 264 | + | .or_not_found("Problem", id)?; | |
| 265 | + | ||
| 266 | + | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 267 | + | let name = updated.project_id.and_then(|pid| { | |
| 268 | + | projects | |
| 269 | + | .iter() | |
| 270 | + | .find(|p| p.id == pid) | |
| 271 | + | .map(|p| p.name.clone()) | |
| 272 | + | }); | |
| 273 | + | ||
| 274 | + | Ok(to_response(updated, name)) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// Attributes a problem to a project, or clears the attribution when | |
| 278 | + | /// `project_id` is absent. | |
| 279 | + | #[tauri::command] | |
| 280 | + | #[instrument(skip_all)] | |
| 281 | + | pub async fn set_problem_project( | |
| 282 | + | state: State<'_, Arc<AppState>>, | |
| 283 | + | id: ProblemId, | |
| 284 | + | project_id: Option<ProjectId>, | |
| 285 | + | ) -> Result<ProblemResponse, ApiError> { | |
| 286 | + | let updated = state | |
| 287 | + | .problems | |
| 288 | + | .set_project(id, DESKTOP_USER_ID, project_id) | |
| 289 | + | .await? | |
| 290 | + | .or_not_found("Problem", id)?; | |
| 291 | + | ||
| 292 | + | let projects = state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 293 | + | let name = updated.project_id.and_then(|pid| { | |
| 294 | + | projects | |
| 295 | + | .iter() | |
| 296 | + | .find(|p| p.id == pid) | |
| 297 | + | .map(|p| p.name.clone()) | |
| 298 | + | }); | |
| 299 | + | ||
| 300 | + | Ok(to_response(updated, name)) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /// Counts the problems awaiting triage, for the badge on the Problems pill. | |
| 304 | + | #[tauri::command] | |
| 305 | + | #[instrument(skip_all)] | |
| 306 | + | pub async fn count_open_problems(state: State<'_, Arc<AppState>>) -> Result<usize, ApiError> { | |
| 307 | + | let problems = state | |
| 308 | + | .problems | |
| 309 | + | .list( | |
| 310 | + | DESKTOP_USER_ID, | |
| 311 | + | &ProblemFilter { | |
| 312 | + | status: Some(ProblemStatus::Open), | |
| 313 | + | ..Default::default() | |
| 314 | + | }, | |
| 315 | + | ) | |
| 316 | + | .await?; | |
| 317 | + | Ok(problems.len()) | |
| 318 | + | } |
| @@ -12,6 +12,7 @@ mod email_tests; | |||
| 12 | 12 | mod event_tests; | |
| 13 | 13 | mod export_tests; | |
| 14 | 14 | mod milestone_tests; | |
| 15 | + | mod problem_tests; | |
| 15 | 16 | mod project_tests; | |
| 16 | 17 | mod saved_views_tests; | |
| 17 | 18 | mod search_tests; |
| @@ -0,0 +1,193 @@ | |||
| 1 | + | //! Integration tests for the Problems inbox commands. | |
| 2 | + | //! | |
| 3 | + | //! The command layer's own job is thin — filtering, project-name resolution, | |
| 4 | + | //! and the promote sequence — so that is what these cover. The upsert and | |
| 5 | + | //! ageing semantics are pinned in `goingson-db-sqlite`'s `problem_repo_tests`. | |
| 6 | + | ||
| 7 | + | use chrono::{Duration, Utc}; | |
| 8 | + | use goingson_core::{NewProblem, ProblemStatus, TaskStatus}; | |
| 9 | + | ||
| 10 | + | use crate::test_utils::{create_test_project, setup_test_state}; | |
| 11 | + | ||
| 12 | + | fn new_problem(source_ref: &str, pain: u8, scale: u8) -> NewProblem { | |
| 13 | + | let now = Utc::now(); | |
| 14 | + | NewProblem { | |
| 15 | + | source: "audit".to_string(), | |
| 16 | + | source_ref: source_ref.to_string(), | |
| 17 | + | title: format!("finding {source_ref}"), | |
| 18 | + | body: "db-sqlite/task_repo.rs:120".to_string(), | |
| 19 | + | pain, | |
| 20 | + | scale, | |
| 21 | + | project_id: None, | |
| 22 | + | tags: vec!["testing".to_string()], | |
| 23 | + | created_at: now, | |
| 24 | + | updated_at: now, | |
| 25 | + | resolved_upstream: false, | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[tokio::test] | |
| 30 | + | async fn list_defaults_to_open_and_ranks_by_painhours() { | |
| 31 | + | let (state, user_id) = setup_test_state().await; | |
| 32 | + | ||
| 33 | + | state | |
| 34 | + | .problems | |
| 35 | + | .ingest(user_id, new_problem("narrow", 1, 1)) | |
| 36 | + | .await | |
| 37 | + | .unwrap(); | |
| 38 | + | state | |
| 39 | + | .problems | |
| 40 | + | .ingest(user_id, new_problem("widespread", 1, 5)) | |
| 41 | + | .await | |
| 42 | + | .unwrap(); | |
| 43 | + | let dismissed = state | |
| 44 | + | .problems | |
| 45 | + | .ingest(user_id, new_problem("ignored", 3, 3)) | |
| 46 | + | .await | |
| 47 | + | .unwrap(); | |
| 48 | + | state | |
| 49 | + | .problems | |
| 50 | + | .set_status(dismissed.id, user_id, ProblemStatus::Dismissed) | |
| 51 | + | .await | |
| 52 | + | .unwrap(); | |
| 53 | + | ||
| 54 | + | let open = state | |
| 55 | + | .problems | |
| 56 | + | .list( | |
| 57 | + | user_id, | |
| 58 | + | &goingson_core::ProblemFilter { | |
| 59 | + | status: Some(ProblemStatus::Open), | |
| 60 | + | ..Default::default() | |
| 61 | + | }, | |
| 62 | + | ) | |
| 63 | + | .await | |
| 64 | + | .unwrap(); | |
| 65 | + | ||
| 66 | + | assert_eq!(open.len(), 2, "the dismissed problem is out of the inbox"); | |
| 67 | + | assert_eq!(open[0].source_ref, "widespread", "scale drives the ranking"); | |
| 68 | + | assert!(open[0].painhours() > open[1].painhours()); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | #[tokio::test] | |
| 72 | + | async fn promoting_creates_a_linked_task_carrying_project_and_tags() { | |
| 73 | + | let (state, user_id) = setup_test_state().await; | |
| 74 | + | let project_id = create_test_project(&state, user_id).await; | |
| 75 | + | ||
| 76 | + | let mut input = new_problem("blob-memory", 5, 4); | |
| 77 | + | input.project_id = Some(project_id); | |
| 78 | + | // Backdate it so the freeze has something to freeze. | |
| 79 | + | input.created_at = Utc::now() - Duration::days(60); | |
| 80 | + | let problem = state.problems.ingest(user_id, input).await.unwrap(); | |
| 81 | + | let open_score = problem.painhours(); | |
| 82 | + | ||
| 83 | + | let task = { | |
| 84 | + | let mut tags = problem.tags.clone(); | |
| 85 | + | tags.push(format!("problem:{}:{}", problem.source, problem.source_ref)); | |
| 86 | + | let builder = goingson_core::NewTask::builder(problem.title.clone()) | |
| 87 | + | .priority(goingson_core::Priority::High) | |
| 88 | + | .tags(tags) | |
| 89 | + | .project_id(project_id); | |
| 90 | + | state.tasks.create(user_id, builder.build()).await.unwrap() | |
| 91 | + | }; | |
| 92 | + | ||
| 93 | + | let promoted = state | |
| 94 | + | .problems | |
| 95 | + | .promote(problem.id, user_id, task.id) | |
| 96 | + | .await | |
| 97 | + | .unwrap() | |
| 98 | + | .expect("promote returned None"); | |
| 99 | + | ||
| 100 | + | assert_eq!(promoted.status, ProblemStatus::Promoted); | |
| 101 | + | assert_eq!(promoted.promoted_task_id, Some(task.id)); | |
| 102 | + | assert_eq!(promoted.project_id, Some(project_id)); | |
| 103 | + | ||
| 104 | + | // Settling anchors age at promotion: the score holds rather than dropping, | |
| 105 | + | // and stops climbing from here. | |
| 106 | + | assert_eq!(promoted.painhours(), open_score); | |
| 107 | + | assert!(promoted.settled_at.is_some()); | |
| 108 | + | ||
| 109 | + | let task = state | |
| 110 | + | .tasks | |
| 111 | + | .get_by_id(task.id, user_id) | |
| 112 | + | .await | |
| 113 | + | .unwrap() | |
| 114 | + | .expect("task missing"); | |
| 115 | + | assert_eq!(task.status, TaskStatus::Pending); | |
| 116 | + | assert!( | |
| 117 | + | task.tags.contains(&"problem:audit:blob-memory".to_string()), | |
| 118 | + | "the task must carry its provenance back to the problem" | |
| 119 | + | ); | |
| 120 | + | assert!(task.tags.contains(&"testing".to_string())); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[tokio::test] | |
| 124 | + | async fn dismissing_survives_a_re_report() { | |
| 125 | + | let (state, user_id) = setup_test_state().await; | |
| 126 | + | ||
| 127 | + | let problem = state | |
| 128 | + | .problems | |
| 129 | + | .ingest(user_id, new_problem("style-nit", 2, 2)) | |
| 130 | + | .await | |
| 131 | + | .unwrap(); | |
| 132 | + | state | |
| 133 | + | .problems | |
| 134 | + | .set_status(problem.id, user_id, ProblemStatus::Dismissed) | |
| 135 | + | .await | |
| 136 | + | .unwrap(); | |
| 137 | + | ||
| 138 | + | // The next audit run reports it again, and now calls it fixed. | |
| 139 | + | let mut again = new_problem("style-nit", 2, 2); | |
| 140 | + | again.resolved_upstream = true; | |
| 141 | + | state.problems.ingest(user_id, again).await.unwrap(); | |
| 142 | + | ||
| 143 | + | let after = state | |
| 144 | + | .problems | |
| 145 | + | .find_by_source_ref(user_id, "audit", "style-nit") | |
| 146 | + | .await | |
| 147 | + | .unwrap() | |
| 148 | + | .expect("problem missing"); | |
| 149 | + | assert_eq!( | |
| 150 | + | after.status, | |
| 151 | + | ProblemStatus::Dismissed, | |
| 152 | + | "a human ruling outranks what the source says" | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[tokio::test] | |
| 157 | + | async fn project_attribution_resolves_to_a_name() { | |
| 158 | + | let (state, user_id) = setup_test_state().await; | |
| 159 | + | let project_id = create_test_project(&state, user_id).await; | |
| 160 | + | ||
| 161 | + | let problem = state | |
| 162 | + | .problems | |
| 163 | + | .ingest(user_id, new_problem("unattributed", 3, 3)) | |
| 164 | + | .await | |
| 165 | + | .unwrap(); | |
| 166 | + | assert!(problem.project_id.is_none()); | |
| 167 | + | ||
| 168 | + | let attributed = state | |
| 169 | + | .problems | |
| 170 | + | .set_project(problem.id, user_id, Some(project_id)) | |
| 171 | + | .await | |
| 172 | + | .unwrap() | |
| 173 | + | .expect("problem missing"); | |
| 174 | + | assert_eq!(attributed.project_id, Some(project_id)); | |
| 175 | + | ||
| 176 | + | // The command layer resolves the name from this id; confirm the project it | |
| 177 | + | // points at is really there, which is what that lookup depends on. | |
| 178 | + | let project = state | |
| 179 | + | .projects | |
| 180 | + | .get_by_id(project_id, user_id) | |
| 181 | + | .await | |
| 182 | + | .unwrap() | |
| 183 | + | .expect("project missing"); | |
| 184 | + | assert_eq!(attributed.project_id, Some(project.id)); | |
| 185 | + | ||
| 186 | + | let cleared = state | |
| 187 | + | .problems | |
| 188 | + | .set_project(problem.id, user_id, None) | |
| 189 | + | .await | |
| 190 | + | .unwrap() | |
| 191 | + | .expect("problem missing"); | |
| 192 | + | assert!(cleared.project_id.is_none()); | |
| 193 | + | } |
| @@ -105,6 +105,12 @@ macro_rules! all_commands { | |||
| 105 | 105 | $crate::commands::update_milestone, | |
| 106 | 106 | $crate::commands::delete_milestone, | |
| 107 | 107 | $crate::commands::reorder_milestones, | |
| 108 | + | // Problems inbox (triage; problems arrive from adapters, never created here) | |
| 109 | + | $crate::commands::list_problems, | |
| 110 | + | $crate::commands::promote_problem, | |
| 111 | + | $crate::commands::set_problem_status, | |
| 112 | + | $crate::commands::set_problem_project, | |
| 113 | + | $crate::commands::count_open_problems, | |
| 108 | 114 | // Events | |
| 109 | 115 | $crate::commands::list_events, | |
| 110 | 116 | $crate::commands::get_event, |