Support downloading/publishing artifacts from Pipelines endpoint (#188)

* Support downloading/publishing artifacts from Pipelines endpoint
* Remove `Path` from everywhere
* Remove unused JobId argument
* PR feedback
* More PR feedback
This commit is contained in:
Julio Barba
2019-11-25 13:30:44 -05:00
committed by GitHub
parent 7d505f7f77
commit de29a39d14
22 changed files with 1174 additions and 19 deletions

View File

@@ -50,29 +50,62 @@ namespace GitHub.Runner.Plugins.Artifact
throw new ArgumentException($"Run Id is not an Int32: {buildIdStr}");
}
context.Output($"Download artifact '{artifactName}' to: '{targetPath}'");
// Determine whether to call Pipelines or Build endpoint to publish artifact based on variable setting
string usePipelinesArtifactEndpointVar = context.Variables.GetValueOrDefault("Runner.UseActionsArtifactsApis")?.Value;
bool.TryParse(usePipelinesArtifactEndpointVar, out bool usePipelinesArtifactEndpoint);
string containerPath;
long containerId;
BuildServer buildHelper = new BuildServer(context.VssConnection);
BuildArtifact buildArtifact = await buildHelper.GetArtifact(projectId, buildId, artifactName, token);
context.Output($"Downloading artifact '{artifactName}' to: '{targetPath}'");
if (string.Equals(buildArtifact.Resource.Type, "Container", StringComparison.OrdinalIgnoreCase))
if (usePipelinesArtifactEndpoint)
{
string containerUrl = buildArtifact.Resource.Data;
string[] parts = containerUrl.Split(new[] { '/' }, 3);
if (parts.Length < 3 || !long.TryParse(parts[1], out long containerId))
context.Debug("Downloading artifact using v2 endpoint");
// Definition ID is a dummy value only used by HTTP client routing purposes
int definitionId = 1;
var pipelinesHelper = new PipelinesServer(context.VssConnection);
var actionsStorageArtifact = await pipelinesHelper.GetActionsStorageArtifact(definitionId, buildId, artifactName, token);
if (actionsStorageArtifact == null)
{
throw new ArgumentOutOfRangeException($"Invalid container url '{containerUrl}' for artifact '{buildArtifact.Name}'");
throw new Exception($"The actions storage artifact for '{artifactName}' could not be found, or is no longer available");
}
string containerPath = parts[2];
FileContainerServer fileContainerServer = new FileContainerServer(context.VssConnection, projectId, containerId, containerPath);
await fileContainerServer.DownloadFromContainerAsync(context, targetPath, token);
containerPath = actionsStorageArtifact.Name; // In actions storage artifacts, name equals the path
containerId = actionsStorageArtifact.ContainerId;
}
else
{
throw new NotSupportedException($"Invalid artifact type: {buildArtifact.Resource.Type}");
context.Debug("Downloading artifact using v1 endpoint");
BuildServer buildHelper = new BuildServer(context.VssConnection);
BuildArtifact buildArtifact = await buildHelper.GetArtifact(projectId, buildId, artifactName, token);
if (string.Equals(buildArtifact.Resource.Type, "Container", StringComparison.OrdinalIgnoreCase) ||
// Artifact was published by Pipelines endpoint, check new type here to handle rollback scenario
string.Equals(buildArtifact.Resource.Type, "Actions_Storage", StringComparison.OrdinalIgnoreCase))
{
string containerUrl = buildArtifact.Resource.Data;
string[] parts = containerUrl.Split(new[] { '/' }, 3);
if (parts.Length < 3 || !long.TryParse(parts[1], out containerId))
{
throw new ArgumentOutOfRangeException($"Invalid container url '{containerUrl}' for artifact '{buildArtifact.Name}'");
}
containerPath = parts[2];
}
else
{
throw new NotSupportedException($"Invalid artifact type: {buildArtifact.Resource.Type}");
}
}
FileContainerServer fileContainerServer = new FileContainerServer(context.VssConnection, projectId, containerId, containerPath);
await fileContainerServer.DownloadFromContainerAsync(context, targetPath, token);
context.Output("Artifact download finished.");
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Actions.Pipelines.WebApi;
using GitHub.Services.WebApi;
using GitHub.Runner.Sdk;
using Pipelines = GitHub.Actions.Pipelines.WebApi;
namespace GitHub.Runner.Plugins.Artifact
{
// A client wrapper interacting with Pipelines's Artifact API
public class PipelinesServer
{
private readonly PipelinesHttpClient _pipelinesHttpClient;
public PipelinesServer(VssConnection connection)
{
ArgUtil.NotNull(connection, nameof(connection));
_pipelinesHttpClient = connection.GetClient<PipelinesHttpClient>();
}
// Associate the specified Actions Storage artifact with a pipeline
public async Task<Pipelines.ActionsStorageArtifact> AssociateActionsStorageArtifactAsync(
int pipelineId,
int runId,
long containerId,
string name,
long size,
CancellationToken cancellationToken = default(CancellationToken))
{
CreateArtifactParameters parameters = new CreateActionsStorageArtifactParameters()
{
Name = name,
ContainerId = containerId,
Size = size
};
return await _pipelinesHttpClient.CreateArtifactAsync(
parameters,
pipelineId,
runId,
cancellationToken: cancellationToken) as Pipelines.ActionsStorageArtifact;
}
// Get named Actions Storage artifact for a pipeline
public async Task<Pipelines.ActionsStorageArtifact> GetActionsStorageArtifact(
int pipelineId,
int runId,
string name,
CancellationToken cancellationToken)
{
return await _pipelinesHttpClient.GetArtifactAsync(
pipelineId,
runId,
name,
cancellationToken: cancellationToken) as Pipelines.ActionsStorageArtifact;
}
}
}

View File

@@ -68,27 +68,59 @@ namespace GitHub.Runner.Plugins.Artifact
string containerIdStr = context.Variables.GetValueOrDefault(BuildVariables.ContainerId)?.Value ?? string.Empty;
if (!long.TryParse(containerIdStr, out long containerId))
{
throw new ArgumentException($"Container Id is not a Int64: {containerIdStr}");
throw new ArgumentException($"Container Id is not an Int64: {containerIdStr}");
}
context.Output($"Uploading artifact '{artifactName}' from '{fullPath}' for run #{buildId}");
FileContainerServer fileContainerHelper = new FileContainerServer(context.VssConnection, projectId, containerId, artifactName);
var propertiesDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
long size = 0;
try
{
long size = await fileContainerHelper.CopyToContainerAsync(context, fullPath, token);
size = await fileContainerHelper.CopyToContainerAsync(context, fullPath, token);
propertiesDictionary.Add("artifactsize", size.ToString());
context.Output($"Uploaded '{size}' bytes from '{fullPath}' to server");
}
// if any of the results were successful, make sure to attach them to the build
finally
{
string fileContainerFullPath = StringUtil.Format($"#/{containerId}/{artifactName}");
BuildServer buildHelper = new BuildServer(context.VssConnection);
string jobId = context.Variables.GetValueOrDefault(WellKnownDistributedTaskVariables.JobId).Value ?? string.Empty;
var artifact = await buildHelper.AssociateArtifact(projectId, buildId, jobId, artifactName, ArtifactResourceTypes.Container, fileContainerFullPath, propertiesDictionary, token);
context.Output($"Associated artifact {artifactName} ({artifact.Id}) with run #{buildId}");
// Determine whether to call Pipelines or Build endpoint to publish artifact based on variable setting
string usePipelinesArtifactEndpointVar = context.Variables.GetValueOrDefault("Runner.UseActionsArtifactsApis")?.Value;
bool.TryParse(usePipelinesArtifactEndpointVar, out bool usePipelinesArtifactEndpoint);
if (usePipelinesArtifactEndpoint)
{
// Definition ID is a dummy value only used by HTTP client routing purposes
int definitionId = 1;
PipelinesServer pipelinesHelper = new PipelinesServer(context.VssConnection);
var artifact = await pipelinesHelper.AssociateActionsStorageArtifactAsync(
definitionId,
buildId,
containerId,
artifactName,
size,
token);
context.Output($"Associated artifact {artifactName} ({artifact.ContainerId}) with run #{buildId}");
context.Debug($"Associated artifact using v2 endpoint");
}
else
{
string fileContainerFullPath = StringUtil.Format($"#/{containerId}/{artifactName}");
BuildServer buildHelper = new BuildServer(context.VssConnection);
string jobId = context.Variables.GetValueOrDefault(WellKnownDistributedTaskVariables.JobId).Value ?? string.Empty;
var artifact = await buildHelper.AssociateArtifact(projectId, buildId, jobId, artifactName, ArtifactResourceTypes.Container, fileContainerFullPath, propertiesDictionary, token);
context.Output($"Associated artifact {artifactName} ({artifact.Id}) with run #{buildId}");
context.Debug($"Associated artifact using v1 endpoint");
}
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Runtime.Serialization;
using GitHub.Services.WebApi;
namespace GitHub.Actions.Pipelines.WebApi
{
[DataContract]
public class ActionsStorageArtifact : Artifact
{
public ActionsStorageArtifact()
: base(ArtifactType.Actions_Storage)
{
}
/// <summary>
/// File Container ID
/// </summary>
[DataMember]
public long ContainerId
{
get;
set;
}
/// <summary>
/// Size of the file in bytes
/// </summary>
[DataMember]
public long Size
{
get;
set;
}
/// <summary>
/// Signed content url for downloading the artifact
/// </summary>
[DataMember]
public SignedUrl SignedContent
{
get;
set;
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Runtime.Serialization;
using GitHub.Actions.Pipelines.WebApi.Contracts;
using Newtonsoft.Json;
namespace GitHub.Actions.Pipelines.WebApi
{
[DataContract]
[KnownType(typeof(ActionsStorageArtifact))]
[JsonConverter(typeof(ArtifactJsonConverter))]
public class Artifact
{
public Artifact(ArtifactType type)
{
Type = type;
}
/// <summary>
/// The type of the artifact.
/// </summary>
[DataMember]
public ArtifactType Type
{
get;
}
/// <summary>
/// The name of the artifact.
/// </summary>
[DataMember]
public string Name
{
get;
set;
}
/// <summary>
/// Self-referential url
/// </summary>
[DataMember]
public string Url
{
get;
set;
}
}
}

View File

@@ -0,0 +1,85 @@
using System;
using System.Reflection;
using GitHub.Services.WebApi;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace GitHub.Actions.Pipelines.WebApi
{
public abstract class ArtifactBaseJsonConverter<T> : VssSecureJsonConverter where T : class
{
public override bool CanConvert(Type objectType)
{
return typeof(T).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
}
// by returning false, the converter doesn't take part in writes
// which means we use the default serialization logic
public override bool CanWrite
{
get
{
return false;
}
}
protected abstract T Create(Type objectType);
protected abstract T Create(ArtifactType type);
public override object ReadJson(
JsonReader reader,
Type objectType,
object existingValue,
JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.StartObject)
{
return null;
}
var contract = serializer.ContractResolver.ResolveContract(objectType) as JsonObjectContract;
if (contract == null)
{
return existingValue;
}
// if objectType is one of our known types, we can ignore the type property
T targetObject = Create(objectType);
// read the data into a JObject so we can look at it
var value = JObject.Load(reader);
if (targetObject == null)
{
// use the Type property
var typeProperty = contract.Properties.GetClosestMatchProperty("Type");
if (typeProperty == null)
{
// we don't know the type. just bail
return existingValue;
}
if (!value.TryGetValue(typeProperty.PropertyName, StringComparison.OrdinalIgnoreCase, out var typeValue))
{
// a type property exists on the contract, but the JObject has no value for it
return existingValue;
}
var type = UnknownEnum.Parse<ArtifactType>(typeValue.ToString());
targetObject = Create(type);
}
if (targetObject != null)
{
using (var objectReader = value.CreateReader())
{
serializer.Populate(objectReader, targetObject);
}
}
return targetObject;
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
namespace GitHub.Actions.Pipelines.WebApi.Contracts
{
public class ArtifactJsonConverter : ArtifactBaseJsonConverter<Artifact>
{
protected override Artifact Create(Type objectType)
{
if (objectType == typeof(ActionsStorageArtifact))
{
return new ActionsStorageArtifact();
}
else
{
return null;
}
}
protected override Artifact Create(ArtifactType type)
{
if (type == ArtifactType.Actions_Storage)
{
return new ActionsStorageArtifact();
}
return null;
}
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace GitHub.Actions.Pipelines.WebApi
{
[DataContract]
[JsonConverter(typeof(ArtifactTypeEnumJsonConverter))]
public enum ArtifactType
{
Unknown = 0,
Actions_Storage = 1
}
}

View File

@@ -0,0 +1,25 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System;
using System.Net.Http.Formatting;
namespace GitHub.Actions.Pipelines.WebApi
{
public class ArtifactTypeEnumJsonConverter : UnknownEnumJsonConverter
{
//json.net v12 exposes a "NamingStrategy" member that can do all this. We are at json.net v10 which only supports camel case.
//This is a poor man's way to fake it
public override void WriteJson(JsonWriter writer, object enumValue, JsonSerializer serializer)
{
var value = (ArtifactType)enumValue;
if (value == ArtifactType.Actions_Storage)
{
writer.WriteValue("actions_storage");
}
else
{
base.WriteJson(writer, enumValue, serializer);
}
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Runtime.Serialization;
namespace GitHub.Actions.Pipelines.WebApi
{
[DataContract]
public class CreateActionsStorageArtifactParameters : CreateArtifactParameters
{
public CreateActionsStorageArtifactParameters()
: base(ArtifactType.Actions_Storage)
{
}
/// <summary>
/// the id of the file container
/// </summary>
[DataMember]
public long ContainerId
{
get;
set;
}
/// <summary>
/// Size of the file in bytes
/// </summary>
[DataMember]
public long Size
{
get;
set;
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace GitHub.Actions.Pipelines.WebApi
{
[DataContract]
[KnownType(typeof(CreateActionsStorageArtifactParameters))]
[JsonConverter(typeof(CreateArtifactParametersJsonConverter))]
public class CreateArtifactParameters
{
protected CreateArtifactParameters(ArtifactType type)
{
Type = type;
}
/// <summary>
/// The type of the artifact.
/// </summary>
[DataMember]
public ArtifactType Type
{
get;
}
/// <summary>
/// The name of the artifact.
/// </summary>
[DataMember]
public string Name
{
get;
set;
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
namespace GitHub.Actions.Pipelines.WebApi
{
public class CreateArtifactParametersJsonConverter : ArtifactBaseJsonConverter<CreateArtifactParameters>
{
protected override CreateArtifactParameters Create(Type objectType)
{
if (objectType == typeof(CreateActionsStorageArtifactParameters))
{
return new CreateActionsStorageArtifactParameters();
}
else
{
return null;
}
}
protected override CreateArtifactParameters Create(ArtifactType type)
{
if (type == ArtifactType.Actions_Storage)
{
return new CreateActionsStorageArtifactParameters();
}
return null;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.ComponentModel;
namespace GitHub.Actions.Pipelines.WebApi
{
/// <summary>
/// $expand options for GetArtifact and ListArtifacts.
/// </summary>
[TypeConverter(typeof(KnownFlagsEnumTypeConverter))]
[Flags]
public enum GetArtifactExpandOptions
{
None = 0,
SignedContent = 1,
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Serialization;
using GitHub.Services.Common;
namespace GitHub.Actions.Pipelines.WebApi
{
public static class FlagsEnum
{
public static TEnum ParseKnownFlags<TEnum>(string stringValue) where TEnum : System.Enum
{
return (TEnum)ParseKnownFlags(typeof(TEnum), stringValue);
}
/// <summary>
/// Parse known enum flags in a comma-separated string. Unknown flags are ignored. Allows for degraded compatibility without serializing enums to integers.
/// </summary>
/// <remarks>
/// Case insensitive. Both standard and EnumMemberAttribute names are parsed.
/// </remarks>
/// <exception cref="NullReferenceException">Thrown if stringValue is null.</exception>
/// <exception cref="ArgumentException">Thrown if a flag name is empty.</exception>
public static object ParseKnownFlags(Type enumType, string stringValue)
{
ArgumentUtility.CheckForNull(enumType, nameof(enumType));
if (!enumType.IsEnum)
{
throw new ArgumentException(PipelinesWebApiResources.FlagEnumTypeRequired());
}
// Check for the flags attribute in debug. Skip this reflection in release.
Debug.Assert(enumType.GetCustomAttributes(typeof(FlagsAttribute), inherit: false).Any(), "FlagsEnum only intended for enums with the Flags attribute.");
// The exception types below are based on Enum.TryParseEnum (http://index/?query=TryParseEnum&rightProject=mscorlib&file=system%5Cenum.cs&rightSymbol=bhaeh2vnegwo)
if (stringValue == null)
{
throw new ArgumentNullException(stringValue);
}
if (String.IsNullOrWhiteSpace(stringValue))
{
throw new ArgumentException(PipelinesWebApiResources.NonEmptyEnumElementsRequired(stringValue));
}
if (UInt64.TryParse(stringValue, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out ulong ulongValue))
{
return Enum.Parse(enumType, stringValue);
}
var enumNames = Enum.GetNames(enumType).ToHashSet(name => name, StringComparer.OrdinalIgnoreCase);
var enumMemberMappings = new Lazy<IDictionary<string, string>>(() =>
{
IDictionary<string, string> mappings = null;
foreach (var field in enumType.GetFields())
{
if (field.GetCustomAttributes(typeof(EnumMemberAttribute), false).FirstOrDefault() is EnumMemberAttribute enumMemberAttribute)
{
if (mappings == null)
{
mappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
mappings.Add(enumMemberAttribute.Value, field.GetValue(null).ToString());
}
}
return mappings;
});
var values = stringValue.Split(s_enumSeparatorCharArray);
var matches = new List<string>();
for (int i = 0; i < values.Length; i++)
{
string value = values[i].Trim();
if (String.IsNullOrEmpty(value))
{
throw new ArgumentException(PipelinesWebApiResources.NonEmptyEnumElementsRequired(stringValue));
}
if (enumNames.Contains(value))
{
matches.Add(value);
}
else if (enumMemberMappings.Value != null && enumMemberMappings.Value.TryGetValue(value, out string matchingValue))
{
matches.Add(matchingValue);
}
}
if (!matches.Any())
{
return Enum.Parse(enumType, "0");
}
string matchesString = String.Join(", ", matches);
return Enum.Parse(enumType, matchesString, ignoreCase: true);
}
private static readonly char[] s_enumSeparatorCharArray = new char[] { ',' };
}
}

View File

@@ -0,0 +1,273 @@
/*
* ---------------------------------------------------------
* Copyright(C) Microsoft Corporation. All rights reserved.
* ---------------------------------------------------------
*
* ---------------------------------------------------------
* Generated file, DO NOT EDIT
* ---------------------------------------------------------
*
* See following wiki page for instructions on how to regenerate:
* https://aka.ms/azure-devops-client-generation
*
* Configuration file:
* actions\client\webapi\clientgeneratorconfigs\pipelines.genclient.json
*/
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
namespace GitHub.Actions.Pipelines.WebApi
{
[ResourceArea(PipelinesArea.IdString)]
public abstract class PipelinesHttpClientBase : VssHttpClientBase
{
public PipelinesHttpClientBase(Uri baseUrl, VssCredentials credentials)
: base(baseUrl, credentials)
{
}
public PipelinesHttpClientBase(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings)
: base(baseUrl, credentials, settings)
{
}
public PipelinesHttpClientBase(Uri baseUrl, VssCredentials credentials, params DelegatingHandler[] handlers)
: base(baseUrl, credentials, handlers)
{
}
public PipelinesHttpClientBase(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings, params DelegatingHandler[] handlers)
: base(baseUrl, credentials, settings, handlers)
{
}
public PipelinesHttpClientBase(Uri baseUrl, HttpMessageHandler pipeline, bool disposeHandler)
: base(baseUrl, pipeline, disposeHandler)
{
}
/// <summary>
/// [Preview API] Associates an artifact with a run.
/// </summary>
/// <param name="createArtifactParameters"></param>
/// <param name="pipelineId">The ID of the pipeline.</param>
/// <param name="runId">The ID of the run.</param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> CreateArtifactAsync(
CreateArtifactParameters createArtifactParameters,
int pipelineId,
int runId,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("POST");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { pipelineId = pipelineId, runId = runId };
HttpContent content = new ObjectContent<CreateArtifactParameters>(createArtifactParameters, new VssJsonMediaTypeFormatter(true));
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
userState: userState,
cancellationToken: cancellationToken,
content: content);
}
/// <summary>
/// [Preview API] Associates an artifact with a run.
/// </summary>
/// <param name="createArtifactParameters"></param>
/// <param name="project">Project ID or project name</param>
/// <param name="pipelineId">The ID of the pipeline.</param>
/// <param name="runId">The ID of the run.</param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> CreateArtifactAsync(
CreateArtifactParameters createArtifactParameters,
string project,
int pipelineId,
int runId,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("POST");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { project = project, pipelineId = pipelineId, runId = runId };
HttpContent content = new ObjectContent<CreateArtifactParameters>(createArtifactParameters, new VssJsonMediaTypeFormatter(true));
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
userState: userState,
cancellationToken: cancellationToken,
content: content);
}
/// <summary>
/// [Preview API] Associates an artifact with a run.
/// </summary>
/// <param name="createArtifactParameters"></param>
/// <param name="project">Project ID</param>
/// <param name="pipelineId">The ID of the pipeline.</param>
/// <param name="runId">The ID of the run.</param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> CreateArtifactAsync(
CreateArtifactParameters createArtifactParameters,
Guid project,
int pipelineId,
int runId,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("POST");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { project = project, pipelineId = pipelineId, runId = runId };
HttpContent content = new ObjectContent<CreateArtifactParameters>(createArtifactParameters, new VssJsonMediaTypeFormatter(true));
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
userState: userState,
cancellationToken: cancellationToken,
content: content);
}
/// <summary>
/// [Preview API] Get a specific artifact
/// </summary>
/// <param name="project">Project ID or project name</param>
/// <param name="pipelineId"></param>
/// <param name="runId"></param>
/// <param name="artifactName"></param>
/// <param name="expand"></param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> GetArtifactAsync(
string project,
int pipelineId,
int runId,
string artifactName,
GetArtifactExpandOptions? expand = null,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("GET");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { project = project, pipelineId = pipelineId, runId = runId };
List<KeyValuePair<string, string>> queryParams = new List<KeyValuePair<string, string>>();
queryParams.Add("artifactName", artifactName);
if (expand != null)
{
queryParams.Add("$expand", expand.Value.ToString());
}
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
queryParameters: queryParams,
userState: userState,
cancellationToken: cancellationToken);
}
/// <summary>
/// [Preview API] Get a specific artifact
/// </summary>
/// <param name="project">Project ID</param>
/// <param name="pipelineId"></param>
/// <param name="runId"></param>
/// <param name="artifactName"></param>
/// <param name="expand"></param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> GetArtifactAsync(
Guid project,
int pipelineId,
int runId,
string artifactName,
GetArtifactExpandOptions? expand = null,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("GET");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { project = project, pipelineId = pipelineId, runId = runId };
List<KeyValuePair<string, string>> queryParams = new List<KeyValuePair<string, string>>();
queryParams.Add("artifactName", artifactName);
if (expand != null)
{
queryParams.Add("$expand", expand.Value.ToString());
}
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
queryParameters: queryParams,
userState: userState,
cancellationToken: cancellationToken);
}
/// <summary>
/// [Preview API] Get a specific artifact
/// </summary>
/// <param name="pipelineId"></param>
/// <param name="runId"></param>
/// <param name="artifactName"></param>
/// <param name="expand"></param>
/// <param name="userState"></param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
public virtual Task<Artifact> GetArtifactAsync(
int pipelineId,
int runId,
string artifactName,
GetArtifactExpandOptions? expand = null,
object userState = null,
CancellationToken cancellationToken = default)
{
HttpMethod httpMethod = new HttpMethod("GET");
Guid locationId = new Guid("85023071-bd5e-4438-89b0-2a5bf362a19d");
object routeValues = new { pipelineId = pipelineId, runId = runId };
List<KeyValuePair<string, string>> queryParams = new List<KeyValuePair<string, string>>();
queryParams.Add("artifactName", artifactName);
if (expand != null)
{
queryParams.Add("$expand", expand.Value.ToString());
}
return SendAsync<Artifact>(
httpMethod,
locationId,
routeValues: routeValues,
version: new ApiResourceVersion(6.0, 1),
queryParameters: queryParams,
userState: userState,
cancellationToken: cancellationToken);
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.ComponentModel;
using System.Globalization;
namespace GitHub.Actions.Pipelines.WebApi
{
/// <summary>
/// Parses known enum flags in a comma-separated string. Unknown flags are ignored. Allows for degraded compatibility without serializing enums to integer values.
/// </summary>
/// <remarks>
/// Case insensitive. Both standard and EnumMemberAttribute names are parsed.
/// json deserialization doesn't happen for query parameters :)
/// </remarks>
public class KnownFlagsEnumTypeConverter : EnumConverter
{
public KnownFlagsEnumTypeConverter(Type type)
: base(type)
{
}
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
/// <exception cref="FormatException">Thrown if a flag name is empty.</exception>
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is string stringValue)
{
try
{
return FlagsEnum.ParseKnownFlags(EnumType, stringValue);
}
catch (Exception ex)
{
// Matches the exception type thrown by EnumTypeConverter.
throw new FormatException(PipelinesWebApiResources.InvalidFlagsEnumValue(stringValue, EnumType), ex);
}
}
return base.ConvertFrom(context, culture, value);
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Net.Http;
using GitHub.Services.Common;
using GitHub.Services.WebApi;
namespace GitHub.Actions.Pipelines.WebApi
{
[ResourceArea(PipelinesArea.IdString)]
public class PipelinesHttpClient : PipelinesHttpClientBase
{
public PipelinesHttpClient(Uri baseUrl, VssCredentials credentials)
: base(baseUrl, credentials)
{
}
public PipelinesHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings)
: base(baseUrl, credentials, settings)
{
}
public PipelinesHttpClient(Uri baseUrl, VssCredentials credentials, params DelegatingHandler[] handlers)
: base(baseUrl, credentials, handlers)
{
}
public PipelinesHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings, params DelegatingHandler[] handlers)
: base(baseUrl, credentials, settings, handlers)
{
}
public PipelinesHttpClient(Uri baseUrl, HttpMessageHandler pipeline, bool disposeHandler)
: base(baseUrl, pipeline, disposeHandler)
{
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
namespace GitHub.Actions.Pipelines.WebApi
{
public static class PipelinesArea
{
public const string Name = "pipelines";
public const string IdString = "2e0bf237-8973-4ec9-a581-9c3d679d1776";
public static readonly Guid Id = new Guid(PipelinesArea.IdString);
}
public static class PipelinesResources
{
public static class Artifacts
{
public const string Name = "artifacts";
public static readonly Guid Id = new Guid("85023071-BD5E-4438-89B0-2A5BF362A19D");
}
public static class PipelineOrgs
{
public const string Name = "orgs";
public static readonly Guid Id = new Guid("CD70BA1A-D59A-4E0B-9934-97998159CCC8");
}
public static class Logs
{
public const string Name = "logs";
public static readonly Guid Id = new Guid("fb1b6d27-3957-43d5-a14b-a2d70403e545");
}
public static class Pipelines
{
public const string Name = "pipelines";
public static readonly Guid Id = new Guid("28e1305e-2afe-47bf-abaf-cbb0e6a91988");
}
public static class Reputations
{
public const string Name = "reputations";
public static readonly Guid Id = new Guid("ABA353B0-46FB-4885-88C5-391C6B6382B3");
}
public static class Runs
{
public const string Name = "runs";
public static readonly Guid Id = new Guid("7859261e-d2e9-4a68-b820-a5d84cc5bb3d");
}
public static class SignalR
{
public const string Name = "signalr";
public static readonly Guid Id = new Guid("1FFE4916-AC72-4566-ADD0-9BAB31E44FCF");
}
public static class SignedArtifactsContent
{
public const string Name = "signedartifactscontent";
public static readonly Guid Id = new Guid("6B2AC16F-CD00-4DF9-A13B-3A1CC8AFB188");
}
public static class SignedLogContent
{
public const string Name = "signedlogcontent";
public static readonly Guid Id = new Guid("74f99e32-e2c4-44f4-93dc-dec0bca530a5");
}
public static class SignalRLive
{
public const string Name = "live";
public static readonly Guid Id = new Guid("C41B3775-6D50-48BD-B261-42DA7F0F1BA0");
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Linq;
using System.Runtime.Serialization;
namespace GitHub.Actions.Pipelines.WebApi
{
public static class UnknownEnum
{
public static T Parse<T>(string stringValue)
{
return (T)Parse(typeof(T), stringValue);
}
public static object Parse(Type enumType, string stringValue)
{
var underlyingType = Nullable.GetUnderlyingType(enumType);
enumType = underlyingType != null ? underlyingType : enumType;
var names = Enum.GetNames(enumType);
if (!string.IsNullOrEmpty(stringValue))
{
var match = names.FirstOrDefault(name => string.Equals(name, stringValue, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
return Enum.Parse(enumType, match);
}
// maybe we have an enum member with an EnumMember attribute specifying a custom name
foreach (var field in enumType.GetFields())
{
var enumMemberAttribute = field.GetCustomAttributes(typeof(EnumMemberAttribute), false).FirstOrDefault() as EnumMemberAttribute;
if (enumMemberAttribute != null && string.Equals(enumMemberAttribute.Value, stringValue, StringComparison.OrdinalIgnoreCase))
{
// we already have the field, no need to do enum.parse on it
return field.GetValue(null);
}
}
}
return Enum.Parse(enumType, UnknownName);
}
private const string UnknownName = "Unknown";
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace GitHub.Actions.Pipelines.WebApi
{
public class UnknownEnumJsonConverter : StringEnumConverter
{
public UnknownEnumJsonConverter()
{
this.CamelCaseText = true;
}
public override bool CanConvert(Type objectType)
{
// we require one member to be named "Unknown"
return objectType.IsEnum && Enum.GetNames(objectType).Any(name => string.Equals(name, UnknownName, StringComparison.OrdinalIgnoreCase));
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
// Newtonsoft doesn't call CanConvert if you specify the converter using a JsonConverter attribute
// they just assume you know what you're doing :)
if (!CanConvert(objectType))
{
// if there's no Unknown value, fall back to the StringEnumConverter behavior
return base.ReadJson(reader, objectType, existingValue, serializer);
}
if (reader.TokenType == JsonToken.Integer)
{
var intValue = Convert.ToInt32(reader.Value);
var values = (int[])Enum.GetValues(objectType);
if (values.Contains(intValue))
{
return Enum.Parse(objectType, intValue.ToString());
}
}
if (reader.TokenType == JsonToken.String)
{
var stringValue = reader.Value.ToString();
return UnknownEnum.Parse(objectType, stringValue);
}
// we know there's an Unknown value because CanConvert returned true
return Enum.Parse(objectType, UnknownName);
}
private const string UnknownName = "Unknown";
}
}

View File

@@ -0,0 +1,26 @@
using System.Globalization;
namespace GitHub.Actions.Pipelines.WebApi
{
public static class PipelinesWebApiResources
{
public static string FlagEnumTypeRequired()
{
const string Format = @"Invalid type. An enum type with the Flags attribute must be supplied.";
return Format;
}
public static string InvalidFlagsEnumValue(object arg0, object arg1)
{
const string Format = @"'{0}' is not a valid value for {1}";
return string.Format(CultureInfo.CurrentCulture, Format, arg0, arg1);
}
public static string NonEmptyEnumElementsRequired(object arg0)
{
const string Format = @"Each comma separated enum value must be non-empty: {0}";
return string.Format(CultureInfo.CurrentCulture, Format, arg0);
}
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Runtime.Serialization;
namespace GitHub.Services.WebApi
{
/// <summary>
/// A signed url allowing limited-time anonymous access to private resources.
/// </summary>
[DataContract]
public class SignedUrl
{
[DataMember]
public string Url { get; set; }
[DataMember]
public DateTime SignatureExpires { get; set; }
}
}