@@ -64,26 +64,61 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
6464 static string WithApiVersion ( string url ) =>
6565 url . Contains ( "api-version=" , StringComparison . Ordinal ) ? url : url + ( url . Contains ( '?' ) ? "&" : "?" ) + "api-version=" + ApiVersion ;
6666
67- public async Task < JsonElement > GetAsync ( string url , CancellationToken ct = default )
67+ public Task < JsonElement > GetAsync ( string url , CancellationToken ct = default ) => SendAsync ( HttpMethod . Get , url , null , null , ct ) ;
68+
69+ public Task < JsonElement > PostAsync ( string url , object body , CancellationToken ct = default ) =>
70+ SendAsync ( HttpMethod . Post , url , body , "application/json" , ct ) ;
71+
72+ /// <summary>JSON Patch (work item create and update).</summary>
73+ public Task < JsonElement > PatchAsync ( string url , object operations , CancellationToken ct = default ) =>
74+ SendAsync ( HttpMethod . Patch , url , operations , "application/json-patch+json" , ct ) ;
75+
76+ /// <summary>
77+ /// Sends one request: relative URLs go to the organization, absolute ones only to Azure DevOps hosts (vssps, vsrm),
78+ /// so a URL taken from data can never carry the PAT anywhere else.
79+ /// </summary>
80+ public async Task < JsonElement > SendAsync ( HttpMethod method , string url , object ? body , string ? contentType , CancellationToken ct = default )
6881 {
6982 url = WithApiVersion ( url ) ;
83+ if ( Uri . TryCreate ( url , UriKind . Absolute , out var abs ) && abs . Scheme is "https" or "http" && ! IsAzureDevOpsHost ( abs ) )
84+ throw new AdoApiException ( $ "refusing to send the PAT to { abs . Host } : only Azure DevOps hosts are allowed") ;
85+ var payload = body is null ? null : JsonSerializer . SerializeToUtf8Bytes ( body , BodyJson ) ;
7086 for ( var attempt = 1 ; ; attempt ++ )
7187 {
72- using var req = new HttpRequestMessage ( HttpMethod . Get , url ) ;
88+ using var req = new HttpRequestMessage ( method , url ) ;
7389 req . Headers . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
90+ if ( payload is not null )
91+ {
92+ req . Content = new ByteArrayContent ( payload ) ;
93+ req . Content . Headers . ContentType = new MediaTypeHeaderValue ( contentType ?? "application/json" ) ;
94+ }
7495 using var res = await http . SendAsync ( req , HttpCompletionOption . ResponseHeadersRead , ct ) ;
7596 if ( attempt < MaxAttempts && RetryDelay ( res ) is { } delay )
7697 {
7798 await Delay ( delay , ct ) ;
7899 continue ;
79100 }
80101 await EnsureSuccessAsync ( res , ct ) ;
81- await using var body = await res . Content . ReadAsStreamAsync ( ct ) ;
82- using var doc = await JsonDocument . ParseAsync ( body , cancellationToken : ct ) ;
102+ await using var stream = await res . Content . ReadAsStreamAsync ( ct ) ;
103+ if ( res . Content . Headers . ContentLength == 0 ) return default ;
104+ using var doc = await JsonDocument . ParseAsync ( stream , cancellationToken : ct ) ;
83105 return doc . RootElement . Clone ( ) ;
84106 }
85107 }
86108
109+ static readonly JsonSerializerOptions BodyJson = new ( JsonSerializerDefaults . Web )
110+ {
111+ DefaultIgnoreCondition = System . Text . Json . Serialization . JsonIgnoreCondition . WhenWritingNull ,
112+ } ;
113+
114+ static bool IsAzureDevOpsHost ( Uri uri ) =>
115+ uri . Scheme == "https" && ( uri . Host . Equals ( "dev.azure.com" , StringComparison . OrdinalIgnoreCase )
116+ || uri . Host . EndsWith ( ".dev.azure.com" , StringComparison . OrdinalIgnoreCase )
117+ || uri . Host . EndsWith ( ".visualstudio.com" , StringComparison . OrdinalIgnoreCase ) ) ;
118+
119+ /// <summary>Organization-level host of another Azure DevOps service, e.g. "vssps" or "vsrm".</summary>
120+ public string ServiceUrl ( string service ) => $ "https://{ service } .dev.azure.com/{ Uri . EscapeDataString ( organization ) } /";
121+
87122 /// <summary>The "value" array of a list response.</summary>
88123 public async Task < List < JsonElement > > GetListAsync ( string url , CancellationToken ct = default )
89124 {
@@ -132,6 +167,7 @@ static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken
132167 }
133168 catch ( JsonException ) { }
134169 message ??= text . Length > 300 ? text [ .. 300 ] + "…" : text ;
170+ message = Explain ( message ) ;
135171
136172 var hint = res . StatusCode == HttpStatusCode . Forbidden ? " (the PAT or the account lacks the permission or scope for this)" : "" ;
137173 throw new AdoApiException (
@@ -140,6 +176,24 @@ static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken
140176 }
141177
142178 static string Describe ( Uri ? uri ) => uri is null ? "request" : uri . GetLeftPart ( UriPartial . Path ) ;
179+
180+ static readonly System . Text . RegularExpressions . Regex RuleError =
181+ new ( @"Rule Error for field (?<field>.+?)\. Error code: (?<code>\w+)" , System . Text . RegularExpressions . RegexOptions . CultureInvariant ) ;
182+
183+ /// <summary>Work item rule errors (TF401320) in plain words; Claude can fix the request from them.</summary>
184+ internal static string Explain ( string message )
185+ {
186+ var parts = RuleError . Matches ( message ) . Select ( m => m . Groups [ "code" ] . Value switch
187+ {
188+ "Required" => $ "field '{ m . Groups [ "field" ] . Value } ' is required for this change",
189+ "InvalidListValue" or "InvalidNotEmpty" => $ "field '{ m . Groups [ "field" ] . Value } ' has a value that is not allowed (ado_fields field=… lists the values)",
190+ "InvalidEmpty" => $ "field '{ m . Groups [ "field" ] . Value } ' must be empty",
191+ "InvalidNotOldValue" => $ "field '{ m . Groups [ "field" ] . Value } ' must change",
192+ "InvalidChangedValue" or "ReadOnly" => $ "field '{ m . Groups [ "field" ] . Value } ' cannot be changed",
193+ var code => $ "field '{ m . Groups [ "field" ] . Value } ': { code } ",
194+ } ) . Distinct ( ) . ToList ( ) ;
195+ return parts . Count == 0 ? message : string . Join ( "; " , parts ) + $ " ({ message . Split ( '\n ' ) [ 0 ] . Trim ( ) } )";
196+ }
143197}
144198
145199/// <summary>Small helpers for reading JsonElement without exceptions.</summary>
0 commit comments