Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ Point it at Linear, Sentry, Jira, GitLab, Discord, Slack, or GitHub review comme
- Daily, weekly, and monthly automated status reports
- Breakdown of attempts, success/failure rates, PR metrics, pending work
- Delivered via all configured notification channels
- Support digest: reads Discord support forum threads from the threads bot's Appwrite project, ranks the ones that need a reply and posts them to Claudear's Discord channel when one enters the list; it can also draft answers for admins to review on the dashboard's Support page; approved answers are posted by the threads project, never by Claudear (`[reports.support_digest]`)

### Daemon Mode & IPC
- Runs as a background service with full IPC control
Expand Down
55 changes: 55 additions & 0 deletions claudear.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1330,3 +1330,58 @@ hour = 9
# High-recurrence threshold: minimum Sentry event_count for an issue to qualify.
# Issues currently flagged escalating qualify regardless of this. (default: 50)
min_event_count = 50

[reports.support_digest]
# Digest of Discord support forum threads that need a reply.
#
# Reads the forum from the Appwrite project the threads bot syncs Discord into
# (github.com/appwrite-labs/threads), so Claudear needs no access to the forum.
# Ranks open threads by how urgently they need a reply (no reply yet, how long
# the poster has waited, bumps, production/billing/data-loss keywords, others
# hitting the same problem) and posts the top ones to the Discord notifier,
# mentioning notifiers.discord.user_id. Also lists threads whose poster said
# it's fixed, so they can be closed. It only posts when a thread enters the
# list, so an hourly scan stays quiet. Report-only: nothing is posted to the
# forum and threads never enter the fix pipeline.

# Enable the digest (default: false)
enabled = false

# The threads project. Appwrite's public one is
# https://fra.cloud.appwrite.io/v1, project 656dd556812fe2e5f4ed, database main.
endpoint = ""
project_id = ""
database_id = "main"

# API key with rows.read; only needed when the tables are not public.
api_key = ""

# Discord server the forum belongs to, used to link threads
# (Appwrite: 564160730845151244).
guild_id = ""

# Only threads active in the last N days (default: 14, at most 365)
days = 14

# How often to scan, in hours (default: 1.0, 0 = disable, at most 8760)
interval_hours = 1.0

# How many needs-reply threads to post (default: 10, at most 10)
max_entries = 10

# Replies from these Discord user ids count as the team's. Thread authors
# holding one of team_roles count too, but the authors table only has people
# who opened a thread, so list most staff here. With drafts on, include the
# threads bot too: otherwise an answer it posts reads as a community reply and
# the thread is drafted again.
team_user_ids = []
team_roles = ["Core"]

# Write a suggested answer for listed threads to the project's `drafts` table,
# using the QA agent with code search (default: false). Admins review, edit and
# approve them on the dashboard's Support page; the threads project then posts
# approved answers to the forum. Needs api_key with rows read/write.
drafts = false

# Most drafts written per scan (default: 3)
max_drafts = 3
118 changes: 118 additions & 0 deletions crates/claudear-config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,120 @@ impl Default for Config {
pub struct ReportsConfig {
/// Weekly digest of repetitive, non-actionable Sentry issues.
pub repetitive_digest: RepetitiveDigestConfig,
/// Digest of Discord support forum threads that need a reply.
pub support_digest: SupportDigestConfig,
}

/// Digest of Discord support forum threads that need a reply.
///
/// Reads the forum from the Appwrite project that the threads bot syncs Discord
/// into, so Claudear needs no access to the forum itself. Every `interval_hours`
/// it ranks open threads by how urgently they need a reply and posts the
/// ranking to the configured notifier(s) when a thread enters the top
/// `max_entries`. Report-only: nothing is posted to the forum and threads are
/// never fed into the fix pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SupportDigestConfig {
/// Whether the digest is enabled (default: false).
pub enabled: bool,
/// Appwrite endpoint of the threads project, e.g. "https://fra.cloud.appwrite.io/v1".
pub endpoint: String,
/// Appwrite project id of the threads project.
pub project_id: String,
/// Database holding the `threads`, `messages` and `authors` tables (default: "main").
pub database_id: String,
/// API key with `rows.read`; only needed when the tables are not public.
pub api_key: Option<SecretValue>,
/// Discord server id the forum belongs to, used to link threads.
pub guild_id: String,
/// Only threads active in the last N days (default: 14, at most 365).
pub days: i64,
/// How often to scan, in hours (default: 1.0, 0 = disable, at most 8760).
pub interval_hours: f64,
/// How many needs-reply threads to post (default: 10, at most 10).
pub max_entries: usize,
/// Discord user ids whose replies count as the team's.
pub team_user_ids: Vec<String>,
/// Discord role names; thread authors holding one count as team (default: ["Core"]).
pub team_roles: Vec<String>,
/// Write a suggested answer for listed threads to the project's `drafts`
/// table, for review in the dashboard (default: false). Needs `api_key`
/// with write access.
pub drafts: bool,
/// Most drafts written per scan (default: 3).
pub max_drafts: usize,
}

impl SupportDigestConfig {
/// Longest allowed scan interval: one year.
pub const MAX_INTERVAL_HOURS: f64 = 24.0 * 365.0;

/// Longest allowed activity window, in days.
pub const MAX_DAYS: i64 = 365;

/// Validate the threads project and the scan bounds.
pub fn validate(&self) -> Result<()> {
for (name, value) in [
("endpoint", &self.endpoint),
("project_id", &self.project_id),
("database_id", &self.database_id),
("guild_id", &self.guild_id),
] {
if value.trim().is_empty() {
return Err(Error::config(format!(
"reports.support_digest.{name} is required"
)));
}
}
if !self.interval_hours.is_finite()
|| !(0.0..=Self::MAX_INTERVAL_HOURS).contains(&self.interval_hours)
{
return Err(Error::config(format!(
"reports.support_digest.interval_hours must be between 0 and {}, got {}",
Self::MAX_INTERVAL_HOURS,
self.interval_hours
)));
}
if self.drafts
&& self
.api_key
.as_ref()
.is_none_or(|key| key.expose().trim().is_empty())
{
return Err(Error::config(
"reports.support_digest.api_key is required when drafts are enabled",
));
}
if !(1..=Self::MAX_DAYS).contains(&self.days) {
return Err(Error::config(format!(
"reports.support_digest.days must be between 1 and {}, got {}",
Self::MAX_DAYS,
self.days
)));
}
Ok(())
}
}

impl Default for SupportDigestConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: String::new(),
project_id: String::new(),
database_id: "main".to_string(),
api_key: None,
guild_id: String::new(),
days: 14,
interval_hours: 1.0,
max_entries: 10,
team_user_ids: Vec::new(),
team_roles: vec!["Core".to_string()],
drafts: false,
max_drafts: 3,
}
}
}

/// Weekly digest of repetitive, non-actionable Sentry issues.
Expand Down Expand Up @@ -3556,6 +3670,10 @@ impl Config {
self.deploy_qa.validate()?;
}

if self.reports.support_digest.enabled {
self.reports.support_digest.validate()?;
}

let has_github_token = self
.github()
.token
Expand Down
87 changes: 87 additions & 0 deletions crates/claudear-engine/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ pub fn create_api_router_full(
"/api/support/replies/{action_run_id}/rating",
axum::routing::post(rate_reply_handler),
)
.route("/api/support/drafts", get(support_drafts_handler))
.route(
"/api/support/drafts/{thread_id}",
axum::routing::post(review_draft_handler),
)
.route("/api/feedback", get(feedback_handler))
.route("/api/regressions", get(regressions_handler))
.route(
Expand Down Expand Up @@ -1452,6 +1457,88 @@ async fn rate_reply_handler(
Ok(StatusCode::NO_CONTENT)
}

/// Suggested answers to Discord support threads that wait for a reviewer, read
/// from the threads project. Empty when support drafts are off.
async fn support_drafts_handler(
_user: AuthUser,
State(state): State<ApiState>,
) -> Result<Json<Vec<crate::support_digest::DraftReview>>, StatusCode> {
let cfg = &state.config.reports.support_digest;
if !cfg.enabled || !cfg.drafts {
return Ok(Json(Vec::new()));
}
let store = crate::support_digest::ThreadsStore::new(cfg).map_err(|e| {
tracing::error!(error = %e, "Internal server error");
sentry::capture_error(&e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
store.drafts_to_review().await.map(Json).map_err(|e| {
tracing::error!(error = %e, "Internal server error");
sentry::capture_error(&e);
StatusCode::INTERNAL_SERVER_ERROR
})
}

#[derive(Deserialize)]
struct ReviewDraftRequest {
/// The `updated_at` of the draft the reviewer saw.
revision: String,
status: crate::support_digest::DraftStatus,
#[serde(default)]
answer: Option<String>,
}

/// Approve or reject a support draft, optionally with an edited answer. The
/// threads project posts an approved draft to its Discord thread. `409` when
/// the draft changed since the reviewer loaded it.
async fn review_draft_handler(
admin: AdminUser,
State(state): State<ApiState>,
Path(thread_id): Path<String>,
Json(body): Json<ReviewDraftRequest>,
) -> Result<StatusCode, StatusCode> {
use crate::support_digest::{DraftStatus, ReviewOutcome};

let cfg = &state.config.reports.support_digest;
if !cfg.enabled || !cfg.drafts {
return Err(StatusCode::NOT_FOUND);
}
// Thread ids are Discord snowflakes and end up in the Appwrite URL path.
if thread_id.is_empty() || !thread_id.chars().all(|c| c.is_ascii_digit()) {
return Err(StatusCode::BAD_REQUEST);
}
if !matches!(body.status, DraftStatus::Approved | DraftStatus::Rejected) {
return Err(StatusCode::BAD_REQUEST);
}
let answer = body
.answer
.as_deref()
.map(str::trim)
.filter(|answer| !answer.is_empty());

let internal = |e: claudear_core::error::Error| {
tracing::error!(error = %e, "Internal server error");
sentry::capture_error(&e);
StatusCode::INTERNAL_SERVER_ERROR
};
let store = crate::support_digest::ThreadsStore::new(cfg).map_err(internal)?;
match store
.review_draft(
&thread_id,
&body.revision,
body.status,
answer,
&admin.0.email,
)
.await
Comment thread
HarshMN2345 marked this conversation as resolved.
.map_err(internal)?
{
ReviewOutcome::Saved => Ok(StatusCode::NO_CONTENT),
ReviewOutcome::Missing => Err(StatusCode::NOT_FOUND),
ReviewOutcome::Stale => Err(StatusCode::CONFLICT),
}
}

async fn metrics_handler(
_user: AuthUser,
State(state): State<ApiState>,
Expand Down
26 changes: 26 additions & 0 deletions crates/claudear-engine/src/housekeeping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ impl HousekeepingWorker {
// daemon restarts during the target hour on the target weekday.
let mut digest_schedule = self.watcher.repetitive_digest_schedule();

// Support forum digest, scanned on its own interval from the first tick.
// A scan pages through the threads project and may run the QA agent, so
// it runs as its own task rather than holding up this loop; a new one
// starts only once the last finished, and shutdown drains it.
let support_digest_interval = self.watcher.support_digest_interval();
let mut last_support_digest: Option<Instant> = None;
let mut support_digest_task: Option<tokio::task::JoinHandle<()>> = None;

while self.watcher.is_running() {
timer.tick().await;
if !self.watcher.is_running() {
Expand Down Expand Up @@ -153,6 +161,24 @@ impl HousekeepingWorker {
}
};

// Discord support forum digest (report-only).
if !self.watcher.is_dry_run() {
if let Some(interval) = support_digest_interval {
let idle = support_digest_task
.as_ref()
.is_none_or(|task| task.is_finished());
if idle && last_support_digest.is_none_or(|at| at.elapsed() >= interval) {
let watcher = Arc::clone(&self.watcher);
support_digest_task = Some(tokio::spawn(async move {
if let Err(e) = watcher.send_support_digest().await {
tracing::error!(component = "digest", error = %e, "Error sending support digest");
}
}));
last_support_digest = Some(Instant::now());
}
}
}

let (_, _, housekeeping_ok, _, _) = tokio::join!(
auto_close_fut,
reviews_fut,
Expand Down
1 change: 1 addition & 0 deletions crates/claudear-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ pub mod processing;
pub mod repo_index;
pub mod retry;
pub mod shutdown;
pub mod support_digest;
pub mod watcher;
Loading
Loading