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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@ safe-outputs:
create-wiki-page:
wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID)
wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project
branch: "main" # Optional — git branch override; auto-detected for code wikis (see note below)
path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope)
title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title)
comment: "Created by agent" # Optional — default commit comment when agent omits one
Expand All @@ -981,6 +982,8 @@ safe-outputs:

Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message.

**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration.

#### update-wiki-page
Updates the content of an existing Azure DevOps wiki page. The wiki page must already exist; this tool edits its content but does not create new pages.

Expand All @@ -995,6 +998,7 @@ safe-outputs:
update-wiki-page:
wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID)
wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project
branch: "main" # Optional — git branch override; auto-detected for code wikis (see note below)
path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope)
title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title)
comment: "Updated by agent" # Optional — default commit comment when agent omits one
Expand All @@ -1003,6 +1007,8 @@ safe-outputs:

Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message.

**Code wikis vs project wikis:** The executor automatically detects code wikis (type 1) and resolves the published branch from the wiki metadata. You only need to set `branch` explicitly to override the auto-detected value (e.g. targeting a non-default branch). Project wikis (type 0) need no branch configuration.

### Adding New Features

When extending the compiler:
Expand Down
64 changes: 55 additions & 9 deletions src/tools/create_wiki_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::PATH_SEGMENT;
use super::resolve_wiki_branch;
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
use crate::tool_result;
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
Expand Down Expand Up @@ -108,6 +109,12 @@ pub struct CreateWikiPageConfig {
#[serde(default, rename = "wiki-project")]
pub wiki_project: Option<String>,

/// Git branch for the wiki. Required for **code wikis** (type 1) where the
/// ADO API demands an explicit `versionDescriptor`. For project wikis this
/// can be omitted (defaults to `wikiMaster` server-side).
#[serde(default)]
pub branch: Option<String>,

/// Security restriction: the agent may only create wiki pages whose paths
/// start with this prefix (e.g. `"/agent-output"`). Paths that do not match
/// are rejected at execution time. When omitted, no restriction is applied.
Expand Down Expand Up @@ -231,13 +238,34 @@ impl Executor for CreateWikiPageResult {

let client = reqwest::Client::new();

// Resolve the effective branch: explicit config → auto-detect from wiki
// metadata (code wikis need an explicit versionDescriptor).
let resolved_branch = match resolve_wiki_branch(
&client,
org_url,
project,
wiki_name,
token,
config.branch.as_deref(),
)
.await
{
Ok(b) => b,
Err(msg) => return Ok(ExecutionResult::failure(msg)),
};

// ── GET: check whether the page already exists ────────────────────────
let mut get_query: Vec<(&str, &str)> = vec![
("path", effective_path.as_str()),
("api-version", "7.0"),
];
if let Some(branch) = &resolved_branch {
get_query.push(("versionDescriptor.version", branch.as_str()));
get_query.push(("versionDescriptor.versionType", "branch"));
}
let get_resp = client
.get(&base_url)
.query(&[
("path", effective_path.as_str()),
("api-version", "7.0"),
])
.query(&get_query)
.basic_auth("", Some(token))
.send()
.await
Expand Down Expand Up @@ -276,13 +304,18 @@ impl Executor for CreateWikiPageResult {
// with 412 if this resource already exists", closing the TOCTOU race
// between our GET (404) and the PUT where a concurrent request could
// create the page first.
let mut put_query: Vec<(&str, &str)> = vec![
("path", effective_path.as_str()),
("comment", comment),
("api-version", "7.0"),
];
if let Some(branch) = &resolved_branch {
put_query.push(("versionDescriptor.version", branch.as_str()));
put_query.push(("versionDescriptor.versionType", "branch"));
}
let put_resp = client
.put(&base_url)
.query(&[
("path", effective_path.as_str()),
("comment", comment),
("api-version", "7.0"),
])
.query(&put_query)
.header("Content-Type", "application/json")
.header("If-Match", "")
.basic_auth("", Some(token))
Expand Down Expand Up @@ -495,6 +528,7 @@ mod tests {
let config = CreateWikiPageConfig::default();
assert!(config.wiki_name.is_none());
assert!(config.wiki_project.is_none());
assert!(config.branch.is_none());
assert!(config.path_prefix.is_none());
assert!(config.title_prefix.is_none());
assert!(config.comment.is_none());
Expand All @@ -512,11 +546,23 @@ comment: "Created by agent"
let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki"));
assert_eq!(config.wiki_project.as_deref(), Some("OtherProject"));
assert!(config.branch.is_none());
assert_eq!(config.path_prefix.as_deref(), Some("/agent-output"));
assert_eq!(config.title_prefix.as_deref(), Some("[Agent] "));
assert_eq!(config.comment.as_deref(), Some("Created by agent"));
}

#[test]
fn test_config_deserializes_with_branch() {
let yaml = r#"
wiki-name: "Azure Sphere"
branch: "main"
"#;
let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere"));
assert_eq!(config.branch.as_deref(), Some("main"));
}

#[test]
fn test_config_partial_deserialize_uses_defaults() {
let yaml = r#"
Expand Down
91 changes: 90 additions & 1 deletion src/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Tool parameter and result structs for MCP tools

use percent_encoding::{AsciiSet, CONTROLS};
use log::{debug, warn};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};

/// Characters to percent-encode in a URL path segment.
/// Encodes the structural delimiters that would break URL parsing if left raw:
Expand All @@ -9,6 +10,94 @@ use percent_encoding::{AsciiSet, CONTROLS};
/// types) against accidental corruption of the URL structure.
pub(crate) const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/').add(b' ');

/// Resolve the effective branch for a wiki.
///
/// If `configured_branch` is `Some`, that value is returned directly.
/// Otherwise the wiki metadata API is queried: code wikis (type&nbsp;1) return
/// the published branch from the `versions` array; project wikis (type&nbsp;0)
/// return `Ok(None)` because the server handles branching internally.
///
/// Returns `Err` when a code wiki is detected but the branch cannot be
/// resolved — callers should surface this as a user-facing failure rather
/// than proceeding to a confusing ADO PUT error.
pub(crate) async fn resolve_wiki_branch(
client: &reqwest::Client,
org_url: &str,
project: &str,
wiki_name: &str,
token: &str,
configured_branch: Option<&str>,
) -> Result<Option<String>, String> {
// Explicit configuration always wins.
if let Some(b) = configured_branch {
return Ok(Some(b.to_owned()));
}

let url = format!(
"{}/{}/_apis/wiki/wikis/{}",
org_url.trim_end_matches('/'),
utf8_percent_encode(project, PATH_SEGMENT),
utf8_percent_encode(wiki_name, PATH_SEGMENT),
);

let resp = match client
.get(&url)
.query(&[("api-version", "7.0")])
.basic_auth("", Some(token))
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!("Wiki metadata request failed: {e} — skipping branch auto-detection");
return Ok(None);
}
};

if !resp.status().is_success() {
warn!(
"Wiki metadata request returned HTTP {} — skipping branch auto-detection",
resp.status()
);
return Ok(None);
}

let body: serde_json::Value = match resp.json().await {
Ok(b) => b,
Err(e) => {
warn!("Failed to parse wiki metadata response: {e}");
return Ok(None);
}
};

// type 0 = project wiki, type 1 = code wiki
let wiki_type = body.get("type").and_then(|v| v.as_u64()).unwrap_or(0);
if wiki_type != 1 {
debug!("Wiki is a project wiki (type {wiki_type}) — no branch needed");
return Ok(None);
}

// Code wiki: extract the published branch from versions[0].version
let branch = body
.get("versions")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_owned());

match &branch {
Some(b) => {
debug!("Detected code wiki — resolved branch: {b}");
Ok(branch)
}
None => Err(format!(
"Wiki '{wiki_name}' is a code wiki but its published branch could not be \
determined. Set 'branch' explicitly in the safe-outputs config."
)),
}
}

mod comment_on_work_item;
mod create_pr;
mod create_wiki_page;
Expand Down
64 changes: 55 additions & 9 deletions src/tools/update_wiki_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::PATH_SEGMENT;
use super::resolve_wiki_branch;
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
use crate::tool_result;
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
Expand Down Expand Up @@ -104,6 +105,12 @@ pub struct UpdateWikiPageConfig {
#[serde(default, rename = "wiki-project")]
pub wiki_project: Option<String>,

/// Git branch for the wiki. Required for **code wikis** (type 1) where the
/// ADO API demands an explicit `versionDescriptor`. For project wikis this
/// can be omitted (defaults to `wikiMaster` server-side).
#[serde(default)]
pub branch: Option<String>,

/// Security restriction: the agent may only write wiki pages whose paths
/// start with this prefix (e.g. `"/agent-output"`). Paths that do not match
/// are rejected at execution time. When omitted, no restriction is applied.
Expand Down Expand Up @@ -227,13 +234,34 @@ impl Executor for UpdateWikiPageResult {

let client = reqwest::Client::new();

// Resolve the effective branch: explicit config → auto-detect from wiki
// metadata (code wikis need an explicit versionDescriptor).
let resolved_branch = match resolve_wiki_branch(
&client,
org_url,
project,
wiki_name,
token,
config.branch.as_deref(),
)
.await
{
Ok(b) => b,
Err(msg) => return Ok(ExecutionResult::failure(msg)),
};

// ── GET: check whether the page exists and obtain its ETag ────────────
let mut get_query: Vec<(&str, &str)> = vec![
("path", effective_path.as_str()),
("api-version", "7.0"),
];
if let Some(branch) = &resolved_branch {
get_query.push(("versionDescriptor.version", branch.as_str()));
get_query.push(("versionDescriptor.versionType", "branch"));
}
let get_resp = client
.get(&base_url)
.query(&[
("path", effective_path.as_str()),
("api-version", "7.0"),
])
.query(&get_query)
.basic_auth("", Some(token))
.send()
.await
Expand Down Expand Up @@ -272,13 +300,18 @@ impl Executor for UpdateWikiPageResult {
debug!("Updating existing wiki page: {effective_path}");

// ── PUT: create or update the page ────────────────────────────────────
let mut put_query: Vec<(&str, &str)> = vec![
("path", effective_path.as_str()),
("comment", comment),
("api-version", "7.0"),
];
if let Some(branch) = &resolved_branch {
put_query.push(("versionDescriptor.version", branch.as_str()));
put_query.push(("versionDescriptor.versionType", "branch"));
}
let mut put_req = client
.put(&base_url)
.query(&[
("path", effective_path.as_str()),
("comment", comment),
("api-version", "7.0"),
])
.query(&put_query)
.header("Content-Type", "application/json")
.basic_auth("", Some(token))
.json(&serde_json::json!({ "content": self.content }));
Expand Down Expand Up @@ -465,6 +498,7 @@ mod tests {
let config = UpdateWikiPageConfig::default();
assert!(config.wiki_name.is_none());
assert!(config.wiki_project.is_none());
assert!(config.branch.is_none());
assert!(config.path_prefix.is_none());
assert!(config.title_prefix.is_none());
assert!(config.comment.is_none());
Expand All @@ -482,11 +516,23 @@ comment: "Updated by agent"
let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki"));
assert_eq!(config.wiki_project.as_deref(), Some("OtherProject"));
assert!(config.branch.is_none());
assert_eq!(config.path_prefix.as_deref(), Some("/agent-output"));
assert_eq!(config.title_prefix.as_deref(), Some("[Agent] "));
assert_eq!(config.comment.as_deref(), Some("Updated by agent"));
}

#[test]
fn test_config_deserializes_with_branch() {
let yaml = r#"
wiki-name: "Azure Sphere"
branch: "main"
"#;
let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere"));
assert_eq!(config.branch.as_deref(), Some("main"));
}

#[test]
fn test_config_partial_deserialize_uses_defaults() {
let yaml = r#"
Expand Down
Loading