g1t

syntaqx/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/crates/contracts/src/work.rs

571 lines17,391 bytes
//! The work service: issues, pull requests, comments and sessions.
//!
//! Each `*Args` struct is the argument of the method of the same name,
//! served at `POST /rpc/<method>`.
//!
//! Issues and pull requests share one sequence of numbers per repository,
//! so `#12` names exactly one of them.

use serde::{Deserialize, Serialize};

use crate::repos::RepoPath;
use crate::{User, Viewer};

/// Labels every repository starts with. Any other label comes into being
/// the first time it is put on an issue.
pub const DEFAULT_LABELS: [&str; 5] = ["bug", "feature", "docs", "chore", "question"];

/// `open` or `closed`: the filter on lists of issues and pull requests. An
/// open pull request is a draft or one ready for review; a closed one was
/// merged or closed without merging.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
    Open,
    Closed,
}

/// Why an issue was closed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueReason {
    /// The work was done. If a pull request did it, `resolved_by` names it.
    Completed,
    NotPlanned,
}

impl IssueReason {
    pub fn as_str(self) -> &'static str {
        match self {
            IssueReason::Completed => "completed",
            IssueReason::NotPlanned => "not_planned",
        }
    }
}

/// Something that should change in a repository: a bug, a feature, a
/// question. Opened by a person, an agent or an integration. Pull requests
/// are made against it; the one that is merged resolves it.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Issue {
    pub id: String,
    pub repo_id: String,
    /// Shown as `#12`.
    pub number: u32,
    pub title: String,
    /// Markdown. Also what an agent is given to work from.
    pub body: String,
    pub labels: Vec<String>,
    /// Commands that must pass for a pull request to be accepted.
    pub checks: Vec<String>,
    pub state: State,
    /// Set when closed.
    pub reason: Option<IssueReason>,
    /// The number of the pull request whose merge closed this issue.
    pub resolved_by: Option<u32>,
    pub author: User,
    /// RFC 3339.
    pub created_at: String,
    /// RFC 3339.
    pub updated_at: String,
    /// RFC 3339.
    pub closed_at: Option<String>,
    /// Pull requests made against this issue, in any state.
    pub pull_count: u32,
    pub comment_count: u32,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PullStatus {
    /// Still being worked on.
    Draft,
    /// Ready for review.
    Open,
    Merged,
    /// Closed without merging.
    Closed,
}

impl PullStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            PullStatus::Draft => "draft",
            PullStatus::Open => "open",
            PullStatus::Merged => "merged",
            PullStatus::Closed => "closed",
        }
    }

    /// Whether the pull request can still be changed or merged.
    pub fn is_active(self) -> bool {
        matches!(self, PullStatus::Draft | PullStatus::Open)
    }
}

/// Where the agent runs: on g1t's sandboxes, or in someone's own session.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Runtime {
    Hosted,
    External,
}

/// A proposed change. It is made either in a fork created for it, which is
/// how agents work, or on a branch pushed to the repository itself.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pull {
    pub id: String,
    pub repo_id: String,
    /// Shown as `#12`.
    pub number: u32,
    /// The number of the issue this is for, if any.
    pub issue: Option<u32>,
    pub title: String,
    /// Markdown: what changed and why. Set when marked ready.
    pub body: Option<String>,
    /// A label for the agent doing the work, e.g. `claude-code`.
    pub agent: String,
    pub runtime: Runtime,
    pub status: PullStatus,
    /// The fork holding the change, unless it is on a branch.
    pub fork: Option<RepoPath>,
    /// The fork's repository id.
    pub fork_repo_id: Option<String>,
    /// The branch of the repository holding the change, unless it is in a
    /// fork.
    pub branch: Option<String>,
    pub head_commit: Option<String>,
    /// For a merged pull request, what the branch pointed to before the
    /// merge. Comparing against it shows what the pull request changed.
    pub merge_base: Option<String>,
    /// Username of whoever merged it.
    pub merged_by: Option<String>,
    /// RFC 3339.
    pub merged_at: Option<String>,
    /// Set on a pull request closed because another one for the same issue
    /// was merged: that one's number.
    pub superseded_by: Option<u32>,
    /// Where the latest run of the issue's acceptance checks stands, if
    /// there has been one against the current head.
    pub check_status: Option<CheckStatus>,
    pub author: User,
    /// RFC 3339.
    pub created_at: String,
    /// RFC 3339.
    pub updated_at: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
    /// Waiting for a sandbox.
    Queued,
    Running,
    Passed,
    Failed,
    /// The checks could not be run at all.
    Errored,
}

impl CheckStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            CheckStatus::Queued => "queued",
            CheckStatus::Running => "running",
            CheckStatus::Passed => "passed",
            CheckStatus::Failed => "failed",
            CheckStatus::Errored => "errored",
        }
    }
}

/// How one acceptance check went.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckResult {
    pub command: String,
    pub passed: bool,
    /// Absent when the command was stopped for taking too long.
    #[serde(default)]
    pub exit_code: Option<i32>,
    /// What the command printed, standard output and error together. The
    /// end of it, when there was a lot.
    #[serde(default)]
    pub output: String,
    #[serde(default)]
    pub duration_ms: u64,
}

/// One run of an issue's acceptance checks against a pull request's head,
/// in a sandbox that holds nothing but that commit.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckRun {
    pub id: String,
    /// The commit that was checked.
    pub head_commit: String,
    pub status: CheckStatus,
    pub results: Vec<CheckResult>,
    /// Why the checks could not be run, when `status` is `errored`.
    pub error: Option<String>,
    /// RFC 3339.
    pub created_at: String,
    /// RFC 3339.
    pub finished_at: Option<String>,
}

/// A reviewer's decision on a pull request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
    Approve,
    RequestChanges,
}

impl Verdict {
    pub fn as_str(self) -> &'static str {
        match self {
            Verdict::Approve => "approve",
            Verdict::RequestChanges => "request_changes",
        }
    }
}

/// A comment on an issue or a pull request. On a pull request it can sit
/// on one line of the change, and it can carry a reviewer's verdict.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
    pub id: String,
    pub author: User,
    /// Markdown.
    pub body: String,
    /// The file commented on, for a comment on a line.
    pub path: Option<String>,
    /// The line of that file, as numbered after the change.
    pub line: Option<u32>,
    pub verdict: Option<Verdict>,
    /// RFC 3339.
    pub created_at: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionEntryKind {
    Prompt,
    Message,
    ToolCall,
    ToolResult,
    Note,
}

/// One step of an agent's session: the "why" behind a pull request's commits.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionEntry {
    pub seq: u32,
    pub kind: SessionEntryKind,
    pub text: String,
    /// For tool calls and results.
    pub tool: Option<String>,
    /// The fork's head commit when this entry was recorded, if known.
    pub commit: Option<String>,
    /// RFC 3339.
    pub at: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NewSessionEntry {
    pub kind: SessionEntryKind,
    pub text: String,
    #[serde(default)]
    pub tool: Option<String>,
    #[serde(default)]
    pub commit: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IssueDetail {
    pub issue: Issue,
    /// Every pull request made against it, oldest first.
    pub pulls: Vec<Pull>,
    pub comments: Vec<Comment>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PullDetail {
    pub pull: Pull,
    /// The issue it is for, if any.
    pub issue: Option<Issue>,
    pub comments: Vec<Comment>,
    /// The latest run of the issue's acceptance checks.
    pub checks: Option<CheckRun>,
}

/// `open_issue`. Returns `Outcome<Issue>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenIssueArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub title: String,
    #[serde(default)]
    pub body: String,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub checks: Vec<String>,
}

/// `list_issues`, newest first. Returns `Outcome<Vec<Issue>>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListIssuesArgs {
    pub repo: RepoPath,
    pub viewer: Viewer,
    #[serde(default)]
    pub state: Option<State>,
    /// Only issues carrying this label.
    #[serde(default)]
    pub label: Option<String>,
}

/// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListPullsArgs {
    pub repo: RepoPath,
    pub viewer: Viewer,
    #[serde(default)]
    pub state: Option<State>,
}

/// `get_issue` (`Outcome<IssueDetail>`), `get_pull` (`Outcome<PullDetail>`),
/// `read_session` (`Outcome<Vec<SessionEntry>>`), `list_labels`
/// (`Outcome<Vec<String>>`) and `counts` (`Outcome<Counts>`). The last two
/// ignore `number`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewArgs {
    pub repo: RepoPath,
    #[serde(default)]
    pub number: u32,
    pub viewer: Viewer,
    /// For `read_session`: only entries after this sequence number.
    #[serde(default)]
    pub after_seq: u32,
}

/// How many issues and pull requests are open on a repository.
#[derive(Debug, Serialize, Deserialize)]
pub struct Counts {
    pub issues: u32,
    pub pulls: u32,
}

/// `update_issue`: changes whichever fields are given. The author or a
/// member of the workspace may. Returns `Outcome<Issue>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateIssueArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub number: u32,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub body: Option<String>,
    #[serde(default)]
    pub labels: Option<Vec<String>>,
}

/// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct IssueActionArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub number: u32,
    /// For `close_issue`; `completed` if left out.
    #[serde(default)]
    pub reason: Option<IssueReason>,
}

/// `add_comment`, on an issue or a pull request. On a pull request it may
/// name a line of the change, and may carry a verdict; nobody can give a
/// verdict on their own pull request. Returns `Outcome<Comment>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct AddCommentArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub number: u32,
    /// May be empty when approving.
    #[serde(default)]
    pub body: String,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub line: Option<u32>,
    #[serde(default)]
    pub verdict: Option<Verdict>,
}

/// `open_pull`. Without `branch`, forks the repo and returns a draft pull
/// request to push to. With it, opens a pull request, ready for review,
/// for a branch already pushed to the repo. Returns `Outcome<Pull>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenPullArgs {
    pub actor: User,
    pub repo: RepoPath,
    /// The issue this is for.
    #[serde(default)]
    pub issue: Option<u32>,
    /// Defaults to the issue's title; required without an issue.
    #[serde(default)]
    pub title: String,
    /// What changed and why. Usually set later, when a draft is marked ready.
    #[serde(default)]
    pub body: String,
    /// A branch of the repository that already holds the change.
    #[serde(default)]
    pub branch: Option<String>,
    #[serde(default)]
    pub agent: String,
    pub runtime: Runtime,
}

/// `ready_pull`, `close_pull` and `merge_pull`. Each returns `Outcome<Pull>`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PullActionArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub number: u32,
    /// For `ready_pull`: what changed and why.
    #[serde(default)]
    pub summary: String,
    /// For `merge_pull`: leave the issue open and the other pull requests
    /// for it untouched, because this one is only part of the work.
    #[serde(default)]
    pub keep_issue_open: bool,
    /// For `merge_pull`: merge although the acceptance checks have not
    /// passed.
    #[serde(default)]
    pub ignore_checks: bool,
}

/// `start_checks`: begins a run of the acceptance checks for a pull request
/// that is ready for review. Called by the runner service, which starts the
/// sandbox. Returns `Outcome<CheckJob>`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartChecksArgs {
    pub pull_id: String,
}

/// What a sandbox needs to carry out a check run.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckJob {
    pub run_id: String,
    /// Lets the sandbox, and nothing else, report this run's results.
    pub token: String,
    pub commands: Vec<String>,
    /// The repository holding the commit: the fork, or the repository itself.
    pub source: RepoPath,
    pub commit: String,
    /// Who opened the pull request, and so can read its source.
    pub author: User,
    /// Username of whoever wrote the checks: the issue's author.
    pub requested_by: String,
    pub repo: RepoPath,
    pub number: u32,
}

/// `report_checks`: what a sandbox says about its run. With no results and
/// no error it has started. `skip` forgets the run, for one that will not
/// be carried out. Returns `Outcome<CheckRun>`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportChecksArgs {
    pub run_id: String,
    pub token: String,
    #[serde(default)]
    pub results: Vec<CheckResult>,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub skip: bool,
}

/// A pull request in progress, with where it lives.
#[derive(Debug, Serialize, Deserialize)]
pub struct ActivePull {
    pub pull: Pull,
    pub issue: Option<Issue>,
}

/// `list_active_pulls`: drafts and open pull requests the viewer started,
/// most recently active first. Returns `Vec<ActivePull>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct ViewerArgs {
    pub viewer: Viewer,
}

/// `append_session`. Returns `Outcome<Appended>`.
#[derive(Debug, Serialize, Deserialize)]
pub struct AppendSessionArgs {
    pub actor: User,
    pub repo: RepoPath,
    pub number: u32,
    pub entries: Vec<NewSessionEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Appended {
    pub count: u32,
}

/// Lowercases, trims and de-duplicates labels, dropping empty ones.
/// Returns `None` if there are too many or one is too long.
pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> {
    const MAX_LABELS: usize = 10;
    const MAX_LABEL_CHARS: usize = 40;
    let mut normalized: Vec<String> = Vec::new();
    for label in labels {
        let label = label
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ")
            .to_lowercase();
        if label.is_empty() || normalized.contains(&label) {
            continue;
        }
        if label.chars().count() > MAX_LABEL_CHARS {
            return None;
        }
        normalized.push(label);
    }
    (normalized.len() <= MAX_LABELS).then_some(normalized)
}

#[cfg(test)]
mod tests {
    use super::normalize_labels;

    fn labels(names: &[&str]) -> Vec<String> {
        names.iter().map(|name| (*name).to_owned()).collect()
    }

    #[test]
    fn labels_are_lowercased_trimmed_and_unique() {
        assert_eq!(
            normalize_labels(&labels(&[" Bug ", "bug", "", "Good  First Issue"])),
            Some(labels(&["bug", "good first issue"]))
        );
    }

    #[test]
    fn too_long_or_too_many_labels_are_refused() {
        assert_eq!(normalize_labels(&["x".repeat(41)]), None);
        let many: Vec<String> = (0..11).map(|i| format!("label-{i}")).collect();
        assert_eq!(normalize_labels(&many), None);
    }
}