Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### `makegov-tango`

#### Added

- **Award announcements** (Tango API 5.14.0). `list_award_announcements`, `iterate_award_announcements` and `get_award_announcement` read `/api/news/award_announcements/`, with `ListAwardAnnouncementsOptions` naming all twenty of the API's filters plus `ordering` (`published_date`, `amount`, `rank`), `GetAwardAnnouncementOptions`, and `SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL` / `SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE` mirroring the API's list and detail defaults. Every row names its `source`: `official_release` rows are the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text.
- **`award_announcement` alerts** (Tango API 5.14.0). `create_webhook_alert` accepts the `award_announcement` query type, which emits `alerts.award_announcement.match`.

## [0.4.0] — 2026-10-29

### `makegov-tango`
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs
| DIBBS (DLA) | `list_dibbs_rfqs` / `list_dibbs_rfps` / `list_dibbs_awards` | `get_dibbs_rfq` / `get_dibbs_rfp` / `get_dibbs_award` | `iterate_dibbs_*` |
| GSA eBuy | `list_ebuy_requests` | `get_ebuy_request` | `iterate_ebuy_requests` |
| Exclusions | `list_exclusions` | `get_exclusion` | `iterate_exclusions` |
| Award announcements | `list_award_announcements` | `get_award_announcement` | `iterate_award_announcements` |
| Federal Register | `list_federal_register_documents` | `get_federal_register_document` | `iterate_federal_register_documents` |
| SBIR / STTR | `list_sbir_topics` / `list_sbir_solicitations` | `get_sbir_topic` / `get_sbir_solicitation` | `iterate_sbir_*` |
| State & local (SLED) | `list_sled_opportunities` / `list_sled_forecasts` | `get_sled_opportunity` / `get_sled_forecast` | `iterate_sled_*` |
Expand All @@ -300,6 +301,8 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs

Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `get_ebuy_attachment_url` / `get_ebuy_access`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `list_opportunities` with `search` also matches attachment text. Metrics dispatcher: `list_metrics`.

Award announcements (Tango API 5.14.0) name their `source` on every row: `official_release` rows are the Department of War's daily contracts release, and `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text.

See [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) for full signatures, filter fields, and quirks.

## Workspace layout
Expand Down
18 changes: 9 additions & 9 deletions crates/tango/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ pub use error::{Error, ErrorBody, Result};
pub use internal::ListOptions;
pub use pagination::{Page, PageStream};
pub use shapes::{
DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL,
SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_DIBBS_AWARDS_MINIMAL, SHAPE_DIBBS_RFPS_MINIMAL,
SHAPE_DIBBS_RFQS_MINIMAL, SHAPE_EBUY_REQUESTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE,
SHAPE_ENTITIES_MINIMAL, SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FEDERAL_REGISTER_COMPREHENSIVE,
SHAPE_FEDERAL_REGISTER_MINIMAL, SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL,
SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL,
SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL,
SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, SHAPE_ORGANIZATIONS_MINIMAL,
SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL,
DEFAULT_BASE_URL, SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE, SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL,
SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL, SHAPE_CONTRACT_APPEALS_MINIMAL,
SHAPE_DIBBS_AWARDS_MINIMAL, SHAPE_DIBBS_RFPS_MINIMAL, SHAPE_DIBBS_RFQS_MINIMAL,
SHAPE_EBUY_REQUESTS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL,
SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FEDERAL_REGISTER_COMPREHENSIVE, SHAPE_FEDERAL_REGISTER_MINIMAL,
SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL,
SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE,
SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL,
SHAPE_ORGANIZATIONS_MINIMAL, SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL,
SHAPE_SBIR_SOLICITATIONS_MINIMAL, SHAPE_SBIR_TOPICS_MINIMAL,
SHAPE_SLED_FORECASTS_COMPREHENSIVE, SHAPE_SLED_FORECASTS_MINIMAL,
SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE, SHAPE_SLED_OPPORTUNITIES_MINIMAL,
Expand Down
318 changes: 318 additions & 0 deletions crates/tango/src/resources/award_announcements.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
//! `GET /api/news/award_announcements/` — federal award announcements from two sources, named on every row by `source` (Tango API 5.14.0).
//!
//! `official_release` rows are the awards of $7.5 million or more that the Department of War publishes in its daily contracts release, with the text as published.
//!
//! `makegov_generated` rows cover new civilian IDVs of $25 million or more and every new OTA and OT IDV.
//! They are written by MakeGov from FPDS data with a language model and are **not government text**.
//! A generated announcement is dropped when an official release covers the same award.
//!
//! Lists hide announcements DoD later withdrew unless [`ListAwardAnnouncementsOptions::include_withdrawn`] is `Some(true)`; a withdrawn announcement stays retrievable by its `uuid`.
//!
//! `text` can run past 16,000 characters, so a list carries it only when named in `shape`.

use crate::client::Client;
use crate::error::{Error, Result};
use crate::internal::{apply_pagination, push_opt, push_opt_bool};
use crate::pagination::{FetchFn, Page, PageStream};
use crate::resources::agencies::urlencoding;
use crate::Record;
use bon::Builder;
use std::collections::BTreeMap;
use std::sync::Arc;

/// Options for [`Client::list_award_announcements`] and [`Client::iterate_award_announcements`].
///
/// All twenty of the API's filters are named fields, plus `ordering`.
/// This endpoint pages by `page`, so there is no `cursor` option.
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListAwardAnnouncementsOptions {
/// 1-based page number.
#[builder(into)]
pub page: Option<u32>,
/// Page size (server default 10, cap 100).
#[builder(into)]
pub limit: Option<u32>,
/// Comma-separated field selector. Use [`SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL`](crate::SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL) or roll your own. Name `text` to get the announcement body on a list.
#[builder(into)]
pub shape: Option<String>,
/// Collapse nested objects into dot-separated keys.
#[builder(default)]
pub flat: bool,
/// When [`flat`](Self::flat) is also true, flatten list-valued fields.
#[builder(default)]
pub flat_lists: bool,

/// Full-text search over the title and text, at least two characters; a match in the title ranks above a match in the text. Wrap in double quotes for a phrase.
#[builder(into)]
pub search: Option<String>,
/// `official_release` for the Department of War's daily contracts release, or `makegov_generated` for announcements written by MakeGov from FPDS data with a language model, which are not government text.
#[builder(into)]
pub source: Option<String>,
/// A Tango agency name, abbreviation, code or organization key for the awarding organization, e.g. `Navy`. OR several with `|`.
#[builder(into)]
pub agency: Option<String>,
/// The DoD component as printed in the official release's section header, e.g. `NAVY`. Case-insensitive exact match; OR several with `|`. Generated announcements have no component.
#[builder(into)]
pub component: Option<String>,
/// Published on or after (`YYYY-MM-DD`).
#[builder(into)]
pub published_date_after: Option<String>,
/// Published on or before (`YYYY-MM-DD`).
#[builder(into)]
pub published_date_before: Option<String>,
/// Awarded on or after (`YYYY-MM-DD`).
#[builder(into)]
pub award_date_after: Option<String>,
/// Awarded on or before (`YYYY-MM-DD`).
#[builder(into)]
pub award_date_before: Option<String>,
/// Amount at least, in USD.
#[builder(into)]
pub amount_min: Option<String>,
/// Amount at most, in USD.
#[builder(into)]
pub amount_max: Option<String>,
/// A contract number named in the announcement, e.g. `N00019-25-C-0070` or `N0001925C0070`. Dashes and case are ignored; OR several with `|`.
#[builder(into)]
pub piid: Option<String>,
/// Case-insensitive substring match on the awardee names, at least three characters.
#[builder(into)]
pub awardee: Option<String>,
/// An exact UEI of an awardee. Official releases print no UEI, so this matches only where one was resolved.
#[builder(into)]
pub uei: Option<String>,
/// Action type, e.g. `new_award`. OR several with `|`.
#[builder(into)]
pub action_type: Option<String>,
/// Announcement type. OR several with `|`.
#[builder(into)]
pub announcement_type: Option<String>,
/// Announces a civilian award.
pub civilian: Option<bool>,
/// `Some(true)` keeps announcements with at least one small-business awardee. `Some(false)` keeps those whose awardees are stated not to be small. Generated announcements state neither, so they match neither.
pub small_business: Option<bool>,
/// Announces a modification to an existing contract.
pub is_modification: Option<bool>,
/// Names more than one awardee.
pub is_multiple_award: Option<bool>,
/// `Some(true)` includes announcements DoD later withdrew, which lists hide otherwise.
pub include_withdrawn: Option<bool>,

/// Sort key: `published_date`, `amount` or `rank`, prefixed with `-` for descending. The server default is `-published_date`, or `-rank` under a [`search`](Self::search). `rank` requires a non-empty `search`, and descending `published_date` and `amount` put rows with no value last.
#[builder(into)]
pub ordering: Option<String>,

/// Escape hatch for filter keys not yet first-classed on this struct. The API answers an unknown key with a 400.
#[builder(default)]
pub extra: BTreeMap<String, String>,
}

impl ListAwardAnnouncementsOptions {
pub(crate) fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
apply_pagination(
&mut q,
self.page,
self.limit,
None,
self.shape.as_deref(),
self.flat,
self.flat_lists,
);
for (key, value) in [
("search", &self.search),
("source", &self.source),
("agency", &self.agency),
("component", &self.component),
("published_date_after", &self.published_date_after),
("published_date_before", &self.published_date_before),
("award_date_after", &self.award_date_after),
("award_date_before", &self.award_date_before),
("amount_min", &self.amount_min),
("amount_max", &self.amount_max),
("piid", &self.piid),
("awardee", &self.awardee),
("uei", &self.uei),
("action_type", &self.action_type),
("announcement_type", &self.announcement_type),
] {
push_opt(&mut q, key, value.as_deref());
}
push_opt_bool(&mut q, "civilian", self.civilian);
push_opt_bool(&mut q, "small_business", self.small_business);
push_opt_bool(&mut q, "is_modification", self.is_modification);
push_opt_bool(&mut q, "is_multiple_award", self.is_multiple_award);
push_opt_bool(&mut q, "include_withdrawn", self.include_withdrawn);
push_opt(&mut q, "ordering", self.ordering.as_deref());
for (k, v) in &self.extra {
if !v.is_empty() {
q.push((k.clone(), v.clone()));
}
}
q
}
}

/// Options for [`Client::get_award_announcement`].
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetAwardAnnouncementOptions {
/// Shape selector. When empty, the server returns its detail default, which [`SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE`](crate::SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE) mirrors.
#[builder(into)]
pub shape: Option<String>,
/// Flatten nested objects into dot-separated keys.
#[builder(default)]
pub flat: bool,
/// When `flat=true`, also flatten list-valued nested fields.
#[builder(default)]
pub flat_lists: bool,
}

impl GetAwardAnnouncementOptions {
pub(crate) fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
push_opt(&mut q, "shape", self.shape.as_deref());
if self.flat {
q.push(("flat".into(), "true".into()));
}
if self.flat_lists {
q.push(("flat_lists".into(), "true".into()));
}
q
}
}

impl Client {
/// `GET /api/news/award_announcements/` — one page of award announcements, newest publication first by default, or best match first under a `search`.
///
/// `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text; read `source` on each row, or filter on it with [`ListAwardAnnouncementsOptions::source`].
pub async fn list_award_announcements(
&self,
opts: ListAwardAnnouncementsOptions,
) -> Result<Page<Record>> {
let q = opts.to_query();
self.get_page("/api/news/award_announcements/", &q).await
}

/// `GET /api/news/award_announcements/{uuid}/` — one award announcement by its `uuid`, including one DoD later withdrew.
///
/// `makegov_generated` rows are written by MakeGov from FPDS data with a language model and are not government text.
pub async fn get_award_announcement(
&self,
announcement_uuid: &str,
opts: Option<GetAwardAnnouncementOptions>,
) -> Result<Record> {
if announcement_uuid.is_empty() {
return Err(Error::Validation {
message: "get_award_announcement: announcement_uuid is required".into(),
response: None,
});
}
let q = opts.unwrap_or_default().to_query();
let path = format!(
"/api/news/award_announcements/{}/",
urlencoding(announcement_uuid)
);
self.get_json::<Record>(&path, &q).await
}

/// Stream every award announcement matching `opts`.
pub fn iterate_award_announcements(
&self,
opts: ListAwardAnnouncementsOptions,
) -> PageStream<Record> {
let opts = Arc::new(opts);
let fetch: FetchFn<Record> = Box::new(move |client, page, _cursor| {
let mut next = (*opts).clone();
next.page = page;
Box::pin(async move { client.list_award_announcements(next).await })
});
PageStream::new(self.clone(), fetch)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
}

#[test]
fn list_award_announcements_all_filters_emit() {
let opts = ListAwardAnnouncementsOptions::builder()
.search("\"hypersonic\"")
.source("official_release")
.agency("Navy|Army")
.component("NAVY")
.published_date_after("2026-09-01")
.published_date_before("2026-09-30")
.award_date_after("2026-08-01")
.award_date_before("2026-08-31")
.amount_min("100000000")
.amount_max("500000000.50")
.piid("N00019-25-C-0070")
.awardee("lockheed")
.uei("ABCDEF123456")
.action_type("new_award")
.announcement_type("award")
.civilian(false)
.small_business(false)
.is_modification(true)
.is_multiple_award(false)
.include_withdrawn(true)
.ordering("-amount")
.build();
let q = opts.to_query();
for (k, v) in [
("search", "\"hypersonic\""),
("source", "official_release"),
("agency", "Navy|Army"),
("component", "NAVY"),
("published_date_after", "2026-09-01"),
("published_date_before", "2026-09-30"),
("award_date_after", "2026-08-01"),
("award_date_before", "2026-08-31"),
("amount_min", "100000000"),
("amount_max", "500000000.50"),
("piid", "N00019-25-C-0070"),
("awardee", "lockheed"),
("uei", "ABCDEF123456"),
("action_type", "new_award"),
("announcement_type", "award"),
("civilian", "false"),
("small_business", "false"),
("is_modification", "true"),
("is_multiple_award", "false"),
("include_withdrawn", "true"),
("ordering", "-amount"),
] {
assert_eq!(get_q(&q, k).as_deref(), Some(v), "{k}");
}
assert_eq!(q.len(), 21);
}

#[test]
fn list_award_announcements_zero_value_omitted() {
assert!(ListAwardAnnouncementsOptions::builder()
.build()
.to_query()
.is_empty());
}

#[test]
fn only_the_detail_shape_names_text() {
let names_text = |shape: &str| shape.split(',').any(|f| f == "text");
assert!(!names_text(crate::SHAPE_AWARD_ANNOUNCEMENTS_MINIMAL));
assert!(names_text(crate::SHAPE_AWARD_ANNOUNCEMENTS_COMPREHENSIVE));
}

#[tokio::test]
async fn empty_uuid_is_rejected_before_any_request() {
let client = Client::builder().api_key("x").build().expect("client");
let err = client.get_award_announcement("", None).await.unwrap_err();
assert!(matches!(err, Error::Validation { .. }));
}
}
2 changes: 2 additions & 0 deletions crates/tango/src/resources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! methods on [`Client`](crate::Client) and exports its `*Options` builders.

pub(crate) mod agencies;
pub(crate) mod award_announcements;
pub(crate) mod budget;
pub(crate) mod contract_appeals;
pub(crate) mod contracts;
Expand Down Expand Up @@ -34,6 +35,7 @@ pub use agencies::{
AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions,
ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions,
};
pub use award_announcements::{GetAwardAnnouncementOptions, ListAwardAnnouncementsOptions};
pub use budget::{
BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, ListBudgetAccountsOptions,
};
Expand Down
Loading
Loading