@@ -200,6 +200,11 @@ pub struct DefinitionSummary {
200200 pub id : u64 ,
201201 pub name : String ,
202202 pub process : Option < ProcessInfo > ,
203+ /// `enabled`, `disabled`, or `paused`. Populated when `list_definitions`
204+ /// is called with `includeAllProperties=true` (the default in
205+ /// [`list_definitions`]). Older/cached responses may omit it.
206+ #[ serde( rename = "queueStatus" ) ]
207+ pub queue_status : Option < String > ,
203208}
204209
205210#[ derive( Debug , Deserialize ) ]
@@ -786,18 +791,77 @@ pub async fn resolve_definitions(
786791// overhaul. Locking the surface here lets the parallel command PRs depend on
787792// stable function signatures from day one.
788793
794+ /// Characters that must be percent-encoded when used in a URL path
795+ /// segment. Built from RFC 3986 §3.3: `pchar` allows unreserved
796+ /// characters (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`, `~`),
797+ /// percent-encoded triplets, sub-delims, and `:` / `@`. We additionally
798+ /// encode `:`, `@`, `%`, and `/` so a repository name containing any
799+ /// of those does not break out of the segment, and the U+0021 (`!`)
800+ /// just for symmetry with common path-encoding tables. Notably this
801+ /// preserves `-`, `_`, `.`, `~` which `NON_ALPHANUMERIC` would over-
802+ /// encode (e.g. `my-repo` → `my%2Drepo`).
803+ const PATH_SEGMENT : & percent_encoding:: AsciiSet = & percent_encoding:: CONTROLS
804+ . add ( b' ' )
805+ . add ( b'"' )
806+ . add ( b'#' )
807+ . add ( b'<' )
808+ . add ( b'>' )
809+ . add ( b'?' )
810+ . add ( b'`' )
811+ . add ( b'{' )
812+ . add ( b'}' )
813+ . add ( b'/' )
814+ . add ( b'%' )
815+ . add ( b'@' )
816+ . add ( b':' )
817+ . add ( b'!' ) ;
818+
789819/// Look up an ADO Git repository's GUID by name.
790820///
791821/// Calls `GET /_apis/git/repositories/{repoName}?api-version=7.1` and reads
792822/// the `id` field. Required for `create_definition`, which needs a
793823/// `repository.id` (not just a name) on the POST body.
794824pub async fn get_repository_id (
795- _client : & reqwest:: Client ,
796- _ctx : & AdoContext ,
797- _auth : & AdoAuth ,
798- _repo_name : & str ,
825+ client : & reqwest:: Client ,
826+ ctx : & AdoContext ,
827+ auth : & AdoAuth ,
828+ repo_name : & str ,
799829) -> Result < String > {
800- anyhow:: bail!( "not yet implemented: filled in by PR 2 (ado-aw enable)" )
830+ let url = format ! (
831+ "{}/{}/_apis/git/repositories/{}?api-version=7.1" ,
832+ ctx. org_url. trim_end_matches( '/' ) ,
833+ percent_encoding:: utf8_percent_encode( & ctx. project, PATH_SEGMENT ) ,
834+ percent_encoding:: utf8_percent_encode( repo_name, PATH_SEGMENT ) ,
835+ ) ;
836+
837+ debug ! ( "Looking up repository '{}': {}" , repo_name, url) ;
838+
839+ let resp = auth
840+ . apply ( client. get ( & url) )
841+ . send ( )
842+ . await
843+ . with_context ( || format ! ( "Failed to look up repository '{}'" , repo_name) ) ?;
844+
845+ let status = resp. status ( ) ;
846+ if !status. is_success ( ) {
847+ let body = resp. text ( ) . await . unwrap_or_default ( ) ;
848+ anyhow:: bail!(
849+ "ADO API returned {} when looking up repository '{}': {}" ,
850+ status,
851+ repo_name,
852+ body
853+ ) ;
854+ }
855+
856+ let body: serde_json:: Value = resp
857+ . json ( )
858+ . await
859+ . with_context ( || format ! ( "Failed to parse repository response for '{}'" , repo_name) ) ?;
860+
861+ body. get ( "id" )
862+ . and_then ( |v| v. as_str ( ) )
863+ . map ( str:: to_string)
864+ . with_context ( || format ! ( "Repository '{}' response has no 'id' field" , repo_name) )
801865}
802866
803867/// Fetch the full JSON body of a build definition.
@@ -806,26 +870,107 @@ pub async fn get_repository_id(
806870/// the raw `serde_json::Value` so callers can mutate specific fields and
807871/// PUT the result back (the standard GET → mutate → PUT cycle).
808872pub async fn get_definition_full (
809- _client : & reqwest:: Client ,
810- _ctx : & AdoContext ,
811- _auth : & AdoAuth ,
812- _id : u64 ,
873+ client : & reqwest:: Client ,
874+ ctx : & AdoContext ,
875+ auth : & AdoAuth ,
876+ id : u64 ,
813877) -> Result < serde_json:: Value > {
814- anyhow:: bail!( "not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)" )
878+ let url = format ! (
879+ "{}/{}/_apis/build/definitions/{}?api-version=7.1" ,
880+ ctx. org_url. trim_end_matches( '/' ) ,
881+ percent_encoding:: utf8_percent_encode( & ctx. project, PATH_SEGMENT ) ,
882+ id
883+ ) ;
884+
885+ let resp = auth
886+ . apply ( client. get ( & url) )
887+ . send ( )
888+ . await
889+ . with_context ( || format ! ( "Failed to fetch definition {}" , id) ) ?;
890+
891+ let status = resp. status ( ) ;
892+ if !status. is_success ( ) {
893+ let body = resp. text ( ) . await . unwrap_or_default ( ) ;
894+ anyhow:: bail!(
895+ "ADO API returned {} when fetching definition {}: {}" ,
896+ status,
897+ id,
898+ body
899+ ) ;
900+ }
901+
902+ let body = resp
903+ . text ( )
904+ . await
905+ . with_context ( || format ! ( "Failed to read definition {} response body" , id) ) ?;
906+
907+ serde_json:: from_str ( & body) . with_context ( || {
908+ let snippet: String = body. chars ( ) . take ( 500 ) . collect ( ) ;
909+ format ! (
910+ "Failed to parse definition {} as JSON. \
911+ This usually means the PAT is invalid or expired. \
912+ Response body (first 500 chars):\n {snippet}",
913+ id
914+ )
915+ } )
815916}
816917
817918/// PATCH the `queueStatus` field on a build definition.
818919///
819920/// `status` must be one of `"enabled"`, `"disabled"`, or `"paused"`.
820- /// Implements the GET → mutate → PUT cycle internally.
921+ /// Implements the GET → mutate → PUT cycle internally; the full definition
922+ /// is round-tripped to satisfy the PUT API's "you must send the whole
923+ /// document" requirement.
821924pub async fn patch_queue_status (
822- _client : & reqwest:: Client ,
823- _ctx : & AdoContext ,
824- _auth : & AdoAuth ,
825- _id : u64 ,
826- _status : & str ,
925+ client : & reqwest:: Client ,
926+ ctx : & AdoContext ,
927+ auth : & AdoAuth ,
928+ id : u64 ,
929+ status : & str ,
827930) -> Result < ( ) > {
828- anyhow:: bail!( "not yet implemented: filled in by PR 2 (ado-aw enable) or PR 3 (ado-aw disable)" )
931+ match status {
932+ "enabled" | "disabled" | "paused" => { }
933+ other => anyhow:: bail!(
934+ "patch_queue_status: invalid status '{}', expected one of enabled/disabled/paused" ,
935+ other
936+ ) ,
937+ }
938+
939+ let mut definition = get_definition_full ( client, ctx, auth, id)
940+ . await
941+ . with_context ( || format ! ( "Failed to fetch definition {} before patching" , id) ) ?;
942+
943+ definition[ "queueStatus" ] = serde_json:: Value :: String ( status. to_string ( ) ) ;
944+
945+ let put_url = format ! (
946+ "{}/{}/_apis/build/definitions/{}?api-version=7.1" ,
947+ ctx. org_url. trim_end_matches( '/' ) ,
948+ percent_encoding:: utf8_percent_encode( & ctx. project, PATH_SEGMENT ) ,
949+ id
950+ ) ;
951+
952+ debug ! ( "PUT definition {} with queueStatus={}: {}" , id, status, put_url) ;
953+
954+ let resp = auth
955+ . apply ( client. put ( & put_url) )
956+ . header ( "Content-Type" , "application/json" )
957+ . json ( & definition)
958+ . send ( )
959+ . await
960+ . with_context ( || format ! ( "Failed to update queueStatus on definition {}" , id) ) ?;
961+
962+ let resp_status = resp. status ( ) ;
963+ if !resp_status. is_success ( ) {
964+ let body = resp. text ( ) . await . unwrap_or_default ( ) ;
965+ anyhow:: bail!(
966+ "ADO API returned {} when updating queueStatus on definition {}: {}" ,
967+ resp_status,
968+ id,
969+ body
970+ ) ;
971+ }
972+
973+ Ok ( ( ) )
829974}
830975
831976/// Delete a build definition.
@@ -845,12 +990,46 @@ pub async fn delete_definition(
845990/// Calls `POST /_apis/build/definitions?api-version=7.1` with the supplied
846991/// JSON body and returns the new definition's `id`.
847992pub async fn create_definition (
848- _client : & reqwest:: Client ,
849- _ctx : & AdoContext ,
850- _auth : & AdoAuth ,
851- _body : & serde_json:: Value ,
993+ client : & reqwest:: Client ,
994+ ctx : & AdoContext ,
995+ auth : & AdoAuth ,
996+ body : & serde_json:: Value ,
852997) -> Result < u64 > {
853- anyhow:: bail!( "not yet implemented: filled in by PR 2 (ado-aw enable)" )
998+ let url = format ! (
999+ "{}/{}/_apis/build/definitions?api-version=7.1" ,
1000+ ctx. org_url. trim_end_matches( '/' ) ,
1001+ percent_encoding:: utf8_percent_encode( & ctx. project, PATH_SEGMENT ) ,
1002+ ) ;
1003+
1004+ debug ! ( "POST new definition: {}" , url) ;
1005+
1006+ let resp = auth
1007+ . apply ( client. post ( & url) )
1008+ . header ( "Content-Type" , "application/json" )
1009+ . json ( body)
1010+ . send ( )
1011+ . await
1012+ . context ( "Failed to create build definition" ) ?;
1013+
1014+ let status = resp. status ( ) ;
1015+ if !status. is_success ( ) {
1016+ let resp_body = resp. text ( ) . await . unwrap_or_default ( ) ;
1017+ anyhow:: bail!(
1018+ "ADO API returned {} when creating definition: {}" ,
1019+ status,
1020+ resp_body
1021+ ) ;
1022+ }
1023+
1024+ let resp_body: serde_json:: Value = resp
1025+ . json ( )
1026+ . await
1027+ . context ( "Failed to parse create-definition response" ) ?;
1028+
1029+ resp_body
1030+ . get ( "id" )
1031+ . and_then ( |v| v. as_u64 ( ) )
1032+ . context ( "create_definition response has no numeric 'id' field" )
8541033}
8551034
8561035/// Queue a build for a definition.
@@ -1005,6 +1184,7 @@ mod tests {
10051184 id,
10061185 name : name. to_string ( ) ,
10071186 process : None ,
1187+ queue_status : None ,
10081188 }
10091189 }
10101190
@@ -1015,6 +1195,7 @@ mod tests {
10151195 process : Some ( ProcessInfo {
10161196 yaml_filename : Some ( yaml_filename. to_string ( ) ) ,
10171197 } ) ,
1198+ queue_status : None ,
10181199 }
10191200 }
10201201
@@ -1148,4 +1329,61 @@ mod tests {
11481329 assert_eq ! ( format!( "{}" , MatchMethod :: PipelineName ) , "pipeline-name" ) ;
11491330 assert_eq ! ( format!( "{}" , MatchMethod :: Explicit ) , "explicit" ) ;
11501331 }
1332+
1333+ // ==================== DefinitionSummary deserialization ====================
1334+
1335+ #[ test]
1336+ fn definition_summary_deserializes_queue_status ( ) {
1337+ let raw = serde_json:: json!( {
1338+ "id" : 42 ,
1339+ "name" : "Daily noop" ,
1340+ "queueStatus" : "disabled" ,
1341+ "process" : { "yamlFilename" : "/tests/noop.lock.yml" }
1342+ } ) ;
1343+ let def: DefinitionSummary = serde_json:: from_value ( raw) . unwrap ( ) ;
1344+ assert_eq ! ( def. id, 42 ) ;
1345+ assert_eq ! ( def. queue_status. as_deref( ) , Some ( "disabled" ) ) ;
1346+ assert_eq ! (
1347+ def. process
1348+ . as_ref( )
1349+ . and_then( |p| p. yaml_filename. as_deref( ) ) ,
1350+ Some ( "/tests/noop.lock.yml" )
1351+ ) ;
1352+ }
1353+
1354+ #[ test]
1355+ fn definition_summary_queue_status_missing_is_none ( ) {
1356+ let raw = serde_json:: json!( { "id" : 1 , "name" : "x" } ) ;
1357+ let def: DefinitionSummary = serde_json:: from_value ( raw) . unwrap ( ) ;
1358+ assert ! ( def. queue_status. is_none( ) ) ;
1359+ }
1360+
1361+ // ==================== PATH_SEGMENT percent-encoding ====================
1362+
1363+ #[ test]
1364+ fn path_segment_preserves_rfc3986_unreserved_chars ( ) {
1365+ // RFC 3986 unreserved set: A-Z / a-z / 0-9 / - / _ / . / ~
1366+ // These MUST NOT be percent-encoded in a URL path segment.
1367+ let encoded =
1368+ percent_encoding:: utf8_percent_encode ( "my-repo_name.with~tilde" , PATH_SEGMENT )
1369+ . to_string ( ) ;
1370+ assert_eq ! ( encoded, "my-repo_name.with~tilde" ) ;
1371+ }
1372+
1373+ #[ test]
1374+ fn path_segment_encodes_space_and_reserved_punctuation ( ) {
1375+ let encoded =
1376+ percent_encoding:: utf8_percent_encode ( "my repo/with?special#chars" , PATH_SEGMENT )
1377+ . to_string ( ) ;
1378+ // Spaces become %20, slashes %2F, ? becomes %3F, # becomes %23.
1379+ assert_eq ! ( encoded, "my%20repo%2Fwith%3Fspecial%23chars" ) ;
1380+ }
1381+
1382+ #[ test]
1383+ fn path_segment_handles_non_ascii ( ) {
1384+ let encoded =
1385+ percent_encoding:: utf8_percent_encode ( "café-π" , PATH_SEGMENT ) . to_string ( ) ;
1386+ // Non-ASCII bytes get encoded per UTF-8.
1387+ assert_eq ! ( encoded, "caf%C3%A9-%CF%80" ) ;
1388+ }
11511389}
0 commit comments