Skip to content

Commit 5f9e197

Browse files
fix(compile): implement review suggestions for job/stage template targets
Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/fa2ca612-1703-4107-87c3-2b2ec35429b8 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 0aa186a commit 5f9e197

5 files changed

Lines changed: 189 additions & 142 deletions

File tree

docs/template-markers.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on
88

99
- **Standalone**: Uses `src/data/base.yml`
1010
- **1ES**: Uses `src/data/1es-base.yml`
11+
- **Job template**: Uses `src/data/job-base.yml`
12+
- **Stage template**: Uses `src/data/stage-base.yml`
1113

1214
Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ engine_run }}` denotes the full engine invocation command. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).
1315

@@ -495,3 +497,54 @@ Should be replaced with the domain the AWF-sandboxed agent uses to reach MCPG on
495497
The 1ES target uses the same template markers as standalone, plus the 1ES-specific `extends:` / `stages:` / `templateContext` wrapping. The 1ES template includes `templateContext.type: buildJob` for all jobs, and the pool is specified at the top-level `parameters.pool` rather than per-job.
496498

497499
Both targets share the same execution model (Copilot CLI + AWF + MCPG) and the same set of template markers.
500+
501+
## Job/Stage Template Markers
502+
503+
The `target: job` and `target: stage` targets use `job-base.yml` and `stage-base.yml`
504+
respectively. Both include all the standard AWF/MCPG markers above, plus the two
505+
template-specific markers below.
506+
507+
### {{ stage_prefix }}
508+
509+
Replaced with a PascalCase ADO-safe identifier derived from the agent `name:` front
510+
matter field. Used to prefix the three job names so that including multiple templates
511+
in the same pipeline produces unique job identifiers.
512+
513+
Derivation rules:
514+
515+
- Non-ASCII-alphanumeric characters are treated as word separators (they are not
516+
included in the output).
517+
- Each word is capitalised and the words are concatenated: `"daily code review"`
518+
`"DailyCodeReview"`.
519+
- An empty result (all characters stripped) falls back to `"Agent"`.
520+
- A result starting with a digit is prefixed with `_`: `"123start"` → `"_123start"`.
521+
- Names containing non-ASCII alphanumeric characters (e.g. `"über-agent"`) produce a
522+
compiler warning because those characters are silently dropped.
523+
524+
Example job names produced for `name: Daily Code Review`:
525+
526+
```yaml
527+
jobs:
528+
- job: DailyCodeReview_Agent
529+
- job: DailyCodeReview_Detection
530+
dependsOn: DailyCodeReview_Agent
531+
- job: DailyCodeReview_Execution
532+
dependsOn: [DailyCodeReview_Agent, DailyCodeReview_Detection]
533+
```
534+
535+
### {{ template_parameters }}
536+
537+
Replaced with the `parameters:` block that callers pass when including the template.
538+
Contains `clearMemory` (auto-injected when `tools.cache-memory` is configured) and any
539+
user-defined `parameters:` from front matter. Replaced with an empty string when no
540+
parameters are needed.
541+
542+
Example output when `tools.cache-memory` is configured:
543+
544+
```yaml
545+
parameters:
546+
- name: clearMemory
547+
displayName: Clear agent memory
548+
type: boolean
549+
default: false
550+
```

src/compile/common.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,19 @@ pub const DEFAULT_POOL: &str = "AZS-1ES-L-MMS-ubuntu-22.04";
10101010
/// - `"123start"` → `"_123start"` (prefix underscore for leading digit)
10111011
/// - `"über-agent"` → `"BerAgent"` (non-ASCII stripped; ADO requires `[A-Za-z0-9_]`)
10121012
pub fn generate_stage_prefix(name: &str) -> String {
1013+
// Warn if any Unicode alphanumeric characters are present — they will be
1014+
// treated as word-separator boundaries and stripped from the output, which
1015+
// may surprise users whose agent name starts with a non-ASCII letter.
1016+
if name.chars().any(|c| c.is_alphanumeric() && !c.is_ascii_alphanumeric()) {
1017+
log::warn!(
1018+
"Agent name '{}' contains non-ASCII alphanumeric characters; \
1019+
these are dropped from the ADO job-name prefix because ADO identifiers \
1020+
require [A-Za-z0-9_]. Rename the agent to use ASCII characters only \
1021+
if the prefix is important.",
1022+
name
1023+
);
1024+
}
1025+
10131026
let pascal: String = name
10141027
.split(|c: char| !c.is_ascii_alphanumeric())
10151028
.filter(|s| !s.is_empty())
@@ -2483,6 +2496,19 @@ pub struct CompileConfig {
24832496
pub skip_header: bool,
24842497
}
24852498

2499+
/// Input configuration for [`compile_template_target`].
2500+
///
2501+
/// Groups the template-specific settings so that the function stays within
2502+
/// the seven-argument limit while remaining easy to extend.
2503+
pub struct TemplateTargetConfig<'a> {
2504+
/// Raw YAML template string (e.g. `job-base.yml` or `stage-base.yml`).
2505+
pub template: &'a str,
2506+
/// When true, the "Verify pipeline integrity" step is omitted.
2507+
pub skip_integrity: bool,
2508+
/// When true, MCPG debug diagnostics are included in the generated pipeline.
2509+
pub debug_pipeline: bool,
2510+
}
2511+
24862512
/// Shared compilation flow used by both standalone and 1ES compilers.
24872513
///
24882514
/// This function handles the common pipeline compilation steps:
@@ -2771,6 +2797,85 @@ pub async fn compile_shared(
27712797
}
27722798
}
27732799

2800+
/// Shared compilation flow for template-producing compilers (`target: job` and
2801+
/// `target: stage`).
2802+
///
2803+
/// Handles the full setup — collecting extensions, building the compile context,
2804+
/// generating the stage prefix and template parameters, computing AWF/MCPG
2805+
/// values — and delegates to [`compile_shared`]. The caller supplies:
2806+
///
2807+
/// - `cfg`: target-specific settings (template string, integrity / debug flags).
2808+
/// - `header_fn`: a function that generates the leading comment block prepended
2809+
/// to the compiled YAML. The two template compilers use different header
2810+
/// layouts, so this lets each compiler keep its own generator while sharing
2811+
/// all of the boilerplate setup.
2812+
///
2813+
/// Returns the final YAML string with the header prepended.
2814+
pub async fn compile_template_target(
2815+
input_path: &Path,
2816+
output_path: &Path,
2817+
front_matter: &FrontMatter,
2818+
markdown_body: &str,
2819+
cfg: TemplateTargetConfig<'_>,
2820+
header_fn: impl FnOnce(&Path, &FrontMatter) -> String,
2821+
) -> Result<String> {
2822+
// Collect extensions (needed before compile_shared for MCPG config)
2823+
let extensions = super::extensions::collect_extensions(front_matter);
2824+
2825+
// Build compile context for MCPG config generation
2826+
let input_dir = input_path.parent().unwrap_or(Path::new("."));
2827+
let ctx = CompileContext::new(front_matter, input_dir).await?;
2828+
2829+
// Generate stage prefix for job-name uniqueness and template parameters
2830+
let stage_prefix = generate_stage_prefix(&front_matter.name);
2831+
let template_params = generate_template_parameters(front_matter)?;
2832+
2833+
// AWF / MCPG values (same as standalone)
2834+
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
2835+
let awf_mounts = generate_awf_mounts(&extensions);
2836+
let awf_paths = collect_awf_path_prepends(&extensions);
2837+
let awf_path_step = generate_awf_path_step(&awf_paths);
2838+
let enabled_tools_args = generate_enabled_tools_args(front_matter);
2839+
2840+
let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
2841+
let mcpg_config_json =
2842+
serde_json::to_string_pretty(&config_obj).context("Failed to serialize MCPG config")?;
2843+
let mcpg_docker_env = generate_mcpg_docker_env(front_matter, &extensions);
2844+
let mcpg_step_env = generate_mcpg_step_env(&extensions);
2845+
2846+
let config = CompileConfig {
2847+
template: cfg.template.to_string(),
2848+
extra_replacements: vec![
2849+
("{{ stage_prefix }}".into(), stage_prefix),
2850+
("{{ template_parameters }}".into(), template_params),
2851+
("{{ firewall_version }}".into(), AWF_VERSION.into()),
2852+
("{{ mcpg_version }}".into(), MCPG_VERSION.into()),
2853+
("{{ mcpg_image }}".into(), MCPG_IMAGE.into()),
2854+
("{{ mcpg_port }}".into(), MCPG_PORT.to_string()),
2855+
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
2856+
("{{ allowed_domains }}".into(), allowed_domains),
2857+
("{{ awf_mounts }}".into(), awf_mounts),
2858+
("{{ awf_path_step }}".into(), awf_path_step),
2859+
("{{ enabled_tools_args }}".into(), enabled_tools_args),
2860+
("{{ mcpg_config }}".into(), mcpg_config_json),
2861+
("{{ mcpg_docker_env }}".into(), mcpg_docker_env),
2862+
("{{ mcpg_step_env }}".into(), mcpg_step_env),
2863+
],
2864+
skip_integrity: cfg.skip_integrity,
2865+
debug_pipeline: cfg.debug_pipeline,
2866+
has_awf_paths: !awf_paths.is_empty(),
2867+
skip_header: true,
2868+
};
2869+
2870+
let yaml = compile_shared(
2871+
input_path, output_path, front_matter, markdown_body,
2872+
&extensions, &ctx, config,
2873+
).await?;
2874+
2875+
let header = header_fn(input_path, front_matter);
2876+
Ok(format!("{}{}", header, yaml))
2877+
}
2878+
27742879
#[cfg(test)]
27752880
mod tests {
27762881
use super::*;

src/compile/job.rs

Lines changed: 15 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,14 @@
77
//! - Directly in a flat pipeline's `jobs:` list
88
//! - Inside a user-defined stage's `jobs:` list
99
10-
use anyhow::{Context, Result};
10+
use anyhow::Result;
1111
use async_trait::async_trait;
12-
use log::{info, warn};
12+
use log::warn;
1313
use std::path::Path;
1414

1515
use super::Compiler;
1616
use super::common::{
17-
AWF_VERSION, MCPG_VERSION, MCPG_IMAGE, MCPG_PORT, MCPG_DOMAIN,
18-
CompileConfig, compile_shared,
19-
generate_allowed_domains,
20-
generate_awf_mounts,
21-
generate_awf_path_step,
22-
collect_awf_path_prepends,
23-
generate_enabled_tools_args,
24-
generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env,
25-
generate_stage_prefix, generate_template_parameters,
17+
compile_template_target, TemplateTargetConfig,
2618
generate_header_comment,
2719
};
2820
use super::types::FrontMatter;
@@ -45,70 +37,22 @@ impl Compiler for JobCompiler {
4537
skip_integrity: bool,
4638
debug_pipeline: bool,
4739
) -> Result<String> {
48-
info!("Compiling for job template target");
49-
5040
if front_matter.on_config.is_some() {
5141
warn!("on: trigger configuration is ignored for target: job (triggers are the parent pipeline's concern)");
5242
}
5343

54-
// Collect extensions (needed before compile_shared for MCPG config)
55-
let extensions = super::extensions::collect_extensions(front_matter);
56-
57-
// Build compile context for MCPG config generation
58-
let input_dir = input_path.parent().unwrap_or(std::path::Path::new("."));
59-
let ctx = super::extensions::CompileContext::new(front_matter, input_dir).await?;
60-
61-
// Generate stage prefix for job-name uniqueness
62-
let stage_prefix = generate_stage_prefix(&front_matter.name);
63-
64-
// Generate template-level parameters
65-
let template_params = generate_template_parameters(front_matter)?;
66-
67-
// Same AWF/MCPG values as standalone
68-
let allowed_domains = generate_allowed_domains(front_matter, &extensions)?;
69-
let awf_mounts = generate_awf_mounts(&extensions);
70-
let awf_paths = collect_awf_path_prepends(&extensions);
71-
let awf_path_step = generate_awf_path_step(&awf_paths);
72-
let enabled_tools_args = generate_enabled_tools_args(front_matter);
73-
74-
let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?;
75-
let mcpg_config_json =
76-
serde_json::to_string_pretty(&config_obj).context("Failed to serialize MCPG config")?;
77-
let mcpg_docker_env = generate_mcpg_docker_env(front_matter, &extensions);
78-
let mcpg_step_env = generate_mcpg_step_env(&extensions);
79-
80-
let config = CompileConfig {
81-
template: include_str!("../data/job-base.yml").to_string(),
82-
extra_replacements: vec![
83-
("{{ stage_prefix }}".into(), stage_prefix),
84-
("{{ template_parameters }}".into(), template_params),
85-
("{{ firewall_version }}".into(), AWF_VERSION.into()),
86-
("{{ mcpg_version }}".into(), MCPG_VERSION.into()),
87-
("{{ mcpg_image }}".into(), MCPG_IMAGE.into()),
88-
("{{ mcpg_port }}".into(), MCPG_PORT.to_string()),
89-
("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()),
90-
("{{ allowed_domains }}".into(), allowed_domains),
91-
("{{ awf_mounts }}".into(), awf_mounts),
92-
("{{ awf_path_step }}".into(), awf_path_step),
93-
("{{ enabled_tools_args }}".into(), enabled_tools_args),
94-
("{{ mcpg_config }}".into(), mcpg_config_json),
95-
("{{ mcpg_docker_env }}".into(), mcpg_docker_env),
96-
("{{ mcpg_step_env }}".into(), mcpg_step_env),
97-
],
98-
skip_integrity,
99-
debug_pipeline,
100-
has_awf_paths: !awf_paths.is_empty(),
101-
skip_header: true,
102-
};
103-
104-
let yaml = compile_shared(
105-
input_path, output_path, front_matter, markdown_body,
106-
&extensions, &ctx, config,
107-
).await?;
108-
109-
// Prepend custom header with job-template usage instructions
110-
let header = generate_job_header(input_path, front_matter);
111-
Ok(format!("{}{}", header, yaml))
44+
compile_template_target(
45+
input_path,
46+
output_path,
47+
front_matter,
48+
markdown_body,
49+
TemplateTargetConfig {
50+
template: include_str!("../data/job-base.yml"),
51+
skip_integrity,
52+
debug_pipeline,
53+
},
54+
generate_job_header,
55+
).await
11256
}
11357
}
11458

src/compile/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ async fn compile_pipeline_inner(
196196
CompileTarget::Job => Box::new(job::JobCompiler),
197197
CompileTarget::Stage => Box::new(stage::StageCompiler),
198198
};
199+
info!("Using {} compiler", compiler.target_name());
199200

200201
// Compile (no source mutation yet — a failure here must leave the
201202
// source byte-identical).

0 commit comments

Comments
 (0)