tga 10.3.0

Developer productivity analytics — git commit collection, classification, and reporting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! The tga → trusty-review DD-manifest adapter (DOC-67 §6, #5236).
//!
//! Why: `tga audit` and trusty-review are separate processes with no Cargo edge
//! between them (DOC-67 §5). One TOML file is the entire contract: tga names the
//! engagement and the repositories, trusty-review renders. Keeping the builder
//! pure — data in, structure out, the caller writes the file — is what makes the
//! field mapping provable in unit tests instead of only observable by running a
//! full audit.
//! What: [`DdManifest`] and its two sections, [`DdManifestOptions`] for the
//! run-scoped engagement metadata, and [`build_dd_manifest`], which maps a
//! resolved tga [`Config`] onto trusty-review's manifest schema per §6's
//! field table. [`DdManifest::to_toml`] serializes.
//! Test: `super::dd_manifest_tests`.
//!
//! ## Two properties a reviewer should check first
//!
//! **No credential reaches the file.** The manifest is handed to a third party.
//! Every string this module emits passes through
//! [`trusty_common::credentials::scrub_secrets`] with the credentials the
//! config holds as needles, so a token that reached a repository name, a CLI
//! title, or a stage's error message is removed rather than merely unlikely to
//! be there. It is a guarantee about the output, not a claim about the inputs.
//!
//! That guarantee has one precondition the caller owes, because `scrub_secrets`
//! matches a credential's whole value: nothing may shorten a string between the
//! moment it is captured and the moment it arrives here. Stage messages are the
//! one channel that shortens — [`crate::audit::sweep_gap_lines`] excerpts them —
//! so it redacts first, using [`configured_secrets`] on the same config (#5239).
//!
//! **The same input yields the same bytes** (DOC-67 §9). Nothing here reads the
//! clock, the environment, or the filesystem, and every collection is an
//! ordered `Vec` walked in config order. The one machine-dependent value is the
//! repository path, which is load-bearing — trusty-review scans that checkout —
//! and so is emitted as configured.

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

use serde::Serialize;
use trusty_common::credentials::scrub_secrets;

use crate::core::config::Config;

/// Failures the DD-manifest adapter can report.
///
/// Why: a library module, so a typed error rather than `anyhow` — the caller
/// (`tga audit`) turns these into operator-facing text.
/// What: an empty repository set (the manifest schema requires at least one
/// entry, so producing it would only move the failure into trusty-review with a
/// less actionable message), and TOML serialization failure.
/// Test: `super::dd_manifest_tests::empty_config_is_an_actionable_error`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DdManifestError {
    /// The resolved config names no repositories.
    #[error(
        "no repositories to audit: add entries under `repositories:` in config.yaml \
         (or let `tga install` discover them) before running `tga audit`"
    )]
    NoRepositories,

    /// The manifest could not be serialized as TOML.
    #[error("failed to serialize the DD manifest as TOML: {0}")]
    Serialize(#[from] toml::ser::Error),

    /// An existing `manifest.toml` could not be merged into (#6190).
    ///
    /// Why: the only alternative is the replacing write this ticket removed, so
    /// refusing is deliberate — a file this crate cannot parse is a file whose
    /// contents it cannot promise to preserve.
    /// Test: `super::dd_manifest_merge_tests::an_unparseable_manifest_is_refused_not_replaced`.
    #[error(
        "the existing manifest could not be merged into ({0}); move it aside and re-run rather \
         than overwriting it — it may carry investigation scope this run does not own"
    )]
    MergeSource(String),
}

/// Engagement metadata for one audit run.
///
/// Why: DOC-67 §6 maps the report's title/analyst/client from CLI flags, and §2
/// forbids obtaining any of them interactively — so each is simply absent when
/// not supplied and the template renders its own fallback. `gaps` is the channel
/// §9 requires: areas the sweep could not assess, carried into the report rather
/// than left in the orchestrator's stderr.
/// What: the four run-scoped values; everything else comes from [`Config`].
/// Test: `super::dd_manifest_tests::maps_engagement_metadata`.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct DdManifestOptions {
    /// Report title, e.g. `"Acme — Technical Due Diligence"`.
    pub title: String,
    /// Analyst producing the report; `None` renders the template's fallback.
    pub analyst: Option<String>,
    /// Client the report is produced for; `None` renders the fallback.
    pub client: Option<String>,
    /// Gaps & Caveats lines for areas this run could not assess.
    pub gaps: Vec<String>,
    /// Ticketing artifact filename, relative to the manifest (#5405).
    ///
    /// `None` when the sweep's correlation stage failed and no artifact was
    /// written; see [`DdReportSection::ticketing`].
    pub ticketing: Option<PathBuf>,
    /// Directory a relative `RepositoryConfig.path` is relative to.
    ///
    /// Why: tga resolves a relative repository path against the process's
    /// working directory, and trusty-review resolves one against the MANIFEST's
    /// directory — and the manifest is written into the audit's output
    /// directory, which is a different place. Copying the path through verbatim
    /// therefore points the renderer at a checkout that does not exist, and its
    /// only symptom is a report with no analysis and no stated reason. The
    /// caller supplies its working directory; the builder does the join, so it
    /// still performs no I/O.
    /// What: prefixed onto every relative repository path. Absolute paths pass
    /// through untouched. Empty (the default) leaves paths as configured.
    /// Test: `super::dd_manifest_tests::relative_paths_are_anchored_to_base_dir`.
    pub base_dir: PathBuf,
}

/// A trusty-review report manifest, as tga produces it.
///
/// Why: mirrors `trusty_review::report::manifest`'s TOML shape without a Cargo
/// dependency on that crate (DOC-67 §5 — the file is the seam). Only the four
/// fields §6's table maps are declared; every other key trusty-review accepts is
/// deliberately absent so its own defaults apply.
/// What: the `[report]` section plus one `[[repositories]]` entry per configured
/// repository, in config order.
/// Test: `super::dd_manifest_tests::round_trips_through_the_review_schema`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DdManifest {
    /// The `[report]` metadata section.
    pub report: DdReportSection,
    /// One entry per audited repository, in config order.
    pub repositories: Vec<DdRepositoryEntry>,
}

/// The `[report]` section of a DD manifest.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DdReportSection {
    /// Report title (also the output slug seed).
    pub title: String,
    /// Analyst name; omitted from the TOML when absent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub analyst: Option<String>,
    /// Client name; omitted from the TOML when absent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client: Option<String>,
    /// Named unassessed areas; omitted from the TOML when empty.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub gaps: Vec<String>,
    /// Path to the run's ticketing artifact, relative to this manifest (#5405).
    ///
    /// Why: the board-correlation figures are database-wide, not per
    /// repository, so they belong to the `[report]` section rather than to a
    /// `[[repositories]]` entry. Routing them through `RepositoryEntry.metrics`
    /// instead would be actively wrong: that field's "declared metrics always
    /// win" precedence would block the live `--analyze` fetch for the repository
    /// carrying it.
    /// What: the filename [`crate::report::ticketing`] wrote beside this
    /// manifest, or `None` when the correlation stage failed — in which case the
    /// failure already reached `gaps` as a named stage failure, so the report
    /// states the absence rather than rendering a silently missing section.
    /// Omitted from the TOML when absent, which is what an older trusty-review
    /// (and a hand-written manifest) sees.
    /// Test: `super::dd_manifest_tests::round_trips_through_the_review_schema`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ticketing: Option<PathBuf>,
}

/// One audited repository.
///
/// Why: §6 fixes the mapping — every AUDIT repo is a local checkout by
/// construction, `slug` is trusty-review's to derive, `git_ref` is whatever HEAD
/// is at collection time, and `metrics` must stay unset so the live `--analyze`
/// fetch is not blocked by a declared file.
/// What: the name and the checkout path, and nothing else.
/// Test: `super::dd_manifest_tests::names_fall_back_to_the_directory_basename`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DdRepositoryEntry {
    /// Display name for the application section.
    pub name: String,
    /// Local checkout path trusty-review scans.
    pub path: PathBuf,
    /// Path to this repository's authorship artifact, relative to the
    /// manifest (#5453/#6004) — mirrors the DOC-67 §8 `velocity` field
    /// precedent: a new, separate, per-repository optional field rather than
    /// routing through `metrics` (whose "declared metrics always win"
    /// precedence would block the live `--analyze` fetch).
    ///
    /// Why: [`dd_repository_entries`] stays pure (no I/O, no database), so
    /// this starts `None` for every entry it builds; the caller
    /// (`commands::audit`) sets it after writing the artifact, once it has a
    /// database connection open. Omitted from the TOML when absent — the
    /// same backward-compatibility shape [`DdReportSection::ticketing`] uses
    /// — so an older trusty-review, or a manifest whose authorship stage
    /// failed, sees exactly what it saw before this field existed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub authorship: Option<PathBuf>,
}

impl DdManifest {
    /// Serialize to the TOML text trusty-review's `load_manifest` reads.
    ///
    /// Why: the caller writes the file; keeping serialization here means the
    /// determinism property is testable without touching disk.
    /// What: `toml::to_string_pretty` over the declared field order.
    /// Test: `super::dd_manifest_tests::two_builds_are_byte_identical`.
    ///
    /// # Errors
    ///
    /// [`DdManifestError::Serialize`] if a value cannot be represented in TOML
    /// (a non-UTF-8 repository path is the only realistic case).
    pub fn to_toml(&self) -> Result<String, DdManifestError> {
        Ok(toml::to_string_pretty(self)?)
    }

    /// The TOML to write, given whatever the output directory already holds.
    ///
    /// Why (#6190): `manifest.toml` has a second writer — trusty-audit's
    /// grounding pass — and this crate's write used to replace the whole file,
    /// which silently discarded that pass's investigation scope. Routing every
    /// write through one function is what keeps the replacing form from coming
    /// back at a second call site.
    /// What: [`super::dd_manifest_merge::merge_into`] when a manifest is already
    /// there, [`Self::to_toml`] when the file is new.
    /// Test: `super::dd_manifest_merge_tests`.
    ///
    /// # Errors
    ///
    /// [`DdManifestError::Serialize`], or [`DdManifestError::MergeSource`] when
    /// `existing` is present but not readable as TOML.
    pub fn to_toml_merged(&self, existing: Option<&str>) -> Result<String, DdManifestError> {
        match existing {
            Some(text) => super::dd_manifest_merge::merge_into(self, text),
            None => self.to_toml(),
        }
    }
}

/// Build the DD manifest for one audit run.
///
/// Why: this is the whole tga→trusty-review seam (DOC-67 §6). It exists as a
/// pure function so the field mapping, the determinism property, and the
/// no-credential property are provable from unit tests rather than from a live
/// audit — none of which would be true if it wrote the file itself.
///
/// What: maps `cfg.repositories` onto `[[repositories]]` in config order, taking
/// each entry's `name` or falling back to its directory basename, and fills
/// `[report]` from `opts`. Every emitted string is scrubbed of the credentials
/// `cfg` holds, so a token that leaked into a name, a title, or a gap line is
/// removed before it can reach an artifact. No I/O, no clock, no environment
/// read: two calls on the same input produce equal values.
///
/// Test: `super::dd_manifest_tests` — the field mapping, the basename fallback,
/// `two_builds_are_byte_identical`, and `configured_token_never_reaches_the_manifest`.
///
/// # Errors
///
/// [`DdManifestError::NoRepositories`] when the config names none.
pub fn build_dd_manifest(
    cfg: &Config,
    opts: &DdManifestOptions,
) -> Result<DdManifest, DdManifestError> {
    if cfg.repositories.is_empty() {
        return Err(DdManifestError::NoRepositories);
    }

    // #5236: the needle set is derived once, then applied to every string that
    // leaves this function — the guarantee is about the output, not about which
    // input fields we happened to remember are sensitive.
    let secrets = configured_secrets(cfg);
    let clean = |s: &str| scrub_secrets(s, &secrets);

    let repositories = dd_repository_entries(cfg, &opts.base_dir);

    Ok(DdManifest {
        report: DdReportSection {
            title: clean(&opts.title),
            analyst: opts.analyst.as_deref().map(&clean),
            client: opts.client.as_deref().map(&clean),
            gaps: opts.gaps.iter().map(|g| clean(g)).collect(),
            // #5405: a filename this crate chose, never operator text — so it
            // carries no credential to scrub.
            ticketing: opts.ticketing.clone(),
        },
        repositories,
    })
}

/// The `[[repositories]]` entries for one audit run.
///
/// Why: #5670 — `tga audit` indexes each repository before the renderer asks for
/// it, and the index id trusty-review looks up is derived from the checkout path
/// in THIS list. Sharing the mapping rather than re-deriving it beside the caller
/// is what makes the two agree by construction: index an anchored path the
/// manifest does not carry, or under a name the manifest does not use, and the
/// run indexes repositories nobody ever queries while still rendering hollow
/// sections.
/// What: one entry per configured repository, in config order, with the same
/// name fallback and the same base-dir anchoring [`build_dd_manifest`] emits,
/// scrubbed against the same needles. Pure: no I/O, no clock, no environment.
/// Test: `super::dd_manifest_tests::names_fall_back_to_the_directory_basename`,
/// and `crate::audit::tests::index_ids_match_the_manifest_paths_the_renderer_reads`.
pub fn dd_repository_entries(cfg: &Config, base_dir: &Path) -> Vec<DdRepositoryEntry> {
    let secrets = configured_secrets(cfg);
    cfg.repositories
        .iter()
        .map(|repo| DdRepositoryEntry {
            name: scrub_secrets(&repo_name(repo.name.as_deref(), &repo.path), &secrets),
            path: anchor(base_dir, &repo.path),
            authorship: None,
        })
        .collect()
}

/// Anchor a possibly-relative repository path to `base`.
///
/// Why/What: see [`DdManifestOptions::base_dir`]. An absolute path, or an empty
/// `base`, is returned unchanged; a pure join otherwise, with no filesystem
/// access and therefore no canonicalization.
/// Test: `super::dd_manifest_tests::relative_paths_are_anchored_to_base_dir`.
fn anchor(base: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() || base.as_os_str().is_empty() {
        path.to_path_buf()
    } else {
        base.join(path)
    }
}

/// The display name for a repository: its configured name, else the directory
/// basename (`config/mod.rs`'s own documented fallback).
///
/// Why: this is the ONE derivation of a repository's name in tga, and it has to
/// be, because two independent copies is a silent-zero join. `commits.
/// repository` is written by [`crate::collect::git::GitCollector`] at collection
/// time and read back by an equality filter at report time (#5453's per-repo
/// authorship artifact) — a name the two sides spell differently matches no rows
/// and renders a confident "0 authors, bus factor 0" instead of an error. The
/// collector calls this too since #5453's review; before that it had its own
/// copy, which disagreed on a configured name that was empty or whitespace.
/// What: the configured name when it is non-empty after trimming, else the
/// basename of the tilde-expanded path, else the path itself. Expansion happens
/// HERE so a caller holding the raw config path and a caller holding an
/// already-expanded one still agree.
/// Test: `super::dd_manifest_tests::{names_fall_back_to_the_directory_basename,
/// a_blank_configured_name_falls_back_the_same_way_for_every_caller}`.
pub fn repo_name(configured: Option<&str>, path: &Path) -> String {
    match configured.map(str::trim).filter(|n| !n.is_empty()) {
        Some(name) => name.to_string(),
        None => {
            let expanded = crate::core::config::expand_path(path);
            expanded
                .file_name()
                .map(|n| n.to_string_lossy().into_owned())
                .unwrap_or_else(|| expanded.display().to_string())
        }
    }
}

/// Every credential the resolved config holds, as scrub needles.
///
/// Why: `scrub_secrets` can only remove values the caller already knows, so the
/// needle set decides how much the guarantee is worth. These are the fields tga
/// itself reads to authenticate — the ones an error message or an expanded
/// `${GITHUB_TOKEN}` can carry into text this module emits. Public since #5239
/// because [`crate::audit::sweep_gap_lines`] must redact a stage message
/// *before* it excerpts it, and it has to redact against the same needles this
/// builder does — a second derivation here would be the drift the
/// common-entry-point rule exists to prevent.
/// What: the GitHub / Bitbucket / JIRA / Linear / Azure-DevOps / OpenRouter
/// credentials, skipping absent and empty values. Returns raw secrets: the only
/// correct use is as a needle set, never logged, displayed, or serialized.
/// Test: `super::dd_manifest_tests::configured_token_never_reaches_the_manifest`.
pub fn configured_secrets(cfg: &Config) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut push = |v: Option<&String>| {
        if let Some(v) = v.filter(|v| !v.is_empty()) {
            out.push(v.clone());
        }
    };

    push(cfg.github.as_ref().and_then(|g| g.token.as_ref()));
    push(cfg.bitbucket.as_ref().and_then(|b| b.token.as_ref()));
    push(cfg.bitbucket.as_ref().and_then(|b| b.app_password.as_ref()));
    push(cfg.jira.as_ref().and_then(|j| j.token.as_ref()));
    push(cfg.linear.as_ref().and_then(|l| l.api_key.as_ref()));
    push(
        cfg.classification
            .as_ref()
            .and_then(|c| c.openrouter_api_key.as_ref()),
    );
    if let Some(azdo) = cfg.pm.as_ref().and_then(|p| p.azure_devops.as_ref()) {
        if !azdo.pat.is_empty() {
            out.push(azdo.pat.clone());
        }
    }
    out
}

#[cfg(test)]
#[path = "dd_manifest_tests.rs"]
mod dd_manifest_tests;