Move the work service's issues to workspace work items
Migration 0049 turns issues into work items and links each to its repository as its origin, moves labels and milestones to the workspace, and rekeys pulls, comments, reactions, claims, sessions and decisions by work item id. A job run by the cron (and migrate_work_items) numbers the moved items per workspace above every number its repositories used, merges their labels and milestones, and announces where they went with work.migrated. New work items and pull requests share the workspace's sequence. Merges and pushed commits land the items they close in their repository and close an item once every target has landed; Part of links without closing. get_work shows each repository's state, what is missing and suggested targets.
- G1T-Session:
- ses_01m561b3rje8nvxjcdsz6frggj
| 1 | + | -- Issues move out of repositories into their workspaces as work items | |
| 2 | + | -- (src/items.rs). A work item belongs to a workspace, is numbered in the | |
| 3 | + | -- workspace's sequence (which new pull requests share), and links the | |
| 4 | + | -- repositories it concerns: where it was filed (origin, which keeps the | |
| 5 | + | -- issue's old number), where it is meant to land (target), and where work | |
| 6 | + | -- for it landed or is under way (touched). | |
| 7 | + | -- | |
| 8 | + | -- This database does not know which workspace a repository is in, so the | |
| 9 | + | -- job in src/work_migration.rs assigns each work item its workspace and | |
| 10 | + | -- number, and moves labels and milestones to the workspace, once it has | |
| 11 | + | -- asked repos. Everything else moves here: every row that named an issue | |
| 12 | + | -- by repository and number names its work item by id. Until the job is | |
| 13 | + | -- done, an old `#N` resolves through `work_item_repos.legacy_number`. | |
| 14 | + | -- | |
| 15 | + | -- Tables are rebuilt the way D1 allows: inside the migration's | |
| 16 | + | -- transaction, with foreign keys deferred until every rebuilt table is in | |
| 17 | + | -- place again. | |
| 18 | + | PRAGMA defer_foreign_keys = true; | |
| 19 | + | ||
| 20 | + | -- Which workspace each repository is in, and its path: recorded by the | |
| 21 | + | -- job, then kept on repo.created, renamed, transferred and visibility | |
| 22 | + | -- changes. `private` is 1 until the repository is known to be public. | |
| 23 | + | CREATE TABLE repo_workspaces ( | |
| 24 | + | repo_id TEXT PRIMARY KEY, | |
| 25 | + | workspace TEXT NOT NULL, | |
| 26 | + | -- owner/name, as it is now. | |
| 27 | + | path TEXT NOT NULL, | |
| 28 | + | private INTEGER NOT NULL DEFAULT 1 | |
| 29 | + | ); | |
| 30 | + | CREATE INDEX repo_workspaces_by_workspace ON repo_workspaces (workspace); | |
| 31 | + | ||
| 32 | + | -- Each workspace's sequence of numbers, shared by work items and new pull | |
| 33 | + | -- requests. The per-repository `counters` stays, emptied by the job, for | |
| 34 | + | -- the code that still reads it while the job runs. | |
| 35 | + | CREATE TABLE work_counters ( | |
| 36 | + | workspace TEXT PRIMARY KEY, | |
| 37 | + | last INTEGER NOT NULL | |
| 38 | + | ); | |
| 39 | + | ||
| 40 | + | -- One-off jobs, resumable: where each got to, and when it finished. | |
| 41 | + | CREATE TABLE work_jobs ( | |
| 42 | + | name TEXT PRIMARY KEY, | |
| 43 | + | cursor TEXT, | |
| 44 | + | done_at TEXT | |
| 45 | + | ); | |
| 46 | + | INSERT INTO work_jobs (name) VALUES ('work_items'); | |
| 47 | + | ||
| 48 | + | -- The issues, renamed first so that pulls' reference follows them. | |
| 49 | + | ALTER TABLE issues RENAME TO work_items; | |
| 50 | + | ||
| 51 | + | CREATE TABLE work_items_next ( | |
| 52 | + | -- wki_…; one moved from an issue keeps its iss_… id. | |
| 53 | + | id TEXT PRIMARY KEY, | |
| 54 | + | -- The workspace's slug and its number there: NULL until the job | |
| 55 | + | -- assigns them to a moved issue. | |
| 56 | + | workspace TEXT, | |
| 57 | + | number INTEGER, | |
| 58 | + | title TEXT NOT NULL, | |
| 59 | + | body TEXT NOT NULL, | |
| 60 | + | -- JSON array of label names. | |
| 61 | + | labels TEXT NOT NULL DEFAULT '[]', | |
| 62 | + | state TEXT NOT NULL DEFAULT 'open', | |
| 63 | + | -- Why it was closed: completed or not_planned. | |
| 64 | + | reason TEXT, | |
| 65 | + | -- JSON array of { repo_id, pull, commit, pushed_by }: what resolved it | |
| 66 | + | -- in each repository it landed in. | |
| 67 | + | resolutions TEXT NOT NULL DEFAULT '[]', | |
| 68 | + | author_id TEXT NOT NULL, | |
| 69 | + | author_name TEXT NOT NULL, | |
| 70 | + | requested_by_id TEXT, | |
| 71 | + | requested_by_name TEXT, | |
| 72 | + | created_at TEXT NOT NULL, | |
| 73 | + | updated_at TEXT NOT NULL, | |
| 74 | + | closed_at TEXT, | |
| 75 | + | -- JSON array of usernames. | |
| 76 | + | assignees TEXT NOT NULL DEFAULT '[]', | |
| 77 | + | -- JSON array of the ids of the work items that have to land first. | |
| 78 | + | blocked_by TEXT NOT NULL DEFAULT '[]', | |
| 79 | + | -- The number of its workspace milestone. | |
| 80 | + | milestone INTEGER, | |
| 81 | + | UNIQUE (workspace, number) | |
| 82 | + | ); | |
| 83 | + | ||
| 84 | + | INSERT INTO work_items_next | |
| 85 | + | (id, workspace, number, title, body, labels, state, reason, resolutions, author_id, author_name, | |
| 86 | + | requested_by_id, requested_by_name, created_at, updated_at, closed_at, assignees, blocked_by, milestone) | |
| 87 | + | SELECT | |
| 88 | + | i.id, NULL, NULL, i.title, i.body, i.labels, i.state, i.reason, | |
| 89 | + | CASE WHEN i.resolved_by IS NOT NULL OR i.resolved_commit IS NOT NULL | |
| 90 | + | THEN json_array(json_object('repo_id', i.repo_id, 'pull', i.resolved_by, 'commit', i.resolved_commit, | |
| 91 | + | 'pushed_by', i.resolved_pushed_by)) | |
| 92 | + | ELSE '[]' END, | |
| 93 | + | i.author_id, i.author_name, i.requested_by_id, i.requested_by_name, i.created_at, i.updated_at, i.closed_at, | |
| 94 | + | i.assignees, | |
| 95 | + | (SELECT json_group_array(id) FROM | |
| 96 | + | (SELECT b.id AS id FROM json_each(i.blocked_by) j JOIN work_items b ON b.repo_id = i.repo_id AND b.number = j.value | |
| 97 | + | ORDER BY j.key)), | |
| 98 | + | i.milestone | |
| 99 | + | FROM work_items i; | |
| 100 | + | ||
| 101 | + | -- The repositories each work item is linked to. | |
| 102 | + | CREATE TABLE work_item_repos ( | |
| 103 | + | item_id TEXT NOT NULL, | |
| 104 | + | repo_id TEXT NOT NULL, | |
| 105 | + | role TEXT NOT NULL CHECK (role IN ('origin', 'target', 'touched')), | |
| 106 | + | -- For the origin of a moved issue: its number in that repository. | |
| 107 | + | legacy_number INTEGER, | |
| 108 | + | -- owner/name: filled in by the job, kept when the repository goes. | |
| 109 | + | path TEXT, | |
| 110 | + | -- When something that closes it landed there. | |
| 111 | + | landed_at TEXT, | |
| 112 | + | added_at TEXT NOT NULL, | |
| 113 | + | -- Who linked it, by id; NULL when g1t did. | |
| 114 | + | added_by TEXT, | |
| 115 | + | PRIMARY KEY (item_id, repo_id) | |
| 116 | + | ); | |
| 117 | + | CREATE INDEX work_item_repos_by_repo ON work_item_repos (repo_id, role); | |
| 118 | + | CREATE UNIQUE INDEX work_item_repos_by_legacy ON work_item_repos (repo_id, legacy_number) | |
| 119 | + | WHERE legacy_number IS NOT NULL; | |
| 120 | + | ||
| 121 | + | INSERT INTO work_item_repos (item_id, repo_id, role, legacy_number, path, landed_at, added_at, added_by) | |
| 122 | + | SELECT id, repo_id, 'origin', number, NULL, | |
| 123 | + | CASE WHEN state = 'closed' AND (resolved_by IS NOT NULL OR resolved_commit IS NOT NULL) THEN closed_at END, | |
| 124 | + | created_at, author_id | |
| 125 | + | FROM work_items; | |
| 126 | + | ||
| 127 | + | DROP TABLE work_items; | |
| 128 | + | ALTER TABLE work_items_next RENAME TO work_items; | |
| 129 | + | CREATE INDEX work_items_by_state ON work_items (workspace, state, number); | |
| 130 | + | CREATE INDEX work_items_by_milestone ON work_items (workspace, milestone); | |
| 131 | + | CREATE INDEX work_items_by_author ON work_items (author_id, created_at); | |
| 132 | + | CREATE INDEX work_items_by_owner ON work_items (COALESCE(requested_by_id, author_id), created_at); | |
| 133 | + | CREATE INDEX work_items_unnumbered ON work_items (created_at, id) WHERE number IS NULL; | |
| 134 | + | ||
| 135 | + | -- Pull requests name their work item by id, and what they close or are | |
| 136 | + | -- part of as JSON arrays of ids. | |
| 137 | + | DROP INDEX pulls_by_issue; | |
| 138 | + | ALTER TABLE pulls RENAME COLUMN issue_id TO item_id; | |
| 139 | + | ALTER TABLE pulls DROP COLUMN issue_number; | |
| 140 | + | ALTER TABLE pulls ADD COLUMN closes_items TEXT NOT NULL DEFAULT '[]'; | |
| 141 | + | ALTER TABLE pulls ADD COLUMN parts TEXT NOT NULL DEFAULT '[]'; | |
| 142 | + | UPDATE pulls SET closes_items = (SELECT json_group_array(id) FROM | |
| 143 | + | (SELECT r.item_id AS id FROM json_each(pulls.closes) j | |
| 144 | + | JOIN work_item_repos r ON r.repo_id = pulls.repo_id AND r.legacy_number = j.value | |
| 145 | + | ORDER BY j.key)) | |
| 146 | + | WHERE closes IS NOT NULL AND json_valid(closes); | |
| 147 | + | ALTER TABLE pulls DROP COLUMN closes; | |
| 148 | + | CREATE INDEX pulls_by_item ON pulls (item_id); | |
| 149 | + | ||
| 150 | + | -- Comments: a pull request's by repository and number, a work item's by | |
| 151 | + | -- its id. | |
| 152 | + | CREATE TABLE comments_next ( | |
| 153 | + | id TEXT PRIMARY KEY, | |
| 154 | + | repo_id TEXT, | |
| 155 | + | number INTEGER, | |
| 156 | + | item_id TEXT, | |
| 157 | + | author_id TEXT NOT NULL, | |
| 158 | + | author_name TEXT NOT NULL, | |
| 159 | + | body TEXT NOT NULL, | |
| 160 | + | -- For a comment on one line of a pull request's change: the file, and | |
| 161 | + | -- the line as numbered after the change. | |
| 162 | + | path TEXT, | |
| 163 | + | line INTEGER, | |
| 164 | + | -- A reviewer's decision: approve or request_changes. | |
| 165 | + | verdict TEXT, | |
| 166 | + | created_at TEXT NOT NULL, | |
| 167 | + | kind TEXT NOT NULL DEFAULT 'comment', | |
| 168 | + | edited_at TEXT, | |
| 169 | + | agent_id TEXT, | |
| 170 | + | agent_handle TEXT, | |
| 171 | + | agent_name TEXT, | |
| 172 | + | agent_avatar_seed TEXT, | |
| 173 | + | acting_for_id TEXT, | |
| 174 | + | acting_for_name TEXT, | |
| 175 | + | agent_verdict TEXT, | |
| 176 | + | CHECK (item_id IS NOT NULL OR (repo_id IS NOT NULL AND number IS NOT NULL)) | |
| 177 | + | ); | |
| 178 | + | INSERT INTO comments_next | |
| 179 | + | (id, repo_id, number, item_id, author_id, author_name, body, path, line, verdict, created_at, kind, edited_at, | |
| 180 | + | agent_id, agent_handle, agent_name, agent_avatar_seed, acting_for_id, acting_for_name, agent_verdict) | |
| 181 | + | SELECT c.id, | |
| 182 | + | CASE WHEN r.item_id IS NULL THEN c.repo_id END, CASE WHEN r.item_id IS NULL THEN c.number END, r.item_id, | |
| 183 | + | c.author_id, c.author_name, c.body, c.path, c.line, c.verdict, c.created_at, c.kind, c.edited_at, | |
| 184 | + | c.agent_id, c.agent_handle, c.agent_name, c.agent_avatar_seed, c.acting_for_id, c.acting_for_name, c.agent_verdict | |
| 185 | + | FROM comments c LEFT JOIN work_item_repos r ON r.repo_id = c.repo_id AND r.legacy_number = c.number; | |
| 186 | + | DROP TABLE comments; | |
| 187 | + | ALTER TABLE comments_next RENAME TO comments; | |
| 188 | + | CREATE INDEX comments_by_subject ON comments (repo_id, number, id); | |
| 189 | + | CREATE INDEX comments_by_item ON comments (item_id, id); | |
| 190 | + | CREATE INDEX comments_by_author ON comments (author_id, created_at); | |
| 191 | + | CREATE INDEX comments_by_agent ON comments (agent_id, repo_id, number, created_at) | |
| 192 | + | WHERE agent_id IS NOT NULL; | |
| 193 | + | ||
| 194 | + | -- Reactions: the same. | |
| 195 | + | CREATE TABLE reactions_next ( | |
| 196 | + | -- The comment's id, or the pull request's or work item's own id for its | |
| 197 | + | -- description. | |
| 198 | + | subject TEXT NOT NULL, | |
| 199 | + | repo_id TEXT, | |
| 200 | + | number INTEGER, | |
| 201 | + | item_id TEXT, | |
| 202 | + | user_id TEXT NOT NULL, | |
| 203 | + | user_name TEXT NOT NULL, | |
| 204 | + | content TEXT NOT NULL, | |
| 205 | + | created_at TEXT NOT NULL, | |
| 206 | + | PRIMARY KEY (subject, user_id, content) | |
| 207 | + | ); | |
| 208 | + | INSERT INTO reactions_next (subject, repo_id, number, item_id, user_id, user_name, content, created_at) | |
| 209 | + | SELECT x.subject, | |
| 210 | + | CASE WHEN r.item_id IS NULL THEN x.repo_id END, CASE WHEN r.item_id IS NULL THEN x.number END, r.item_id, | |
| 211 | + | x.user_id, x.user_name, x.content, x.created_at | |
| 212 | + | FROM reactions x LEFT JOIN work_item_repos r ON r.repo_id = x.repo_id AND r.legacy_number = x.number; | |
| 213 | + | DROP TABLE reactions; | |
| 214 | + | ALTER TABLE reactions_next RENAME TO reactions; | |
| 215 | + | CREATE INDEX reactions_by_thread ON reactions (repo_id, number); | |
| 216 | + | CREATE INDEX reactions_by_item ON reactions (item_id); | |
| 217 | + | CREATE INDEX reactions_by_user ON reactions (user_id); | |
| 218 | + | ||
| 219 | + | -- Claims are on a work item; the repository is the claimant's checkout. | |
| 220 | + | CREATE TABLE claims_next ( | |
| 221 | + | id TEXT PRIMARY KEY, | |
| 222 | + | item_id TEXT NOT NULL, | |
| 223 | + | repo_id TEXT, | |
| 224 | + | session_id TEXT, | |
| 225 | + | actor_id TEXT NOT NULL, | |
| 226 | + | actor_name TEXT NOT NULL, | |
| 227 | + | harness TEXT, | |
| 228 | + | note TEXT, | |
| 229 | + | -- active, released, expired or handed_off. | |
| 230 | + | state TEXT NOT NULL DEFAULT 'active', | |
| 231 | + | ttl_minutes INTEGER NOT NULL, | |
| 232 | + | created_at TEXT NOT NULL, | |
| 233 | + | expires_at TEXT NOT NULL, | |
| 234 | + | ended_at TEXT, | |
| 235 | + | end_reason TEXT, | |
| 236 | + | -- For a hand-off: the username it is for; NULL for anyone. | |
| 237 | + | handoff_to TEXT | |
| 238 | + | ); | |
| 239 | + | INSERT INTO claims_next | |
| 240 | + | (id, item_id, repo_id, session_id, actor_id, actor_name, harness, note, state, ttl_minutes, created_at, | |
| 241 | + | expires_at, ended_at, end_reason, handoff_to) | |
| 242 | + | SELECT c.id, r.item_id, c.repo_id, c.session_id, c.actor_id, c.actor_name, c.harness, c.note, c.state, c.ttl_minutes, | |
| 243 | + | c.created_at, c.expires_at, c.ended_at, c.end_reason, c.handoff_to | |
| 244 | + | FROM claims c JOIN work_item_repos r ON r.repo_id = c.repo_id AND r.legacy_number = c.issue; | |
| 245 | + | DROP TABLE claims; | |
| 246 | + | ALTER TABLE claims_next RENAME TO claims; | |
| 247 | + | CREATE INDEX claims_by_item ON claims (item_id, state); | |
| 248 | + | CREATE INDEX claims_by_state ON claims (state, expires_at); | |
| 249 | + | CREATE INDEX claims_by_repo ON claims (repo_id, state) WHERE repo_id IS NOT NULL; | |
| 250 | + | CREATE INDEX claims_by_session ON claims (session_id, state) WHERE session_id IS NOT NULL; | |
| 251 | + | ||
| 252 | + | -- A session's work item, by id. | |
| 253 | + | ALTER TABLE sessions ADD COLUMN item_id TEXT; | |
| 254 | + | UPDATE sessions SET item_id = (SELECT r.item_id FROM work_item_repos r | |
| 255 | + | WHERE r.repo_id = sessions.repo_id AND r.legacy_number = sessions.issue) | |
| 256 | + | WHERE issue IS NOT NULL; | |
| 257 | + | DROP INDEX sessions_by_issue; | |
| 258 | + | ALTER TABLE sessions DROP COLUMN issue; | |
| 259 | + | CREATE INDEX sessions_by_item ON sessions (item_id) WHERE item_id IS NOT NULL; | |
| 260 | + | ||
| 261 | + | -- Sessions' ties: a pull request by number, a work item (kind `item`) by | |
| 262 | + | -- id, with number 0. | |
| 263 | + | CREATE TABLE session_links_next ( | |
| 264 | + | session_id TEXT NOT NULL, | |
| 265 | + | repo_id TEXT NOT NULL, | |
| 266 | + | -- item or pull. | |
| 267 | + | kind TEXT NOT NULL, | |
| 268 | + | -- A pull request's number; 0 for a work item. | |
| 269 | + | number INTEGER NOT NULL, | |
| 270 | + | -- A work item's id; '' for a pull request. | |
| 271 | + | item_id TEXT NOT NULL DEFAULT '', | |
| 272 | + | via TEXT NOT NULL, | |
| 273 | + | at TEXT NOT NULL, | |
| 274 | + | PRIMARY KEY (session_id, kind, number, item_id, via) | |
| 275 | + | ); | |
| 276 | + | INSERT INTO session_links_next (session_id, repo_id, kind, number, item_id, via, at) | |
| 277 | + | SELECT session_id, repo_id, kind, number, '', via, at FROM session_links WHERE kind = 'pull'; | |
| 278 | + | INSERT OR IGNORE INTO session_links_next (session_id, repo_id, kind, number, item_id, via, at) | |
| 279 | + | SELECT l.session_id, l.repo_id, 'item', 0, r.item_id, l.via, l.at | |
| 280 | + | FROM session_links l JOIN work_item_repos r ON r.repo_id = l.repo_id AND r.legacy_number = l.number | |
| 281 | + | WHERE l.kind = 'issue'; | |
| 282 | + | DROP TABLE session_links; | |
| 283 | + | ALTER TABLE session_links_next RENAME TO session_links; | |
| 284 | + | CREATE INDEX session_links_by_number ON session_links (repo_id, kind, number); | |
| 285 | + | CREATE INDEX session_links_by_item ON session_links (item_id) WHERE item_id <> ''; | |
| 286 | + | ||
| 287 | + | -- What a session named: a work item (kind `item`) by id. | |
| 288 | + | UPDATE OR IGNORE session_touches SET kind = 'item', | |
| 289 | + | value = (SELECT r.item_id FROM work_item_repos r | |
| 290 | + | WHERE r.repo_id = session_touches.repo_id AND r.legacy_number = CAST(session_touches.value AS INTEGER)) | |
| 291 | + | WHERE kind = 'issue' AND EXISTS (SELECT 1 FROM work_item_repos r | |
| 292 | + | WHERE r.repo_id = session_touches.repo_id AND r.legacy_number = CAST(session_touches.value AS INTEGER)); | |
| 293 | + | DELETE FROM session_touches WHERE kind = 'issue'; | |
| 294 | + | ||
| 295 | + | -- Decisions on a work item, by id; a pull request's stay by number. | |
| 296 | + | ALTER TABLE decisions ADD COLUMN item_id TEXT; | |
| 297 | + | UPDATE decisions SET item_id = (SELECT r.item_id FROM work_item_repos r | |
| 298 | + | WHERE r.repo_id = decisions.repo_id AND r.legacy_number = decisions.issue) | |
| 299 | + | WHERE issue IS NOT NULL; | |
| 300 | + | DROP INDEX decisions_by_issue; | |
| 301 | + | ALTER TABLE decisions DROP COLUMN issue; | |
| 302 | + | CREATE INDEX decisions_by_item ON decisions (item_id, created_at) WHERE item_id IS NOT NULL; | |
| 303 | + | ||
| 304 | + | -- Mentions of the retired agent, kept for their history: on a work item | |
| 305 | + | -- by id too. | |
| 306 | + | ALTER TABLE agent_mentions ADD COLUMN item_id TEXT; | |
| 307 | + | UPDATE agent_mentions SET item_id = (SELECT r.item_id FROM work_item_repos r | |
| 308 | + | WHERE r.repo_id = agent_mentions.repo_id AND r.legacy_number = agent_mentions.number) | |
| 309 | + | WHERE pull_id IS NULL; | |
| 310 | + | ||
| 311 | + | ALTER TABLE queue_entries RENAME COLUMN keep_issue_open TO keep_item_open; | |
| 312 | + | ||
| 313 | + | ALTER TABLE repo_settings RENAME COLUMN has_issues TO has_work_items; | |
| 314 | + | ALTER TABLE repo_settings RENAME COLUMN auto_close_issues TO auto_close_items; | |
| 315 | + | UPDATE repo_settings SET merge_commit_template = replace(merge_commit_template, '{issues}', '{items}') | |
| 316 | + | WHERE merge_commit_template LIKE '%{issues}%'; | |
| 317 | + | UPDATE repo_settings SET squash_commit_template = replace(squash_commit_template, '{issues}', '{items}') | |
| 318 | + | WHERE squash_commit_template LIKE '%{issues}%'; | |
| 319 | + | ||
| 320 | + | -- Labels and milestones belong to the workspace. The repositories' own | |
| 321 | + | -- wait here until the job merges them in: labels by name (the first made | |
| 322 | + | -- keeps its color and description), milestones renumbered in the | |
| 323 | + | -- workspace (`milestone_moves` says where each went). | |
| 324 | + | ALTER TABLE labels RENAME TO repo_labels; | |
| 325 | + | ALTER TABLE milestones RENAME TO repo_milestones; | |
| 326 | + | DROP INDEX milestones_by_state; | |
| 327 | + | ||
| 328 | + | CREATE TABLE labels ( | |
| 329 | + | workspace TEXT NOT NULL, | |
| 330 | + | name TEXT NOT NULL, | |
| 331 | + | -- Six hex digits, without '#'. | |
| 332 | + | color TEXT NOT NULL, | |
| 333 | + | description TEXT NOT NULL DEFAULT '', | |
| 334 | + | created_at TEXT NOT NULL, | |
| 335 | + | PRIMARY KEY (workspace, name) | |
| 336 | + | ); | |
| 337 | + | ||
| 338 | + | CREATE TABLE milestones ( | |
| 339 | + | workspace TEXT NOT NULL, | |
| 340 | + | number INTEGER NOT NULL, | |
| 341 | + | -- For one moved from a repository: that repository's id. | |
| 342 | + | origin_repo_id TEXT, | |
| 343 | + | title TEXT NOT NULL, | |
| 344 | + | description TEXT NOT NULL DEFAULT '', | |
| 345 | + | -- YYYY-MM-DD, or NULL. | |
| 346 | + | due_on TEXT, | |
| 347 | + | -- open or closed. | |
| 348 | + | state TEXT NOT NULL DEFAULT 'open', | |
| 349 | + | created_at TEXT NOT NULL, | |
| 350 | + | updated_at TEXT NOT NULL, | |
| 351 | + | closed_at TEXT, | |
| 352 | + | PRIMARY KEY (workspace, number) | |
| 353 | + | ); | |
| 354 | + | CREATE INDEX milestones_by_state ON milestones (workspace, state, due_on); | |
| 355 | + | ||
| 356 | + | CREATE TABLE milestone_moves ( | |
| 357 | + | repo_id TEXT NOT NULL, | |
| 358 | + | old_number INTEGER NOT NULL, | |
| 359 | + | workspace TEXT NOT NULL, | |
| 360 | + | number INTEGER NOT NULL, | |
| 361 | + | PRIMARY KEY (repo_id, old_number) | |
| 362 | + | ); | |
| 363 | + | ||
| 364 | + | PRAGMA defer_foreign_keys = false; |
| 1 | − | //! Files put in an issue's, a pull request's or a comment's Markdown | |
| 1 | + | //! Files put in a work item's, a pull request's or a comment's Markdown | |
| 2 | 2 | //! (`upload_attachment`): whoever may comment on a repository may add one, | |
| 3 | 3 | //! as an agent shows a screenshot of its preview in a pull request instead | |
| 4 | 4 | //! of committing the image. The bytes go in the usercontent namespace |
| 1 | − | //! A person's work, for their profile: the issues and pull requests they | |
| 2 | − | //! opened, and those g1t opened for them (`requested_by`, see Pull::owner), | |
| 3 | − | //! newest first, a page at a time. | |
| 1 | + | //! A person's work, for their profile: the work items and pull requests | |
| 2 | + | //! they opened, and those g1t opened for them (`requested_by`, see | |
| 3 | + | //! Pull::owner), newest first, a page at a time. | |
| 4 | 4 | //! | |
| 5 | − | //! Only work on repositories the viewer may read is ever shown, counted or | |
| 6 | − | //! named. Which those are is the repos service's decision (`readable`, the | |
| 7 | − | //! same check as opening the repository), made once per request over every | |
| 8 | − | //! repository the person has worked in; every query here is then confined | |
| 9 | − | //! to that set. A private repository's titles, numbers and even its | |
| 10 | − | //! existence never reach anyone who could not open it. | |
| 5 | + | //! Only work the viewer may read is ever shown, counted or named: pull | |
| 6 | + | //! requests of repositories they may read, and work items filed from one | |
| 7 | + | //! of those or of a workspace they are a member of. Which repositories | |
| 8 | + | //! those are is the repos service's decision (`readable`, the same check as | |
| 9 | + | //! opening the repository), made once per request over every repository | |
| 10 | + | //! the person has worked in; every query here is then confined to that | |
| 11 | + | //! set. A private repository's titles, numbers and even its existence | |
| 12 | + | //! never reach anyone who could not open it. | |
| 11 | 13 | ||
| 12 | 14 | use std::collections::HashMap; | |
| 13 | 15 | ||
| ⋯ | |||
| 25 | 27 | struct ItemRow { | |
| 26 | 28 | kind: AuthoredKind, | |
| 27 | 29 | id: String, | |
| 28 | − | repo_id: String, | |
| 30 | + | /// A pull request's repository, or a work item's origin. | |
| 31 | + | repo_id: Option<String>, | |
| 32 | + | /// A work item's workspace. | |
| 33 | + | #[serde(default)] | |
| 34 | + | workspace: Option<String>, | |
| 29 | 35 | number: u32, | |
| 30 | 36 | title: String, | |
| 31 | 37 | state: State, | |
| 32 | − | reason: Option<IssueReason>, | |
| 38 | + | reason: Option<CloseReason>, | |
| 33 | 39 | status: Option<PullStatus>, | |
| 34 | 40 | created_at: String, | |
| 35 | 41 | updated_at: String, | |
| ⋯ | |||
| 96 | 102 | } | |
| 97 | 103 | } | |
| 98 | 104 | ||
| 99 | − | /// The work SQL for one page: the person's issues and pull requests in | |
| 100 | − | /// `visible` as one list, filtered, ordered and cut to `limit + 1` rows. | |
| 101 | − | fn page_sql(a: &ByAuthorArgs, params: &mut Params, author: &str, visible: &str, limit: u32) -> String { | |
| 105 | + | /// The work SQL for one page: the person's pull requests in `visible`, and | |
| 106 | + | /// their work items filed from one of those or of a workspace in | |
| 107 | + | /// `members`, as one list, filtered, ordered and cut to `limit + 1` rows. | |
| 108 | + | fn page_sql(a: &ByAuthorArgs, params: &mut Params, author: &str, visible: &str, members: &str, limit: u32) -> String { | |
| 102 | 109 | let author = params.push(author); | |
| 103 | 110 | let visible = params.push(visible); | |
| 111 | + | let members = params.push(members); | |
| 104 | 112 | let mut conditions = Vec::new(); | |
| 105 | 113 | if let Some(kind) = a.kind { | |
| 106 | 114 | conditions.push(format!( | |
| 107 | 115 | "kind = {}", | |
| 108 | 116 | params.push(match kind { | |
| 109 | − | AuthoredKind::Issue => "issue", | |
| 117 | + | AuthoredKind::Item => "item", | |
| 110 | 118 | AuthoredKind::Pull => "pull", | |
| 111 | 119 | }) | |
| 112 | 120 | )); | |
| ⋯ | |||
| 137 | 145 | let limit = params.push(limit + 1); | |
| 138 | 146 | format!( | |
| 139 | 147 | "SELECT * FROM ( | |
| 140 | − | SELECT 'issue' AS kind, id, repo_id, number, title, state, reason, | |
| 141 | − | NULL AS status, created_at, updated_at, NULL AS merged_at | |
| 142 | − | FROM issues | |
| 143 | − | WHERE COALESCE(requested_by_id, author_id) = {author} AND repo_id IN (SELECT value FROM json_each({visible})) | |
| 148 | + | SELECT 'item' AS kind, i.id AS id, o.repo_id AS repo_id, i.workspace AS workspace, i.number AS number, | |
| 149 | + | i.title AS title, i.state AS state, i.reason AS reason, | |
| 150 | + | NULL AS status, i.created_at AS created_at, i.updated_at AS updated_at, NULL AS merged_at | |
| 151 | + | FROM work_items i LEFT JOIN work_item_repos o ON o.item_id = i.id AND o.role = 'origin' | |
| 152 | + | WHERE COALESCE(i.requested_by_id, i.author_id) = {author} AND i.number IS NOT NULL | |
| 153 | + | AND (o.repo_id IN (SELECT value FROM json_each({visible})) | |
| 154 | + | OR i.workspace IN (SELECT value FROM json_each({members}))) | |
| 144 | 155 | UNION ALL | |
| 145 | − | SELECT 'pull' AS kind, id, repo_id, number, title, | |
| 156 | + | SELECT 'pull' AS kind, id, repo_id, NULL AS workspace, number, title, | |
| 146 | 157 | CASE WHEN status IN ('draft', 'open') THEN 'open' ELSE 'closed' END AS state, | |
| 147 | 158 | NULL AS reason, status, created_at, updated_at, merged_at | |
| 148 | 159 | FROM pulls | |
| ⋯ | |||
| 161 | 172 | AND repo_id IN (SELECT value FROM json_each(?2)) AND status IN ('draft', 'open')) AS pulls_open, | |
| 162 | 173 | (SELECT count(*) FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 | |
| 163 | 174 | AND repo_id IN (SELECT value FROM json_each(?2))) AS pulls, | |
| 164 | − | (SELECT count(*) FROM issues WHERE COALESCE(requested_by_id, author_id) = ?1 | |
| 165 | − | AND repo_id IN (SELECT value FROM json_each(?2))) AS issues, | |
| 166 | − | (SELECT count(*) FROM issues WHERE COALESCE(requested_by_id, author_id) = ?1 | |
| 167 | − | AND repo_id IN (SELECT value FROM json_each(?2)) AND state = 'open') AS issues_open"; | |
| 175 | + | (SELECT count(*) FROM work_items i WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.number IS NOT NULL | |
| 176 | + | AND (i.workspace IN (SELECT value FROM json_each(?3)) OR EXISTS (SELECT 1 FROM work_item_repos o | |
| 177 | + | WHERE o.item_id = i.id AND o.role = 'origin' AND o.repo_id IN (SELECT value FROM json_each(?2))))) AS items, | |
| 178 | + | (SELECT count(*) FROM work_items i WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.number IS NOT NULL | |
| 179 | + | AND i.state = 'open' | |
| 180 | + | AND (i.workspace IN (SELECT value FROM json_each(?3)) OR EXISTS (SELECT 1 FROM work_item_repos o | |
| 181 | + | WHERE o.item_id = i.id AND o.role = 'origin' AND o.repo_id IN (SELECT value FROM json_each(?2))))) AS items_open"; | |
| 168 | 182 | ||
| 169 | 183 | #[derive(Deserialize)] | |
| 170 | 184 | #[serde(rename_all = "snake_case")] | |
| ⋯ | |||
| 172 | 186 | pulls_merged: u32, | |
| 173 | 187 | pulls_open: u32, | |
| 174 | 188 | pulls: u32, | |
| 175 | − | issues: u32, | |
| 176 | − | issues_open: u32, | |
| 189 | + | items: u32, | |
| 190 | + | items_open: u32, | |
| 177 | 191 | } | |
| 178 | 192 | ||
| 179 | 193 | /// Whether `repo` is the one named `namespace/name`. | |
| ⋯ | |||
| 202 | 216 | .db | |
| 203 | 217 | .prepare( | |
| 204 | 218 | "SELECT repo_id, count(*) AS n FROM ( | |
| 205 | − | SELECT repo_id FROM issues WHERE COALESCE(requested_by_id, author_id) = ?1 | |
| 219 | + | SELECT o.repo_id AS repo_id FROM work_items i JOIN work_item_repos o ON o.item_id = i.id AND o.role = 'origin' | |
| 220 | + | WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 | |
| 206 | 221 | UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 | |
| 207 | 222 | ) GROUP BY repo_id ORDER BY n DESC LIMIT ?2", | |
| 208 | 223 | ) | |
| ⋯ | |||
| 210 | 225 | .all() | |
| 211 | 226 | .await? | |
| 212 | 227 | .results::<RepoCount>()?; | |
| 213 | − | if touched.is_empty() { | |
| 228 | + | // The workspaces whose work items the viewer sees as a member. | |
| 229 | + | let members: Vec<String> = a | |
| 230 | + | .viewer | |
| 231 | + | .as_ref() | |
| 232 | + | .map(|viewer| { | |
| 233 | + | viewer | |
| 234 | + | .workspaces | |
| 235 | + | .iter() | |
| 236 | + | .map(|membership| membership.slug.to_lowercase()) | |
| 237 | + | .filter(|slug| g1t_contracts::access::workspace_role(viewer, slug).is_some()) | |
| 238 | + | .collect() | |
| 239 | + | }) | |
| 240 | + | .unwrap_or_default(); | |
| 241 | + | if touched.is_empty() && members.is_empty() { | |
| 214 | 242 | return Ok(Outcome::Ok(Authored::default())); | |
| 215 | 243 | } | |
| 216 | 244 | // ...and of those, the ones the viewer may read. | |
| ⋯ | |||
| 236 | 264 | }) | |
| 237 | 265 | }) | |
| 238 | 266 | .collect(); | |
| 239 | − | if readable.is_empty() { | |
| 267 | + | if readable.is_empty() && members.is_empty() { | |
| 240 | 268 | return Ok(Outcome::Ok(Authored::default())); | |
| 241 | 269 | } | |
| 242 | 270 | ||
| 243 | 271 | let all_ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect(); | |
| 244 | 272 | let all = serde_json::to_string(&all_ids)?; | |
| 273 | + | let member_list = serde_json::to_string(&members)?; | |
| 245 | 274 | let counts = self | |
| 246 | 275 | .db | |
| 247 | 276 | .prepare(COUNTS_SQL) | |
| 248 | − | .bind(&[person.id.as_str().into(), all.as_str().into()])? | |
| 277 | + | .bind(&[person.id.as_str().into(), all.as_str().into(), member_list.as_str().into()])? | |
| 249 | 278 | .first::<CountsRow>(None) | |
| 250 | 279 | .await? | |
| 251 | 280 | .map(|row| AuthoredCounts { | |
| 252 | 281 | pulls_merged: row.pulls_merged, | |
| 253 | 282 | pulls_open: row.pulls_open, | |
| 254 | 283 | pulls: row.pulls, | |
| 255 | − | issues: row.issues, | |
| 256 | − | issues_open: row.issues_open, | |
| 284 | + | items: row.items, | |
| 285 | + | items_open: row.items_open, | |
| 257 | 286 | }) | |
| 258 | 287 | .unwrap_or_default(); | |
| 259 | 288 | ||
| 260 | − | // A repository filter narrows the set; one the viewer cannot read, | |
| 261 | − | // or the person never worked in, leaves nothing. | |
| 262 | − | let shown = match a.repo.as_deref().map(str::trim).filter(|name| !name.is_empty()) { | |
| 263 | − | Some(name) => serde_json::to_string( | |
| 264 | − | &readable | |
| 265 | − | .iter() | |
| 266 | − | .filter(|repo| is_named(repo, name)) | |
| 267 | − | .map(|repo| repo.id.as_str()) | |
| 268 | − | .collect::<Vec<_>>(), | |
| 269 | − | )?, | |
| 270 | − | None => all, | |
| 289 | + | // A repository filter narrows the set to its pull requests and the | |
| 290 | + | // work items filed from it; one the viewer cannot read, or the | |
| 291 | + | // person never worked in, leaves nothing. | |
| 292 | + | let (shown, member_list) = match a.repo.as_deref().map(str::trim).filter(|name| !name.is_empty()) { | |
| 293 | + | Some(name) => ( | |
| 294 | + | serde_json::to_string( | |
| 295 | + | &readable | |
| 296 | + | .iter() | |
| 297 | + | .filter(|repo| is_named(repo, name)) | |
| 298 | + | .map(|repo| repo.id.as_str()) | |
| 299 | + | .collect::<Vec<_>>(), | |
| 300 | + | )?, | |
| 301 | + | "[]".to_owned(), | |
| 302 | + | ), | |
| 303 | + | None => (all, member_list), | |
| 271 | 304 | }; | |
| 272 | 305 | let limit = a.limit.unwrap_or(AUTHORED_PAGE).clamp(1, AUTHORED_PAGE); | |
| 273 | 306 | let mut params = Params(Vec::new()); | |
| 274 | − | let sql = page_sql(&a, &mut params, &person.id, &shown, limit); | |
| 307 | + | let sql = page_sql(&a, &mut params, &person.id, &shown, &member_list, limit); | |
| 275 | 308 | let mut rows = self | |
| 276 | 309 | .db | |
| 277 | 310 | .prepare(sql) | |
| ⋯ | |||
| 288 | 321 | let items = rows | |
| 289 | 322 | .into_iter() | |
| 290 | 323 | .filter_map(|row| { | |
| 291 | − | let repo = by_id.get(row.repo_id.as_str())?; | |
| 324 | + | let repo = row.repo_id.as_deref().and_then(|id| by_id.get(id)); | |
| 325 | + | // A pull request is shown only with its repository. | |
| 326 | + | if row.kind == AuthoredKind::Pull && repo.is_none() { | |
| 327 | + | return None; | |
| 328 | + | } | |
| 329 | + | let workspace = row.workspace.clone().or_else(|| repo.map(|repo| repo.namespace.to_lowercase()))?; | |
| 292 | 330 | Some(AuthoredItem { | |
| 293 | 331 | kind: row.kind, | |
| 294 | − | repo: RepoPath { | |
| 295 | − | namespace: repo.namespace.clone(), | |
| 296 | − | name: repo.name.clone(), | |
| 297 | − | }, | |
| 332 | + | workspace, | |
| 333 | + | repo: repo.map(|repo| RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }), | |
| 298 | 334 | number: row.number, | |
| 299 | 335 | title: row.title, | |
| 300 | 336 | state: row.state, | |
| ⋯ | |||
| 338 | 374 | ItemRow { | |
| 339 | 375 | kind: AuthoredKind::Pull, | |
| 340 | 376 | id: id.into(), | |
| 341 | − | repo_id: "rep_1".into(), | |
| 377 | + | repo_id: Some("rep_1".into()), | |
| 378 | + | workspace: None, | |
| 342 | 379 | number: 1, | |
| 343 | 380 | title: "t".into(), | |
| 344 | 381 | state: State::Open, | |
| ⋯ | |||
| 353 | 390 | #[test] | |
| 354 | 391 | fn every_query_is_confined_to_the_readable_set() { | |
| 355 | 392 | let mut params = Params(Vec::new()); | |
| 356 | − | let sql = page_sql(&args(), &mut params, "usr_1", "[]", 25); | |
| 393 | + | let sql = page_sql(&args(), &mut params, "usr_1", "[]", "[]", 25); | |
| 357 | 394 | assert_eq!(sql.matches("json_each(?2)").count(), 2); | |
| 358 | − | assert_eq!(sql.matches("COALESCE(requested_by_id, author_id) = ?1").count(), 2); | |
| 395 | + | assert_eq!(sql.matches("json_each(?3)").count(), 1); | |
| 396 | + | assert_eq!(sql.matches("requested_by_id, ").count(), 2); | |
| 359 | 397 | assert_eq!(COUNTS_SQL.matches("json_each(?2)").count(), 5); | |
| 360 | − | assert!(sql.contains("LIMIT ?3")); | |
| 398 | + | assert!(sql.contains("LIMIT ?4")); | |
| 361 | 399 | assert!(sql.contains("ORDER BY created_at DESC, id DESC")); | |
| 362 | 400 | } | |
| 363 | 401 | ||
| ⋯ | |||
| 371 | 409 | before: Some("2026-10-01T00:00:00.000Z|pul_9".into()), | |
| 372 | 410 | ..args() | |
| 373 | 411 | }; | |
| 374 | − | let sql = page_sql(&a, &mut params, "usr_1", "[]", 10); | |
| 375 | − | assert!(sql.contains("kind = ?3")); | |
| 412 | + | let sql = page_sql(&a, &mut params, "usr_1", "[]", "[]", 10); | |
| 413 | + | assert!(sql.contains("kind = ?4")); | |
| 376 | 414 | assert!(sql.contains("status = 'merged'")); | |
| 377 | − | assert!(sql.contains("(created_at > ?4 OR (created_at = ?4 AND id > ?5))")); | |
| 415 | + | assert!(sql.contains("(created_at > ?5 OR (created_at = ?5 AND id > ?6))")); | |
| 378 | 416 | assert!(sql.contains("ORDER BY created_at ASC, id ASC")); | |
| 379 | − | assert!(sql.contains("LIMIT ?6")); | |
| 380 | − | assert_eq!(params.0.len(), 6); | |
| 417 | + | assert!(sql.contains("LIMIT ?7")); | |
| 418 | + | assert_eq!(params.0.len(), 7); | |
| 381 | 419 | } | |
| 382 | 420 | ||
| 383 | 421 | #[test] | |
| ⋯ | |||
| 387 | 425 | before: Some("nonsense".into()), | |
| 388 | 426 | ..args() | |
| 389 | 427 | }; | |
| 390 | − | let sql = page_sql(&a, &mut params, "usr_1", "[]", 10); | |
| 391 | − | assert!(!sql.contains(" OR (")); | |
| 428 | + | let sql = page_sql(&a, &mut params, "usr_1", "[]", "[]", 10); | |
| 429 | + | assert!(!sql.contains("id < ")); | |
| 392 | 430 | assert_eq!(parse_cursor("a|b|c"), None); | |
| 393 | 431 | assert_eq!(parse_cursor("|b"), None); | |
| 394 | 432 | assert_eq!(parse_cursor("k|i"), Some(("k", "i"))); | |
| 424 | 424 | repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, | |
| 425 | 425 | number, | |
| 426 | 426 | summary: String::new(), | |
| 427 | − | keep_issue_open: false, | |
| 427 | + | keep_item_open: false, | |
| 428 | 428 | ignore_checks: false, | |
| 429 | 429 | bypass_rules: false, | |
| 430 | 430 | merge_method: Some(method), |
| 1 | 1 | //! Check runs: the record of the merge queue taking a pull request out, | |
| 2 | − | //! and of commands written on issues, which g1t ran before a pull | |
| 2 | + | //! and of commands written on work items, which g1t ran before a pull | |
| 3 | 3 | //! request's checks were the workflows run on it. Those runs are kept so | |
| 4 | 4 | //! their history still reads; a sandbox still finishing one reports | |
| 5 | 5 | //! through the API with the job's one-time token. |
| 1 | − | //! The issues a pull request says it closes: a closing keyword followed by | |
| 2 | − | //! an issue of the same repository, in its title or description, such as | |
| 3 | − | //! `Fixes #12`, `closes: #3` or `Resolves flagon-io/g1t#40`. They close | |
| 4 | − | //! when it merges into the default branch (`Work::record_merge`), where the | |
| 5 | − | //! repository closes issues that way (`RepoSettings::auto_close_issues`). | |
| 1 | + | //! The work items a pull request or a commit says it closes or is part of, | |
| 2 | + | //! and what landing them does. | |
| 6 | 3 | //! | |
| 7 | − | //! What sits in code, a `span` or a fenced block, says nothing: it is | |
| 8 | − | //! usually an example. | |
| 4 | + | //! A closing keyword followed by a reference (`Fixes #12`, `closes: #3`, | |
| 5 | + | //! `Resolves flagon-io/g1t#40`) names a work item to close; `Part of #12` | |
| 6 | + | //! or `Refs #12` one it is part of (`g1t_contracts::work::refs`). Each is | |
| 7 | + | //! read in the repository the pull request or commit is in, and only work | |
| 8 | + | //! items of that repository's workspace count: another workspace's are | |
| 9 | + | //! never closed from here. | |
| 10 | + | //! | |
| 11 | + | //! When a pull request merges into the default branch, or a commit naming | |
| 12 | + | //! a work item is pushed there, where the repository closes work items | |
| 13 | + | //! that way (`RepoSettings::auto_close_items`), the repository's link to | |
| 14 | + | //! each work item it closes is marked landed and the resolution recorded | |
| 15 | + | //! ([`LAND_SQL`]); the work item closes once it has landed in every target | |
| 16 | + | //! it has, or at once when it has none. What it is part of is linked | |
| 17 | + | //! (`touched`), and closes nothing. | |
| 9 | 18 | ||
| 10 | − | use g1t_contracts::repos::RepoPath; | |
| 19 | + | use g1t_contracts::events::WorkItemEvent; | |
| 20 | + | use g1t_contracts::repos::Repo; | |
| 21 | + | use g1t_contracts::time::rfc3339; | |
| 22 | + | use g1t_contracts::work::refs::{self, Ref, RefScope}; | |
| 23 | + | use g1t_contracts::work::*; | |
| 24 | + | use g1t_contracts::User; | |
| 25 | + | use g1t_kit::now_ms; | |
| 26 | + | use worker::Result; | |
| 27 | + | use worker::wasm_bindgen::JsValue; | |
| 11 | 28 | ||
| 12 | − | const KEYWORDS: [&str; 9] = ["close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"]; | |
| 29 | + | use crate::Work; | |
| 30 | + | use crate::items::{Held, Named, StoredResolution, landing_note, path_of}; | |
| 31 | + | use crate::rows::{By, ValueRow}; | |
| 13 | 32 | ||
| 14 | − | /// The most issues one pull request closes this way. | |
| 15 | − | pub(crate) const MAX_CLOSES: usize = 50; | |
| 33 | + | /// The most work items one pull request or commit closes this way. | |
| 34 | + | pub(crate) const MAX_CLOSES: usize = refs::MAX_REFS; | |
| 16 | 35 | ||
| 17 | − | /// The issue numbers `text` names after a closing keyword, in the order | |
| 18 | − | /// named, each once. `repo` is the repository the pull request is in: a | |
| 19 | − | /// reference naming another repository closes nothing. | |
| 20 | − | pub(crate) fn references(text: &str, repo: &RepoPath) -> Vec<u32> { | |
| 21 | − | let mut found = Vec::new(); | |
| 22 | − | for prose in outside_code(text) { | |
| 23 | − | scan(&prose, repo, &mut found); | |
| 24 | − | } | |
| 25 | − | found.truncate(MAX_CLOSES); | |
| 26 | − | found | |
| 36 | + | /// What landed: a pull request merged, or a commit pushed. | |
| 37 | + | #[derive(Clone, Debug, Default)] | |
| 38 | + | pub(crate) struct Landing { | |
| 39 | + | pub pull: Option<u32>, | |
| 40 | + | pub commit: Option<String>, | |
| 41 | + | pub pushed_by: Option<String>, | |
| 27 | 42 | } | |
| 28 | 43 | ||
| 29 | − | /// The issues a pull request closes: its own issue first, then those its | |
| 30 | − | /// title and description name. | |
| 31 | − | pub(crate) fn closes(issue: Option<u32>, title: &str, body: Option<&str>, repo: &RepoPath) -> Vec<u32> { | |
| 32 | − | let mut closes: Vec<u32> = issue.into_iter().collect(); | |
| 33 | − | let text = format!("{title}\n{}", body.unwrap_or_default()); | |
| 34 | − | for number in references(&text, repo) { | |
| 35 | − | if !closes.contains(&number) { | |
| 36 | − | closes.push(number); | |
| 37 | − | } | |
| 44 | + | /// Landing a work item (`?1`) in repository `?2` at `?3`, by `?4` (an | |
| 45 | + | /// account id), resolved by `?5` (a stored resolution, JSON), whose pull | |
| 46 | + | /// request and commit are `?6` and `?7`, in one batch: linked (as touched, | |
| 47 | + | /// unless it is already), marked landed there, the resolution recorded | |
| 48 | + | /// once, and closed when no target is left to land. | |
| 49 | + | pub(crate) const LAND_SQL: [&str; 4] = [ | |
| 50 | + | "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by) | |
| 51 | + | VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING", | |
| 52 | + | "UPDATE work_item_repos SET landed_at = COALESCE(landed_at, ?3) WHERE item_id = ?1 AND repo_id = ?2", | |
| 53 | + | "UPDATE work_items SET resolutions = json_insert(resolutions, '$[#]', json(?5)), updated_at = ?3 | |
| 54 | + | WHERE id = ?1 AND NOT EXISTS (SELECT 1 FROM json_each(work_items.resolutions) j | |
| 55 | + | WHERE json_extract(j.value, '$.repo_id') = ?2 AND json_extract(j.value, '$.pull') IS ?6 | |
| 56 | + | AND json_extract(j.value, '$.commit') IS ?7)", | |
| 57 | + | "UPDATE work_items SET state = 'closed', reason = 'completed', closed_at = ?3, updated_at = ?3 | |
| 58 | + | WHERE id = ?1 AND state = 'open' | |
| 59 | + | AND NOT EXISTS (SELECT 1 FROM work_item_repos WHERE item_id = ?1 AND role = 'target' AND landed_at IS NULL) | |
| 60 | + | RETURNING id AS value", | |
| 61 | + | ]; | |
| 62 | + | ||
| 63 | + | /// Linking a work item (`?1`) that a change in repository `?2` is part of, | |
| 64 | + | /// at `?3`, by `?4`, unless it is linked already. | |
| 65 | + | pub(crate) const PART_SQL: &str = "INSERT INTO work_item_repos (item_id, repo_id, role, added_at, added_by) | |
| 66 | + | VALUES (?1, ?2, 'touched', ?3, ?4) ON CONFLICT (item_id, repo_id) DO NOTHING RETURNING item_id AS value"; | |
| 67 | + | ||
| 68 | + | /// The work items a text names, by id and number, in the order named. | |
| 69 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 70 | + | pub(crate) struct NamedItems { | |
| 71 | + | pub closes: Vec<(String, u32)>, | |
| 72 | + | pub parts: Vec<(String, u32)>, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | impl NamedItems { | |
| 76 | + | pub(crate) fn close_ids(&self) -> Vec<String> { | |
| 77 | + | self.closes.iter().map(|(id, _)| id.clone()).collect() | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub(crate) fn part_ids(&self) -> Vec<String> { | |
| 81 | + | self.parts.iter().map(|(id, _)| id.clone()).collect() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub(crate) fn close_numbers(&self) -> Vec<u32> { | |
| 85 | + | self.closes.iter().map(|(_, number)| *number).collect() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub(crate) fn part_numbers(&self) -> Vec<u32> { | |
| 89 | + | self.parts.iter().map(|(_, number)| *number).collect() | |
| 38 | 90 | } | |
| 39 | − | closes.truncate(MAX_CLOSES); | |
| 40 | − | closes | |
| 41 | 91 | } | |
| 42 | 92 | ||
| 43 | − | /// The parts of Markdown outside fenced code blocks and code spans, each | |
| 44 | − | /// with code replaced by a space so words on either side stay apart. | |
| 45 | − | fn outside_code(text: &str) -> Vec<String> { | |
| 46 | − | let mut parts = Vec::new(); | |
| 47 | − | let mut current = String::new(); | |
| 48 | − | // The fence that opened the block being skipped: its character and length. | |
| 49 | − | let mut fence: Option<(char, usize)> = None; | |
| 50 | − | for line in text.lines() { | |
| 51 | − | let trimmed = line.trim_start(); | |
| 52 | − | let opener = ['`', '~'].into_iter().find_map(|mark| { | |
| 53 | − | let run = trimmed.chars().take_while(|c| *c == mark).count(); | |
| 54 | − | (run >= 3).then_some((mark, run)) | |
| 55 | − | }); | |
| 56 | − | match (fence, opener) { | |
| 57 | − | (None, Some(opened)) => { | |
| 58 | − | fence = Some(opened); | |
| 59 | − | parts.push(std::mem::take(&mut current)); | |
| 60 | − | } | |
| 61 | − | (Some((mark, length)), Some((closing, run))) if closing == mark && run >= length && trimmed.trim_end().chars().all(|c| c == mark) => { | |
| 62 | − | fence = None; | |
| 93 | + | impl Work { | |
| 94 | + | /// The work item `reference` names, read in `repo`: its id, when it is | |
| 95 | + | /// one of the workspace's work items. | |
| 96 | + | pub(crate) async fn ref_item(&self, repo: &Repo, reference: &Ref) -> Result<Option<String>> { | |
| 97 | + | let workspace = repo.namespace.to_lowercase(); | |
| 98 | + | let (repo_id, in_repo) = match &reference.scope { | |
| 99 | + | RefScope::Here => (Some(repo.id.clone()), true), | |
| 100 | + | RefScope::Workspace(named) => { | |
| 101 | + | if *named != workspace { | |
| 102 | + | return Ok(None); | |
| 103 | + | } | |
| 104 | + | (None, false) | |
| 63 | 105 | } | |
| 64 | − | (Some(_), _) => {} | |
| 65 | − | (None, None) => { | |
| 66 | − | current.push_str(&without_spans(line)); | |
| 67 | − | current.push('\n'); | |
| 106 | + | RefScope::Repo(path) => { | |
| 107 | + | if !path.namespace.eq_ignore_ascii_case(&workspace) { | |
| 108 | + | return Ok(None); | |
| 109 | + | } | |
| 110 | + | let named = format!("{}/{}", path.namespace, path.name); | |
| 111 | + | if named.eq_ignore_ascii_case(&path_of(repo)) { | |
| 112 | + | (Some(repo.id.clone()), true) | |
| 113 | + | } else { | |
| 114 | + | let known = self | |
| 115 | + | .db | |
| 116 | + | .prepare("SELECT repo_id AS value FROM repo_workspaces WHERE lower(path) = lower(?)") | |
| 117 | + | .bind(&[named.as_str().into()])? | |
| 118 | + | .first::<ValueRow>(None) | |
| 119 | + | .await?; | |
| 120 | + | match known { | |
| 121 | + | Some(row) => (Some(row.value), true), | |
| 122 | + | None => (None, false), | |
| 123 | + | } | |
| 124 | + | } | |
| 68 | 125 | } | |
| 69 | − | } | |
| 126 | + | }; | |
| 127 | + | let found = self.lookup_ref(repo_id.as_deref(), &workspace, reference.number).await?; | |
| 128 | + | Ok(match found.named(in_repo) { | |
| 129 | + | Some(Named::Item(id)) => Some(id), | |
| 130 | + | _ => None, | |
| 131 | + | }) | |
| 70 | 132 | } | |
| 71 | − | parts.push(current); | |
| 72 | − | parts | |
| 73 | − | } | |
| 74 | 133 | ||
| 75 | − | /// A line with its code spans (between matching runs of backticks) blanked. | |
| 76 | − | fn without_spans(line: &str) -> String { | |
| 77 | − | let chars: Vec<char> = line.chars().collect(); | |
| 78 | − | let mut out = String::with_capacity(line.len()); | |
| 79 | − | let mut i = 0; | |
| 80 | − | while i < chars.len() { | |
| 81 | − | if chars[i] != '`' { | |
| 82 | − | out.push(chars[i]); | |
| 83 | − | i += 1; | |
| 84 | − | continue; | |
| 85 | − | } | |
| 86 | − | let run = chars[i..].iter().take_while(|c| **c == '`').count(); | |
| 87 | − | // The closing run of the same length, if there is one. | |
| 88 | − | let mut j = i + run; | |
| 89 | − | let mut close = None; | |
| 90 | − | while j < chars.len() { | |
| 91 | − | if chars[j] == '`' { | |
| 92 | − | let other = chars[j..].iter().take_while(|c| **c == '`').count(); | |
| 93 | − | if other == run { | |
| 94 | − | close = Some(j + other); | |
| 95 | − | break; | |
| 96 | − | } | |
| 97 | − | j += other; | |
| 98 | − | } else { | |
| 99 | − | j += 1; | |
| 134 | + | /// The work items `text`, written in `repo`, closes and is part of, | |
| 135 | + | /// with `own` (a pull request's own work item) closed first. | |
| 136 | + | pub(crate) async fn named_items(&self, repo: &Repo, own: Option<&str>, text: &str) -> Result<NamedItems> { | |
| 137 | + | let named = refs::named(text); | |
| 138 | + | let mut closes: Vec<String> = own.map(str::to_owned).into_iter().collect(); | |
| 139 | + | for reference in &named.closes { | |
| 140 | + | if let Some(id) = self.ref_item(repo, reference).await? | |
| 141 | + | && !closes.contains(&id) | |
| 142 | + | { | |
| 143 | + | closes.push(id); | |
| 100 | 144 | } | |
| 101 | 145 | } | |
| 102 | − | match close { | |
| 103 | − | Some(end) => { | |
| 104 | − | out.push(' '); | |
| 105 | − | i = end; | |
| 106 | − | } | |
| 107 | − | // An unmatched run is literal backticks. | |
| 108 | − | None => { | |
| 109 | − | out.extend(&chars[i..i + run]); | |
| 110 | − | i += run; | |
| 146 | + | let mut parts: Vec<String> = Vec::new(); | |
| 147 | + | for reference in &named.parts { | |
| 148 | + | if let Some(id) = self.ref_item(repo, reference).await? | |
| 149 | + | && !closes.contains(&id) | |
| 150 | + | && !parts.contains(&id) | |
| 151 | + | { | |
| 152 | + | parts.push(id); | |
| 111 | 153 | } | |
| 112 | 154 | } | |
| 155 | + | closes.truncate(MAX_CLOSES); | |
| 156 | + | parts.truncate(MAX_CLOSES); | |
| 157 | + | let mut all = closes.clone(); | |
| 158 | + | all.extend(parts.iter().cloned()); | |
| 159 | + | let held = self.items_by_ids(&all).await?; | |
| 160 | + | let number = |id: &String| held.iter().find(|held| held.item.id == *id).map(|held| (id.clone(), held.item.number)); | |
| 161 | + | Ok(NamedItems { closes: closes.iter().filter_map(number).collect(), parts: parts.iter().filter_map(number).collect() }) | |
| 113 | 162 | } | |
| 114 | − | out | |
| 115 | − | } | |
| 116 | − | ||
| 117 | − | fn is_word(c: char) -> bool { | |
| 118 | − | c.is_alphanumeric() || c == '_' | |
| 119 | − | } | |
| 120 | 163 | ||
| 121 | − | /// Every keyword-and-reference pair in `text`. | |
| 122 | − | fn scan(text: &str, repo: &RepoPath, found: &mut Vec<u32>) { | |
| 123 | − | let chars: Vec<char> = text.chars().collect(); | |
| 124 | − | let mut i = 0; | |
| 125 | − | while i < chars.len() { | |
| 126 | − | // A word starts here. | |
| 127 | − | if !is_word(chars[i]) || (i > 0 && is_word(chars[i - 1])) { | |
| 128 | − | i += 1; | |
| 129 | − | continue; | |
| 130 | − | } | |
| 131 | − | let end = chars[i..].iter().position(|c| !is_word(*c)).map_or(chars.len(), |n| i + n); | |
| 132 | − | let word: String = chars[i..end].iter().collect::<String>().to_lowercase(); | |
| 133 | − | i = end; | |
| 134 | − | if !KEYWORDS.contains(&word.as_str()) { | |
| 135 | − | continue; | |
| 136 | − | } | |
| 137 | − | // An optional colon, then spaces on the same line; without a colon, | |
| 138 | − | // at least one space. | |
| 139 | − | let mut at = end; | |
| 140 | − | let colon = chars.get(at) == Some(&':'); | |
| 141 | − | if colon { | |
| 142 | − | at += 1; | |
| 143 | − | } | |
| 144 | − | let spaces = chars[at..].iter().take_while(|c| **c == ' ' || **c == '\t').count(); | |
| 145 | − | if spaces == 0 && !colon { | |
| 146 | − | continue; | |
| 147 | − | } | |
| 148 | − | at += spaces; | |
| 149 | − | if let Some((number, after)) = reference(&chars, at, repo) { | |
| 150 | − | if !found.contains(&number) { | |
| 151 | − | found.push(number); | |
| 152 | − | } | |
| 153 | − | i = after; | |
| 164 | + | /// The work items a pull request closes and is part of as its title and | |
| 165 | + | /// description say now: none closed where it does not merge into the | |
| 166 | + | /// default branch, where the repository does not close work items that | |
| 167 | + | /// way, or where it is kept open (`keep_open`), when its own work item | |
| 168 | + | /// is only something it is part of. | |
| 169 | + | pub(crate) async fn items_to_close(&self, pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_open: bool) -> Result<NamedItems> { | |
| 170 | + | let own = self.pull_item_id(&pull.id).await?; | |
| 171 | + | let text = format!("{}\n{}", pull.title, pull.body.as_deref().unwrap_or_default()); | |
| 172 | + | let mut named = self.named_items(repo, own.as_deref(), &text).await?; | |
| 173 | + | if keep_open || !settings.auto_close_items || !pull.targets_default(&repo.default_branch) { | |
| 174 | + | let closed = std::mem::take(&mut named.closes); | |
| 175 | + | let mut parts = if keep_open { closed } else { Vec::new() }; | |
| 176 | + | parts.extend(named.parts); | |
| 177 | + | named.parts = parts; | |
| 154 | 178 | } | |
| 179 | + | Ok(named) | |
| 155 | 180 | } | |
| 156 | − | } | |
| 157 | 181 | ||
| 158 | − | /// An issue of `repo` referenced at `at`: `#12` or `owner/name#12`, ending | |
| 159 | − | /// at a word boundary. The number and where the reference ends. | |
| 160 | − | fn reference(chars: &[char], at: usize, repo: &RepoPath) -> Option<(u32, usize)> { | |
| 161 | − | let hash = if chars.get(at) == Some(&'#') { | |
| 162 | − | at | |
| 163 | − | } else { | |
| 164 | − | // owner/name#N, naming this repository. | |
| 165 | − | let length = chars[at..].iter().position(|c| *c == '#' || c.is_whitespace()).unwrap_or(chars.len() - at); | |
| 166 | − | let hash = at + length; | |
| 167 | − | if chars.get(hash) != Some(&'#') { | |
| 168 | − | return None; | |
| 182 | + | /// The id of a pull request's own work item, if it has one. | |
| 183 | + | pub(crate) async fn pull_item_id(&self, pull_id: &str) -> Result<Option<String>> { | |
| 184 | + | Ok(self | |
| 185 | + | .db | |
| 186 | + | .prepare("SELECT item_id AS value FROM pulls WHERE id = ? AND item_id IS NOT NULL") | |
| 187 | + | .bind(&[pull_id.into()])? | |
| 188 | + | .first::<ValueRow>(None) | |
| 189 | + | .await? | |
| 190 | + | .map(|row| row.value)) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Lands `closes` (work items, by id) in `repo` by `landing`, and links | |
| 194 | + | /// `parts`: see the module's notes. Says so on each, with the session | |
| 195 | + | /// it was done in when one is known; `agent` when an agent pushed for | |
| 196 | + | /// `actor` (the note is then g1t's, for them). Returns the work items | |
| 197 | + | /// it closed. | |
| 198 | + | #[allow(clippy::too_many_arguments)] | |
| 199 | + | pub(crate) async fn land( | |
| 200 | + | &self, | |
| 201 | + | repo: &Repo, | |
| 202 | + | landing: &Landing, | |
| 203 | + | actor: &User, | |
| 204 | + | closes: &[String], | |
| 205 | + | parts: &[String], | |
| 206 | + | session: Option<&str>, | |
| 207 | + | agent: bool, | |
| 208 | + | ) -> Result<Vec<Held>> { | |
| 209 | + | let now = rfc3339(now_ms()); | |
| 210 | + | let path = path_of(repo); | |
| 211 | + | let by = match By::from(actor) { | |
| 212 | + | by if agent && by.acting_for.is_none() && by.id == actor.id => By { | |
| 213 | + | id: g1t_contracts::identity::AGENT_ID, | |
| 214 | + | name: g1t_contracts::identity::AGENT_NAME, | |
| 215 | + | acting_for: Some((&actor.id, &actor.username)), | |
| 216 | + | }, | |
| 217 | + | by => by, | |
| 218 | + | }; | |
| 219 | + | let stored = StoredResolution { | |
| 220 | + | repo_id: repo.id.clone(), | |
| 221 | + | pull: landing.pull, | |
| 222 | + | commit: landing.commit.clone(), | |
| 223 | + | pushed_by: landing.pushed_by.clone(), | |
| 224 | + | }; | |
| 225 | + | let resolution = Resolution { | |
| 226 | + | repo: path.clone(), | |
| 227 | + | pull: landing.pull, | |
| 228 | + | commit: landing.commit.clone(), | |
| 229 | + | pushed_by: landing.pushed_by.clone(), | |
| 230 | + | }; | |
| 231 | + | let what = landing_note(&path, landing.pull, landing.commit.as_deref()); | |
| 232 | + | let mut closed = Vec::new(); | |
| 233 | + | for id in closes { | |
| 234 | + | let Some(before) = self.item_by_id(id).await? else { continue }; | |
| 235 | + | let binds: [JsValue; 7] = [ | |
| 236 | + | id.as_str().into(), | |
| 237 | + | repo.id.as_str().into(), | |
| 238 | + | now.as_str().into(), | |
| 239 | + | actor.id.as_str().into(), | |
| 240 | + | serde_json::to_string(&stored)?.into(), | |
| 241 | + | landing.pull.map_or(JsValue::NULL, JsValue::from), | |
| 242 | + | landing.commit.as_deref().map_or(JsValue::NULL, JsValue::from), | |
| 243 | + | ]; | |
| 244 | + | let statements = LAND_SQL | |
| 245 | + | .iter() | |
| 246 | + | .map(|sql| self.db.prepare(*sql).bind(&binds[..g1t_kit::rename::parameters(sql)])) | |
| 247 | + | .collect::<Result<Vec<_>>>()?; | |
| 248 | + | let results = self.db.batch(statements).await?; | |
| 249 | + | let closed_now = results | |
| 250 | + | .get(3) | |
| 251 | + | .map(|result| result.results::<ValueRow>()) | |
| 252 | + | .transpose()? | |
| 253 | + | .is_some_and(|rows| !rows.is_empty()); | |
| 254 | + | let Some(held) = self.item_by_id(id).await? else { continue }; | |
| 255 | + | if closed_now { | |
| 256 | + | self.publish_item( | |
| 257 | + | "work_item.closed", | |
| 258 | + | &held, | |
| 259 | + | Some(actor), | |
| 260 | + | WorkItemEvent { | |
| 261 | + | reason: Some(CloseReason::Completed.as_str()), | |
| 262 | + | resolution: Some(resolution.clone()), | |
| 263 | + | missing: Some(Vec::new()), | |
| 264 | + | session: session.map(str::to_owned), | |
| 265 | + | ..Self::item_event(&held) | |
| 266 | + | }, | |
| 267 | + | ) | |
| 268 | + | .await?; | |
| 269 | + | let text = match landing.pull { | |
| 270 | + | Some(_) => format!("closed this by merging {what}"), | |
| 271 | + | None => format!("closed this in {what}"), | |
| 272 | + | }; | |
| 273 | + | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; | |
| 274 | + | self.item_resolved(&held, actor, true, landing.pull).await; | |
| 275 | + | closed.push(held); | |
| 276 | + | continue; | |
| 277 | + | } | |
| 278 | + | self.publish_item( | |
| 279 | + | "work_item.landed", | |
| 280 | + | &held, | |
| 281 | + | Some(actor), | |
| 282 | + | WorkItemEvent { | |
| 283 | + | resolution: Some(resolution.clone()), | |
| 284 | + | missing: Some(held.item.missing()), | |
| 285 | + | session: session.map(str::to_owned), | |
| 286 | + | ..Self::item_event(&held) | |
| 287 | + | }, | |
| 288 | + | ) | |
| 289 | + | .await?; | |
| 290 | + | if before.item.state == State::Open { | |
| 291 | + | let missing = held.item.missing(); | |
| 292 | + | let text = if missing.is_empty() { | |
| 293 | + | format!("landed {what}") | |
| 294 | + | } else { | |
| 295 | + | format!("landed {what}; still to land in {}", missing.join(", ")) | |
| 296 | + | }; | |
| 297 | + | self.note_item(id, by, &crate::pushed::closed_note(&text, session)).await?; | |
| 298 | + | // Merged for it: what it blocks hears, though it stays open. | |
| 299 | + | self.item_resolved(&held, actor, false, landing.pull).await; | |
| 300 | + | } | |
| 169 | 301 | } | |
| 170 | − | let named: String = chars[at..hash].iter().collect(); | |
| 171 | − | let (owner, name) = named.split_once('/')?; | |
| 172 | − | if !owner.eq_ignore_ascii_case(&repo.namespace) || !name.eq_ignore_ascii_case(&repo.name) { | |
| 173 | − | return None; | |
| 302 | + | for id in parts { | |
| 303 | + | let linked = self | |
| 304 | + | .db | |
| 305 | + | .prepare(PART_SQL) | |
| 306 | + | .bind(&[id.as_str().into(), repo.id.as_str().into(), now.as_str().into(), actor.id.as_str().into()])? | |
| 307 | + | .first::<ValueRow>(None) | |
| 308 | + | .await?; | |
| 309 | + | if linked.is_none() { | |
| 310 | + | continue; | |
| 311 | + | } | |
| 312 | + | let Some(held) = self.item_by_id(id).await? else { continue }; | |
| 313 | + | self.publish_item( | |
| 314 | + | "work_item.linked", | |
| 315 | + | &held, | |
| 316 | + | Some(actor), | |
| 317 | + | WorkItemEvent { repo: Some(path.clone()), role: Some(LinkRole::Touched), ..Self::item_event(&held) }, | |
| 318 | + | ) | |
| 319 | + | .await?; | |
| 320 | + | self.note_item(id, by, &format!("linked {path}: {what} is part of this")).await?; | |
| 174 | 321 | } | |
| 175 | − | hash | |
| 176 | − | }; | |
| 177 | − | let digits = chars[hash + 1..].iter().take_while(|c| c.is_ascii_digit()).count(); | |
| 178 | − | if digits == 0 || digits > 9 { | |
| 179 | − | return None; | |
| 180 | − | } | |
| 181 | − | let after = hash + 1 + digits; | |
| 182 | − | if chars.get(after).is_some_and(|c| is_word(*c)) { | |
| 183 | − | return None; | |
| 322 | + | Ok(closed) | |
| 184 | 323 | } | |
| 185 | − | let number: u32 = chars[hash + 1..after].iter().collect::<String>().parse().ok()?; | |
| 186 | − | (number > 0).then_some((number, after)) | |
| 187 | 324 | } | |
| 188 | 325 | ||
| 189 | 326 | #[cfg(test)] | |
| 190 | 327 | mod tests { | |
| 191 | 328 | use super::*; | |
| 192 | − | ||
| 193 | − | fn repo() -> RepoPath { | |
| 194 | − | RepoPath { namespace: "flagon-io".into(), name: "g1t".into() } | |
| 195 | − | } | |
| 196 | − | ||
| 197 | − | fn refs(text: &str) -> Vec<u32> { | |
| 198 | − | references(text, &repo()) | |
| 199 | − | } | |
| 200 | − | ||
| 201 | − | #[test] | |
| 202 | − | fn every_keyword_closes_whatever_its_case() { | |
| 203 | − | for keyword in KEYWORDS { | |
| 204 | − | assert_eq!(refs(&format!("{keyword} #7")), vec![7], "{keyword}"); | |
| 205 | − | assert_eq!(refs(&format!("{} #7", keyword.to_uppercase())), vec![7], "{keyword}"); | |
| 206 | − | } | |
| 207 | − | assert_eq!(refs("Fixes: #12"), vec![12]); | |
| 208 | − | assert_eq!(refs("fixes:#12"), vec![12]); | |
| 209 | − | assert_eq!(refs("This fixes #3 and resolves #4, closes #3 again."), vec![3, 4]); | |
| 210 | − | } | |
| 211 | − | ||
| 212 | − | #[test] | |
| 213 | − | fn only_whole_words_and_whole_numbers() { | |
| 214 | − | assert!(refs("prefixes #3").is_empty()); | |
| 215 | − | assert!(refs("fixesthis #3").is_empty()); | |
| 216 | − | assert!(refs("fixes#3").is_empty(), "no space and no colon"); | |
| 217 | − | assert!(refs("fixes #3a").is_empty()); | |
| 218 | − | assert!(refs("fixes #").is_empty()); | |
| 219 | − | assert!(refs("fixes #0").is_empty()); | |
| 220 | − | assert!(refs("fixes\n#3").is_empty(), "on the same line"); | |
| 221 | − | assert_eq!(refs("(fixes #3)."), vec![3]); | |
| 222 | − | assert!(refs("fixes issue #3").is_empty(), "the reference follows the keyword"); | |
| 223 | − | } | |
| 224 | 329 | ||
| 225 | 330 | #[test] | |
| 226 | − | fn a_reference_to_this_repository_by_name_counts_and_others_do_not() { | |
| 227 | − | assert_eq!(refs("Closes flagon-io/g1t#40"), vec![40]); | |
| 228 | − | assert_eq!(refs("closes Flagon-IO/G1T#40"), vec![40]); | |
| 229 | − | assert!(refs("closes someone/else#40").is_empty()); | |
| 230 | − | assert!(refs("closes flagon-io/g1t-docs#40").is_empty()); | |
| 231 | − | assert!(refs("closes g1t#40").is_empty()); | |
| 232 | − | } | |
| 233 | − | ||
| 234 | − | #[test] | |
| 235 | − | fn code_says_nothing() { | |
| 236 | − | assert!(refs("Write `fixes #3` to close an issue.").is_empty()); | |
| 237 | − | assert!(refs("Write ``fixes #3 `x` `` here.").is_empty()); | |
| 238 | − | assert!(refs("```\nfixes #3\n```").is_empty()); | |
| 239 | − | assert!(refs("~~~md\nfixes #3\n~~~").is_empty()); | |
| 240 | − | assert_eq!(refs("```\nfixes #3\n```\nfixes #4"), vec![4]); | |
| 241 | − | // An unclosed span is just a backtick. | |
| 242 | − | assert_eq!(refs("a ` fixes #5"), vec![5]); | |
| 243 | − | // An unclosed fence hides the rest. | |
| 244 | − | assert!(refs("````\nfixes #6\n```\nfixes #7").is_empty()); | |
| 245 | − | } | |
| 246 | − | ||
| 247 | − | #[test] | |
| 248 | − | fn its_own_issue_comes_first_and_nothing_twice() { | |
| 249 | − | assert_eq!(closes(Some(12), "Fix it", Some("Fixes #12, fixes #3"), &repo()), vec![12, 3]); | |
| 250 | − | assert_eq!(closes(None, "Resolve #9", None, &repo()), vec![9]); | |
| 251 | − | assert!(closes(None, "Nothing", Some(""), &repo()).is_empty()); | |
| 252 | − | let many: String = (1..=80).map(|n| format!("fixes #{n} ")).collect(); | |
| 253 | − | assert_eq!(closes(None, "", Some(&many), &repo()).len(), MAX_CLOSES); | |
| 331 | + | fn landing_statements_take_the_item_repository_and_what_landed() { | |
| 332 | + | let counts: Vec<usize> = LAND_SQL.iter().map(|sql| g1t_kit::rename::parameters(sql)).collect(); | |
| 333 | + | assert_eq!(counts, vec![4, 3, 7, 3]); | |
| 334 | + | assert_eq!(g1t_kit::rename::parameters(PART_SQL), 4); | |
| 254 | 335 | } | |
| 255 | 336 | } |
| 4 | 4 | //! Anyone who can read the repository reads them, and comments unless the | |
| 5 | 5 | //! repository turned commit comments off (`allow_commit_comments`); those | |
| 6 | 6 | //! already written stay readable either way. Only its author edits one; | |
| 7 | − | //! its author or a maintainer (`ManageSettings`, as for issue comments) | |
| 7 | + | //! its author or a maintainer (`ManageSettings`, as for work item comments) | |
| 8 | 8 | //! deletes it. Each change is published as `commit_comment.created`, | |
| 9 | 9 | //! `.edited` or `.deleted` with the comment, and the inbox tells the | |
| 10 | 10 | //! commit's author (when their address is a g1t account's) and whoever it | |
| ⋯ | |||
| 284 | 284 | data.excerpt = Some(excerpt(&comment.body)); | |
| 285 | 285 | data.mentions = people_mentioned(&comment.body); | |
| 286 | 286 | let teams = teams_mentioned(&comment.body); | |
| 287 | − | match self.team_mentions(&repo.id, &teams, &a.actor.id, &a.actor.username).await { | |
| 287 | + | match self.team_mentions(Some(&repo.id), &teams, &a.actor.id, &a.actor.username).await { | |
| 288 | 288 | Ok(told) => data.team_mentions = told, | |
| 289 | 289 | Err(error) => worker::console_error!("commit comment team mentions not resolved: {error}"), | |
| 290 | 290 | } | |
| ⋯ | |||
| 505 | 505 | /// module and a purge run against it. | |
| 506 | 506 | #[test] | |
| 507 | 507 | fn the_table_holds_comments_by_commit_and_goes_with_its_repository() { | |
| 508 | − | let db = rusqlite::Connection::open_in_memory().unwrap(); | |
| 509 | − | let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); | |
| 510 | − | let mut names: Vec<String> = std::fs::read_dir(dir) | |
| 511 | − | .unwrap() | |
| 512 | − | .map(|entry| entry.unwrap().file_name().into_string().unwrap()) | |
| 513 | − | .filter(|name| name.ends_with(".sql")) | |
| 514 | − | .collect(); | |
| 515 | − | names.sort(); | |
| 516 | − | for name in names { | |
| 517 | − | db.execute_batch(&std::fs::read_to_string(format!("{dir}/{name}")).unwrap()).unwrap(); | |
| 518 | − | } | |
| 508 | + | let db = crate::migrated::database(); | |
| 519 | 509 | let insert = "INSERT INTO commit_comments | |
| 520 | 510 | (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at) | |
| 521 | 511 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; | |
| 1 | 1 | //! A person's year, for the contribution calendar on their profile: how | |
| 2 | − | //! many commits they pushed to a default branch (or `gh-pages`), issues and | |
| 3 | − | //! pull requests they opened (or g1t opened for them, see `requested_by`) | |
| 2 | + | //! many commits they pushed to a default branch (or `gh-pages`), work items | |
| 3 | + | //! (filed from a repository) and pull requests they opened (or g1t opened for them, see `requested_by`) | |
| 4 | 4 | //! and reviews they gave, day by day (UTC). | |
| 5 | 5 | //! | |
| 6 | 6 | //! Commits are the repos service's: it credits whoever pushed, as each | |
| ⋯ | |||
| 26 | 26 | ||
| 27 | 27 | /// Every repository the person did something in since `?2`, busiest first. | |
| 28 | 28 | const TOUCHED_SQL: &str = "SELECT repo_id, count(*) AS n FROM ( | |
| 29 | − | SELECT repo_id FROM issues WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2 | |
| 29 | + | SELECT o.repo_id AS repo_id FROM work_items i JOIN work_item_repos o ON o.item_id = i.id AND o.role = 'origin' | |
| 30 | + | WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.created_at >= ?2 | |
| 30 | 31 | UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2 | |
| 31 | 32 | UNION ALL SELECT repo_id FROM comments WHERE author_id = ?1 AND created_at >= ?2 AND verdict IS NOT NULL | |
| 32 | 33 | ) GROUP BY repo_id ORDER BY n DESC LIMIT ?3"; | |
| ⋯ | |||
| 34 | 35 | /// What they did each day since `?3`, in the repositories `?2` (a JSON | |
| 35 | 36 | /// array of ids) only. | |
| 36 | 37 | const DAYS_SQL: &str = "SELECT day, count(*) AS n FROM ( | |
| 37 | − | SELECT substr(created_at, 1, 10) AS day FROM issues | |
| 38 | − | WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3 | |
| 39 | − | AND repo_id IN (SELECT value FROM json_each(?2)) | |
| 38 | + | SELECT substr(i.created_at, 1, 10) AS day FROM work_items i | |
| 39 | + | JOIN work_item_repos o ON o.item_id = i.id AND o.role = 'origin' | |
| 40 | + | WHERE COALESCE(i.requested_by_id, i.author_id) = ?1 AND i.created_at >= ?3 | |
| 41 | + | AND o.repo_id IN (SELECT value FROM json_each(?2)) | |
| 40 | 42 | UNION ALL | |
| 41 | 43 | SELECT substr(created_at, 1, 10) AS day FROM pulls | |
| 42 | 44 | WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3 | |
| ⋯ | |||
| 123 | 125 | Ok(Outcome::Ok(tally(rows?, commits, from))) | |
| 124 | 126 | } | |
| 125 | 127 | ||
| 126 | − | /// Issues, pull requests and reviews a day, in repositories the viewer may read. | |
| 128 | + | /// Work items, pull requests and reviews a day, in repositories the viewer may read. | |
| 127 | 129 | async fn work_days(&self, person: &str, from: &str, viewer: Viewer) -> Result<Vec<DayRow>> { | |
| 128 | 130 | let touched = self | |
| 129 | 131 | .db | |
| 1 | 1 | //! Claims: an advisory, expiring "I'm working on #N". | |
| 2 | 2 | //! | |
| 3 | − | //! A claim is never a lock: anyone may claim an issue someone else has | |
| 3 | + | //! A claim is never a lock: anyone may claim a work item someone else has | |
| 4 | 4 | //! claimed, and is told who else is on it. It lapses unless renewed, by | |
| 5 | 5 | //! claiming again or by a heartbeat of the session it was made from, and | |
| 6 | − | //! ends when released, handed off, or when its issue closes. | |
| 6 | + | //! ends when released, handed off, or when its work item closes. It is on | |
| 7 | + | //! a work item of a workspace, and made from a checkout of one of the | |
| 8 | + | //! workspace's repositories when it names one. | |
| 7 | 9 | ||
| 10 | + | use g1t_contracts::access::Capability; | |
| 8 | 11 | use g1t_contracts::identity::UsernameArgs; | |
| 12 | + | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 9 | 13 | use g1t_contracts::time::rfc3339; | |
| 10 | 14 | use g1t_contracts::work::*; | |
| 11 | 15 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| ⋯ | |||
| 16 | 20 | use g1t_contracts::credentials::Principal; | |
| 17 | 21 | ||
| 18 | 22 | use super::updates::push_delivery; | |
| 19 | − | use super::{CLAIM_COLUMNS, ClaimRow, after_minutes, tidy, unwrap_or_fail}; | |
| 23 | + | use super::{CLAIM_COLUMNS, ClaimRow, SessionRow, after_minutes, tidy, unwrap_or_fail}; | |
| 20 | 24 | use crate::Work; | |
| 25 | + | use crate::items::{Held, workspace_of}; | |
| 21 | 26 | ||
| 22 | 27 | /// The most claims one list returns. | |
| 23 | 28 | const LIST_LIMIT: u32 = 100; | |
| ⋯ | |||
| 25 | 30 | /// Resolve ownership and insert/renew in one SQLite statement. In particular, | |
| 26 | 31 | /// two requests that both read no claim must converge on the same row. | |
| 27 | 32 | /// Sessionless claims retain the caller-wide semantics of claim_is_mine. | |
| 28 | − | pub(super) fn upsert_claim_sql() -> String { | |
| 33 | + | /// `?2` is the work item, `?3` the repository it is claimed from. | |
| 34 | + | pub(crate) fn upsert_claim_sql() -> String { | |
| 29 | 35 | format!( | |
| 30 | 36 | "INSERT INTO claims | |
| 31 | − | (id, repo_id, issue, session_id, actor_id, actor_name, harness, note, state, ttl_minutes, | |
| 37 | + | (id, item_id, repo_id, session_id, actor_id, actor_name, harness, note, state, ttl_minutes, | |
| 32 | 38 | created_at, expires_at) | |
| 33 | 39 | VALUES (COALESCE((SELECT id FROM claims | |
| 34 | − | WHERE repo_id = ?2 AND issue = ?3 AND actor_id = ?5 AND state = 'active' | |
| 40 | + | WHERE item_id = ?2 AND actor_id = ?5 AND state = 'active' | |
| 35 | 41 | AND (session_id IS NULL OR ?4 IS NULL OR session_id = ?4) | |
| 36 | 42 | ORDER BY created_at, id LIMIT 1), ?1), | |
| 37 | 43 | ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, ?11) | |
| ⋯ | |||
| 39 | 45 | expires_at = MAX(claims.expires_at, excluded.expires_at), ttl_minutes = excluded.ttl_minutes, | |
| 40 | 46 | note = COALESCE(excluded.note, claims.note), | |
| 41 | 47 | session_id = COALESCE(excluded.session_id, claims.session_id), | |
| 48 | + | repo_id = COALESCE(excluded.repo_id, claims.repo_id), | |
| 42 | 49 | harness = COALESCE(excluded.harness, claims.harness) | |
| 43 | 50 | WHERE claims.state = 'active' | |
| 44 | 51 | RETURNING {CLAIM_COLUMNS}" | |
| ⋯ | |||
| 49 | 56 | value.as_deref().map_or(JsValue::NULL, JsValue::from) | |
| 50 | 57 | } | |
| 51 | 58 | ||
| 59 | + | /// The repositories a claim's updates go to: those its work item is linked | |
| 60 | + | /// to, and the one it was made from. | |
| 61 | + | fn claim_repos(held: &Held, repo_id: Option<&str>) -> Vec<String> { | |
| 62 | + | let mut repos = held.repo_ids(); | |
| 63 | + | if let Some(repo_id) = repo_id | |
| 64 | + | && !repos.iter().any(|known| known == repo_id) | |
| 65 | + | { | |
| 66 | + | repos.push(repo_id.to_owned()); | |
| 67 | + | } | |
| 68 | + | repos | |
| 69 | + | } | |
| 70 | + | ||
| 52 | 71 | impl Work { | |
| 53 | − | /// The caller's active claim on an issue, if they hold one. | |
| 54 | − | async fn own_claim(&self, repo_id: &str, issue: u32, actor: &User, session: Option<&str>) -> Result<Option<Claim>> { | |
| 72 | + | /// The caller's active claim on a work item, if they hold one. | |
| 73 | + | async fn own_claim(&self, item_id: &str, actor: &User, session: Option<&str>) -> Result<Option<Claim>> { | |
| 55 | 74 | Ok(self | |
| 56 | − | .active_claims(repo_id, issue) | |
| 75 | + | .active_claims(item_id) | |
| 57 | 76 | .await? | |
| 58 | 77 | .into_iter() | |
| 59 | 78 | .find(|claim| claim_is_mine(&claim.actor.id, claim.session_id.as_deref(), &actor.id, session))) | |
| 60 | 79 | } | |
| 61 | 80 | ||
| 62 | − | /// `claim_issue`: the issue named, or with a `title` and no number, a | |
| 63 | − | /// new one opened for it, with the claim's session attached to it. | |
| 64 | − | pub(crate) async fn claim_issue(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> { | |
| 81 | + | /// One of `actor`'s sessions, in `repo` when one is named, else in any | |
| 82 | + | /// repository. | |
| 83 | + | async fn actor_session(&self, repo: Option<&Repo>, actor: &User, id: &str) -> Result<Option<SessionRow>> { | |
| 84 | + | let row = match repo { | |
| 85 | + | Some(repo) => self.session_row(&repo.id, id).await?, | |
| 86 | + | None => self | |
| 87 | + | .db | |
| 88 | + | .prepare(format!("SELECT {} FROM sessions WHERE id = ?", super::SESSION_COLUMNS)) | |
| 89 | + | .bind(&[id.into()])? | |
| 90 | + | .first::<SessionRow>(None) | |
| 91 | + | .await?, | |
| 92 | + | }; | |
| 93 | + | Ok(row.filter(|row| row.actor_id == actor.id)) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// The repository a claim is made from, if `actor` may take part in it. | |
| 97 | + | async fn claiming_repo(&self, repo: Option<&RepoPath>, actor: &User) -> Result<Outcome<Option<Repo>>> { | |
| 98 | + | Ok(match repo { | |
| 99 | + | Some(path) => Outcome::Ok(Some(unwrap_or_fail!(self.participating_repo(path, actor).await?))), | |
| 100 | + | None => { | |
| 101 | + | if !actor.verified { | |
| 102 | + | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); | |
| 103 | + | } | |
| 104 | + | Outcome::Ok(None) | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// `claim_work_item`: the work item named, or with a `title` and no | |
| 110 | + | /// number, a new one opened for it, with the claim's session attached | |
| 111 | + | /// to it. | |
| 112 | + | pub(crate) async fn claim_work_item(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> { | |
| 65 | 113 | if a.number == 0 { | |
| 66 | 114 | return self.open_and_claim(a).await; | |
| 67 | 115 | } | |
| 68 | 116 | self.claim_number(a).await | |
| 69 | 117 | } | |
| 70 | 118 | ||
| 71 | − | /// Opens an issue from `title` and `body`, claims it, and attaches the | |
| 72 | − | /// claim's session to it. The session is checked before anything is | |
| 73 | − | /// opened, so a claim that would be refused opens no issue. | |
| 119 | + | /// Opens a work item from `title` and `body`, filed from `repo` when | |
| 120 | + | /// given, claims it, and attaches the claim's session to it. The | |
| 121 | + | /// session is checked before anything is opened, so a claim that would | |
| 122 | + | /// be refused opens nothing. | |
| 74 | 123 | async fn open_and_claim(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> { | |
| 75 | 124 | let Some(title) = a.title.as_deref().map(str::trim).filter(|title| !title.is_empty()).map(str::to_owned) else { | |
| 76 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Name the issue by its number, or give a title (and body) to open one.")); | |
| 125 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the work item by its number, or give a title (and body) to open one.")); | |
| 77 | 126 | }; | |
| 78 | − | let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?); | |
| 127 | + | let repo = unwrap_or_fail!(self.claiming_repo(a.repo.as_ref(), &a.actor).await?); | |
| 79 | 128 | let session_id = a.session_id.as_deref().map(str::trim).filter(|id| !id.is_empty()).map(str::to_owned); | |
| 129 | + | let mut session_repo = None; | |
| 80 | 130 | if let Some(id) = &session_id { | |
| 81 | − | match self.session_row(&repo.id, id).await? { | |
| 82 | − | Some(row) if row.actor_id == a.actor.id => { | |
| 83 | − | if row.at(now_ms()).state == SessionState::Ended { | |
| 131 | + | match self.actor_session(repo.as_ref(), &a.actor, id).await? { | |
| 132 | + | Some(row) => { | |
| 133 | + | if row.clone().at(now_ms()).state == SessionState::Ended { | |
| 84 | 134 | return Ok(Outcome::fail(FailureCode::Invalid, "That session has ended. Start a new one.")); | |
| 85 | 135 | } | |
| 136 | + | session_repo = Some(row.repo_id.clone()); | |
| 86 | 137 | } | |
| 87 | − | _ => return Ok(Outcome::fail(FailureCode::Invalid, "session_id is not one of your sessions in this repository.")), | |
| 138 | + | None => return Ok(Outcome::fail(FailureCode::Invalid, "session_id is not one of your sessions in this repository.")), | |
| 88 | 139 | } | |
| 89 | 140 | } | |
| 90 | 141 | let opened = unwrap_or_fail!( | |
| 91 | − | self.open_issue(OpenIssueArgs { | |
| 142 | + | self.open_work_item(OpenWorkItemArgs { | |
| 92 | 143 | actor: a.actor.clone(), | |
| 144 | + | workspace: workspace_of(&a.workspace, a.repo.as_ref()), | |
| 93 | 145 | repo: a.repo.clone(), | |
| 146 | + | targets: Vec::new(), | |
| 94 | 147 | title, | |
| 95 | 148 | body: a.body.clone().unwrap_or_default(), | |
| 96 | 149 | labels: Vec::new(), | |
| 97 | − | checks: Vec::new(), | |
| 98 | 150 | milestone: None, | |
| 99 | 151 | }) | |
| 100 | 152 | .await? | |
| 101 | 153 | ); | |
| 102 | 154 | let claimed = unwrap_or_fail!( | |
| 103 | − | self.claim_number(ClaimArgs { number: opened.number, title: None, body: None, session_id: session_id.clone(), ..a.clone() }) | |
| 104 | − | .await? | |
| 155 | + | self.claim_number(ClaimArgs { | |
| 156 | + | workspace: opened.workspace.clone(), | |
| 157 | + | number: opened.number, | |
| 158 | + | title: None, | |
| 159 | + | body: None, | |
| 160 | + | session_id: session_id.clone(), | |
| 161 | + | ..a.clone() | |
| 162 | + | }) | |
| 163 | + | .await? | |
| 105 | 164 | ); | |
| 106 | − | // The session is on the issue now, not only tied to it by the claim. | |
| 107 | − | if let Some(id) = session_id { | |
| 108 | − | let attached = self | |
| 109 | − | .update_session(UpdateSessionArgs { | |
| 110 | − | actor: a.actor.clone(), | |
| 111 | − | repo: a.repo.clone(), | |
| 112 | − | id, | |
| 113 | − | issue: Some(opened.number), | |
| 114 | − | pull: None, | |
| 115 | − | branch: None, | |
| 116 | − | head_commit: None, | |
| 117 | − | title: None, | |
| 118 | − | agent_type: None, | |
| 119 | − | task: None, | |
| 120 | − | prompt: None, | |
| 121 | − | model: None, | |
| 122 | − | result: None, | |
| 123 | − | }) | |
| 124 | − | .await?; | |
| 125 | − | if let Outcome::Fail(failure) = attached { | |
| 126 | − | worker::console_error!("#{}: the session was not attached: {}", opened.number, failure.message); | |
| 165 | + | // The session is on the work item now, not only tied to it by the claim. | |
| 166 | + | if let (Some(id), Some(session_repo)) = (session_id, session_repo) { | |
| 167 | + | let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: session_repo }).await?; | |
| 168 | + | // A session in another workspace's repository names none of | |
| 169 | + | // this one's work items. | |
| 170 | + | if let Some(path) = path.filter(|path| path.namespace.eq_ignore_ascii_case(&opened.workspace)) { | |
| 171 | + | let attached = self | |
| 172 | + | .update_session(UpdateSessionArgs { | |
| 173 | + | actor: a.actor.clone(), | |
| 174 | + | repo: path, | |
| 175 | + | id, | |
| 176 | + | item: Some(opened.number), | |
| 177 | + | pull: None, | |
| 178 | + | branch: None, | |
| 179 | + | head_commit: None, | |
| 180 | + | title: None, | |
| 181 | + | agent_type: None, | |
| 182 | + | task: None, | |
| 183 | + | prompt: None, | |
| 184 | + | model: None, | |
| 185 | + | result: None, | |
| 186 | + | }) | |
| 187 | + | .await?; | |
| 188 | + | if let Outcome::Fail(failure) = attached { | |
| 189 | + | worker::console_error!("#{}: the session was not attached: {}", opened.number, failure.message); | |
| 190 | + | } | |
| 127 | 191 | } | |
| 128 | 192 | } | |
| 129 | − | Ok(Outcome::Ok(Claimed { issue: Some(opened), ..claimed })) | |
| 193 | + | Ok(Outcome::Ok(Claimed { item: Some(opened), ..claimed })) | |
| 130 | 194 | } | |
| 131 | 195 | ||
| 132 | 196 | async fn claim_number(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> { | |
| 133 | − | let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?); | |
| 134 | − | let Some(issue) = self.issue(&repo.id, a.number).await? else { | |
| 135 | − | return Ok(Outcome::fail(FailureCode::NotFound, "Issue not found.")); | |
| 136 | − | }; | |
| 137 | − | if issue.state == State::Closed { | |
| 138 | − | return Ok(Outcome::fail(FailureCode::Conflict, format!("#{} is closed.", issue.number))); | |
| 197 | + | let repo = unwrap_or_fail!(self.claiming_repo(a.repo.as_ref(), &a.actor).await?); | |
| 198 | + | let workspace = workspace_of(&a.workspace, a.repo.as_ref()); | |
| 199 | + | let held = unwrap_or_fail!(self.item_for(&Some(a.actor.clone()), &workspace, a.number, Capability::Participate).await?); | |
| 200 | + | if held.item.state == State::Closed { | |
| 201 | + | return Ok(Outcome::fail(FailureCode::Conflict, format!("#{} is closed.", held.item.number))); | |
| 139 | 202 | } | |
| 140 | 203 | // The session it is made from, whose heartbeats renew it. | |
| 141 | 204 | let session = match a.session_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) { | |
| 142 | − | Some(id) => match self.session_row(&repo.id, id).await? { | |
| 143 | − | Some(row) if row.actor_id == a.actor.id => Some(row.at(now_ms())), | |
| 144 | − | _ => return Ok(Outcome::fail(FailureCode::Invalid, "session_id is not one of your sessions in this repository.")), | |
| 205 | + | Some(id) => match self.actor_session(repo.as_ref(), &a.actor, id).await? { | |
| 206 | + | Some(row) => Some(row.at(now_ms())), | |
| 207 | + | None => return Ok(Outcome::fail(FailureCode::Invalid, "session_id is not one of your sessions in this repository.")), | |
| 145 | 208 | }, | |
| 146 | 209 | None => None, | |
| 147 | 210 | }; | |
| 148 | 211 | if session.as_ref().is_some_and(|session| session.state == SessionState::Ended) { | |
| 149 | 212 | return Ok(Outcome::fail(FailureCode::Invalid, "That session has ended. Start a new one.")); | |
| 150 | 213 | } | |
| 214 | + | let repo_id = repo.as_ref().map(|repo| repo.id.clone()).or_else(|| session.as_ref().map(|session| session.repo_id.clone())); | |
| 151 | 215 | let minutes = claim_minutes(a.ttl_minutes); | |
| 152 | 216 | let note = tidy(a.note.as_deref(), MAX_NOTE_CHARS); | |
| 153 | 217 | let session_id = session.as_ref().map(|session| session.id.clone()); | |
| 154 | 218 | let harness = session.as_ref().map(|session| session.harness.as_str().to_owned()); | |
| 155 | 219 | let now = now_ms(); | |
| 156 | 220 | let expires_at = after_minutes(now, minutes); | |
| 157 | − | let standing = self.active_claims(&repo.id, issue.number).await?; | |
| 221 | + | let standing = self.active_claims(&held.item.id).await?; | |
| 158 | 222 | let (mine, others): (Vec<Claim>, Vec<Claim>) = standing | |
| 159 | 223 | .into_iter() | |
| 160 | 224 | .partition(|claim| claim_is_mine(&claim.actor.id, claim.session_id.as_deref(), &a.actor.id, session_id.as_deref())); | |
| 161 | − | let blocked_by = self.blockers(&repo.id, &issue.blocked_by).await?; | |
| 225 | + | let blocked_by = self.blockers(&held.item.workspace, &held.item.blocked_by).await?; | |
| 226 | + | let repos = claim_repos(&held, repo_id.as_deref()); | |
| 162 | 227 | // Claiming what you hold renews it. | |
| 163 | − | if let Some(held) = mine.into_iter().next() { | |
| 228 | + | if let Some(held_claim) = mine.into_iter().next() { | |
| 164 | 229 | let renewed = self | |
| 165 | 230 | .db | |
| 166 | 231 | .prepare(format!( | |
| 167 | 232 | "UPDATE claims | |
| 168 | 233 | SET expires_at = ?, ttl_minutes = ?, note = COALESCE(?, note), | |
| 169 | − | session_id = COALESCE(?, session_id), harness = COALESCE(?, harness) | |
| 234 | + | session_id = COALESCE(?, session_id), harness = COALESCE(?, harness), | |
| 235 | + | repo_id = COALESCE(?, repo_id) | |
| 170 | 236 | WHERE id = ? AND state = 'active' | |
| 171 | 237 | RETURNING {CLAIM_COLUMNS}" | |
| 172 | 238 | )) | |
| ⋯ | |||
| 176 | 242 | text_or_null(¬e), | |
| 177 | 243 | text_or_null(&session_id), | |
| 178 | 244 | text_or_null(&harness), | |
| 179 | − | held.id.as_str().into(), | |
| 245 | + | text_or_null(&repo_id), | |
| 246 | + | held_claim.id.as_str().into(), | |
| 180 | 247 | ])? | |
| 181 | 248 | .first::<ClaimRow>(None) | |
| 182 | 249 | .await?; | |
| 183 | 250 | if let Some(renewed) = renewed { | |
| 184 | − | if let Some(session_id) = &session_id { | |
| 185 | − | self.link(session_id, &repo.id, LinkKind::Issue, issue.number, LinkVia::Claim).await; | |
| 251 | + | if let (Some(session_id), Some(repo_id)) = (&session_id, &repo_id) { | |
| 252 | + | self.link_item(session_id, repo_id, &held.item.id, LinkVia::Claim).await; | |
| 186 | 253 | } | |
| 187 | − | return Ok(Outcome::Ok(Claimed { claim: renewed.into(), others, blocked_by, issue: None })); | |
| 254 | + | return Ok(Outcome::Ok(Claimed { claim: renewed.into(), others, blocked_by, item: None })); | |
| 188 | 255 | } | |
| 189 | 256 | } | |
| 190 | 257 | // Taking over what was handed off to you, or to anyone. | |
| ⋯ | |||
| 192 | 259 | .db | |
| 193 | 260 | .prepare(format!( | |
| 194 | 261 | "SELECT {CLAIM_COLUMNS} FROM claims | |
| 195 | − | WHERE repo_id = ? AND issue = ? AND state <> 'active' ORDER BY ended_at DESC, id DESC LIMIT 1" | |
| 262 | + | WHERE item_id = ? AND state <> 'active' ORDER BY ended_at DESC, id DESC LIMIT 1" | |
| 196 | 263 | )) | |
| 197 | − | .bind(&[repo.id.as_str().into(), issue.number.into()])? | |
| 264 | + | .bind(&[held.item.id.as_str().into()])? | |
| 198 | 265 | .first::<ClaimRow>(None) | |
| 199 | 266 | .await?; | |
| 200 | 267 | let took_over = handed | |
| ⋯ | |||
| 208 | 275 | // is an older one of the caller's) is not. | |
| 209 | 276 | let proposed = Claim { | |
| 210 | 277 | id: proposed_id.clone(), | |
| 211 | − | repo_id: repo.id.clone(), | |
| 212 | − | issue: issue.number, | |
| 278 | + | repo_id: repo_id.clone(), | |
| 279 | + | workspace: held.item.workspace.clone(), | |
| 280 | + | item: held.item.number, | |
| 213 | 281 | session_id: session_id.clone(), | |
| 214 | 282 | actor: Principal { id: a.actor.id.clone(), username: a.actor.username.clone() }, | |
| 215 | 283 | harness: harness.as_deref().and_then(Harness::parse), | |
| ⋯ | |||
| 223 | 291 | }; | |
| 224 | 292 | let event = |claim: &Claim| { | |
| 225 | 293 | g1t_contracts::events::marked( | |
| 226 | − | ClaimEvent { others: Some(others.clone()), took_over: took_over.clone(), ..ClaimEvent::of(claim) }, | |
| 294 | + | ClaimEvent { others: Some(others.clone()), took_over: took_over.clone(), ..ClaimEvent::of(claim, &repos) }, | |
| 227 | 295 | Some(&a.actor), | |
| 228 | 296 | ) | |
| 229 | 297 | }; | |
| ⋯ | |||
| 232 | 300 | .prepare(upsert_claim_sql()) | |
| 233 | 301 | .bind(&[ | |
| 234 | 302 | proposed_id.as_str().into(), | |
| 235 | − | repo.id.as_str().into(), | |
| 236 | − | issue.number.into(), | |
| 303 | + | held.item.id.as_str().into(), | |
| 304 | + | text_or_null(&repo_id), | |
| 237 | 305 | text_or_null(&session_id), | |
| 238 | 306 | a.actor.id.as_str().into(), | |
| 239 | 307 | a.actor.username.as_str().into(), | |
| ⋯ | |||
| 245 | 313 | ])?]; | |
| 246 | 314 | let sent = push_delivery( | |
| 247 | 315 | &mut statements, | |
| 248 | − | self.delivery( | |
| 316 | + | self.delivery_to( | |
| 249 | 317 | "work.claimed", | |
| 250 | − | &repo.id, | |
| 318 | + | &repos, | |
| 251 | 319 | event(&proposed), | |
| 252 | 320 | &format!("{proposed_id}:claimed"), | |
| 253 | 321 | "EXISTS (SELECT 1 FROM claims WHERE id = ?7)", | |
| ⋯ | |||
| 262 | 330 | return Err(worker::Error::RustError("the claim was not recorded".into())); | |
| 263 | 331 | }; | |
| 264 | 332 | let claim = Claim::from(created); | |
| 265 | − | if let Some(session_id) = &claim.session_id { | |
| 266 | − | self.link(session_id, &repo.id, LinkKind::Issue, claim.issue, LinkVia::Claim).await; | |
| 333 | + | if let (Some(session_id), Some(repo_id)) = (&claim.session_id, &claim.repo_id) { | |
| 334 | + | self.link_item(session_id, repo_id, &held.item.id, LinkVia::Claim).await; | |
| 267 | 335 | } | |
| 268 | 336 | if claim.id != proposed_id { | |
| 269 | − | return Ok(Outcome::Ok(Claimed { claim, others, blocked_by, issue: None })); | |
| 337 | + | return Ok(Outcome::Ok(Claimed { claim, others, blocked_by, item: None })); | |
| 270 | 338 | } | |
| 271 | − | self.delivered(sent, "work.claimed", &repo.id, Some(a.actor.id.clone()), event(&claim)).await?; | |
| 272 | − | Ok(Outcome::Ok(Claimed { claim, others, blocked_by, issue: None })) | |
| 339 | + | self.delivered(sent, "work.claimed", held.event_repo().unwrap_or_default(), Some(a.actor.id.clone()), event(&claim)).await?; | |
| 340 | + | Ok(Outcome::Ok(Claimed { claim, others, blocked_by, item: None })) | |
| 273 | 341 | } | |
| 274 | 342 | ||
| 275 | − | /// Ends the caller's active claim on an issue as `state`, announced as | |
| 276 | − | /// `kind` with the event `event` makes of it, or refuses when they hold | |
| 277 | − | /// none. | |
| 343 | + | /// Ends the caller's active claim on a work item as `state`, announced | |
| 344 | + | /// as `kind` with the event `event` makes of it, or refuses when they | |
| 345 | + | /// hold none. | |
| 278 | 346 | #[allow(clippy::too_many_arguments)] | |
| 279 | 347 | async fn end_own_claim( | |
| 280 | 348 | &self, | |
| 281 | 349 | actor: &User, | |
| 282 | − | repo: &g1t_contracts::repos::RepoPath, | |
| 350 | + | workspace: &str, | |
| 351 | + | repo: Option<&RepoPath>, | |
| 283 | 352 | number: u32, | |
| 284 | 353 | state: ClaimState, | |
| 285 | 354 | reason: &str, | |
| 286 | 355 | handoff: Option<(&Option<String>, &str)>, | |
| 287 | 356 | session: Option<&str>, | |
| 288 | 357 | kind: &'static str, | |
| 289 | − | event: impl Fn(&Claim) -> ClaimEvent, | |
| 358 | + | event: impl Fn(&Claim, &[String]) -> ClaimEvent, | |
| 290 | 359 | ) -> Result<Outcome<Claim>> { | |
| 291 | − | let repo = unwrap_or_fail!(self.participating_repo(repo, actor).await?); | |
| 292 | − | let Some(held) = self.own_claim(&repo.id, number, actor, session.map(str::trim).filter(|id| !id.is_empty())).await? else { | |
| 360 | + | unwrap_or_fail!(self.claiming_repo(repo, actor).await?); | |
| 361 | + | let workspace = workspace_of(workspace, repo); | |
| 362 | + | let held = unwrap_or_fail!(self.item_for(&Some(actor.clone()), &workspace, number, Capability::Read).await?); | |
| 363 | + | let Some(claim) = self.own_claim(&held.item.id, actor, session.map(str::trim).filter(|id| !id.is_empty())).await? else { | |
| 293 | 364 | return Ok(Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}."))); | |
| 294 | 365 | }; | |
| 366 | + | let repos = claim_repos(&held, claim.repo_id.as_deref()); | |
| 295 | 367 | let at = rfc3339(now_ms()); | |
| 296 | 368 | let expected = Claim { | |
| 297 | 369 | state, | |
| 298 | 370 | ended_at: Some(at.clone()), | |
| 299 | 371 | end_reason: Some(reason.to_owned()), | |
| 300 | 372 | handoff_to: handoff.and_then(|(to, _)| to.clone()), | |
| 301 | − | note: handoff.map(|(_, note)| note.to_owned()).or(held.note.clone()), | |
| 302 | − | ..held.clone() | |
| 373 | + | note: handoff.map(|(_, note)| note.to_owned()).or(claim.note.clone()), | |
| 374 | + | ..claim.clone() | |
| 303 | 375 | }; | |
| 304 | 376 | let (handoff_to, note) = match handoff { | |
| 305 | 377 | Some((to, note)) => (text_or_null(to), JsValue::from(note)), | |
| ⋯ | |||
| 313 | 385 | WHERE id = ? AND state = 'active' | |
| 314 | 386 | RETURNING {CLAIM_COLUMNS}" | |
| 315 | 387 | )) | |
| 316 | − | .bind(&[state.as_str().into(), at.as_str().into(), reason.into(), handoff_to, note, held.id.as_str().into()])?]; | |
| 388 | + | .bind(&[state.as_str().into(), at.as_str().into(), reason.into(), handoff_to, note, claim.id.as_str().into()])?]; | |
| 317 | 389 | // A claim ends once: its id and how it ended name the change. | |
| 318 | 390 | let sent = push_delivery( | |
| 319 | 391 | &mut statements, | |
| 320 | − | self.delivery( | |
| 392 | + | self.delivery_to( | |
| 321 | 393 | kind, | |
| 322 | − | &repo.id, | |
| 323 | − | g1t_contracts::events::marked(event(&expected), Some(actor)), | |
| 324 | − | &format!("{}:{}", held.id, state.as_str()), | |
| 394 | + | &repos, | |
| 395 | + | g1t_contracts::events::marked(event(&expected, &repos), Some(actor)), | |
| 396 | + | &format!("{}:{}", claim.id, state.as_str()), | |
| 325 | 397 | "EXISTS (SELECT 1 FROM claims WHERE id = ?7 AND state = ?8)", | |
| 326 | − | vec![held.id.as_str().into(), state.as_str().into()], | |
| 398 | + | vec![claim.id.as_str().into(), state.as_str().into()], | |
| 327 | 399 | )?, | |
| 328 | 400 | ); | |
| 329 | 401 | let ended = match self.db.batch(statements).await?.into_iter().next() { | |
| ⋯ | |||
| 334 | 406 | return Ok(Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}."))); | |
| 335 | 407 | }; | |
| 336 | 408 | let claim = Claim::from(row); | |
| 337 | − | let data = g1t_contracts::events::marked(event(&claim), Some(actor)); | |
| 338 | − | self.delivered(sent, kind, &repo.id, Some(actor.id.clone()), data).await?; | |
| 409 | + | let data = g1t_contracts::events::marked(event(&claim, &repos), Some(actor)); | |
| 410 | + | self.delivered(sent, kind, held.event_repo().unwrap_or_default(), Some(actor.id.clone()), data).await?; | |
| 339 | 411 | Ok(Outcome::Ok(claim)) | |
| 340 | 412 | } | |
| 341 | 413 | ||
| ⋯ | |||
| 344 | 416 | let claim = unwrap_or_fail!( | |
| 345 | 417 | self.end_own_claim( | |
| 346 | 418 | &a.actor, | |
| 347 | − | &a.repo, | |
| 419 | + | &a.workspace, | |
| 420 | + | a.repo.as_ref(), | |
| 348 | 421 | a.number, | |
| 349 | 422 | ClaimState::Released, | |
| 350 | 423 | &reason, | |
| 351 | 424 | None, | |
| 352 | 425 | a.session_id.as_deref(), | |
| 353 | 426 | "work.released", | |
| 354 | − | |claim| ClaimEvent { reason: Some(reason.clone()), ..ClaimEvent::of(claim) }, | |
| 427 | + | |claim, repos| ClaimEvent { reason: Some(reason.clone()), ..ClaimEvent::of(claim, repos) }, | |
| 355 | 428 | ) | |
| 356 | 429 | .await? | |
| 357 | 430 | ); | |
| 358 | 431 | Ok(Outcome::Ok(claim)) | |
| 359 | 432 | } | |
| 360 | 433 | ||
| 361 | − | pub(crate) async fn hand_off_issue(&self, a: HandOffArgs) -> Result<Outcome<Claim>> { | |
| 434 | + | pub(crate) async fn hand_off_work_item(&self, a: HandOffArgs) -> Result<Outcome<Claim>> { | |
| 362 | 435 | let Some(note) = tidy(Some(&a.note), MAX_NOTE_CHARS) else { | |
| 363 | 436 | return Ok(Outcome::fail(FailureCode::Invalid, "Say where the work stands in note, for whoever picks it up.")); | |
| 364 | 437 | }; | |
| ⋯ | |||
| 373 | 446 | let claim = unwrap_or_fail!( | |
| 374 | 447 | self.end_own_claim( | |
| 375 | 448 | &a.actor, | |
| 376 | − | &a.repo, | |
| 449 | + | &a.workspace, | |
| 450 | + | a.repo.as_ref(), | |
| 377 | 451 | a.number, | |
| 378 | 452 | ClaimState::HandedOff, | |
| 379 | 453 | "handed_off", | |
| 380 | 454 | Some((&to, ¬e)), | |
| 381 | 455 | a.session_id.as_deref(), | |
| 382 | 456 | "work.handoff_requested", | |
| 383 | − | |claim| ClaimEvent { | |
| 457 | + | |claim, repos| ClaimEvent { | |
| 384 | 458 | from: Some(a.actor.username.clone()), | |
| 385 | 459 | to: to.clone(), | |
| 386 | 460 | note: Some(note.clone()), | |
| 387 | − | ..ClaimEvent::of(claim) | |
| 461 | + | ..ClaimEvent::of(claim, repos) | |
| 388 | 462 | }, | |
| 389 | 463 | ) | |
| 390 | 464 | .await? | |
| ⋯ | |||
| 392 | 466 | Ok(Outcome::Ok(claim)) | |
| 393 | 467 | } | |
| 394 | 468 | ||
| 469 | + | /// A workspace's claims on the work items the viewer may see; with a | |
| 470 | + | /// repository, those made from it or on work items linked to it. | |
| 395 | 471 | pub(crate) async fn list_claims(&self, a: ListClaimsArgs) -> Result<Outcome<Vec<Claim>>> { | |
| 396 | − | let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?); | |
| 472 | + | let workspace = workspace_of(&a.workspace, a.repo.as_ref()); | |
| 473 | + | let repo = match &a.repo { | |
| 474 | + | Some(path) => Some(unwrap_or_fail!(self.coordination_repo(path, &a.viewer).await?)), | |
| 475 | + | None => None, | |
| 476 | + | }; | |
| 397 | 477 | let state = a.state.unwrap_or(ClaimState::Active); | |
| 398 | 478 | let rows = self | |
| 399 | 479 | .db | |
| 400 | 480 | .prepare(format!( | |
| 401 | 481 | "SELECT {CLAIM_COLUMNS} FROM claims | |
| 402 | − | WHERE repo_id = ?1 AND state = ?2 AND (?3 IS NULL OR issue = ?3) | |
| 403 | − | ORDER BY created_at DESC, id DESC LIMIT ?4" | |
| 482 | + | WHERE state = ?2 | |
| 483 | + | AND item_id IN (SELECT id FROM work_items WHERE workspace = ?1 AND (?3 IS NULL OR number = ?3)) | |
| 484 | + | AND (?4 IS NULL OR repo_id = ?4 OR item_id IN (SELECT item_id FROM work_item_repos WHERE repo_id = ?4)) | |
| 485 | + | ORDER BY created_at DESC, id DESC LIMIT ?5" | |
| 404 | 486 | )) | |
| 405 | 487 | .bind(&[ | |
| 406 | − | repo.id.as_str().into(), | |
| 488 | + | workspace.as_str().into(), | |
| 407 | 489 | state.as_str().into(), | |
| 408 | − | a.issue.filter(|number| *number > 0).map_or(JsValue::NULL, JsValue::from), | |
| 490 | + | a.item.filter(|number| *number > 0).map_or(JsValue::NULL, JsValue::from), | |
| 491 | + | text_or_null(&repo.as_ref().map(|repo| repo.id.clone())), | |
| 409 | 492 | LIST_LIMIT.into(), | |
| 410 | 493 | ])? | |
| 411 | 494 | .all() | |
| 412 | 495 | .await? | |
| 413 | 496 | .results::<ClaimRow>()?; | |
| 414 | − | Ok(Outcome::Ok(rows.into_iter().map(Claim::from).collect())) | |
| 497 | + | let mut ids: Vec<String> = rows.iter().map(|row| row.item_id.clone()).collect(); | |
| 498 | + | ids.sort(); | |
| 499 | + | ids.dedup(); | |
| 500 | + | let seen: Vec<String> = self | |
| 501 | + | .items_by_ids(&ids) | |
| 502 | + | .await? | |
| 503 | + | .into_iter() | |
| 504 | + | .filter(|held| held.may(a.viewer.as_ref(), Capability::Read)) | |
| 505 | + | .map(|held| held.item.id) | |
| 506 | + | .collect(); | |
| 507 | + | Ok(Outcome::Ok(rows.into_iter().filter(|row| seen.contains(&row.item_id)).map(Claim::from).collect())) | |
| 415 | 508 | } | |
| 416 | 509 | ||
| 417 | − | /// An issue closed: its active claims end as released (`issue_closed`), | |
| 418 | − | /// each announced. | |
| 419 | − | pub(crate) async fn release_claims_on_close(&self, repo_id: &str, issue: u32, actor: Option<&User>) -> Result<()> { | |
| 510 | + | /// A work item closed: its active claims end as released | |
| 511 | + | /// (`item_closed`), each announced. | |
| 512 | + | pub(crate) async fn release_claims_on_close(&self, held: &Held, actor: Option<&User>) -> Result<()> { | |
| 420 | 513 | let at = rfc3339(now_ms()); | |
| 421 | − | let active = self.active_claims(repo_id, issue).await?; | |
| 514 | + | let active = self.active_claims(&held.item.id).await?; | |
| 422 | 515 | if active.is_empty() { | |
| 423 | 516 | return Ok(()); | |
| 424 | 517 | } | |
| 425 | 518 | let event = |claim: &Claim| { | |
| 426 | − | g1t_contracts::events::marked( | |
| 427 | − | ClaimEvent { reason: Some("issue_closed".to_owned()), ..ClaimEvent::of(claim) }, | |
| 428 | − | actor, | |
| 429 | − | ) | |
| 519 | + | let repos = claim_repos(held, claim.repo_id.as_deref()); | |
| 520 | + | g1t_contracts::events::marked(ClaimEvent { reason: Some("item_closed".to_owned()), ..ClaimEvent::of(claim, &repos) }, actor) | |
| 430 | 521 | }; | |
| 431 | 522 | // Each claim's release and its updates are one change, in one batch. | |
| 432 | 523 | let mut statements = Vec::new(); | |
| 433 | 524 | let mut sent = Vec::new(); | |
| 434 | − | for held in &active { | |
| 525 | + | for claim in &active { | |
| 435 | 526 | statements.push( | |
| 436 | 527 | self.db | |
| 437 | 528 | .prepare(format!( | |
| 438 | − | "UPDATE claims SET state = 'released', ended_at = ?, end_reason = 'issue_closed' | |
| 529 | + | "UPDATE claims SET state = 'released', ended_at = ?, end_reason = 'item_closed' | |
| 439 | 530 | WHERE id = ? AND state = 'active' | |
| 440 | 531 | RETURNING {CLAIM_COLUMNS}" | |
| 441 | 532 | )) | |
| 442 | − | .bind(&[at.as_str().into(), held.id.as_str().into()])?, | |
| 533 | + | .bind(&[at.as_str().into(), claim.id.as_str().into()])?, | |
| 443 | 534 | ); | |
| 444 | 535 | let expected = Claim { | |
| 445 | 536 | state: ClaimState::Released, | |
| 446 | 537 | ended_at: Some(at.clone()), | |
| 447 | − | end_reason: Some("issue_closed".to_owned()), | |
| 448 | − | ..held.clone() | |
| 538 | + | end_reason: Some("item_closed".to_owned()), | |
| 539 | + | ..claim.clone() | |
| 449 | 540 | }; | |
| 450 | − | let delivery = self.delivery( | |
| 541 | + | let delivery = self.delivery_to( | |
| 451 | 542 | "work.released", | |
| 452 | − | repo_id, | |
| 543 | + | &claim_repos(held, claim.repo_id.as_deref()), | |
| 453 | 544 | event(&expected), | |
| 454 | − | &format!("{}:{}", held.id, ClaimState::Released.as_str()), | |
| 545 | + | &format!("{}:{}", claim.id, ClaimState::Released.as_str()), | |
| 455 | 546 | "EXISTS (SELECT 1 FROM claims WHERE id = ?7 AND state = 'released')", | |
| 456 | − | vec![held.id.as_str().into()], | |
| 547 | + | vec![claim.id.as_str().into()], | |
| 457 | 548 | )?; | |
| 458 | 549 | let index = statements.len() - 1; | |
| 459 | 550 | sent.push((index, push_delivery(&mut statements, delivery))); | |
| ⋯ | |||
| 465 | 556 | continue; | |
| 466 | 557 | }; | |
| 467 | 558 | let claim = Claim::from(row); | |
| 468 | − | self.delivered(sent, "work.released", repo_id, actor.map(|actor| actor.id.clone()), event(&claim)).await?; | |
| 559 | + | let repo = claim.repo_id.clone().or_else(|| held.event_repo().map(str::to_owned)).unwrap_or_default(); | |
| 560 | + | self.delivered(sent, "work.released", &repo, actor.map(|actor| actor.id.clone()), event(&claim)).await?; | |
| 469 | 561 | } | |
| 470 | 562 | Ok(()) | |
| 471 | 563 | } | |
Comments
Sign in to comment.