use std::{
collections::{HashMap, HashSet},
fmt,
future::Future,
str::FromStr,
};
use anyhow::Context;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::domain::{
ActivityEntry, ActivityKind, ChangedFile, CheckRun, CheckStatus, Commit, Deployment,
MetadataItem, PullRequest, ReactionCounts, Resource, ResourceId, ResourceKind,
FULL_DEPTH_WARNING_HINT,
};
use crate::github::transport::{run_graphql_query, run_rest_get, GITHUB_GRAPHQL_URL};
use crate::github::{
public_rest::{fetch_public_rest_issue, fetch_public_rest_pr},
queries::{
assignees_query, base_issue_query, base_pr_query, changed_files_query, check_suites_query,
comments_query, commit_authors_query, commit_comment_thread_comments_query,
commit_deployment_items_query, commit_deployments_query, commits_query,
issue_duplicate_query, issue_linked_branches_query, issue_parent_query,
issue_relationships_query, labels_query, linked_resources_query, participants_query,
project_items_query, review_requests_query, review_thread_comments_query,
review_threads_query, reviews_query, status_rollup_query, timeline_query,
},
};
pub trait GithubGateway {
fn fetch_resource(
&self,
id: &ResourceId,
) -> impl Future<Output = anyhow::Result<Resource>> + Send;
}
#[derive(Debug, Clone)]
pub struct GithubApiGateway {
api_depth: ApiDepth,
}
impl GithubApiGateway {
pub fn new(api_depth: ApiDepth) -> Self {
Self { api_depth }
}
pub fn from_env() -> Self {
Self::new(ApiDepth::from_env())
}
}
impl Default for GithubApiGateway {
fn default() -> Self {
Self::from_env()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApiDepth {
#[default]
Partial,
Full,
}
impl ApiDepth {
pub fn from_env() -> Self {
std::env::var("GZG_API_DEPTH")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or_default()
}
fn is_full(self) -> bool {
self == Self::Full
}
}
impl fmt::Display for ApiDepth {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Partial => formatter.write_str("partial"),
Self::Full => formatter.write_str("full"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiDepthParseError(String);
impl fmt::Display for ApiDepthParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"invalid API depth {:?}; expected partial or full",
self.0
)
}
}
impl std::error::Error for ApiDepthParseError {}
impl FromStr for ApiDepth {
type Err = ApiDepthParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"partial" | "normal" | "default" => Ok(Self::Partial),
"full" | "exhaustive" => Ok(Self::Full),
_ => Err(ApiDepthParseError(value.into())),
}
}
}
impl GithubGateway for GithubApiGateway {
async fn fetch_resource(&self, id: &ResourceId) -> anyhow::Result<Resource> {
match id.kind_hint {
Some(ResourceKind::PullRequest) => fetch_pr(id, self.api_depth).await,
Some(ResourceKind::Issue) => fetch_issue(id, self.api_depth).await,
None => match fetch_pr(id, self.api_depth).await {
Ok(resource) => Ok(resource),
Err(pr_error) => fetch_issue(id, self.api_depth)
.await
.with_context(|| format!("failed as PR first: {pr_error}")),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IssueRelationshipKind {
SubIssues,
TrackedIssues,
TrackedInIssues,
BlockedBy,
Blocking,
}
impl IssueRelationshipKind {
const ALL: [Self; 5] = [
Self::SubIssues,
Self::TrackedIssues,
Self::TrackedInIssues,
Self::BlockedBy,
Self::Blocking,
];
fn connection(self) -> &'static str {
match self {
Self::SubIssues => "subIssues",
Self::TrackedIssues => "trackedIssues",
Self::TrackedInIssues => "trackedInIssues",
Self::BlockedBy => "blockedBy",
Self::Blocking => "blocking",
}
}
fn metadata_label(self) -> &'static str {
match self {
Self::SubIssues => "Sub-issues",
Self::TrackedIssues => "Tracked issues",
Self::TrackedInIssues => "Tracked in",
Self::BlockedBy => "Blocked by",
Self::Blocking => "Blocking",
}
}
fn warning_label(self) -> &'static str {
match self {
Self::SubIssues => "sub-issues unavailable",
Self::TrackedIssues => "tracked issues unavailable",
Self::TrackedInIssues => "tracked-in issues unavailable",
Self::BlockedBy => "blocked-by issues unavailable",
Self::Blocking => "blocking issues unavailable",
}
}
}
pub fn command_preview_for_pr(id: &ResourceId) -> Vec<String> {
graphql_preview("pullRequest", id)
}
pub fn command_preview_for_issue(id: &ResourceId) -> Vec<String> {
graphql_preview("issue", id)
}
async fn fetch_pr(id: &ResourceId, api_depth: ApiDepth) -> anyhow::Result<Resource> {
let output = match run_graphql_base_pr(id).await {
Ok(output) => output,
Err(error) if crate::github::auth::should_try_public_rest_fallback(&error) => {
return fetch_public_rest_pr(id, error).await;
}
Err(error) => return Err(error),
};
let full_depth = api_depth.is_full();
let partial_depth_warnings = if full_depth {
Vec::new()
} else {
base_pr_partial_depth_warnings(&output)
};
let dto = pr_view_from_graphql(&output)?;
let mut resource = dto.into_resource(id);
resource.warnings.extend(partial_depth_warnings);
if full_depth {
enrich_project_metadata(&mut resource, id, ResourceKind::PullRequest).await;
enrich_labels_and_assignees(&mut resource, id, ResourceKind::PullRequest).await;
enrich_linked_resources(&mut resource, id, ResourceKind::PullRequest).await;
enrich_review_requests(&mut resource, id).await;
match fetch_comment_activity(id, ResourceKind::PullRequest).await {
Ok(comments) => replace_comment_activity(&mut resource, comments),
Err(error) => push_enrichment_warning(&mut resource, "comments unavailable", &error),
}
match fetch_review_activity(id).await {
Ok(reviews) => replace_review_activity(&mut resource, reviews),
Err(error) => push_enrichment_warning(&mut resource, "reviews unavailable", &error),
}
}
enrich_participants(&mut resource, id, ResourceKind::PullRequest).await;
match fetch_review_thread_activity(id).await {
Ok(review_comments) => resource.activity.extend(review_comments),
Err(error) => push_enrichment_warning(&mut resource, "review threads unavailable", &error),
}
match fetch_timeline_activity(id, ResourceKind::PullRequest).await {
Ok(timeline) => resource.activity.extend(timeline),
Err(error) => push_enrichment_warning(&mut resource, "timeline unavailable", &error),
}
sort_activity(&mut resource.activity);
let mut warnings = Vec::new();
if let Some(pr) = resource.pull_request.as_mut() {
if full_depth {
match fetch_changed_files(id).await {
Ok(files) => pr.files = files,
Err(error) => warnings.push(format!("full changed file list unavailable: {error}")),
}
match fetch_commits(id).await {
Ok(commits) => replace_pr_commits(pr, commits),
Err(error) => warnings.push(format!("full commit list unavailable: {error}")),
}
match fetch_commit_deployments(id).await {
Ok(deployments) => apply_commit_deployments(&mut pr.commits, deployments),
Err(error) => warnings.push(format!("commit deployments unavailable: {error}")),
}
match fetch_status_rollup_checks(id).await {
Ok(checks) => pr.checks = checks,
Err(error) => warnings.push(format!("full status rollup unavailable: {error}")),
}
}
match fetch_file_patches(id).await {
Ok(patches) => apply_file_patches(&mut pr.files, patches),
Err(error) => warnings.push(format!("file patch context unavailable: {error}")),
}
match fetch_check_suites(id).await {
Ok(suites) => apply_check_suites(&mut pr.checks, suites),
Err(error) => warnings.push(format!("check suites unavailable: {error}")),
}
}
resource.warnings.extend(warnings);
Ok(resource)
}
async fn fetch_issue(id: &ResourceId, api_depth: ApiDepth) -> anyhow::Result<Resource> {
let output = match run_graphql_base_issue(id).await {
Ok(output) => output,
Err(error) if crate::github::auth::should_try_public_rest_fallback(&error) => {
return fetch_public_rest_issue(id, error).await;
}
Err(error) => return Err(error),
};
let full_depth = api_depth.is_full();
let partial_depth_warnings = if full_depth {
Vec::new()
} else {
base_issue_partial_depth_warnings(&output)
};
let dto = issue_view_from_graphql(&output)?;
let mut resource = dto.into_resource(id);
resource.warnings.extend(partial_depth_warnings);
if full_depth {
enrich_project_metadata(&mut resource, id, ResourceKind::Issue).await;
enrich_labels_and_assignees(&mut resource, id, ResourceKind::Issue).await;
enrich_linked_resources(&mut resource, id, ResourceKind::Issue).await;
match fetch_comment_activity(id, ResourceKind::Issue).await {
Ok(comments) => replace_comment_activity(&mut resource, comments),
Err(error) => push_enrichment_warning(&mut resource, "comments unavailable", &error),
}
}
enrich_participants(&mut resource, id, ResourceKind::Issue).await;
enrich_issue_relationships(&mut resource, id).await;
enrich_issue_linked_branches(&mut resource, id).await;
match fetch_timeline_activity(id, ResourceKind::Issue).await {
Ok(timeline) => resource.activity.extend(timeline),
Err(error) => push_enrichment_warning(&mut resource, "timeline unavailable", &error),
}
sort_activity(&mut resource.activity);
Ok(resource)
}
fn base_pr_partial_depth_warnings(raw: &[u8]) -> Vec<String> {
let Ok(value) = serde_json::from_slice::<Value>(raw) else {
return Vec::new();
};
let Some(pr) = value.pointer("/data/repository/pullRequest") else {
return Vec::new();
};
partial_depth_warnings(
pr,
&[
(&["labels"][..], "labels"),
(&["assignees"][..], "assignees"),
(&["reviewRequests"][..], "review requests"),
(&["closingIssuesReferences"][..], "linked closing issues"),
(&["commits"][..], "commits"),
(&["statusCheckRollup", "contexts"][..], "checks"),
(&["files"][..], "changed files"),
(&["comments"][..], "comments"),
(&["reviews"][..], "reviews"),
],
)
}
fn base_issue_partial_depth_warnings(raw: &[u8]) -> Vec<String> {
let Ok(value) = serde_json::from_slice::<Value>(raw) else {
return Vec::new();
};
let Some(issue) = value.pointer("/data/repository/issue") else {
return Vec::new();
};
partial_depth_warnings(
issue,
&[
(&["labels"][..], "labels"),
(&["assignees"][..], "assignees"),
(
&["closedByPullRequestsReferences"][..],
"linked closing pull requests",
),
(&["comments"][..], "comments"),
],
)
}
fn partial_depth_warnings(resource: &Value, connections: &[(&[&str], &str)]) -> Vec<String> {
let truncated = connections
.iter()
.filter_map(|(path, label)| has_next_page(resource, path).then_some(*label))
.collect::<Vec<_>>();
if truncated.is_empty() {
Vec::new()
} else {
vec![format!(
"normal API depth shows the first 100 only for {}; {FULL_DEPTH_WARNING_HINT} for exhaustive pagination",
truncated.join(", ")
)]
}
}
fn has_next_page(value: &Value, path: &[&str]) -> bool {
let mut current = value;
for key in path {
let Some(next) = current.get(*key) else {
return false;
};
current = next;
}
current
.pointer("/pageInfo/hasNextPage")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn push_enrichment_warning(resource: &mut Resource, label: &str, error: &anyhow::Error) {
resource.warnings.push(format!("{label}: {error}"));
}
fn replace_comment_activity(resource: &mut Resource, comments: Vec<ActivityEntry>) {
resource
.activity
.retain(|entry| entry.kind != ActivityKind::Comment);
resource.activity.extend(comments);
}
fn replace_review_activity(resource: &mut Resource, reviews: Vec<ActivityEntry>) {
resource
.activity
.retain(|entry| entry.kind != ActivityKind::Review);
resource.activity.extend(reviews);
}
async fn enrich_project_metadata(resource: &mut Resource, id: &ResourceId, kind: ResourceKind) {
match fetch_project_names(id, kind).await {
Ok(projects) => apply_project_metadata(&mut resource.metadata, projects),
Err(error) if is_project_scope_error(&error) => {}
Err(error) => push_enrichment_warning(resource, "projects unavailable", &error),
}
}
async fn enrich_labels_and_assignees(resource: &mut Resource, id: &ResourceId, kind: ResourceKind) {
match fetch_labels(id, kind).await {
Ok(labels) => resource.labels = labels,
Err(error) => push_enrichment_warning(resource, "labels unavailable", &error),
}
match fetch_assignees(id, kind).await {
Ok(assignees) => resource.assignees = assignees,
Err(error) => push_enrichment_warning(resource, "assignees unavailable", &error),
}
}
async fn enrich_participants(resource: &mut Resource, id: &ResourceId, kind: ResourceKind) {
match fetch_participants(id, kind).await {
Ok(participants) => apply_participant_metadata(&mut resource.metadata, participants),
Err(error) => push_enrichment_warning(resource, "participants unavailable", &error),
}
}
async fn enrich_review_requests(resource: &mut Resource, id: &ResourceId) {
match fetch_review_requests(id).await {
Ok(review_requests) => {
if let Some(pr) = &mut resource.pull_request {
pr.requested_reviewers = review_requests;
}
}
Err(error) => push_enrichment_warning(resource, "review requests unavailable", &error),
}
}
async fn enrich_linked_resources(resource: &mut Resource, id: &ResourceId, kind: ResourceKind) {
match fetch_linked_resources(id, kind).await {
Ok(related_resources) => resource.related_resources = related_resources,
Err(error) => push_enrichment_warning(resource, "linked resources unavailable", &error),
}
}
async fn enrich_issue_relationships(resource: &mut Resource, id: &ResourceId) {
match fetch_issue_parent(id).await {
Ok(Some(parent)) => {
apply_issue_relationship_metadata(
&mut resource.metadata,
"Parent issue",
vec![parent.clone()],
);
append_related_resources(resource, vec![parent]);
}
Ok(None) => {}
Err(error) => push_enrichment_warning(resource, "parent issue unavailable", &error),
}
match fetch_issue_duplicate(id).await {
Ok(Some(duplicate)) => {
apply_issue_relationship_metadata(
&mut resource.metadata,
"Duplicate of",
vec![duplicate.clone()],
);
append_related_resources(resource, vec![duplicate]);
}
Ok(None) => {}
Err(error) => push_enrichment_warning(resource, "duplicate issue unavailable", &error),
}
for kind in IssueRelationshipKind::ALL {
match fetch_issue_relationships(id, kind).await {
Ok(related) => {
apply_issue_relationship_metadata(
&mut resource.metadata,
kind.metadata_label(),
related.clone(),
);
append_related_resources(resource, related);
}
Err(error) => push_enrichment_warning(resource, kind.warning_label(), &error),
}
}
}
async fn enrich_issue_linked_branches(resource: &mut Resource, id: &ResourceId) {
match fetch_issue_linked_branches(id).await {
Ok(branches) => apply_linked_branch_metadata(&mut resource.metadata, branches),
Err(error) => push_enrichment_warning(resource, "linked branches unavailable", &error),
}
}
fn append_related_resources(resource: &mut Resource, related: Vec<ResourceId>) {
let mut seen = resource
.related_resources
.iter()
.map(ResourceId::canonical_name)
.collect::<HashSet<_>>();
resource.related_resources.extend(
related
.into_iter()
.filter(|id| seen.insert(id.canonical_name())),
);
}
fn owner_repo_number_variables(id: &ResourceId, after: Option<&str>) -> Value {
json!({
"owner": id.owner,
"name": id.repo,
"number": id.number,
"after": after,
})
}
async fn run_graphql_base_pr(id: &ResourceId) -> anyhow::Result<Vec<u8>> {
run_graphql_query(base_pr_query(), owner_repo_number_variables(id, None)).await
}
async fn run_graphql_base_issue(id: &ResourceId) -> anyhow::Result<Vec<u8>> {
run_graphql_query(base_issue_query(), owner_repo_number_variables(id, None)).await
}
async fn run_graphql_project_items(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = project_items_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_labels(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = labels_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_assignees(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = assignees_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_participants(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = participants_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_review_requests(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
review_requests_query(),
owner_repo_number_variables(id, after),
)
.await
}
async fn run_graphql_linked_resources(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = linked_resources_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_issue_parent(id: &ResourceId) -> anyhow::Result<Vec<u8>> {
run_graphql_query(issue_parent_query(), owner_repo_number_variables(id, None)).await
}
async fn run_graphql_issue_duplicate(id: &ResourceId) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
issue_duplicate_query(),
owner_repo_number_variables(id, None),
)
.await
}
async fn run_graphql_issue_relationships(
id: &ResourceId,
kind: IssueRelationshipKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = issue_relationships_query(kind.connection());
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_issue_linked_branches(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
issue_linked_branches_query(),
owner_repo_number_variables(id, after),
)
.await
}
fn graphql_preview(selector: &str, id: &ResourceId) -> Vec<String> {
vec![
"POST".into(),
GITHUB_GRAPHQL_URL.into(),
format!("repository={}", id.repo_name_with_owner()),
format!("number={}", id.number),
format!("selector={selector}"),
]
}
async fn run_graphql_comments(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = comments_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_review_threads(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = review_threads_query();
run_graphql_query(query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_reviews(id: &ResourceId, after: Option<&str>) -> anyhow::Result<Vec<u8>> {
run_graphql_query(reviews_query(), owner_repo_number_variables(id, after)).await
}
async fn run_graphql_review_thread_comments(
thread_id: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = review_thread_comments_query();
run_graphql_query(
query,
json!({
"threadId": thread_id,
"after": after,
}),
)
.await
}
async fn run_graphql_commit_comment_thread_comments(
thread_id: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = commit_comment_thread_comments_query();
run_graphql_query(
query,
json!({
"threadId": thread_id,
"after": after,
}),
)
.await
}
async fn run_graphql_changed_files(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
changed_files_query(),
owner_repo_number_variables(id, after),
)
.await
}
async fn run_graphql_commits(id: &ResourceId, after: Option<&str>) -> anyhow::Result<Vec<u8>> {
run_graphql_query(commits_query(), owner_repo_number_variables(id, after)).await
}
async fn run_graphql_commit_authors(
id: &ResourceId,
oid: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
commit_authors_query(),
json!({
"owner": id.owner,
"name": id.repo,
"oid": oid,
"after": after,
}),
)
.await
}
async fn run_graphql_timeline(
id: &ResourceId,
kind: ResourceKind,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
let query = timeline_query(kind);
run_graphql_query(&query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_commit_deployments(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
commit_deployments_query(),
owner_repo_number_variables(id, after),
)
.await
}
async fn run_graphql_commit_deployment_items(
id: &ResourceId,
oid: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
commit_deployment_items_query(),
json!({
"owner": id.owner,
"name": id.repo,
"oid": oid,
"after": after,
}),
)
.await
}
async fn run_graphql_check_suites(id: &ResourceId, after: Option<&str>) -> anyhow::Result<Vec<u8>> {
let query = check_suites_query();
run_graphql_query(query, owner_repo_number_variables(id, after)).await
}
async fn run_graphql_status_rollup(
id: &ResourceId,
after: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
run_graphql_query(
status_rollup_query(),
owner_repo_number_variables(id, after),
)
.await
}
async fn run_pr_diff(id: &ResourceId) -> anyhow::Result<Vec<u8>> {
run_rest_get(&pr_diff_rest_path(id), "application/vnd.github.v3.diff").await
}
fn pr_diff_rest_path(id: &ResourceId) -> String {
format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number)
}
#[derive(Debug, Deserialize)]
struct UserDto {
login: Option<String>,
name: Option<String>,
}
impl UserDto {
fn display_name(&self) -> String {
self.login
.clone()
.or_else(|| self.name.clone())
.unwrap_or_else(|| "unknown".to_string())
}
}
#[derive(Debug, Deserialize)]
struct LabelDto {
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelsResponse {
data: LabelsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelsData {
repository: Option<LabelsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelsRepository {
issue: Option<LabelsResource>,
pull_request: Option<LabelsResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelsResource {
labels: LabelsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LabelsConnection {
page_info: PageInfoDto,
nodes: Vec<LabelDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssigneesResponse {
data: AssigneesData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssigneesData {
repository: Option<AssigneesRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssigneesRepository {
issue: Option<AssigneesResource>,
pull_request: Option<AssigneesResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssigneesResource {
assignees: AssigneesConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssigneesConnection {
page_info: PageInfoDto,
nodes: Vec<UserDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParticipantsResponse {
data: ParticipantsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParticipantsData {
repository: Option<ParticipantsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParticipantsRepository {
issue: Option<ParticipantsResource>,
pull_request: Option<ParticipantsResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParticipantsResource {
participants: ParticipantsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParticipantsConnection {
page_info: PageInfoDto,
nodes: Vec<UserDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewRequestsResponse {
data: ReviewRequestsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewRequestsData {
repository: Option<ReviewRequestsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewRequestsRepository {
pull_request: Option<ReviewRequestsPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewRequestsPullRequest {
review_requests: ReviewRequestsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewRequestsConnection {
page_info: PageInfoDto,
nodes: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedResourcesResponse {
data: LinkedResourcesData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedResourcesData {
repository: Option<LinkedResourcesRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedResourcesRepository {
issue: Option<LinkedResourcesResource>,
pull_request: Option<LinkedResourcesResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedResourcesResource {
closing_issues_references: Option<LinkedResourcesConnection>,
closed_by_pull_requests_references: Option<LinkedResourcesConnection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedResourcesConnection {
page_info: PageInfoDto,
nodes: Vec<RelatedResourceDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueParentResponse {
data: IssueParentData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueParentData {
repository: Option<IssueParentRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueParentRepository {
issue: Option<IssueParentResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueParentResource {
parent: Option<RelatedResourceDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueDuplicateResponse {
data: IssueDuplicateData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueDuplicateData {
repository: Option<IssueDuplicateRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueDuplicateRepository {
issue: Option<IssueDuplicateResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueDuplicateResource {
duplicate_of: Option<RelatedResourceDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueRelationshipsResponse {
data: IssueRelationshipsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueRelationshipsData {
repository: Option<IssueRelationshipsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueRelationshipsRepository {
issue: Option<IssueRelationshipsResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueRelationshipsResource {
sub_issues: Option<LinkedResourcesConnection>,
tracked_issues: Option<LinkedResourcesConnection>,
tracked_in_issues: Option<LinkedResourcesConnection>,
blocked_by: Option<LinkedResourcesConnection>,
blocking: Option<LinkedResourcesConnection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueLinkedBranchesResponse {
data: IssueLinkedBranchesData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueLinkedBranchesData {
repository: Option<IssueLinkedBranchesRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueLinkedBranchesRepository {
issue: Option<IssueLinkedBranchesResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueLinkedBranchesResource {
linked_branches: IssueLinkedBranchesConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueLinkedBranchesConnection {
page_info: PageInfoDto,
nodes: Vec<LinkedBranchDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedBranchDto {
#[serde(rename = "ref")]
branch_ref: Option<BranchRefDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BranchRefDto {
name: Option<String>,
repository: Option<BranchRepositoryDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BranchRepositoryDto {
name_with_owner: Option<String>,
url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueDependenciesSummaryDto {
blocked_by: u64,
blocking: u64,
total_blocked_by: u64,
total_blocking: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SubIssuesSummaryDto {
total: u64,
completed: u64,
percent_completed: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReactionGroupDto {
content: String,
users: TotalCountDto,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TotalCountDto {
total_count: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentDto {
id: Option<String>,
author: Option<UserDto>,
author_association: Option<String>,
body: String,
created_at: Option<String>,
updated_at: Option<String>,
url: Option<String>,
includes_created_edit: Option<bool>,
is_minimized: Option<bool>,
minimized_reason: Option<String>,
#[serde(default)]
reaction_groups: Vec<ReactionGroupDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsResponse {
data: CommentsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsData {
repository: Option<CommentsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsRepository {
issue: Option<CommentsResource>,
pull_request: Option<CommentsResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsResource {
comments: CommentsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentsConnection {
page_info: PageInfoDto,
nodes: Vec<CommentDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewDto {
id: Option<String>,
author: Option<UserDto>,
author_association: Option<String>,
body: Option<String>,
state: Option<String>,
submitted_at: Option<String>,
updated_at: Option<String>,
url: Option<String>,
#[serde(default)]
reaction_groups: Vec<ReactionGroupDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewsResponse {
data: ReviewsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewsData {
repository: Option<ReviewsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewsRepository {
pull_request: Option<ReviewsPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewsPullRequest {
reviews: ReviewsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewsConnection {
page_info: PageInfoDto,
nodes: Vec<ReviewDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadsResponse {
data: ReviewThreadsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadsData {
repository: Option<ReviewThreadsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadsRepository {
pull_request: Option<ReviewThreadsPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadsPullRequest {
review_threads: ReviewThreadsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadsConnection {
page_info: PageInfoDto,
nodes: Vec<ReviewThreadDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadDto {
id: Option<String>,
is_resolved: Option<bool>,
is_outdated: Option<bool>,
path: Option<String>,
line: Option<u64>,
comments: ReviewThreadCommentsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadCommentsConnection {
page_info: PageInfoDto,
nodes: Vec<ReviewThreadCommentDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadCommentsResponse {
data: ReviewThreadCommentsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadCommentsData {
node: Option<ReviewThreadCommentsNode>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadCommentsNode {
comments: ReviewThreadCommentsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitCommentThreadCommentsResponse {
data: CommitCommentThreadCommentsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitCommentThreadCommentsData {
node: Option<CommitCommentThreadCommentsNode>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitCommentThreadCommentsNode {
comments: CommitCommentThreadCommentsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitCommentThreadCommentsConnection {
page_info: PageInfoDto,
nodes: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReviewThreadCommentDto {
id: Option<String>,
author: Option<UserDto>,
author_association: Option<String>,
body: String,
created_at: Option<String>,
updated_at: Option<String>,
url: Option<String>,
includes_created_edit: Option<bool>,
is_minimized: Option<bool>,
minimized_reason: Option<String>,
#[serde(default)]
reaction_groups: Vec<ReactionGroupDto>,
path: Option<String>,
line: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangedFilesResponse {
data: ChangedFilesData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangedFilesData {
repository: Option<ChangedFilesRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangedFilesRepository {
pull_request: Option<ChangedFilesPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangedFilesPullRequest {
files: ChangedFilesConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangedFilesConnection {
page_info: PageInfoDto,
nodes: Vec<FileDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TimelineResponse {
data: TimelineData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TimelineData {
repository: Option<TimelineRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TimelineRepository {
issue: Option<TimelineResource>,
pull_request: Option<TimelineResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TimelineResource {
timeline_items: TimelineItemsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TimelineItemsConnection {
page_info: PageInfoDto,
nodes: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemsResponse {
data: ProjectItemsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemsData {
repository: Option<ProjectItemsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemsRepository {
issue: Option<ProjectItemsResource>,
pull_request: Option<ProjectItemsResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemsResource {
project_items: ProjectItemsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemsConnection {
page_info: PageInfoDto,
nodes: Vec<ProjectItemDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectItemDto {
project: Option<ProjectDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectDto {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageInfoDto {
has_next_page: bool,
end_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDto {
oid: String,
message_headline: String,
message_body: Option<String>,
committed_date: Option<String>,
authored_date: Option<String>,
authors: Option<Vec<UserDto>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitsResponse {
data: CommitsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitsData {
repository: Option<CommitsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitsRepository {
pull_request: Option<CommitsPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitsPullRequest {
commits: CommitsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitsConnection {
page_info: PageInfoDto,
nodes: Vec<CommitNodeDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitNodeDto {
commit: CommitWithAuthorsConnectionDto,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitWithAuthorsConnectionDto {
oid: String,
message_headline: String,
message_body: Option<String>,
committed_date: Option<String>,
authored_date: Option<String>,
authors: Option<CommitAuthorsConnection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorsConnection {
page_info: PageInfoDto,
nodes: Vec<CommitAuthorDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorDto {
name: Option<String>,
user: Option<UserDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorsResponse {
data: CommitAuthorsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorsData {
repository: Option<CommitAuthorsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorsRepository {
object: Option<CommitAuthorsObject>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitAuthorsObject {
authors: CommitAuthorsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentsResponse {
data: CommitDeploymentsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentsData {
repository: Option<CommitDeploymentsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentsRepository {
pull_request: Option<CommitDeploymentsPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentsPullRequest {
commits: CommitDeploymentsConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentsConnection {
page_info: PageInfoDto,
nodes: Vec<CommitDeploymentNode>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentNode {
commit: CommitDeploymentCommit,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentCommit {
oid: String,
deployments: DeploymentConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeploymentConnection {
page_info: PageInfoDto,
nodes: Vec<DeploymentDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeploymentDto {
environment: Option<String>,
task: Option<String>,
description: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
latest_status: Option<DeploymentStatusDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeploymentStatusDto {
state: Option<String>,
description: Option<String>,
environment_url: Option<String>,
log_url: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentItemsResponse {
data: CommitDeploymentItemsData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentItemsData {
repository: Option<CommitDeploymentItemsRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentItemsRepository {
object: Option<CommitDeploymentItemsObject>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitDeploymentItemsObject {
deployments: DeploymentConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesResponse {
data: CheckSuitesData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesData {
repository: Option<CheckSuitesRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesRepository {
pull_request: Option<CheckSuitesPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesPullRequest {
commits: CheckSuitesCommitConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesCommitConnection {
nodes: Vec<CheckSuitesCommitNode>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesCommitNode {
commit: CheckSuitesCommit,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuitesCommit {
check_suites: CheckSuiteConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuiteConnection {
page_info: PageInfoDto,
nodes: Vec<CheckSuiteDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuiteDto {
status: Option<String>,
conclusion: Option<String>,
url: Option<String>,
app: Option<CheckSuiteAppDto>,
workflow_run: Option<CheckSuiteWorkflowRunDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuiteAppDto {
name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuiteWorkflowRunDto {
url: Option<String>,
workflow: Option<CheckSuiteWorkflowDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckSuiteWorkflowDto {
name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusRollupResponse {
data: StatusRollupData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusRollupData {
repository: Option<StatusRollupRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusRollupRepository {
pull_request: Option<StatusRollupPullRequest>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusRollupPullRequest {
status_check_rollup: Option<StatusCheckRollupDto>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusCheckRollupDto {
contexts: StatusContextConnection,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StatusContextConnection {
page_info: PageInfoDto,
nodes: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckDto {
name: Option<String>,
context: Option<String>,
workflow_name: Option<String>,
status: Option<String>,
state: Option<String>,
conclusion: Option<String>,
details_url: Option<String>,
target_url: Option<String>,
started_at: Option<String>,
completed_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FileDto {
path: String,
additions: u64,
deletions: u64,
change_type: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RelatedResourceDto {
number: Option<u64>,
url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PrView {
number: u64,
title: String,
url: String,
state: String,
author: Option<UserDto>,
created_at: String,
updated_at: String,
labels: Vec<LabelDto>,
#[serde(default)]
assignees: Vec<UserDto>,
reaction_groups: Vec<ReactionGroupDto>,
body: String,
base_ref_name: String,
head_ref_name: String,
base_ref_oid: Option<String>,
head_ref_oid: Option<String>,
head_repository: Option<Value>,
head_repository_owner: Option<Value>,
review_decision: Option<String>,
#[serde(default)]
review_requests: Vec<Value>,
#[serde(default)]
closing_issues_references: Vec<RelatedResourceDto>,
merge_state_status: Option<String>,
mergeable: Option<String>,
#[serde(default)]
is_draft: bool,
#[serde(default)]
is_cross_repository: bool,
#[serde(default)]
maintainer_can_modify: bool,
#[serde(default)]
locked: bool,
active_lock_reason: Option<String>,
#[serde(default)]
is_in_merge_queue: bool,
#[serde(default)]
is_merge_queue_enabled: bool,
#[serde(default)]
can_be_rebased: bool,
total_comments_count: Option<u64>,
changed_files: Option<u64>,
#[serde(default)]
closed: bool,
closed_at: Option<String>,
merged_at: Option<String>,
merged_by: Option<UserDto>,
milestone: Option<Value>,
#[serde(default)]
project_items: Vec<Value>,
auto_merge_request: Option<Value>,
merge_commit: Option<Value>,
potential_merge_commit: Option<Value>,
additions: u64,
deletions: u64,
commits: Vec<CommitDto>,
status_check_rollup: Vec<CheckDto>,
files: Vec<FileDto>,
comments: Vec<CommentDto>,
reviews: Vec<ReviewDto>,
}
impl PrView {
fn into_resource(self, requested: &ResourceId) -> Resource {
let resource_metadata = pr_resource_metadata(&self);
let pull_request_metadata = pr_metadata(&self);
let id = ResourceId {
owner: requested.owner.clone(),
repo: requested.repo.clone(),
number: self.number,
kind_hint: Some(ResourceKind::PullRequest),
};
Resource {
id,
title: self.title,
url: self.url,
state: self.state,
author: display_author(self.author),
created_at: self.created_at,
updated_at: self.updated_at,
labels: self.labels.into_iter().map(|label| label.name).collect(),
assignees: names_from_users(self.assignees),
reactions: reaction_counts(self.reaction_groups),
body: self.body,
activity: pr_activity(self.comments, self.reviews),
related_resources: related_resource_ids(
self.closing_issues_references,
ResourceKind::Issue,
requested,
),
metadata: resource_metadata,
warnings: Vec::new(),
pull_request: Some(PullRequest {
base_ref: self.base_ref_name,
head_ref: self.head_ref_name,
requested_reviewers: review_request_names(self.review_requests),
review_decision: self.review_decision.filter(|value| !value.is_empty()),
merge_state: self.merge_state_status.filter(|value| !value.is_empty()),
additions: self.additions,
deletions: self.deletions,
commits: self.commits.into_iter().map(commit_from_dto).collect(),
checks: self
.status_check_rollup
.into_iter()
.map(check_from_dto)
.collect(),
files: self.files.into_iter().map(file_from_dto).collect(),
metadata: pull_request_metadata,
}),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssueView {
number: u64,
title: String,
url: String,
state: String,
author: Option<UserDto>,
created_at: String,
updated_at: String,
labels: Vec<LabelDto>,
#[serde(default)]
assignees: Vec<UserDto>,
reaction_groups: Vec<ReactionGroupDto>,
body: String,
#[serde(default)]
closed: bool,
#[serde(default)]
is_pinned: bool,
#[serde(default)]
locked: bool,
active_lock_reason: Option<String>,
issue_type: Option<Value>,
state_reason: Option<String>,
closed_at: Option<String>,
last_edited_at: Option<String>,
#[serde(default)]
created_via_email: bool,
tracked_issues_count: Option<u64>,
open_tracked_issues_count: Option<u64>,
closed_tracked_issues_count: Option<u64>,
issue_dependencies_summary: Option<IssueDependenciesSummaryDto>,
sub_issues_summary: Option<SubIssuesSummaryDto>,
milestone: Option<Value>,
#[serde(default)]
project_items: Vec<Value>,
#[serde(default)]
closed_by_pull_requests_references: Vec<RelatedResourceDto>,
comments: Vec<CommentDto>,
}
impl IssueView {
fn into_resource(self, requested: &ResourceId) -> Resource {
let metadata = issue_metadata(&self);
Resource {
id: ResourceId {
owner: requested.owner.clone(),
repo: requested.repo.clone(),
number: self.number,
kind_hint: Some(ResourceKind::Issue),
},
title: self.title,
url: self.url,
state: self.state,
author: display_author(self.author),
created_at: self.created_at,
updated_at: self.updated_at,
labels: self.labels.into_iter().map(|label| label.name).collect(),
assignees: names_from_users(self.assignees),
reactions: reaction_counts(self.reaction_groups),
body: self.body,
activity: comments_to_activity(self.comments),
related_resources: related_resource_ids(
self.closed_by_pull_requests_references,
ResourceKind::PullRequest,
requested,
),
metadata,
warnings: Vec::new(),
pull_request: None,
}
}
}
fn pr_view_from_graphql(raw: &[u8]) -> anyhow::Result<PrView> {
let mut value: Value =
serde_json::from_slice(raw).context("failed to parse base PR GraphQL JSON")?;
let mut pr = value
.pointer_mut("/data/repository/pullRequest")
.and_then(Value::take_non_null)
.context("base PR GraphQL response did not include a pull request")?;
normalize_base_pr_value(&mut pr);
serde_json::from_value(pr).context("failed to normalize base PR GraphQL JSON")
}
fn issue_view_from_graphql(raw: &[u8]) -> anyhow::Result<IssueView> {
let mut value: Value =
serde_json::from_slice(raw).context("failed to parse base issue GraphQL JSON")?;
let mut issue = value
.pointer_mut("/data/repository/issue")
.and_then(Value::take_non_null)
.context("base issue GraphQL response did not include an issue")?;
normalize_base_issue_value(&mut issue);
serde_json::from_value(issue).context("failed to normalize base issue GraphQL JSON")
}
trait TakeNonNull {
fn take_non_null(&mut self) -> Option<Value>;
}
impl TakeNonNull for Value {
fn take_non_null(&mut self) -> Option<Value> {
if self.is_null() {
return None;
}
Some(self.take())
}
}
fn normalize_base_pr_value(pr: &mut Value) {
for key in [
"labels",
"assignees",
"reviewRequests",
"closingIssuesReferences",
"projectItems",
"files",
"comments",
"reviews",
] {
replace_connection_with_nodes(pr, key);
}
normalize_commits_connection(pr);
normalize_status_check_rollup(pr);
}
fn normalize_base_issue_value(issue: &mut Value) {
for key in [
"labels",
"assignees",
"projectItems",
"closedByPullRequestsReferences",
"comments",
] {
replace_connection_with_nodes(issue, key);
}
}
fn replace_connection_with_nodes(value: &mut Value, key: &str) {
let nodes = value
.get_mut(key)
.and_then(|connection| connection.get_mut("nodes"))
.map(Value::take)
.unwrap_or_else(|| Value::Array(Vec::new()));
if let Some(object) = value.as_object_mut() {
object.insert(key.to_string(), nodes);
}
}
fn normalize_commits_connection(pr: &mut Value) {
let commits = pr
.get_mut("commits")
.and_then(|connection| connection.get_mut("nodes"))
.and_then(Value::as_array_mut)
.map(|nodes| {
nodes
.iter_mut()
.filter_map(|node| {
let mut commit = node.get_mut("commit")?.take();
normalize_commit_authors(&mut commit);
Some(commit)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(object) = pr.as_object_mut() {
object.insert("commits".to_string(), Value::Array(commits));
}
}
fn normalize_commit_authors(commit: &mut Value) {
let authors = commit
.get_mut("authors")
.and_then(|connection| connection.get_mut("nodes"))
.and_then(Value::as_array_mut)
.map(|nodes| {
nodes
.iter_mut()
.filter_map(|author| {
if let Some(user) = author.get_mut("user").filter(|user| !user.is_null()) {
return Some(user.take());
}
let name = author.get("name").and_then(Value::as_str)?;
Some(json!({ "login": name, "name": name }))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(object) = commit.as_object_mut() {
object.insert("authors".to_string(), Value::Array(authors));
}
}
fn normalize_status_check_rollup(pr: &mut Value) {
let checks = pr
.get_mut("statusCheckRollup")
.and_then(|rollup| rollup.get_mut("contexts"))
.and_then(|contexts| contexts.get_mut("nodes"))
.and_then(Value::as_array_mut)
.map(|nodes| {
nodes
.iter_mut()
.map(|node| {
let check = node.take();
normalize_status_check_value(check)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(object) = pr.as_object_mut() {
object.insert("statusCheckRollup".to_string(), Value::Array(checks));
}
}
fn normalize_status_check_value(mut check: Value) -> Value {
if let Some(workflow_name) = check
.pointer("/checkSuite/workflowRun/workflow/name")
.cloned()
{
if let Some(object) = check.as_object_mut() {
object.insert("workflowName".to_string(), workflow_name);
}
}
check
}
fn issue_metadata(issue: &IssueView) -> Vec<MetadataItem> {
let mut items = Vec::new();
push_bool_metadata(&mut items, "Closed", issue.closed);
push_bool_metadata(&mut items, "Pinned", issue.is_pinned);
push_bool_metadata(&mut items, "Locked", issue.locked);
push_nonempty_metadata(
&mut items,
"Lock reason",
issue.active_lock_reason.as_deref(),
);
push_nonempty_metadata(
&mut items,
"Issue type",
value_title(issue.issue_type.as_ref()).as_deref(),
);
push_nonempty_metadata(&mut items, "State reason", issue.state_reason.as_deref());
push_nonempty_metadata(&mut items, "Closed at", issue.closed_at.as_deref());
push_nonempty_metadata(
&mut items,
"Last edited at",
issue.last_edited_at.as_deref(),
);
push_bool_metadata(&mut items, "Created via email", issue.created_via_email);
push_issue_count_metadata(
&mut items,
issue.tracked_issues_count,
issue.open_tracked_issues_count,
issue.closed_tracked_issues_count,
);
push_issue_dependency_metadata(&mut items, issue.issue_dependencies_summary.as_ref());
push_sub_issue_summary_metadata(&mut items, issue.sub_issues_summary.as_ref());
push_nonempty_metadata(
&mut items,
"Milestone",
value_title(issue.milestone.as_ref()).as_deref(),
);
push_vec_metadata(&mut items, "Projects", value_titles(&issue.project_items));
items
}
fn push_issue_count_metadata(
items: &mut Vec<MetadataItem>,
total: Option<u64>,
open: Option<u64>,
closed: Option<u64>,
) {
let Some(total) = total.filter(|count| *count > 0) else {
return;
};
let open = open.unwrap_or_default();
let closed = closed.unwrap_or_default();
push_nonempty_metadata(
items,
"Tracked issues",
Some(format!("{total} total, {open} open, {closed} closed").as_str()),
);
}
fn push_issue_dependency_metadata(
items: &mut Vec<MetadataItem>,
summary: Option<&IssueDependenciesSummaryDto>,
) {
let Some(summary) = summary else {
return;
};
if summary.total_blocked_by > 0 {
push_nonempty_metadata(
items,
"Blocked by count",
Some(
format!(
"{} total, {} open",
summary.total_blocked_by, summary.blocked_by
)
.as_str(),
),
);
}
if summary.total_blocking > 0 {
push_nonempty_metadata(
items,
"Blocking count",
Some(
format!(
"{} total, {} open",
summary.total_blocking, summary.blocking
)
.as_str(),
),
);
}
}
fn push_sub_issue_summary_metadata(
items: &mut Vec<MetadataItem>,
summary: Option<&SubIssuesSummaryDto>,
) {
let Some(summary) = summary.filter(|summary| summary.total > 0) else {
return;
};
push_nonempty_metadata(
items,
"Sub-issue summary",
Some(
format!(
"{} total, {} completed, {}%",
summary.total, summary.completed, summary.percent_completed
)
.as_str(),
),
);
}
fn pr_resource_metadata(pr: &PrView) -> Vec<MetadataItem> {
let mut items = Vec::new();
push_bool_metadata(&mut items, "Closed", pr.closed);
push_bool_metadata(&mut items, "Draft", pr.is_draft);
push_bool_metadata(&mut items, "Cross repository", pr.is_cross_repository);
push_bool_metadata(
&mut items,
"Maintainer can modify",
pr.maintainer_can_modify,
);
push_bool_metadata(&mut items, "Locked", pr.locked);
push_nonempty_metadata(&mut items, "Lock reason", pr.active_lock_reason.as_deref());
push_bool_metadata(&mut items, "In merge queue", pr.is_in_merge_queue);
push_bool_metadata(&mut items, "Merge queue enabled", pr.is_merge_queue_enabled);
push_bool_metadata(&mut items, "Can be rebased", pr.can_be_rebased);
push_nonempty_metadata(&mut items, "Mergeable", pr.mergeable.as_deref());
push_nonempty_metadata(
&mut items,
"Total comments",
pr.total_comments_count
.map(|count| count.to_string())
.as_deref(),
);
push_nonempty_metadata(
&mut items,
"Changed files",
pr.changed_files.map(|count| count.to_string()).as_deref(),
);
push_nonempty_metadata(
&mut items,
"Milestone",
value_title(pr.milestone.as_ref()).as_deref(),
);
push_vec_metadata(&mut items, "Projects", value_titles(&pr.project_items));
items
}
fn pr_metadata(pr: &PrView) -> Vec<MetadataItem> {
let mut items = Vec::new();
push_nonempty_metadata(&mut items, "Base ref OID", pr.base_ref_oid.as_deref());
push_nonempty_metadata(&mut items, "Head ref OID", pr.head_ref_oid.as_deref());
push_nonempty_metadata(
&mut items,
"Head repository",
value_title(pr.head_repository.as_ref()).as_deref(),
);
push_nonempty_metadata(
&mut items,
"Head repository owner",
value_title(pr.head_repository_owner.as_ref()).as_deref(),
);
push_nonempty_metadata(&mut items, "Closed at", pr.closed_at.as_deref());
push_nonempty_metadata(&mut items, "Merged at", pr.merged_at.as_deref());
push_nonempty_metadata(
&mut items,
"Merged by",
pr.merged_by.as_ref().map(UserDto::display_name).as_deref(),
);
push_nonempty_metadata(
&mut items,
"Auto-merge",
pr.auto_merge_request
.as_ref()
.map(|_| "enabled".to_string())
.as_deref(),
);
push_nonempty_metadata(
&mut items,
"Merge commit",
value_oid(pr.merge_commit.as_ref()).as_deref(),
);
push_nonempty_metadata(
&mut items,
"Potential merge commit",
value_oid(pr.potential_merge_commit.as_ref()).as_deref(),
);
items
}
fn push_bool_metadata(items: &mut Vec<MetadataItem>, label: &str, value: bool) {
items.push(MetadataItem {
label: label.to_string(),
value: if value { "yes" } else { "no" }.to_string(),
});
}
fn push_nonempty_metadata(items: &mut Vec<MetadataItem>, label: &str, value: Option<&str>) {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
items.push(MetadataItem {
label: label.to_string(),
value: value.to_string(),
});
}
fn push_vec_metadata(items: &mut Vec<MetadataItem>, label: &str, values: Vec<String>) {
if values.is_empty() {
return;
}
items.push(MetadataItem {
label: label.to_string(),
value: values.join(", "),
});
}
fn apply_project_metadata(items: &mut Vec<MetadataItem>, projects: Vec<String>) {
let projects = deduped_nonempty_strings(projects);
if projects.is_empty() {
return;
}
if let Some(item) = items.iter_mut().find(|item| item.label == "Projects") {
item.value = projects.join(", ");
return;
}
push_vec_metadata(items, "Projects", projects);
}
fn apply_participant_metadata(items: &mut Vec<MetadataItem>, participants: Vec<String>) {
let participants = deduped_nonempty_strings(participants);
if participants.is_empty() {
return;
}
if let Some(item) = items.iter_mut().find(|item| item.label == "Participants") {
item.value = participants.join(", ");
return;
}
push_vec_metadata(items, "Participants", participants);
}
fn apply_linked_branch_metadata(items: &mut Vec<MetadataItem>, branches: Vec<String>) {
let branches = deduped_nonempty_strings(branches);
if branches.is_empty() {
return;
}
if let Some(item) = items
.iter_mut()
.find(|item| item.label == "Linked branches")
{
item.value = branches.join(", ");
return;
}
push_vec_metadata(items, "Linked branches", branches);
}
fn apply_issue_relationship_metadata(
items: &mut Vec<MetadataItem>,
label: &str,
related: Vec<ResourceId>,
) {
let values = deduped_nonempty_strings(
related
.into_iter()
.map(|id| id.canonical_name())
.collect::<Vec<_>>(),
);
if values.is_empty() {
return;
}
if let Some(item) = items.iter_mut().find(|item| item.label == label) {
item.value = values.join(", ");
return;
}
push_vec_metadata(items, label, values);
}
fn deduped_nonempty_strings(values: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.collect()
}
fn is_project_scope_error(error: &anyhow::Error) -> bool {
let message = error.to_string();
message.contains("read:project") || message.contains("INSUFFICIENT_SCOPES")
}
async fn fetch_labels(id: &ResourceId, kind: ResourceKind) -> anyhow::Result<Vec<String>> {
let mut labels = Vec::new();
let mut after = None;
loop {
let output = run_graphql_labels(id, kind, after.as_deref()).await?;
let response: LabelsResponse =
serde_json::from_slice(&output).context("failed to parse labels GraphQL JSON")?;
let Some(page) = labels_page(response) else {
return Ok(labels);
};
labels.extend(page.labels);
if !page.has_next_page {
return Ok(labels);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("labels page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_assignees(id: &ResourceId, kind: ResourceKind) -> anyhow::Result<Vec<String>> {
let mut assignees = Vec::new();
let mut after = None;
loop {
let output = run_graphql_assignees(id, kind, after.as_deref()).await?;
let response: AssigneesResponse =
serde_json::from_slice(&output).context("failed to parse assignees GraphQL JSON")?;
let Some(page) = assignees_page(response) else {
return Ok(assignees);
};
assignees.extend(page.assignees);
if !page.has_next_page {
return Ok(assignees);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("assignees page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_participants(id: &ResourceId, kind: ResourceKind) -> anyhow::Result<Vec<String>> {
let mut participants = Vec::new();
let mut after = None;
loop {
let output = run_graphql_participants(id, kind, after.as_deref()).await?;
let response: ParticipantsResponse =
serde_json::from_slice(&output).context("failed to parse participants GraphQL JSON")?;
let Some(page) = participants_page(response) else {
return Ok(participants);
};
participants.extend(page.participants);
if !page.has_next_page {
return Ok(participants);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("participants page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_review_requests(id: &ResourceId) -> anyhow::Result<Vec<String>> {
let mut reviewers = Vec::new();
let mut after = None;
loop {
let output = run_graphql_review_requests(id, after.as_deref()).await?;
let response: ReviewRequestsResponse = serde_json::from_slice(&output)
.context("failed to parse review requests GraphQL JSON")?;
let Some(page) = review_requests_page(response) else {
return Ok(reviewers);
};
reviewers.extend(page.reviewers);
if !page.has_next_page {
return Ok(reviewers);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("review requests page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_linked_resources(
id: &ResourceId,
kind: ResourceKind,
) -> anyhow::Result<Vec<ResourceId>> {
let mut related_resources = Vec::new();
let mut after = None;
loop {
let output = run_graphql_linked_resources(id, kind, after.as_deref()).await?;
let response: LinkedResourcesResponse = serde_json::from_slice(&output)
.context("failed to parse linked resources GraphQL JSON")?;
let Some(page) = linked_resources_page(response, kind, id) else {
return Ok(related_resources);
};
related_resources.extend(page.related_resources);
if !page.has_next_page {
return Ok(related_resources);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("linked resources page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_issue_parent(id: &ResourceId) -> anyhow::Result<Option<ResourceId>> {
let output = run_graphql_issue_parent(id).await?;
let response: IssueParentResponse =
serde_json::from_slice(&output).context("failed to parse issue parent GraphQL JSON")?;
Ok(issue_parent_from_response(response, id))
}
async fn fetch_issue_duplicate(id: &ResourceId) -> anyhow::Result<Option<ResourceId>> {
let output = run_graphql_issue_duplicate(id).await?;
let response: IssueDuplicateResponse =
serde_json::from_slice(&output).context("failed to parse issue duplicate GraphQL JSON")?;
Ok(issue_duplicate_from_response(response, id))
}
async fn fetch_issue_relationships(
id: &ResourceId,
kind: IssueRelationshipKind,
) -> anyhow::Result<Vec<ResourceId>> {
let mut related_resources = Vec::new();
let mut after = None;
loop {
let output = run_graphql_issue_relationships(id, kind, after.as_deref()).await?;
let response: IssueRelationshipsResponse = serde_json::from_slice(&output)
.context("failed to parse issue relationships GraphQL JSON")?;
let Some(page) = issue_relationships_page(response, kind, id) else {
return Ok(related_resources);
};
related_resources.extend(page.related_resources);
if !page.has_next_page {
return Ok(related_resources);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!(
"{} page reported next page without an end cursor",
kind.connection()
);
};
after = Some(cursor);
}
}
async fn fetch_issue_linked_branches(id: &ResourceId) -> anyhow::Result<Vec<String>> {
let mut branches = Vec::new();
let mut after = None;
loop {
let output = run_graphql_issue_linked_branches(id, after.as_deref()).await?;
let response: IssueLinkedBranchesResponse = serde_json::from_slice(&output)
.context("failed to parse issue linked branches GraphQL JSON")?;
let Some(page) = issue_linked_branches_page(response) else {
return Ok(branches);
};
branches.extend(page.branches);
if !page.has_next_page {
return Ok(branches);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("linked branches page reported next page without an end cursor");
};
after = Some(cursor);
}
}
struct LabelsPage {
labels: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct AssigneesPage {
assignees: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct ParticipantsPage {
participants: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct ReviewRequestsPage {
reviewers: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct LinkedResourcesPage {
related_resources: Vec<ResourceId>,
has_next_page: bool,
end_cursor: Option<String>,
}
type IssueRelationshipsPage = LinkedResourcesPage;
struct IssueLinkedBranchesPage {
branches: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
fn labels_from_response(response: LabelsResponse) -> Vec<String> {
labels_page(response)
.map(|page| page.labels)
.unwrap_or_default()
}
#[cfg(test)]
fn assignees_from_response(response: AssigneesResponse) -> Vec<String> {
assignees_page(response)
.map(|page| page.assignees)
.unwrap_or_default()
}
#[cfg(test)]
fn participants_from_response(response: ParticipantsResponse) -> Vec<String> {
participants_page(response)
.map(|page| page.participants)
.unwrap_or_default()
}
#[cfg(test)]
fn review_requests_from_response(response: ReviewRequestsResponse) -> Vec<String> {
review_requests_page(response)
.map(|page| page.reviewers)
.unwrap_or_default()
}
#[cfg(test)]
fn linked_resources_from_response(
response: LinkedResourcesResponse,
kind: ResourceKind,
requested: &ResourceId,
) -> Vec<ResourceId> {
linked_resources_page(response, kind, requested)
.map(|page| page.related_resources)
.unwrap_or_default()
}
fn issue_parent_from_response(
response: IssueParentResponse,
requested: &ResourceId,
) -> Option<ResourceId> {
response
.data
.repository?
.issue?
.parent
.and_then(|parent| related_resource_id(parent, ResourceKind::Issue, requested))
}
fn issue_duplicate_from_response(
response: IssueDuplicateResponse,
requested: &ResourceId,
) -> Option<ResourceId> {
response
.data
.repository?
.issue?
.duplicate_of
.and_then(|duplicate| related_resource_id(duplicate, ResourceKind::Issue, requested))
}
#[cfg(test)]
fn issue_relationships_from_response(
response: IssueRelationshipsResponse,
kind: IssueRelationshipKind,
requested: &ResourceId,
) -> Vec<ResourceId> {
issue_relationships_page(response, kind, requested)
.map(|page| page.related_resources)
.unwrap_or_default()
}
#[cfg(test)]
fn issue_linked_branches_from_response(response: IssueLinkedBranchesResponse) -> Vec<String> {
issue_linked_branches_page(response)
.map(|page| page.branches)
.unwrap_or_default()
}
fn labels_page(response: LabelsResponse) -> Option<LabelsPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.labels.page_info;
Some(LabelsPage {
labels: resource
.labels
.nodes
.into_iter()
.map(|label| label.name)
.filter(|name| !name.trim().is_empty())
.collect(),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn assignees_page(response: AssigneesResponse) -> Option<AssigneesPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.assignees.page_info;
Some(AssigneesPage {
assignees: names_from_users(resource.assignees.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn participants_page(response: ParticipantsResponse) -> Option<ParticipantsPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.participants.page_info;
Some(ParticipantsPage {
participants: names_from_users(resource.participants.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn review_requests_page(response: ReviewRequestsResponse) -> Option<ReviewRequestsPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let page_info = pull_request.review_requests.page_info;
Some(ReviewRequestsPage {
reviewers: review_request_names(pull_request.review_requests.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn linked_resources_page(
response: LinkedResourcesResponse,
kind: ResourceKind,
requested: &ResourceId,
) -> Option<LinkedResourcesPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let fallback_kind = match kind {
ResourceKind::Issue => ResourceKind::PullRequest,
ResourceKind::PullRequest => ResourceKind::Issue,
};
let connection = match kind {
ResourceKind::Issue => resource.closed_by_pull_requests_references?,
ResourceKind::PullRequest => resource.closing_issues_references?,
};
let page_info = connection.page_info;
Some(LinkedResourcesPage {
related_resources: related_resource_ids(connection.nodes, fallback_kind, requested),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn issue_relationships_page(
response: IssueRelationshipsResponse,
kind: IssueRelationshipKind,
requested: &ResourceId,
) -> Option<IssueRelationshipsPage> {
let repository = response.data.repository?;
let resource = repository.issue?;
let connection = issue_relationship_connection(resource, kind)?;
let page_info = connection.page_info;
Some(IssueRelationshipsPage {
related_resources: related_resource_ids(connection.nodes, ResourceKind::Issue, requested),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn issue_relationship_connection(
resource: IssueRelationshipsResource,
kind: IssueRelationshipKind,
) -> Option<LinkedResourcesConnection> {
match kind {
IssueRelationshipKind::SubIssues => resource.sub_issues,
IssueRelationshipKind::TrackedIssues => resource.tracked_issues,
IssueRelationshipKind::TrackedInIssues => resource.tracked_in_issues,
IssueRelationshipKind::BlockedBy => resource.blocked_by,
IssueRelationshipKind::Blocking => resource.blocking,
}
}
fn issue_linked_branches_page(
response: IssueLinkedBranchesResponse,
) -> Option<IssueLinkedBranchesPage> {
let repository = response.data.repository?;
let resource = repository.issue?;
let page_info = resource.linked_branches.page_info;
Some(IssueLinkedBranchesPage {
branches: linked_branch_labels(resource.linked_branches.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn linked_branch_labels(branches: Vec<LinkedBranchDto>) -> Vec<String> {
branches
.into_iter()
.filter_map(linked_branch_label)
.collect()
}
fn linked_branch_label(branch: LinkedBranchDto) -> Option<String> {
let branch_ref = branch.branch_ref?;
let branch_name = branch_ref.name?.trim().to_string();
if branch_name.is_empty() {
return None;
}
let repository_name = branch_ref
.repository
.and_then(|repository| {
repository
.name_with_owner
.or(repository.url)
.map(|value| value.trim().to_string())
})
.filter(|value| !value.is_empty());
Some(match repository_name {
Some(repository_name) => format!("{repository_name}:{branch_name}"),
None => branch_name,
})
}
fn value_titles(values: &[Value]) -> Vec<String> {
values
.iter()
.filter_map(|value| value_title(Some(value)))
.collect()
}
fn value_title(value: Option<&Value>) -> Option<String> {
let value = value?;
["title", "name", "login", "oid"]
.into_iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::to_string)
.or_else(|| {
value
.get("project")
.and_then(|project| value_title(Some(project)))
})
.or_else(|| {
value
.get("repository")
.and_then(|repository| value_title(Some(repository)))
})
.or_else(|| {
value
.get("owner")
.and_then(|owner| value_title(Some(owner)))
})
}
fn value_oid(value: Option<&Value>) -> Option<String> {
value
.and_then(|value| value.get("oid").and_then(Value::as_str))
.map(str::to_string)
}
fn display_author(author: Option<UserDto>) -> String {
author
.map(|author| author.display_name())
.unwrap_or_else(|| "unknown".to_string())
}
fn names_from_users(users: Vec<UserDto>) -> Vec<String> {
users
.into_iter()
.map(|user| user.display_name())
.filter(|name| name != "unknown")
.collect()
}
fn review_request_names(requests: Vec<Value>) -> Vec<String> {
requests
.into_iter()
.filter_map(|request| display_review_request(&request))
.collect()
}
fn related_resource_ids(
references: Vec<RelatedResourceDto>,
fallback_kind: ResourceKind,
requested: &ResourceId,
) -> Vec<ResourceId> {
references
.into_iter()
.filter_map(|reference| related_resource_id(reference, fallback_kind, requested))
.collect()
}
fn related_resource_id(
reference: RelatedResourceDto,
fallback_kind: ResourceKind,
requested: &ResourceId,
) -> Option<ResourceId> {
reference
.url
.as_deref()
.and_then(|url| ResourceId::parse(url).ok())
.or_else(|| {
reference.number.map(|number| ResourceId {
owner: requested.owner.clone(),
repo: requested.repo.clone(),
number,
kind_hint: Some(fallback_kind),
})
})
}
fn display_review_request(request: &Value) -> Option<String> {
if let Some(name) = ["login", "name", "slug"]
.into_iter()
.find_map(|key| request.get(key).and_then(Value::as_str))
{
return Some(name.to_string());
}
request
.get("requestedReviewer")
.and_then(display_review_request)
}
fn reaction_counts(groups: Vec<ReactionGroupDto>) -> ReactionCounts {
let mut counts = ReactionCounts::default();
for group in groups {
let count = group.users.total_count;
match group.content.as_str() {
"THUMBS_UP" => counts.thumbs_up = count,
"THUMBS_DOWN" => counts.thumbs_down = count,
"LAUGH" => counts.laugh = count,
"HOORAY" => counts.hooray = count,
"CONFUSED" => counts.confused = count,
"HEART" => counts.heart = count,
"ROCKET" => counts.rocket = count,
"EYES" => counts.eyes = count,
_ => {}
}
}
counts
}
fn reaction_counts_from_value(value: Option<&Value>) -> ReactionCounts {
value
.and_then(|value| serde_json::from_value::<Vec<ReactionGroupDto>>(value.clone()).ok())
.map(reaction_counts)
.unwrap_or_default()
}
fn comments_to_activity(comments: Vec<CommentDto>) -> Vec<ActivityEntry> {
comments
.into_iter()
.enumerate()
.map(|(index, comment)| ActivityEntry {
id: comment.id.unwrap_or_else(|| format!("comment-{index}")),
kind: ActivityKind::Comment,
author: display_author(comment.author),
body: comment.body,
updated_at: comment
.updated_at
.or(comment.created_at)
.unwrap_or_else(|| "unknown".to_string()),
path: None,
line: None,
url: comment.url,
author_association: comment.author_association,
reactions: reaction_counts(comment.reaction_groups),
includes_created_edit: comment.includes_created_edit.unwrap_or(false),
is_minimized: comment.is_minimized.unwrap_or(false),
minimized_reason: comment.minimized_reason.filter(|value| !value.is_empty()),
thread_id: None,
thread_resolved: None,
thread_outdated: None,
})
.collect()
}
async fn fetch_comment_activity(
id: &ResourceId,
kind: ResourceKind,
) -> anyhow::Result<Vec<ActivityEntry>> {
let mut activity = Vec::new();
let mut after = None;
loop {
let output = run_graphql_comments(id, kind, after.as_deref()).await?;
let response: CommentsResponse =
serde_json::from_slice(&output).context("failed to parse comments GraphQL JSON")?;
let Some(page) = comment_activity_page(response) else {
return Ok(activity);
};
activity.extend(page.activity);
if !page.has_next_page {
return Ok(activity);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("comments page reported next page without an end cursor");
};
after = Some(cursor);
}
}
struct CommentActivityPage {
activity: Vec<ActivityEntry>,
has_next_page: bool,
end_cursor: Option<String>,
}
fn comment_activity_page(response: CommentsResponse) -> Option<CommentActivityPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.comments.page_info;
Some(CommentActivityPage {
activity: comments_to_activity(resource.comments.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
async fn fetch_review_activity(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
let mut activity = Vec::new();
let mut after = None;
loop {
let output = run_graphql_reviews(id, after.as_deref()).await?;
let response: ReviewsResponse =
serde_json::from_slice(&output).context("failed to parse reviews GraphQL JSON")?;
let Some(page) = review_activity_page(response) else {
return Ok(activity);
};
activity.extend(page.activity);
if !page.has_next_page {
return Ok(activity);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("reviews page reported next page without an end cursor");
};
after = Some(cursor);
}
}
struct ReviewActivityPage {
activity: Vec<ActivityEntry>,
has_next_page: bool,
end_cursor: Option<String>,
}
fn review_activity_page(response: ReviewsResponse) -> Option<ReviewActivityPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let page_info = pull_request.reviews.page_info;
Some(ReviewActivityPage {
activity: reviews_to_activity(pull_request.reviews.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
async fn fetch_review_thread_activity(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
let mut activity = Vec::new();
let mut after = None;
loop {
let output = run_graphql_review_threads(id, after.as_deref()).await?;
let response: ReviewThreadsResponse = serde_json::from_slice(&output)
.context("failed to parse reviewThreads GraphQL JSON")?;
let Some(page) = review_thread_page(response) else {
return Ok(activity);
};
for mut thread in page.threads {
fetch_remaining_review_thread_comments(&mut thread).await?;
activity.extend(review_thread_to_activity(thread));
}
if !page.has_next_page {
return Ok(activity);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("review threads page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_remaining_review_thread_comments(
thread: &mut ReviewThreadDto,
) -> anyhow::Result<()> {
while thread.comments.page_info.has_next_page {
let thread_id = thread
.id
.as_deref()
.context("review thread comment page reported next page without a thread id")?;
let cursor = thread
.comments
.page_info
.end_cursor
.as_deref()
.context("review thread comment page reported next page without an end cursor")?;
let output = run_graphql_review_thread_comments(thread_id, Some(cursor)).await?;
let response: ReviewThreadCommentsResponse = serde_json::from_slice(&output)
.context("failed to parse review thread comments GraphQL JSON")?;
let Some(page) = review_thread_comments_page(response) else {
return Ok(());
};
thread.comments.nodes.extend(page.nodes);
thread.comments.page_info = PageInfoDto {
has_next_page: page.has_next_page,
end_cursor: page.end_cursor,
};
}
Ok(())
}
async fn fetch_remaining_commit_comment_thread_comments(node: &mut Value) -> anyhow::Result<()> {
loop {
let has_next_page = node
.pointer("/comments/pageInfo/hasNextPage")
.and_then(Value::as_bool)
.unwrap_or(false);
if !has_next_page {
return Ok(());
}
let thread_id = string_field(node, "id")
.context("commit comment thread page reported next page without a thread id")?
.to_string();
let cursor = string_field_at(node, &["comments", "pageInfo", "endCursor"])
.context("commit comment thread page reported next page without an end cursor")?
.to_string();
let output = run_graphql_commit_comment_thread_comments(&thread_id, Some(&cursor)).await?;
let response: CommitCommentThreadCommentsResponse = serde_json::from_slice(&output)
.context("failed to parse commit comment thread comments GraphQL JSON")?;
let Some(page) = commit_comment_thread_comments_page(response) else {
return Ok(());
};
append_commit_comment_thread_page(node, page)?;
}
}
async fn fetch_timeline_activity(
id: &ResourceId,
kind: ResourceKind,
) -> anyhow::Result<Vec<ActivityEntry>> {
let mut activity = Vec::new();
let mut after = None;
loop {
let output = run_graphql_timeline(id, kind, after.as_deref()).await?;
let response: TimelineResponse =
serde_json::from_slice(&output).context("failed to parse timeline GraphQL JSON")?;
let Some(page) = timeline_activity_page_live(response).await? else {
return Ok(activity);
};
activity.extend(page.activity);
if !page.has_next_page {
return Ok(activity);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("timeline page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_project_names(id: &ResourceId, kind: ResourceKind) -> anyhow::Result<Vec<String>> {
let mut projects = Vec::new();
let mut after = None;
loop {
let output = run_graphql_project_items(id, kind, after.as_deref()).await?;
let response: ProjectItemsResponse = serde_json::from_slice(&output)
.context("failed to parse project items GraphQL JSON")?;
let Some(page) = project_items_page(response) else {
return Ok(projects);
};
projects.extend(page.projects);
if !page.has_next_page {
return Ok(deduped_nonempty_strings(projects));
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("project items page reported next page without an end cursor");
};
after = Some(cursor);
}
}
struct ProjectItemsPage {
projects: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
fn project_items_page(response: ProjectItemsResponse) -> Option<ProjectItemsPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.project_items.page_info;
Some(ProjectItemsPage {
projects: resource
.project_items
.nodes
.into_iter()
.filter_map(|item| item.project?.title)
.filter(|title| !title.trim().is_empty())
.collect(),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
async fn fetch_changed_files(id: &ResourceId) -> anyhow::Result<Vec<ChangedFile>> {
let mut files = Vec::new();
let mut after = None;
loop {
let output = run_graphql_changed_files(id, after.as_deref()).await?;
let response: ChangedFilesResponse = serde_json::from_slice(&output)
.context("failed to parse changed files GraphQL JSON")?;
let Some(repository) = response.data.repository else {
return Ok(files);
};
let Some(pull_request) = repository.pull_request else {
return Ok(files);
};
let page_info = pull_request.files.page_info;
files.extend(pull_request.files.nodes.into_iter().map(file_from_dto));
if !page_info.has_next_page {
return Ok(files);
}
let Some(cursor) = page_info.end_cursor else {
anyhow::bail!("changed files page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_commits(id: &ResourceId) -> anyhow::Result<Vec<Commit>> {
let mut commits = Vec::new();
let mut after = None;
loop {
let output = run_graphql_commits(id, after.as_deref()).await?;
let response: CommitsResponse =
serde_json::from_slice(&output).context("failed to parse commits GraphQL JSON")?;
let Some(mut page) = commits_page(response) else {
return Ok(commits);
};
enrich_remaining_commit_authors(id, &mut page.commits, page.author_continuations).await?;
commits.extend(page.commits);
if !page.has_next_page {
return Ok(commits);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("commits page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn enrich_remaining_commit_authors(
id: &ResourceId,
commits: &mut [Commit],
continuations: Vec<CommitAuthorContinuation>,
) -> anyhow::Result<()> {
for continuation in continuations {
let authors = fetch_remaining_commit_authors(
id,
&continuation.commit_oid,
continuation.after.as_deref(),
)
.await?;
if let Some(commit) = commits
.iter_mut()
.find(|commit| commit.oid == continuation.commit_oid)
{
commit.authors.extend(authors);
commit.authors = deduped_nonempty_strings(std::mem::take(&mut commit.authors));
}
}
Ok(())
}
async fn fetch_remaining_commit_authors(
id: &ResourceId,
oid: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let mut authors = Vec::new();
let Some(initial_after) = after else {
anyhow::bail!("commit authors page reported next page without an end cursor");
};
let mut after = Some(initial_after.to_string());
loop {
let output = run_graphql_commit_authors(id, oid, after.as_deref()).await?;
let response: CommitAuthorsResponse = serde_json::from_slice(&output)
.context("failed to parse commit authors GraphQL JSON")?;
let Some(page) = commit_authors_page(response) else {
return Ok(authors);
};
authors.extend(page.authors);
if !page.has_next_page {
return Ok(authors);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("commit authors page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_commit_deployments(
id: &ResourceId,
) -> anyhow::Result<HashMap<String, Vec<Deployment>>> {
let mut deployments = HashMap::new();
let mut after = None;
loop {
let output = run_graphql_commit_deployments(id, after.as_deref()).await?;
let response: CommitDeploymentsResponse = serde_json::from_slice(&output)
.context("failed to parse commit deployments GraphQL JSON")?;
let Some(page) = commit_deployments_page(response) else {
return Ok(deployments);
};
deployments.extend(page.deployments_by_commit);
enrich_remaining_commit_deployments(id, &mut deployments, page.deployment_continuations)
.await?;
if !page.has_next_page {
return Ok(deployments);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("commit deployments page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn enrich_remaining_commit_deployments(
id: &ResourceId,
deployments_by_commit: &mut HashMap<String, Vec<Deployment>>,
continuations: Vec<CommitDeploymentContinuation>,
) -> anyhow::Result<()> {
for continuation in continuations {
let deployments = fetch_remaining_commit_deployments(
id,
&continuation.commit_oid,
continuation.after.as_deref(),
)
.await?;
deployments_by_commit
.entry(continuation.commit_oid)
.or_default()
.extend(deployments);
}
Ok(())
}
async fn fetch_remaining_commit_deployments(
id: &ResourceId,
oid: &str,
after: Option<&str>,
) -> anyhow::Result<Vec<Deployment>> {
let mut deployments = Vec::new();
let Some(initial_after) = after else {
anyhow::bail!("commit deployments page reported next page without an end cursor");
};
let mut after = Some(initial_after.to_string());
loop {
let output = run_graphql_commit_deployment_items(id, oid, after.as_deref()).await?;
let response: CommitDeploymentItemsResponse = serde_json::from_slice(&output)
.context("failed to parse commit deployment items GraphQL JSON")?;
let Some(page) = commit_deployment_items_page(response) else {
return Ok(deployments);
};
deployments.extend(page.deployments);
if !page.has_next_page {
return Ok(deployments);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("commit deployments page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_check_suites(id: &ResourceId) -> anyhow::Result<Vec<CheckRun>> {
let mut checks = Vec::new();
let mut after = None;
loop {
let output = run_graphql_check_suites(id, after.as_deref()).await?;
let response: CheckSuitesResponse =
serde_json::from_slice(&output).context("failed to parse check suites GraphQL JSON")?;
let Some(page) = check_suites_page(response) else {
return Ok(deduped_check_suites(checks));
};
checks.extend(page.checks);
if !page.has_next_page {
return Ok(deduped_check_suites(checks));
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("check suites page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_status_rollup_checks(id: &ResourceId) -> anyhow::Result<Vec<CheckRun>> {
let mut checks = Vec::new();
let mut after = None;
loop {
let output = run_graphql_status_rollup(id, after.as_deref()).await?;
let response: StatusRollupResponse = serde_json::from_slice(&output)
.context("failed to parse status rollup GraphQL JSON")?;
let Some(page) = status_rollup_page(response)? else {
return Ok(checks);
};
checks.extend(page.checks);
if !page.has_next_page {
return Ok(checks);
}
let Some(cursor) = page.end_cursor else {
anyhow::bail!("status rollup page reported next page without an end cursor");
};
after = Some(cursor);
}
}
async fn fetch_file_patches(id: &ResourceId) -> anyhow::Result<HashMap<String, String>> {
let output = run_pr_diff(id).await?;
let diff = String::from_utf8_lossy(&output);
Ok(parse_unified_diff_patches(&diff))
}
fn apply_commit_deployments(
commits: &mut [Commit],
deployments_by_commit: HashMap<String, Vec<Deployment>>,
) {
for commit in commits {
if let Some(deployments) = deployments_by_commit.get(&commit.oid) {
commit.deployments = deployments.clone();
}
}
}
fn apply_check_suites(checks: &mut Vec<CheckRun>, suites: Vec<CheckRun>) {
let mut names = checks
.iter()
.map(|check| check.name.clone())
.collect::<HashSet<_>>();
let additions = suites
.into_iter()
.filter(|suite| names.insert(suite.name.clone()))
.collect::<Vec<_>>();
if additions.is_empty() {
return;
}
let mut merged = additions;
merged.append(checks);
*checks = merged;
}
fn apply_file_patches(files: &mut [ChangedFile], patches: HashMap<String, String>) {
for file in files {
if let Some(patch) = patches.get(&file.path) {
file.patch = Some(patch.clone());
}
}
}
fn replace_pr_commits(pr: &mut PullRequest, commits: Vec<Commit>) {
if !commits.is_empty() {
pr.commits = commits;
}
}
struct CommitsPage {
commits: Vec<Commit>,
author_continuations: Vec<CommitAuthorContinuation>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct CommitAuthorContinuation {
commit_oid: String,
after: Option<String>,
}
struct CommitAuthorsPage {
authors: Vec<String>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct CommitDeploymentsPage {
deployments_by_commit: HashMap<String, Vec<Deployment>>,
deployment_continuations: Vec<CommitDeploymentContinuation>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct CommitDeploymentContinuation {
commit_oid: String,
after: Option<String>,
}
struct CommitDeploymentItemsPage {
deployments: Vec<Deployment>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
fn commits_from_response(response: CommitsResponse) -> Vec<Commit> {
commits_page(response)
.map(|page| page.commits)
.unwrap_or_default()
}
fn commits_page(response: CommitsResponse) -> Option<CommitsPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let page_info = pull_request.commits.page_info;
let mut commits = Vec::new();
let mut author_continuations = Vec::new();
for node in pull_request.commits.nodes {
let commit = node.commit;
if let Some(authors) = &commit.authors {
if authors.page_info.has_next_page {
author_continuations.push(CommitAuthorContinuation {
commit_oid: commit.oid.clone(),
after: authors.page_info.end_cursor.clone(),
});
}
}
commits.push(commit_from_dto(commit_dto_from_connection(commit)));
}
Some(CommitsPage {
commits,
author_continuations,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn commit_dto_from_connection(commit: CommitWithAuthorsConnectionDto) -> CommitDto {
CommitDto {
oid: commit.oid,
message_headline: commit.message_headline,
message_body: commit.message_body,
committed_date: commit.committed_date,
authored_date: commit.authored_date,
authors: commit.authors.map(|authors| {
authors
.nodes
.into_iter()
.map(author_from_commit_author)
.collect()
}),
}
}
#[cfg(test)]
fn commit_authors_from_response(response: CommitAuthorsResponse) -> Vec<String> {
commit_authors_page(response)
.map(|page| page.authors)
.unwrap_or_default()
}
fn commit_authors_page(response: CommitAuthorsResponse) -> Option<CommitAuthorsPage> {
let repository = response.data.repository?;
let object = repository.object?;
let page_info = object.authors.page_info;
Some(CommitAuthorsPage {
authors: names_from_commit_authors(object.authors.nodes),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn author_from_commit_author(author: CommitAuthorDto) -> UserDto {
if let Some(user) = author.user {
return user;
}
let name = author.name.unwrap_or_else(|| "unknown".to_string());
UserDto {
login: Some(name.clone()),
name: Some(name),
}
}
fn names_from_commit_authors(authors: Vec<CommitAuthorDto>) -> Vec<String> {
names_from_users(authors.into_iter().map(author_from_commit_author).collect())
}
struct CheckSuitesPage {
checks: Vec<CheckRun>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct StatusRollupPage {
checks: Vec<CheckRun>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
fn check_suites_from_response(response: CheckSuitesResponse) -> Vec<CheckRun> {
check_suites_page(response)
.map(|page| deduped_check_suites(page.checks))
.unwrap_or_default()
}
#[cfg(test)]
fn status_rollup_checks_from_response(response: StatusRollupResponse) -> Vec<CheckRun> {
status_rollup_page(response)
.ok()
.flatten()
.map(|page| page.checks)
.unwrap_or_default()
}
fn status_rollup_page(response: StatusRollupResponse) -> anyhow::Result<Option<StatusRollupPage>> {
let Some(repository) = response.data.repository else {
return Ok(None);
};
let Some(pull_request) = repository.pull_request else {
return Ok(None);
};
let Some(rollup) = pull_request.status_check_rollup else {
return Ok(Some(StatusRollupPage {
checks: Vec::new(),
has_next_page: false,
end_cursor: None,
}));
};
let page_info = rollup.contexts.page_info;
let checks = rollup
.contexts
.nodes
.into_iter()
.map(status_check_from_value)
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Some(StatusRollupPage {
checks,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
}))
}
fn status_check_from_value(value: Value) -> anyhow::Result<CheckRun> {
serde_json::from_value(normalize_status_check_value(value))
.context("failed to parse status rollup context")
.map(check_from_dto)
}
fn check_suites_page(response: CheckSuitesResponse) -> Option<CheckSuitesPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let node = pull_request.commits.nodes.into_iter().next()?;
let page_info = node.commit.check_suites.page_info;
let checks = node
.commit
.check_suites
.nodes
.into_iter()
.map(check_suite_from_dto)
.collect();
Some(CheckSuitesPage {
checks,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn deduped_check_suites(checks: Vec<CheckRun>) -> Vec<CheckRun> {
let mut by_name = HashMap::new();
for check in checks {
by_name.insert(check.name.clone(), check);
}
let mut checks = by_name.into_values().collect::<Vec<_>>();
checks.sort_by(|left, right| left.name.cmp(&right.name));
checks
}
fn check_suite_from_dto(suite: CheckSuiteDto) -> CheckRun {
let raw_status = suite.status.filter(|value| !value.is_empty());
let raw_conclusion = suite.conclusion.filter(|value| !value.is_empty());
let workflow_name = suite
.workflow_run
.as_ref()
.and_then(|run| run.workflow.as_ref())
.and_then(|workflow| workflow.name.as_ref())
.filter(|value| !value.is_empty())
.cloned();
let app_name = suite
.app
.as_ref()
.and_then(|app| app.name.as_ref())
.filter(|value| !value.is_empty())
.cloned();
let name = workflow_name
.as_ref()
.or(app_name.as_ref())
.map(|name| format!("suite/{name}"))
.unwrap_or_else(|| "suite/check suite".to_string());
let details_url = suite
.workflow_run
.and_then(|run| run.url)
.or(suite.url)
.filter(|value| !value.is_empty());
let summary = app_name.map(|app| format!("check suite from {app}"));
CheckRun {
name,
status: CheckStatus::from_github(raw_status.as_deref(), raw_conclusion.as_deref()),
summary,
details_url,
started_at: None,
completed_at: None,
raw_status,
raw_conclusion,
}
}
#[cfg(test)]
fn commit_deployments_from_response(
response: CommitDeploymentsResponse,
) -> HashMap<String, Vec<Deployment>> {
commit_deployments_page(response)
.map(|page| page.deployments_by_commit)
.unwrap_or_default()
}
fn commit_deployments_page(response: CommitDeploymentsResponse) -> Option<CommitDeploymentsPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let page_info = pull_request.commits.page_info;
let mut deployments_by_commit = HashMap::new();
let mut deployment_continuations = Vec::new();
for node in pull_request.commits.nodes {
let commit = node.commit;
if commit.deployments.page_info.has_next_page {
deployment_continuations.push(CommitDeploymentContinuation {
commit_oid: commit.oid.clone(),
after: commit.deployments.page_info.end_cursor.clone(),
});
}
deployments_by_commit.insert(
commit.oid,
commit
.deployments
.nodes
.into_iter()
.map(deployment_from_dto)
.collect::<Vec<_>>(),
);
}
Some(CommitDeploymentsPage {
deployments_by_commit,
deployment_continuations,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
#[cfg(test)]
fn commit_deployment_items_from_response(
response: CommitDeploymentItemsResponse,
) -> Vec<Deployment> {
commit_deployment_items_page(response)
.map(|page| page.deployments)
.unwrap_or_default()
}
fn commit_deployment_items_page(
response: CommitDeploymentItemsResponse,
) -> Option<CommitDeploymentItemsPage> {
let repository = response.data.repository?;
let object = repository.object?;
let page_info = object.deployments.page_info;
Some(CommitDeploymentItemsPage {
deployments: object
.deployments
.nodes
.into_iter()
.map(deployment_from_dto)
.collect(),
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn deployment_from_dto(deployment: DeploymentDto) -> Deployment {
let status = deployment.latest_status;
let state = status
.as_ref()
.and_then(|status| status.state.clone())
.unwrap_or_else(|| "UNKNOWN".to_string());
let description = status
.as_ref()
.and_then(|status| status.description.clone())
.or(deployment.description);
let created_at = deployment
.created_at
.or_else(|| status.as_ref().and_then(|status| status.created_at.clone()));
Deployment {
environment: deployment
.environment
.filter(|value| !value.is_empty())
.or(deployment.task)
.unwrap_or_else(|| "deployment".to_string()),
state,
description,
environment_url: status
.as_ref()
.and_then(|status| status.environment_url.clone()),
log_url: status.as_ref().and_then(|status| status.log_url.clone()),
created_at,
updated_at: deployment
.updated_at
.or_else(|| status.and_then(|status| status.created_at))
.unwrap_or_else(|| "unknown".to_string()),
}
}
#[cfg(test)]
fn changed_files_from_response(response: ChangedFilesResponse) -> Vec<ChangedFile> {
response
.data
.repository
.and_then(|repository| repository.pull_request)
.map(|pull_request| {
pull_request
.files
.nodes
.into_iter()
.map(file_from_dto)
.collect()
})
.unwrap_or_default()
}
struct ReviewThreadPage {
threads: Vec<ReviewThreadDto>,
has_next_page: bool,
end_cursor: Option<String>,
}
struct ReviewThreadCommentsPage {
nodes: Vec<ReviewThreadCommentDto>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
struct ReviewThreadActivityPage {
activity: Vec<ActivityEntry>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
fn review_thread_activity(response: ReviewThreadsResponse) -> Vec<ActivityEntry> {
review_thread_activity_page(response)
.map(|page| page.activity)
.unwrap_or_default()
}
#[cfg(test)]
fn review_thread_activity_page(
response: ReviewThreadsResponse,
) -> Option<ReviewThreadActivityPage> {
let page = review_thread_page(response)?;
let activity = page
.threads
.into_iter()
.flat_map(review_thread_to_activity)
.collect();
Some(ReviewThreadActivityPage {
activity,
has_next_page: page.has_next_page,
end_cursor: page.end_cursor,
})
}
fn review_thread_page(response: ReviewThreadsResponse) -> Option<ReviewThreadPage> {
let repository = response.data.repository?;
let pull_request = repository.pull_request?;
let page_info = pull_request.review_threads.page_info;
Some(ReviewThreadPage {
threads: pull_request.review_threads.nodes,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn review_thread_comments_page(
response: ReviewThreadCommentsResponse,
) -> Option<ReviewThreadCommentsPage> {
let node = response.data.node?;
let page_info = node.comments.page_info;
Some(ReviewThreadCommentsPage {
nodes: node.comments.nodes,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
struct CommitCommentThreadCommentsPage {
nodes: Vec<Value>,
has_next_page: bool,
end_cursor: Option<String>,
}
fn commit_comment_thread_comments_page(
response: CommitCommentThreadCommentsResponse,
) -> Option<CommitCommentThreadCommentsPage> {
let node = response.data.node?;
let page_info = node.comments.page_info;
Some(CommitCommentThreadCommentsPage {
nodes: node.comments.nodes,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
fn append_commit_comment_thread_page(
node: &mut Value,
page: CommitCommentThreadCommentsPage,
) -> anyhow::Result<()> {
let comments = node
.get_mut("comments")
.and_then(Value::as_object_mut)
.context("commit comment thread is missing comments object")?;
let nodes = comments
.get_mut("nodes")
.and_then(Value::as_array_mut)
.context("commit comment thread is missing comments nodes")?;
nodes.extend(page.nodes);
comments.insert(
"pageInfo".to_string(),
json!({
"hasNextPage": page.has_next_page,
"endCursor": page.end_cursor,
}),
);
Ok(())
}
fn review_thread_to_activity(thread: ReviewThreadDto) -> Vec<ActivityEntry> {
let mut entries = Vec::new();
for comment in thread.comments.nodes {
entries.push(ActivityEntry {
id: comment.id.unwrap_or_else(|| {
format!(
"review-comment-{}-{}",
thread.path.as_deref().unwrap_or("unknown"),
entries.len()
)
}),
kind: ActivityKind::ReviewComment,
author: display_author(comment.author),
body: comment.body,
updated_at: comment
.updated_at
.or(comment.created_at)
.unwrap_or_else(|| "unknown".to_string()),
path: comment.path.or_else(|| thread.path.clone()),
line: comment.line.or(thread.line),
url: comment.url,
author_association: comment.author_association,
reactions: reaction_counts(comment.reaction_groups),
includes_created_edit: comment.includes_created_edit.unwrap_or(false),
is_minimized: comment.is_minimized.unwrap_or(false),
minimized_reason: comment.minimized_reason.filter(|value| !value.is_empty()),
thread_id: thread.id.clone(),
thread_resolved: thread.is_resolved,
thread_outdated: thread.is_outdated,
});
}
entries
}
struct TimelineActivityPage {
activity: Vec<ActivityEntry>,
has_next_page: bool,
end_cursor: Option<String>,
}
#[cfg(test)]
fn timeline_activity(response: TimelineResponse) -> Vec<ActivityEntry> {
timeline_activity_page(response)
.map(|page| page.activity)
.unwrap_or_default()
}
#[cfg(test)]
fn timeline_activity_page(response: TimelineResponse) -> Option<TimelineActivityPage> {
let repository = response.data.repository?;
let resource = repository.pull_request.or(repository.issue)?;
let page_info = resource.timeline_items.page_info;
let activity = resource
.timeline_items
.nodes
.into_iter()
.enumerate()
.flat_map(|(index, node)| timeline_node_to_activities(index, &node))
.collect();
Some(TimelineActivityPage {
activity,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
})
}
async fn timeline_activity_page_live(
response: TimelineResponse,
) -> anyhow::Result<Option<TimelineActivityPage>> {
let Some(repository) = response.data.repository else {
return Ok(None);
};
let Some(resource) = repository.pull_request.or(repository.issue) else {
return Ok(None);
};
let page_info = resource.timeline_items.page_info;
let mut activity = Vec::new();
for (index, mut node) in resource.timeline_items.nodes.into_iter().enumerate() {
if string_field(&node, "__typename") == Some("PullRequestCommitCommentThread") {
fetch_remaining_commit_comment_thread_comments(&mut node).await?;
}
activity.extend(timeline_node_to_activities(index, &node));
}
Ok(Some(TimelineActivityPage {
activity,
has_next_page: page_info.has_next_page,
end_cursor: page_info.end_cursor,
}))
}
fn timeline_node_to_activities(index: usize, node: &Value) -> Vec<ActivityEntry> {
match string_field(node, "__typename") {
Some("PullRequestCommitCommentThread") => commit_comment_thread_to_activity(index, node),
_ => vec![timeline_node_to_activity(index, node)],
}
}
fn commit_comment_thread_to_activity(index: usize, node: &Value) -> Vec<ActivityEntry> {
let thread_id = string_field(node, "id").map(str::to_string);
let thread_path = string_field(node, "path").map(str::to_string);
let thread_position = u64_field(node, "position");
let commit = short_oid_at(node, &["commit", "oid"]);
let Some(comments) = node
.get("comments")
.and_then(|comments| comments.get("nodes"))
.and_then(Value::as_array)
else {
return vec![empty_commit_comment_thread(index, node, &commit)];
};
if comments.is_empty() {
return vec![empty_commit_comment_thread(index, node, &commit)];
}
comments
.iter()
.enumerate()
.map(|(comment_index, comment)| {
let comment_path = string_field(comment, "path")
.map(str::to_string)
.or_else(|| thread_path.clone());
let position = u64_field(comment, "position").or(thread_position);
let comment_body = string_field(comment, "body").unwrap_or_default();
let body = if comment_body.trim().is_empty() {
format!("on commit {commit}")
} else {
format!("on commit {commit}\n{comment_body}")
};
ActivityEntry {
id: string_field(comment, "id")
.map(str::to_string)
.unwrap_or_else(|| format!("commit-comment-{index}-{comment_index}")),
kind: ActivityKind::CommitComment,
author: string_field_at(comment, &["author", "login"])
.unwrap_or("unknown")
.to_string(),
body,
updated_at: string_field(comment, "updatedAt")
.or_else(|| string_field(comment, "createdAt"))
.unwrap_or("unknown")
.to_string(),
path: comment_path,
line: position,
url: string_field(comment, "url").map(str::to_string),
author_association: string_field(comment, "authorAssociation").map(str::to_string),
reactions: reaction_counts_from_value(comment.get("reactionGroups")),
includes_created_edit: bool_field(comment, "includesCreatedEdit").unwrap_or(false),
is_minimized: bool_field(comment, "isMinimized").unwrap_or(false),
minimized_reason: string_field(comment, "minimizedReason")
.filter(|value| !value.is_empty())
.map(str::to_string),
thread_id: thread_id.clone(),
thread_resolved: None,
thread_outdated: None,
}
})
.collect()
}
fn empty_commit_comment_thread(index: usize, node: &Value, commit: &str) -> ActivityEntry {
ActivityEntry {
id: string_field(node, "id")
.map(str::to_string)
.unwrap_or_else(|| format!("commit-comment-thread-{index}")),
kind: ActivityKind::CommitComment,
author: "github".to_string(),
body: format!("commit comment thread on {commit}"),
updated_at: "unknown".to_string(),
path: string_field(node, "path").map(str::to_string),
line: u64_field(node, "position"),
url: None,
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: string_field(node, "id").map(str::to_string),
thread_resolved: None,
thread_outdated: None,
}
}
fn timeline_node_to_activity(index: usize, node: &Value) -> ActivityEntry {
let typename = string_field(node, "__typename").unwrap_or("TimelineEvent");
let body = timeline_body(typename, node);
let author = actor_login(node).unwrap_or_else(|| "github".to_string());
let updated_at = string_field(node, "createdAt")
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
ActivityEntry {
id: string_field(node, "id")
.map(str::to_string)
.unwrap_or_else(|| synthetic_timeline_id(index, typename, &updated_at, &author, &body)),
kind: ActivityKind::Timeline,
author,
body,
updated_at,
path: None,
line: None,
url: cross_reference_url(node),
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
}
}
fn synthetic_timeline_id(
index: usize,
typename: &str,
updated_at: &str,
author: &str,
body: &str,
) -> String {
format!(
"timeline-{}-{}-{}-{}-{index}",
timeline_id_component(typename),
timeline_id_component(updated_at),
timeline_id_component(author),
timeline_id_component(body)
)
}
fn timeline_id_component(value: &str) -> String {
let mut component = String::new();
let mut previous_dash = false;
for ch in value.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
component.push(ch);
previous_dash = false;
} else if !previous_dash && !component.is_empty() {
component.push('-');
previous_dash = true;
}
if component.len() >= 64 {
break;
}
}
let component = component.trim_matches('-');
if component.is_empty() {
"unknown".to_string()
} else {
component.to_string()
}
}
fn timeline_body(typename: &str, node: &Value) -> String {
match typename {
"ClosedEvent" => match string_field_at(node, &["closer", "__typename"]) {
Some(closer) => format!("closed by {closer}"),
None => "closed".to_string(),
},
"ReopenedEvent" => "reopened".to_string(),
"LabeledEvent" => format!(
"added label {}",
string_field_at(node, &["label", "name"]).unwrap_or("unknown")
),
"UnlabeledEvent" => format!(
"removed label {}",
string_field_at(node, &["label", "name"]).unwrap_or("unknown")
),
"AssignedEvent" => format!("assigned {}", assignee_name(node)),
"UnassignedEvent" => format!("unassigned {}", assignee_name(node)),
"PinnedEvent" => "pinned".to_string(),
"UnpinnedEvent" => "unpinned".to_string(),
"LockedEvent" => format!(
"locked{}",
string_field(node, "lockReason")
.map(|reason| format!(": {}", format_github_state(reason)))
.unwrap_or_default()
),
"UnlockedEvent" => "unlocked".to_string(),
"AddedToProjectEvent" => "added to project".to_string(),
"AddedToProjectV2Event" => format!("added to project{}", automated_suffix(node)),
"MovedColumnsInProjectEvent" => "moved in project".to_string(),
"RemovedFromProjectEvent" => "removed from project".to_string(),
"RemovedFromProjectV2Event" => format!("removed from project{}", automated_suffix(node)),
"ProjectV2ItemStatusChangedEvent" => format!(
"changed project status from {} to {}{}",
github_value_or_unknown(node, "previousStatus"),
github_value_or_unknown(node, "status"),
automated_suffix(node)
),
"ConvertedFromDraftEvent" => {
format!("converted from draft{}", automated_suffix(node))
}
"ConvertedNoteToIssueEvent" => format!(
"converted project note to issue{}",
string_field(node, "projectColumnName")
.map(|column| format!(" from {column}"))
.unwrap_or_default()
),
"UserBlockedEvent" => format!(
"blocked {} for {}",
string_field_at(node, &["subject", "login"]).unwrap_or("unknown"),
format_github_state(string_field(node, "blockDuration").unwrap_or("unknown"))
),
"SubscribedEvent" => "subscribed".to_string(),
"UnsubscribedEvent" => "unsubscribed".to_string(),
"MentionedEvent" => "mentioned".to_string(),
"CommentDeletedEvent" => "deleted a comment".to_string(),
"TransferredEvent" => format!(
"transferred from {}",
string_field_at(node, &["fromRepository", "nameWithOwner"]).unwrap_or("unknown")
),
"MarkedAsDuplicateEvent" => format!(
"marked duplicate of {}",
resource_reference_label(node.get("canonical").unwrap_or(&Value::Null))
),
"UnmarkedAsDuplicateEvent" => format!(
"unmarked duplicate of {}",
resource_reference_label(node.get("canonical").unwrap_or(&Value::Null))
),
"ConnectedEvent" => format!(
"connected {} to {}",
resource_reference_label(node.get("source").unwrap_or(&Value::Null)),
resource_reference_label(node.get("subject").unwrap_or(&Value::Null))
),
"DisconnectedEvent" => format!(
"disconnected {} from {}",
resource_reference_label(node.get("source").unwrap_or(&Value::Null)),
resource_reference_label(node.get("subject").unwrap_or(&Value::Null))
),
"ReferencedEvent" => format!(
"referenced commit {}",
string_field_at(node, &["commit", "oid"])
.map(|oid| oid.chars().take(12).collect::<String>())
.unwrap_or_else(|| "unknown".to_string())
),
"CrossReferencedEvent" => cross_reference_body(node),
"RenamedTitleEvent" => format!(
"renamed title from \"{}\" to \"{}\"",
string_field(node, "previousTitle").unwrap_or("unknown"),
string_field(node, "currentTitle").unwrap_or("unknown")
),
"MilestonedEvent" => format!(
"added milestone {}",
string_field(node, "milestoneTitle").unwrap_or("unknown")
),
"DemilestonedEvent" => format!(
"removed milestone {}",
string_field(node, "milestoneTitle").unwrap_or("unknown")
),
"ConvertedToDiscussionEvent" => format!(
"converted to discussion {}",
string_field_at(node, &["discussion", "title"]).unwrap_or("unknown")
),
"IssueCommentPinnedEvent" => "pinned a comment".to_string(),
"IssueCommentUnpinnedEvent" => "unpinned a comment".to_string(),
"IssueTypeAddedEvent" => {
format!("added issue type {}", issue_type_name(node, "issueType"))
}
"IssueTypeRemovedEvent" => {
format!("removed issue type {}", issue_type_name(node, "issueType"))
}
"IssueTypeChangedEvent" => format!(
"changed issue type from {} to {}",
issue_type_name(node, "prevIssueType"),
issue_type_name(node, "issueType")
),
"IssueFieldAddedEvent" => format!(
"added issue field {}{}",
issue_field_name(node),
value_suffix(node, "value")
.or_else(|| value_suffix(node, "color"))
.unwrap_or_default()
),
"IssueFieldRemovedEvent" => {
format!("removed issue field {}", issue_field_name(node))
}
"IssueFieldChangedEvent" => format!(
"changed issue field {} from {} to {}",
issue_field_name(node),
issue_field_value(node, "previousValue", "previousColor"),
issue_field_value(node, "newValue", "newColor")
),
"SubIssueAddedEvent" => format!(
"added sub-issue {}",
resource_reference_label(node.get("subIssue").unwrap_or(&Value::Null))
),
"SubIssueRemovedEvent" => format!(
"removed sub-issue {}",
resource_reference_label(node.get("subIssue").unwrap_or(&Value::Null))
),
"ParentIssueAddedEvent" => format!(
"added parent issue {}",
resource_reference_label(node.get("parent").unwrap_or(&Value::Null))
),
"ParentIssueRemovedEvent" => format!(
"removed parent issue {}",
resource_reference_label(node.get("parent").unwrap_or(&Value::Null))
),
"BlockedByAddedEvent" => format!(
"blocked by {}",
resource_reference_label(node.get("blockingIssue").unwrap_or(&Value::Null))
),
"BlockedByRemovedEvent" => format!(
"removed blocking issue {}",
resource_reference_label(node.get("blockingIssue").unwrap_or(&Value::Null))
),
"BlockingAddedEvent" => format!(
"blocking {}",
resource_reference_label(node.get("blockedIssue").unwrap_or(&Value::Null))
),
"BlockingRemovedEvent" => format!(
"removed blocked issue {}",
resource_reference_label(node.get("blockedIssue").unwrap_or(&Value::Null))
),
"MergedEvent" => {
let commit = short_oid_at(node, &["commit", "oid"]);
let branch = string_field(node, "mergeRefName").unwrap_or("base branch");
format!("merged into {branch} at {commit}")
}
"ReviewRequestedEvent" => {
format!("requested review from {}", requested_reviewer_name(node))
}
"ReviewRequestRemovedEvent" => {
format!(
"removed review request from {}",
requested_reviewer_name(node)
)
}
"ReadyForReviewEvent" => "marked ready for review".to_string(),
"ConvertToDraftEvent" => "converted to draft".to_string(),
"BaseRefChangedEvent" => format!(
"changed base from {} to {}",
string_field(node, "previousRefName").unwrap_or("unknown"),
string_field(node, "currentRefName").unwrap_or("unknown")
),
"BaseRefForcePushedEvent" => ref_force_push_body("force-pushed base", node),
"BaseRefDeletedEvent" => format!(
"deleted base branch {}",
string_field(node, "baseRefName").unwrap_or("unknown")
),
"HeadRefForcePushedEvent" => ref_force_push_body("force-pushed head", node),
"HeadRefDeletedEvent" => format!(
"deleted head branch {}",
string_field(node, "headRefName").unwrap_or("unknown")
),
"HeadRefRestoredEvent" => "restored head branch".to_string(),
"ReviewDismissedEvent" => format!(
"dismissed {} review{}",
format_github_state(string_field(node, "previousReviewState").unwrap_or("review")),
string_field(node, "dismissalMessage")
.filter(|message| !message.trim().is_empty())
.map(|message| format!(": {message}"))
.unwrap_or_default()
),
"AddedToMergeQueueEvent" => format!(
"added to merge queue{}",
string_field_at(node, &["enqueuer", "login"])
.map(|login| format!(" by {login}"))
.unwrap_or_default()
),
"RemovedFromMergeQueueEvent" => format!(
"removed from merge queue{}{}",
string_field_at(node, &["beforeCommit", "oid"])
.map(|_| format!(" at {}", short_oid_at(node, &["beforeCommit", "oid"])))
.unwrap_or_default(),
string_field(node, "reason")
.map(|reason| format!(": {reason}"))
.unwrap_or_default()
),
"AutomaticBaseChangeFailedEvent" => {
automatic_base_change_body("failed to change base", node)
}
"AutomaticBaseChangeSucceededEvent" => {
automatic_base_change_body("automatically changed base", node)
}
"AutoRebaseEnabledEvent" => format!(
"enabled auto-rebase{}",
string_field_at(node, &["enabler", "login"])
.map(|login| format!(" by {login}"))
.unwrap_or_default()
),
"AutoSquashEnabledEvent" => format!(
"enabled auto-squash{}",
string_field_at(node, &["enabler", "login"])
.map(|login| format!(" by {login}"))
.unwrap_or_default()
),
"AutoMergeEnabledEvent" => "enabled auto-merge".to_string(),
"AutoMergeDisabledEvent" => format!(
"disabled auto-merge{}",
string_field(node, "reason")
.map(|reason| format!(": {reason}"))
.unwrap_or_default()
),
"PullRequestRevisionMarker" => format!(
"revision marker at {}",
short_oid_at(node, &["lastSeenCommit", "oid"])
),
"DeployedEvent" => deployment_body(node),
"DeploymentEnvironmentChangedEvent" => deployment_environment_changed_body(node),
other => format!("{other} event"),
}
}
fn cross_reference_body(node: &Value) -> String {
let source = node.get("source").unwrap_or(&Value::Null);
let label = resource_reference_label(source);
let title = string_field(source, "title").unwrap_or("untitled");
let url = string_field(source, "url");
match url {
Some(url) => format!("cross-referenced by {label}: {title}\n{url}"),
None => format!("cross-referenced by {label}: {title}"),
}
}
fn resource_reference_label(value: &Value) -> String {
let number = value.get("number").and_then(Value::as_u64);
let title = string_field(value, "title").unwrap_or("untitled");
let repo = string_field_at(value, &["repository", "nameWithOwner"]);
match (repo, number) {
(Some(repo), Some(number)) => format!("{repo}#{number}"),
(_, Some(number)) => format!("#{number}"),
_ => title.to_string(),
}
}
fn issue_type_name(node: &Value, key: &str) -> String {
string_field_at(node, &[key, "name"])
.unwrap_or("unknown")
.to_string()
}
fn issue_field_name(node: &Value) -> String {
string_field_at(node, &["issueField", "name"])
.unwrap_or("unknown")
.to_string()
}
fn issue_field_value(node: &Value, value_key: &str, color_key: &str) -> String {
string_field(node, value_key)
.or_else(|| string_field(node, color_key))
.unwrap_or("unknown")
.to_string()
}
fn value_suffix(node: &Value, key: &str) -> Option<String> {
string_field(node, key)
.filter(|value| !value.trim().is_empty())
.map(|value| format!(" = {value}"))
}
fn automated_suffix(node: &Value) -> &'static str {
match node.get("wasAutomated").and_then(Value::as_bool) {
Some(true) => " automatically",
_ => "",
}
}
fn github_value_or_unknown(node: &Value, key: &str) -> String {
string_field(node, key)
.map(format_github_state)
.unwrap_or_else(|| "unknown".to_string())
}
fn deployment_body(node: &Value) -> String {
let ref_name = string_field_at(node, &["ref", "name"]).unwrap_or("ref");
let environment = deployment_environment(node, &["deployment"]);
let state = string_field_at(node, &["deployment", "latestStatus", "state"])
.or_else(|| string_field_at(node, &["deployment", "state"]))
.map(format_github_state)
.unwrap_or_else(|| "unknown".to_string());
format!("deployed {ref_name} to {environment}: {state}")
}
fn deployment_environment_changed_body(node: &Value) -> String {
let environment = deployment_environment(node, &["deploymentStatus"]);
let state = string_field_at(node, &["deploymentStatus", "state"])
.map(format_github_state)
.unwrap_or_else(|| "unknown".to_string());
format!("deployment environment changed to {environment}: {state}")
}
fn deployment_environment(node: &Value, path: &[&str]) -> String {
let mut environment_path = path.to_vec();
environment_path.push("environment");
let mut latest_environment_path = path.to_vec();
latest_environment_path.push("latestEnvironment");
let mut nested_environment_path = path.to_vec();
nested_environment_path.push("deployment");
nested_environment_path.push("environment");
let mut nested_latest_environment_path = path.to_vec();
nested_latest_environment_path.push("deployment");
nested_latest_environment_path.push("latestEnvironment");
string_field_at(node, &latest_environment_path)
.or_else(|| string_field_at(node, &environment_path))
.or_else(|| string_field_at(node, &nested_latest_environment_path))
.or_else(|| string_field_at(node, &nested_environment_path))
.unwrap_or("unknown")
.to_string()
}
fn ref_force_push_body(prefix: &str, node: &Value) -> String {
let ref_name = string_field_at(node, &["ref", "name"]).unwrap_or("branch");
let before = short_oid_at(node, &["beforeCommit", "oid"]);
let after = short_oid_at(node, &["afterCommit", "oid"]);
format!("{prefix} {ref_name} from {before} to {after}")
}
fn automatic_base_change_body(prefix: &str, node: &Value) -> String {
format!(
"{prefix} from {} to {}",
string_field(node, "oldBase").unwrap_or("unknown"),
string_field(node, "newBase").unwrap_or("unknown")
)
}
fn short_oid_at(node: &Value, path: &[&str]) -> String {
string_field_at(node, path)
.map(short_oid)
.unwrap_or_else(|| "unknown".to_string())
}
fn short_oid(oid: &str) -> String {
oid.chars().take(12).collect()
}
fn cross_reference_url(node: &Value) -> Option<String> {
let paths: &[&[&str]] = &[
&["source", "url"],
&["canonical", "url"],
&["duplicate", "url"],
&["subject", "url"],
&["subIssue", "url"],
&["parent", "url"],
&["blockingIssue", "url"],
&["blockedIssue", "url"],
&["discussion", "url"],
&["issueComment", "url"],
&["deployment", "latestStatus", "environmentUrl"],
&["deployment", "latestStatus", "logUrl"],
&["deploymentStatus", "environmentUrl"],
&["deploymentStatus", "logUrl"],
&["url"],
];
paths
.iter()
.find_map(|path| string_field_at(node, path).map(str::to_string))
}
fn actor_login(node: &Value) -> Option<String> {
string_field_at(node, &["actor", "login"]).map(str::to_string)
}
fn assignee_name(node: &Value) -> String {
string_field_at(node, &["assignee", "login"])
.or_else(|| string_field_at(node, &["assignee", "__typename"]))
.unwrap_or("unknown")
.to_string()
}
fn requested_reviewer_name(node: &Value) -> String {
string_field_at(node, &["requestedReviewer", "login"])
.or_else(|| string_field_at(node, &["requestedReviewer", "slug"]))
.or_else(|| string_field_at(node, &["requestedReviewer", "name"]))
.or_else(|| string_field_at(node, &["requestedReviewer", "__typename"]))
.unwrap_or("unknown")
.to_string()
}
fn format_github_state(value: &str) -> String {
value
.split('_')
.map(|part| {
let lower = part.to_ascii_lowercase();
let mut chars = lower.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(Value::as_str)
}
fn bool_field(value: &Value, key: &str) -> Option<bool> {
value.get(key).and_then(Value::as_bool)
}
fn u64_field(value: &Value, key: &str) -> Option<u64> {
value.get(key).and_then(Value::as_u64)
}
fn string_field_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_str()
}
fn pr_activity(comments: Vec<CommentDto>, reviews: Vec<ReviewDto>) -> Vec<ActivityEntry> {
let mut activity = comments_to_activity(comments);
activity.extend(reviews_to_activity(reviews));
sort_activity(&mut activity);
activity
}
fn reviews_to_activity(reviews: Vec<ReviewDto>) -> Vec<ActivityEntry> {
reviews
.into_iter()
.enumerate()
.map(|(index, review)| {
let state = review.state.unwrap_or_else(|| "REVIEW".to_string());
let body = review.body.unwrap_or_default();
let body = if body.trim().is_empty() {
state.clone()
} else {
format!("{state}: {body}")
};
ActivityEntry {
id: review.id.unwrap_or_else(|| format!("review-{index}")),
kind: ActivityKind::Review,
author: display_author(review.author),
body,
updated_at: review
.updated_at
.or(review.submitted_at)
.unwrap_or_else(|| "unknown".to_string()),
path: None,
line: None,
url: review.url,
author_association: review.author_association,
reactions: reaction_counts(review.reaction_groups),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
}
})
.collect()
}
fn sort_activity(activity: &mut [ActivityEntry]) {
activity.sort_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.id.cmp(&right.id))
});
}
fn commit_from_dto(commit: CommitDto) -> Commit {
let authors = commit
.authors
.unwrap_or_default()
.into_iter()
.map(|author| author.display_name())
.filter(|name| name != "unknown")
.collect::<Vec<_>>();
let author = authors
.first()
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let authored_at = commit.authored_date.filter(|value| !value.is_empty());
Commit {
oid: commit.oid,
message: commit.message_headline,
body: commit.message_body.unwrap_or_default(),
author,
authors,
authored_at: authored_at.clone(),
committed_at: commit
.committed_date
.or(authored_at)
.unwrap_or_else(|| "unknown".to_string()),
status: CheckStatus::Unknown,
deployments: Vec::new(),
}
}
fn check_from_dto(check: CheckDto) -> CheckRun {
let status = CheckStatus::from_github(
check.status.as_deref().or(check.state.as_deref()),
check.conclusion.as_deref(),
);
let raw_status = check
.status
.or(check.state)
.filter(|value| !value.is_empty());
let raw_conclusion = check.conclusion.filter(|value| !value.is_empty());
let name = match (check.workflow_name, check.name, check.context) {
(Some(workflow), Some(name), _) if workflow != name => format!("{workflow}/{name}"),
(Some(workflow), _, _) => workflow,
(_, Some(name), _) => name,
(_, _, Some(context)) => context,
_ => "check".to_string(),
};
CheckRun {
name,
status,
summary: None,
details_url: check
.details_url
.or(check.target_url)
.filter(|value| !value.is_empty()),
started_at: check.started_at.filter(|value| !value.is_empty()),
completed_at: check.completed_at.filter(|value| !value.is_empty()),
raw_status,
raw_conclusion,
}
}
fn file_from_dto(file: FileDto) -> ChangedFile {
ChangedFile {
path: file.path,
additions: file.additions,
deletions: file.deletions,
change_type: file.change_type.unwrap_or_else(|| "MODIFIED".to_string()),
patch: None,
}
}
fn parse_unified_diff_patches(diff: &str) -> HashMap<String, String> {
let mut patches = HashMap::new();
let mut current_path: Option<String> = None;
let mut current_lines = Vec::new();
for line in diff.lines() {
if let Some(path) = parse_diff_header_path(line) {
if let Some(path) = current_path.take() {
if !current_lines.is_empty() {
patches.insert(path, current_lines.join("\n"));
}
}
current_path = Some(path);
current_lines.clear();
current_lines.push(line.to_string());
continue;
}
if current_path.is_some() {
current_lines.push(line.to_string());
}
}
if let Some(path) = current_path {
if !current_lines.is_empty() {
patches.insert(path, current_lines.join("\n"));
}
}
patches
}
fn parse_diff_header_path(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let (_left, right) = rest.split_once(' ')?;
right
.strip_prefix("b/")
.or_else(|| right.strip_prefix("a/"))
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_command_preview_uses_direct_graphql_shape() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
assert_eq!(
command_preview_for_pr(&id),
vec![
"POST",
"https://api.github.com/graphql",
"repository=openclaw/openclaw",
"number=81834",
"selector=pullRequest",
]
);
}
#[test]
fn api_depth_parses_user_facing_names() {
assert_eq!("partial".parse::<ApiDepth>().unwrap(), ApiDepth::Partial);
assert_eq!("normal".parse::<ApiDepth>().unwrap(), ApiDepth::Partial);
assert_eq!("default".parse::<ApiDepth>().unwrap(), ApiDepth::Partial);
assert_eq!("full".parse::<ApiDepth>().unwrap(), ApiDepth::Full);
assert_eq!("exhaustive".parse::<ApiDepth>().unwrap(), ApiDepth::Full);
assert!("deep".parse::<ApiDepth>().is_err());
}
#[test]
fn pr_diff_uses_rest_pull_diff_path() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
assert_eq!(
pr_diff_rest_path(&id),
"/repos/openclaw/openclaw/pulls/81834"
);
}
#[test]
fn timeline_query_keeps_pr_only_events_out_of_issue_queries() {
let issue_query = timeline_query(ResourceKind::Issue);
let pr_query = timeline_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(issue_query.contains("CLOSED_EVENT"));
assert!(issue_query.contains("LOCKED_EVENT"));
assert!(issue_query.contains("MARKED_AS_DUPLICATE_EVENT"));
assert!(!issue_query.contains("MERGED_EVENT"));
assert!(!issue_query.contains("PULL_REQUEST_COMMIT_COMMENT_THREAD"));
assert!(!issue_query.contains("PULL_REQUEST_REVISION_MARKER"));
assert!(!issue_query.contains("ReviewRequestedEvent"));
assert!(pr_query.contains("pullRequest(number: $number)"));
assert!(pr_query.contains("LOCKED_EVENT"));
assert!(pr_query.contains("MARKED_AS_DUPLICATE_EVENT"));
assert!(pr_query.contains("MERGED_EVENT"));
assert!(pr_query.contains("PULL_REQUEST_COMMIT_COMMENT_THREAD"));
assert!(pr_query.contains("PullRequestCommitCommentThread"));
assert!(pr_query.contains("PULL_REQUEST_REVISION_MARKER"));
assert!(pr_query.contains("PullRequestRevisionMarker"));
assert!(pr_query.contains("ReviewRequestedEvent"));
assert!(pr_query.contains("BASE_REF_CHANGED_EVENT"));
assert!(pr_query.contains("HEAD_REF_FORCE_PUSHED_EVENT"));
assert!(pr_query.contains("REVIEW_DISMISSED_EVENT"));
assert!(pr_query.contains("ADDED_TO_MERGE_QUEUE_EVENT"));
assert!(pr_query.contains("DEPLOYED_EVENT"));
assert!(pr_query.contains("DeploymentEnvironmentChangedEvent"));
assert!(issue_query.contains("ADDED_TO_PROJECT_V2_EVENT"));
assert!(issue_query.contains("PROJECT_V2_ITEM_STATUS_CHANGED_EVENT"));
assert!(issue_query.contains("CONVERTED_NOTE_TO_ISSUE_EVENT"));
assert!(!issue_query.contains("... on AddedToProjectEvent {{ id"));
assert!(!issue_query.contains("... on AddedToProjectV2Event {{ id"));
assert!(!issue_query.contains("... on MovedColumnsInProjectEvent {{ id"));
assert!(!issue_query.contains("... on RemovedFromProjectEvent {{ id"));
assert!(!issue_query.contains("... on RemovedFromProjectV2Event {{ id"));
assert!(!issue_query.contains("... on ProjectV2ItemStatusChangedEvent {{\n id"));
assert!(!issue_query.contains("... on ConvertedNoteToIssueEvent {{ id"));
assert!(!issue_query.contains("... on IssueFieldAddedEvent {{\n id"));
assert!(!issue_query.contains("... on IssueFieldRemovedEvent {{\n id"));
assert!(!issue_query.contains("... on IssueFieldChangedEvent {{\n id"));
assert!(issue_query.contains("USER_BLOCKED_EVENT"));
assert!(issue_query.contains("ISSUE_TYPE_CHANGED_EVENT"));
assert!(issue_query.contains("ISSUE_FIELD_CHANGED_EVENT"));
assert!(issue_query.contains("SUB_ISSUE_ADDED_EVENT"));
assert!(issue_query.contains("BLOCKED_BY_ADDED_EVENT"));
assert!(issue_query.contains("ConvertedToDiscussionEvent"));
}
#[test]
fn base_pr_query_requests_current_status_metadata() {
let query = base_pr_query();
assert!(query.contains("locked"));
assert!(query.contains("activeLockReason"));
assert!(query.contains("isInMergeQueue"));
assert!(query.contains("isMergeQueueEnabled"));
assert!(query.contains("canBeRebased"));
assert!(query.contains("totalCommentsCount"));
}
#[test]
fn base_pr_query_requests_partial_depth_markers() {
let query = base_pr_query();
for field in [
"labels(first: 100) { pageInfo { hasNextPage }",
"assignees(first: 100) { pageInfo { hasNextPage }",
"reviewRequests(first: 100) {\n pageInfo { hasNextPage }",
"closingIssuesReferences(first: 100) { pageInfo { hasNextPage }",
"commits(first: 100) {\n pageInfo { hasNextPage }",
"contexts(first: 100) {\n pageInfo { hasNextPage }",
"files(first: 100) { pageInfo { hasNextPage }",
"comments(first: 100) {\n pageInfo { hasNextPage }",
"reviews(first: 100) {\n pageInfo { hasNextPage }",
] {
assert!(query.contains(field), "{field}");
}
}
#[test]
fn base_issue_query_requests_current_status_metadata() {
let query = base_issue_query();
assert!(query.contains("locked"));
assert!(query.contains("activeLockReason"));
assert!(query.contains("issueType { name }"));
assert!(query.contains("lastEditedAt"));
assert!(query.contains("createdViaEmail"));
assert!(query.contains("trackedIssuesCount"));
assert!(query.contains("openTrackedIssuesCount: trackedIssuesCount(states: OPEN)"));
assert!(query.contains("closedTrackedIssuesCount: trackedIssuesCount(states: CLOSED)"));
assert!(query.contains("issueDependenciesSummary"));
assert!(query.contains("totalBlockedBy"));
assert!(query.contains("subIssuesSummary"));
assert!(query.contains("percentCompleted"));
}
#[test]
fn base_issue_query_requests_partial_depth_markers() {
let query = base_issue_query();
for field in [
"labels(first: 100) { pageInfo { hasNextPage }",
"assignees(first: 100) { pageInfo { hasNextPage }",
"closedByPullRequestsReferences(first: 100) { pageInfo { hasNextPage }",
"comments(first: 100) {\n pageInfo { hasNextPage }",
] {
assert!(query.contains(field), "{field}");
}
}
#[test]
fn base_pr_partial_depth_warning_lists_paginated_sections() {
let raw = serde_json::to_vec(&json!({
"data": {
"repository": {
"pullRequest": {
"labels": {"pageInfo": {"hasNextPage": true}},
"assignees": {"pageInfo": {"hasNextPage": false}},
"reviewRequests": {"pageInfo": {"hasNextPage": true}},
"closingIssuesReferences": {"pageInfo": {"hasNextPage": false}},
"commits": {"pageInfo": {"hasNextPage": true}},
"statusCheckRollup": {
"contexts": {"pageInfo": {"hasNextPage": true}}
},
"files": {"pageInfo": {"hasNextPage": false}},
"comments": {"pageInfo": {"hasNextPage": true}},
"reviews": {"pageInfo": {"hasNextPage": false}}
}
}
}
}))
.expect("json");
let warnings = base_pr_partial_depth_warnings(&raw);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("labels"));
assert!(warnings[0].contains("review requests"));
assert!(warnings[0].contains("commits"));
assert!(warnings[0].contains("checks"));
assert!(warnings[0].contains("comments"));
assert!(warnings[0].contains("--api-depth full"));
assert!(warnings[0].contains("GZG_API_DEPTH=full"));
assert!(!warnings[0].contains("assignees"));
assert!(!warnings[0].contains("changed files"));
}
#[test]
fn base_issue_partial_depth_warning_lists_paginated_sections() {
let raw = serde_json::to_vec(&json!({
"data": {
"repository": {
"issue": {
"labels": {"pageInfo": {"hasNextPage": false}},
"assignees": {"pageInfo": {"hasNextPage": true}},
"closedByPullRequestsReferences": {
"pageInfo": {"hasNextPage": true}
},
"comments": {"pageInfo": {"hasNextPage": true}}
}
}
}
}))
.expect("json");
let warnings = base_issue_partial_depth_warnings(&raw);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("assignees"));
assert!(warnings[0].contains("linked closing pull requests"));
assert!(warnings[0].contains("comments"));
assert!(warnings[0].contains("--api-depth full"));
assert!(warnings[0].contains("GZG_API_DEPTH=full"));
assert!(!warnings[0].contains("labels"));
}
#[test]
fn base_partial_depth_warning_is_empty_when_first_pages_are_complete() {
let raw = serde_json::to_vec(&json!({
"data": {
"repository": {
"issue": {
"labels": {"pageInfo": {"hasNextPage": false}},
"assignees": {"pageInfo": {"hasNextPage": false}},
"closedByPullRequestsReferences": {
"pageInfo": {"hasNextPage": false}
},
"comments": {"pageInfo": {"hasNextPage": false}}
}
}
}
}))
.expect("json");
assert!(base_issue_partial_depth_warnings(&raw).is_empty());
}
#[test]
fn comments_query_uses_selector_and_pagination_state() {
let issue_query = comments_query(ResourceKind::Issue);
let pr_query = comments_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("comments(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("reactionGroups"));
}
}
#[test]
fn labels_query_uses_selector_and_pagination_state() {
let issue_query = labels_query(ResourceKind::Issue);
let pr_query = labels_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("labels(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("name"));
}
}
#[test]
fn assignees_query_uses_selector_and_pagination_state() {
let issue_query = assignees_query(ResourceKind::Issue);
let pr_query = assignees_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("assignees(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("login"));
}
}
#[test]
fn participants_query_uses_selector_and_pagination_state() {
let issue_query = participants_query(ResourceKind::Issue);
let pr_query = participants_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("participants(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("login"));
assert!(query.contains("name"));
}
}
#[test]
fn review_requests_query_requests_pagination_state_and_reviewer_fragments() {
let query = review_requests_query();
assert!(query.contains("$after: String"));
assert!(query.contains("pullRequest(number: $number)"));
assert!(query.contains("reviewRequests(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("... on User { login name }"));
assert!(query.contains("... on Team { name slug }"));
}
#[test]
fn linked_resources_query_uses_selector_connection_and_pagination_state() {
let issue_query = linked_resources_query(ResourceKind::Issue);
let pr_query = linked_resources_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(issue_query.contains("closedByPullRequestsReferences(first: 100, after: $after)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
assert!(pr_query.contains("closingIssuesReferences(first: 100, after: $after)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("number"));
assert!(query.contains("url"));
}
}
#[test]
fn issue_parent_query_requests_parent_reference() {
let query = issue_parent_query();
assert!(query.contains("issue(number: $number)"));
assert!(query.contains("parent"));
assert!(query.contains("number"));
assert!(query.contains("url"));
}
#[test]
fn issue_duplicate_query_requests_duplicate_reference() {
let query = issue_duplicate_query();
assert!(query.contains("issue(number: $number)"));
assert!(query.contains("duplicateOf"));
assert!(query.contains("number"));
assert!(query.contains("url"));
}
#[test]
fn issue_relationships_query_requests_connection_and_pagination_state() {
let query = issue_relationships_query("blockedBy");
assert!(query.contains("issue(number: $number)"));
assert!(query.contains("$after: String"));
assert!(query.contains("blockedBy(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("number"));
assert!(query.contains("url"));
}
#[test]
fn issue_linked_branches_query_requests_ref_repository_and_pagination_state() {
let query = issue_linked_branches_query();
assert!(query.contains("issue(number: $number)"));
assert!(query.contains("$after: String"));
assert!(query.contains("linkedBranches(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("ref"));
assert!(query.contains("name"));
assert!(query.contains("repository"));
assert!(query.contains("nameWithOwner"));
assert!(query.contains("url"));
}
#[test]
fn reviews_query_requests_pagination_state_and_reaction_fields() {
let query = reviews_query();
assert!(query.contains("$after: String"));
assert!(query.contains("pullRequest(number: $number)"));
assert!(query.contains("reviews(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("authorAssociation"));
assert!(query.contains("submittedAt"));
assert!(query.contains("updatedAt"));
assert!(query.contains("reactionGroups"));
}
#[test]
fn project_items_query_uses_selector_and_pagination_state() {
let issue_query = project_items_query(ResourceKind::Issue);
let pr_query = project_items_query(ResourceKind::PullRequest);
assert!(issue_query.contains("issue(number: $number)"));
assert!(pr_query.contains("pullRequest(number: $number)"));
for query in [issue_query, pr_query] {
assert!(query.contains("$after: String"));
assert!(query.contains("projectItems(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("project"));
assert!(query.contains("title"));
}
}
#[test]
fn project_items_page_preserves_pagination_state() {
let page = project_items_page(ProjectItemsResponse {
data: ProjectItemsData {
repository: Some(ProjectItemsRepository {
issue: None,
pull_request: Some(ProjectItemsResource {
project_items: ProjectItemsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("project-cursor-2".into()),
},
nodes: vec![
ProjectItemDto {
project: Some(ProjectDto {
title: Some("Roadmap".into()),
}),
},
ProjectItemDto {
project: Some(ProjectDto {
title: Some(" ".into()),
}),
},
ProjectItemDto { project: None },
],
},
}),
}),
},
})
.expect("project items page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("project-cursor-2"));
assert_eq!(page.projects, vec!["Roadmap"]);
}
#[test]
fn labels_page_preserves_pagination_state_and_filters_blank_values() {
let page = labels_page(LabelsResponse {
data: LabelsData {
repository: Some(LabelsRepository {
issue: None,
pull_request: Some(LabelsResource {
labels: LabelsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("label-cursor-2".into()),
},
nodes: vec![
LabelDto { name: "P1".into() },
LabelDto { name: " ".into() },
],
},
}),
}),
},
})
.expect("labels page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("label-cursor-2"));
assert_eq!(page.labels, vec!["P1"]);
let labels = labels_from_response(LabelsResponse {
data: LabelsData {
repository: Some(LabelsRepository {
issue: Some(LabelsResource {
labels: LabelsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: Vec::new(),
},
}),
pull_request: None,
}),
},
});
assert!(labels.is_empty());
}
#[test]
fn assignees_page_preserves_pagination_state_and_display_names() {
let page = assignees_page(AssigneesResponse {
data: AssigneesData {
repository: Some(AssigneesRepository {
issue: None,
pull_request: Some(AssigneesResource {
assignees: AssigneesConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("assignee-cursor-2".into()),
},
nodes: vec![
UserDto {
login: Some("alice".into()),
name: None,
},
UserDto {
login: None,
name: Some("Display Name".into()),
},
],
},
}),
}),
},
})
.expect("assignees page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("assignee-cursor-2"));
assert_eq!(page.assignees, vec!["alice", "Display Name"]);
let assignees = assignees_from_response(AssigneesResponse {
data: AssigneesData { repository: None },
});
assert!(assignees.is_empty());
}
#[test]
fn participants_page_preserves_pagination_state_and_display_names() {
let page = participants_page(ParticipantsResponse {
data: ParticipantsData {
repository: Some(ParticipantsRepository {
issue: Some(ParticipantsResource {
participants: ParticipantsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("participant-cursor-2".into()),
},
nodes: vec![
UserDto {
login: Some("alice".into()),
name: Some("Alice Maintainer".into()),
},
UserDto {
login: None,
name: Some("Display Name".into()),
},
UserDto {
login: None,
name: None,
},
],
},
}),
pull_request: None,
}),
},
})
.expect("participants page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("participant-cursor-2"));
assert_eq!(page.participants, vec!["alice", "Display Name"]);
let participants = participants_from_response(ParticipantsResponse {
data: ParticipantsData {
repository: Some(ParticipantsRepository {
issue: None,
pull_request: Some(ParticipantsResource {
participants: ParticipantsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: Vec::new(),
},
}),
}),
},
});
assert!(participants.is_empty());
}
#[test]
fn review_requests_page_preserves_pagination_state_and_display_names() {
let page = review_requests_page(ReviewRequestsResponse {
data: ReviewRequestsData {
repository: Some(ReviewRequestsRepository {
pull_request: Some(ReviewRequestsPullRequest {
review_requests: ReviewRequestsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("review-request-cursor-2".into()),
},
nodes: vec![
json!({
"requestedReviewer": {
"__typename": "User",
"login": "maintainer",
"name": null
}
}),
json!({
"requestedReviewer": {
"__typename": "Team",
"name": "Docs Team",
"slug": "docs-team"
}
}),
json!({
"requestedReviewer": null
}),
],
},
}),
}),
},
})
.expect("review requests page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("review-request-cursor-2"));
assert_eq!(page.reviewers, vec!["maintainer", "Docs Team"]);
let reviewers = review_requests_from_response(ReviewRequestsResponse {
data: ReviewRequestsData {
repository: Some(ReviewRequestsRepository {
pull_request: Some(ReviewRequestsPullRequest {
review_requests: ReviewRequestsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: Vec::new(),
},
}),
}),
},
});
assert!(reviewers.is_empty());
}
#[test]
fn linked_resources_page_preserves_pagination_state_and_kind_mapping() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let page = linked_resources_page(
LinkedResourcesResponse {
data: LinkedResourcesData {
repository: Some(LinkedResourcesRepository {
issue: None,
pull_request: Some(LinkedResourcesResource {
closing_issues_references: Some(LinkedResourcesConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("linked-cursor-2".into()),
},
nodes: vec![
RelatedResourceDto {
number: None,
url: Some(
"https://github.com/openclaw/openclaw/issues/88499"
.into(),
),
},
RelatedResourceDto {
number: Some(90001),
url: None,
},
],
}),
closed_by_pull_requests_references: None,
}),
}),
},
},
ResourceKind::PullRequest,
&requested,
)
.expect("linked resources page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("linked-cursor-2"));
assert_eq!(page.related_resources.len(), 2);
assert_eq!(page.related_resources[0].number, 88499);
assert_eq!(
page.related_resources[0].kind_hint,
Some(ResourceKind::Issue)
);
assert_eq!(page.related_resources[1].number, 90001);
assert_eq!(
page.related_resources[1].kind_hint,
Some(ResourceKind::Issue)
);
let issue = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let related_prs = linked_resources_from_response(
LinkedResourcesResponse {
data: LinkedResourcesData {
repository: Some(LinkedResourcesRepository {
issue: Some(LinkedResourcesResource {
closing_issues_references: None,
closed_by_pull_requests_references: Some(LinkedResourcesConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![RelatedResourceDto {
number: Some(81834),
url: None,
}],
}),
}),
pull_request: None,
}),
},
},
ResourceKind::Issue,
&issue,
);
assert_eq!(related_prs.len(), 1);
assert_eq!(related_prs[0].number, 81834);
assert_eq!(related_prs[0].kind_hint, Some(ResourceKind::PullRequest));
}
#[test]
fn linked_resources_page_allows_valid_empty_pages() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let related = linked_resources_from_response(
LinkedResourcesResponse {
data: LinkedResourcesData {
repository: Some(LinkedResourcesRepository {
issue: None,
pull_request: Some(LinkedResourcesResource {
closing_issues_references: Some(LinkedResourcesConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: Vec::new(),
}),
closed_by_pull_requests_references: None,
}),
}),
},
},
ResourceKind::PullRequest,
&requested,
);
assert!(related.is_empty());
}
#[test]
fn issue_parent_from_response_maps_url_or_number() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let parent = issue_parent_from_response(
IssueParentResponse {
data: IssueParentData {
repository: Some(IssueParentRepository {
issue: Some(IssueParentResource {
parent: Some(RelatedResourceDto {
number: None,
url: Some("https://github.com/other/repo/issues/12".into()),
}),
}),
}),
},
},
&requested,
)
.expect("parent issue");
assert_eq!(parent.canonical_name(), "other/repo#12");
assert_eq!(parent.kind_hint, Some(ResourceKind::Issue));
let parent = issue_parent_from_response(
IssueParentResponse {
data: IssueParentData {
repository: Some(IssueParentRepository {
issue: Some(IssueParentResource {
parent: Some(RelatedResourceDto {
number: Some(90001),
url: None,
}),
}),
}),
},
},
&requested,
)
.expect("parent issue fallback number");
assert_eq!(parent.canonical_name(), "openclaw/openclaw#90001");
assert_eq!(parent.kind_hint, Some(ResourceKind::Issue));
}
#[test]
fn issue_duplicate_from_response_maps_url_or_number() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let duplicate = issue_duplicate_from_response(
IssueDuplicateResponse {
data: IssueDuplicateData {
repository: Some(IssueDuplicateRepository {
issue: Some(IssueDuplicateResource {
duplicate_of: Some(RelatedResourceDto {
number: None,
url: Some("https://github.com/other/repo/issues/12".into()),
}),
}),
}),
},
},
&requested,
)
.expect("duplicate issue");
assert_eq!(duplicate.canonical_name(), "other/repo#12");
assert_eq!(duplicate.kind_hint, Some(ResourceKind::Issue));
let duplicate = issue_duplicate_from_response(
IssueDuplicateResponse {
data: IssueDuplicateData {
repository: Some(IssueDuplicateRepository {
issue: Some(IssueDuplicateResource {
duplicate_of: Some(RelatedResourceDto {
number: Some(90001),
url: None,
}),
}),
}),
},
},
&requested,
)
.expect("duplicate issue fallback number");
assert_eq!(duplicate.canonical_name(), "openclaw/openclaw#90001");
assert_eq!(duplicate.kind_hint, Some(ResourceKind::Issue));
let no_duplicate = issue_duplicate_from_response(
IssueDuplicateResponse {
data: IssueDuplicateData {
repository: Some(IssueDuplicateRepository {
issue: Some(IssueDuplicateResource { duplicate_of: None }),
}),
},
},
&requested,
);
assert!(no_duplicate.is_none());
}
#[test]
fn issue_relationships_page_preserves_pagination_state_and_kind_mapping() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let page = issue_relationships_page(
IssueRelationshipsResponse {
data: IssueRelationshipsData {
repository: Some(IssueRelationshipsRepository {
issue: Some(IssueRelationshipsResource {
sub_issues: None,
tracked_issues: None,
tracked_in_issues: None,
blocked_by: Some(LinkedResourcesConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("blocked-by-cursor-2".into()),
},
nodes: vec![
RelatedResourceDto {
number: None,
url: Some("https://github.com/other/repo/issues/12".into()),
},
RelatedResourceDto {
number: Some(90001),
url: None,
},
],
}),
blocking: None,
}),
}),
},
},
IssueRelationshipKind::BlockedBy,
&requested,
)
.expect("blocked-by page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("blocked-by-cursor-2"));
assert_eq!(page.related_resources.len(), 2);
assert_eq!(page.related_resources[0].canonical_name(), "other/repo#12");
assert_eq!(
page.related_resources[0].kind_hint,
Some(ResourceKind::Issue)
);
assert_eq!(
page.related_resources[1].canonical_name(),
"openclaw/openclaw#90001"
);
assert_eq!(
page.related_resources[1].kind_hint,
Some(ResourceKind::Issue)
);
let tracked = issue_relationships_from_response(
IssueRelationshipsResponse {
data: IssueRelationshipsData {
repository: Some(IssueRelationshipsRepository {
issue: Some(IssueRelationshipsResource {
sub_issues: None,
tracked_issues: Some(LinkedResourcesConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: Vec::new(),
}),
tracked_in_issues: None,
blocked_by: None,
blocking: None,
}),
}),
},
},
IssueRelationshipKind::TrackedIssues,
&requested,
);
assert!(tracked.is_empty());
}
#[test]
fn issue_linked_branches_page_preserves_pagination_state_and_labels() {
let page = issue_linked_branches_page(IssueLinkedBranchesResponse {
data: IssueLinkedBranchesData {
repository: Some(IssueLinkedBranchesRepository {
issue: Some(IssueLinkedBranchesResource {
linked_branches: IssueLinkedBranchesConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("branch-cursor-2".into()),
},
nodes: vec![
LinkedBranchDto {
branch_ref: Some(BranchRefDto {
name: Some("feature/status".into()),
repository: Some(BranchRepositoryDto {
name_with_owner: Some("openclaw/openclaw".into()),
url: Some(
"https://github.com/openclaw/openclaw".into(),
),
}),
}),
},
LinkedBranchDto {
branch_ref: Some(BranchRefDto {
name: Some("local-only".into()),
repository: None,
}),
},
LinkedBranchDto {
branch_ref: Some(BranchRefDto {
name: Some(" ".into()),
repository: Some(BranchRepositoryDto {
name_with_owner: Some("ignored/repo".into()),
url: None,
}),
}),
},
LinkedBranchDto { branch_ref: None },
],
},
}),
}),
},
})
.expect("linked branches page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("branch-cursor-2"));
assert_eq!(
page.branches,
vec!["openclaw/openclaw:feature/status", "local-only"]
);
let branches = issue_linked_branches_from_response(IssueLinkedBranchesResponse {
data: IssueLinkedBranchesData { repository: None },
});
assert!(branches.is_empty());
}
#[test]
fn apply_project_metadata_replaces_existing_value_and_dedupes() {
let mut metadata = vec![
MetadataItem {
label: "Closed".into(),
value: "no".into(),
},
MetadataItem {
label: "Projects".into(),
value: "Old".into(),
},
];
apply_project_metadata(
&mut metadata,
vec![" Roadmap ".into(), "Roadmap".into(), "Triage".into()],
);
assert_eq!(metadata.len(), 2);
assert_eq!(metadata[1].label, "Projects");
assert_eq!(metadata[1].value, "Roadmap, Triage");
}
#[test]
fn apply_participant_metadata_replaces_existing_value_and_dedupes() {
let mut metadata = vec![
MetadataItem {
label: "Closed".into(),
value: "no".into(),
},
MetadataItem {
label: "Participants".into(),
value: "old".into(),
},
];
apply_participant_metadata(
&mut metadata,
vec![" alice ".into(), "alice".into(), "bob".into(), "".into()],
);
assert_eq!(metadata.len(), 2);
assert_eq!(metadata[1].label, "Participants");
assert_eq!(metadata[1].value, "alice, bob");
}
#[test]
fn apply_issue_relationship_metadata_replaces_existing_value_and_dedupes() {
let mut metadata = vec![
MetadataItem {
label: "Closed".into(),
value: "no".into(),
},
MetadataItem {
label: "Blocked by".into(),
value: "old".into(),
},
];
let first = ResourceId::parse("https://github.com/openclaw/openclaw/issues/88499").unwrap();
let duplicate =
ResourceId::parse("https://github.com/openclaw/openclaw/issues/88499").unwrap();
let second = ResourceId::parse("https://github.com/other/repo/issues/12").unwrap();
apply_issue_relationship_metadata(
&mut metadata,
"Blocked by",
vec![first, duplicate, second],
);
assert_eq!(metadata.len(), 2);
assert_eq!(metadata[1].label, "Blocked by");
assert_eq!(metadata[1].value, "openclaw/openclaw#88499, other/repo#12");
}
#[test]
fn apply_linked_branch_metadata_replaces_existing_value_and_dedupes() {
let mut metadata = vec![
MetadataItem {
label: "Closed".into(),
value: "no".into(),
},
MetadataItem {
label: "Linked branches".into(),
value: "old".into(),
},
];
apply_linked_branch_metadata(
&mut metadata,
vec![
" openclaw/openclaw:feature/status ".into(),
"openclaw/openclaw:feature/status".into(),
"dutifuldev/ghzinga:issue-branch".into(),
"".into(),
],
);
assert_eq!(metadata.len(), 2);
assert_eq!(metadata[1].label, "Linked branches");
assert_eq!(
metadata[1].value,
"openclaw/openclaw:feature/status, dutifuldev/ghzinga:issue-branch"
);
}
#[test]
fn append_related_resources_dedupes_existing_targets() {
let mut resource = Resource {
id: ResourceId::parse("https://github.com/openclaw/openclaw/issues/1").unwrap(),
title: "Issue".into(),
url: "https://github.com/openclaw/openclaw/issues/1".into(),
state: "OPEN".into(),
author: "author".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
labels: Vec::new(),
assignees: Vec::new(),
reactions: ReactionCounts::default(),
body: String::new(),
activity: Vec::new(),
related_resources: Vec::new(),
metadata: Vec::new(),
warnings: Vec::new(),
pull_request: None,
};
resource.related_resources =
vec![ResourceId::parse("https://github.com/openclaw/openclaw/issues/88499").unwrap()];
append_related_resources(
&mut resource,
vec![
ResourceId::parse("https://github.com/openclaw/openclaw/issues/88499").unwrap(),
ResourceId::parse("https://github.com/other/repo/issues/12").unwrap(),
],
);
assert_eq!(resource.related_resources.len(), 2);
assert_eq!(
resource.related_resources[0].canonical_name(),
"openclaw/openclaw#88499"
);
assert_eq!(
resource.related_resources[1].canonical_name(),
"other/repo#12"
);
}
#[test]
fn project_scope_errors_are_suppressed_for_optional_metadata() {
let error = anyhow::anyhow!(
"GitHub GraphQL request returned errors: INSUFFICIENT_SCOPES read:project"
);
assert!(is_project_scope_error(&error));
}
#[test]
fn check_suites_query_requests_pagination_state() {
let query = check_suites_query();
assert!(query.contains("$after: String"));
assert!(query.contains("checkSuites(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
}
#[test]
fn status_rollup_query_requests_pagination_state_and_context_fields() {
let query = status_rollup_query();
assert!(query.contains("$after: String"));
assert!(query.contains("statusCheckRollup"));
assert!(query.contains("contexts(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("... on CheckRun"));
assert!(query.contains("detailsUrl"));
assert!(query.contains("checkSuite { workflowRun { workflow { name } } }"));
assert!(query.contains("... on StatusContext"));
assert!(query.contains("targetUrl"));
}
#[test]
fn commits_query_requests_pagination_state_and_full_commit_fields() {
let query = commits_query();
assert!(query.contains("$after: String"));
assert!(query.contains("commits(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("messageHeadline"));
assert!(query.contains("messageBody"));
assert!(query.contains("committedDate"));
assert!(query.contains("authoredDate"));
assert!(query.contains("authors(first: 100)"));
assert!(query.contains("authors(first: 100) {\n pageInfo"));
assert!(query.contains("user { login name }"));
}
#[test]
fn commit_authors_query_requests_commit_object_and_pagination_state() {
let query = commit_authors_query();
assert!(query.contains("$oid: GitObjectID!"));
assert!(query.contains("object(oid: $oid)"));
assert!(query.contains("... on Commit"));
assert!(query.contains("authors(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("user { login name }"));
}
#[test]
fn commit_deployments_query_requests_commit_pagination_state_and_status_fields() {
let query = commit_deployments_query();
assert!(query.contains("$after: String"));
assert!(query.contains("commits(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("deployments(first: 100)"));
assert!(query.contains("deployments(first: 100) {\n pageInfo"));
assert!(query.contains("latestStatus"));
assert!(query.contains("environmentUrl"));
assert!(query.contains("logUrl"));
}
#[test]
fn commit_deployment_items_query_requests_commit_object_and_pagination_state() {
let query = commit_deployment_items_query();
assert!(query.contains("$oid: GitObjectID!"));
assert!(query.contains("object(oid: $oid)"));
assert!(query.contains("... on Commit"));
assert!(query.contains("deployments(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
assert!(query.contains("latestStatus"));
assert!(query.contains("environmentUrl"));
assert!(query.contains("logUrl"));
}
#[test]
fn review_threads_query_requests_pagination_state() {
let query = review_threads_query();
assert!(query.contains("$after: String"));
assert!(query.contains("reviewThreads(first: 100, after: $after)"));
assert!(query.contains("comments(first: 100)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
}
#[test]
fn comment_activity_page_preserves_pagination_state() {
let page = comment_activity_page(CommentsResponse {
data: CommentsData {
repository: Some(CommentsRepository {
issue: Some(CommentsResource {
comments: CommentsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("comment-cursor-2".into()),
},
nodes: vec![CommentDto {
id: Some("comment-1".into()),
author: Some(UserDto {
login: Some("alice".into()),
name: None,
}),
author_association: Some("MEMBER".into()),
body: "hello".into(),
created_at: Some("2026-01-01T00:00:00Z".into()),
updated_at: None,
url: Some(
"https://github.com/openclaw/openclaw/issues/1#issuecomment-1"
.into(),
),
includes_created_edit: Some(true),
is_minimized: Some(false),
minimized_reason: None,
reaction_groups: vec![ReactionGroupDto {
content: "THUMBS_UP".into(),
users: TotalCountDto { total_count: 2 },
}],
}],
},
}),
pull_request: None,
}),
},
})
.expect("comment page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("comment-cursor-2"));
assert_eq!(page.activity.len(), 1);
assert_eq!(page.activity[0].kind, ActivityKind::Comment);
assert_eq!(page.activity[0].author, "alice");
assert_eq!(
page.activity[0].author_association.as_deref(),
Some("MEMBER")
);
assert_eq!(page.activity[0].reactions.thumbs_up, 2);
assert!(page.activity[0].includes_created_edit);
}
#[test]
fn review_activity_page_preserves_pagination_state() {
let page = review_activity_page(ReviewsResponse {
data: ReviewsData {
repository: Some(ReviewsRepository {
pull_request: Some(ReviewsPullRequest {
reviews: ReviewsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("review-cursor-2".into()),
},
nodes: vec![ReviewDto {
id: Some("review-1".into()),
author: Some(UserDto {
login: Some("bob".into()),
name: None,
}),
author_association: Some("CONTRIBUTOR".into()),
body: Some("looks good".into()),
state: Some("APPROVED".into()),
submitted_at: Some("2026-01-02T00:00:00Z".into()),
updated_at: None,
url: Some(
"https://github.com/openclaw/openclaw/pull/81834#pullrequestreview-1"
.into(),
),
reaction_groups: vec![ReactionGroupDto {
content: "THUMBS_UP".into(),
users: TotalCountDto { total_count: 3 },
}],
}],
},
}),
}),
},
})
.expect("review page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("review-cursor-2"));
assert_eq!(page.activity.len(), 1);
assert_eq!(page.activity[0].kind, ActivityKind::Review);
assert_eq!(page.activity[0].author, "bob");
assert_eq!(page.activity[0].body, "APPROVED: looks good");
assert_eq!(
page.activity[0].author_association.as_deref(),
Some("CONTRIBUTOR")
);
assert_eq!(page.activity[0].reactions.thumbs_up, 3);
}
#[test]
fn replace_comment_activity_keeps_other_activity() {
let mut resource = Resource {
id: ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap(),
title: "title".into(),
url: "https://github.com/openclaw/openclaw/pull/81834".into(),
state: "OPEN".into(),
author: "alice".into(),
created_at: "created".into(),
updated_at: "updated".into(),
labels: Vec::new(),
assignees: Vec::new(),
reactions: ReactionCounts::default(),
body: "body".into(),
activity: vec![
ActivityEntry {
id: "old-comment".into(),
kind: ActivityKind::Comment,
author: "alice".into(),
body: "old".into(),
updated_at: "old".into(),
path: None,
line: None,
url: None,
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
},
ActivityEntry {
id: "review".into(),
kind: ActivityKind::Review,
author: "bob".into(),
body: "approved".into(),
updated_at: "new".into(),
path: None,
line: None,
url: None,
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
},
],
related_resources: Vec::new(),
metadata: Vec::new(),
warnings: Vec::new(),
pull_request: None,
};
replace_comment_activity(
&mut resource,
vec![ActivityEntry {
id: "new-comment".into(),
kind: ActivityKind::Comment,
author: "carol".into(),
body: "new".into(),
updated_at: "newer".into(),
path: None,
line: None,
url: None,
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
}],
);
assert_eq!(resource.activity.len(), 2);
assert!(resource.activity.iter().any(|entry| entry.id == "review"));
assert!(resource
.activity
.iter()
.any(|entry| entry.id == "new-comment"));
assert!(!resource
.activity
.iter()
.any(|entry| entry.id == "old-comment"));
}
#[test]
fn replace_review_activity_keeps_comments_review_comments_and_timeline() {
fn activity(id: &str, kind: ActivityKind) -> ActivityEntry {
ActivityEntry {
id: id.into(),
kind,
author: "alice".into(),
body: id.into(),
updated_at: "2026-01-01T00:00:00Z".into(),
path: None,
line: None,
url: None,
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: None,
thread_resolved: None,
thread_outdated: None,
}
}
let mut resource = Resource {
id: ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap(),
title: "title".into(),
url: "https://github.com/openclaw/openclaw/pull/81834".into(),
state: "OPEN".into(),
author: "alice".into(),
created_at: "created".into(),
updated_at: "updated".into(),
labels: Vec::new(),
assignees: Vec::new(),
reactions: ReactionCounts::default(),
body: "body".into(),
activity: vec![
activity("comment", ActivityKind::Comment),
activity("old-review", ActivityKind::Review),
activity("review-comment", ActivityKind::ReviewComment),
activity("timeline", ActivityKind::Timeline),
],
related_resources: Vec::new(),
metadata: Vec::new(),
warnings: Vec::new(),
pull_request: None,
};
replace_review_activity(
&mut resource,
vec![activity("new-review", ActivityKind::Review)],
);
assert_eq!(resource.activity.len(), 4);
assert!(resource.activity.iter().any(|entry| entry.id == "comment"));
assert!(resource
.activity
.iter()
.any(|entry| entry.id == "review-comment"));
assert!(resource.activity.iter().any(|entry| entry.id == "timeline"));
assert!(resource
.activity
.iter()
.any(|entry| entry.id == "new-review"));
assert!(!resource
.activity
.iter()
.any(|entry| entry.id == "old-review"));
}
#[test]
fn review_thread_comments_query_requests_comment_pagination_state() {
let query = review_thread_comments_query();
assert!(query.contains("$threadId: ID!"));
assert!(query.contains("node(id: $threadId)"));
assert!(query.contains("comments(first: 100, after: $after)"));
assert!(query.contains("pageInfo"));
assert!(query.contains("hasNextPage"));
assert!(query.contains("endCursor"));
}
#[test]
fn enrichment_warning_records_label_and_error() {
let mut resource = Resource {
id: ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap(),
title: "title".into(),
url: "https://github.com/openclaw/openclaw/pull/81834".into(),
state: "OPEN".into(),
author: "alice".into(),
created_at: "created".into(),
updated_at: "updated".into(),
labels: Vec::new(),
assignees: Vec::new(),
reactions: ReactionCounts::default(),
body: "body".into(),
activity: Vec::new(),
related_resources: Vec::new(),
metadata: Vec::new(),
warnings: Vec::new(),
pull_request: None,
};
let error = anyhow::anyhow!("permission denied");
push_enrichment_warning(&mut resource, "timeline unavailable", &error);
assert_eq!(
resource.warnings,
vec!["timeline unavailable: permission denied"]
);
}
#[test]
fn classifies_cancelled_checks_as_skipped_not_failure() {
assert_eq!(
CheckStatus::from_github(Some("COMPLETED"), Some("CANCELLED")),
CheckStatus::Skipped
);
assert_eq!(
CheckStatus::from_github(Some("COMPLETED"), Some("ACTION_REQUIRED")),
CheckStatus::Pending
);
}
#[test]
fn check_from_dto_preserves_github_metadata() {
let check = check_from_dto(CheckDto {
name: Some("unit".into()),
context: None,
workflow_name: Some("CI".into()),
status: Some("COMPLETED".into()),
state: None,
conclusion: Some("FAILURE".into()),
details_url: Some("https://github.com/openclaw/openclaw/actions/runs/1/job/2".into()),
target_url: None,
started_at: Some("2026-05-30T03:28:54Z".into()),
completed_at: Some("2026-05-30T03:28:56Z".into()),
});
assert_eq!(check.name, "CI/unit");
assert_eq!(check.status, CheckStatus::Failure);
assert_eq!(check.raw_status.as_deref(), Some("COMPLETED"));
assert_eq!(check.raw_conclusion.as_deref(), Some("FAILURE"));
assert_eq!(
check.details_url.as_deref(),
Some("https://github.com/openclaw/openclaw/actions/runs/1/job/2")
);
assert_eq!(check.started_at.as_deref(), Some("2026-05-30T03:28:54Z"));
assert_eq!(check.completed_at.as_deref(), Some("2026-05-30T03:28:56Z"));
}
#[test]
fn check_from_dto_handles_status_context_fields() {
let check = check_from_dto(CheckDto {
name: None,
context: Some("license/cla".into()),
workflow_name: None,
status: None,
state: Some("SUCCESS".into()),
conclusion: None,
details_url: None,
target_url: Some("https://ci.example.test/status/1".into()),
started_at: None,
completed_at: None,
});
assert_eq!(check.name, "license/cla");
assert_eq!(check.status, CheckStatus::Success);
assert_eq!(check.raw_status.as_deref(), Some("SUCCESS"));
assert_eq!(
check.details_url.as_deref(),
Some("https://ci.example.test/status/1")
);
}
#[test]
fn commit_from_dto_preserves_body_dates_and_authors() {
let commit = commit_from_dto(CommitDto {
oid: "abcdef123".into(),
message_headline: "feat: add thing".into(),
message_body: Some("body\n\nCo-Authored-By: Friend <friend@example.com>".into()),
committed_date: Some("2026-05-30T03:18:51Z".into()),
authored_date: Some("2026-05-14T13:10:00Z".into()),
authors: Some(vec![
UserDto {
login: Some("alice".into()),
name: None,
},
UserDto {
login: Some("friend".into()),
name: None,
},
]),
});
assert_eq!(commit.oid, "abcdef123");
assert_eq!(commit.message, "feat: add thing");
assert!(commit.body.contains("Co-Authored-By"));
assert_eq!(commit.author, "alice");
assert_eq!(commit.authors, vec!["alice", "friend"]);
assert_eq!(commit.authored_at.as_deref(), Some("2026-05-14T13:10:00Z"));
assert_eq!(commit.committed_at, "2026-05-30T03:18:51Z");
assert!(commit.deployments.is_empty());
}
#[test]
fn commits_page_preserves_pagination_state_and_author_fallbacks() {
let page = commits_page(CommitsResponse {
data: CommitsData {
repository: Some(CommitsRepository {
pull_request: Some(CommitsPullRequest {
commits: CommitsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("commit-cursor-2".into()),
},
nodes: vec![CommitNodeDto {
commit: CommitWithAuthorsConnectionDto {
oid: "abcdef123".into(),
message_headline: "feat: add thing".into(),
message_body: Some("body".into()),
committed_date: Some("2026-05-30T03:18:51Z".into()),
authored_date: Some("2026-05-14T13:10:00Z".into()),
authors: Some(CommitAuthorsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("author-cursor-2".into()),
},
nodes: vec![
CommitAuthorDto {
name: Some("fallback-name".into()),
user: None,
},
CommitAuthorDto {
name: None,
user: Some(UserDto {
login: Some("alice".into()),
name: None,
}),
},
],
}),
},
}],
},
}),
}),
},
})
.expect("commits page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("commit-cursor-2"));
assert_eq!(page.commits.len(), 1);
assert_eq!(page.commits[0].oid, "abcdef123");
assert_eq!(page.commits[0].body, "body");
assert_eq!(page.commits[0].authors, vec!["fallback-name", "alice"]);
assert_eq!(page.author_continuations.len(), 1);
assert_eq!(page.author_continuations[0].commit_oid, "abcdef123");
assert_eq!(
page.author_continuations[0].after.as_deref(),
Some("author-cursor-2")
);
}
#[test]
fn commits_from_response_returns_empty_for_missing_pr() {
let commits = commits_from_response(CommitsResponse {
data: CommitsData { repository: None },
});
assert!(commits.is_empty());
}
#[test]
fn commit_authors_page_preserves_pagination_state_and_display_names() {
let page = commit_authors_page(CommitAuthorsResponse {
data: CommitAuthorsData {
repository: Some(CommitAuthorsRepository {
object: Some(CommitAuthorsObject {
authors: CommitAuthorsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("author-cursor-3".into()),
},
nodes: vec![
CommitAuthorDto {
name: Some("fallback-name".into()),
user: None,
},
CommitAuthorDto {
name: None,
user: Some(UserDto {
login: Some("alice".into()),
name: None,
}),
},
],
},
}),
}),
},
})
.expect("commit authors page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("author-cursor-3"));
assert_eq!(page.authors, vec!["fallback-name", "alice"]);
let authors = commit_authors_from_response(CommitAuthorsResponse {
data: CommitAuthorsData { repository: None },
});
assert!(authors.is_empty());
}
#[test]
fn commit_deployments_from_response_maps_environment_status_and_urls() {
let response = CommitDeploymentsResponse {
data: CommitDeploymentsData {
repository: Some(CommitDeploymentsRepository {
pull_request: Some(CommitDeploymentsPullRequest {
commits: CommitDeploymentsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![CommitDeploymentNode {
commit: CommitDeploymentCommit {
oid: "abcdef123".into(),
deployments: DeploymentConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![DeploymentDto {
environment: Some("preview".into()),
task: Some("deploy".into()),
description: None,
created_at: Some("2026-05-30T03:20:00Z".into()),
updated_at: Some("2026-05-30T03:21:00Z".into()),
latest_status: Some(DeploymentStatusDto {
state: Some("SUCCESS".into()),
description: Some("Preview deployed".into()),
environment_url: Some(
"https://example.test/preview".into(),
),
log_url: Some("https://example.test/logs".into()),
created_at: Some("2026-05-30T03:21:00Z".into()),
}),
}],
},
},
}],
},
}),
}),
},
};
let deployments = commit_deployments_from_response(response);
assert_eq!(deployments["abcdef123"][0].environment, "preview");
assert_eq!(deployments["abcdef123"][0].state, "SUCCESS");
assert_eq!(
deployments["abcdef123"][0].description.as_deref(),
Some("Preview deployed")
);
assert_eq!(
deployments["abcdef123"][0].environment_url.as_deref(),
Some("https://example.test/preview")
);
assert_eq!(
deployments["abcdef123"][0].log_url.as_deref(),
Some("https://example.test/logs")
);
}
#[test]
fn commit_deployments_page_preserves_pagination_state() {
let page = commit_deployments_page(CommitDeploymentsResponse {
data: CommitDeploymentsData {
repository: Some(CommitDeploymentsRepository {
pull_request: Some(CommitDeploymentsPullRequest {
commits: CommitDeploymentsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("deployment-commit-cursor-2".into()),
},
nodes: vec![CommitDeploymentNode {
commit: CommitDeploymentCommit {
oid: "commit-with-no-deployments".into(),
deployments: DeploymentConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("deployment-item-cursor-2".into()),
},
nodes: Vec::new(),
},
},
}],
},
}),
}),
},
})
.expect("commit deployments page");
assert!(page.has_next_page);
assert_eq!(
page.end_cursor.as_deref(),
Some("deployment-commit-cursor-2")
);
assert!(page.deployments_by_commit["commit-with-no-deployments"].is_empty());
assert_eq!(page.deployment_continuations.len(), 1);
assert_eq!(
page.deployment_continuations[0].commit_oid,
"commit-with-no-deployments"
);
assert_eq!(
page.deployment_continuations[0].after.as_deref(),
Some("deployment-item-cursor-2")
);
let deployments = commit_deployments_from_response(CommitDeploymentsResponse {
data: CommitDeploymentsData { repository: None },
});
assert!(deployments.is_empty());
}
#[test]
fn commit_deployment_items_page_preserves_pagination_state_and_status_mapping() {
let page = commit_deployment_items_page(CommitDeploymentItemsResponse {
data: CommitDeploymentItemsData {
repository: Some(CommitDeploymentItemsRepository {
object: Some(CommitDeploymentItemsObject {
deployments: DeploymentConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("deployment-item-cursor-3".into()),
},
nodes: vec![DeploymentDto {
environment: Some("production".into()),
task: None,
description: Some("queued".into()),
created_at: Some("2026-05-30T03:20:00Z".into()),
updated_at: None,
latest_status: Some(DeploymentStatusDto {
state: Some("IN_PROGRESS".into()),
description: Some("Deploying".into()),
environment_url: Some("https://example.test/prod".into()),
log_url: Some("https://example.test/prod/logs".into()),
created_at: Some("2026-05-30T03:21:00Z".into()),
}),
}],
},
}),
}),
},
})
.expect("commit deployment items page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("deployment-item-cursor-3"));
assert_eq!(page.deployments[0].environment, "production");
assert_eq!(page.deployments[0].state, "IN_PROGRESS");
assert_eq!(
page.deployments[0].description.as_deref(),
Some("Deploying")
);
let deployments = commit_deployment_items_from_response(CommitDeploymentItemsResponse {
data: CommitDeploymentItemsData { repository: None },
});
assert!(deployments.is_empty());
}
#[test]
fn applies_commit_deployments_to_matching_commits() {
let mut commits = vec![Commit {
oid: "abcdef123".into(),
message: "feat: add thing".into(),
body: String::new(),
author: "alice".into(),
authors: vec!["alice".into()],
authored_at: None,
committed_at: "2026-05-30T03:18:51Z".into(),
status: CheckStatus::Unknown,
deployments: Vec::new(),
}];
let mut deployments = HashMap::new();
deployments.insert(
"abcdef123".into(),
vec![Deployment {
environment: "preview".into(),
state: "SUCCESS".into(),
description: None,
environment_url: None,
log_url: None,
created_at: None,
updated_at: "2026-05-30T03:21:00Z".into(),
}],
);
apply_commit_deployments(&mut commits, deployments);
assert_eq!(commits[0].deployments[0].environment, "preview");
}
#[test]
fn replace_pr_commits_keeps_base_commits_when_paginated_list_is_empty() {
let mut pr = PullRequest {
base_ref: "main".into(),
head_ref: "feature".into(),
requested_reviewers: Vec::new(),
review_decision: None,
merge_state: None,
additions: 0,
deletions: 0,
commits: vec![Commit {
oid: "base".into(),
message: "base commit".into(),
body: String::new(),
author: "alice".into(),
authors: vec!["alice".into()],
authored_at: None,
committed_at: "2026-05-30T03:18:51Z".into(),
status: CheckStatus::Unknown,
deployments: Vec::new(),
}],
checks: Vec::new(),
files: Vec::new(),
metadata: Vec::new(),
};
replace_pr_commits(&mut pr, Vec::new());
assert_eq!(pr.commits.len(), 1);
assert_eq!(pr.commits[0].oid, "base");
replace_pr_commits(
&mut pr,
vec![Commit {
oid: "full".into(),
message: "full commit".into(),
body: String::new(),
author: "bob".into(),
authors: vec!["bob".into()],
authored_at: None,
committed_at: "2026-05-30T03:19:51Z".into(),
status: CheckStatus::Unknown,
deployments: Vec::new(),
}],
);
assert_eq!(pr.commits.len(), 1);
assert_eq!(pr.commits[0].oid, "full");
}
#[test]
fn check_suite_from_dto_maps_workflow_status_and_urls() {
let check = check_suite_from_dto(CheckSuiteDto {
status: Some("COMPLETED".into()),
conclusion: Some("ACTION_REQUIRED".into()),
url: Some("https://github.com/openclaw/openclaw/commit/abc/checks".into()),
app: Some(CheckSuiteAppDto {
name: Some("GitHub Actions".into()),
}),
workflow_run: Some(CheckSuiteWorkflowRunDto {
url: Some("https://github.com/openclaw/openclaw/actions/runs/1".into()),
workflow: Some(CheckSuiteWorkflowDto {
name: Some("CI".into()),
}),
}),
});
assert_eq!(check.name, "suite/CI");
assert_eq!(check.status, CheckStatus::Pending);
assert_eq!(check.raw_status.as_deref(), Some("COMPLETED"));
assert_eq!(check.raw_conclusion.as_deref(), Some("ACTION_REQUIRED"));
assert_eq!(
check.details_url.as_deref(),
Some("https://github.com/openclaw/openclaw/actions/runs/1")
);
assert_eq!(
check.summary.as_deref(),
Some("check suite from GitHub Actions")
);
}
#[test]
fn check_suites_from_response_keeps_latest_suite_by_name() {
let response = CheckSuitesResponse {
data: CheckSuitesData {
repository: Some(CheckSuitesRepository {
pull_request: Some(CheckSuitesPullRequest {
commits: CheckSuitesCommitConnection {
nodes: vec![CheckSuitesCommitNode {
commit: CheckSuitesCommit {
check_suites: CheckSuiteConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![
CheckSuiteDto {
status: Some("IN_PROGRESS".into()),
conclusion: None,
url: Some("https://example.test/old".into()),
app: Some(CheckSuiteAppDto {
name: Some("GitHub Actions".into()),
}),
workflow_run: Some(CheckSuiteWorkflowRunDto {
url: Some(
"https://example.test/run-old".into(),
),
workflow: Some(CheckSuiteWorkflowDto {
name: Some("CI".into()),
}),
}),
},
CheckSuiteDto {
status: Some("COMPLETED".into()),
conclusion: Some("SUCCESS".into()),
url: Some("https://example.test/new".into()),
app: Some(CheckSuiteAppDto {
name: Some("GitHub Actions".into()),
}),
workflow_run: Some(CheckSuiteWorkflowRunDto {
url: Some(
"https://example.test/run-new".into(),
),
workflow: Some(CheckSuiteWorkflowDto {
name: Some("CI".into()),
}),
}),
},
],
},
},
}],
},
}),
}),
},
};
let suites = check_suites_from_response(response);
assert_eq!(suites.len(), 1);
assert_eq!(suites[0].name, "suite/CI");
assert_eq!(suites[0].status, CheckStatus::Success);
assert_eq!(
suites[0].details_url.as_deref(),
Some("https://example.test/run-new")
);
}
#[test]
fn check_suites_page_preserves_pagination_state() {
let page = check_suites_page(CheckSuitesResponse {
data: CheckSuitesData {
repository: Some(CheckSuitesRepository {
pull_request: Some(CheckSuitesPullRequest {
commits: CheckSuitesCommitConnection {
nodes: vec![CheckSuitesCommitNode {
commit: CheckSuitesCommit {
check_suites: CheckSuiteConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("suite-cursor-2".into()),
},
nodes: vec![CheckSuiteDto {
status: Some("IN_PROGRESS".into()),
conclusion: None,
url: Some("https://example.test/suite".into()),
app: Some(CheckSuiteAppDto {
name: Some("GitHub Actions".into()),
}),
workflow_run: Some(CheckSuiteWorkflowRunDto {
url: Some("https://example.test/run".into()),
workflow: Some(CheckSuiteWorkflowDto {
name: Some("CI".into()),
}),
}),
}],
},
},
}],
},
}),
}),
},
})
.expect("check suites page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("suite-cursor-2"));
assert_eq!(page.checks.len(), 1);
assert_eq!(page.checks[0].name, "suite/CI");
assert_eq!(page.checks[0].status, CheckStatus::Pending);
}
#[test]
fn status_rollup_page_preserves_pagination_state_and_context_types() {
let page = status_rollup_page(StatusRollupResponse {
data: StatusRollupData {
repository: Some(StatusRollupRepository {
pull_request: Some(StatusRollupPullRequest {
status_check_rollup: Some(StatusCheckRollupDto {
contexts: StatusContextConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("status-cursor-2".into()),
},
nodes: vec![
json!({
"__typename": "CheckRun",
"name": "unit",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"detailsUrl": "https://example.test/check",
"startedAt": "2026-05-30T03:18:51Z",
"completedAt": "2026-05-30T03:19:51Z",
"checkSuite": {
"workflowRun": {
"workflow": {"name": "CI"}
}
}
}),
json!({
"__typename": "StatusContext",
"context": "license/cla",
"state": "PENDING",
"targetUrl": "https://example.test/status"
}),
],
},
}),
}),
}),
},
})
.expect("status rollup page result")
.expect("status rollup page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("status-cursor-2"));
assert_eq!(page.checks.len(), 2);
assert_eq!(page.checks[0].name, "CI/unit");
assert_eq!(page.checks[0].status, CheckStatus::Success);
assert_eq!(
page.checks[0].details_url.as_deref(),
Some("https://example.test/check")
);
assert_eq!(page.checks[1].name, "license/cla");
assert_eq!(page.checks[1].status, CheckStatus::Pending);
assert_eq!(
page.checks[1].details_url.as_deref(),
Some("https://example.test/status")
);
}
#[test]
fn status_rollup_checks_from_response_handles_null_rollup() {
let checks = status_rollup_checks_from_response(StatusRollupResponse {
data: StatusRollupData {
repository: Some(StatusRollupRepository {
pull_request: Some(StatusRollupPullRequest {
status_check_rollup: None,
}),
}),
},
});
assert!(checks.is_empty());
}
#[test]
fn apply_check_suites_dedupes_existing_names() {
let mut checks = vec![CheckRun {
name: "suite/CI".into(),
status: CheckStatus::Success,
summary: None,
details_url: None,
started_at: None,
completed_at: None,
raw_status: None,
raw_conclusion: None,
}];
let suites = vec![
CheckRun {
name: "suite/CI".into(),
status: CheckStatus::Pending,
summary: None,
details_url: None,
started_at: None,
completed_at: None,
raw_status: Some("IN_PROGRESS".into()),
raw_conclusion: None,
},
CheckRun {
name: "suite/CodeQL".into(),
status: CheckStatus::Success,
summary: None,
details_url: None,
started_at: None,
completed_at: None,
raw_status: Some("COMPLETED".into()),
raw_conclusion: Some("SUCCESS".into()),
},
];
apply_check_suites(&mut checks, suites);
assert_eq!(checks.len(), 2);
assert_eq!(checks[0].name, "suite/CodeQL");
assert_eq!(checks[1].name, "suite/CI");
assert_eq!(checks[1].status, CheckStatus::Success);
}
#[test]
fn maps_reactions_by_content_name() {
let counts = reaction_counts(vec![
ReactionGroupDto {
content: "THUMBS_UP".into(),
users: TotalCountDto { total_count: 3 },
},
ReactionGroupDto {
content: "EYES".into(),
users: TotalCountDto { total_count: 1 },
},
]);
assert_eq!(counts.thumbs_up, 3);
assert_eq!(counts.eyes, 1);
assert_eq!(counts.total(), 4);
}
#[test]
fn maps_people_and_review_requests_to_display_names() {
assert_eq!(
names_from_users(vec![UserDto {
login: Some("assignee".into()),
name: None,
}]),
vec!["assignee"]
);
assert_eq!(
review_request_names(vec![
serde_json::json!({"requestedReviewer": {"login": "reviewer"}}),
serde_json::json!({"slug": "docs-team"}),
]),
vec!["reviewer", "docs-team"]
);
}
#[test]
fn pr_view_preserves_extra_github_metadata() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let dto: PrView = serde_json::from_str(
r#"{
"number": 81834,
"title": "metadata test",
"url": "https://github.com/openclaw/openclaw/pull/81834",
"state": "OPEN",
"author": {"login": "alice"},
"createdAt": "created",
"updatedAt": "updated",
"labels": [],
"assignees": [],
"reactionGroups": [],
"body": "body",
"baseRefName": "main",
"headRefName": "branch",
"baseRefOid": "base-sha",
"headRefOid": "head-sha",
"headRepository": {"name": "fork"},
"headRepositoryOwner": {"login": "alice"},
"reviewDecision": null,
"reviewRequests": [],
"closingIssuesReferences": [],
"mergeStateStatus": "CLEAN",
"mergeable": "MERGEABLE",
"isDraft": false,
"isCrossRepository": true,
"maintainerCanModify": true,
"locked": true,
"activeLockReason": "RESOLVED",
"isInMergeQueue": true,
"isMergeQueueEnabled": true,
"canBeRebased": true,
"totalCommentsCount": 42,
"changedFiles": 14,
"closed": false,
"closedAt": null,
"mergedAt": null,
"mergedBy": null,
"milestone": {"title": "v1"},
"projectItems": [{"project": {"title": "Roadmap"}}],
"autoMergeRequest": null,
"mergeCommit": null,
"potentialMergeCommit": {"oid": "merge-sha"},
"additions": 1,
"deletions": 2,
"commits": [],
"statusCheckRollup": [],
"files": [],
"comments": [],
"reviews": []
}"#,
)
.unwrap();
let resource = dto.into_resource(&requested);
let pr = resource.pull_request.as_ref().unwrap();
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Cross repository" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Locked" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Lock reason" && item.value == "RESOLVED"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "In merge queue" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Merge queue enabled" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Can be rebased" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Total comments" && item.value == "42"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Changed files" && item.value == "14"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Milestone" && item.value == "v1"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Projects" && item.value == "Roadmap"));
assert!(pr
.metadata
.iter()
.any(|item| item.label == "Head repository owner" && item.value == "alice"));
assert!(pr
.metadata
.iter()
.any(|item| item.label == "Potential merge commit" && item.value == "merge-sha"));
}
#[test]
fn issue_view_preserves_extra_github_metadata() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let dto: IssueView = serde_json::from_str(
r#"{
"number": 88499,
"title": "issue metadata test",
"url": "https://github.com/openclaw/openclaw/issues/88499",
"state": "CLOSED",
"author": {"login": "alice"},
"createdAt": "created",
"updatedAt": "updated",
"labels": [],
"assignees": [],
"reactionGroups": [],
"body": "body",
"closed": true,
"isPinned": true,
"locked": true,
"activeLockReason": "RESOLVED",
"issueType": {"name": "Task"},
"stateReason": "COMPLETED",
"closedAt": "closed",
"lastEditedAt": "edited",
"createdViaEmail": true,
"trackedIssuesCount": 5,
"openTrackedIssuesCount": 3,
"closedTrackedIssuesCount": 2,
"issueDependenciesSummary": {
"blockedBy": 1,
"blocking": 2,
"totalBlockedBy": 4,
"totalBlocking": 6
},
"subIssuesSummary": {
"total": 8,
"completed": 5,
"percentCompleted": 62
},
"milestone": {"title": "v2"},
"projectItems": [{"project": {"title": "Triage"}}],
"closedByPullRequestsReferences": [],
"comments": []
}"#,
)
.unwrap();
let resource = dto.into_resource(&requested);
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Closed" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Pinned" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Locked" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Lock reason" && item.value == "RESOLVED"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Issue type" && item.value == "Task"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "State reason" && item.value == "COMPLETED"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Last edited at" && item.value == "edited"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Created via email" && item.value == "yes"));
assert!(resource.metadata.iter().any(
|item| item.label == "Tracked issues" && item.value == "5 total, 3 open, 2 closed"
));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Blocked by count" && item.value == "4 total, 1 open"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Blocking count" && item.value == "6 total, 2 open"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Sub-issue summary"
&& item.value == "8 total, 5 completed, 62%"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Projects" && item.value == "Triage"));
}
#[test]
fn parses_unified_diff_patches_by_file_path() {
let patches = parse_unified_diff_patches(
"From abc\n\
diff --git a/src/one.rs b/src/one.rs\n\
index 111..222 100644\n\
--- a/src/one.rs\n\
+++ b/src/one.rs\n\
@@ -1 +1 @@\n\
-old\n\
+new\n\
diff --git a/docs/two.md b/docs/two.md\n\
@@ -2 +2 @@\n\
+line\n",
);
assert!(patches["src/one.rs"].contains("+new"));
assert!(patches["docs/two.md"].contains("+line"));
}
#[test]
fn applies_file_patches_to_matching_changed_files() {
let mut files = vec![ChangedFile {
path: "src/one.rs".into(),
additions: 1,
deletions: 1,
change_type: "MODIFIED".into(),
patch: None,
}];
let patches = HashMap::from([("src/one.rs".to_string(), "patch body".to_string())]);
apply_file_patches(&mut files, patches);
assert_eq!(files[0].patch.as_deref(), Some("patch body"));
}
#[test]
fn related_resource_ids_parse_urls_and_number_fallbacks() {
let requested = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let related = related_resource_ids(
vec![
RelatedResourceDto {
number: None,
url: Some("https://github.com/other/repo/pull/12".into()),
},
RelatedResourceDto {
number: Some(34),
url: None,
},
],
ResourceKind::PullRequest,
&requested,
);
assert_eq!(related.len(), 2);
assert_eq!(related[0].canonical_name(), "other/repo#12");
assert_eq!(related[0].kind_hint, Some(ResourceKind::PullRequest));
assert_eq!(related[1].canonical_name(), "openclaw/openclaw#34");
assert_eq!(related[1].kind_hint, Some(ResourceKind::PullRequest));
}
#[test]
fn pr_activity_includes_reviews_with_state() {
let activity = pr_activity(
vec![CommentDto {
id: Some("comment".into()),
author: Some(UserDto {
login: Some("alice".into()),
name: None,
}),
author_association: Some("MEMBER".into()),
body: "plain comment".into(),
created_at: Some("2026-01-01T00:00:00Z".into()),
updated_at: None,
url: Some("https://github.com/openclaw/openclaw/pull/81834#issuecomment-1".into()),
includes_created_edit: Some(true),
is_minimized: Some(false),
minimized_reason: None,
reaction_groups: vec![ReactionGroupDto {
content: "THUMBS_UP".into(),
users: TotalCountDto { total_count: 2 },
}],
}],
vec![ReviewDto {
id: Some("review".into()),
author: Some(UserDto {
login: Some("bob".into()),
name: None,
}),
author_association: Some("CONTRIBUTOR".into()),
body: Some("looks good".into()),
state: Some("APPROVED".into()),
submitted_at: Some("2026-01-02T00:00:00Z".into()),
updated_at: None,
url: Some(
"https://github.com/openclaw/openclaw/pull/81834#pullrequestreview-1".into(),
),
reaction_groups: Vec::new(),
}],
);
assert_eq!(activity.len(), 2);
assert_eq!(activity[0].kind, ActivityKind::Comment);
assert_eq!(activity[0].author_association.as_deref(), Some("MEMBER"));
assert_eq!(activity[0].reactions.thumbs_up, 2);
assert!(activity[0].includes_created_edit);
assert_eq!(activity[1].kind, ActivityKind::Review);
assert_eq!(activity[1].body, "APPROVED: looks good");
assert_eq!(
activity[1].url.as_deref(),
Some("https://github.com/openclaw/openclaw/pull/81834#pullrequestreview-1")
);
}
#[test]
fn review_thread_activity_keeps_path_and_line() {
let activity = review_thread_activity(ReviewThreadsResponse {
data: ReviewThreadsData {
repository: Some(ReviewThreadsRepository {
pull_request: Some(ReviewThreadsPullRequest {
review_threads: ReviewThreadsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![ReviewThreadDto {
id: Some("thread-1".into()),
is_resolved: Some(false),
is_outdated: Some(true),
path: Some("src/lib.rs".into()),
line: Some(42),
comments: ReviewThreadCommentsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![ReviewThreadCommentDto {
id: Some("review-comment".into()),
author: Some(UserDto {
login: Some("reviewer".into()),
name: None,
}),
author_association: Some("MEMBER".into()),
body: "Please split this branch.".into(),
created_at: Some("2026-01-01T00:00:00Z".into()),
updated_at: Some("2026-01-02T00:00:00Z".into()),
url: Some("https://github.com/openclaw/openclaw/pull/81834#discussion_r1".into()),
includes_created_edit: Some(false),
is_minimized: Some(true),
minimized_reason: Some("resolved".into()),
reaction_groups: vec![ReactionGroupDto {
content: "EYES".into(),
users: TotalCountDto { total_count: 1 },
}],
path: None,
line: None,
}],
},
}],
},
}),
}),
},
});
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].kind, ActivityKind::ReviewComment);
assert_eq!(activity[0].path.as_deref(), Some("src/lib.rs"));
assert_eq!(activity[0].line, Some(42));
assert_eq!(activity[0].thread_id.as_deref(), Some("thread-1"));
assert_eq!(activity[0].thread_resolved, Some(false));
assert_eq!(activity[0].thread_outdated, Some(true));
assert_eq!(activity[0].author_association.as_deref(), Some("MEMBER"));
assert_eq!(activity[0].reactions.eyes, 1);
assert!(activity[0].is_minimized);
assert_eq!(activity[0].minimized_reason.as_deref(), Some("resolved"));
assert_eq!(activity[0].author, "reviewer");
}
#[test]
fn review_thread_activity_page_preserves_pagination_state() {
let page = review_thread_activity_page(ReviewThreadsResponse {
data: ReviewThreadsData {
repository: Some(ReviewThreadsRepository {
pull_request: Some(ReviewThreadsPullRequest {
review_threads: ReviewThreadsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("cursor-2".into()),
},
nodes: vec![ReviewThreadDto {
id: Some("thread-2".into()),
is_resolved: Some(true),
is_outdated: Some(false),
path: Some("src/main.rs".into()),
line: Some(7),
comments: ReviewThreadCommentsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![ReviewThreadCommentDto {
id: Some("review-comment-2".into()),
author: Some(UserDto {
login: Some("maintainer".into()),
name: None,
}),
author_association: Some("MEMBER".into()),
body: "Follow-up.".into(),
created_at: Some("2026-01-03T00:00:00Z".into()),
updated_at: Some("2026-01-04T00:00:00Z".into()),
url: None,
includes_created_edit: Some(false),
is_minimized: Some(false),
minimized_reason: None,
reaction_groups: Vec::new(),
path: Some("src/main.rs".into()),
line: Some(8),
}],
},
}],
},
}),
}),
},
})
.expect("review thread page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("cursor-2"));
assert_eq!(page.activity.len(), 1);
assert_eq!(page.activity[0].thread_id.as_deref(), Some("thread-2"));
assert_eq!(page.activity[0].thread_resolved, Some(true));
assert_eq!(page.activity[0].path.as_deref(), Some("src/main.rs"));
assert_eq!(page.activity[0].line, Some(8));
}
#[test]
fn review_thread_comments_page_preserves_pagination_state() {
let page = review_thread_comments_page(ReviewThreadCommentsResponse {
data: ReviewThreadCommentsData {
node: Some(ReviewThreadCommentsNode {
comments: ReviewThreadCommentsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("comment-cursor-2".into()),
},
nodes: vec![ReviewThreadCommentDto {
id: Some("review-comment-3".into()),
author: Some(UserDto {
login: Some("reviewer".into()),
name: None,
}),
author_association: Some("MEMBER".into()),
body: "Another follow-up.".into(),
created_at: Some("2026-01-05T00:00:00Z".into()),
updated_at: Some("2026-01-06T00:00:00Z".into()),
url: None,
includes_created_edit: Some(false),
is_minimized: Some(false),
minimized_reason: None,
reaction_groups: Vec::new(),
path: Some("src/lib.rs".into()),
line: Some(43),
}],
},
}),
},
})
.expect("review thread comments page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("comment-cursor-2"));
assert_eq!(page.nodes.len(), 1);
assert_eq!(page.nodes[0].id.as_deref(), Some("review-comment-3"));
}
#[test]
fn commit_comment_thread_comments_page_preserves_pagination_state() {
let page = commit_comment_thread_comments_page(CommitCommentThreadCommentsResponse {
data: CommitCommentThreadCommentsData {
node: Some(CommitCommentThreadCommentsNode {
comments: CommitCommentThreadCommentsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("commit-comment-cursor-2".into()),
},
nodes: vec![serde_json::json!({
"id": "commit-comment-2",
"author": {"login": "reviewer"},
"body": "Follow-up on the commit.",
"createdAt": "2026-01-05T00:00:00Z",
"updatedAt": "2026-01-06T00:00:00Z",
"url": "https://github.com/openclaw/openclaw/pull/81834#commitcomment-2",
"path": "src/lib.rs",
"position": 9
})],
},
}),
},
})
.expect("commit comment thread comments page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("commit-comment-cursor-2"));
assert_eq!(page.nodes.len(), 1);
assert_eq!(string_field(&page.nodes[0], "id"), Some("commit-comment-2"));
}
#[test]
fn timeline_activity_maps_commit_comment_threads() {
let activity = timeline_activity(TimelineResponse {
data: TimelineData {
repository: Some(TimelineRepository {
issue: None,
pull_request: Some(TimelineResource {
timeline_items: TimelineItemsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![serde_json::json!({
"__typename": "PullRequestCommitCommentThread",
"id": "commit-thread-1",
"path": "src/lib.rs",
"position": 42,
"commit": {"oid": "abcdef1234567890"},
"comments": {
"pageInfo": {
"hasNextPage": false,
"endCursor": null
},
"nodes": [{
"id": "commit-comment-1",
"author": {"login": "reviewer"},
"authorAssociation": "MEMBER",
"body": "Please explain this commit.",
"createdAt": "2026-01-05T00:00:00Z",
"updatedAt": "2026-01-06T00:00:00Z",
"url": "https://github.com/openclaw/openclaw/pull/81834#commitcomment-1",
"includesCreatedEdit": true,
"isMinimized": true,
"minimizedReason": "outdated",
"reactionGroups": [{
"content": "EYES",
"users": {"totalCount": 3}
}],
"path": null,
"position": 43
}]
}
})],
},
}),
}),
},
});
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].kind, ActivityKind::CommitComment);
assert_eq!(activity[0].author, "reviewer");
assert_eq!(
activity[0].body,
"on commit abcdef123456\nPlease explain this commit."
);
assert_eq!(activity[0].path.as_deref(), Some("src/lib.rs"));
assert_eq!(activity[0].line, Some(43));
assert_eq!(activity[0].thread_id.as_deref(), Some("commit-thread-1"));
assert_eq!(activity[0].author_association.as_deref(), Some("MEMBER"));
assert_eq!(activity[0].reactions.eyes, 3);
assert!(activity[0].includes_created_edit);
assert!(activity[0].is_minimized);
assert_eq!(activity[0].minimized_reason.as_deref(), Some("outdated"));
assert_eq!(
activity[0].url.as_deref(),
Some("https://github.com/openclaw/openclaw/pull/81834#commitcomment-1")
);
}
#[test]
fn timeline_activity_maps_github_events() {
let activity = timeline_activity(TimelineResponse {
data: TimelineData {
repository: Some(TimelineRepository {
issue: Some(TimelineResource {
timeline_items: TimelineItemsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![
serde_json::json!({
"__typename": "LabeledEvent",
"id": "label-event",
"createdAt": "2026-05-31T02:28:11Z",
"actor": {"login": "clawsweeper"},
"label": {"name": "P2"}
}),
serde_json::json!({
"__typename": "CrossReferencedEvent",
"id": "cross-ref",
"createdAt": "2026-05-31T07:01:12Z",
"actor": {"login": "alice"},
"source": {
"__typename": "Issue",
"number": 88538,
"title": "related issue",
"url": "https://github.com/openclaw/openclaw/issues/88538",
"repository": {"nameWithOwner": "openclaw/openclaw"}
}
}),
serde_json::json!({
"__typename": "ReviewRequestedEvent",
"id": "review-requested",
"createdAt": "2026-05-31T07:02:12Z",
"actor": {"login": "alice"},
"requestedReviewer": {"__typename": "User", "login": "maintainer"}
}),
serde_json::json!({
"__typename": "ReadyForReviewEvent",
"id": "ready",
"createdAt": "2026-05-31T07:03:12Z",
"actor": {"login": "alice"}
}),
serde_json::json!({
"__typename": "MergedEvent",
"id": "merged",
"createdAt": "2026-05-31T07:04:12Z",
"actor": {"login": "alice"},
"mergeRefName": "main",
"commit": {"oid": "abcdef1234567890"}
}),
serde_json::json!({
"__typename": "LockedEvent",
"id": "locked",
"createdAt": "2026-05-31T07:05:12Z",
"actor": {"login": "alice"},
"lockReason": "RESOLVED"
}),
serde_json::json!({
"__typename": "MarkedAsDuplicateEvent",
"id": "duplicate",
"createdAt": "2026-05-31T07:06:12Z",
"actor": {"login": "alice"},
"canonical": {
"__typename": "Issue",
"number": 88499,
"title": "canonical issue",
"url": "https://github.com/openclaw/openclaw/issues/88499",
"repository": {"nameWithOwner": "openclaw/openclaw"}
}
}),
serde_json::json!({
"__typename": "ConnectedEvent",
"id": "connected",
"createdAt": "2026-05-31T07:07:12Z",
"actor": {"login": "alice"},
"source": {
"__typename": "Issue",
"number": 88499,
"title": "source issue",
"url": "https://github.com/openclaw/openclaw/issues/88499",
"repository": {"nameWithOwner": "openclaw/openclaw"}
},
"subject": {
"__typename": "PullRequest",
"number": 81834,
"title": "subject pr",
"url": "https://github.com/openclaw/openclaw/pull/81834",
"repository": {"nameWithOwner": "openclaw/openclaw"}
}
}),
serde_json::json!({
"__typename": "BaseRefChangedEvent",
"id": "base-changed",
"createdAt": "2026-05-31T07:08:12Z",
"actor": {"login": "alice"},
"previousRefName": "develop",
"currentRefName": "main"
}),
serde_json::json!({
"__typename": "HeadRefForcePushedEvent",
"id": "head-force",
"createdAt": "2026-05-31T07:09:12Z",
"actor": {"login": "alice"},
"ref": {"name": "feature"},
"beforeCommit": {"oid": "111111111111aaaa"},
"afterCommit": {"oid": "222222222222bbbb"}
}),
serde_json::json!({
"__typename": "ReviewDismissedEvent",
"id": "review-dismissed",
"createdAt": "2026-05-31T07:10:12Z",
"actor": {"login": "alice"},
"previousReviewState": "APPROVED",
"dismissalMessage": "new commits",
"url": "https://github.com/openclaw/openclaw/pull/81834#event-1"
}),
serde_json::json!({
"__typename": "AddedToMergeQueueEvent",
"id": "merge-queue",
"createdAt": "2026-05-31T07:11:12Z",
"actor": {"login": "alice"},
"enqueuer": {"login": "maintainer"}
}),
serde_json::json!({
"__typename": "IssueTypeChangedEvent",
"id": "type-changed",
"createdAt": "2026-05-31T07:12:12Z",
"actor": {"login": "alice"},
"prevIssueType": {"name": "Bug"},
"issueType": {"name": "Task"}
}),
serde_json::json!({
"__typename": "SubIssueAddedEvent",
"id": "sub-issue",
"createdAt": "2026-05-31T07:13:12Z",
"actor": {"login": "alice"},
"subIssue": {
"number": 88538,
"title": "child issue",
"url": "https://github.com/openclaw/openclaw/issues/88538",
"repository": {"nameWithOwner": "openclaw/openclaw"}
}
}),
serde_json::json!({
"__typename": "BlockedByAddedEvent",
"id": "blocked-by",
"createdAt": "2026-05-31T07:14:12Z",
"actor": {"login": "alice"},
"blockingIssue": {
"number": 88499,
"title": "blocking issue",
"url": "https://github.com/openclaw/openclaw/issues/88499",
"repository": {"nameWithOwner": "openclaw/openclaw"}
}
}),
serde_json::json!({
"__typename": "ConvertedToDiscussionEvent",
"id": "converted-discussion",
"createdAt": "2026-05-31T07:15:12Z",
"actor": {"login": "alice"},
"discussion": {
"title": "design discussion",
"url": "https://github.com/orgs/openclaw/discussions/12"
}
}),
serde_json::json!({
"__typename": "IssueCommentPinnedEvent",
"id": "pinned-comment",
"createdAt": "2026-05-31T07:16:12Z",
"actor": {"login": "alice"},
"issueComment": {
"url": "https://github.com/openclaw/openclaw/issues/88499#issuecomment-2"
}
}),
serde_json::json!({
"__typename": "PullRequestRevisionMarker",
"createdAt": "2026-05-31T07:17:12Z",
"lastSeenCommit": {"oid": "fedcba9876543210"}
}),
],
},
}),
pull_request: None,
}),
},
});
assert_eq!(activity.len(), 18);
assert_eq!(activity[0].kind, ActivityKind::Timeline);
assert_eq!(activity[0].author, "clawsweeper");
assert_eq!(activity[0].body, "added label P2");
assert_eq!(activity[1].author, "alice");
assert!(activity[1]
.body
.contains("cross-referenced by openclaw/openclaw#88538"));
assert_eq!(
activity[1].url.as_deref(),
Some("https://github.com/openclaw/openclaw/issues/88538")
);
assert_eq!(activity[2].body, "requested review from maintainer");
assert_eq!(activity[3].body, "marked ready for review");
assert_eq!(activity[4].body, "merged into main at abcdef123456");
assert_eq!(activity[5].body, "locked: Resolved");
assert_eq!(
activity[6].body,
"marked duplicate of openclaw/openclaw#88499"
);
assert_eq!(
activity[6].url.as_deref(),
Some("https://github.com/openclaw/openclaw/issues/88499")
);
assert_eq!(
activity[7].body,
"connected openclaw/openclaw#88499 to openclaw/openclaw#81834"
);
assert_eq!(activity[8].body, "changed base from develop to main");
assert_eq!(
activity[9].body,
"force-pushed head feature from 111111111111 to 222222222222"
);
assert_eq!(activity[10].body, "dismissed Approved review: new commits");
assert_eq!(
activity[10].url.as_deref(),
Some("https://github.com/openclaw/openclaw/pull/81834#event-1")
);
assert_eq!(activity[11].body, "added to merge queue by maintainer");
assert_eq!(activity[12].body, "changed issue type from Bug to Task");
assert_eq!(activity[13].body, "added sub-issue openclaw/openclaw#88538");
assert_eq!(
activity[13].url.as_deref(),
Some("https://github.com/openclaw/openclaw/issues/88538")
);
assert_eq!(activity[14].body, "blocked by openclaw/openclaw#88499");
assert_eq!(
activity[15].body,
"converted to discussion design discussion"
);
assert_eq!(
activity[15].url.as_deref(),
Some("https://github.com/orgs/openclaw/discussions/12")
);
assert_eq!(activity[16].body, "pinned a comment");
assert_eq!(
activity[16].url.as_deref(),
Some("https://github.com/openclaw/openclaw/issues/88499#issuecomment-2")
);
assert_eq!(activity[17].body, "revision marker at fedcba987654");
assert_eq!(activity[17].updated_at, "2026-05-31T07:17:12Z");
}
#[test]
fn timeline_activity_maps_schema_coverage_events() {
let activity = timeline_activity(TimelineResponse {
data: TimelineData {
repository: Some(TimelineRepository {
issue: Some(TimelineResource {
timeline_items: TimelineItemsConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![
serde_json::json!({
"__typename": "AddedToProjectV2Event",
"createdAt": "2026-05-31T08:00:00Z",
"actor": {"login": "alice"},
"wasAutomated": true
}),
serde_json::json!({
"__typename": "ProjectV2ItemStatusChangedEvent",
"createdAt": "2026-05-31T08:01:00Z",
"actor": {"login": "alice"},
"previousStatus": "TODO",
"status": "IN_PROGRESS",
"wasAutomated": false
}),
serde_json::json!({
"__typename": "ConvertedNoteToIssueEvent",
"createdAt": "2026-05-31T08:02:00Z",
"actor": {"login": "alice"},
"projectColumnName": "Backlog"
}),
serde_json::json!({
"__typename": "UserBlockedEvent",
"id": "user-blocked",
"createdAt": "2026-05-31T08:03:00Z",
"actor": {"login": "owner"},
"subject": {"login": "spammer"},
"blockDuration": "ONE_DAY"
}),
serde_json::json!({
"__typename": "IssueFieldAddedEvent",
"createdAt": "2026-05-31T08:04:00Z",
"actor": {"login": "alice"},
"value": "High",
"issueField": {"__typename": "IssueFieldSingleSelect", "name": "Priority"}
}),
serde_json::json!({
"__typename": "IssueFieldChangedEvent",
"createdAt": "2026-05-31T08:05:00Z",
"actor": {"login": "alice"},
"previousValue": "High",
"newValue": "Urgent",
"issueField": {"__typename": "IssueFieldSingleSelect", "name": "Priority"}
}),
serde_json::json!({
"__typename": "DeployedEvent",
"id": "deployed",
"createdAt": "2026-05-31T08:06:00Z",
"actor": {"login": "deploy-bot"},
"ref": {"name": "main"},
"deployment": {
"environment": "production",
"latestEnvironment": "production",
"state": "SUCCESS",
"latestStatus": {
"state": "SUCCESS",
"environmentUrl": "https://example.com/app",
"logUrl": "https://example.com/logs"
}
}
}),
serde_json::json!({
"__typename": "DeploymentEnvironmentChangedEvent",
"id": "deployment-env",
"createdAt": "2026-05-31T08:07:00Z",
"actor": {"login": "deploy-bot"},
"deploymentStatus": {
"state": "INACTIVE",
"environment": "staging",
"environmentUrl": "https://example.com/staging",
"deployment": {
"environment": "staging",
"latestEnvironment": "staging"
}
}
}),
],
},
}),
pull_request: None,
}),
},
});
assert_eq!(activity.len(), 8);
assert!(activity[0]
.id
.starts_with("timeline-addedtoprojectv2event-2026-05-31t08-00-00z-alice"));
assert!(activity[1]
.id
.starts_with("timeline-projectv2itemstatuschangedevent-2026-05-31t08-01-00z"));
assert!(activity[2]
.id
.starts_with("timeline-convertednotetoissueevent-2026-05-31t08-02-00z"));
assert_eq!(activity[0].body, "added to project automatically");
assert_eq!(
activity[1].body,
"changed project status from Todo to In Progress"
);
assert_eq!(
activity[2].body,
"converted project note to issue from Backlog"
);
assert_eq!(activity[3].body, "blocked spammer for One Day");
assert_eq!(activity[4].body, "added issue field Priority = High");
assert_eq!(
activity[5].body,
"changed issue field Priority from High to Urgent"
);
assert_eq!(activity[6].body, "deployed main to production: Success");
assert_eq!(activity[6].url.as_deref(), Some("https://example.com/app"));
assert_eq!(
activity[7].body,
"deployment environment changed to staging: Inactive"
);
assert_eq!(
activity[7].url.as_deref(),
Some("https://example.com/staging")
);
}
#[test]
fn timeline_activity_page_preserves_pagination_state() {
let page = timeline_activity_page(TimelineResponse {
data: TimelineData {
repository: Some(TimelineRepository {
issue: Some(TimelineResource {
timeline_items: TimelineItemsConnection {
page_info: PageInfoDto {
has_next_page: true,
end_cursor: Some("cursor-2".into()),
},
nodes: vec![serde_json::json!({
"__typename": "PinnedEvent",
"id": "pinned",
"createdAt": "2026-05-31T07:08:12Z",
"actor": {"login": "alice"}
})],
},
}),
pull_request: None,
}),
},
})
.expect("timeline page");
assert!(page.has_next_page);
assert_eq!(page.end_cursor.as_deref(), Some("cursor-2"));
assert_eq!(page.activity.len(), 1);
assert_eq!(page.activity[0].body, "pinned");
}
#[test]
fn changed_files_from_graphql_keep_change_type() {
let files = changed_files_from_response(ChangedFilesResponse {
data: ChangedFilesData {
repository: Some(ChangedFilesRepository {
pull_request: Some(ChangedFilesPullRequest {
files: ChangedFilesConnection {
page_info: PageInfoDto {
has_next_page: false,
end_cursor: None,
},
nodes: vec![
FileDto {
path: "src/lib.rs".into(),
additions: 10,
deletions: 2,
change_type: Some("MODIFIED".into()),
},
FileDto {
path: "src/new.rs".into(),
additions: 7,
deletions: 0,
change_type: Some("ADDED".into()),
},
],
},
}),
}),
},
});
assert_eq!(files.len(), 2);
assert_eq!(files[0].change_type, "MODIFIED");
assert_eq!(files[1].change_type, "ADDED");
}
}