Skip to content

Commit b3d1813

Browse files
refactor: reduce complexity of execute_impl in update_work_item.rs (#368)
Extract four focused helpers from the monolithic execute_impl: - check_target_config: validates the configured target constraint - check_field_permissions: data-driven table check replacing 7 sequential if-guards - check_prefix_guards: fetches the work item and verifies title-/tag-prefix rules - build_patch_document: assembles the JSON Patch operations execute_impl is now ~100 lines of linear, easy-to-follow orchestration; each extracted function has a single responsibility and is independently testable. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 701674a commit b3d1813

1 file changed

Lines changed: 172 additions & 148 deletions

File tree

src/safeoutputs/update_work_item.rs

Lines changed: 172 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,162 @@ pub struct UpdateWorkItemConfig {
189189
pub tags: bool,
190190
}
191191

192+
/// Check that `config.target` permits updating the given work item ID.
193+
/// Returns `Some(failure)` if the update should be blocked, `None` if it is allowed.
194+
fn check_target_config(id: u64, config: &UpdateWorkItemConfig) -> Option<ExecutionResult> {
195+
let target = config.target.as_ref()?; // None → missing config handled below
196+
let target_allowed = match target {
197+
TargetConfig::Pattern(p) if p == "*" => true,
198+
TargetConfig::Id(allowed_id) => *allowed_id == id,
199+
TargetConfig::Pattern(p) => {
200+
log::warn!(
201+
"update-work-item: unrecognised target pattern '{}'; \
202+
only \"*\" or an integer ID are valid — all updates are blocked",
203+
p
204+
);
205+
false
206+
}
207+
};
208+
if target_allowed {
209+
None
210+
} else {
211+
Some(ExecutionResult::failure(format!(
212+
"Work item #{id} is not permitted by the update-work-item target configuration"
213+
)))
214+
}
215+
}
216+
217+
/// Validate that every field the agent requested to update is enabled in the configuration.
218+
/// Returns `Some(failure)` on the first disabled field, `None` when all are allowed.
219+
fn check_field_permissions(
220+
params: &UpdateWorkItemResult,
221+
config: &UpdateWorkItemConfig,
222+
) -> Option<ExecutionResult> {
223+
let checks: &[(&str, bool, bool)] = &[
224+
("Title updates are not enabled in the update-work-item configuration; set 'title: true' in safe-outputs", params.title.is_some(), config.title),
225+
("Body/description updates are not enabled in the update-work-item configuration; set 'body: true' in safe-outputs", params.body.is_some(), config.body),
226+
("State/status updates are not enabled in the update-work-item configuration; set 'status: true' in safe-outputs", params.state.is_some(), config.status),
227+
("Area path updates are not enabled in the update-work-item configuration; set 'area-path: true' in safe-outputs", params.area_path.is_some(), config.area_path),
228+
("Iteration path updates are not enabled in the update-work-item configuration; set 'iteration-path: true' in safe-outputs", params.iteration_path.is_some(), config.iteration_path),
229+
("Assignee updates are not enabled in the update-work-item configuration; set 'assignee: true' in safe-outputs", params.assignee.is_some(), config.assignee),
230+
("Tag updates are not enabled in the update-work-item configuration; set 'tags: true' in safe-outputs", params.tags.is_some(), config.tags),
231+
];
232+
for (msg, field_set, field_enabled) in checks {
233+
if *field_set && !field_enabled {
234+
return Some(ExecutionResult::failure(*msg));
235+
}
236+
}
237+
None
238+
}
239+
240+
/// Fetch the current work item and verify title-prefix / tag-prefix guards when configured.
241+
/// Returns `Ok(Some(failure))` if a guard blocks the update, `Ok(None)` if all guards pass,
242+
/// or `Ok(Some(failure))` when the fetch itself fails.
243+
async fn check_prefix_guards(
244+
client: &reqwest::Client,
245+
org_url: &str,
246+
project: &str,
247+
token: &str,
248+
id: u64,
249+
config: &UpdateWorkItemConfig,
250+
) -> anyhow::Result<Option<ExecutionResult>> {
251+
if config.title_prefix.is_none() && config.tag_prefix.is_none() {
252+
return Ok(None);
253+
}
254+
255+
debug!(
256+
"Fetching work item #{} to check prefix guards (title_prefix={:?}, tag_prefix={:?})",
257+
id, config.title_prefix, config.tag_prefix
258+
);
259+
260+
let wi = match fetch_work_item(client, org_url, project, token, id).await {
261+
Ok(wi) => wi,
262+
Err(e) => {
263+
return Ok(Some(ExecutionResult::failure(format!(
264+
"Failed to fetch work item #{id} for prefix validation: {e}"
265+
))));
266+
}
267+
};
268+
269+
// title-prefix check
270+
if let Some(prefix) = &config.title_prefix {
271+
let current_title = wi
272+
.get("fields")
273+
.and_then(|f| f.get("System.Title"))
274+
.and_then(|t| t.as_str())
275+
.unwrap_or("");
276+
if !current_title.starts_with(prefix.as_str()) {
277+
return Ok(Some(ExecutionResult::failure(format!(
278+
"Work item #{id} title '{current_title}' does not start with the required prefix '{prefix}' (configured in title-prefix)"
279+
))));
280+
}
281+
debug!("Title-prefix check passed: '{}'", current_title);
282+
}
283+
284+
// tag-prefix check: ADO stores tags as a semicolon-separated string
285+
if let Some(prefix) = &config.tag_prefix {
286+
let raw_tags = wi
287+
.get("fields")
288+
.and_then(|f| f.get("System.Tags"))
289+
.and_then(|t| t.as_str())
290+
.unwrap_or("");
291+
let has_matching_tag = raw_tags
292+
.split(';')
293+
.map(str::trim)
294+
.any(|tag| tag.starts_with(prefix.as_str()));
295+
if !has_matching_tag {
296+
return Ok(Some(ExecutionResult::failure(format!(
297+
"Work item #{id} has no tag starting with '{prefix}' (configured in tag-prefix). Current tags: '{raw_tags}'"
298+
))));
299+
}
300+
debug!("Tag-prefix check passed; matched in tags: '{}'", raw_tags);
301+
}
302+
303+
Ok(None)
304+
}
305+
306+
/// Build the JSON Patch document for the PATCH request from the provided fields.
307+
fn build_patch_document(
308+
params: &UpdateWorkItemResult,
309+
config: &UpdateWorkItemConfig,
310+
) -> Vec<serde_json::Value> {
311+
let mut patch_doc: Vec<serde_json::Value> = Vec::new();
312+
313+
if let Some(title) = &params.title {
314+
patch_doc.push(replace_field_op("System.Title", title));
315+
}
316+
if let Some(body) = &params.body {
317+
patch_doc.push(replace_field_op("System.Description", body));
318+
// Only add the markdown format hint when explicitly opted in.
319+
// This op is only supported on ADO Services and Server 2022+; omitting it
320+
// keeps the body update working on older on-premises deployments.
321+
if config.markdown_body {
322+
patch_doc.push(serde_json::json!({
323+
"op": "replace",
324+
"path": "/multilineFieldsFormat/System.Description",
325+
"value": "Markdown"
326+
}));
327+
}
328+
}
329+
if let Some(state) = &params.state {
330+
patch_doc.push(replace_field_op("System.State", state));
331+
}
332+
if let Some(area_path) = &params.area_path {
333+
patch_doc.push(replace_field_op("System.AreaPath", area_path));
334+
}
335+
if let Some(iteration_path) = &params.iteration_path {
336+
patch_doc.push(replace_field_op("System.IterationPath", iteration_path));
337+
}
338+
if let Some(assignee) = &params.assignee {
339+
patch_doc.push(replace_field_op("System.AssignedTo", assignee));
340+
}
341+
if let Some(tags) = &params.tags {
342+
patch_doc.push(replace_field_op("System.Tags", tags.join("; ")));
343+
}
344+
345+
patch_doc
346+
}
347+
192348
/// Build a replace-field patch operation for work item updates
193349
fn replace_field_op(field: &str, value: impl Into<String>) -> serde_json::Value {
194350
serde_json::json!({
@@ -285,164 +441,32 @@ impl Executor for UpdateWorkItemResult {
285441
config.tag_prefix,
286442
);
287443

288-
// Validate the target constraint
289-
let target = match &config.target {
290-
Some(t) => t,
291-
None => {
292-
return Ok(ExecutionResult::failure(
293-
"update-work-item target is not configured. \
294-
This is required to scope which work items the agent can update."
295-
.to_string(),
296-
));
297-
}
298-
};
299-
let target_allowed = match target {
300-
TargetConfig::Pattern(p) if p == "*" => true,
301-
TargetConfig::Id(allowed_id) => *allowed_id == self.id,
302-
TargetConfig::Pattern(p) => {
303-
log::warn!(
304-
"update-work-item: unrecognised target pattern '{}'; \
305-
only \"*\" or an integer ID are valid — all updates are blocked",
306-
p
307-
);
308-
false
309-
}
310-
};
311-
if !target_allowed {
312-
return Ok(ExecutionResult::failure(format!(
313-
"Work item #{} is not permitted by the update-work-item target configuration",
314-
self.id
315-
)));
316-
}
317-
318-
// Validate that each provided field is enabled in the configuration
319-
if self.title.is_some() && !config.title {
320-
return Ok(ExecutionResult::failure(
321-
"Title updates are not enabled in the update-work-item configuration; set 'title: true' in safe-outputs",
322-
));
323-
}
324-
if self.body.is_some() && !config.body {
325-
return Ok(ExecutionResult::failure(
326-
"Body/description updates are not enabled in the update-work-item configuration; set 'body: true' in safe-outputs",
327-
));
328-
}
329-
if self.state.is_some() && !config.status {
444+
// Validate target and field-permission guards; return early on first failure
445+
if config.target.is_none() {
330446
return Ok(ExecutionResult::failure(
331-
"State/status updates are not enabled in the update-work-item configuration; set 'status: true' in safe-outputs",
447+
"update-work-item target is not configured. \
448+
This is required to scope which work items the agent can update."
449+
.to_string(),
332450
));
333451
}
334-
if self.area_path.is_some() && !config.area_path {
335-
return Ok(ExecutionResult::failure(
336-
"Area path updates are not enabled in the update-work-item configuration; set 'area-path: true' in safe-outputs",
337-
));
452+
if let Some(result) = check_target_config(self.id, &config) {
453+
return Ok(result);
338454
}
339-
if self.iteration_path.is_some() && !config.iteration_path {
340-
return Ok(ExecutionResult::failure(
341-
"Iteration path updates are not enabled in the update-work-item configuration; set 'iteration-path: true' in safe-outputs",
342-
));
343-
}
344-
if self.assignee.is_some() && !config.assignee {
345-
return Ok(ExecutionResult::failure(
346-
"Assignee updates are not enabled in the update-work-item configuration; set 'assignee: true' in safe-outputs",
347-
));
348-
}
349-
if self.tags.is_some() && !config.tags {
350-
return Ok(ExecutionResult::failure(
351-
"Tag updates are not enabled in the update-work-item configuration; set 'tags: true' in safe-outputs",
352-
));
455+
if let Some(result) = check_field_permissions(self, &config) {
456+
return Ok(result);
353457
}
354458

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

357-
// If either prefix guard is configured, fetch the current work item once and check both
358-
if config.title_prefix.is_some() || config.tag_prefix.is_some() {
359-
debug!(
360-
"Fetching work item #{} to check prefix guards (title_prefix={:?}, tag_prefix={:?})",
361-
self.id, config.title_prefix, config.tag_prefix
362-
);
363-
match fetch_work_item(&client, org_url, project, token, self.id).await {
364-
Ok(wi) => {
365-
// title-prefix check
366-
if let Some(prefix) = &config.title_prefix {
367-
let current_title = wi
368-
.get("fields")
369-
.and_then(|f| f.get("System.Title"))
370-
.and_then(|t| t.as_str())
371-
.unwrap_or("");
372-
if !current_title.starts_with(prefix.as_str()) {
373-
return Ok(ExecutionResult::failure(format!(
374-
"Work item #{} title '{}' does not start with the required prefix '{}' (configured in title-prefix)",
375-
self.id, current_title, prefix
376-
)));
377-
}
378-
debug!("Title-prefix check passed: '{}'", current_title);
379-
}
380-
381-
// tag-prefix check: ADO stores tags as a semicolon-separated string
382-
if let Some(prefix) = &config.tag_prefix {
383-
let raw_tags = wi
384-
.get("fields")
385-
.and_then(|f| f.get("System.Tags"))
386-
.and_then(|t| t.as_str())
387-
.unwrap_or("");
388-
let has_matching_tag = raw_tags
389-
.split(';')
390-
.map(str::trim)
391-
.any(|tag| tag.starts_with(prefix.as_str()));
392-
if !has_matching_tag {
393-
return Ok(ExecutionResult::failure(format!(
394-
"Work item #{} has no tag starting with '{}' (configured in tag-prefix). Current tags: '{}'",
395-
self.id, prefix, raw_tags
396-
)));
397-
}
398-
debug!("Tag-prefix check passed; matched in tags: '{}'", raw_tags);
399-
}
400-
}
401-
Err(e) => {
402-
return Ok(ExecutionResult::failure(format!(
403-
"Failed to fetch work item #{} for prefix validation: {}",
404-
self.id, e
405-
)));
406-
}
407-
}
408-
}
409-
410-
// Build the JSON Patch document for the update
411-
let mut patch_doc: Vec<serde_json::Value> = Vec::new();
412-
413-
if let Some(title) = &self.title {
414-
patch_doc.push(replace_field_op("System.Title", title));
415-
}
416-
if let Some(body) = &self.body {
417-
patch_doc.push(replace_field_op("System.Description", body));
418-
// Only add the markdown format hint when explicitly opted in.
419-
// This op is only supported on ADO Services and Server 2022+; omitting it
420-
// keeps the body update working on older on-premises deployments.
421-
if config.markdown_body {
422-
patch_doc.push(serde_json::json!({
423-
"op": "replace",
424-
"path": "/multilineFieldsFormat/System.Description",
425-
"value": "Markdown"
426-
}));
427-
}
428-
}
429-
if let Some(state) = &self.state {
430-
patch_doc.push(replace_field_op("System.State", state));
431-
}
432-
if let Some(area_path) = &self.area_path {
433-
patch_doc.push(replace_field_op("System.AreaPath", area_path));
434-
}
435-
if let Some(iteration_path) = &self.iteration_path {
436-
patch_doc.push(replace_field_op("System.IterationPath", iteration_path));
437-
}
438-
if let Some(assignee) = &self.assignee {
439-
patch_doc.push(replace_field_op("System.AssignedTo", assignee));
440-
}
441-
if let Some(tags) = &self.tags {
442-
patch_doc.push(replace_field_op("System.Tags", tags.join("; ")));
461+
// Enforce title-prefix / tag-prefix guards (requires a GET if either is set)
462+
if let Some(result) =
463+
check_prefix_guards(&client, org_url, project, token, self.id, &config).await?
464+
{
465+
return Ok(result);
443466
}
444467

445-
// Make the PATCH API call
468+
// Build and send the PATCH request
469+
let patch_doc = build_patch_document(self, &config);
446470
let url = format!(
447471
"{}/{}/_apis/wit/workitems/{}?api-version=7.0",
448472
org_url.trim_end_matches('/'),

0 commit comments

Comments
 (0)