From 4e1396922f0424c90776e02746a80a6f7c4a98b7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 27 Jul 2026 16:47:54 +0100 Subject: [PATCH] Add build validation pipeline status column to PR list Adds a "Build" column showing an aggregated status icon for the build validation (branch policy) pipelines of each pull request, with a tooltip breaking down each pipeline's status. Statuses are fetched lazily per visible PR via the Policy Evaluations API, mirroring the existing comment-threads loading pattern (cache + capped concurrency). Addresses the build status portion of #1. Co-Authored-By: Claude Fable 5 --- README.md | 2 + azure-devops-extension.json | 2 +- src/Samples/ActivePullRequests/Constants.tsx | 3 + .../PullRequestsListingPageContent.tsx | 198 +++++++++++++++++- 4 files changed, 203 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2043325..f1f76ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Active Pull Requests A VSTS extension for showing a list of all active pull requests for chosen projects with features such as sorting, filtering, and much more. +Each pull request row shows a `Build` column with the status of the build validation (branch policy) pipelines for that pull request: succeeded, failed, running or queued. Hover over the icon for a per-pipeline breakdown. The column is empty when no build validation policy applies to the pull request. + ![alt text](images/screenshot.png) After installing, open an Azure DevOps project and go to `Repos` -> `Active Pull Requests`. diff --git a/azure-devops-extension.json b/azure-devops-extension.json index 1e3a6bf..50d925d 100644 --- a/azure-devops-extension.json +++ b/azure-devops-extension.json @@ -2,7 +2,7 @@ "manifestVersion": 1, "id": "active-pull-requests", "publisher": "karlingen", - "version": "1.0.43", + "version": "1.0.44", "name": "Active Pull Requests", "description": "Azure DevOps Extension", "categories": [ diff --git a/src/Samples/ActivePullRequests/Constants.tsx b/src/Samples/ActivePullRequests/Constants.tsx index b258273..0c2bbd0 100644 --- a/src/Samples/ActivePullRequests/Constants.tsx +++ b/src/Samples/ActivePullRequests/Constants.tsx @@ -15,4 +15,7 @@ export default class Constants { // Filter values public static readonly IsDraftKey = "isDraft"; public static readonly IsPublishedKey = "isPublished"; + + // Policy configuration type id for "Build" (build validation) policies + public static readonly BuildPolicyTypeId = "0609b952-1397-4640-95ec-e00a01b2c241"; } \ No newline at end of file diff --git a/src/Samples/ActivePullRequests/PullRequestsListingPageContent.tsx b/src/Samples/ActivePullRequests/PullRequestsListingPageContent.tsx index 9cc3308..14b88eb 100644 --- a/src/Samples/ActivePullRequests/PullRequestsListingPageContent.tsx +++ b/src/Samples/ActivePullRequests/PullRequestsListingPageContent.tsx @@ -13,6 +13,7 @@ import { CommentThreadStatus, IdentityRefWithVote } from "azure-devops-extension-api/Git"; +import { PolicyEvaluationRecord, PolicyEvaluationStatus, PolicyRestClient } from "azure-devops-extension-api/Policy"; import { Card } from "azure-devops-ui/Card"; import { Status, Statuses, StatusSize, IStatusProps } from "azure-devops-ui/Status"; import { Tooltip } from "azure-devops-ui/TooltipEx"; @@ -60,6 +61,7 @@ interface Dictionary { // Global caches to persist data across component instances const commentThreadsCache: { [key: number]: GitPullRequestCommentThread[] } = {}; +const buildPolicyEvaluationsCache: { [key: number]: PolicyEvaluationRecord[] } = {}; const avatarCache: { [descriptor: string]: string } = {}; // Maps descriptor to base64 data URL interface IPullRequestsListingPageContentState { @@ -67,6 +69,7 @@ interface IPullRequestsListingPageContentState { allPullRequests: GitPullRequest[]; filteredItems: GitPullRequest[]; commentThreadsByPRId: Dictionary; + buildPolicyEvaluationsByPRId: Dictionary; loading: boolean; currentUserId: string; avatarDataUrls: Dictionary; @@ -91,6 +94,7 @@ class PullRequestsListingPageContent extends React.Component, + tableItem: GitPullRequest + ): JSX.Element => { + + const hasEvaluations = Object.prototype.hasOwnProperty.call(this.state.buildPolicyEvaluationsByPRId, tableItem.pullRequestId); + const evaluations = hasEvaluations ? this.state.buildPolicyEvaluationsByPRId[tableItem.pullRequestId] : []; + + let content: JSX.Element; + if (!hasEvaluations) { + content = ( + + + + ); + } else if (evaluations.length === 0) { + // No build validation policy applies to this pull request + content = ; + } else { + const statusIndicatorData = getBuildStatusIndicatorData(evaluations); + content = ( + +
+ +
+
+ ); + } + + return ( + + {content} + + ); + }, + width: new ObservableValue(-25) + }, { id: "comments", name: "Comments", @@ -585,6 +644,98 @@ class PullRequestsListingPageContent extends React.Component worker())); } + private async ensureBuildStatusesLoadedForPRs(pullRequests: GitPullRequest[]) { + // First, hydrate component state from cache for any visible PRs + // that have cached evaluations but are not yet present in state. + const fromCache: Dictionary = {}; + for (const pr of pullRequests) { + const id = pr.pullRequestId; + const inState = Object.prototype.hasOwnProperty.call(this.state.buildPolicyEvaluationsByPRId, id); + const inCache = Object.prototype.hasOwnProperty.call(buildPolicyEvaluationsCache, id); + if (!inState && inCache) { + fromCache[id] = buildPolicyEvaluationsCache[id]; + } + } + + if (Object.keys(fromCache).length > 0 && this._isMounted) { + this.setState(prev => ({ + buildPolicyEvaluationsByPRId: { ...prev.buildPolicyEvaluationsByPRId, ...fromCache } + })); + } + + // Determine which PRs need fetching (not already in state or cache) + const toFetch = pullRequests.filter(pr => + !Object.prototype.hasOwnProperty.call(this.state.buildPolicyEvaluationsByPRId, pr.pullRequestId) && + !Object.prototype.hasOwnProperty.call(buildPolicyEvaluationsCache, pr.pullRequestId) + ); + + if (toFetch.length > 0) { + // Fire and forget; do not block UI + this.fetchBuildPolicyEvaluationsForPRs(toFetch); + } + } + + private async fetchBuildPolicyEvaluationsForPRs(pullRequests: GitPullRequest[]) { + const policyRestClient: PolicyRestClient = getClient(PolicyRestClient); + const maxConcurrency = 6; // Avoid overloading the API/browser + let index = 0; + + const worker = async () => { + let localBatch: Dictionary = {}; + const batchSize = 10; + while (true) { + const i = index++; + if (i >= pullRequests.length) { + break; + } + const pr = pullRequests[i]; + + // Skip if already cached (race-safe check) + if (Object.prototype.hasOwnProperty.call(buildPolicyEvaluationsCache, pr.pullRequestId)) { + localBatch[pr.pullRequestId] = buildPolicyEvaluationsCache[pr.pullRequestId]; + } else { + try { + const artifactId = `vstfs:///CodeReview/CodeReviewId/${pr.repository.project.id}/${pr.pullRequestId}`; + const evaluations = await policyRestClient.getPolicyEvaluations(pr.repository.project.id, artifactId); + const buildEvaluations = evaluations?.filter(x => + x.configuration?.type?.id === Constants.BuildPolicyTypeId && + x.configuration.isEnabled && + !x.configuration.isDeleted && + x.status !== PolicyEvaluationStatus.NotApplicable) ?? []; + buildPolicyEvaluationsCache[pr.pullRequestId] = buildEvaluations; + localBatch[pr.pullRequestId] = buildEvaluations; + } catch (e) { + // On error, store empty to avoid blocking UI + buildPolicyEvaluationsCache[pr.pullRequestId] = []; + localBatch[pr.pullRequestId] = []; + } + } + + if (Object.keys(localBatch).length >= batchSize) { + const toApply = localBatch; + localBatch = {}; + if (this._isMounted) { + this.setState(prev => ({ + buildPolicyEvaluationsByPRId: { ...prev.buildPolicyEvaluationsByPRId, ...toApply } + })); + } + } + } + + // Flush any remaining items in this worker's batch + if (Object.keys(localBatch).length > 0) { + const toApply = localBatch; + if (this._isMounted) { + this.setState(prev => ({ + buildPolicyEvaluationsByPRId: { ...prev.buildPolicyEvaluationsByPRId, ...toApply } + })); + } + } + }; + + await Promise.all(new Array(maxConcurrency).fill(0).map(() => worker())); + } + private getPRUrl(projectName: string, repositoryName: string, pullRequestId): string { return this.props.baseUrl + encodeURIComponent(projectName) + '/_git/' + encodeURIComponent(repositoryName) + '/pullRequest/' + pullRequestId; } @@ -660,6 +811,9 @@ class PullRequestsListingPageContent extends React.Component x.status === PolicyEvaluationStatus.Rejected || x.status === PolicyEvaluationStatus.Broken)) { + return { statusProps: Statuses.Failed, label: "Failed" } + } + if (evaluations.some(x => x.status === PolicyEvaluationStatus.Running)) { + return { statusProps: Statuses.Running, label: "Running" } + } + if (evaluations.some(x => x.status === PolicyEvaluationStatus.Queued)) { + return { statusProps: Statuses.Queued, label: "Queued" } + } + if (evaluations.every(x => x.status === PolicyEvaluationStatus.Approved)) { + return { statusProps: Statuses.Success, label: "Succeeded" } + } + + return { statusProps: Statuses.Queued, label: "Unknown" } +} + +function getBuildStatusLabel(status: PolicyEvaluationStatus): string { + switch (status) { + case PolicyEvaluationStatus.Queued: + return "queued"; + case PolicyEvaluationStatus.Running: + return "running"; + case PolicyEvaluationStatus.Approved: + return "succeeded"; + case PolicyEvaluationStatus.Rejected: + return "failed"; + case PolicyEvaluationStatus.Broken: + return "broken"; + } + + return "unknown"; +} + +function getBuildStatusTooltipText(evaluations: PolicyEvaluationRecord[]): string { + return evaluations + .map(x => `${x.configuration?.settings?.displayName || "Build validation"}: ${getBuildStatusLabel(x.status)}`) + .join(", "); +} + function getVoteStatusIndicatorData(vote: number): IStatusIndicatorData { // This is from the 'vote' method docs (I can't find a constant): // 10 - approved