pr_01m47d15m3e54sn21z27rpy5n9/crates/contracts/src/work.rs

413 lines12,054 bytesCodeBlame
1//! The work service: issues, pull requests, comments and sessions.
2//!
3//! Each `*Args` struct is the argument of the method of the same name,
4//! served at `POST /rpc/<method>`.
5//!
6//! Issues and pull requests share one sequence of numbers per repository,
7//! so `#12` names exactly one of them.
8
9use serde::{Deserialize, Serialize};
10
11use crate::repos::RepoPath;
12use crate::{User, Viewer};
13
14/// Labels every repository starts with. Any other label comes into being
15/// the first time it is put on an issue.
16pub const DEFAULT_LABELS: [&str; 5] = ["bug", "feature", "docs", "chore", "question"];
17
18/// `open` or `closed`: the filter on lists of issues and pull requests. An
19/// open pull request is a draft or one ready for review; a closed one was
20/// merged or closed without merging.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum State {
24 Open,
25 Closed,
26}
27
28/// Why an issue was closed.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum IssueReason {
32 /// The work was done. If a pull request did it, `resolved_by` names it.
33 Completed,
34 NotPlanned,
35}
36
37impl IssueReason {
38 pub fn as_str(self) -> &'static str {
39 match self {
40 IssueReason::Completed => "completed",
41 IssueReason::NotPlanned => "not_planned",
42 }
43 }
44}
45
46/// Something that should change in a repository: a bug, a feature, a
47/// question. Opened by a person, an agent or an integration. Pull requests
48/// are made against it; the one that is merged resolves it.
49#[derive(Clone, Debug, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct Issue {
52 pub id: String,
53 pub repo_id: String,
54 /// Shown as `#12`.
55 pub number: u32,
56 pub title: String,
57 /// Markdown. Also what an agent is given to work from.
58 pub body: String,
59 pub labels: Vec<String>,
60 /// Commands that must pass for a pull request to be accepted.
61 pub checks: Vec<String>,
62 pub state: State,
63 /// Set when closed.
64 pub reason: Option<IssueReason>,
65 /// The number of the pull request whose merge closed this issue.
66 pub resolved_by: Option<u32>,
67 pub author: User,
68 /// RFC 3339.
69 pub created_at: String,
70 /// RFC 3339.
71 pub updated_at: String,
72 /// RFC 3339.
73 pub closed_at: Option<String>,
74 /// Pull requests made against this issue, in any state.
75 pub pull_count: u32,
76 pub comment_count: u32,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum PullStatus {
82 /// Still being worked on.
83 Draft,
84 /// Ready for review.
85 Open,
86 Merged,
87 /// Closed without merging.
88 Closed,
89}
90
91impl PullStatus {
92 pub fn as_str(self) -> &'static str {
93 match self {
94 PullStatus::Draft => "draft",
95 PullStatus::Open => "open",
96 PullStatus::Merged => "merged",
97 PullStatus::Closed => "closed",
98 }
99 }
100
101 /// Whether the pull request can still be changed or merged.
102 pub fn is_active(self) -> bool {
103 matches!(self, PullStatus::Draft | PullStatus::Open)
104 }
105}
106
107/// Where the agent runs: on g1t's sandboxes, or in someone's own session.
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum Runtime {
111 Hosted,
112 External,
113}
114
115/// A proposed change, made in its own fork by an agent or a person.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct Pull {
119 pub id: String,
120 pub repo_id: String,
121 /// Shown as `#12`.
122 pub number: u32,
123 /// The number of the issue this is for, if any.
124 pub issue: Option<u32>,
125 pub title: String,
126 /// Markdown: what changed and why. Set when marked ready.
127 pub body: Option<String>,
128 /// A label for the agent doing the work, e.g. `claude-code`.
129 pub agent: String,
130 pub runtime: Runtime,
131 pub status: PullStatus,
132 pub fork: RepoPath,
133 /// The fork's repository id.
134 pub fork_repo_id: String,
135 pub head_commit: Option<String>,
136 /// For a merged pull request, what the branch pointed to before the
137 /// merge. Comparing against it shows what the pull request changed.
138 pub merge_base: Option<String>,
139 /// Username of whoever merged it.
140 pub merged_by: Option<String>,
141 /// RFC 3339.
142 pub merged_at: Option<String>,
143 /// Set on a pull request closed because another one for the same issue
144 /// was merged: that one's number.
145 pub superseded_by: Option<u32>,
146 pub author: User,
147 /// RFC 3339.
148 pub created_at: String,
149 /// RFC 3339.
150 pub updated_at: String,
151}
152
153#[derive(Clone, Debug, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct Comment {
156 pub id: String,
157 pub author: User,
158 /// Markdown.
159 pub body: String,
160 /// RFC 3339.
161 pub created_at: String,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum SessionEntryKind {
167 Prompt,
168 Message,
169 ToolCall,
170 ToolResult,
171 Note,
172}
173
174/// One step of an agent's session: the "why" behind a pull request's commits.
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct SessionEntry {
177 pub seq: u32,
178 pub kind: SessionEntryKind,
179 pub text: String,
180 /// For tool calls and results.
181 pub tool: Option<String>,
182 /// The fork's head commit when this entry was recorded, if known.
183 pub commit: Option<String>,
184 /// RFC 3339.
185 pub at: String,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize)]
189pub struct NewSessionEntry {
190 pub kind: SessionEntryKind,
191 pub text: String,
192 #[serde(default)]
193 pub tool: Option<String>,
194 #[serde(default)]
195 pub commit: Option<String>,
196}
197
198#[derive(Clone, Debug, Serialize, Deserialize)]
199pub struct IssueDetail {
200 pub issue: Issue,
201 /// Every pull request made against it, oldest first.
202 pub pulls: Vec<Pull>,
203 pub comments: Vec<Comment>,
204}
205
206#[derive(Clone, Debug, Serialize, Deserialize)]
207pub struct PullDetail {
208 pub pull: Pull,
209 /// The issue it is for, if any.
210 pub issue: Option<Issue>,
211 pub comments: Vec<Comment>,
212}
213
214/// `open_issue`. Returns `Outcome<Issue>`.
215#[derive(Debug, Serialize, Deserialize)]
216pub struct OpenIssueArgs {
217 pub actor: User,
218 pub repo: RepoPath,
219 pub title: String,
220 #[serde(default)]
221 pub body: String,
222 #[serde(default)]
223 pub labels: Vec<String>,
224 #[serde(default)]
225 pub checks: Vec<String>,
226}
227
228/// `list_issues`, newest first. Returns `Outcome<Vec<Issue>>`.
229#[derive(Debug, Serialize, Deserialize)]
230pub struct ListIssuesArgs {
231 pub repo: RepoPath,
232 pub viewer: Viewer,
233 #[serde(default)]
234 pub state: Option<State>,
235 /// Only issues carrying this label.
236 #[serde(default)]
237 pub label: Option<String>,
238}
239
240/// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`.
241#[derive(Debug, Serialize, Deserialize)]
242pub struct ListPullsArgs {
243 pub repo: RepoPath,
244 pub viewer: Viewer,
245 #[serde(default)]
246 pub state: Option<State>,
247}
248
249/// `get_issue` (`Outcome<IssueDetail>`), `get_pull` (`Outcome<PullDetail>`),
250/// `read_session` (`Outcome<Vec<SessionEntry>>`), `list_labels`
251/// (`Outcome<Vec<String>>`) and `counts` (`Outcome<Counts>`). The last two
252/// ignore `number`.
253#[derive(Debug, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct ViewArgs {
256 pub repo: RepoPath,
257 #[serde(default)]
258 pub number: u32,
259 pub viewer: Viewer,
260 /// For `read_session`: only entries after this sequence number.
261 #[serde(default)]
262 pub after_seq: u32,
263}
264
265/// How many issues and pull requests are open on a repository.
266#[derive(Debug, Serialize, Deserialize)]
267pub struct Counts {
268 pub issues: u32,
269 pub pulls: u32,
270}
271
272/// `update_issue`: changes whichever fields are given. The author or a
273/// member of the workspace may. Returns `Outcome<Issue>`.
274#[derive(Debug, Serialize, Deserialize)]
275pub struct UpdateIssueArgs {
276 pub actor: User,
277 pub repo: RepoPath,
278 pub number: u32,
279 #[serde(default)]
280 pub title: Option<String>,
281 #[serde(default)]
282 pub body: Option<String>,
283 #[serde(default)]
284 pub labels: Option<Vec<String>>,
285}
286
287/// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`.
288#[derive(Debug, Serialize, Deserialize)]
289pub struct IssueActionArgs {
290 pub actor: User,
291 pub repo: RepoPath,
292 pub number: u32,
293 /// For `close_issue`; `completed` if left out.
294 #[serde(default)]
295 pub reason: Option<IssueReason>,
296}
297
298/// `add_comment`, on an issue or a pull request. Returns `Outcome<Comment>`.
299#[derive(Debug, Serialize, Deserialize)]
300pub struct AddCommentArgs {
301 pub actor: User,
302 pub repo: RepoPath,
303 pub number: u32,
304 pub body: String,
305}
306
307/// `open_pull`: forks the repo and returns the draft pull request to push
308/// to. Returns `Outcome<Pull>`.
309#[derive(Debug, Serialize, Deserialize)]
310pub struct OpenPullArgs {
311 pub actor: User,
312 pub repo: RepoPath,
313 /// The issue this is for.
314 #[serde(default)]
315 pub issue: Option<u32>,
316 /// Defaults to the issue's title; required without an issue.
317 #[serde(default)]
318 pub title: String,
319 #[serde(default)]
320 pub agent: String,
321 pub runtime: Runtime,
322}
323
324/// `ready_pull`, `close_pull` and `merge_pull`. Each returns `Outcome<Pull>`.
325#[derive(Debug, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct PullActionArgs {
328 pub actor: User,
329 pub repo: RepoPath,
330 pub number: u32,
331 /// For `ready_pull`: what changed and why.
332 #[serde(default)]
333 pub summary: String,
334 /// For `merge_pull`: leave the issue open and the other pull requests
335 /// for it untouched, because this one is only part of the work.
336 #[serde(default)]
337 pub keep_issue_open: bool,
338}
339
340/// A pull request in progress, with where it lives.
341#[derive(Debug, Serialize, Deserialize)]
342pub struct ActivePull {
343 pub pull: Pull,
344 pub issue: Option<Issue>,
345}
346
347/// `list_active_pulls`: drafts and open pull requests the viewer started,
348/// most recently active first. Returns `Vec<ActivePull>`.
349#[derive(Debug, Serialize, Deserialize)]
350pub struct ViewerArgs {
351 pub viewer: Viewer,
352}
353
354/// `append_session`. Returns `Outcome<Appended>`.
355#[derive(Debug, Serialize, Deserialize)]
356pub struct AppendSessionArgs {
357 pub actor: User,
358 pub repo: RepoPath,
359 pub number: u32,
360 pub entries: Vec<NewSessionEntry>,
361}
362
363#[derive(Debug, Serialize, Deserialize)]
364pub struct Appended {
365 pub count: u32,
366}
367
368/// Lowercases, trims and de-duplicates labels, dropping empty ones.
369/// Returns `None` if there are too many or one is too long.
370pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> {
371 const MAX_LABELS: usize = 10;
372 const MAX_LABEL_CHARS: usize = 40;
373 let mut normalized: Vec<String> = Vec::new();
374 for label in labels {
375 let label = label
376 .split_whitespace()
377 .collect::<Vec<_>>()
378 .join(" ")
379 .to_lowercase();
380 if label.is_empty() || normalized.contains(&label) {
381 continue;
382 }
383 if label.chars().count() > MAX_LABEL_CHARS {
384 return None;
385 }
386 normalized.push(label);
387 }
388 (normalized.len() <= MAX_LABELS).then_some(normalized)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::normalize_labels;
394
395 fn labels(names: &[&str]) -> Vec<String> {
396 names.iter().map(|name| (*name).to_owned()).collect()
397 }
398
399 #[test]
400 fn labels_are_lowercased_trimmed_and_unique() {
401 assert_eq!(
402 normalize_labels(&labels(&[" Bug ", "bug", "", "Good First Issue"])),
403 Some(labels(&["bug", "good first issue"]))
404 );
405 }
406
407 #[test]
408 fn too_long_or_too_many_labels_are_refused() {
409 assert_eq!(normalize_labels(&["x".repeat(41)]), None);
410 let many: Vec<String> = (0..11).map(|i| format!("label-{i}")).collect();
411 assert_eq!(normalize_labels(&many), None);
412 }
413}