diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..a18bd6df --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "strawberryshake.tools": { + "version": "11.1.0", + "commands": [ + "dotnet-graphql" + ] + }, + "dotnet-ef": { + "version": "5.0.5", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a30767f2..50943b39 100644 --- a/.gitignore +++ b/.gitignore @@ -265,4 +265,5 @@ launchSettings.json *.db-journal logs/ *.ird -credentials.json \ No newline at end of file +credentials.json +Generated/ \ No newline at end of file diff --git a/Clients/CirrusCiClient/.graphqlrc.json b/Clients/CirrusCiClient/.graphqlrc.json new file mode 100644 index 00000000..74afbc82 --- /dev/null +++ b/Clients/CirrusCiClient/.graphqlrc.json @@ -0,0 +1,12 @@ +{ + "schema": "schema.graphql", + "documents": "**/*.graphql", + "extensions": { + "strawberryShake": { + "name": "Client", + "namespace": "CirrusCiClient", + "url": "https://api.cirrus-ci.com/graphql", + "dependencyInjection": true + } + } +} \ No newline at end of file diff --git a/Clients/CirrusCiClient/CirrusCi.cs b/Clients/CirrusCiClient/CirrusCi.cs new file mode 100644 index 00000000..c8063d97 --- /dev/null +++ b/Clients/CirrusCiClient/CirrusCi.cs @@ -0,0 +1,130 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CirrusCiClient.POCOs; +using CompatApiClient; +using CompatApiClient.Utils; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using StrawberryShake; + +namespace CirrusCiClient +{ + public static class CirrusCi + { + private static readonly MemoryCache BuildInfoCache = new(new MemoryCacheOptions { ExpirationScanFrequency = TimeSpan.FromHours(1) }); + private static readonly IServiceProvider ServiceProvider; + private static IClient Client => ServiceProvider.GetRequiredService(); + + static CirrusCi() + { + var collection = new ServiceCollection(); + collection.AddClient(ExecutionStrategy.CacheAndNetwork) + .ConfigureHttpClient(c => c.BaseAddress = new("https://api.cirrus-ci.com/graphql")); + ServiceProvider = collection.BuildServiceProvider(); + } + + public static async Task GetPrBuildInfoAsync(string? commit, DateTime? oldestTimestamp, int pr, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(commit)) + return null; + + commit = commit.ToLower(); + var queryResult = await Client.GetPrBuilds.ExecuteAsync("pull/" + pr, oldestTimestamp.ToTimestamp(), cancellationToken); + queryResult.EnsureNoErrors(); + if (queryResult.Data?.GithubRepository?.Builds?.Edges is {Count: > 0} edgeList) + { + var node = edgeList.LastOrDefault(e => e?.Node?.ChangeIdInRepo == commit)?.Node; + if (node is null) + return null; + + var winTask = node.Tasks?.FirstOrDefault(t => t?.Name.Contains("Windows") ?? false); + var winArtifact = winTask?.Artifacts? + .Where(a => a?.Files is {Count: >0}) + .SelectMany(a => a!.Files!) + .FirstOrDefault(f => f?.Path.EndsWith(".7z") ?? false); + var linTask = node.Tasks?.FirstOrDefault(t => t is {} lt && lt.Name.Contains("Linux") && lt.Name.Contains("GCC")); + var linArtifact = linTask?.Artifacts? + .Where(a => a?.Files is {Count: >0}) + .SelectMany(a => a!.Files!) + .FirstOrDefault(a => a?.Path.EndsWith(".AppImage") ?? false); + + var startTime = FromTimestamp(node.BuildCreatedTimestamp); + var finishTime = GetFinishTime(node); + return new() + { + Commit = node.ChangeIdInRepo, + WindowsFilename = winArtifact?.Path is string wp ? Path.GetFileName(wp) : null, + LinuxFilename = linArtifact?.Path is string lp ? Path.GetFileName(lp) : null, + WindowsBuildDownloadLink = winTask?.Id is string wtid && winArtifact?.Path is string wtap ? $"https://api.cirrus-ci.com/v1/artifact/task/{wtid}/Artifact/{wtap}" : null, + LinuxBuildDownloadLink = linTask?.Id is string ltid && linArtifact?.Path is string ltap ? $"https://api.cirrus-ci.com/v1/artifact/task/{ltid}/Artifact/{ltap}" : null, + StartTime = startTime, + FinishTime = finishTime, + Status = node.Status, + }; + } + return null; + } + + public static async Task GetPipelineDurationAsync(CancellationToken cancellationToken) + { + const string cacheKey = "project-build-stats"; + if (BuildInfoCache.TryGetValue(cacheKey, out ProjectBuildStats result)) + return result; + + try + { + var queryResult = await Client.GetLastFewBuilds.ExecuteAsync(200, cancellationToken).ConfigureAwait(false); + queryResult.EnsureNoErrors(); + + var times = ( + from edge in queryResult.Data?.GithubRepository?.Builds?.Edges + let node = edge?.Node + where node?.Status == BuildStatus.Completed + let p = new {start = FromTimestamp(node.BuildCreatedTimestamp), finish = GetFinishTime(node)} + where p.finish.HasValue + let ts = p.finish!.Value - p.start + orderby ts descending + select ts + ).ToList(); + if (times.Count <= 10) + return ProjectBuildStats.Defaults; + + result = new() + { + Percentile95 = times[(int)(times.Count * 0.05)], + Percentile90 = times[(int)(times.Count * 0.10)], + Percentile85 = times[(int)(times.Count * 0.16)], + Percentile80 = times[(int)(times.Count * 0.20)], + Mean = TimeSpan.FromTicks(times.Select(t => t.Ticks).Mean()), + StdDev = TimeSpan.FromTicks((long)times.Select(t => t.Ticks).StdDev()), + BuildCount = times.Count, + }; + BuildInfoCache.Set(cacheKey, result, TimeSpan.FromDays(1)); + return result; + } + catch (Exception e) + { + ApiConfig.Log.Error(e, "Failed to get Cirrus build stats"); + } + return ProjectBuildStats.Defaults; + } + + private static DateTime? GetFinishTime(IBaseNodeInfo node) + => node.LatestGroupTasks? + .Select(t => t?.FinalStatusTimestamp) + .Where(ts => ts > 0) + .ToList() is {Count: >0} finalTimes + ? FromTimestamp(finalTimes.Max()!.Value) + : node.ClockDurationInSeconds > 0 + ? FromTimestamp(node.BuildCreatedTimestamp).AddSeconds(node.ClockDurationInSeconds.Value) + : (DateTime?)null; + + [return: NotNullIfNotNull(nameof(DateTime))] + private static string? ToTimestamp(this DateTime? dateTime) => dateTime.HasValue ? (dateTime.Value.ToUniversalTime() - DateTime.UnixEpoch).TotalMilliseconds.ToString("0") : null; + private static DateTime FromTimestamp(long timestamp) => DateTime.UnixEpoch.AddMilliseconds(timestamp); + } +} \ No newline at end of file diff --git a/Clients/CirrusCiClient/CirrusCiClient.csproj b/Clients/CirrusCiClient/CirrusCiClient.csproj new file mode 100644 index 00000000..bdeeb643 --- /dev/null +++ b/Clients/CirrusCiClient/CirrusCiClient.csproj @@ -0,0 +1,20 @@ + + + + net5.0 + enable + + + + + + + + + + + + + + + diff --git a/Clients/CirrusCiClient/POCOs/BuildInfo.cs b/Clients/CirrusCiClient/POCOs/BuildInfo.cs new file mode 100644 index 00000000..8ad29433 --- /dev/null +++ b/Clients/CirrusCiClient/POCOs/BuildInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace CirrusCiClient.POCOs +{ + public record BuildInfo + { + public string? Commit { get; init; } + public string? WindowsFilename { get; init; } + public string? LinuxFilename { get; init; } + public string? WindowsBuildDownloadLink { get; init; } + public string? LinuxBuildDownloadLink { get; init; } + public DateTime StartTime { get; init; } + public DateTime? FinishTime { get; init; } + public BuildStatus? Status { get; init; } + } +} \ No newline at end of file diff --git a/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs b/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs new file mode 100644 index 00000000..e285ceb9 --- /dev/null +++ b/Clients/CirrusCiClient/POCOs/ProjectBuildStats.cs @@ -0,0 +1,25 @@ +using System; + +namespace CirrusCiClient.POCOs +{ + public record ProjectBuildStats + { + public TimeSpan Percentile95 { get; init; } + public TimeSpan Percentile90 { get; init; } + public TimeSpan Percentile85 { get; init; } + public TimeSpan Percentile80 { get; init; } + public TimeSpan Mean { get; init; } + public TimeSpan StdDev { get; init; } + public int BuildCount { get; init; } + + public static readonly ProjectBuildStats Defaults = new() + { + Percentile95 = TimeSpan.FromSeconds(1120), + Percentile90 = TimeSpan.FromSeconds(900), + Percentile85 = TimeSpan.FromSeconds(870), + Percentile80 = TimeSpan.FromSeconds(865), + Mean = TimeSpan.FromSeconds(860), + StdDev = TimeSpan.FromSeconds(420), + }; + } +} \ No newline at end of file diff --git a/Clients/CirrusCiClient/Queries/GetBuilds.graphql b/Clients/CirrusCiClient/Queries/GetBuilds.graphql new file mode 100644 index 00000000..df51d5ae --- /dev/null +++ b/Clients/CirrusCiClient/Queries/GetBuilds.graphql @@ -0,0 +1,65 @@ +query GetPrBuilds($branch: String, $after: String) { + githubRepository(owner: "RPCS3", name: "rpcs3") { + builds(branch: $branch, after: $after, last: 20) { + edges { + node { + id + changeIdInRepo + pullRequest + ...BaseNodeInfo + tasks { + id + name + status + artifacts { + files { + path + size + } + } + } + } + } + } + } +} + +query GetBuildWithArtifacts($buildId: ID!) { + build(id: $buildId) { + pullRequest + buildCreatedTimestamp + clockDurationInSeconds + tasks { + name + status + artifacts { + name + files { + path + size + } + } + } + } +} + +query GetLastFewBuilds($count: Int!) { + githubRepository(owner: "RPCS3", name: "rpcs3") { + builds(last: $count) { + edges { + node { + ...BaseNodeInfo + } + } + } + } +} + +fragment BaseNodeInfo on Build { + status + buildCreatedTimestamp + clockDurationInSeconds + latestGroupTasks { + finalStatusTimestamp + } +} \ No newline at end of file diff --git a/Clients/CirrusCiClient/schema.extensions.graphql b/Clients/CirrusCiClient/schema.extensions.graphql new file mode 100644 index 00000000..60b39b1e --- /dev/null +++ b/Clients/CirrusCiClient/schema.extensions.graphql @@ -0,0 +1 @@ +extend schema @key(fields: "id") \ No newline at end of file diff --git a/Clients/CirrusCiClient/schema.graphql b/Clients/CirrusCiClient/schema.graphql new file mode 100644 index 00000000..fecb65af --- /dev/null +++ b/Clients/CirrusCiClient/schema.graphql @@ -0,0 +1,913 @@ +schema { + query: Root + mutation: Mutation + subscription: Subscription +} + +type AccountTransaction { + accountId: Long! + taskId: Long! + repositoryId: Long! + timestamp: Long! + microCreditsAmount: Long! + creditsAmount: String! + initialCreditsAmount: String + task: Task + repository: Repository +} + +"An edge in a connection" +type AccountTransactionEdge { + "The item at the end of the edge" + node: AccountTransaction + "cursor marks a unique position or index into the connection" + cursor: String! +} + +"A connection to a list of items." +type AccountTransactionsConnection { + "a list of edges" + edges: [AccountTransactionEdge] + "details about this specific page" + pageInfo: PageInfo! +} + +type ApiAccessToken { + maskedToken: String + creationTimestamp: Long +} + +type ArtifactFileInfo { + path: String! + size: Long! +} + +type Artifacts { + name: String! + type: String + format: String + files: [ArtifactFileInfo] +} + +type BillingSettings { + accountId: Long! + enabled: Boolean! + billingCreditsLimit: Long! + billingEmailAddress: String! + invoiceTemplate: String +} + +input BillingSettingsInput { + accountId: ID! + enabled: Boolean! + billingEmailAddress: String! + invoiceTemplate: String + clientMutationId: String +} + +type BillingSettingsPayload { + settings: BillingSettings + clientMutationId: String +} + +type Build { + id: ID! + repositoryId: ID! + branch: String! + changeIdInRepo: String! + changeMessageTitle: String + changeMessage: String + durationInSeconds: Long + clockDurationInSeconds: Long + pullRequest: Long + checkSuiteId: Long + isSenderUserCollaborator: Boolean + senderUserPermissions: String + changeTimestamp: Long! + buildCreatedTimestamp: Long! + status: BuildStatus + notifications: [Notification] + parsingResult: ParsingResult + tasks: [Task] + taskGroupsAmount: Long + latestGroupTasks: [Task] + repository: Repository! + viewerPermission: PermissionType! + source: String + hooks: [Hook] +} + +input BuildApproveInput { + buildId: ID! + clientMutationId: String +} + +type BuildApprovePayload { + build: Build! + clientMutationId: String +} + +input BuildReTriggerInput { + buildId: ID! + clientMutationId: String +} + +type BuildReTriggerPayload { + build: Build! + clientMutationId: String +} + +"Build status." +enum BuildStatus { + CREATED + NEEDS_APPROVAL + TRIGGERED + EXECUTING + FAILED + COMPLETED + ABORTED + ERRORED +} + +input BuyComputeCreditsInput { + accountId: ID! + amountOfCredits: String! + paymentTokenId: String! + receiptEmail: String + clientMutationId: String +} + +type BuyComputeCreditsPayload { + error: String + info: GitHubOrganizationInfo + user: User + clientMutationId: String +} + +"Repository Setting to choose where to look for the configuration file." +enum ConfigResolutionStrategy { + SAME_SHA + MERGE_FOR_PRS + DEFAULT_BRANCH +} + +input CreatePersistentWorkerPoolInput { + ownerId: Long! + name: String! + enabledForPublic: Boolean! + clientMutationId: String +} + +type CreatePersistentWorkerPoolPayload { + pool: PersistentWorkerPool! + clientMutationId: String +} + +type DayDate { + year: Int + month: Int + day: Int +} + +"Repository Setting to choose how to decrypt variables." +enum DecryptEnvironmentVariablesFor { + USERS_WITH_WRITE_PERMISSIONS + EVERYONE +} + +input DeletePersistentWorkerInput { + poolId: String! + name: String! + clientMutationId: String +} + +type DeletePersistentWorkerPayload { + clientMutationId: String +} + +input DeletePersistentWorkerPoolInput { + poolId: String! + clientMutationId: String +} + +type DeletePersistentWorkerPoolPayload { + deletedPoolId: String! + clientMutationId: String +} + +input DeleteWebPushConfigurationInput { + endpoint: String! + clientMutationId: String +} + +type DeleteWebPushConfigurationPayload { + clientMutationId: String +} + +type ExecutionChart { + startTimestamp: Long! + maxValue: Float! + minValue: Float! + points: [ExecutionChartPoint]! +} + +type ExecutionChartPoint { + value: Float! + secondsFromStart: Long! +} + +type ExecutionEvent { + timestamp: Long! + message: String! +} + +type ExecutionInfo { + labels: [String] + events: [ExecutionEvent] + cpuChart: ExecutionChart + memoryChart: ExecutionChart +} + +input GenerateNewAccessTokenInput { + accountId: ID + clientMutationId: String +} + +type GenerateNewAccessTokenPayload { + token: String! + clientMutationId: String +} + +input GetPersistentWorkerPoolRegistrationTokenInput { + poolId: ID + clientMutationId: String +} + +type GetPersistentWorkerPoolRegistrationTokenPayload { + token: String! + clientMutationId: String +} + +type GitHubMarketplacePurchase { + accountId: Long! + login: String! + planId: Long! + planName: String! + unitCount: Long! + onFreeTrial: Boolean! + freeTrialDaysLeft: Int! +} + +type GitHubOrganizationInfo { + id: ID! + name: String! + role: String! + purchase: GitHubMarketplacePurchase + activeUsersAmount: Int! + activeUserNames: [String] + balanceInCredits: String! + transactions("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int): AccountTransactionsConnection + billingSettings: BillingSettings + webhookSettings: WebHookSettings + webhookDeliveries("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int): WebhookDeliveriesConnection + apiToken: ApiAccessToken + persistentWorkerPools: [PersistentWorkerPool] +} + +type Hook { + id: ID! + repositoryId: ID! + repository: Repository! + buildId: ID + build: Build + taskId: ID + task: Task + timestamp: Long! + name: String! + info: HookExecutionInfo! +} + +type HookExecutionInfo { + error: String! + arguments: String + result: String + outputLogs: [String] + durationNanos: Long! +} + +type InstanceResources { + cpu: Float! + memory: Long! +} + +"Long type" +scalar Long + +type MetricsChart { + title: String + points: [TimePoint] + dataUnits: String +} + +input MetricsQueryParameters { + status: TaskStatus + platform: PlatformType + type: String + isCommunity: Boolean + isPR: Boolean + usedComputeCredits: Boolean + branch: String +} + +type Mutation { + securedVariable(input: RepositorySecuredVariableInput!): RepositorySecuredVariablePayload + securedOrganizationVariable(input: OrganizationSecuredVariableInput!): OrganizationSecuredVariablePayload + updateSecuredOrganizationVariable(input: UpdateOrganizationSecuredVariableInput!): UpdateOrganizationSecuredVariablePayload + createBuild(input: RepositoryCreateBuildInput!): RepositoryCreateBuildPayload + deleteRepository(input: RepositoryDeleteInput!): RepositoryDeletePayload + rerun(input: TaskReRunInput!): TaskReRunPayload + batchReRun(input: TasksReRunInput!): TasksReRunPayload + abortTask(input: TaskAbortInput!): TaskAbortPayload + batchAbort(input: TaskBatchAbortInput!): TaskBatchAbortPayload + retrigger(input: BuildReTriggerInput!): BuildReTriggerPayload + saveSettings(input: RepositorySettingsInput!): RepositorySettingsPayload + saveCronSettings(input: RepositorySaveCronSettingsInput!): RepositorySaveCronSettingsPayload + removeCronSettings(input: RepositoryRemoveCronSettingsInput!): RepositoryRemoveCronSettingsPayload + approve(input: BuildApproveInput!): BuildApprovePayload + trigger(input: TaskTriggerInput!): TaskTriggerPayload + buyComputeCredits(input: BuyComputeCreditsInput!): BuyComputeCreditsPayload + saveWebHookSettings(input: SaveWebHookSettingsInput!): SaveWebHookSettingsPayload + saveBillingSettings(input: BillingSettingsInput!): BillingSettingsPayload + generateNewAccessToken(input: GenerateNewAccessTokenInput!): GenerateNewAccessTokenPayload + deletePersistentWorker(input: DeletePersistentWorkerInput!): DeletePersistentWorkerPayload + updatePersistentWorker(input: UpdatePersistentWorkerInput!): UpdatePersistentWorkerPayload + persistentWorkerPoolRegistrationToken(input: GetPersistentWorkerPoolRegistrationTokenInput!): GetPersistentWorkerPoolRegistrationTokenPayload + createPersistentWorkerPool(input: CreatePersistentWorkerPoolInput!): CreatePersistentWorkerPoolPayload + updatePersistentWorkerPool(input: UpdatePersistentWorkerPoolInput!): UpdatePersistentWorkerPoolPayload + deletePersistentWorkerPool(input: DeletePersistentWorkerPoolInput!): DeletePersistentWorkerPoolPayload + saveWebPushConfiguration(input: SaveWebPushConfigurationInput!): SaveWebPushConfigurationPayload + deleteWebPushConfiguration(input: DeleteWebPushConfigurationInput!): DeleteWebPushConfigurationPayload +} + +type Notification { + level: NotificationLevel + message: String! + link: String +} + +"Notification level." +enum NotificationLevel { + INFO + WARNING + ERROR +} + +input OrganizationSecuredVariableInput { + organizationId: ID! + valueToSecure: String! + clientMutationId: String +} + +type OrganizationSecuredVariablePayload { + variableName: String! + clientMutationId: String +} + +"Information about pagination in a connection." +type PageInfo { + "When paginating forwards, are there more items?" + hasNextPage: Boolean! + "When paginating backwards, are there more items?" + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +type ParsingResult { + rawYamlConfig: String! + rawStarlarkConfig: String! + processedYamlConfig: String! + issues: [ParsingResultIssue] + outputLogs: [String] +} + +type ParsingResultIssue { + level: ParsingResultIssueLevel! + message: String! + line: Long! + column: Long! +} + +enum ParsingResultIssueLevel { + INFO + WARNING + ERROR +} + +"User access level." +enum PermissionType { + NONE + READ + WRITE + ADMIN +} + +type PersistentWorker { + name: String! + disabled: Boolean! + arch: String! + hostname: String! + os: String! + version: String! + labels: [String] + info: PersistentWorkerInfo + assignedTasks("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int): PersistentWorkerAssignedTasksConnection +} + +"An edge in a connection" +type PersistentWorkerAssignedTaskEdge { + "The item at the end of the edge" + node: Task + "cursor marks a unique position or index into the connection" + cursor: String! +} + +"A connection to a list of items." +type PersistentWorkerAssignedTasksConnection { + "a list of edges" + edges: [PersistentWorkerAssignedTaskEdge] + "details about this specific page" + pageInfo: PageInfo! +} + +type PersistentWorkerInfo { + heartbeatTimestamp: Long! + runningTasks: [Task] +} + +type PersistentWorkerPool { + id: ID! + name: String! + enabledForPublic: Boolean! + workers: [PersistentWorker] + viewerPermission: PermissionType +} + +"Task platform." +enum PlatformType { + LINUX + DARWIN + WINDOWS + FREEBSD +} + +type Repository { + id: ID! + owner: String! + name: String! + cloneUrl: String! + masterBranch: String! + isPrivate: Boolean! + builds("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int "branch to fetch builds for" branch: String): RepositoryBuildsConnection + settings: RepositorySettings + cronSettings: [RepositoryCronSettings] + viewerPermission: PermissionType + lastDefaultBranchBuild: Build + metrics(parameters: MetricsQueryParameters): [MetricsChart] +} + +"An edge in a connection" +type RepositoryBuildEdge { + "The item at the end of the edge" + node: Build + "cursor marks a unique position or index into the connection" + cursor: String! +} + +"A connection to a list of items." +type RepositoryBuildsConnection { + "a list of edges" + edges: [RepositoryBuildEdge] + "details about this specific page" + pageInfo: PageInfo! +} + +input RepositoryCreateBuildInput { + repositoryId: ID! + branch: String! + sha: String + configOverride: String + clientMutationId: String +} + +type RepositoryCreateBuildPayload { + build: Build! + clientMutationId: String +} + +type RepositoryCronSettings { + name: String! + expression: String! + branch: String! + nextInvocationTimestamp: Long! + lastInvocationBuild: Build +} + +input RepositoryDeleteInput { + repositoryId: ID! + clientMutationId: String +} + +type RepositoryDeletePayload { + deleted: Boolean! + clientMutationId: String +} + +input RepositoryRemoveCronSettingsInput { + repositoryId: ID! + name: String! + clientMutationId: String +} + +type RepositoryRemoveCronSettingsPayload { + settings: [RepositoryCronSettings] + clientMutationId: String +} + +input RepositorySaveCronSettingsInput { + repositoryId: ID! + name: String! + expression: String! + branch: String! + clientMutationId: String +} + +type RepositorySaveCronSettingsPayload { + settings: [RepositoryCronSettings] + clientMutationId: String +} + +input RepositorySecuredVariableInput { + repositoryId: ID! + valueToSecure: String! + clientMutationId: String +} + +type RepositorySecuredVariablePayload { + variableName: String! + clientMutationId: String +} + +type RepositorySettings { + needsApproval: Boolean + decryptEnvironmentVariables: DecryptEnvironmentVariablesFor + configResolutionStrategy: ConfigResolutionStrategy + additionalEnvironment: [String] +} + +input RepositorySettingsInput { + repositoryId: ID! + needsApproval: Boolean + decryptEnvironmentVariables: DecryptEnvironmentVariablesFor + configResolutionStrategy: ConfigResolutionStrategy + additionalEnvironment: [String] + clientMutationId: String +} + +type RepositorySettingsPayload { + settings: RepositorySettings! + clientMutationId: String +} + +type Root { + viewer: User + repository(id: ID!): Repository + githubRepository(owner: String! name: String!): Repository + githubRepositories(owner: String!): [Repository] + githubOrganizationInfo(organization: String!): GitHubOrganizationInfo + build(id: ID!): Build + searchBuilds(repositoryOwner: String! repositoryName: String! SHA: String): [Build] + task(id: ID!): Task + hook(id: ID!): Hook + webhookDelivery(id: String!): WebHookDelivery + persistentWorkerPool(poolId: ID): PersistentWorkerPool + persistentWorker(poolId: ID name: String): PersistentWorker +} + +input SaveWebHookSettingsInput { + accountId: ID! + webhookURL: String! + clientMutationId: String +} + +type SaveWebHookSettingsPayload { + error: String + info: GitHubOrganizationInfo + clientMutationId: String +} + +input SaveWebPushConfigurationInput { + endpoint: String! + p256dhKey: String! + authKey: String! + clientMutationId: String +} + +type SaveWebPushConfigurationPayload { + clientMutationId: String +} + +type Subscription { + task(id: ID!): Task + build(id: ID!): Build + repository(id: ID!): Repository +} + +type Task { + id: ID! + buildId: ID! + repositoryId: ID! + name: String! + localGroupId: Long! + requiredGroups: [Long] + status: TaskStatus + notifications: [Notification] + commands: [TaskCommand] + firstFailedCommand: TaskCommand + artifacts: [Artifacts] + commandLogsTail(name: String!): [String] + statusTimestamp: Long! + creationTimestamp: Long! + scheduledTimestamp: Long! + executingTimestamp: Long! + finalStatusTimestamp: Long! + durationInSeconds: Long! + labels: [String] + uniqueLabels: [String] + requiredPRLabels: [String] + timeoutInSeconds: Long! + optional: Boolean + statusDurations: [TaskStatusDuration] + repository: Repository! + build: Build! + previousRuns: [Task] + allOtherRuns: [Task] + dependencies: [Task] + automaticReRun: Boolean! + automaticallyReRunnable: Boolean! + experimental: Boolean! + stateful: Boolean! + useComputeCredits: Boolean! + usedComputeCredits: Boolean! + transaction: AccountTransaction + triggerType: TaskTriggerType! + instanceResources: InstanceResources + executionInfo: ExecutionInfo + baseEnvironment: [String] + hooks: [Hook] +} + +input TaskAbortInput { + taskId: ID! + clientMutationId: String +} + +type TaskAbortPayload { + abortedTask: Task! + clientMutationId: String +} + +input TaskBatchAbortInput { + taskIds: [ID] + clientMutationId: String +} + +type TaskBatchAbortPayload { + tasks: [Task] + clientMutationId: String +} + +type TaskCommand { + name: String + type: TaskCommandType + status: TaskCommandStatus + durationInSeconds: Int + logsTail: [String] +} + +"Task Command status." +enum TaskCommandStatus { + UNDEFINED + SUCCESS + FAILURE + EXECUTING + SKIPPED + ABORTED +} + +"Task Command type." +enum TaskCommandType { + WAIT + EXIT + EXECUTE_SCRIPT + CACHE + UPLOAD_CACHE + CLONE + EXECUTE_BACKGROUND_SCRIPT + ARTIFACTS +} + +input TaskReRunInput { + taskId: ID! + clientMutationId: String +} + +type TaskReRunPayload { + newTask: Task! + clientMutationId: String +} + +"Task status." +enum TaskStatus { + CREATED + TRIGGERED + SCHEDULED + EXECUTING + ABORTED + FAILED + COMPLETED + SKIPPED + PAUSED +} + +type TaskStatusDuration { + status: TaskStatus! + durationInSeconds: Long! +} + +input TaskTriggerInput { + taskId: ID! + clientMutationId: String +} + +type TaskTriggerPayload { + task: Task! + clientMutationId: String +} + +"Task trigger type." +enum TaskTriggerType { + AUTOMATIC + MANUAL +} + +input TasksReRunInput { + taskIds: [ID] + clientMutationId: String +} + +type TasksReRunPayload { + newTasks: [Task] + clientMutationId: String +} + +type TimePoint { + date: DayDate + value: Float +} + +input UpdateOrganizationSecuredVariableInput { + organizationId: ID! + name: String! + updatedValueToSecure: String! + clientMutationId: String +} + +type UpdateOrganizationSecuredVariablePayload { + variableName: String! + clientMutationId: String +} + +input UpdatePersistentWorkerInput { + poolId: String! + name: String! + disabled: Boolean! + clientMutationId: String +} + +type UpdatePersistentWorkerPayload { + worker: PersistentWorker! + clientMutationId: String +} + +input UpdatePersistentWorkerPoolInput { + poolId: String! + name: String! + enabledForPublic: Boolean! + clientMutationId: String +} + +type UpdatePersistentWorkerPoolPayload { + pool: PersistentWorkerPool! + clientMutationId: String +} + +type User { + id: ID! + githubUserId: Long + githubUserName: String! + category: UserCategoryType! + avatarURL: String + builds("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int): UserBuildsConnection + githubMarketplacePurchase: GitHubMarketplacePurchase + topActiveRepositories: [Repository] + organizations: [GitHubOrganizationInfo] + balanceInCredits: String! + transactions("fetching only nodes before this node (exclusive)" before: String "fetching only nodes after this node (exclusive)" after: String "fetching only the first certain number of nodes" first: Int "fetching only the last certain number of nodes" last: Int): UserTransactionsConnection + apiToken: ApiAccessToken + webPushServerKey: String! + persistentWorkerPools: [PersistentWorkerPool] +} + +"An edge in a connection" +type UserBuildEdge { + "The item at the end of the edge" + node: Build + "cursor marks a unique position or index into the connection" + cursor: String! +} + +"A connection to a list of items." +type UserBuildsConnection { + "a list of edges" + edges: [UserBuildEdge] + "details about this specific page" + pageInfo: PageInfo! +} + +"User type." +enum UserCategoryType { + DEFAULT + BLOCKED + TRUSTED + ADMIN +} + +"An edge in a connection" +type UserTransactionEdge { + "The item at the end of the edge" + node: AccountTransaction + "cursor marks a unique position or index into the connection" + cursor: String! +} + +"A connection to a list of items." +type UserTransactionsConnection { + "a list of edges" + edges: [UserTransactionEdge] + "details about this specific page" + pageInfo: PageInfo! +} + +type WebHookDelivery { + id: String! + accountId: Long! + repositoryId: Long! + timestamp: Long! + payload: WebHookDeliveryPayload! + response: WebHookDeliveryResponse! +} + +"An edge in a connection" +type WebHookDeliveryEdge { + "The item at the end of the edge" + node: WebHookDelivery + "cursor marks a unique position or index into the connection" + cursor: String! +} + +type WebHookDeliveryPayload { + event: String! + action: String! + data: String! +} + +type WebHookDeliveryResponse { + status: Int! + duration: Long! + data: String! +} + +type WebHookSettings { + webhookURL: String +} + +"A connection to a list of items." +type WebhookDeliveriesConnection { + "a list of edges" + edges: [WebHookDeliveryEdge] + "details about this specific page" + pageInfo: PageInfo! +} \ No newline at end of file diff --git a/CompatBot/Utils/Statistics.cs b/Clients/CompatApiClient/Utils/Statistics.cs similarity index 97% rename from CompatBot/Utils/Statistics.cs rename to Clients/CompatApiClient/Utils/Statistics.cs index 42abc561..649634e9 100644 --- a/CompatBot/Utils/Statistics.cs +++ b/Clients/CompatApiClient/Utils/Statistics.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace CompatBot.Utils +namespace CompatApiClient.Utils { public static class Statistics { diff --git a/Clients/GithubClient/Client.cs b/Clients/GithubClient/Client.cs index 2d3258e7..21010cdf 100644 --- a/Clients/GithubClient/Client.cs +++ b/Clients/GithubClient/Client.cs @@ -33,7 +33,7 @@ namespace GithubClient public Client() { client = HttpClientFactory.Create(new CompressionMessageHandler()); - jsonOptions = new JsonSerializerOptions + jsonOptions = new() { PropertyNamingPolicy = SpecialJsonNamingPolicy.SnakeCase, IgnoreNullValues = true, @@ -72,7 +72,7 @@ namespace GithubClient if (result == null) { ApiConfig.Log.Debug($"Failed to get {nameof(PrInfo)}, returning empty result"); - return new PrInfo { Number = pr }; + return new() { Number = pr }; } StatusesCache.Set(pr, result, PrStatusCacheTime); @@ -111,7 +111,7 @@ namespace GithubClient if (result == null) { ApiConfig.Log.Debug($"Failed to get {nameof(IssueInfo)}, returning empty result"); - return new IssueInfo { Number = issue }; + return new() { Number = issue }; } IssuesCache.Set(issue, result, IssueStatusCacheTime); diff --git a/CompatBot/Commands/Pr.cs b/CompatBot/Commands/Pr.cs index 3ed42103..0c9a16e6 100644 --- a/CompatBot/Commands/Pr.cs +++ b/CompatBot/Commands/Pr.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using CirrusCiClient; using CompatApiClient.Utils; using CompatBot.Commands.Attributes; using CompatBot.Utils; @@ -12,6 +13,7 @@ using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.Entities; using Microsoft.TeamFoundation.Build.WebApi; +using BuildStatus = Microsoft.TeamFoundation.Build.WebApi.BuildStatus; namespace CompatBot.Commands { @@ -127,12 +129,12 @@ namespace CompatBot.Commands string? linuxDownloadText = null; string? buildTime = null; - if (azureClient != null && prInfo.Head?.Sha is string commit) + if (prInfo.Head?.Sha is string commit) try { windowsDownloadText = "⏳ Pending..."; linuxDownloadText = "⏳ Pending..."; - var latestBuild = await azureClient.GetPrBuildInfoAsync(commit, prInfo.MergedAt, pr, Config.Cts.Token).ConfigureAwait(false); + var latestBuild = await CirrusCi.GetPrBuildInfoAsync(commit, prInfo.MergedAt, pr, Config.Cts.Token).ConfigureAwait(false); if (latestBuild == null) { if (state == "Open") @@ -143,18 +145,16 @@ namespace CompatBot.Commands else { bool shouldHaveArtifacts = false; - if (latestBuild.Status == BuildStatus.Completed - && (latestBuild.Result == BuildResult.Succeeded || latestBuild.Result == BuildResult.PartiallySucceeded) - && latestBuild.FinishTime.HasValue) + if (latestBuild is {Status: CirrusCiClient.BuildStatus.Completed, FinishTime: not null}) { buildTime = $"Built on {latestBuild.FinishTime:u} ({(DateTime.UtcNow - latestBuild.FinishTime.Value).AsTimeDeltaDescription()} ago)"; shouldHaveArtifacts = true; } - else if (latestBuild.Result == BuildResult.Failed || latestBuild.Result == BuildResult.Canceled) - windowsDownloadText = $"❌ {latestBuild.Result}"; - else if (latestBuild.Status == BuildStatus.InProgress && latestBuild.StartTime != null) + else if (latestBuild.Status is CirrusCiClient.BuildStatus.Failed or CirrusCiClient.BuildStatus.Errored or CirrusCiClient.BuildStatus.Aborted) + windowsDownloadText = $"❌ {latestBuild.Status}"; + else if (latestBuild is {Status: CirrusCiClient.BuildStatus.Executing}) { - var estimatedCompletionTime = latestBuild.StartTime.Value + (await azureClient.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; + var estimatedCompletionTime = latestBuild.StartTime + (await CirrusCi.GetPipelineDurationAsync(Config.Cts.Token).ConfigureAwait(false)).Mean; var estimatedTime = TimeSpan.FromMinutes(1); if (estimatedCompletionTime > DateTime.UtcNow) estimatedTime = estimatedCompletionTime - DateTime.UtcNow; @@ -202,7 +202,7 @@ namespace CompatBot.Commands if (!string.IsNullOrEmpty(buildTime)) embed.WithFooter(buildTime); } - else if (state == "Merged") + else if (state == "Merged" && azureClient is not null) { var mergeTime = prInfo.MergedAt.GetValueOrDefault(); var now = DateTime.UtcNow; diff --git a/CompatBot/CompatBot.csproj b/CompatBot/CompatBot.csproj index 238115b2..8465d4f3 100644 --- a/CompatBot/CompatBot.csproj +++ b/CompatBot/CompatBot.csproj @@ -86,6 +86,7 @@ + diff --git a/CompatBot/Program.cs b/CompatBot/Program.cs index 71208b8b..cd0f4f69 100644 --- a/CompatBot/Program.cs +++ b/CompatBot/Program.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; +using CirrusCiClient; using CompatBot.Commands; using CompatBot.Commands.Converters; using CompatBot.Database; @@ -113,7 +114,8 @@ namespace CompatBot #endif StatsStorage.BackgroundSaveAsync(), CompatList.ImportCompatListAsync(), - Config.GetAzureDevOpsClient().GetPipelineDurationAsync(Config.Cts.Token) + Config.GetAzureDevOpsClient().GetPipelineDurationAsync(Config.Cts.Token), + CirrusCi.GetPipelineDurationAsync(Config.Cts.Token) ); try diff --git a/CompatBot/Utils/Extensions/AzureDevOpsClientExtensions.cs b/CompatBot/Utils/Extensions/AzureDevOpsClientExtensions.cs index 6d50b868..69bb8fd4 100644 --- a/CompatBot/Utils/Extensions/AzureDevOpsClientExtensions.cs +++ b/CompatBot/Utils/Extensions/AzureDevOpsClientExtensions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using CompatApiClient.Utils; using Microsoft.Extensions.Caching.Memory; using Microsoft.TeamFoundation.Build.WebApi; using SharpCompress.Readers; diff --git a/discord-bot-net.sln b/discord-bot-net.sln index 9d0c5366..1574bb75 100644 --- a/discord-bot-net.sln +++ b/discord-bot-net.sln @@ -40,6 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YandexDiskClient", "Clients EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGenerators", "SourceGenerators\SourceGenerators.csproj", "{1A75FAF1-1DD1-43FF-A789-1AB216F4B94E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CirrusCiClient", "Clients\CirrusCiClient\CirrusCiClient.csproj", "{897476B0-B80A-4134-A576-8CAEAEA14A28}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -89,6 +91,10 @@ Global {1A75FAF1-1DD1-43FF-A789-1AB216F4B94E}.Debug|Any CPU.Build.0 = Debug|Any CPU {1A75FAF1-1DD1-43FF-A789-1AB216F4B94E}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A75FAF1-1DD1-43FF-A789-1AB216F4B94E}.Release|Any CPU.Build.0 = Release|Any CPU + {897476B0-B80A-4134-A576-8CAEAEA14A28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {897476B0-B80A-4134-A576-8CAEAEA14A28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {897476B0-B80A-4134-A576-8CAEAEA14A28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {897476B0-B80A-4134-A576-8CAEAEA14A28}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -101,6 +107,7 @@ Global {5C4BCF33-2EC6-455F-B026-8A0001B7B7AD} = {E7FE0ADD-CBA6-4321-8A1C-0A3B5C3F54C2} {1F743D3D-4A87-47EF-B88D-A0DCEE1C5FB7} = {E7FE0ADD-CBA6-4321-8A1C-0A3B5C3F54C2} {CABC3E5E-2153-443B-A5A8-DA3E389359EC} = {E7FE0ADD-CBA6-4321-8A1C-0A3B5C3F54C2} + {897476B0-B80A-4134-A576-8CAEAEA14A28} = {E7FE0ADD-CBA6-4321-8A1C-0A3B5C3F54C2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D7696F56-AEAC-4D83-9BD8-BE0C122A5DCE}