1use async_trait::async_trait;
8use serde::Serialize;
9
10use crate::{CollectorError, CollectorResult, ExceptionBatch, ExceptionReporter, ReportResult};
11
12#[derive(Debug)]
19pub struct GitHubReporter;
20
21impl GitHubReporter {
22 #[must_use]
24 pub fn new() -> Self {
25 Self
26 }
27
28 async fn create_issue(&self, repo: &str, title: &str, body: &str) -> CollectorResult<String> {
30 let output = tokio::process::Command::new("gh")
31 .args([
32 "issue", "create", "--repo", repo, "--title", title, "--body", body,
33 ])
34 .output()
35 .await
36 .map_err(|e| CollectorError::Reporter {
37 reason: format!("failed to run gh issue create: {e}"),
38 })?;
39
40 if !output.status.success() {
41 let stderr = String::from_utf8_lossy(&output.stderr);
42 return Err(CollectorError::Reporter {
43 reason: format!("gh issue create failed: {stderr}"),
44 });
45 }
46
47 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
48 Ok(stdout)
49 }
50
51 async fn comment_on_issue(
53 &self,
54 repo: &str,
55 issue_number: &str,
56 body: &str,
57 ) -> CollectorResult<()> {
58 let issue_ref = format!("{repo}#{issue_number}");
59 let output = tokio::process::Command::new("gh")
60 .args(["issue", "comment", &issue_ref, "--body", body])
61 .output()
62 .await
63 .map_err(|e| CollectorError::Reporter {
64 reason: format!("failed to run gh issue comment: {e}"),
65 })?;
66
67 if !output.status.success() {
68 let stderr = String::from_utf8_lossy(&output.stderr);
69 return Err(CollectorError::Reporter {
70 reason: format!("gh issue comment failed: {stderr}"),
71 });
72 }
73
74 Ok(())
75 }
76}
77
78impl Default for GitHubReporter {
79 fn default() -> Self {
80 Self
81 }
82}
83
84#[async_trait]
85impl ExceptionReporter for GitHubReporter {
86 async fn check_available(&self) -> bool {
88 which::which("gh").is_ok()
89 }
90
91 async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult> {
96 let repo = match &batch.target {
97 crate::ReportTarget::GitHub { repo, .. } => repo.clone(),
98 crate::ReportTarget::CustomPlatform { .. } => {
99 return Err(CollectorError::Reporter {
100 reason: "GitHubReporter requires ReportTarget::GitHub".to_string(),
101 });
102 }
103 };
104
105 let mut reported_count = 0usize;
106 let mut errors: Vec<String> = Vec::new();
107
108 for record in &batch.records {
109 if let Some(dup_url) = &record.duplicate_of {
110 let issue_number = dup_url.rsplit('/').next().unwrap_or("unknown");
111
112 let comment_body = format!(
113 "## 重复出现\n\n此异常仍在发生。\n\n- 组件: {}\n- 最近发生: {}\n",
114 record.component,
115 record.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
116 );
117
118 match self
119 .comment_on_issue(&repo, issue_number, &comment_body)
120 .await
121 {
122 Ok(()) => reported_count += 1,
123 Err(e) => errors.push(format!("comment failed for {dup_url}: {e}")),
124 }
125 } else {
126 let title = format!(
127 "[{}] {}: {}",
128 record.component,
129 record.kind,
130 truncate_for_title(&record.message, 80)
131 );
132 let body = build_issue_body(record);
133
134 match self.create_issue(&repo, &title, &body).await {
135 Ok(_url) => reported_count += 1,
136 Err(e) => errors.push(format!("issue create failed: {e}")),
137 }
138 }
139 }
140
141 let total = batch.records.len();
142 if reported_count == total {
143 Ok(ReportResult::Success)
144 } else if reported_count > 0 {
145 Ok(ReportResult::Partial { reported_count })
146 } else {
147 let reason = errors.join("; ");
148 let retry_at = chrono::Utc::now() + chrono::TimeDelta::minutes(5);
149 Ok(ReportResult::Failed { reason, retry_at })
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
161pub struct CustomPlatformReporter {
162 endpoint: String,
164}
165
166impl CustomPlatformReporter {
167 #[must_use]
169 pub fn new(endpoint: impl Into<String>) -> Self {
170 Self {
171 endpoint: endpoint.into(),
172 }
173 }
174}
175
176#[derive(Debug, Serialize)]
178struct ExceptionPayload<'a> {
179 component: &'a str,
180 batch_id: &'a str,
181 records: &'a [crate::ExceptionRecord],
182}
183
184#[async_trait]
185impl ExceptionReporter for CustomPlatformReporter {
186 async fn check_available(&self) -> bool {
188 !self.endpoint.is_empty()
189 }
190
191 async fn report(&self, batch: &ExceptionBatch) -> CollectorResult<ReportResult> {
193 let url = format!("{}/api/exceptions", self.endpoint);
194 let payload = ExceptionPayload {
195 component: &batch.component,
196 batch_id: &batch.id.to_string(),
197 records: &batch.records,
198 };
199 let body = serde_json::to_string(&payload).map_err(CollectorError::SerdeJson)?;
200
201 let result = tokio::task::spawn_blocking(
202 move || -> Result<ureq::Response, Box<dyn std::error::Error + Send + Sync>> {
203 ureq::post(&url)
204 .set("Content-Type", "application/json")
205 .send_string(&body)
206 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
207 },
208 )
209 .await
210 .map_err(|e| CollectorError::Reporter {
211 reason: format!("spawn_blocking join error: {e}"),
212 })?;
213
214 match result {
215 Ok(response) if response.status() < 400 => {
216 if batch.records.is_empty() {
217 Ok(ReportResult::Success)
218 } else {
219 Ok(ReportResult::Partial {
220 reported_count: batch.records.len(),
221 })
222 }
223 }
224 Ok(response) => {
225 let status = response.status();
226 Ok(ReportResult::Failed {
227 reason: format!("HTTP {status}"),
228 retry_at: chrono::Utc::now() + chrono::TimeDelta::minutes(5),
229 })
230 }
231 Err(e) => Ok(ReportResult::Failed {
232 reason: e.to_string(),
233 retry_at: chrono::Utc::now() + chrono::TimeDelta::minutes(5),
234 }),
235 }
236 }
237}
238
239fn truncate_for_title(msg: &str, max_len: usize) -> &str {
243 if msg.len() <= max_len {
244 msg
245 } else {
246 &msg[..max_len]
247 }
248}
249
250fn build_issue_body(record: &crate::ExceptionRecord) -> String {
252 format!(
253 "## 异常摘要\n{message}\n\n## 堆栈\n```\n{stacktrace}\n```\n\n## 影响范围\n- 组件: \
254 {component}\n- 模块: {module}\n- 位置: {location}\n- 时间: {timestamp}\n\n---\n> 由 \
255 TokenFleet Exception Collector 自动生成",
256 message = record.message,
257 stacktrace = record.stacktrace,
258 component = record.component,
259 module = record.module_path.as_deref().unwrap_or("unknown"),
260 location = record.location.as_deref().unwrap_or("unknown"),
261 timestamp = record.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
262 )
263}
264
265#[cfg(test)]
268#[allow(
269 clippy::unwrap_used,
270 clippy::unwrap_in_result,
271 clippy::expect_used,
272 clippy::panic,
273 clippy::pedantic,
274 clippy::wildcard_enum_match_arm,
275 reason = "test module relaxes production lint strictness"
276)]
277mod tests {
278 use super::*;
279 use crate::{ExceptionKind, ExceptionRecord};
280
281 fn make_record(message: &str) -> ExceptionRecord {
282 ExceptionRecord::new(
283 "test-comp",
284 ExceptionKind::ErrorLog,
285 message,
286 "stack trace line",
287 )
288 }
289
290 #[test]
293 fn test_github_reporter_should_create() {
294 let reporter = GitHubReporter::new();
295 let _ = reporter;
296 }
297
298 #[test]
299 fn test_github_reporter_should_be_send_sync() {
300 fn assert_send_sync<T: Send + Sync>() {}
301 assert_send_sync::<GitHubReporter>();
302 }
303
304 #[test]
307 fn test_custom_platform_reporter_should_create() {
308 let reporter = CustomPlatformReporter::new("https://example.com");
309 assert!(reporter.endpoint.contains("example.com"));
310 }
311
312 #[test]
313 fn test_custom_platform_reporter_should_be_cloneable() {
314 let reporter = CustomPlatformReporter::new("https://example.com");
315 let cloned = reporter.clone();
316 assert_eq!(cloned.endpoint, "https://example.com");
317 }
318
319 #[tokio::test]
320 async fn test_custom_platform_reporter_check_available_should_return_true() {
321 let reporter = CustomPlatformReporter::new("https://example.com");
322 assert!(reporter.check_available().await);
323 }
324
325 #[tokio::test]
326 async fn test_custom_platform_reporter_check_available_should_return_false_for_empty() {
327 let reporter = CustomPlatformReporter::new("");
328 assert!(!reporter.check_available().await);
329 }
330
331 #[test]
334 fn test_truncate_for_title_should_keep_short_messages() {
335 assert_eq!(truncate_for_title("short", 80), "short");
336 }
337
338 #[test]
339 fn test_truncate_for_title_should_truncate_long_messages() {
340 let long = "x".repeat(100);
341 let result = truncate_for_title(&long, 80);
342 assert_eq!(result.len(), 80);
343 }
344
345 #[test]
346 fn test_build_issue_body_should_include_component_and_message() {
347 let mut record = make_record("test error message");
348 record.module_path = Some("my::module".to_string());
349 record.location = Some("src/main.rs:42".to_string());
350
351 let body = build_issue_body(&record);
352 assert!(body.contains("test error message"));
353 assert!(body.contains("test-comp"));
354 assert!(body.contains("my::module"));
355 assert!(body.contains("src/main.rs:42"));
356 }
357}