Skip to content

Commit f8ea1e9

Browse files
fix: auto-detect code wiki branch for wiki page safe outputs (#115)
* fix: auto-detect code wiki branch for wiki page safe outputs The ADO Wiki Pages API requires an explicit versionDescriptor for write operations on code wikis (type 1). Rather than requiring users to set the branch manually, the executor now auto-detects the wiki type by calling the wiki metadata API and extracts the published branch from the versions array. Behaviour: - If 'branch' is set in front matter config, that value is used as-is - Otherwise the wiki metadata is fetched; code wikis (type 1) resolve their published branch automatically - Project wikis (type 0) continue to work without any branch parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use warn! for wiki branch auto-detection failures Elevate log level from debug! to warn! when wiki metadata fetch fails or when a code wiki has an empty versions array. These silent failures cause confusing downstream ADO PUT errors that are hard to diagnose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fail fast when code wiki branch auto-detection fails When a code wiki is detected but the published branch cannot be resolved (empty versions array), return an actionable error instead of proceeding without versionDescriptor — which would cause a confusing HTTP 400 from the ADO PUT endpoint. Also eliminates silent .ok()? chains in the metadata fetch path, replacing them with explicit warn! logs and graceful fallthrough for network/parse errors (which may affect project wikis too). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 32137fe commit f8ea1e9

4 files changed

Lines changed: 206 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@ safe-outputs:
973973
create-wiki-page:
974974
wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID)
975975
wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project
976+
branch: "main" # Optional — git branch override; auto-detected for code wikis (see note below)
976977
path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope)
977978
title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title)
978979
comment: "Created by agent" # Optional — default commit comment when agent omits one
@@ -981,6 +982,8 @@ safe-outputs:
981982

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

985+
**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.
986+
984987
#### update-wiki-page
985988
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.
986989

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

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

1010+
**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.
1011+
10061012
### Adding New Features
10071013

10081014
When extending the compiler:

src/tools/create_wiki_page.rs

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use schemars::JsonSchema;
77
use serde::{Deserialize, Serialize};
88

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

112+
/// Git branch for the wiki. Required for **code wikis** (type 1) where the
113+
/// ADO API demands an explicit `versionDescriptor`. For project wikis this
114+
/// can be omitted (defaults to `wikiMaster` server-side).
115+
#[serde(default)]
116+
pub branch: Option<String>,
117+
111118
/// Security restriction: the agent may only create wiki pages whose paths
112119
/// start with this prefix (e.g. `"/agent-output"`). Paths that do not match
113120
/// are rejected at execution time. When omitted, no restriction is applied.
@@ -231,13 +238,34 @@ impl Executor for CreateWikiPageResult {
231238

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

241+
// Resolve the effective branch: explicit config → auto-detect from wiki
242+
// metadata (code wikis need an explicit versionDescriptor).
243+
let resolved_branch = match resolve_wiki_branch(
244+
&client,
245+
org_url,
246+
project,
247+
wiki_name,
248+
token,
249+
config.branch.as_deref(),
250+
)
251+
.await
252+
{
253+
Ok(b) => b,
254+
Err(msg) => return Ok(ExecutionResult::failure(msg)),
255+
};
256+
234257
// ── GET: check whether the page already exists ────────────────────────
258+
let mut get_query: Vec<(&str, &str)> = vec![
259+
("path", effective_path.as_str()),
260+
("api-version", "7.0"),
261+
];
262+
if let Some(branch) = &resolved_branch {
263+
get_query.push(("versionDescriptor.version", branch.as_str()));
264+
get_query.push(("versionDescriptor.versionType", "branch"));
265+
}
235266
let get_resp = client
236267
.get(&base_url)
237-
.query(&[
238-
("path", effective_path.as_str()),
239-
("api-version", "7.0"),
240-
])
268+
.query(&get_query)
241269
.basic_auth("", Some(token))
242270
.send()
243271
.await
@@ -276,13 +304,18 @@ impl Executor for CreateWikiPageResult {
276304
// with 412 if this resource already exists", closing the TOCTOU race
277305
// between our GET (404) and the PUT where a concurrent request could
278306
// create the page first.
307+
let mut put_query: Vec<(&str, &str)> = vec![
308+
("path", effective_path.as_str()),
309+
("comment", comment),
310+
("api-version", "7.0"),
311+
];
312+
if let Some(branch) = &resolved_branch {
313+
put_query.push(("versionDescriptor.version", branch.as_str()));
314+
put_query.push(("versionDescriptor.versionType", "branch"));
315+
}
279316
let put_resp = client
280317
.put(&base_url)
281-
.query(&[
282-
("path", effective_path.as_str()),
283-
("comment", comment),
284-
("api-version", "7.0"),
285-
])
318+
.query(&put_query)
286319
.header("Content-Type", "application/json")
287320
.header("If-Match", "")
288321
.basic_auth("", Some(token))
@@ -495,6 +528,7 @@ mod tests {
495528
let config = CreateWikiPageConfig::default();
496529
assert!(config.wiki_name.is_none());
497530
assert!(config.wiki_project.is_none());
531+
assert!(config.branch.is_none());
498532
assert!(config.path_prefix.is_none());
499533
assert!(config.title_prefix.is_none());
500534
assert!(config.comment.is_none());
@@ -512,11 +546,23 @@ comment: "Created by agent"
512546
let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
513547
assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki"));
514548
assert_eq!(config.wiki_project.as_deref(), Some("OtherProject"));
549+
assert!(config.branch.is_none());
515550
assert_eq!(config.path_prefix.as_deref(), Some("/agent-output"));
516551
assert_eq!(config.title_prefix.as_deref(), Some("[Agent] "));
517552
assert_eq!(config.comment.as_deref(), Some("Created by agent"));
518553
}
519554

555+
#[test]
556+
fn test_config_deserializes_with_branch() {
557+
let yaml = r#"
558+
wiki-name: "Azure Sphere"
559+
branch: "main"
560+
"#;
561+
let config: CreateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
562+
assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere"));
563+
assert_eq!(config.branch.as_deref(), Some("main"));
564+
}
565+
520566
#[test]
521567
fn test_config_partial_deserialize_uses_defaults() {
522568
let yaml = r#"

src/tools/mod.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Tool parameter and result structs for MCP tools
22
3-
use percent_encoding::{AsciiSet, CONTROLS};
3+
use log::{debug, warn};
4+
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
45

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

13+
/// Resolve the effective branch for a wiki.
14+
///
15+
/// If `configured_branch` is `Some`, that value is returned directly.
16+
/// Otherwise the wiki metadata API is queried: code wikis (type&nbsp;1) return
17+
/// the published branch from the `versions` array; project wikis (type&nbsp;0)
18+
/// return `Ok(None)` because the server handles branching internally.
19+
///
20+
/// Returns `Err` when a code wiki is detected but the branch cannot be
21+
/// resolved — callers should surface this as a user-facing failure rather
22+
/// than proceeding to a confusing ADO PUT error.
23+
pub(crate) async fn resolve_wiki_branch(
24+
client: &reqwest::Client,
25+
org_url: &str,
26+
project: &str,
27+
wiki_name: &str,
28+
token: &str,
29+
configured_branch: Option<&str>,
30+
) -> Result<Option<String>, String> {
31+
// Explicit configuration always wins.
32+
if let Some(b) = configured_branch {
33+
return Ok(Some(b.to_owned()));
34+
}
35+
36+
let url = format!(
37+
"{}/{}/_apis/wiki/wikis/{}",
38+
org_url.trim_end_matches('/'),
39+
utf8_percent_encode(project, PATH_SEGMENT),
40+
utf8_percent_encode(wiki_name, PATH_SEGMENT),
41+
);
42+
43+
let resp = match client
44+
.get(&url)
45+
.query(&[("api-version", "7.0")])
46+
.basic_auth("", Some(token))
47+
.send()
48+
.await
49+
{
50+
Ok(r) => r,
51+
Err(e) => {
52+
warn!("Wiki metadata request failed: {e} — skipping branch auto-detection");
53+
return Ok(None);
54+
}
55+
};
56+
57+
if !resp.status().is_success() {
58+
warn!(
59+
"Wiki metadata request returned HTTP {} — skipping branch auto-detection",
60+
resp.status()
61+
);
62+
return Ok(None);
63+
}
64+
65+
let body: serde_json::Value = match resp.json().await {
66+
Ok(b) => b,
67+
Err(e) => {
68+
warn!("Failed to parse wiki metadata response: {e}");
69+
return Ok(None);
70+
}
71+
};
72+
73+
// type 0 = project wiki, type 1 = code wiki
74+
let wiki_type = body.get("type").and_then(|v| v.as_u64()).unwrap_or(0);
75+
if wiki_type != 1 {
76+
debug!("Wiki is a project wiki (type {wiki_type}) — no branch needed");
77+
return Ok(None);
78+
}
79+
80+
// Code wiki: extract the published branch from versions[0].version
81+
let branch = body
82+
.get("versions")
83+
.and_then(|v| v.as_array())
84+
.and_then(|arr| arr.first())
85+
.and_then(|v| v.get("version"))
86+
.and_then(|v| v.as_str())
87+
.map(|s| s.to_owned());
88+
89+
match &branch {
90+
Some(b) => {
91+
debug!("Detected code wiki — resolved branch: {b}");
92+
Ok(branch)
93+
}
94+
None => Err(format!(
95+
"Wiki '{wiki_name}' is a code wiki but its published branch could not be \
96+
determined. Set 'branch' explicitly in the safe-outputs config."
97+
)),
98+
}
99+
}
100+
12101
mod comment_on_work_item;
13102
mod create_pr;
14103
mod create_wiki_page;

src/tools/update_wiki_page.rs

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use schemars::JsonSchema;
77
use serde::{Deserialize, Serialize};
88

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

108+
/// Git branch for the wiki. Required for **code wikis** (type 1) where the
109+
/// ADO API demands an explicit `versionDescriptor`. For project wikis this
110+
/// can be omitted (defaults to `wikiMaster` server-side).
111+
#[serde(default)]
112+
pub branch: Option<String>,
113+
107114
/// Security restriction: the agent may only write wiki pages whose paths
108115
/// start with this prefix (e.g. `"/agent-output"`). Paths that do not match
109116
/// are rejected at execution time. When omitted, no restriction is applied.
@@ -227,13 +234,34 @@ impl Executor for UpdateWikiPageResult {
227234

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

237+
// Resolve the effective branch: explicit config → auto-detect from wiki
238+
// metadata (code wikis need an explicit versionDescriptor).
239+
let resolved_branch = match resolve_wiki_branch(
240+
&client,
241+
org_url,
242+
project,
243+
wiki_name,
244+
token,
245+
config.branch.as_deref(),
246+
)
247+
.await
248+
{
249+
Ok(b) => b,
250+
Err(msg) => return Ok(ExecutionResult::failure(msg)),
251+
};
252+
230253
// ── GET: check whether the page exists and obtain its ETag ────────────
254+
let mut get_query: Vec<(&str, &str)> = vec![
255+
("path", effective_path.as_str()),
256+
("api-version", "7.0"),
257+
];
258+
if let Some(branch) = &resolved_branch {
259+
get_query.push(("versionDescriptor.version", branch.as_str()));
260+
get_query.push(("versionDescriptor.versionType", "branch"));
261+
}
231262
let get_resp = client
232263
.get(&base_url)
233-
.query(&[
234-
("path", effective_path.as_str()),
235-
("api-version", "7.0"),
236-
])
264+
.query(&get_query)
237265
.basic_auth("", Some(token))
238266
.send()
239267
.await
@@ -272,13 +300,18 @@ impl Executor for UpdateWikiPageResult {
272300
debug!("Updating existing wiki page: {effective_path}");
273301

274302
// ── PUT: create or update the page ────────────────────────────────────
303+
let mut put_query: Vec<(&str, &str)> = vec![
304+
("path", effective_path.as_str()),
305+
("comment", comment),
306+
("api-version", "7.0"),
307+
];
308+
if let Some(branch) = &resolved_branch {
309+
put_query.push(("versionDescriptor.version", branch.as_str()));
310+
put_query.push(("versionDescriptor.versionType", "branch"));
311+
}
275312
let mut put_req = client
276313
.put(&base_url)
277-
.query(&[
278-
("path", effective_path.as_str()),
279-
("comment", comment),
280-
("api-version", "7.0"),
281-
])
314+
.query(&put_query)
282315
.header("Content-Type", "application/json")
283316
.basic_auth("", Some(token))
284317
.json(&serde_json::json!({ "content": self.content }));
@@ -465,6 +498,7 @@ mod tests {
465498
let config = UpdateWikiPageConfig::default();
466499
assert!(config.wiki_name.is_none());
467500
assert!(config.wiki_project.is_none());
501+
assert!(config.branch.is_none());
468502
assert!(config.path_prefix.is_none());
469503
assert!(config.title_prefix.is_none());
470504
assert!(config.comment.is_none());
@@ -482,11 +516,23 @@ comment: "Updated by agent"
482516
let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
483517
assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki"));
484518
assert_eq!(config.wiki_project.as_deref(), Some("OtherProject"));
519+
assert!(config.branch.is_none());
485520
assert_eq!(config.path_prefix.as_deref(), Some("/agent-output"));
486521
assert_eq!(config.title_prefix.as_deref(), Some("[Agent] "));
487522
assert_eq!(config.comment.as_deref(), Some("Updated by agent"));
488523
}
489524

525+
#[test]
526+
fn test_config_deserializes_with_branch() {
527+
let yaml = r#"
528+
wiki-name: "Azure Sphere"
529+
branch: "main"
530+
"#;
531+
let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap();
532+
assert_eq!(config.wiki_name.as_deref(), Some("Azure Sphere"));
533+
assert_eq!(config.branch.as_deref(), Some("main"));
534+
}
535+
490536
#[test]
491537
fn test_config_partial_deserialize_uses_defaults() {
492538
let yaml = r#"

0 commit comments

Comments
 (0)