Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion engine/core/src/adapters/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1818,7 +1818,7 @@ mod tests {
fn a_domain_error_exits_one() {
let registry = default_registry();
let result = at_eighty(
&argv(&["place", "metrics"]),
&argv(&["engine", "status"]),
&registry,
"test",
&mut no_dispatch,
Expand Down
85 changes: 45 additions & 40 deletions engine/core/src/application/place.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,59 @@
//! `src/vogt/application/services/place.py`.
//!
//! `place.metrics` answers every shell badge in one bounded response. A badge
//! whose source is not ported yet is `null`, the same shape Python returns
//! when a provider raises: one unavailable answer never hides the others or
//! fails the route. The saved Inbox filter, the backlog count and the inbox
//! badge itself are those sources. `work.list` and `drift.list` are ported, so
//! their badges are real answers.
//! whose source fails is `null`, the same shape Python returns when a provider
//! raises: one unavailable answer never hides the others or fails the route.
//! Read order follows Python's, because the step clock is read along the way
//! and the answer's `generated_at` is the last of those reads.

use serde_json::{json, Value};

use super::context::Built;
use super::context::{AppContext, Built};
use super::services::inbox::inbox_badge;
use super::services::now_of;
use super::services::preferences::{saved_inbox_filter, InboxSavedFilter};
use super::views::backlog_total_considered;
use crate::core::{Clock, IdFactory};
use crate::errors::VogtError;
use crate::storage::interface::{DeclaredStore, ReadView};
use crate::storage::interface::{DeclaredStore, ReadView, WorkFilter};

/// Return all shell badge values in one bounded response.
pub fn place_metrics_op(ctx: &Built, _params: Value) -> Result<Value, VogtError> {
match ctx {
Built::SystemRandom(ctx) => metrics(ctx),
Built::SystemSequential(ctx) => metrics(ctx),
Built::StepRandom(ctx) => metrics(ctx),
Built::StepSequential(ctx) => metrics(ctx),
}
crate::with_ctx!(ctx, |ctx| metrics(ctx))
}

fn metrics<C: crate::core::Clock, I: crate::core::IdFactory>(
ctx: &super::context::AppContext<C, I>,
) -> Result<Value, VogtError> {
fn metrics<C, I>(ctx: &AppContext<C, I>) -> Result<Value, VogtError>
where
C: Clock + 'static,
I: IdFactory + 'static,
{
// The saved filter and the badge are read first, as Python does. An
// unreadable filter means "no filter", and an unreadable badge is null.
let saved: Option<InboxSavedFilter> = saved_inbox_filter(ctx).ok().flatten();
let badge = inbox_badge(ctx, saved.as_ref()).ok();
// The store counts, in Python's order. The backlog badge is the whole
// candidate population, which `backlog` ranks and reports.
let projects_total = ctx.declared.read()?.counts()?.projects;
let revision = ctx.declared.read()?.current_revision()?;
let generated_at = crate::application::services::now_of(&ctx.clock);
// `work.list` and `list_drift` are ported, so both badges are real answers.
// The inbox and backlog badges read services that are not, and a null there
// means "not available", which is distinct from a counted zero.
// The badge counts every open item, the same total `work.list` reports with
// no project scope and the default filter. The upstream join adds nothing
// when no project is linked, which is the only state the declared store can
// answer on its own.
let work_total =
ctx.declared
.read()?
.count_work_items(&crate::storage::interface::WorkFilter {
exclude_terminal: true,
limit: 1,
..crate::storage::interface::WorkFilter::default()
})?;
let work_total = ctx.declared.read()?.count_work_items(&WorkFilter {
exclude_terminal: true,
limit: 1,
..WorkFilter::default()
})?;
let backlog_total = backlog_total_considered(ctx).ok();
let drift_present = !ctx
.declared
.read()?
.list_drift(Some("open"), None, None, 1)?
.is_empty();
let revision = ctx.declared.read()?.current_revision()?;
let generated_at = now_of(&ctx.clock);
Ok(json!({
"inbox_active": Value::Null,
"inbox_active_unfiltered": Value::Null,
"inbox_filter": Value::Null,
"inbox_active": badge.map(|(filtered, _)| filtered),
"inbox_active_unfiltered": badge.map(|(_, active)| active),
"inbox_filter": saved,
"projects_total": projects_total,
"work_total": work_total,
"backlog_total_considered": Value::Null,
"backlog_total_considered": backlog_total,
"drift_present": drift_present,
"revision": revision,
"generated_at": generated_at,
Expand All @@ -67,9 +65,10 @@ fn metrics<C: crate::core::Clock, I: crate::core::IdFactory>(
mod tests {
use super::*;
use crate::core::{ActorKind, Principal, SequentialIds, StepClock};
use crate::storage::interface::ObservedStore;

#[test]
fn metrics_report_the_project_count_and_leave_unported_badges_absent() {
fn metrics_report_every_badge_as_a_counted_answer() {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
Expand Down Expand Up @@ -101,11 +100,17 @@ mod tests {
unreachable!()
};
ctx.declared.migrate().unwrap();
ctx.observed.migrate().unwrap();
ctx.declared.bootstrap(&principal).unwrap();
let result = place_metrics_op(&built, Value::Null).unwrap();
assert_eq!(result["projects_total"], 0);
assert!(result["inbox_active"].is_null());
// No work items exist, so the ported work badge answers zero.
// An empty Inbox and an empty backlog count as zero, not null: null
// would mean the provider failed.
assert_eq!(result["inbox_active"], 0);
assert_eq!(result["inbox_active_unfiltered"], 0);
assert!(result["inbox_filter"].is_null());
assert_eq!(result["backlog_total_considered"], 0);
// No work items exist, so the work badge answers zero.
assert_eq!(result["work_total"], 0);
// No proposals exist, so the ported drift badge answers false rather
// than null.
Expand Down
35 changes: 20 additions & 15 deletions engine/core/src/application/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
//!
//! `project.import` clones, registers and (with `consolidate: false`) stops there.
//! Consolidation waits on `forge.onboard`, which has no Rust port yet.
//! Ranked backlog is not ported, so the brief says `not_collected` for the
//! counts that come out of the gather rather than inventing a zero.

use std::path::{Component, Path, PathBuf};

Expand Down Expand Up @@ -54,9 +52,6 @@ const PROJECT_SCAFFOLDED_EVENT: &str = "project.scaffolded";
/// string is what the observed store is queried by.
const KIND_DEP_SCAN: &str = "dep_scan";

/// Ranked backlog is `views._gather`, which is not ported.
const BACKLOG_NOT_COLLECTED: &str = "ranked backlog is not ported yet";

// --- parameters -------------------------------------------------------------

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -930,6 +925,15 @@ fn brief_project<C: Clock, I: IdFactory>(
by_state.sort();
by_kind.sort();
let observed_version = observed_version(ctx, &project.id)?;
drop(view);
// The same population `backlog` and `bugs` rank, so the three views agree
// about one project at one moment.
let population = crate::application::views::brief_population(
ctx,
&project.slug,
params.backlog_limit as usize,
&params.mode,
)?;
let version_matches = match (&observed_version, &project.current_version) {
(Some(observed), Some(declared)) => {
Some(observed.trim_start_matches('v') == declared.trim_start_matches('v'))
Expand All @@ -939,15 +943,13 @@ fn brief_project<C: Clock, I: IdFactory>(
let adopted = project.contract_adopted_at.is_some();
Ok(json!({
"project": project,
// The gather that ranks open work is not ported. These are not zeros:
// a zero would say a sweep found nothing.
"open_work": "not_collected",
"open_bugs": "not_collected",
"declared_work": "not_collected",
"observed_work": "not_collected",
"open_work": population.open_work,
"open_bugs": population.open_bugs,
"declared_work": population.declared,
"observed_work": population.observed,
"by_state": tally_object(&by_state),
"by_kind": tally_object(&by_kind),
"top_backlog": {"status": "not_collected", "detail": BACKLOG_NOT_COLLECTED},
"top_backlog": population.top,
"current_version": project.current_version,
"declared_version": project.current_version,
"observed_version": observed_version,
Expand All @@ -961,8 +963,6 @@ fn brief_project<C: Clock, I: IdFactory>(
"ci_status": ci_summary(ctx, &project.id)?,
"dependencies": dependency_summary(ctx, &project.id)?,
"freshness": crate::application::services::freshness::freshness_of(&ctx.observed, &ctx.clock)?,
"backlog_limit": params.backlog_limit,
"mode": params.mode,
}))
}

Expand Down Expand Up @@ -1150,7 +1150,12 @@ fn apply_change<C: Clock + 'static, I: IdFactory + 'static>(
let summary = if change.event == PROJECT_TRANSITIONED_EVENT {
json!({"slug": project.slug, "from": project.lifecycle_state.to_string(), "to": change.lifecycle_state})
} else {
json!({"slug": project.slug, "exclusions": updated.exclusions, "repo_url": updated.repo_url})
json!({
"slug": project.slug,
"exclusions": updated.exclusions,
"repo_url": updated.repo_url,
"agent_policy": serde_json::to_value(&updated.agent_policy).unwrap_or(Value::Null),
})
};
let id = project.id.clone();
Ok(WriteOutcome::new(
Expand Down
48 changes: 48 additions & 0 deletions engine/core/src/application/services/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use serde_json::{json, Value};

use crate::actors::{self, ActorClass};
use crate::application::context::{AppContext, Built};
use crate::application::services::preferences::InboxSavedFilter;
use crate::application::services::{context_parts, dispatch, now_of, write_context};
use crate::application::writes::{audited_write, WriteOutcome};
use crate::core::{
Expand Down Expand Up @@ -52,6 +53,53 @@ pub fn inbox_restore_op(built: &Built, params: Value) -> Result<Value, VogtError
dispatch!(built, inbox_restore, params)
}

/// The Inbox badge as `place.metrics` reads it: the active count, and the
/// count under the caller's saved filter (`filtered`). It is the length the
/// Inbox list shows under the same filter, without the coverage and engine
/// reads `inbox.list` adds. A snooze that has run out counts as active.
pub(crate) fn inbox_badge<C, I>(
ctx: &AppContext<C, I>,
saved: Option<&InboxSavedFilter>,
) -> Result<(i64, i64), VogtError>
where
C: Clock + 'static,
I: IdFactory + 'static,
{
let (declared, observed, _, _, _, config) = context_parts(ctx);
let view = declared.read()?;
let mut entries = collect(ctx, observed, &view, config)?;
let triage = view.inbox_triage_by_keys(
&entries
.iter()
.map(|entry| text_of(entry, "entry_key"))
.collect::<Vec<_>>(),
)?;
for entry in &mut entries {
*entry = apply_triage(ctx, &triage, entry.clone());
}
let moment = now_of(&ctx.clock);
let active = ["active".to_string()];
let active_total = entries
.iter()
.filter(|entry| triage_matches(entry, &active, moment))
.count() as i64;
let filtered = match saved {
None => active_total,
Some(saved) => entries
.iter()
.filter(|entry| {
saved
.sources
.as_ref()
.is_none_or(|wanted| wanted.contains(&text_of(entry, "source")))
&& triage_matches(entry, &saved.triage_states, moment)
&& actor_matches(entry, &saved.actor)
})
.count() as i64,
};
Ok((filtered, active_total))
}

fn inbox_list<C, I>(ctx: &AppContext<C, I>, params: Value) -> Result<Value, VogtError>
where
C: Clock + 'static,
Expand Down
57 changes: 57 additions & 0 deletions engine/core/src/application/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,63 @@ pub(crate) fn candidate_population<C: Clock, I: IdFactory>(
Ok((gathered.ranked.len() as i64, gathered.declared))
}

/// What `project.brief` reads from the backlog gather for one project: the
/// open-work and open-bug counts, the declared and observed split, and the top
/// rows. Python's `brief_project` calls `_gather` with no filters and then
/// takes these from it, so this is the same gather `backlog` ranks.
pub(crate) struct BriefPopulation {
pub open_work: i64,
pub open_bugs: i64,
pub declared: i64,
pub observed: i64,
pub top: Vec<serde_json::Value>,
}

pub(crate) fn brief_population<C: Clock, I: IdFactory>(
ctx: &AppContext<C, I>,
project: &str,
backlog_limit: usize,
mode: &str,
) -> Result<BriefPopulation, VogtError> {
let query = GatherQuery {
project: Some(project),
kinds: Vec::new(),
priorities: Vec::new(),
assignee: None,
initiative: None,
label: None,
trust_states: Vec::new(),
include_prs: true,
};
let gathered = gather(ctx, &query)?;
let open_bugs = gathered
.ranked
.iter()
.filter(|row| row.value.get("kind").and_then(serde_json::Value::as_str) == Some("bug"))
.count() as i64;
Ok(BriefPopulation {
open_work: gathered.ranked.len() as i64,
open_bugs,
declared: gathered.declared,
observed: gathered.observed,
top: gathered
.ranked
.iter()
.take(backlog_limit)
.map(|row| projected(&row.value, mode))
.collect(),
})
}

/// `backlog(limit=1).total_considered`, the backlog badge `place.metrics`
/// reads. It is the whole candidate population, not the page.
pub(crate) fn backlog_total_considered<C: Clock, I: IdFactory>(
ctx: &AppContext<C, I>,
) -> Result<i64, VogtError> {
let answer = backlog(ctx, serde_json::json!({"limit": 1}))?;
Ok(answer["total_considered"].as_i64().unwrap_or(0))
}

/// The candidate population of a ranked view, scored and ordered. Ports
/// `_gather`: declared rows first, then the observed subjects that name no
/// declared work, with a PR collapsed under the item it implements.
Expand Down
10 changes: 9 additions & 1 deletion engine/core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ pub fn service_for(name: &str) -> Option<ServiceFn> {
"project.list" => Some(crate::application::projects::list_op),
"project.update" => Some(crate::application::projects::update_op),
"project.transition" => Some(crate::application::projects::transition_op),
"project.brief" => Some(crate::application::projects::brief_op),
"place.metrics" => Some(crate::application::place::place_metrics_op),
"label.create" => Some(crate::application::taxonomy::create_label_op),
"label.list" => Some(crate::application::taxonomy::list_labels_op),
"actor.create" => Some(crate::application::taxonomy::create_actor_op),
Expand Down Expand Up @@ -992,7 +994,7 @@ mod tests {
#[test]
fn not_ported_is_a_failure() {
let registry = registry();
let operation = registry.get("place.metrics").unwrap();
let operation = registry.get("engine.status").unwrap();
let error = operation.run(None, serde_json::json!({})).unwrap_err();
assert!(
error.message().contains("not been ported"),
Expand All @@ -1001,6 +1003,12 @@ mod tests {
);
}

#[test]
fn the_project_brief_and_place_metrics_handlers_are_wired() {
assert!(service_for("project.brief").is_some());
assert!(service_for("place.metrics").is_some());
}

#[test]
fn dump_names_every_operation_and_its_exclusion() {
let manifest = dump();
Expand Down
Loading
Loading