Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parent954ad0eBrowse files
46 files+4917−24370/46 viewed
+364−0
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−1
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
22 //! (`upload_attachment`): whoever may comment on a repository may add one,
33 //! as an agent shows a screenshot of its preview in a pull request instead
44 //! of committing the image. The bytes go in the usercontent namespace
+98−60
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.
44 //!
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.
1113
1214 use std::collections::HashMap;
1315
2527 struct ItemRow {
2628 kind: AuthoredKind,
2729 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>,
2935 number: u32,
3036 title: String,
3137 state: State,
32− reason: Option<IssueReason>,
38+ reason: Option<CloseReason>,
3339 status: Option<PullStatus>,
3440 created_at: String,
3541 updated_at: String,
96102 }
97103 }
98104
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 {
102109 let author = params.push(author);
103110 let visible = params.push(visible);
111+ let members = params.push(members);
104112 let mut conditions = Vec::new();
105113 if let Some(kind) = a.kind {
106114 conditions.push(format!(
107115 "kind = {}",
108116 params.push(match kind {
109− AuthoredKind::Issue => "issue",
117+ AuthoredKind::Item => "item",
110118 AuthoredKind::Pull => "pull",
111119 })
112120 ));
137145 let limit = params.push(limit + 1);
138146 format!(
139147 "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})))
144155 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,
146157 CASE WHEN status IN ('draft', 'open') THEN 'open' ELSE 'closed' END AS state,
147158 NULL AS reason, status, created_at, updated_at, merged_at
148159 FROM pulls
161172 AND repo_id IN (SELECT value FROM json_each(?2)) AND status IN ('draft', 'open')) AS pulls_open,
162173 (SELECT count(*) FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1
163174 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";
168182
169183 #[derive(Deserialize)]
170184 #[serde(rename_all = "snake_case")]
172186 pulls_merged: u32,
173187 pulls_open: u32,
174188 pulls: u32,
175− issues: u32,
176− issues_open: u32,
189+ items: u32,
190+ items_open: u32,
177191 }
178192
179193 /// Whether `repo` is the one named `namespace/name`.
202216 .db
203217 .prepare(
204218 "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
206221 UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1
207222 ) GROUP BY repo_id ORDER BY n DESC LIMIT ?2",
208223 )
210225 .all()
211226 .await?
212227 .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() {
214242 return Ok(Outcome::Ok(Authored::default()));
215243 }
216244 // ...and of those, the ones the viewer may read.
236264 })
237265 })
238266 .collect();
239− if readable.is_empty() {
267+ if readable.is_empty() && members.is_empty() {
240268 return Ok(Outcome::Ok(Authored::default()));
241269 }
242270
243271 let all_ids: Vec<&str> = readable.iter().map(|repo| repo.id.as_str()).collect();
244272 let all = serde_json::to_string(&all_ids)?;
273+ let member_list = serde_json::to_string(&members)?;
245274 let counts = self
246275 .db
247276 .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()])?
249278 .first::<CountsRow>(None)
250279 .await?
251280 .map(|row| AuthoredCounts {
252281 pulls_merged: row.pulls_merged,
253282 pulls_open: row.pulls_open,
254283 pulls: row.pulls,
255− issues: row.issues,
256− issues_open: row.issues_open,
284+ items: row.items,
285+ items_open: row.items_open,
257286 })
258287 .unwrap_or_default();
259288
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),
271304 };
272305 let limit = a.limit.unwrap_or(AUTHORED_PAGE).clamp(1, AUTHORED_PAGE);
273306 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);
275308 let mut rows = self
276309 .db
277310 .prepare(sql)
288321 let items = rows
289322 .into_iter()
290323 .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()))?;
292330 Some(AuthoredItem {
293331 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() }),
298334 number: row.number,
299335 title: row.title,
300336 state: row.state,
338374 ItemRow {
339375 kind: AuthoredKind::Pull,
340376 id: id.into(),
341− repo_id: "rep_1".into(),
377+ repo_id: Some("rep_1".into()),
378+ workspace: None,
342379 number: 1,
343380 title: "t".into(),
344381 state: State::Open,
353390 #[test]
354391 fn every_query_is_confined_to_the_readable_set() {
355392 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);
357394 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);
359397 assert_eq!(COUNTS_SQL.matches("json_each(?2)").count(), 5);
360− assert!(sql.contains("LIMIT ?3"));
398+ assert!(sql.contains("LIMIT ?4"));
361399 assert!(sql.contains("ORDER BY created_at DESC, id DESC"));
362400 }
363401
371409 before: Some("2026-10-01T00:00:00.000Z|pul_9".into()),
372410 ..args()
373411 };
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"));
376414 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))"));
378416 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);
381419 }
382420
383421 #[test]
387425 before: Some("nonsense".into()),
388426 ..args()
389427 };
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 < "));
392430 assert_eq!(parse_cursor("a|b|c"), None);
393431 assert_eq!(parse_cursor("|b"), None);
394432 assert_eq!(parse_cursor("k|i"), Some(("k", "i")));
+1−1
424424 repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
425425 number,
426426 summary: String::new(),
427− keep_issue_open: false,
427+ keep_item_open: false,
428428 ignore_checks: false,
429429 bypass_rules: false,
430430 merge_method: Some(method),
+1−1
11 //! 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
33 //! request's checks were the workflows run on it. Those runs are kept so
44 //! their history still reads; a sandbox still finishing one reports
55 //! through the API with the job's one-time token.
+301−220
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.
63 //!
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.
918
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;
1128
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};
1332
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;
1635
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>,
2742 }
2843
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()
3890 }
39− closes.truncate(MAX_CLOSES);
40− closes
4191 }
4292
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)
63105 }
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+ }
68125 }
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+ })
70132 }
71− parts.push(current);
72− parts
73−}
74133
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);
100144 }
101145 }
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);
111153 }
112154 }
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() })
113162 }
114− out
115−}
116−
117−fn is_word(c: char) -> bool {
118− c.is_alphanumeric() || c == '_'
119−}
120163
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;
154178 }
179+ Ok(named)
155180 }
156−}
157181
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+ }
169301 }
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?;
174321 }
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)
184323 }
185− let number: u32 = chars[hash + 1..after].iter().collect::<String>().parse().ok()?;
186− (number > 0).then_some((number, after))
187324 }
188325
189326 #[cfg(test)]
190327 mod tests {
191328 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− }
224329
225330 #[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);
254335 }
255336 }
+3−13
44 //! Anyone who can read the repository reads them, and comments unless the
55 //! repository turned commit comments off (`allow_commit_comments`); those
66 //! 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)
88 //! deletes it. Each change is published as `commit_comment.created`,
99 //! `.edited` or `.deleted` with the comment, and the inbox tells the
1010 //! commit's author (when their address is a g1t account's) and whoever it
284284 data.excerpt = Some(excerpt(&comment.body));
285285 data.mentions = people_mentioned(&comment.body);
286286 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 {
288288 Ok(told) => data.team_mentions = told,
289289 Err(error) => worker::console_error!("commit comment team mentions not resolved: {error}"),
290290 }
505505 /// module and a purge run against it.
506506 #[test]
507507 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();
519509 let insert = "INSERT INTO commit_comments
520510 (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at)
521511 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
+9−7
11 //! 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`)
44 //! and reviews they gave, day by day (UTC).
55 //!
66 //! Commits are the repos service's: it credits whoever pushed, as each
2626
2727 /// Every repository the person did something in since `?2`, busiest first.
2828 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
3031 UNION ALL SELECT repo_id FROM pulls WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?2
3132 UNION ALL SELECT repo_id FROM comments WHERE author_id = ?1 AND created_at >= ?2 AND verdict IS NOT NULL
3233 ) GROUP BY repo_id ORDER BY n DESC LIMIT ?3";
3435 /// What they did each day since `?3`, in the repositories `?2` (a JSON
3536 /// array of ids) only.
3637 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))
4042 UNION ALL
4143 SELECT substr(created_at, 1, 10) AS day FROM pulls
4244 WHERE COALESCE(requested_by_id, author_id) = ?1 AND created_at >= ?3
123125 Ok(Outcome::Ok(tally(rows?, commits, from)))
124126 }
125127
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.
127129 async fn work_days(&self, person: &str, from: &str, viewer: Viewer) -> Result<Vec<DayRow>> {
128130 let touched = self
129131 .db
+217−125
11 //! Claims: an advisory, expiring "I'm working on #N".
22 //!
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
44 //! claimed, and is told who else is on it. It lapses unless renewed, by
55 //! 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.
79
10+use g1t_contracts::access::Capability;
811 use g1t_contracts::identity::UsernameArgs;
12+use g1t_contracts::repos::{Repo, RepoPath};
913 use g1t_contracts::time::rfc3339;
1014 use g1t_contracts::work::*;
1115 use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
1620 use g1t_contracts::credentials::Principal;
1721
1822 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};
2024 use crate::Work;
25+use crate::items::{Held, workspace_of};
2126
2227 /// The most claims one list returns.
2328 const LIST_LIMIT: u32 = 100;
2530 /// Resolve ownership and insert/renew in one SQLite statement. In particular,
2631 /// two requests that both read no claim must converge on the same row.
2732 /// 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 {
2935 format!(
3036 "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,
3238 created_at, expires_at)
3339 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'
3541 AND (session_id IS NULL OR ?4 IS NULL OR session_id = ?4)
3642 ORDER BY created_at, id LIMIT 1), ?1),
3743 ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'active', ?9, ?10, ?11)
3945 expires_at = MAX(claims.expires_at, excluded.expires_at), ttl_minutes = excluded.ttl_minutes,
4046 note = COALESCE(excluded.note, claims.note),
4147 session_id = COALESCE(excluded.session_id, claims.session_id),
48+ repo_id = COALESCE(excluded.repo_id, claims.repo_id),
4249 harness = COALESCE(excluded.harness, claims.harness)
4350 WHERE claims.state = 'active'
4451 RETURNING {CLAIM_COLUMNS}"
4956 value.as_deref().map_or(JsValue::NULL, JsValue::from)
5057 }
5158
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+
5271 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>> {
5574 Ok(self
56− .active_claims(repo_id, issue)
75+ .active_claims(item_id)
5776 .await?
5877 .into_iter()
5978 .find(|claim| claim_is_mine(&claim.actor.id, claim.session_id.as_deref(), &actor.id, session)))
6079 }
6180
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>> {
65113 if a.number == 0 {
66114 return self.open_and_claim(a).await;
67115 }
68116 self.claim_number(a).await
69117 }
70118
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.
74123 async fn open_and_claim(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> {
75124 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."));
77126 };
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?);
79128 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;
80130 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 {
84134 return Ok(Outcome::fail(FailureCode::Invalid, "That session has ended. Start a new one."));
85135 }
136+ session_repo = Some(row.repo_id.clone());
86137 }
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.")),
88139 }
89140 }
90141 let opened = unwrap_or_fail!(
91− self.open_issue(OpenIssueArgs {
142+ self.open_work_item(OpenWorkItemArgs {
92143 actor: a.actor.clone(),
144+ workspace: workspace_of(&a.workspace, a.repo.as_ref()),
93145 repo: a.repo.clone(),
146+ targets: Vec::new(),
94147 title,
95148 body: a.body.clone().unwrap_or_default(),
96149 labels: Vec::new(),
97− checks: Vec::new(),
98150 milestone: None,
99151 })
100152 .await?
101153 );
102154 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?
105164 );
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+ }
127191 }
128192 }
129− Ok(Outcome::Ok(Claimed { issue: Some(opened), ..claimed }))
193+ Ok(Outcome::Ok(Claimed { item: Some(opened), ..claimed }))
130194 }
131195
132196 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)));
139202 }
140203 // The session it is made from, whose heartbeats renew it.
141204 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.")),
145208 },
146209 None => None,
147210 };
148211 if session.as_ref().is_some_and(|session| session.state == SessionState::Ended) {
149212 return Ok(Outcome::fail(FailureCode::Invalid, "That session has ended. Start a new one."));
150213 }
214+ let repo_id = repo.as_ref().map(|repo| repo.id.clone()).or_else(|| session.as_ref().map(|session| session.repo_id.clone()));
151215 let minutes = claim_minutes(a.ttl_minutes);
152216 let note = tidy(a.note.as_deref(), MAX_NOTE_CHARS);
153217 let session_id = session.as_ref().map(|session| session.id.clone());
154218 let harness = session.as_ref().map(|session| session.harness.as_str().to_owned());
155219 let now = now_ms();
156220 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?;
158222 let (mine, others): (Vec<Claim>, Vec<Claim>) = standing
159223 .into_iter()
160224 .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());
162227 // 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() {
164229 let renewed = self
165230 .db
166231 .prepare(format!(
167232 "UPDATE claims
168233 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)
170236 WHERE id = ? AND state = 'active'
171237 RETURNING {CLAIM_COLUMNS}"
172238 ))
176242 text_or_null(&note),
177243 text_or_null(&session_id),
178244 text_or_null(&harness),
179− held.id.as_str().into(),
245+ text_or_null(&repo_id),
246+ held_claim.id.as_str().into(),
180247 ])?
181248 .first::<ClaimRow>(None)
182249 .await?;
183250 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;
186253 }
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 }));
188255 }
189256 }
190257 // Taking over what was handed off to you, or to anyone.
192259 .db
193260 .prepare(format!(
194261 "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"
196263 ))
197− .bind(&[repo.id.as_str().into(), issue.number.into()])?
264+ .bind(&[held.item.id.as_str().into()])?
198265 .first::<ClaimRow>(None)
199266 .await?;
200267 let took_over = handed
208275 // is an older one of the caller's) is not.
209276 let proposed = Claim {
210277 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,
213281 session_id: session_id.clone(),
214282 actor: Principal { id: a.actor.id.clone(), username: a.actor.username.clone() },
215283 harness: harness.as_deref().and_then(Harness::parse),
223291 };
224292 let event = |claim: &Claim| {
225293 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) },
227295 Some(&a.actor),
228296 )
229297 };
232300 .prepare(upsert_claim_sql())
233301 .bind(&[
234302 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),
237305 text_or_null(&session_id),
238306 a.actor.id.as_str().into(),
239307 a.actor.username.as_str().into(),
245313 ])?];
246314 let sent = push_delivery(
247315 &mut statements,
248− self.delivery(
316+ self.delivery_to(
249317 "work.claimed",
250− &repo.id,
318+ &repos,
251319 event(&proposed),
252320 &format!("{proposed_id}:claimed"),
253321 "EXISTS (SELECT 1 FROM claims WHERE id = ?7)",
262330 return Err(worker::Error::RustError("the claim was not recorded".into()));
263331 };
264332 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;
267335 }
268336 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 }));
270338 }
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 }))
273341 }
274342
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.
278346 #[allow(clippy::too_many_arguments)]
279347 async fn end_own_claim(
280348 &self,
281349 actor: &User,
282− repo: &g1t_contracts::repos::RepoPath,
350+ workspace: &str,
351+ repo: Option<&RepoPath>,
283352 number: u32,
284353 state: ClaimState,
285354 reason: &str,
286355 handoff: Option<(&Option<String>, &str)>,
287356 session: Option<&str>,
288357 kind: &'static str,
289− event: impl Fn(&Claim) -> ClaimEvent,
358+ event: impl Fn(&Claim, &[String]) -> ClaimEvent,
290359 ) -> 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 {
293364 return Ok(Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}.")));
294365 };
366+ let repos = claim_repos(&held, claim.repo_id.as_deref());
295367 let at = rfc3339(now_ms());
296368 let expected = Claim {
297369 state,
298370 ended_at: Some(at.clone()),
299371 end_reason: Some(reason.to_owned()),
300372 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()
303375 };
304376 let (handoff_to, note) = match handoff {
305377 Some((to, note)) => (text_or_null(to), JsValue::from(note)),
313385 WHERE id = ? AND state = 'active'
314386 RETURNING {CLAIM_COLUMNS}"
315387 ))
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()])?];
317389 // A claim ends once: its id and how it ended name the change.
318390 let sent = push_delivery(
319391 &mut statements,
320− self.delivery(
392+ self.delivery_to(
321393 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()),
325397 "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()],
327399 )?,
328400 );
329401 let ended = match self.db.batch(statements).await?.into_iter().next() {
334406 return Ok(Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}.")));
335407 };
336408 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?;
339411 Ok(Outcome::Ok(claim))
340412 }
341413
344416 let claim = unwrap_or_fail!(
345417 self.end_own_claim(
346418 &a.actor,
347− &a.repo,
419+ &a.workspace,
420+ a.repo.as_ref(),
348421 a.number,
349422 ClaimState::Released,
350423 &reason,
351424 None,
352425 a.session_id.as_deref(),
353426 "work.released",
354− |claim| ClaimEvent { reason: Some(reason.clone()), ..ClaimEvent::of(claim) },
427+ |claim, repos| ClaimEvent { reason: Some(reason.clone()), ..ClaimEvent::of(claim, repos) },
355428 )
356429 .await?
357430 );
358431 Ok(Outcome::Ok(claim))
359432 }
360433
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>> {
362435 let Some(note) = tidy(Some(&a.note), MAX_NOTE_CHARS) else {
363436 return Ok(Outcome::fail(FailureCode::Invalid, "Say where the work stands in note, for whoever picks it up."));
364437 };
373446 let claim = unwrap_or_fail!(
374447 self.end_own_claim(
375448 &a.actor,
376− &a.repo,
449+ &a.workspace,
450+ a.repo.as_ref(),
377451 a.number,
378452 ClaimState::HandedOff,
379453 "handed_off",
380454 Some((&to, &note)),
381455 a.session_id.as_deref(),
382456 "work.handoff_requested",
383− |claim| ClaimEvent {
457+ |claim, repos| ClaimEvent {
384458 from: Some(a.actor.username.clone()),
385459 to: to.clone(),
386460 note: Some(note.clone()),
387− ..ClaimEvent::of(claim)
461+ ..ClaimEvent::of(claim, repos)
388462 },
389463 )
390464 .await?
392466 Ok(Outcome::Ok(claim))
393467 }
394468
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.
395471 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+ };
397477 let state = a.state.unwrap_or(ClaimState::Active);
398478 let rows = self
399479 .db
400480 .prepare(format!(
401481 "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"
404486 ))
405487 .bind(&[
406− repo.id.as_str().into(),
488+ workspace.as_str().into(),
407489 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())),
409492 LIST_LIMIT.into(),
410493 ])?
411494 .all()
412495 .await?
413496 .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()))
415508 }
416509
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<()> {
420513 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?;
422515 if active.is_empty() {
423516 return Ok(());
424517 }
425518 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)
430521 };
431522 // Each claim's release and its updates are one change, in one batch.
432523 let mut statements = Vec::new();
433524 let mut sent = Vec::new();
434− for held in &active {
525+ for claim in &active {
435526 statements.push(
436527 self.db
437528 .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'
439530 WHERE id = ? AND state = 'active'
440531 RETURNING {CLAIM_COLUMNS}"
441532 ))
442− .bind(&[at.as_str().into(), held.id.as_str().into()])?,
533+ .bind(&[at.as_str().into(), claim.id.as_str().into()])?,
443534 );
444535 let expected = Claim {
445536 state: ClaimState::Released,
446537 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()
449540 };
450− let delivery = self.delivery(
541+ let delivery = self.delivery_to(
451542 "work.released",
452− repo_id,
543+ &claim_repos(held, claim.repo_id.as_deref()),
453544 event(&expected),
454− &format!("{}:{}", held.id, ClaimState::Released.as_str()),
545+ &format!("{}:{}", claim.id, ClaimState::Released.as_str()),
455546 "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()],
457548 )?;
458549 let index = statements.len() - 1;
459550 sent.push((index, push_delivery(&mut statements, delivery)));
465556 continue;
466557 };
467558 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?;
469561 }
470562 Ok(())
471563 }
+300−54
Loading the diff…
+108−90
Loading the diff…
+105−54
Loading the diff…
+46−25
Loading the diff…
+4−4
Loading the diff…
+61−43
Loading the diff…
+2−2
Loading the diff…
+54−60
Loading the diff…
+55−18
Loading the diff…
+73−36
Loading the diff…
+8−8
Loading the diff…
+1−11
Loading the diff…
+58−58
Loading the diff…
+12−11
Loading the diff…
+1130−0
A large diff (1,130 lines) is not shown until you ask for it.
+351−0
Loading the diff…
+228−190
Loading the diff…
+418−833
A large diff (1,800 lines) is not shown until you ask for it.
+9−7
Loading the diff…
+7−7
Loading the diff…
+6−30
Loading the diff…
+60−0
Loading the diff…
+124−107
Loading the diff…
+9−7
Loading the diff…
+148−172
Loading the diff…
+5−5
Loading the diff…
+2−14
Loading the diff…
+45−28
Loading the diff…
+12−8
Loading the diff…
+5−3
Loading the diff…
+34−85
Loading the diff…
+1−1
Loading the diff…
+1−1
Loading the diff…
+35−35
Loading the diff…
+2−2
Loading the diff…
+399−0
Loading the diff…
+4−0
Loading the diff…

Comments

Sign in to comment.