use anyhow::Context;
use serde::Deserialize;
use serde_json::Value;
use crate::{
domain::{
ActivityEntry, ActivityKind, ChangedFile, CheckRun, CheckStatus, Commit, Deployment,
MetadataItem, PullRequest, ReactionCounts, Resource, ResourceId, ResourceKind,
},
github::transport::{
run_rest_get_with, GithubHttpTransport, ReqwestGithubHttpTransport, GITHUB_JSON_ACCEPT,
},
};
const REST_PAGE_SIZE: usize = 100;
pub(super) async fn fetch_public_rest_pr(
id: &ResourceId,
auth_error: anyhow::Error,
) -> anyhow::Result<Resource> {
let pull: RestPullDto = run_public_rest_json(&rest_pull_path(id))
.await
.context("public REST fallback could not load pull request")?;
let issue: RestIssueDto = run_public_rest_json(&rest_issue_path(id))
.await
.context("public REST fallback could not load pull request issue metadata")?;
let mut warnings = vec![public_rest_fallback_warning(&auth_error)];
let comments = match fetch_public_rest_comments(id).await {
Ok(comments) => comments,
Err(error) => {
warnings.push(format!("public comments unavailable: {error}"));
Vec::new()
}
};
let reviews = match fetch_public_rest_reviews(id).await {
Ok(reviews) => reviews,
Err(error) => {
warnings.push(format!("public reviews unavailable: {error}"));
Vec::new()
}
};
let review_comments = match fetch_public_rest_review_comments(id).await {
Ok(review_comments) => review_comments,
Err(error) => {
warnings.push(format!("public review comments unavailable: {error}"));
Vec::new()
}
};
let timeline = match fetch_public_rest_timeline_events(id).await {
Ok(timeline) => timeline,
Err(error) => {
warnings.push(format!("public timeline events unavailable: {error}"));
Vec::new()
}
};
let mut commits = match fetch_public_rest_commits(id).await {
Ok(commits) => commits,
Err(error) => {
warnings.push(format!("public commit list unavailable: {error}"));
Vec::new()
}
};
let files = match fetch_public_rest_files(id).await {
Ok(files) => files,
Err(error) => {
warnings.push(format!("public file list unavailable: {error}"));
Vec::new()
}
};
let head_sha = pull
.head
.as_ref()
.and_then(|reference| reference.sha.clone())
.or_else(|| commits.first().map(|commit| commit.oid.clone()));
let checks = match head_sha.as_deref() {
Some(sha) => fetch_public_rest_checks(id, sha, &mut warnings).await,
None => {
warnings.push("public check status unavailable: pull request head SHA missing".into());
Vec::new()
}
};
if let Some(sha) = head_sha.as_deref() {
match fetch_public_rest_head_deployments(id, sha).await {
Ok(head_deployments) => {
warnings.extend(head_deployments.warnings);
apply_public_rest_head_deployments(&mut commits, sha, head_deployments.deployments);
}
Err(error) => warnings.push(format!("public head deployments unavailable: {error}")),
}
}
let mut activity = comments;
activity.extend(reviews);
activity.extend(review_comments);
activity.extend(timeline);
sort_activity(&mut activity);
let pull_request = rest_pull_request(&pull, commits, checks, files);
let mut resource = rest_issue_resource(issue, id, activity);
resource.id.kind_hint = Some(ResourceKind::PullRequest);
resource.url = pull.html_url;
resource.title = pull.title;
resource.state = pull.state.to_ascii_uppercase();
resource.author = display_rest_author(pull.user);
resource.created_at = pull.created_at;
resource.updated_at = pull.updated_at;
resource.pull_request = Some(pull_request);
resource.warnings.extend(warnings);
resource.warnings.push(
"public REST fallback omits GraphQL-only enrichment such as review-thread resolution state, projects, participants, relationship links, and richer check-suite workflow names when REST does not expose them".into(),
);
Ok(resource)
}
pub(super) async fn fetch_public_rest_issue(
id: &ResourceId,
auth_error: anyhow::Error,
) -> anyhow::Result<Resource> {
let issue: RestIssueDto = run_public_rest_json(&rest_issue_path(id))
.await
.context("public REST fallback could not load issue")?;
let mut warnings = vec![public_rest_fallback_warning(&auth_error)];
let comments = match fetch_public_rest_comments(id).await {
Ok(comments) => comments,
Err(error) => {
warnings.push(format!("public comments unavailable: {error}"));
Vec::new()
}
};
let timeline = match fetch_public_rest_timeline_events(id).await {
Ok(timeline) => timeline,
Err(error) => {
warnings.push(format!("public timeline events unavailable: {error}"));
Vec::new()
}
};
let mut activity = comments;
activity.extend(timeline);
sort_activity(&mut activity);
let mut resource = rest_issue_resource(issue, id, activity);
resource.warnings.extend(warnings);
resource.warnings.push(
"public REST fallback omits GraphQL-only enrichment such as projects, participants, issue relationships, duplicate issue targets, linked branches, relationship links, and review data".into(),
);
Ok(resource)
}
async fn run_public_rest_json<T: serde::de::DeserializeOwned>(path: &str) -> anyhow::Result<T> {
run_public_rest_json_with(&ReqwestGithubHttpTransport, path).await
}
fn public_rest_fallback_warning(auth_error: &anyhow::Error) -> String {
if crate::github::auth::is_auth_unavailable(auth_error) {
format!(
"using public REST fallback because GitHub authentication is unavailable: {auth_error}"
)
} else {
format!("using public REST fallback after GitHub auth/API error: {auth_error}")
}
}
async fn run_public_rest_json_with<T: serde::de::DeserializeOwned>(
transport: &impl GithubHttpTransport,
path: &str,
) -> anyhow::Result<T> {
let output = run_rest_get_with(transport, None, path, GITHUB_JSON_ACCEPT).await?;
serde_json::from_slice(&output)
.with_context(|| format!("failed to parse public REST JSON from {path}"))
}
async fn fetch_public_rest_pages<T: serde::de::DeserializeOwned>(
base_path: &str,
) -> anyhow::Result<Vec<T>> {
fetch_public_rest_pages_with(&ReqwestGithubHttpTransport, base_path).await
}
async fn fetch_public_rest_pages_with<T: serde::de::DeserializeOwned>(
transport: &impl GithubHttpTransport,
base_path: &str,
) -> anyhow::Result<Vec<T>> {
let mut page = 1;
let mut items = Vec::new();
loop {
let path = public_rest_page_path(base_path, page);
let mut current_page: Vec<T> = run_public_rest_json_with(transport, &path).await?;
let is_last_page = current_page.len() < REST_PAGE_SIZE;
items.append(&mut current_page);
if is_last_page {
return Ok(items);
}
page += 1;
}
}
fn public_rest_page_path(base_path: &str, page: u64) -> String {
let separator = if base_path.contains('?') { '&' } else { '?' };
format!("{base_path}{separator}per_page={REST_PAGE_SIZE}&page={page}")
}
async fn fetch_public_rest_comments(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
let comments = fetch_public_rest_pages::<RestCommentDto>(&format!(
"/repos/{}/{}/issues/{}/comments",
id.owner, id.repo, id.number
))
.await?;
Ok(comments
.into_iter()
.enumerate()
.map(rest_comment_activity)
.collect())
}
async fn fetch_public_rest_timeline_events(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
fetch_public_rest_timeline_events_with(&ReqwestGithubHttpTransport, id).await
}
async fn fetch_public_rest_timeline_events_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
) -> anyhow::Result<Vec<ActivityEntry>> {
let events = fetch_public_rest_pages_with::<RestTimelineEventDto>(
transport,
&format!(
"/repos/{}/{}/issues/{}/timeline",
id.owner, id.repo, id.number
),
)
.await?;
Ok(events
.into_iter()
.enumerate()
.filter_map(rest_timeline_event_activity)
.collect())
}
async fn fetch_public_rest_commits(id: &ResourceId) -> anyhow::Result<Vec<Commit>> {
let commits = fetch_public_rest_pages::<RestCommitDto>(&format!(
"/repos/{}/{}/pulls/{}/commits",
id.owner, id.repo, id.number
))
.await?;
Ok(commits.into_iter().map(rest_commit).collect())
}
async fn fetch_public_rest_files(id: &ResourceId) -> anyhow::Result<Vec<ChangedFile>> {
let files = fetch_public_rest_pages::<RestFileDto>(&format!(
"/repos/{}/{}/pulls/{}/files",
id.owner, id.repo, id.number
))
.await?;
Ok(files.into_iter().map(rest_file).collect())
}
async fn fetch_public_rest_reviews(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
fetch_public_rest_reviews_with(&ReqwestGithubHttpTransport, id).await
}
async fn fetch_public_rest_reviews_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
) -> anyhow::Result<Vec<ActivityEntry>> {
let reviews = fetch_public_rest_pages_with::<RestReviewDto>(
transport,
&format!(
"/repos/{}/{}/pulls/{}/reviews",
id.owner, id.repo, id.number
),
)
.await?;
Ok(reviews
.into_iter()
.enumerate()
.map(rest_review_activity)
.collect())
}
async fn fetch_public_rest_review_comments(id: &ResourceId) -> anyhow::Result<Vec<ActivityEntry>> {
fetch_public_rest_review_comments_with(&ReqwestGithubHttpTransport, id).await
}
async fn fetch_public_rest_review_comments_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
) -> anyhow::Result<Vec<ActivityEntry>> {
let comments = fetch_public_rest_pages_with::<RestReviewCommentDto>(
transport,
&format!(
"/repos/{}/{}/pulls/{}/comments",
id.owner, id.repo, id.number
),
)
.await?;
Ok(comments
.into_iter()
.enumerate()
.map(rest_review_comment_activity)
.collect())
}
async fn fetch_public_rest_checks(
id: &ResourceId,
sha: &str,
warnings: &mut Vec<String>,
) -> Vec<CheckRun> {
let mut checks = Vec::new();
match fetch_public_rest_check_runs(id, sha).await {
Ok(check_runs) => checks.extend(check_runs),
Err(error) => warnings.push(format!("public check runs unavailable: {error}")),
}
match fetch_public_rest_check_suites(id, sha).await {
Ok(check_suites) => checks.extend(check_suites),
Err(error) => warnings.push(format!("public check suites unavailable: {error}")),
}
match fetch_public_rest_status_contexts(id, sha).await {
Ok(statuses) => checks.extend(statuses),
Err(error) => warnings.push(format!("public status contexts unavailable: {error}")),
}
deduped_public_checks(checks)
}
async fn fetch_public_rest_check_suites(
id: &ResourceId,
sha: &str,
) -> anyhow::Result<Vec<CheckRun>> {
fetch_public_rest_check_suites_with(&ReqwestGithubHttpTransport, id, sha).await
}
async fn fetch_public_rest_check_suites_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
sha: &str,
) -> anyhow::Result<Vec<CheckRun>> {
let mut page = 1;
let mut checks = Vec::new();
loop {
let path = format!(
"/repos/{}/{}/commits/{}/check-suites?per_page={REST_PAGE_SIZE}&page={page}",
id.owner, id.repo, sha
);
let page_dto: RestCheckSuitesPageDto = run_public_rest_json_with(transport, &path).await?;
let is_last_page = page_dto.check_suites.len() < REST_PAGE_SIZE;
checks.extend(
page_dto
.check_suites
.into_iter()
.map(|suite| rest_check_suite(suite, id, sha)),
);
if is_last_page {
return Ok(checks);
}
page += 1;
}
}
async fn fetch_public_rest_check_runs(id: &ResourceId, sha: &str) -> anyhow::Result<Vec<CheckRun>> {
fetch_public_rest_check_runs_with(&ReqwestGithubHttpTransport, id, sha).await
}
async fn fetch_public_rest_check_runs_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
sha: &str,
) -> anyhow::Result<Vec<CheckRun>> {
let mut page = 1;
let mut checks = Vec::new();
loop {
let path = format!(
"/repos/{}/{}/commits/{}/check-runs?per_page={REST_PAGE_SIZE}&page={page}",
id.owner, id.repo, sha
);
let page_dto: RestCheckRunsPageDto = run_public_rest_json_with(transport, &path).await?;
let is_last_page = page_dto.check_runs.len() < REST_PAGE_SIZE;
checks.extend(page_dto.check_runs.into_iter().map(rest_check_run));
if is_last_page {
return Ok(checks);
}
page += 1;
}
}
async fn fetch_public_rest_status_contexts(
id: &ResourceId,
sha: &str,
) -> anyhow::Result<Vec<CheckRun>> {
fetch_public_rest_status_contexts_with(&ReqwestGithubHttpTransport, id, sha).await
}
async fn fetch_public_rest_status_contexts_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
sha: &str,
) -> anyhow::Result<Vec<CheckRun>> {
let status: RestCombinedStatusDto = run_public_rest_json_with(
transport,
&format!("/repos/{}/{}/commits/{}/status", id.owner, id.repo, sha),
)
.await?;
Ok(status
.statuses
.into_iter()
.map(rest_status_context)
.collect())
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PublicRestHeadDeployments {
deployments: Vec<Deployment>,
warnings: Vec<String>,
}
async fn fetch_public_rest_head_deployments(
id: &ResourceId,
sha: &str,
) -> anyhow::Result<PublicRestHeadDeployments> {
fetch_public_rest_head_deployments_with(&ReqwestGithubHttpTransport, id, sha).await
}
async fn fetch_public_rest_head_deployments_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
sha: &str,
) -> anyhow::Result<PublicRestHeadDeployments> {
let deployments = fetch_public_rest_pages_with::<RestDeploymentDto>(
transport,
&format!("/repos/{}/{}/deployments?sha={}", id.owner, id.repo, sha),
)
.await?;
let mut mapped = Vec::with_capacity(deployments.len());
let mut warnings = Vec::new();
for deployment in deployments {
let deployment_id = deployment.id;
let statuses =
match fetch_public_rest_deployment_statuses_with(transport, id, deployment_id).await {
Ok(statuses) => statuses,
Err(error) => {
warnings.push(format!(
"public deployment {deployment_id} status unavailable: {error}"
));
Vec::new()
}
};
mapped.push(rest_deployment(
deployment,
latest_rest_deployment_status(statuses),
));
}
Ok(PublicRestHeadDeployments {
deployments: mapped,
warnings,
})
}
async fn fetch_public_rest_deployment_statuses_with(
transport: &impl GithubHttpTransport,
id: &ResourceId,
deployment_id: u64,
) -> anyhow::Result<Vec<RestDeploymentStatusDto>> {
fetch_public_rest_pages_with::<RestDeploymentStatusDto>(
transport,
&format!(
"/repos/{}/{}/deployments/{}/statuses",
id.owner, id.repo, deployment_id
),
)
.await
}
fn rest_pull_path(id: &ResourceId) -> String {
format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number)
}
fn rest_issue_path(id: &ResourceId) -> String {
format!("/repos/{}/{}/issues/{}", id.owner, id.repo, id.number)
}
#[derive(Debug, Clone, Deserialize)]
struct RestUserDto {
login: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestLabelDto {
name: String,
}
#[derive(Debug, Deserialize, Default)]
struct RestReactionsDto {
#[serde(rename = "+1", default)]
thumbs_up: u64,
#[serde(rename = "-1", default)]
thumbs_down: u64,
#[serde(default)]
laugh: u64,
#[serde(default)]
hooray: u64,
#[serde(default)]
confused: u64,
#[serde(default)]
heart: u64,
#[serde(default)]
rocket: u64,
#[serde(default)]
eyes: u64,
}
#[derive(Debug, Deserialize)]
struct RestIssueDto {
number: u64,
title: String,
html_url: String,
state: String,
user: Option<RestUserDto>,
created_at: String,
updated_at: String,
#[serde(default)]
labels: Vec<RestLabelDto>,
#[serde(default)]
assignees: Vec<RestUserDto>,
#[serde(default)]
reactions: RestReactionsDto,
body: Option<String>,
closed_at: Option<String>,
state_reason: Option<String>,
#[serde(default)]
locked: bool,
active_lock_reason: Option<String>,
milestone: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
struct RestPullDto {
title: String,
html_url: String,
state: String,
user: Option<RestUserDto>,
created_at: String,
updated_at: String,
base: Option<RestRefDto>,
head: Option<RestRefDto>,
#[serde(default)]
requested_reviewers: Vec<RestUserDto>,
mergeable: Option<bool>,
additions: Option<u64>,
deletions: Option<u64>,
changed_files: Option<u64>,
#[serde(default)]
draft: bool,
merged_at: Option<String>,
merge_commit_sha: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RestRefDto {
#[serde(rename = "ref")]
reference: String,
sha: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCommentDto {
id: u64,
user: Option<RestUserDto>,
body: Option<String>,
created_at: String,
updated_at: String,
html_url: Option<String>,
author_association: Option<String>,
#[serde(default)]
reactions: RestReactionsDto,
}
#[derive(Debug, Deserialize)]
struct RestReviewDto {
id: u64,
user: Option<RestUserDto>,
body: Option<String>,
state: Option<String>,
submitted_at: Option<String>,
html_url: Option<String>,
author_association: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestReviewCommentDto {
id: u64,
user: Option<RestUserDto>,
body: Option<String>,
created_at: String,
updated_at: String,
html_url: Option<String>,
author_association: Option<String>,
#[serde(default)]
reactions: RestReactionsDto,
path: Option<String>,
line: Option<u64>,
position: Option<u64>,
original_line: Option<u64>,
original_position: Option<u64>,
pull_request_review_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RestTimelineEventDto {
id: Option<Value>,
event: Option<String>,
actor: Option<RestUserDto>,
created_at: Option<String>,
url: Option<String>,
html_url: Option<String>,
commit_id: Option<String>,
label: Option<RestLabelDto>,
assignee: Option<RestUserDto>,
assigner: Option<RestUserDto>,
requested_reviewer: Option<RestUserDto>,
review_requester: Option<RestUserDto>,
dismissed_review: Option<RestDismissedReviewDto>,
rename: Option<RestRenameDto>,
lock_reason: Option<String>,
milestone: Option<Value>,
source: Option<Value>,
subject: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct RestDismissedReviewDto {
state: Option<String>,
dismissal_message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestRenameDto {
from: Option<String>,
to: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCommitDto {
sha: String,
commit: RestCommitInnerDto,
author: Option<RestUserDto>,
}
#[derive(Debug, Deserialize)]
struct RestCommitInnerDto {
message: String,
author: Option<RestCommitPersonDto>,
committer: Option<RestCommitPersonDto>,
}
#[derive(Debug, Deserialize)]
struct RestCommitPersonDto {
name: Option<String>,
date: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestFileDto {
filename: String,
additions: u64,
deletions: u64,
status: String,
patch: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCheckRunsPageDto {
check_runs: Vec<RestCheckRunDto>,
}
#[derive(Debug, Deserialize)]
struct RestCheckSuitesPageDto {
check_suites: Vec<RestCheckSuiteDto>,
}
#[derive(Debug, Deserialize)]
struct RestCheckSuiteDto {
id: Option<u64>,
status: Option<String>,
conclusion: Option<String>,
url: Option<String>,
html_url: Option<String>,
check_runs_url: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
app: Option<RestCheckSuiteAppDto>,
workflow_run: Option<RestCheckSuiteWorkflowRunDto>,
}
#[derive(Debug, Deserialize)]
struct RestCheckSuiteAppDto {
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCheckSuiteWorkflowRunDto {
name: Option<String>,
html_url: Option<String>,
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCheckRunDto {
name: String,
status: Option<String>,
conclusion: Option<String>,
html_url: Option<String>,
details_url: Option<String>,
started_at: Option<String>,
completed_at: Option<String>,
output: Option<RestCheckRunOutputDto>,
}
#[derive(Debug, Deserialize)]
struct RestCheckRunOutputDto {
title: Option<String>,
summary: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestCombinedStatusDto {
statuses: Vec<RestStatusDto>,
}
#[derive(Debug, Deserialize)]
struct RestStatusDto {
context: String,
state: Option<String>,
target_url: Option<String>,
description: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestDeploymentDto {
id: u64,
environment: Option<String>,
task: Option<String>,
description: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RestDeploymentStatusDto {
state: Option<String>,
description: Option<String>,
environment: Option<String>,
target_url: Option<String>,
environment_url: Option<String>,
log_url: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
fn rest_reaction_counts(reactions: RestReactionsDto) -> ReactionCounts {
ReactionCounts {
thumbs_up: reactions.thumbs_up,
thumbs_down: reactions.thumbs_down,
laugh: reactions.laugh,
hooray: reactions.hooray,
confused: reactions.confused,
heart: reactions.heart,
rocket: reactions.rocket,
eyes: reactions.eyes,
}
}
fn rest_check_run(check: RestCheckRunDto) -> CheckRun {
let raw_status = check.status.filter(|value| !value.is_empty());
let raw_conclusion = check.conclusion.filter(|value| !value.is_empty());
let summary = check.output.and_then(|output| {
output
.summary
.filter(|value| !value.trim().is_empty())
.or_else(|| output.title.filter(|value| !value.trim().is_empty()))
});
CheckRun {
name: check.name,
status: CheckStatus::from_github(raw_status.as_deref(), raw_conclusion.as_deref()),
summary,
details_url: check.details_url.or(check.html_url),
started_at: check.started_at,
completed_at: check.completed_at,
raw_status,
raw_conclusion,
}
}
fn rest_check_suite(suite: RestCheckSuiteDto, id: &ResourceId, sha: &str) -> 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.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.html_url.or(run.url))
.or_else(|| {
suite.id.map(|suite_id| {
format!(
"https://github.com/{}/{}/commit/{}/checks?check_suite_id={}",
id.owner, id.repo, sha, suite_id
)
})
})
.or(suite.html_url)
.or(suite.check_runs_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: suite.created_at,
completed_at: suite.updated_at,
raw_status,
raw_conclusion,
}
}
fn rest_status_context(status: RestStatusDto) -> CheckRun {
let raw_status = status.state.filter(|value| !value.is_empty());
CheckRun {
name: status.context,
status: CheckStatus::from_github(raw_status.as_deref(), None),
summary: status.description.filter(|value| !value.trim().is_empty()),
details_url: status.target_url,
started_at: status.created_at,
completed_at: status.updated_at,
raw_status,
raw_conclusion: None,
}
}
fn rest_deployment(
deployment: RestDeploymentDto,
latest_status: Option<RestDeploymentStatusDto>,
) -> Deployment {
let status = latest_status;
let environment = status
.as_ref()
.and_then(|status| status.environment.clone())
.or(deployment.environment)
.or(deployment.task)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "deployment".to_string());
let state = status
.as_ref()
.and_then(|status| status.state.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "UNKNOWN".to_string())
.to_ascii_uppercase();
let description = status
.as_ref()
.and_then(|status| status.description.clone())
.or(deployment.description)
.filter(|value| !value.trim().is_empty());
let environment_url = status
.as_ref()
.and_then(|status| status.environment_url.clone().or(status.target_url.clone()))
.filter(|value| !value.trim().is_empty());
let log_url = status
.as_ref()
.and_then(|status| status.log_url.clone())
.filter(|value| !value.trim().is_empty());
let created_at = status
.as_ref()
.and_then(|status| status.created_at.clone())
.or(deployment.created_at);
let updated_at = status
.and_then(|status| status.updated_at)
.or(deployment.updated_at)
.unwrap_or_else(|| "unknown".to_string());
Deployment {
environment,
state,
description,
environment_url,
log_url,
created_at,
updated_at,
}
}
fn latest_rest_deployment_status(
statuses: Vec<RestDeploymentStatusDto>,
) -> Option<RestDeploymentStatusDto> {
statuses.into_iter().max_by(|left, right| {
deployment_status_timestamp(left).cmp(deployment_status_timestamp(right))
})
}
fn deployment_status_timestamp(status: &RestDeploymentStatusDto) -> &str {
status
.updated_at
.as_deref()
.or(status.created_at.as_deref())
.unwrap_or("")
}
fn deduped_public_checks(checks: Vec<CheckRun>) -> Vec<CheckRun> {
let mut by_name = std::collections::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 apply_public_rest_head_deployments(
commits: &mut [Commit],
head_sha: &str,
deployments: Vec<Deployment>,
) {
if deployments.is_empty() {
return;
}
if let Some(commit) = commits.iter_mut().find(|commit| commit.oid == head_sha) {
commit.deployments = deployments;
}
}
fn rest_issue_resource(
issue: RestIssueDto,
requested: &ResourceId,
activity: Vec<ActivityEntry>,
) -> Resource {
let metadata = rest_issue_metadata(&issue);
Resource {
id: ResourceId {
owner: requested.owner.clone(),
repo: requested.repo.clone(),
number: issue.number,
kind_hint: Some(ResourceKind::Issue),
},
title: issue.title,
url: issue.html_url,
state: issue.state.to_ascii_uppercase(),
author: display_rest_author(issue.user),
created_at: issue.created_at,
updated_at: issue.updated_at,
labels: issue.labels.into_iter().map(|label| label.name).collect(),
assignees: issue
.assignees
.into_iter()
.map(|user| display_rest_author(Some(user)))
.filter(|name| name != "unknown")
.collect(),
reactions: rest_reaction_counts(issue.reactions),
body: issue.body.unwrap_or_default(),
activity,
related_resources: Vec::new(),
metadata,
warnings: Vec::new(),
pull_request: None,
}
}
fn rest_pull_request(
pull: &RestPullDto,
commits: Vec<Commit>,
checks: Vec<CheckRun>,
files: Vec<ChangedFile>,
) -> PullRequest {
let base_ref = pull
.base
.as_ref()
.map(|reference| reference.reference.clone())
.unwrap_or_default();
let head_ref = pull
.head
.as_ref()
.map(|reference| reference.reference.clone())
.unwrap_or_default();
PullRequest {
base_ref,
head_ref,
requested_reviewers: pull
.requested_reviewers
.iter()
.cloned()
.map(|user| display_rest_author(Some(user)))
.filter(|name| name != "unknown")
.collect(),
review_decision: None,
merge_state: pull.mergeable.map(|mergeable| {
if mergeable {
"MERGEABLE".to_string()
} else {
"CONFLICTING".to_string()
}
}),
additions: pull.additions.unwrap_or_default(),
deletions: pull.deletions.unwrap_or_default(),
commits,
checks,
files,
metadata: rest_pr_metadata(pull),
}
}
fn display_rest_author(author: Option<RestUserDto>) -> String {
author
.and_then(|author| author.login)
.filter(|login| !login.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn rest_issue_metadata(issue: &RestIssueDto) -> Vec<MetadataItem> {
let mut items = Vec::new();
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_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,
"Milestone",
value_title(issue.milestone.as_ref()).as_deref(),
);
items
}
fn rest_pr_metadata(pr: &RestPullDto) -> Vec<MetadataItem> {
let mut items = Vec::new();
push_bool_metadata(&mut items, "Draft", pr.draft);
push_nonempty_metadata(
&mut items,
"Changed files",
pr.changed_files.map(|count| count.to_string()).as_deref(),
);
push_nonempty_metadata(&mut items, "Merged at", pr.merged_at.as_deref());
push_nonempty_metadata(&mut items, "Merge commit", pr.merge_commit_sha.as_deref());
items
}
fn rest_comment_activity((index, comment): (usize, RestCommentDto)) -> ActivityEntry {
let includes_created_edit = comment.updated_at != comment.created_at;
ActivityEntry {
id: format!("rest-comment-{}", comment.id),
kind: ActivityKind::Comment,
author: display_rest_author(comment.user),
body: comment.body.unwrap_or_default(),
updated_at: comment.updated_at,
path: None,
line: None,
url: comment.html_url,
author_association: comment.author_association,
reactions: rest_reaction_counts(comment.reactions),
includes_created_edit,
is_minimized: false,
minimized_reason: None,
thread_id: Some(format!("public-rest-comment-{index}")),
thread_resolved: None,
thread_outdated: None,
}
}
fn rest_review_activity((index, review): (usize, RestReviewDto)) -> ActivityEntry {
let state = review
.state
.as_deref()
.map(|state| state.to_ascii_uppercase())
.unwrap_or_else(|| "REVIEW".to_string());
let submitted_at = review.submitted_at.unwrap_or_else(|| "unknown".to_string());
let body = review.body.unwrap_or_default();
let body = if body.trim().is_empty() {
state.clone()
} else {
format!("{state}: {body}")
};
ActivityEntry {
id: format!("rest-review-{}", review.id),
kind: ActivityKind::Review,
author: display_rest_author(review.user),
body,
updated_at: submitted_at,
path: None,
line: None,
url: review.html_url,
author_association: review.author_association,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: Some(format!("public-rest-review-{index}")),
thread_resolved: None,
thread_outdated: None,
}
}
fn rest_review_comment_activity((index, comment): (usize, RestReviewCommentDto)) -> ActivityEntry {
let includes_created_edit = comment.updated_at != comment.created_at;
ActivityEntry {
id: format!("rest-review-comment-{}", comment.id),
kind: ActivityKind::ReviewComment,
author: display_rest_author(comment.user),
body: comment.body.unwrap_or_default(),
updated_at: comment.updated_at,
path: comment.path,
line: comment
.line
.or(comment.position)
.or(comment.original_line)
.or(comment.original_position),
url: comment.html_url,
author_association: comment.author_association,
reactions: rest_reaction_counts(comment.reactions),
includes_created_edit,
is_minimized: false,
minimized_reason: None,
thread_id: Some(
comment
.pull_request_review_id
.map(|id| format!("public-rest-review-{id}"))
.unwrap_or_else(|| format!("public-rest-review-comment-{index}")),
),
thread_resolved: None,
thread_outdated: None,
}
}
fn rest_timeline_event_activity(
(index, event): (usize, RestTimelineEventDto),
) -> Option<ActivityEntry> {
let event_name = event.event.as_deref()?.trim();
if should_skip_rest_timeline_event(event_name) {
return None;
}
let body = rest_timeline_event_body(event_name, &event);
Some(ActivityEntry {
id: format!(
"rest-timeline-{}",
event
.id
.as_ref()
.map(value_id)
.filter(|id| !id.is_empty())
.unwrap_or_else(|| index.to_string())
),
kind: ActivityKind::Timeline,
author: display_rest_author(event.actor),
body,
updated_at: event
.created_at
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string()),
path: None,
line: None,
url: event.html_url.or(event.url),
author_association: None,
reactions: ReactionCounts::default(),
includes_created_edit: false,
is_minimized: false,
minimized_reason: None,
thread_id: Some(format!("public-rest-timeline-{index}")),
thread_resolved: None,
thread_outdated: None,
})
}
fn should_skip_rest_timeline_event(event_name: &str) -> bool {
matches!(
event_name,
"commented" | "committed" | "reviewed" | "review_comment"
)
}
fn rest_timeline_event_body(event_name: &str, event: &RestTimelineEventDto) -> String {
match event_name {
"assigned" => format!(
"assigned {}",
display_rest_author(event.assignee.clone().or_else(|| event.assigner.clone()))
),
"unassigned" => format!(
"unassigned {}",
display_rest_author(event.assignee.clone().or_else(|| event.assigner.clone()))
),
"labeled" => format!(
"added label {}",
event
.label
.as_ref()
.map(|label| label.name.as_str())
.unwrap_or("unknown")
),
"unlabeled" => format!(
"removed label {}",
event
.label
.as_ref()
.map(|label| label.name.as_str())
.unwrap_or("unknown")
),
"milestoned" => format!(
"added milestone {}",
value_title(event.milestone.as_ref()).unwrap_or_else(|| "unknown".to_string())
),
"demilestoned" => format!(
"removed milestone {}",
value_title(event.milestone.as_ref()).unwrap_or_else(|| "unknown".to_string())
),
"renamed" => match &event.rename {
Some(rename) => format!(
"renamed title from {} to {}",
rename.from.as_deref().unwrap_or("unknown"),
rename.to.as_deref().unwrap_or("unknown")
),
None => "renamed title".to_string(),
},
"closed" => rest_timeline_commit_body("closed", event),
"reopened" => "reopened this".to_string(),
"merged" => rest_timeline_commit_body("merged", event),
"locked" => event
.lock_reason
.as_ref()
.filter(|reason| !reason.trim().is_empty())
.map(|reason| format!("locked this as {reason}"))
.unwrap_or_else(|| "locked this".to_string()),
"unlocked" => "unlocked this".to_string(),
"referenced" => rest_timeline_reference_body("referenced", event),
"cross-referenced" => rest_timeline_reference_body("cross-referenced", event),
"connected" => rest_timeline_reference_body("connected", event),
"disconnected" => rest_timeline_reference_body("disconnected", event),
"review_requested" => format!(
"requested review from {}",
display_rest_author(
event
.requested_reviewer
.clone()
.or_else(|| event.review_requester.clone())
)
),
"review_request_removed" => format!(
"removed review request for {}",
display_rest_author(
event
.requested_reviewer
.clone()
.or_else(|| event.review_requester.clone())
)
),
"review_dismissed" => {
let review = event.dismissed_review.as_ref();
let state = review
.and_then(|review| review.state.as_deref())
.unwrap_or("review");
match review.and_then(|review| review.dismissal_message.as_deref()) {
Some(message) if !message.trim().is_empty() => {
format!("dismissed {state} review: {message}")
}
_ => format!("dismissed {state} review"),
}
}
"ready_for_review" => "marked ready for review".to_string(),
"converted_to_draft" => "converted to draft".to_string(),
"head_ref_deleted" => "deleted head branch".to_string(),
"head_ref_restored" => "restored head branch".to_string(),
"subscribed" => "subscribed".to_string(),
"unsubscribed" => "unsubscribed".to_string(),
"mentioned" => "mentioned this".to_string(),
other => humanize_rest_timeline_event(other),
}
}
fn rest_timeline_commit_body(action: &str, event: &RestTimelineEventDto) -> String {
event
.commit_id
.as_ref()
.filter(|commit| !commit.trim().is_empty())
.map(|commit| format!("{action} this with {commit}"))
.unwrap_or_else(|| format!("{action} this"))
}
fn rest_timeline_reference_body(action: &str, event: &RestTimelineEventDto) -> String {
let reference = event
.source
.as_ref()
.or(event.subject.as_ref())
.and_then(value_reference);
match reference {
Some(reference) => format!("{action} {reference}"),
None => format!("{action} this"),
}
}
fn value_reference(value: &Value) -> Option<String> {
value_url(value)
.or_else(|| value_title(Some(value)))
.or_else(|| {
value
.get("issue")
.and_then(value_reference)
.or_else(|| value.get("pull_request").and_then(value_reference))
.or_else(|| value.get("pullRequest").and_then(value_reference))
})
}
fn value_url(value: &Value) -> Option<String> {
["html_url", "url"]
.into_iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::to_string)
}
fn value_id(value: &Value) -> String {
value
.as_u64()
.map(|id| id.to_string())
.or_else(|| value.as_str().map(str::to_string))
.unwrap_or_else(|| value.to_string())
}
fn humanize_rest_timeline_event(event_name: &str) -> String {
event_name.replace('_', " ")
}
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 rest_commit(commit: RestCommitDto) -> Commit {
let headline = commit
.commit
.message
.lines()
.next()
.unwrap_or_default()
.to_string();
let body = commit
.commit
.message
.lines()
.skip(1)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
let author = {
let login = display_rest_author(commit.author);
if login == "unknown" {
commit
.commit
.author
.as_ref()
.and_then(|author| author.name.clone())
.unwrap_or_else(|| "unknown".to_string())
} else {
login
}
};
let authored_at = commit
.commit
.author
.as_ref()
.and_then(|author| author.date.clone());
let committed_at = commit
.commit
.committer
.and_then(|committer| committer.date)
.or_else(|| authored_at.clone())
.unwrap_or_else(|| "unknown".to_string());
Commit {
oid: commit.sha,
message: headline,
body,
author: author.clone(),
authors: if author == "unknown" {
Vec::new()
} else {
vec![author]
},
authored_at,
committed_at,
status: CheckStatus::Unknown,
deployments: Vec::new(),
}
}
fn rest_file(file: RestFileDto) -> ChangedFile {
ChangedFile {
path: file.filename,
additions: file.additions,
deletions: file.deletions,
change_type: file.status.to_ascii_uppercase(),
patch: file.patch,
}
}
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 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)))
})
}
#[cfg(test)]
mod tests {
use std::{collections::VecDeque, sync::Mutex};
use anyhow::Context;
use serde_json::{json, Value};
use super::*;
use crate::github::transport::{
GithubHttpFuture, GithubHttpMethod, GithubHttpRequest, GithubHttpResponse,
};
#[derive(Debug)]
struct FakeGithubHttpTransport {
requests: Mutex<Vec<GithubHttpRequest>>,
responses: Mutex<VecDeque<GithubHttpResponse>>,
}
impl FakeGithubHttpTransport {
fn from_responses(responses: Vec<GithubHttpResponse>) -> Self {
Self {
requests: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into()),
}
}
fn requests(&self) -> Vec<GithubHttpRequest> {
self.requests.lock().expect("requests lock").clone()
}
}
impl GithubHttpTransport for FakeGithubHttpTransport {
fn execute<'a>(&'a self, request: GithubHttpRequest) -> GithubHttpFuture<'a> {
Box::pin(async move {
self.requests.lock().expect("requests lock").push(request);
self.responses
.lock()
.expect("responses lock")
.pop_front()
.context("fake response queue is empty")
})
}
}
#[tokio::test]
async fn pages_until_short_page_without_auth() {
let first_page = (1..=100).map(rest_comment_json).collect::<Vec<_>>();
let second_page = (101..=102).map(rest_comment_json).collect::<Vec<_>>();
let transport = FakeGithubHttpTransport::from_responses(vec![
GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&first_page).unwrap(),
},
GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&second_page).unwrap(),
},
]);
let comments = fetch_public_rest_pages_with::<RestCommentDto>(
&transport,
"/repos/openclaw/openclaw/issues/88499/comments",
)
.await
.expect("paginated public REST comments");
assert_eq!(comments.len(), 102);
assert_eq!(comments[0].id, 1);
assert_eq!(comments[101].id, 102);
let requests = transport.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].method, GithubHttpMethod::Get);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/issues/88499/comments?per_page=100&page=1"
);
assert_eq!(
requests[1].url,
"https://api.github.com/repos/openclaw/openclaw/issues/88499/comments?per_page=100&page=2"
);
assert_eq!(requests[0].token, None);
assert_eq!(requests[1].token, None);
}
#[test]
fn page_path_uses_ampersand_when_query_already_exists() {
assert_eq!(
public_rest_page_path("/repos/owner/repo/issues/1/comments?since=2026-01-01", 3),
"/repos/owner/repo/issues/1/comments?since=2026-01-01&per_page=100&page=3"
);
}
#[tokio::test]
async fn public_rest_check_runs_page_without_auth_and_preserve_urls() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!({
"check_runs": [
{
"name": "ci / test",
"status": "completed",
"conclusion": "failure",
"html_url": "https://github.com/openclaw/openclaw/runs/1",
"details_url": "https://ci.example.test/build/1",
"started_at": "2026-05-31T00:00:00Z",
"completed_at": "2026-05-31T00:01:00Z",
"output": {
"title": "tests failed",
"summary": "one test failed"
}
}
]
}))
.unwrap(),
}]);
let checks = fetch_public_rest_check_runs_with(&transport, &id, "abc123")
.await
.expect("public check runs");
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].name, "ci / test");
assert_eq!(checks[0].status, CheckStatus::Failure);
assert_eq!(checks[0].summary.as_deref(), Some("one test failed"));
assert_eq!(
checks[0].details_url.as_deref(),
Some("https://ci.example.test/build/1")
);
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, GithubHttpMethod::Get);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/commits/abc123/check-runs?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
}
#[tokio::test]
async fn public_rest_check_suites_page_without_auth_and_preserve_suite_rollups() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!({
"total_count": 2,
"check_suites": [
{
"id": 5,
"status": "completed",
"conclusion": "action_required",
"url": "https://api.github.com/repos/openclaw/openclaw/check-suites/5",
"check_runs_url": "https://api.github.com/repos/openclaw/openclaw/check-suites/5/check-runs",
"created_at": "2026-05-31T00:00:00Z",
"updated_at": "2026-05-31T00:05:00Z",
"app": {"name": "GitHub Actions"},
"workflow_run": {
"name": "CI",
"html_url": "https://github.com/openclaw/openclaw/actions/runs/5",
"url": "https://api.github.com/repos/openclaw/openclaw/actions/runs/5"
}
},
{
"id": 6,
"status": "queued",
"conclusion": null,
"url": "https://api.github.com/repos/openclaw/openclaw/check-suites/6",
"app": {"name": "codecov"}
}
]
}))
.unwrap(),
}]);
let checks = fetch_public_rest_check_suites_with(&transport, &id, "abc123")
.await
.expect("public check suites");
assert_eq!(checks.len(), 2);
assert_eq!(checks[0].name, "suite/CI");
assert_eq!(checks[0].status, CheckStatus::Pending);
assert_eq!(checks[0].raw_status.as_deref(), Some("completed"));
assert_eq!(checks[0].raw_conclusion.as_deref(), Some("action_required"));
assert_eq!(
checks[0].details_url.as_deref(),
Some("https://github.com/openclaw/openclaw/actions/runs/5")
);
assert_eq!(
checks[0].summary.as_deref(),
Some("check suite from GitHub Actions")
);
assert_eq!(
checks[0].started_at.as_deref(),
Some("2026-05-31T00:00:00Z")
);
assert_eq!(
checks[0].completed_at.as_deref(),
Some("2026-05-31T00:05:00Z")
);
assert_eq!(checks[1].name, "suite/codecov");
assert_eq!(checks[1].status, CheckStatus::Pending);
assert_eq!(
checks[1].details_url.as_deref(),
Some("https://github.com/openclaw/openclaw/commit/abc123/checks?check_suite_id=6")
);
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, GithubHttpMethod::Get);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/commits/abc123/check-suites?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
}
#[tokio::test]
async fn public_rest_head_deployments_without_auth_and_preserve_latest_status_urls() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![
GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"id": 7,
"environment": "preview",
"task": "deploy",
"description": "Deploy request",
"created_at": "2026-05-31T00:00:00Z",
"updated_at": "2026-05-31T00:01:00Z"
}
]))
.unwrap(),
},
GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"state": "queued",
"description": "Waiting for worker",
"environment": "preview",
"environment_url": "https://preview.example.test/old",
"log_url": "https://logs.example.test/old",
"created_at": "2026-05-31T00:02:00Z",
"updated_at": "2026-05-31T00:02:00Z"
},
{
"state": "success",
"description": "Preview deployed",
"environment": "preview",
"environment_url": "https://preview.example.test/current",
"log_url": "https://logs.example.test/current",
"target_url": "https://target.example.test/current",
"created_at": "2026-05-31T00:03:00Z",
"updated_at": "2026-05-31T00:04:00Z"
}
]))
.unwrap(),
},
]);
let head_deployments = fetch_public_rest_head_deployments_with(&transport, &id, "abc123")
.await
.expect("public head deployments");
let deployments = head_deployments.deployments;
assert!(head_deployments.warnings.is_empty());
assert_eq!(deployments.len(), 1);
assert_eq!(deployments[0].environment, "preview");
assert_eq!(deployments[0].state, "SUCCESS");
assert_eq!(
deployments[0].description.as_deref(),
Some("Preview deployed")
);
assert_eq!(
deployments[0].environment_url.as_deref(),
Some("https://preview.example.test/current")
);
assert_eq!(
deployments[0].log_url.as_deref(),
Some("https://logs.example.test/current")
);
assert_eq!(
deployments[0].created_at.as_deref(),
Some("2026-05-31T00:03:00Z")
);
assert_eq!(deployments[0].updated_at, "2026-05-31T00:04:00Z");
let requests = transport.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/deployments?sha=abc123&per_page=100&page=1"
);
assert_eq!(
requests[1].url,
"https://api.github.com/repos/openclaw/openclaw/deployments/7/statuses?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
assert_eq!(requests[1].token, None);
}
#[tokio::test]
async fn public_rest_head_deployments_keep_deployment_when_statuses_fail() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![
GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"id": 7,
"environment": "preview",
"task": "deploy",
"description": "Deploy request",
"created_at": "2026-05-31T00:00:00Z",
"updated_at": "2026-05-31T00:01:00Z"
}
]))
.unwrap(),
},
GithubHttpResponse {
status: reqwest::StatusCode::FORBIDDEN,
body: br#"{"message":"statuses hidden"}"#.to_vec(),
},
]);
let head_deployments = fetch_public_rest_head_deployments_with(&transport, &id, "abc123")
.await
.expect("deployment list should remain usable");
assert_eq!(head_deployments.deployments.len(), 1);
assert_eq!(head_deployments.deployments[0].environment, "preview");
assert_eq!(head_deployments.deployments[0].state, "UNKNOWN");
assert_eq!(
head_deployments.deployments[0].description.as_deref(),
Some("Deploy request")
);
assert_eq!(
head_deployments.deployments[0].created_at.as_deref(),
Some("2026-05-31T00:00:00Z")
);
assert_eq!(
head_deployments.deployments[0].updated_at,
"2026-05-31T00:01:00Z"
);
assert_eq!(head_deployments.warnings.len(), 1);
assert!(head_deployments.warnings[0].contains("public deployment 7 status unavailable"));
assert!(head_deployments.warnings[0].contains("statuses hidden"));
}
#[tokio::test]
async fn public_rest_status_contexts_without_auth() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!({
"statuses": [
{
"context": "legacy/build",
"state": "success",
"target_url": "https://ci.example.test/status/1",
"description": "build passed",
"created_at": "2026-05-31T00:00:00Z",
"updated_at": "2026-05-31T00:02:00Z"
}
]
}))
.unwrap(),
}]);
let checks = fetch_public_rest_status_contexts_with(&transport, &id, "abc123")
.await
.expect("public status contexts");
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].name, "legacy/build");
assert_eq!(checks[0].status, CheckStatus::Success);
assert_eq!(checks[0].summary.as_deref(), Some("build passed"));
assert_eq!(
checks[0].details_url.as_deref(),
Some("https://ci.example.test/status/1")
);
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/commits/abc123/status"
);
assert_eq!(requests[0].token, None);
}
#[tokio::test]
async fn public_rest_reviews_without_auth() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"id": 44,
"user": {"login": "maintainer"},
"body": "looks good",
"state": "approved",
"submitted_at": "2026-05-31T00:03:00Z",
"html_url": "https://github.com/openclaw/openclaw/pull/81834#pullrequestreview-44",
"author_association": "MEMBER"
}
]))
.unwrap(),
}]);
let reviews = fetch_public_rest_reviews_with(&transport, &id)
.await
.expect("public reviews");
assert_eq!(reviews.len(), 1);
assert_eq!(reviews[0].kind, ActivityKind::Review);
assert_eq!(reviews[0].author, "maintainer");
assert_eq!(reviews[0].body, "APPROVED: looks good");
assert_eq!(reviews[0].updated_at, "2026-05-31T00:03:00Z");
assert_eq!(
reviews[0].url.as_deref(),
Some("https://github.com/openclaw/openclaw/pull/81834#pullrequestreview-44")
);
assert_eq!(reviews[0].author_association.as_deref(), Some("MEMBER"));
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/pulls/81834/reviews?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
}
#[tokio::test]
async fn public_rest_review_comments_without_auth() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "81834").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"id": 55,
"user": {"login": "reviewer"},
"body": "please rename this",
"created_at": "2026-05-31T00:04:00Z",
"updated_at": "2026-05-31T00:05:00Z",
"html_url": "https://github.com/openclaw/openclaw/pull/81834#discussion_r55",
"author_association": "CONTRIBUTOR",
"reactions": {"eyes": 2},
"path": "src/lib.rs",
"line": 42,
"pull_request_review_id": 44
}
]))
.unwrap(),
}]);
let comments = fetch_public_rest_review_comments_with(&transport, &id)
.await
.expect("public review comments");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].kind, ActivityKind::ReviewComment);
assert_eq!(comments[0].author, "reviewer");
assert_eq!(comments[0].body, "please rename this");
assert_eq!(comments[0].path.as_deref(), Some("src/lib.rs"));
assert_eq!(comments[0].line, Some(42));
assert_eq!(
comments[0].thread_id.as_deref(),
Some("public-rest-review-44")
);
assert_eq!(comments[0].reactions.eyes, 2);
assert!(comments[0].includes_created_edit);
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/pulls/81834/comments?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
}
#[tokio::test]
async fn public_rest_timeline_events_without_auth() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let transport = FakeGithubHttpTransport::from_responses(vec![GithubHttpResponse {
status: reqwest::StatusCode::OK,
body: serde_json::to_vec(&json!([
{
"id": 71,
"event": "labeled",
"actor": {"login": "maintainer"},
"created_at": "2026-05-31T00:01:00Z",
"label": {"name": "bug"},
"url": "https://api.github.com/repos/openclaw/openclaw/issues/events/71"
},
{
"id": 72,
"event": "renamed",
"actor": {"login": "maintainer"},
"created_at": "2026-05-31T00:02:00Z",
"rename": {"from": "old title", "to": "new title"}
},
{
"id": 73,
"event": "cross-referenced",
"actor": {"login": "alice"},
"created_at": "2026-05-31T00:03:00Z",
"source": {
"issue": {
"title": "related issue",
"html_url": "https://github.com/openclaw/openclaw/issues/88500"
}
}
},
{
"id": 74,
"event": "commented",
"actor": {"login": "alice"},
"created_at": "2026-05-31T00:04:00Z"
}
]))
.unwrap(),
}]);
let events = fetch_public_rest_timeline_events_with(&transport, &id)
.await
.expect("public timeline events");
assert_eq!(events.len(), 3);
assert_eq!(events[0].kind, ActivityKind::Timeline);
assert_eq!(events[0].author, "maintainer");
assert_eq!(events[0].body, "added label bug");
assert_eq!(events[0].updated_at, "2026-05-31T00:01:00Z");
assert_eq!(
events[0].url.as_deref(),
Some("https://api.github.com/repos/openclaw/openclaw/issues/events/71")
);
assert_eq!(events[1].body, "renamed title from old title to new title");
assert_eq!(
events[2].body,
"cross-referenced https://github.com/openclaw/openclaw/issues/88500"
);
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].url,
"https://api.github.com/repos/openclaw/openclaw/issues/88499/timeline?per_page=100&page=1"
);
assert_eq!(requests[0].token, None);
}
fn rest_comment_json(id: u64) -> Value {
json!({
"id": id,
"user": {"login": "alice"},
"body": format!("comment {id}"),
"created_at": "2026-05-31T00:00:00Z",
"updated_at": "2026-05-31T00:00:00Z"
})
}
#[test]
fn public_rest_fallback_warning_describes_auth_unavailable() {
let warning = public_rest_fallback_warning(
&crate::github::auth::GithubAuthError::Unavailable("gh auth token failed".into())
.into(),
);
assert!(warning.contains("GitHub authentication is unavailable"));
assert!(warning.contains("gh auth token failed"));
}
#[test]
fn public_rest_fallback_warning_describes_token_api_errors() {
let warning = public_rest_fallback_warning(&anyhow::anyhow!(
"HTTP 403: API rate limit exceeded for token"
));
assert!(warning.contains("GitHub auth/API error"));
assert!(warning.contains("API rate limit exceeded"));
assert!(!warning.contains("no GitHub token"));
}
#[test]
fn issue_fallback_renders_core_monitoring_surfaces() {
let id = ResourceId::from_owner_repo_number("openclaw/openclaw", "88499").unwrap();
let issue = RestIssueDto {
number: 88499,
title: "Public issue".into(),
html_url: "https://github.com/openclaw/openclaw/issues/88499".into(),
state: "open".into(),
user: Some(RestUserDto {
login: Some("alice".into()),
}),
created_at: "2026-05-30T00:00:00Z".into(),
updated_at: "2026-05-31T00:00:00Z".into(),
labels: vec![RestLabelDto { name: "bug".into() }],
assignees: vec![RestUserDto {
login: Some("bob".into()),
}],
reactions: RestReactionsDto {
thumbs_up: 2,
eyes: 1,
..RestReactionsDto::default()
},
body: Some("Issue body".into()),
closed_at: None,
state_reason: Some("REOPENED".into()),
locked: true,
active_lock_reason: Some("TOO_HEATED".into()),
milestone: Some(json!({"title": "v1"})),
};
let activity = vec![rest_comment_activity((
0,
RestCommentDto {
id: 1,
user: Some(RestUserDto {
login: Some("carol".into()),
}),
body: Some("Public comment".into()),
created_at: "2026-05-31T00:00:00Z".into(),
updated_at: "2026-05-31T00:00:00Z".into(),
html_url: Some(
"https://github.com/openclaw/openclaw/issues/88499#issuecomment-1".into(),
),
author_association: Some("MEMBER".into()),
reactions: RestReactionsDto::default(),
},
))];
let resource = rest_issue_resource(issue, &id, activity);
assert_eq!(resource.kind(), ResourceKind::Issue);
assert_eq!(resource.state, "OPEN");
assert_eq!(resource.author, "alice");
assert_eq!(resource.labels, ["bug"]);
assert_eq!(resource.assignees, ["bob"]);
assert_eq!(resource.reactions.total(), 3);
assert_eq!(resource.activity[0].body, "Public comment");
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Milestone" && item.value == "v1"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Locked" && item.value == "yes"));
assert!(resource
.metadata
.iter()
.any(|item| item.label == "Lock reason" && item.value == "TOO_HEATED"));
}
#[test]
fn pr_fallback_renders_core_monitoring_surfaces() {
let pull = RestPullDto {
title: "Public PR".into(),
html_url: "https://github.com/openclaw/openclaw/pull/81834".into(),
state: "open".into(),
user: Some(RestUserDto {
login: Some("alice".into()),
}),
created_at: "2026-05-30T00:00:00Z".into(),
updated_at: "2026-05-31T00:00:00Z".into(),
base: Some(RestRefDto {
reference: "main".into(),
sha: None,
}),
head: Some(RestRefDto {
reference: "feature".into(),
sha: Some("abcdef123456".into()),
}),
requested_reviewers: vec![RestUserDto {
login: Some("reviewer".into()),
}],
mergeable: Some(true),
additions: Some(10),
deletions: Some(2),
changed_files: Some(1),
draft: false,
merged_at: None,
merge_commit_sha: Some("abc123".into()),
};
let commits = vec![rest_commit(RestCommitDto {
sha: "abcdef123456".into(),
commit: RestCommitInnerDto {
message: "feat: public fallback\n\nbody".into(),
author: Some(RestCommitPersonDto {
name: Some("Fallback Author".into()),
date: Some("2026-05-30T00:00:00Z".into()),
}),
committer: None,
},
author: None,
})];
let mut deployed_commits = commits.clone();
apply_public_rest_head_deployments(
&mut deployed_commits,
"abcdef123456",
vec![Deployment {
environment: "preview".into(),
state: "SUCCESS".into(),
description: Some("Preview deployed".into()),
environment_url: Some("https://preview.example.test".into()),
log_url: Some("https://logs.example.test".into()),
created_at: Some("2026-05-31T00:00:00Z".into()),
updated_at: "2026-05-31T00:01:00Z".into(),
}],
);
let files = vec![rest_file(RestFileDto {
filename: "src/lib.rs".into(),
additions: 10,
deletions: 2,
status: "modified".into(),
patch: Some("@@ -1 +1 @@\n-old\n+new".into()),
})];
let pr = rest_pull_request(&pull, deployed_commits, Vec::new(), files);
assert_eq!(pr.base_ref, "main");
assert_eq!(pr.head_ref, "feature");
assert_eq!(pr.requested_reviewers, ["reviewer"]);
assert_eq!(pr.commits[0].message, "feat: public fallback");
assert_eq!(pr.commits[0].body, "body");
assert_eq!(pr.commits[0].deployments[0].environment, "preview");
assert_eq!(pr.commits[0].deployments[0].state, "SUCCESS");
assert_eq!(pr.files[0].path, "src/lib.rs");
assert_eq!(pr.files[0].change_type, "MODIFIED");
assert!(pr
.metadata
.iter()
.any(|item| item.label == "Merge commit" && item.value == "abc123"));
}
#[test]
fn pr_fallback_normalizer_tolerates_missing_optional_fields() {
let pull = RestPullDto {
title: "Minimal public PR".into(),
html_url: "https://github.com/openclaw/openclaw/pull/81834".into(),
state: "open".into(),
user: None,
created_at: "2026-05-30T00:00:00Z".into(),
updated_at: "2026-05-31T00:00:00Z".into(),
base: None,
head: None,
requested_reviewers: vec![RestUserDto { login: None }],
mergeable: None,
additions: None,
deletions: None,
changed_files: None,
draft: false,
merged_at: None,
merge_commit_sha: None,
};
let pr = rest_pull_request(&pull, Vec::new(), Vec::new(), Vec::new());
assert_eq!(pr.base_ref, "");
assert_eq!(pr.head_ref, "");
assert!(pr.requested_reviewers.is_empty());
assert_eq!(pr.review_decision, None);
assert_eq!(pr.merge_state, None);
assert_eq!(pr.additions, 0);
assert_eq!(pr.deletions, 0);
assert!(pr.commits.is_empty());
assert!(pr.checks.is_empty());
assert!(pr.files.is_empty());
assert!(pr
.metadata
.iter()
.any(|item| item.label == "Draft" && item.value == "no"));
assert!(!pr.metadata.iter().any(|item| item.label == "Changed files"));
}
}