├── .github
├── dependabot.yml
└── workflows
│ ├── dependabot-merge.yml
│ └── dotnet.yml
├── .gitignore
├── .idea
└── .idea.PullRequestScanner
│ └── .idea
│ ├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
│ ├── indexLayout.xml
│ ├── projectSettingsUpdater.xml
│ ├── vcs.xml
│ └── workspace.xml
├── LICENSE
├── PullRequestScanner.sln
├── README.md
├── TomLonghurst.PullRequestScanner.AzureDevOps
├── Extensions
│ └── PullRequestScannerBuilderExtensions.cs
├── Mappers
│ ├── DevOpsMapper.cs
│ └── IDevOpsMapper.cs
├── Models
│ └── DevOpsPullRequestContext.cs
├── Options
│ └── AzureDevOpsOptions.cs
├── Services
│ ├── AzureDevOpsInitializer.cs
│ ├── AzureDevOpsPullRequestProvider.cs
│ ├── DevOpsGitRepositoryService.cs
│ ├── DevOpsPullRequestService.cs
│ ├── DevOpsTeamMembersProvider.cs
│ ├── IDevOpsGitRepositoryService.cs
│ └── IDevOpsPullRequestService.cs
└── TomLonghurst.PullRequestScanner.AzureDevOps.csproj
├── TomLonghurst.PullRequestScanner.Example
├── Program.cs
└── TomLonghurst.PullRequestScanner.Example.csproj
├── TomLonghurst.PullRequestScanner.GitHub
├── Extensions
│ └── PullRequestScannerBuilderExtensions.cs
├── Http
│ └── GithubHttpClient.cs
├── Mappers
│ ├── GithubMapper.cs
│ └── IGithubMapper.cs
├── Models
│ ├── GithubComment.cs
│ ├── GithubMember.cs
│ ├── GithubPullRequest.cs
│ ├── GithubRepository.cs
│ ├── GithubReviewer.cs
│ ├── GithubTeam.cs
│ ├── GithubThread.cs
│ ├── GraphQl
│ │ ├── Author.cs
│ │ ├── CommentNode.cs
│ │ ├── CommentThreadNode.cs
│ │ ├── Comments.cs
│ │ ├── Data.cs
│ │ ├── Edge.cs
│ │ ├── GithubGraphQlPullRequestThreadsResponse.cs
│ │ ├── PullRequest.cs
│ │ ├── Repository.cs
│ │ └── ReviewThreads.cs
│ └── Owner.cs
├── Options
│ ├── GithubOptions.cs
│ ├── GithubOrganizationTeamOptions.cs
│ └── GithubUserOptions.cs
├── Services
│ ├── BaseGitHubApiService.cs
│ ├── GitHubPullRequestProvider.cs
│ ├── GithubGraphQlClientProvider.cs
│ ├── GithubPullRequestService.cs
│ ├── GithubQueryRunner.cs
│ ├── GithubRepositoryService.cs
│ ├── GithubTeamMembersProvider.cs
│ ├── IGithubGraphQlClientProvider.cs
│ ├── IGithubPullRequestService.cs
│ ├── IGithubQueryRunner.cs
│ └── IGithubRepositoryService.cs
└── TomLonghurst.PullRequestScanner.GitHub.csproj
├── TomLonghurst.PullRequestScanner.Pipeline
├── Modules
│ ├── NugetVersionGeneratorModule.cs
│ ├── PackProjectsModule.cs
│ ├── PackageFilesRemovalModule.cs
│ ├── PackagePathsParserModule.cs
│ ├── RunUnitTestsModule.cs
│ └── UploadPackagesToNugetModule.cs
├── Program.cs
├── Settings
│ └── NuGetSettings.cs
├── TomLonghurst.PullRequestScanner.Pipeline.csproj
└── appsettings.json
├── TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook
├── Extensions
│ ├── AdaptiveCardExtensions.cs
│ └── PullRequestScannerBuilderExtensions.cs
├── Http
│ └── MicrosoftTeamsWebhookClient.cs
├── Mappers
│ ├── IPullRequestLeaderboardCardMapper.cs
│ ├── IPullRequestStatusCardMapper.cs
│ ├── IPullRequestsOverviewCardMapper.cs
│ ├── PullRequestLeaderboardCardMapper.cs
│ ├── PullRequestStatusCardMapper.cs
│ └── PullRequestsOverviewCardMapper.cs
├── MicrosoftTeamsWebHookPublisherBuilder.cs
├── Models
│ ├── AdaptiveCardMentionedEntity.cs
│ ├── Attachment.cs
│ ├── Mentioned.cs
│ ├── MicrosoftTeamsAdaptiveCard.cs
│ ├── MicrosoftTeamsProperties.cs
│ └── TeamsNotificationCardWrapper.cs
├── Options
│ ├── MicrosoftTeamsOptions.cs
│ ├── MicrosoftTeamsPublishOptions.cs
│ └── MicrosoftTeamsStatusPublishOptions.cs
├── Services
│ ├── IMicrosoftTeamsWebHookPublisher.cs
│ ├── MicrosoftTeamsWebHookPublisher.cs
│ ├── MicrosoftTeamsWebHookPublisherBase.cs
│ ├── PullRequestLeaderboardMicrosoftTeamsWebHookPublisher.cs
│ ├── PullRequestOverviewMicrosoftTeamsWebHookPublisher.cs
│ └── PullRequestStatusMicrosoftTeamsWebHookPublisher.cs
└── TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.csproj
├── TomLonghurst.PullRequestScanner
├── Constants.cs
├── Contracts
│ ├── IPullRequestPlugin.cs
│ ├── IPullRequestProvider.cs
│ └── ITeamMembersProvider.cs
├── Enums
│ └── PullRequestStatus.cs
├── Exceptions
│ ├── NoPullRequestPluginsRegisteredException.cs
│ ├── NoPullRequestProvidersRegisteredException.cs
│ └── PullRequestScannerException.cs
├── Extensions
│ ├── DateTimeExtensions.cs
│ ├── DependencyInjectionExtensions.cs
│ ├── EnumerableExtenions.cs
│ ├── PullRequestScannerBuilder.cs
│ └── UriExtensions.cs
├── IHasCount.cs
├── Mappers
│ └── PullRequestStatusMapper.cs
├── Models
│ ├── Approver.cs
│ ├── Comment.cs
│ ├── CommentThread.cs
│ ├── ITeamMember.cs
│ ├── MutableGroup.cs
│ ├── PullRequest.cs
│ ├── PullRequestReviewLeaderboardModel.cs
│ ├── Repository.cs
│ ├── TeamMember.cs
│ ├── TeamMemberImpl.cs
│ ├── ThreadStatus.cs
│ └── Vote.cs
├── Services
│ ├── IHasPlugins.cs
│ ├── IPluginService.cs
│ ├── IPullRequestScanner.cs
│ ├── IPullRequestService.cs
│ ├── ITeamMembersService.cs
│ ├── PluginService.cs
│ ├── PullRequestScanner.cs
│ ├── PullRequestService.cs
│ └── TeamMembersService.cs
└── TomLonghurst.PullRequestScanner.csproj
└── renovate.json
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "nuget"
4 | directory: "/"
5 | open-pull-requests-limit: 20
6 | schedule:
7 | interval: "daily"
8 | groups:
9 | test-dependencies:
10 | patterns:
11 | - NUnit*
12 | - "*Test*"
13 | modularpipelines-dependencies:
14 | patterns:
15 | - "*ModularPipeline*"
16 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot-merge.yml:
--------------------------------------------------------------------------------
1 | name: Dependabot auto-merge
2 | on: pull_request
3 |
4 | permissions:
5 | contents: write
6 | pull-requests: write
7 |
8 | jobs:
9 | dependabot:
10 | runs-on: ubuntu-latest
11 | if: github.actor == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'dependencies')
12 | steps:
13 | - name: Enable auto-merge for Dependabot PRs
14 | run: gh pr merge --auto --merge "$PR_URL"
15 | env:
16 | PR_URL: ${{github.event.pull_request.html_url}}
17 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
18 |
--------------------------------------------------------------------------------
/.github/workflows/dotnet.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a .NET project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
3 |
4 | name: .NET
5 |
6 | on:
7 | push:
8 | branches: ["main"]
9 | pull_request:
10 | branches: ["main"]
11 | workflow_dispatch:
12 | inputs:
13 | publish-packages:
14 | description: Publish packages?
15 | type: boolean
16 | required: true
17 |
18 | jobs:
19 | modularpipeline:
20 | environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }}
21 | runs-on: ubuntu-latest
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 | with:
26 | fetch-depth: 0
27 | persist-credentials: false
28 | - name: Setup .NET
29 | uses: actions/setup-dotnet@v4
30 | with:
31 | dotnet-version: 8.0.x
32 | - name: Run Pipeline
33 | run: dotnet run -c Release
34 | working-directory: "TomLonghurst.PullRequestScanner.Pipeline"
35 | env:
36 | DOTNET_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Development' }}
37 | NuGet__ApiKey: ${{ secrets.NUGET__APIKEY }}
38 | PULL_REQUEST_BRANCH: ${{ github.event.pull_request.head.ref }}
39 | PUBLISH_PACKAGES: ${{ github.event.inputs.publish-packages || false }}
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 | [Ll]ogs/
33 |
34 | # Visual Studio 2015/2017 cache/options directory
35 | .vs/
36 | # Uncomment if you have tasks that create the project's static files in wwwroot
37 | #wwwroot/
38 |
39 | # Visual Studio 2017 auto generated files
40 | Generated\ Files/
41 |
42 | # MSTest test Results
43 | [Tt]est[Rr]esult*/
44 | [Bb]uild[Ll]og.*
45 |
46 | # NUnit
47 | *.VisualState.xml
48 | TestResult.xml
49 | nunit-*.xml
50 |
51 | # Build Results of an ATL Project
52 | [Dd]ebugPS/
53 | [Rr]eleasePS/
54 | dlldata.c
55 |
56 | # Benchmark Results
57 | BenchmarkDotNet.Artifacts/
58 |
59 | # .NET Core
60 | project.lock.json
61 | project.fragment.lock.json
62 | artifacts/
63 |
64 | # StyleCop
65 | StyleCopReport.xml
66 |
67 | # Files built by Visual Studio
68 | *_i.c
69 | *_p.c
70 | *_h.h
71 | *.ilk
72 | *.meta
73 | *.obj
74 | *.iobj
75 | *.pch
76 | *.pdb
77 | *.ipdb
78 | *.pgc
79 | *.pgd
80 | *.rsp
81 | *.sbr
82 | *.tlb
83 | *.tli
84 | *.tlh
85 | *.tmp
86 | *.tmp_proj
87 | *_wpftmp.csproj
88 | *.log
89 | *.vspscc
90 | *.vssscc
91 | .builds
92 | *.pidb
93 | *.svclog
94 | *.scc
95 |
96 | # Chutzpah Test files
97 | _Chutzpah*
98 |
99 | # Visual C++ cache files
100 | ipch/
101 | *.aps
102 | *.ncb
103 | *.opendb
104 | *.opensdf
105 | *.sdf
106 | *.cachefile
107 | *.VC.db
108 | *.VC.VC.opendb
109 |
110 | # Visual Studio profiler
111 | *.psess
112 | *.vsp
113 | *.vspx
114 | *.sap
115 |
116 | # Visual Studio Trace Files
117 | *.e2e
118 |
119 | # TFS 2012 Local Workspace
120 | $tf/
121 |
122 | # Guidance Automation Toolkit
123 | *.gpState
124 |
125 | # ReSharper is a .NET coding add-in
126 | _ReSharper*/
127 | *.[Rr]e[Ss]harper
128 | *.DotSettings.user
129 |
130 | # TeamCity is a build add-in
131 | _TeamCity*
132 |
133 | # DotCover is a Code Coverage Tool
134 | *.dotCover
135 |
136 | # AxoCover is a Code Coverage Tool
137 | .axoCover/*
138 | !.axoCover/settings.json
139 |
140 | # Visual Studio code coverage results
141 | *.coverage
142 | *.coveragexml
143 |
144 | # NCrunch
145 | _NCrunch_*
146 | .*crunch*.local.xml
147 | nCrunchTemp_*
148 |
149 | # MightyMoose
150 | *.mm.*
151 | AutoTest.Net/
152 |
153 | # Web workbench (sass)
154 | .sass-cache/
155 |
156 | # Installshield output folder
157 | [Ee]xpress/
158 |
159 | # DocProject is a documentation generator add-in
160 | DocProject/buildhelp/
161 | DocProject/Help/*.HxT
162 | DocProject/Help/*.HxC
163 | DocProject/Help/*.hhc
164 | DocProject/Help/*.hhk
165 | DocProject/Help/*.hhp
166 | DocProject/Help/Html2
167 | DocProject/Help/html
168 |
169 | # Click-Once directory
170 | publish/
171 |
172 | # Publish Web Output
173 | *.[Pp]ublish.xml
174 | *.azurePubxml
175 | # Note: Comment the next line if you want to checkin your web deploy settings,
176 | # but database connection strings (with potential passwords) will be unencrypted
177 | *.pubxml
178 | *.publishproj
179 |
180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
181 | # checkin your Azure Web App publish settings, but sensitive information contained
182 | # in these scripts will be unencrypted
183 | PublishScripts/
184 |
185 | # NuGet Packages
186 | *.nupkg
187 | # NuGet Symbol Packages
188 | *.snupkg
189 | # The packages folder can be ignored because of Package Restore
190 | **/[Pp]ackages/*
191 | # except build/, which is used as an MSBuild target.
192 | !**/[Pp]ackages/build/
193 | # Uncomment if necessary however generally it will be regenerated when needed
194 | #!**/[Pp]ackages/repositories.config
195 | # NuGet v3's project.json files produces more ignorable files
196 | *.nuget.props
197 | *.nuget.targets
198 |
199 | # Microsoft Azure Build Output
200 | csx/
201 | *.build.csdef
202 |
203 | # Microsoft Azure Emulator
204 | ecf/
205 | rcf/
206 |
207 | # Windows Store app package directories and files
208 | AppPackages/
209 | BundleArtifacts/
210 | Package.StoreAssociation.xml
211 | _pkginfo.txt
212 | *.appx
213 | *.appxbundle
214 | *.appxupload
215 |
216 | # Visual Studio cache files
217 | # files ending in .cache can be ignored
218 | *.[Cc]ache
219 | # but keep track of directories ending in .cache
220 | !?*.[Cc]ache/
221 |
222 | # Others
223 | ClientBin/
224 | ~$*
225 | *~
226 | *.dbmdl
227 | *.dbproj.schemaview
228 | *.jfm
229 | *.pfx
230 | *.publishsettings
231 | orleans.codegen.cs
232 |
233 | # Including strong name files can present a security risk
234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
235 | #*.snk
236 |
237 | # Since there are multiple workflows, uncomment next line to ignore bower_components
238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
239 | #bower_components/
240 |
241 | # RIA/Silverlight projects
242 | Generated_Code/
243 |
244 | # Backup & report files from converting an old project file
245 | # to a newer Visual Studio version. Backup files are not needed,
246 | # because we have git ;-)
247 | _UpgradeReport_Files/
248 | Backup*/
249 | UpgradeLog*.XML
250 | UpgradeLog*.htm
251 | ServiceFabricBackup/
252 | *.rptproj.bak
253 |
254 | # SQL Server files
255 | *.mdf
256 | *.ldf
257 | *.ndf
258 |
259 | # Business Intelligence projects
260 | *.rdl.data
261 | *.bim.layout
262 | *.bim_*.settings
263 | *.rptproj.rsuser
264 | *- [Bb]ackup.rdl
265 | *- [Bb]ackup ([0-9]).rdl
266 | *- [Bb]ackup ([0-9][0-9]).rdl
267 |
268 | # Microsoft Fakes
269 | FakesAssemblies/
270 |
271 | # GhostDoc plugin setting file
272 | *.GhostDoc.xml
273 |
274 | # Node.js Tools for Visual Studio
275 | .ntvs_analysis.dat
276 | node_modules/
277 |
278 | # Visual Studio 6 build log
279 | *.plg
280 |
281 | # Visual Studio 6 workspace options file
282 | *.opt
283 |
284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
285 | *.vbw
286 |
287 | # Visual Studio LightSwitch build output
288 | **/*.HTMLClient/GeneratedArtifacts
289 | **/*.DesktopClient/GeneratedArtifacts
290 | **/*.DesktopClient/ModelManifest.xml
291 | **/*.Server/GeneratedArtifacts
292 | **/*.Server/ModelManifest.xml
293 | _Pvt_Extensions
294 |
295 | # Paket dependency manager
296 | .paket/paket.exe
297 | paket-files/
298 |
299 | # FAKE - F# Make
300 | .fake/
301 |
302 | # CodeRush personal settings
303 | .cr/personal
304 |
305 | # Python Tools for Visual Studio (PTVS)
306 | __pycache__/
307 | *.pyc
308 |
309 | # Cake - Uncomment if you are using it
310 | # tools/**
311 | # !tools/packages.config
312 |
313 | # Tabs Studio
314 | *.tss
315 |
316 | # Telerik's JustMock configuration file
317 | *.jmconfig
318 |
319 | # BizTalk build output
320 | *.btp.cs
321 | *.btm.cs
322 | *.odx.cs
323 | *.xsd.cs
324 |
325 | # OpenCover UI analysis results
326 | OpenCover/
327 |
328 | # Azure Stream Analytics local run output
329 | ASALocalRun/
330 |
331 | # MSBuild Binary and Structured Log
332 | *.binlog
333 |
334 | # NVidia Nsight GPU debugger configuration file
335 | *.nvuser
336 |
337 | # MFractors (Xamarin productivity tool) working folder
338 | .mfractor/
339 |
340 | # Local History for Visual Studio
341 | .localhistory/
342 |
343 | # BeatPulse healthcheck temp database
344 | healthchecksdb
345 |
346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
347 | MigrationBackup/
348 |
349 | # Ionide (cross platform F# VS Code tools) working folder
350 | .ionide/
351 |
352 | buildAndPublish.bat
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/indexLayout.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/projectSettingsUpdater.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.idea.PullRequestScanner/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | TomLonghurst.PullRequestScanner.Example/TomLonghurst.PullRequestScanner.Example.csproj
5 | TomLonghurst.PullRequestScanner.Pipeline/TomLonghurst.PullRequestScanner.Pipeline.csproj
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {
39 | "associatedIndex": 5
40 | }
41 |
42 |
43 |
44 |
45 |
46 | {
47 | "keyToString": {
48 | "RunOnceActivity.OpenProjectViewOnStart": "true",
49 | "RunOnceActivity.ShowReadmeOnStart": "true",
50 | "git-widget-placeholder": "feature/no-graph-ql",
51 | "ignore.virus.scanning.warn.message": "true",
52 | "node.js.detected.package.eslint": "true",
53 | "node.js.detected.package.tslint": "true",
54 | "node.js.selected.package.eslint": "(autodetect)",
55 | "node.js.selected.package.tslint": "(autodetect)",
56 | "nodejs_package_manager_path": "npm",
57 | "settings.editor.selected.configurable": "preferences.sourceCode.C#",
58 | "vue.rearranger.settings.migration": "true"
59 | },
60 | "keyToStringList": {
61 | "rider.external.source.directories": [
62 | "C:\\Users\\Tom\\AppData\\Roaming\\JetBrains\\Rider2023.3\\resharper-host\\DecompilerCache",
63 | "C:\\Users\\Tom\\AppData\\Roaming\\JetBrains\\Rider2023.3\\resharper-host\\SourcesCache",
64 | "C:\\Users\\Tom\\AppData\\Local\\Symbols\\src"
65 | ]
66 | }
67 | }
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | 1712251347252
111 |
112 |
113 | 1712251347252
114 |
115 |
116 |
117 |
118 |
119 |
120 | 1712251752984
121 |
122 |
123 |
124 | 1712251752984
125 |
126 |
127 |
128 | 1712251768448
129 |
130 |
131 |
132 | 1712251768448
133 |
134 |
135 |
136 | 1712251872759
137 |
138 |
139 |
140 | 1712251872759
141 |
142 |
143 |
144 | 1712251902318
145 |
146 |
147 |
148 | 1712251902318
149 |
150 |
151 |
152 | 1712253053919
153 |
154 |
155 |
156 | 1712253053919
157 |
158 |
159 |
160 | 1712253093530
161 |
162 |
163 |
164 | 1712253093530
165 |
166 |
167 |
168 | 1712253564145
169 |
170 |
171 |
172 | 1712253564145
173 |
174 |
175 |
176 | 1712268001291
177 |
178 |
179 |
180 | 1712268001291
181 |
182 |
183 |
184 | 1712268074428
185 |
186 |
187 |
188 | 1712268074428
189 |
190 |
191 |
192 | 1712268307652
193 |
194 |
195 |
196 | 1712268307652
197 |
198 |
199 |
200 | 1712268339689
201 |
202 |
203 |
204 | 1712268339689
205 |
206 |
207 |
208 | 1712268516150
209 |
210 |
211 |
212 | 1712268516150
213 |
214 |
215 |
216 | 1712268541010
217 |
218 |
219 |
220 | 1712268541010
221 |
222 |
223 |
224 | 1712268928076
225 |
226 |
227 |
228 | 1712268928076
229 |
230 |
231 |
232 | 1712268976444
233 |
234 |
235 |
236 | 1712268976444
237 |
238 |
239 |
240 | 1712269077945
241 |
242 |
243 |
244 | 1712269077945
245 |
246 |
247 |
248 | 1712269230683
249 |
250 |
251 |
252 | 1712269230683
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/PullRequestScanner.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner", "TomLonghurst.PullRequestScanner\TomLonghurst.PullRequestScanner.csproj", "{3DB24D0C-E3D9-4955-8DA6-DFDEEB4D5539}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner.AzureDevOps", "TomLonghurst.PullRequestScanner.AzureDevOps\TomLonghurst.PullRequestScanner.AzureDevOps.csproj", "{7CF342C7-91B7-438C-B3DE-7EF6AE1353CF}"
6 | EndProject
7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner.GitHub", "TomLonghurst.PullRequestScanner.GitHub\TomLonghurst.PullRequestScanner.GitHub.csproj", "{D2967AD5-F36A-4208-82D8-58454ED83D75}"
8 | EndProject
9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook", "TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook\TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.csproj", "{6727C281-B5E9-443D-A739-8218046B3B15}"
10 | EndProject
11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner.Example", "TomLonghurst.PullRequestScanner.Example\TomLonghurst.PullRequestScanner.Example.csproj", "{9F8485B7-A53B-48BB-BC8A-1FE12D5CBB12}"
12 | EndProject
13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TomLonghurst.PullRequestScanner.Pipeline", "TomLonghurst.PullRequestScanner.Pipeline\TomLonghurst.PullRequestScanner.Pipeline.csproj", "{38D9DD6D-37AD-4B2D-AF42-DBF7D7C29480}"
14 | EndProject
15 | Global
16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
17 | Debug|Any CPU = Debug|Any CPU
18 | Release|Any CPU = Release|Any CPU
19 | EndGlobalSection
20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
21 | {3DB24D0C-E3D9-4955-8DA6-DFDEEB4D5539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22 | {3DB24D0C-E3D9-4955-8DA6-DFDEEB4D5539}.Debug|Any CPU.Build.0 = Debug|Any CPU
23 | {3DB24D0C-E3D9-4955-8DA6-DFDEEB4D5539}.Release|Any CPU.ActiveCfg = Release|Any CPU
24 | {3DB24D0C-E3D9-4955-8DA6-DFDEEB4D5539}.Release|Any CPU.Build.0 = Release|Any CPU
25 | {7CF342C7-91B7-438C-B3DE-7EF6AE1353CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
26 | {7CF342C7-91B7-438C-B3DE-7EF6AE1353CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
27 | {7CF342C7-91B7-438C-B3DE-7EF6AE1353CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
28 | {7CF342C7-91B7-438C-B3DE-7EF6AE1353CF}.Release|Any CPU.Build.0 = Release|Any CPU
29 | {D2967AD5-F36A-4208-82D8-58454ED83D75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
30 | {D2967AD5-F36A-4208-82D8-58454ED83D75}.Debug|Any CPU.Build.0 = Debug|Any CPU
31 | {D2967AD5-F36A-4208-82D8-58454ED83D75}.Release|Any CPU.ActiveCfg = Release|Any CPU
32 | {D2967AD5-F36A-4208-82D8-58454ED83D75}.Release|Any CPU.Build.0 = Release|Any CPU
33 | {6727C281-B5E9-443D-A739-8218046B3B15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
34 | {6727C281-B5E9-443D-A739-8218046B3B15}.Debug|Any CPU.Build.0 = Debug|Any CPU
35 | {6727C281-B5E9-443D-A739-8218046B3B15}.Release|Any CPU.ActiveCfg = Release|Any CPU
36 | {6727C281-B5E9-443D-A739-8218046B3B15}.Release|Any CPU.Build.0 = Release|Any CPU
37 | {9F8485B7-A53B-48BB-BC8A-1FE12D5CBB12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
38 | {9F8485B7-A53B-48BB-BC8A-1FE12D5CBB12}.Debug|Any CPU.Build.0 = Debug|Any CPU
39 | {9F8485B7-A53B-48BB-BC8A-1FE12D5CBB12}.Release|Any CPU.ActiveCfg = Release|Any CPU
40 | {9F8485B7-A53B-48BB-BC8A-1FE12D5CBB12}.Release|Any CPU.Build.0 = Release|Any CPU
41 | {38D9DD6D-37AD-4B2D-AF42-DBF7D7C29480}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
42 | {38D9DD6D-37AD-4B2D-AF42-DBF7D7C29480}.Debug|Any CPU.Build.0 = Debug|Any CPU
43 | {38D9DD6D-37AD-4B2D-AF42-DBF7D7C29480}.Release|Any CPU.ActiveCfg = Release|Any CPU
44 | {38D9DD6D-37AD-4B2D-AF42-DBF7D7C29480}.Release|Any CPU.Build.0 = Release|Any CPU
45 | EndGlobalSection
46 | EndGlobal
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PullRequestScanner
2 |
3 | A Pull Request Scanner that is extendable, allowing you to add pull request providers, or plugins, that do something with the pull request data.
4 | If you write the implementation for a new provider, or a new plugin, feel free to open a pull request to add it into the repository.
5 |
6 | Currently the out-of-the-box providers are Azure DevOps and GitHub, with a plugin that can publish cards to a Microsoft Teams Webhook, notifying your team of any open pull requests and their current state.
7 |
8 | # Install
9 |
10 | Install via Nuget
11 |
12 | > Install-Package TomLonghurst.PullRequestScanner
13 |
14 | ## Available Providers
15 |
16 | ### Azure DevOps
17 |
18 | > Install-Package TomLonghurst.PullRequestScanner.AzureDevOps
19 |
20 | ### GitHub
21 |
22 | > Install-Package TomLonghurst.PullRequestScanner.GitHub
23 |
24 | ## Available Plugins
25 |
26 | ### Microsoft Teams Web Hook
27 |
28 | > Install-Package TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook
29 |
30 | ## Usage
31 |
32 | Requires .NET 6
33 |
34 | In your startup, call:
35 |
36 | ```csharp
37 | services
38 | .AddPullRequestScanner();
39 | ```
40 |
41 | And either use any of the already built providers and plugins, or build your own and plug them in.
42 | That could either look like:
43 |
44 | ```csharp
45 | builder.Services
46 | .AddPullRequestScanner()
47 | .AddGithub(new GithubOrganizationTeamOptions
48 | {
49 | PersonalAccessToken = myGithubPat,
50 | OrganizationSlug = myOrganisation,
51 | TeamSlug = myGithubTeamSlug
52 | })
53 | .AddAzureDevOps(new AzureDevOpsOptions
54 | {
55 | OrganizationSlug = myOrganisation,
56 | ProjectSlug = myAzureDevOpsProjectSlug,
57 | PersonalAccessToken = myAzureDevopsPat
58 | })
59 | .AddMicrosoftTeamsWebHookPublisher(new MicrosoftTeamsOptions
60 | {
61 | WebHookUri = new Uri(myMicrosoftTeamsWebhookUri)
62 | },
63 | microsoftTeamsWebHookPublisherBuilder =>
64 | {
65 | microsoftTeamsWebHookPublisherBuilder.AddLeaderboardCardPublisher();
66 | microsoftTeamsWebHookPublisherBuilder.AddOverviewCardPublisher();
67 | microsoftTeamsWebHookPublisherBuilder.AddStatusCardsPublisher();
68 | }
69 | );
70 | ```
71 |
72 | or
73 |
74 | ```csharp
75 | builder.Services
76 | .AddPullRequestScanner()
77 | .AddPullRequestProvider(serviceProvider => serviceProvider.GetRequiredService())
78 | .AddPullRequestProvider(serviceProvider => serviceProvider.GetRequiredService())
79 | .AddPullRequestProvider(serviceProvider => serviceProvider.GetRequiredService())
80 | .AddPlugin(serviceProvider => serviceProvider.GetRequiredService())
81 | .AddPlugin(serviceProvider => serviceProvider.GetRequiredService());
82 | ```
83 |
84 | Then wherever you want to use it, just inject `IPullRequestScanner` into your class. With this you can:
85 |
86 | - Get Pull Request Data
87 | - Execute All Plugins
88 | - Execute All Plugins that meet a condition
89 | - Get a particular plugin and invoke it with more granular control
90 |
91 | ### Excluding Pull Requests
92 | You need to tag/label your pull request with `prscanner-ignore`
93 | - In Azure DevOps, add as a tag to your pull request
94 | - In GitHub, add as a label to your pull request
95 |
96 | ## Example
97 |
98 | A simple Timed Azure Function to notify your team every morning can look as simple as this:
99 |
100 | ```csharp
101 |
102 | [assembly: WebJobsStartup(typeof(Startup))]
103 | namespace MyNamespace;
104 |
105 | public class MorningTrigger
106 | {
107 | private readonly IPullRequestScanner _pullRequestScanner;
108 |
109 | public MorningTrigger(IPullRequestScanner pullRequestScanner)
110 | {
111 | _pullRequestScanner = pullRequestScanner;
112 | }
113 |
114 | [FunctionName("MorningTrigger")]
115 | public async Task RunAsync([TimerTrigger("0 0 8 * * 1-5")] TimerInfo myTimer, ILogger log)
116 | {
117 | await _pullRequestScanner.ExecutePluginsAsync();
118 | }
119 | }
120 | ```
121 |
122 | If I want more control over my plugins or pull request data, I could do this:
123 |
124 | ```csharp
125 | public class MorningTrigger
126 | {
127 | private readonly IPullRequestScanner _pullRequestScanner;
128 | private readonly PullRequestOverviewMicrosoftTeamsWebHookPublisher _overviewCardPublisher;
129 | private readonly PullRequestStatusMicrosoftTeamsWebHookPublisher _statusCardPublisher;
130 |
131 | public MorningTrigger(IPullRequestScanner pullRequestScanner)
132 | {
133 | _pullRequestScanner = pullRequestScanner;
134 | _overviewCardPublisher = _pullRequestScanner.GetPlugin();
135 | _statusCardPublisher = _pullRequestScanner.GetPlugin();
136 | }
137 |
138 | [FunctionName("MorningTrigger")]
139 | public async Task RunAsync([TimerTrigger("0 0 8 * * 1-5")] TimerInfo myTimer, ILogger log)
140 | {
141 | var pullRequests = await _pullRequestScanner.GetPullRequests();
142 |
143 | await _overviewCardPublisher.ExecuteAsync(pullRequests);
144 |
145 | foreach (var pullRequestStatus in new[] { PullRequestStatus.MergeConflicts, PullRequestStatus.ReadyToMerge })
146 | {
147 | await _statusCardPublisher.ExecuteAsync(pullRequests, pullRequestStatus);
148 | }
149 | }
150 | }
151 | ```
152 |
153 | ## Example Output
154 |
155 | 
156 |
157 | 
158 |
159 | 
160 |
161 | 
162 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Extensions/PullRequestScannerBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Extensions;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.VisualStudio.Services.Common;
5 | using Microsoft.VisualStudio.Services.WebApi;
6 | using Mappers;
7 | using Options;
8 | using Services;
9 | using Contracts;
10 | using TomLonghurst.PullRequestScanner.Extensions;
11 |
12 | public static class PullRequestScannerBuilderExtensions
13 | {
14 | public static PullRequestScannerBuilder AddAzureDevOps(
15 | this PullRequestScannerBuilder pullRequestScannerBuilder,
16 | AzureDevOpsOptions azureDevOpsOptions)
17 | {
18 | pullRequestScannerBuilder.Services.AddSingleton(azureDevOpsOptions);
19 | return AddAzureDevOps(pullRequestScannerBuilder);
20 | }
21 |
22 | public static PullRequestScannerBuilder AddAzureDevOps(
23 | this PullRequestScannerBuilder pullRequestScannerBuilder,
24 | Func azureDevOpsOptionsFactory)
25 | {
26 | pullRequestScannerBuilder.Services.AddSingleton(azureDevOpsOptionsFactory);
27 | return AddAzureDevOps(pullRequestScannerBuilder);
28 | }
29 |
30 | private static PullRequestScannerBuilder AddAzureDevOps(this PullRequestScannerBuilder pullRequestScannerBuilder)
31 | {
32 | pullRequestScannerBuilder.Services.AddSingleton(sp =>
33 | {
34 | var azureDevOpsOptions = sp.GetRequiredService();
35 |
36 | var uri = new UriBuilder("https://dev.azure.com/")
37 | {
38 | Path = $"/{azureDevOpsOptions.Organization}",
39 | }.Uri;
40 |
41 | return new VssConnection(uri, new VssBasicCredential(string.Empty, azureDevOpsOptions?.PersonalAccessToken));
42 | });
43 |
44 | pullRequestScannerBuilder.Services.AddTransient()
45 | .AddTransient()
46 | .AddTransient()
47 | .AddSingleton()
48 | .AddSingleton();
49 |
50 | return pullRequestScannerBuilder.AddPullRequestProvider(ActivatorUtilities.GetServiceOrCreateInstance);
51 | }
52 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Mappers/DevOpsMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Mappers;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 | using Models;
5 | using TomLonghurst.PullRequestScanner.Models;
6 | using TomLonghurst.PullRequestScanner.Services;
7 | using Comment = TomLonghurst.PullRequestScanner.Models.Comment;
8 | using CommentThread = TomLonghurst.PullRequestScanner.Models.CommentThread;
9 | using PullRequestStatus = Enums.PullRequestStatus;
10 | using Repository = TomLonghurst.PullRequestScanner.Models.Repository;
11 | using TeamFoundation = Microsoft.TeamFoundation;
12 |
13 | internal class AzureDevOpsMapper(ITeamMembersService teamMembersService) : IAzureDevOpsMapper
14 | {
15 | public PullRequest ToPullRequestModel(AzureDevOpsPullRequestContext pullRequestContext)
16 | {
17 | var pullRequest = pullRequestContext.AzureDevOpsPullRequest;
18 | var pullRequestModel = new PullRequest
19 | {
20 | Title = pullRequest.Title,
21 | Created = pullRequest.CreationDate,
22 | Description = pullRequest.Description,
23 | Url = GetPullRequestUiUrl(pullRequest.Url),
24 | Id = pullRequest.PullRequestId.ToString(),
25 | Number = pullRequest.PullRequestId.ToString(),
26 | Repository = GetRepository(pullRequest.Repository),
27 | IsDraft = pullRequest.IsDraft ?? false,
28 | IsActive = pullRequest.Status == TeamFoundation.SourceControl.WebApi.PullRequestStatus.Active,
29 | PullRequestStatus = GetStatus(pullRequestContext),
30 | Author = GetPerson(pullRequest.CreatedBy.UniqueName, pullRequest.CreatedBy.DisplayName, pullRequest.CreatedBy.Id),
31 | Approvers = pullRequest.Reviewers
32 | .Where(x => x.Vote != 0)
33 | .Where(x => x.UniqueName != pullRequest.CreatedBy.UniqueName)
34 | .Where(x => !x.UniqueName.StartsWith(Constants.VstfsUniqueNamePrefix))
35 | .Where(x => x.DisplayName != Constants.VstsDisplayName)
36 | .Select(r => GetApprover(r, pullRequestContext.PullRequestThreads))
37 | .ToList(),
38 | CommentThreads = pullRequestContext.PullRequestThreads
39 | .Select(GetCommentThread)
40 | .ToList(),
41 | Platform = "AzureDevOps",
42 | Labels = pullRequest.Labels?.Where(x => x.Active != false).Select(x => x.Name).ToList() ?? [],
43 | };
44 |
45 | foreach (var thread in pullRequestModel.CommentThreads)
46 | {
47 | thread.ParentPullRequest = pullRequestModel;
48 | foreach (var comment in thread.Comments)
49 | {
50 | comment.ParentCommentThread = thread;
51 | }
52 | }
53 |
54 | foreach (var approver in pullRequestModel.Approvers)
55 | {
56 | approver.PullRequest = pullRequestModel;
57 | }
58 |
59 | return pullRequestModel;
60 | }
61 |
62 | private CommentThread GetCommentThread(GitPullRequestCommentThread azureDevOpsPullRequestThread)
63 | {
64 | return new CommentThread
65 | {
66 | Status = GetThreadStatus(azureDevOpsPullRequestThread.Status),
67 | Comments = azureDevOpsPullRequestThread
68 | .Comments
69 | .Where(x => !x.Author.UniqueName.StartsWith(Constants.VstfsUniqueNamePrefix))
70 | .Where(x => x.Author.DisplayName != Constants.VstsDisplayName)
71 | .Select(GetComment)
72 | .ToList(),
73 | };
74 | }
75 |
76 | private Comment GetComment(TeamFoundation.SourceControl.WebApi.Comment azureDevOpsComment)
77 | {
78 | return new Comment
79 | {
80 | LastUpdated = azureDevOpsComment.LastUpdatedDate,
81 | Author = GetPerson(azureDevOpsComment.Author.UniqueName, azureDevOpsComment.Author.DisplayName, azureDevOpsComment.Author.Id),
82 | };
83 | }
84 |
85 | private TeamMember GetPerson(string uniqueName, string displayName, string id)
86 | {
87 | var foundTeamMember = teamMembersService.FindTeamMember(uniqueName, id);
88 |
89 | if (foundTeamMember == null)
90 | {
91 | return new TeamMember
92 | {
93 | UniqueNames = { uniqueName },
94 | DisplayName = displayName,
95 | };
96 | }
97 |
98 | return foundTeamMember;
99 | }
100 |
101 | private static ThreadStatus GetThreadStatus(CommentThreadStatus threadStatus)
102 | {
103 | if (threadStatus is CommentThreadStatus.Active or CommentThreadStatus.Pending)
104 | {
105 | return ThreadStatus.Active;
106 | }
107 |
108 | return ThreadStatus.Closed;
109 | }
110 |
111 | private Approver GetApprover(IdentityRefWithVote reviewer, List azureDevOpsPullRequestThreads)
112 | {
113 | return new Approver
114 | {
115 | Vote = GetVote(reviewer.Vote),
116 | IsRequired = reviewer.IsRequired,
117 | TeamMember = GetPerson(reviewer.UniqueName, reviewer.DisplayName, reviewer.Id),
118 | Time = azureDevOpsPullRequestThreads
119 | .Where(x => x.Properties.GetValue("codeReviewThreadType", string.Empty) == "VoteUpdate")
120 | .LastOrDefault(x => x.Comments?.SingleOrDefault(c => c.Author.UniqueName == reviewer.UniqueName) != null)
121 | ?.LastUpdatedDate,
122 | };
123 | }
124 |
125 | private static Vote GetVote(int? vote)
126 | {
127 | if (vote is null or 0)
128 | {
129 | return Vote.NoVote;
130 | }
131 |
132 | if (vote > 0)
133 | {
134 | return Vote.Approved;
135 | }
136 |
137 | return Vote.Rejected;
138 | }
139 |
140 | private static PullRequestStatus GetStatus(AzureDevOpsPullRequestContext azureDevOpsPullRequestContext)
141 | {
142 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.Status == TeamFoundation.SourceControl.WebApi.PullRequestStatus.Completed)
143 | {
144 | return PullRequestStatus.Completed;
145 | }
146 |
147 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.Status == TeamFoundation.SourceControl.WebApi.PullRequestStatus.Abandoned)
148 | {
149 | return PullRequestStatus.Abandoned;
150 | }
151 |
152 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.MergeStatus == PullRequestAsyncStatus.Conflicts)
153 | {
154 | return PullRequestStatus.MergeConflicts;
155 | }
156 |
157 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.IsDraft == true)
158 | {
159 | return PullRequestStatus.Draft;
160 | }
161 |
162 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.Reviewers.Any(r => r.Vote == -10))
163 | {
164 | return PullRequestStatus.Rejected;
165 | }
166 |
167 | if (azureDevOpsPullRequestContext.Iterations.Any())
168 | {
169 | var lastCommitIterationId = azureDevOpsPullRequestContext.Iterations.Max(x => x.IterationId);
170 |
171 | var checksInLastIteration = azureDevOpsPullRequestContext.Iterations
172 | .Where(x => x.IterationId == lastCommitIterationId);
173 |
174 | if (checksInLastIteration
175 | .GroupBy(x => x.Context)
176 | .Any(group => group.MaxBy(s => s.Id)?.State == GitStatusState.Failed))
177 | {
178 | return PullRequestStatus.FailingChecks;
179 | }
180 | }
181 |
182 | if (azureDevOpsPullRequestContext.PullRequestThreads.Any(t => t.Status is CommentThreadStatus.Active or CommentThreadStatus.Pending))
183 | {
184 | return PullRequestStatus.OutStandingComments;
185 | }
186 |
187 | if (!string.IsNullOrEmpty(azureDevOpsPullRequestContext.AzureDevOpsPullRequest.MergeFailureMessage))
188 | {
189 | return PullRequestStatus.FailedToMerge;
190 | }
191 |
192 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.Reviewers.Any(x => x.Vote <= 0 && x.IsRequired == true))
193 | {
194 | return PullRequestStatus.NeedsReviewing;
195 | }
196 |
197 | if (azureDevOpsPullRequestContext.AzureDevOpsPullRequest.Reviewers.Any(r => r.Vote > 0))
198 | {
199 | return PullRequestStatus.ReadyToMerge;
200 | }
201 |
202 | return PullRequestStatus.NeedsReviewing;
203 | }
204 |
205 | private static Repository GetRepository(GitRepository azureDevOpsRepository)
206 | {
207 | return new Repository
208 | {
209 | Name = azureDevOpsRepository.Name,
210 | Id = azureDevOpsRepository.Id.ToString(),
211 | Url = GetRepositoryUiUrl(azureDevOpsRepository.Url),
212 | };
213 | }
214 |
215 | private static string GetPullRequestUiUrl(string pullRequestUrl)
216 | {
217 | return pullRequestUrl
218 | .Replace("pullRequests", "pullrequest")
219 | .Replace("/git/", "/_git/")
220 | .Replace("/repositories/", "/")
221 | .Replace("/_apis/", "/");
222 | }
223 |
224 | private static string GetRepositoryUiUrl(string repositoryUrl)
225 | {
226 | return repositoryUrl
227 | .Replace("/git/", "/_git/")
228 | .Replace("/repositories/", "/")
229 | .Replace("/_apis/", "/");
230 | }
231 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Mappers/IDevOpsMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Mappers;
2 |
3 | using Models;
4 | using TomLonghurst.PullRequestScanner.Models;
5 |
6 | internal interface IAzureDevOpsMapper
7 | {
8 | PullRequest ToPullRequestModel(AzureDevOpsPullRequestContext pullRequestContext);
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Models/DevOpsPullRequestContext.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Models;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 |
5 | internal class AzureDevOpsPullRequestContext
6 | {
7 | public GitPullRequest AzureDevOpsPullRequest { get; set; }
8 |
9 | public List PullRequestThreads { get; set; }
10 |
11 | public List Iterations { get; set; }
12 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Options/AzureDevOpsOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Options;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 |
5 | public class AzureDevOpsOptions
6 | {
7 | public bool IsEnabled { get; set; } = true;
8 | /**
9 | * Gets or sets the organization name.
10 | */
11 | public string Organization { get; set; }
12 | /**
13 | * Gets or sets the project name.
14 | */
15 | public string ProjectName { get; set; }
16 | /**
17 | * Gets or sets the project GUID.
18 | */
19 | public Guid ProjectGuid { get; set; }
20 | /**
21 | * Gets or sets an Azure DevOps Personal Access Token.
22 | */
23 | public string PersonalAccessToken { get; set; }
24 |
25 | public Func RepositoriesToScan { get; set; } = _ => true;
26 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/AzureDevOpsInitializer.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using Initialization.Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.TeamFoundation.Core.WebApi;
5 | using Microsoft.VisualStudio.Services.WebApi;
6 | using Options;
7 |
8 | public class AzureDevOpsInitializer(AzureDevOpsOptions azureDevOpsOptions, VssConnection vssConnection) : IInitializer
9 | {
10 | public async Task InitializeAsync()
11 | {
12 | if (azureDevOpsOptions.ProjectGuid != default)
13 | {
14 | return;
15 | }
16 |
17 | var projects = await GetProjects();
18 |
19 | var foundProject = projects.SingleOrDefault(x => string.Equals(x.Name, azureDevOpsOptions.ProjectName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Unique project with name '{azureDevOpsOptions.ProjectName}' not found");
20 | azureDevOpsOptions.ProjectGuid = foundProject.Id;
21 | }
22 |
23 | private async Task> GetProjects()
24 | {
25 | var projects = new List();
26 |
27 | string continuationToken;
28 | do
29 | {
30 | var projectsInIteration = await vssConnection.GetClient().GetProjects();
31 |
32 | projects.AddRange(projectsInIteration);
33 |
34 | continuationToken = projectsInIteration.ContinuationToken;
35 | }
36 | while (!string.IsNullOrEmpty(continuationToken));
37 |
38 | return projects;
39 | }
40 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/AzureDevOpsPullRequestProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using System.Collections.Immutable;
4 | using EnumerableAsyncProcessor.Extensions;
5 | using Mappers;
6 | using Options;
7 | using Contracts;
8 | using TomLonghurst.PullRequestScanner.Models;
9 |
10 | internal class AzureDevOpsPullRequestProvider : IPullRequestProvider
11 | {
12 | private readonly AzureDevOpsOptions _azureDevOpsOptions;
13 | private readonly IAzureDevOpsGitRepositoryService _devOpsGitRepositoryService;
14 | private readonly IAzureDevOpsPullRequestService _devOpsPullRequestService;
15 | private readonly IAzureDevOpsMapper _devOpsMapper;
16 |
17 | public AzureDevOpsPullRequestProvider(
18 | AzureDevOpsOptions azureDevOpsOptions,
19 | IAzureDevOpsGitRepositoryService azureDevOpsGitRepositoryService,
20 | IAzureDevOpsPullRequestService azureDevOpsPullRequestService,
21 | IAzureDevOpsMapper azureDevOpsMapper)
22 | {
23 | _azureDevOpsOptions = azureDevOpsOptions;
24 | _devOpsGitRepositoryService = azureDevOpsGitRepositoryService;
25 | _devOpsPullRequestService = azureDevOpsPullRequestService;
26 | _devOpsMapper = azureDevOpsMapper;
27 |
28 | ValidateOptions();
29 | }
30 |
31 | public async Task> GetPullRequests()
32 | {
33 | if (_azureDevOpsOptions?.IsEnabled != true)
34 | {
35 | return [];
36 | }
37 |
38 | var repositories = await _devOpsGitRepositoryService.GetGitRepositories();
39 |
40 | var pullRequestsEnumerable = await repositories.ToAsyncProcessorBuilder()
41 | .SelectAsync(repo => _devOpsPullRequestService.GetPullRequestsForRepository(repo))
42 | .ProcessInParallel(50, TimeSpan.FromSeconds(1));
43 |
44 | var azureDevOpsPullRequestContexts = pullRequestsEnumerable.SelectMany(x => x).ToImmutableList();
45 |
46 | var mappedPullRequests = azureDevOpsPullRequestContexts
47 | .Select(pr => _devOpsMapper.ToPullRequestModel(pr))
48 | .ToImmutableList();
49 |
50 | return mappedPullRequests;
51 | }
52 |
53 | private void ValidateOptions()
54 | {
55 | if (_azureDevOpsOptions.IsEnabled != true)
56 | {
57 | return;
58 | }
59 |
60 | ValidatePopulated(_azureDevOpsOptions.Organization, nameof(_azureDevOpsOptions.Organization));
61 |
62 | if (_azureDevOpsOptions.ProjectGuid == default)
63 | {
64 | ValidatePopulated(_azureDevOpsOptions.ProjectName, nameof(_azureDevOpsOptions.ProjectName));
65 | }
66 |
67 | ValidatePopulated(_azureDevOpsOptions.PersonalAccessToken, nameof(_azureDevOpsOptions.PersonalAccessToken));
68 |
69 | static void ValidatePopulated(string value, string propertyName)
70 | {
71 | if (string.IsNullOrEmpty(value))
72 | {
73 | throw new ArgumentNullException(propertyName);
74 | }
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/DevOpsGitRepositoryService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 | using Microsoft.VisualStudio.Services.WebApi;
5 | using Options;
6 |
7 | internal class AzureDevOpsGitRepositoryService(VssConnection vssConnection, AzureDevOpsOptions azureDevOpsOptions) : IAzureDevOpsGitRepositoryService
8 | {
9 | public async Task> GetGitRepositories()
10 | {
11 | var repositories = await vssConnection.GetClient().GetRepositoriesAsync(azureDevOpsOptions.ProjectGuid);
12 |
13 | return repositories.Where(x => x.IsDisabled != true)
14 | .Where(x => azureDevOpsOptions.RepositoriesToScan.Invoke(x))
15 | .ToList();
16 | }
17 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/DevOpsPullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 | using Microsoft.VisualStudio.Services.WebApi;
5 | using Models;
6 | using Options;
7 |
8 | internal class AzureDevOpsPullRequestService(VssConnection vssConnection, AzureDevOpsOptions azureDevOpsOptions) : IAzureDevOpsPullRequestService
9 | {
10 | public async Task> GetPullRequestsForRepository(
11 | GitRepository repository)
12 | {
13 | var pullRequests = new List();
14 |
15 | var iteration = 0;
16 | do
17 | {
18 | var pullRequestsThisIteration = await vssConnection.GetClient().GetPullRequestsAsync(
19 | project: azureDevOpsOptions.ProjectGuid,
20 | repositoryId: repository.Id,
21 | top: 100,
22 | skip: 100 * iteration,
23 | searchCriteria: new GitPullRequestSearchCriteria());
24 |
25 | pullRequests.AddRange(pullRequestsThisIteration);
26 |
27 | iteration++;
28 | }
29 | while (pullRequests.Count == 100 * iteration);
30 |
31 | var nonDraftedPullRequests = pullRequests.Where(IsActiveOrRecentlyClosed);
32 |
33 | var pullRequestsWithThreads = new List();
34 |
35 | foreach (var pullRequest in nonDraftedPullRequests)
36 | {
37 | var threads = await GetThreads(pullRequest);
38 |
39 | var iterations = await GetStatuses(pullRequest);
40 |
41 | pullRequestsWithThreads.Add(new AzureDevOpsPullRequestContext
42 | {
43 | AzureDevOpsPullRequest = pullRequest,
44 | PullRequestThreads = threads,
45 | Iterations = iterations,
46 | });
47 | }
48 |
49 | return pullRequestsWithThreads;
50 | }
51 |
52 | private bool IsActiveOrRecentlyClosed(GitPullRequest gitPullRequest)
53 | {
54 | if (gitPullRequest.Status == PullRequestStatus.Active)
55 | {
56 | return true;
57 | }
58 |
59 | if (gitPullRequest.ClosedDate >= DateTimeOffset.UtcNow.Date - TimeSpan.FromDays(1))
60 | {
61 | return true;
62 | }
63 |
64 | return false;
65 | }
66 |
67 | private async Task> GetThreads(GitPullRequest pullRequest)
68 | {
69 | return await vssConnection.GetClient().GetThreadsAsync(
70 | project: azureDevOpsOptions.ProjectGuid,
71 | repositoryId: pullRequest.Repository.Id,
72 | pullRequestId: pullRequest.PullRequestId);
73 | }
74 |
75 | private async Task> GetStatuses(GitPullRequest pullRequest)
76 | {
77 | return await vssConnection.GetClient().GetPullRequestStatusesAsync(
78 |
79 | project: azureDevOpsOptions.ProjectGuid,
80 | repositoryId: pullRequest.Repository.Id,
81 | pullRequestId: pullRequest.PullRequestId);
82 | }
83 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/DevOpsTeamMembersProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using EnumerableAsyncProcessor.Extensions;
4 | using Microsoft.TeamFoundation.Core.WebApi;
5 | using Microsoft.VisualStudio.Services.WebApi;
6 | using Options;
7 | using Contracts;
8 | using TomLonghurst.PullRequestScanner.Models;
9 | using TeamMember = Microsoft.VisualStudio.Services.WebApi.TeamMember;
10 |
11 | internal class AzureDevOpsTeamMembersProvider(AzureDevOpsOptions azureDevOpsOptions, VssConnection vssConnection) : ITeamMembersProvider
12 | {
13 | public async Task> GetTeamMembers()
14 | {
15 | if (!azureDevOpsOptions.IsEnabled)
16 | {
17 | return [];
18 | }
19 |
20 | var teams = new List();
21 |
22 | var iteration = 0;
23 | do
24 | {
25 | var teamsInIteration = await vssConnection.GetClient().GetTeamsAsync(
26 | projectId: azureDevOpsOptions.ProjectGuid.ToString(),
27 | top: 100,
28 | skip: 100 * iteration);
29 |
30 | teams.AddRange(teamsInIteration);
31 |
32 | iteration++;
33 | }
34 | while (teams.Count == 100 * iteration);
35 |
36 | var membersResponsesArrays = await teams
37 | .ToAsyncProcessorBuilder()
38 | .SelectAsync(GetTeamMembers)
39 | .ProcessInParallel(50, TimeSpan.FromSeconds(5));
40 |
41 | var membersResponses = membersResponsesArrays
42 | .SelectMany(x => x)
43 | .ToList();
44 |
45 | return membersResponses
46 | .Where(x => x.Identity.DisplayName != Constants.VstsDisplayName)
47 | .Where(x => !x.Identity.UniqueName.StartsWith(Constants.VstfsUniqueNamePrefix))
48 | .Select(x => new TeamMemberImpl
49 | {
50 | DisplayName = x.Identity.DisplayName,
51 | UniqueName = x.Identity.UniqueName,
52 | Email = x.Identity.UniqueName,
53 | Id = x.Identity.Id,
54 | })
55 | .ToList();
56 | }
57 |
58 | private async Task> GetTeamMembers(WebApiTeam team)
59 | {
60 | var members = new List();
61 |
62 | var iteration = 0;
63 | do
64 | {
65 | var membersInIteration = await vssConnection.GetClient().GetTeamMembersWithExtendedPropertiesAsync(
66 | projectId: azureDevOpsOptions.ProjectGuid.ToString(),
67 | teamId: team.Id.ToString(),
68 | top: 100,
69 | skip: 100 * iteration);
70 |
71 | members.AddRange(membersInIteration);
72 |
73 | iteration++;
74 | }
75 | while (members.Count == 100 * iteration);
76 |
77 | return members;
78 | }
79 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/IDevOpsGitRepositoryService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 |
5 | internal interface IAzureDevOpsGitRepositoryService
6 | {
7 | Task> GetGitRepositories();
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/Services/IDevOpsPullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.AzureDevOps.Services;
2 |
3 | using Microsoft.TeamFoundation.SourceControl.WebApi;
4 | using Models;
5 |
6 | internal interface IAzureDevOpsPullRequestService
7 | {
8 | Task> GetPullRequestsForRepository(GitRepository repository);
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.AzureDevOps/TomLonghurst.PullRequestScanner.AzureDevOps.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 | latest
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Example/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 | using Microsoft.Extensions.Hosting;
3 | using TomLonghurst.PullRequestScanner.AzureDevOps.Extensions;
4 | using TomLonghurst.PullRequestScanner.AzureDevOps.Options;
5 | using TomLonghurst.PullRequestScanner.Enums;
6 | using TomLonghurst.PullRequestScanner.Extensions;
7 | using TomLonghurst.PullRequestScanner.GitHub.Extensions;
8 | using TomLonghurst.PullRequestScanner.GitHub.Options;
9 | using TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Extensions;
10 | using TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Options;
11 | using TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
12 | using TomLonghurst.PullRequestScanner.Services;
13 |
14 | var host = Host.CreateDefaultBuilder(args)
15 | .ConfigureServices(services =>
16 | {
17 | services.AddPullRequestScanner()
18 | .AddGithub(new GithubOrganizationTeamOptions
19 | {
20 | OrganizationSlug = string.Empty,
21 | PersonalAccessToken = string.Empty,
22 | RepositoriesToScan = repository => true,
23 | })
24 | .AddAzureDevOps(new AzureDevOpsOptions
25 | {
26 | Organization = string.Empty,
27 | ProjectName = string.Empty,
28 | PersonalAccessToken = string.Empty,
29 | RepositoriesToScan = repository => true,
30 | })
31 | .AddMicrosoftTeamsWebHookPublisher(
32 | new MicrosoftTeamsOptions
33 | {
34 | WebHookUri = new Uri(string.Empty),
35 | },
36 | microsoftTeamsWebHookPublisherBuilder =>
37 | {
38 | microsoftTeamsWebHookPublisherBuilder.AddOverviewCardPublisher();
39 | microsoftTeamsWebHookPublisherBuilder.AddLeaderboardCardPublisher();
40 | microsoftTeamsWebHookPublisherBuilder.AddStatusCardsPublisher(new MicrosoftTeamsStatusPublishOptions
41 | {
42 | StatusesToPublish =
43 | [
44 | PullRequestStatus.MergeConflicts,
45 | PullRequestStatus.ReadyToMerge,
46 | PullRequestStatus.FailingChecks,
47 | PullRequestStatus.NeedsReviewing,
48 | PullRequestStatus.Rejected
49 | ],
50 | });
51 | });
52 | })
53 | .Build();
54 |
55 | var pullRequestScanner = host.Services.GetRequiredService();
56 | await pullRequestScanner.ExecutePluginsAsync();
57 |
58 | // More Granular Control
59 | var pullRequests = await pullRequestScanner.GetPullRequests();
60 | var statusPublisherPlugin = pullRequestScanner.GetPlugin();
61 | await statusPublisherPlugin.ExecuteAsync(pullRequests, PullRequestStatus.NeedsReviewing);
62 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Example/TomLonghurst.PullRequestScanner.Example.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net7.0
6 | enable
7 | enable
8 | latest
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Extensions/PullRequestScannerBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Extensions;
2 |
3 | using System.Net.Http.Headers;
4 | using System.Reflection;
5 | using System.Text;
6 | using Microsoft.Extensions.DependencyInjection;
7 | using Octokit;
8 | using Octokit.Internal;
9 | using Contracts;
10 | using TomLonghurst.PullRequestScanner.Extensions;
11 | using Http;
12 | using Mappers;
13 | using Options;
14 | using Services;
15 | using TomLonghurst.PullRequestScanner.Services;
16 | using ProductHeaderValue = Octokit.ProductHeaderValue;
17 |
18 | public static class PullRequestScannerBuilderExtensions
19 | {
20 | public static PullRequestScannerBuilder AddGithub(
21 | this PullRequestScannerBuilder pullRequestScannerBuilder,
22 | GithubOptions githubOptions)
23 | {
24 | pullRequestScannerBuilder.Services.AddSingleton(githubOptions);
25 | return AddGithub(pullRequestScannerBuilder);
26 | }
27 |
28 | public static PullRequestScannerBuilder AddGithub(
29 | this PullRequestScannerBuilder pullRequestScannerBuilder,
30 | Func githubOptionsFactory)
31 | {
32 | pullRequestScannerBuilder.Services.AddSingleton(githubOptionsFactory);
33 | return AddGithub(pullRequestScannerBuilder);
34 | }
35 |
36 | private static PullRequestScannerBuilder AddGithub(this PullRequestScannerBuilder pullRequestScannerBuilder)
37 | {
38 | pullRequestScannerBuilder.Services
39 | .AddHttpClient((provider, client) =>
40 | {
41 | var githubOptions = provider.GetRequiredService();
42 | client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("TomLonghurst.PullRequestScanner", Assembly.GetAssembly(typeof(IPullRequestScanner)).GetName().Version.ToString()));
43 |
44 | client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
45 | "Basic",
46 | Convert.ToBase64String(Encoding.ASCII.GetBytes(githubOptions.PersonalAccessToken)));
47 | client.BaseAddress = new Uri("https://api.github.com/");
48 | });
49 |
50 | pullRequestScannerBuilder.Services
51 | .AddSingleton(serviceProvider =>
52 | {
53 | var githubOptions = serviceProvider.GetRequiredService();
54 |
55 | var version = Assembly.GetAssembly(typeof(GithubRepositoryService))?.GetName()?.Version?.ToString() ?? "1.0";
56 |
57 | var accessToken = githubOptions.PersonalAccessToken;
58 |
59 | if (accessToken.Contains(':'))
60 | {
61 | accessToken = accessToken.Split(':').Last();
62 | }
63 |
64 | return new GitHubClient(new Connection(new ProductHeaderValue("pr-scanner", version), new InMemoryCredentialStore(new Credentials(accessToken))));
65 | })
66 | .AddSingleton()
67 | .AddSingleton()
68 | .AddSingleton()
69 | .AddTransient()
70 | .AddTransient()
71 | .AddTransient();
72 |
73 | return pullRequestScannerBuilder.AddPullRequestProvider(ActivatorUtilities.GetServiceOrCreateInstance);
74 | }
75 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Http/GithubHttpClient.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Http;
2 |
3 | using System.Net.Http.Json;
4 | using Microsoft.Extensions.Logging;
5 | using Polly;
6 | using Polly.Extensions.Http;
7 |
8 | internal class GithubHttpClient
9 | {
10 | private readonly HttpClient _client;
11 | private readonly ILogger _logger;
12 |
13 | public GithubHttpClient(
14 | HttpClient httpClient,
15 | ILogger logger)
16 | {
17 | _client = httpClient;
18 | _logger = logger;
19 | }
20 |
21 | public async Task Get(string path)
22 | {
23 | var response = await
24 | HttpPolicyExtensions.HandleTransientHttpError()
25 | .WaitAndRetryAsync(5, i => TimeSpan.FromSeconds(i * 2))
26 | .ExecuteAsync(() => _client.GetAsync(path));
27 |
28 | if (!response.IsSuccessStatusCode)
29 | {
30 | _logger.LogError("Error calling {Path}: {Response}", path, await response.Content.ReadAsStringAsync());
31 | }
32 |
33 | return await response.EnsureSuccessStatusCode().Content.ReadFromJsonAsync();
34 | }
35 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Mappers/GithubMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Mappers;
2 |
3 | using Octokit;
4 | using Octokit.GraphQL.Model;
5 | using Enums;
6 | using Models;
7 | using TomLonghurst.PullRequestScanner.Models;
8 | using TomLonghurst.PullRequestScanner.Services;
9 | using MergeableState = Octokit.MergeableState;
10 | using PullRequest = TomLonghurst.PullRequestScanner.Models.PullRequest;
11 | using Repository = TomLonghurst.PullRequestScanner.Models.Repository;
12 |
13 | internal class GithubMapper : IGithubMapper
14 | {
15 | private readonly ITeamMembersService _teamMembersService;
16 |
17 | public GithubMapper(ITeamMembersService teamMembersService)
18 | {
19 | _teamMembersService = teamMembersService;
20 | }
21 |
22 | public PullRequest ToPullRequestModel(GithubPullRequest githubPullRequest)
23 | {
24 | var pullRequestModel = new PullRequest
25 | {
26 | Title = githubPullRequest.Title,
27 | Created = githubPullRequest.Created,
28 | Description = githubPullRequest.Body,
29 | Url = githubPullRequest.Url,
30 | Id = githubPullRequest.Id,
31 | Number = githubPullRequest.PullRequestNumber.ToString(),
32 | Repository = GetRepository(githubPullRequest),
33 | IsDraft = githubPullRequest.IsDraft,
34 | IsActive = GetIsActive(githubPullRequest),
35 | PullRequestStatus = GetStatus(githubPullRequest),
36 | Author = GetPerson(githubPullRequest.Author),
37 | Approvers = githubPullRequest.Reviewers
38 | .Where(x => x.Author != githubPullRequest.Author)
39 | .Select(GetApprover)
40 | .ToList(),
41 | CommentThreads = githubPullRequest.Threads
42 | .Select(GetCommentThreads)
43 | .ToList(),
44 | Platform = "GitHub",
45 | Labels = githubPullRequest.Labels,
46 | };
47 |
48 | foreach (var thread in pullRequestModel.CommentThreads)
49 | {
50 | thread.ParentPullRequest = pullRequestModel;
51 | foreach (var comment in thread.Comments)
52 | {
53 | comment.ParentCommentThread = thread;
54 | }
55 | }
56 |
57 | foreach (var approver in pullRequestModel.Approvers)
58 | {
59 | approver.PullRequest = pullRequestModel;
60 | }
61 |
62 | return pullRequestModel;
63 | }
64 |
65 | private static bool GetIsActive(GithubPullRequest githubPullRequest)
66 | {
67 | if (githubPullRequest.IsClosed)
68 | {
69 | return false;
70 | }
71 |
72 | return githubPullRequest.State == ItemState.Open;
73 | }
74 |
75 | private TeamMember GetPerson(string author)
76 | {
77 | var foundTeamMember = _teamMembersService.FindTeamMember(author, author);
78 |
79 | if (foundTeamMember == null)
80 | {
81 | return new TeamMember
82 | {
83 | UniqueNames = { author },
84 | };
85 | }
86 |
87 | return foundTeamMember;
88 | }
89 |
90 | private CommentThread GetCommentThreads(GithubThread githubThread)
91 | {
92 | return new CommentThread
93 | {
94 | Status = GetThreadStatus(githubThread),
95 | Comments = githubThread.Comments.Select(GetComment).ToList(),
96 | };
97 | }
98 |
99 | private Comment GetComment(GithubComment githubComment)
100 | {
101 | return new Comment
102 | {
103 | LastUpdated = githubComment.LastUpdated,
104 | Author = GetPerson(githubComment.Author),
105 | };
106 | }
107 |
108 | private static ThreadStatus GetThreadStatus(GithubThread githubThread)
109 | {
110 | if (!githubThread.IsResolved)
111 | {
112 | return ThreadStatus.Active;
113 | }
114 |
115 | return ThreadStatus.Closed;
116 | }
117 |
118 | private Approver GetApprover(GithubReviewer reviewer)
119 | {
120 | return new Approver
121 | {
122 | Vote = GetVote(reviewer.State),
123 | IsRequired = false,
124 | TeamMember = GetPerson(reviewer.Author),
125 | Time = reviewer.LastUpdated,
126 | };
127 | }
128 |
129 | private static Vote GetVote(Octokit.GraphQL.Model.PullRequestReviewState vote)
130 | {
131 | if (vote == Octokit.GraphQL.Model.PullRequestReviewState.Approved)
132 | {
133 | return Vote.Approved;
134 | }
135 |
136 | return Vote.NoVote;
137 | }
138 |
139 | private static PullRequestStatus GetStatus(GithubPullRequest pullRequest)
140 | {
141 | if (pullRequest.IsMerged)
142 | {
143 | return PullRequestStatus.Completed;
144 | }
145 |
146 | if (pullRequest.State == ItemState.Closed)
147 | {
148 | return PullRequestStatus.Abandoned;
149 | }
150 |
151 | if (pullRequest.Mergeable == MergeableState.Dirty)
152 | {
153 | return PullRequestStatus.MergeConflicts;
154 | }
155 |
156 | if (pullRequest.IsDraft)
157 | {
158 | return PullRequestStatus.Draft;
159 | }
160 |
161 | if (pullRequest.ChecksStatus is StatusState.Error or StatusState.Failure)
162 | {
163 | return PullRequestStatus.FailingChecks;
164 | }
165 |
166 | if (pullRequest.Threads.Any(t => !t.IsResolved))
167 | {
168 | return PullRequestStatus.OutStandingComments;
169 | }
170 |
171 | if (pullRequest.Reviewers.Any(r => r.State == Octokit.GraphQL.Model.PullRequestReviewState.Approved))
172 | {
173 | return PullRequestStatus.ReadyToMerge;
174 | }
175 |
176 | return PullRequestStatus.NeedsReviewing;
177 | }
178 |
179 | private static Repository GetRepository(GithubPullRequest pullRequest)
180 | {
181 | return new Repository
182 | {
183 | Name = pullRequest.RepositoryName,
184 | Id = pullRequest.RepositoryId,
185 | Url = pullRequest.RepositoryUrl,
186 | };
187 | }
188 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Mappers/IGithubMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Mappers;
2 |
3 | using Models;
4 | using TomLonghurst.PullRequestScanner.Models;
5 |
6 | internal interface IGithubMapper
7 | {
8 | PullRequest ToPullRequestModel(GithubPullRequest githubPullRequest);
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubComment.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | public record GithubComment
4 | {
5 | public string Author { get; set; }
6 |
7 | public DateTimeOffset LastUpdated { get; set; }
8 |
9 | public string Body { get; set; }
10 |
11 | public string Id { get; set; }
12 |
13 | public string Url { get; set; }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubMember.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | using TomLonghurst.PullRequestScanner.Models;
4 |
5 | internal class GithubMember : ITeamMember
6 | {
7 | public string DisplayName { get; set; }
8 |
9 | public string UniqueName { get; set; }
10 |
11 | public string Id { get; set; }
12 |
13 | public string Email { get; set; }
14 |
15 | public string ImageUrl { get; set; }
16 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubPullRequest.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | using Octokit;
4 | using Octokit.GraphQL.Model;
5 | using LockReason = Octokit.LockReason;
6 | using MergeableState = Octokit.MergeableState;
7 |
8 | public record GithubPullRequest
9 | {
10 | public string Title { get; set; }
11 |
12 | public LockReason? LockReason { get; set; }
13 |
14 | public int PullRequestNumber { get; set; }
15 |
16 | public string Id { get; set; }
17 |
18 | public bool IsDraft { get; set; }
19 |
20 | public MergeableState Mergeable { get; set; }
21 |
22 | public ItemState State { get; set; }
23 |
24 | public DateTimeOffset Created { get; set; }
25 |
26 | public string Author { get; set; }
27 |
28 | public PullRequestReviewDecision? ReviewDecision { get; set; }
29 |
30 | public DateTimeOffset LastUpdated { get; set; }
31 |
32 | public string Url { get; set; }
33 |
34 | public string Body { get; set; }
35 |
36 | public string RepositoryId { get; set; }
37 |
38 | public string RepositoryName { get; set; }
39 |
40 | public string RepositoryUrl { get; set; }
41 |
42 | public List Reviewers { get; set; }
43 |
44 | public List Threads { get; set; }
45 |
46 | public StatusState ChecksStatus { get; set; }
47 |
48 | public bool IsClosed { get; set; }
49 |
50 | public List Labels { get; set; }
51 |
52 | public bool IsMerged { get; set; }
53 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubRepository.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record GithubRepository(
6 | [property: JsonPropertyName("id")] int Id,
7 | [property: JsonPropertyName("node_id")] string NodeId,
8 | [property: JsonPropertyName("name")] string Name,
9 | [property: JsonPropertyName("full_name")] string FullName,
10 | [property: JsonPropertyName("owner")] Owner Owner,
11 | [property: JsonPropertyName("private")] bool Private,
12 | [property: JsonPropertyName("html_url")] string HtmlUrl,
13 | [property: JsonPropertyName("description")] string Description,
14 | [property: JsonPropertyName("fork")] bool Fork,
15 | [property: JsonPropertyName("url")] string Url,
16 | [property: JsonPropertyName("git_url")] string GitUrl,
17 | [property: JsonPropertyName("pulls_url")] string PullsUrl,
18 | [property: JsonPropertyName("teams_url")] string TeamsUrl,
19 | [property: JsonPropertyName("archived")] bool Archived,
20 | [property: JsonPropertyName("disabled")] bool Disabled,
21 | [property: JsonPropertyName("visibility")] string Visibility,
22 | [property: JsonPropertyName("pushed_at")] DateTime PushedAt,
23 | [property: JsonPropertyName("created_at")] DateTime CreatedAt,
24 | [property: JsonPropertyName("updated_at")] DateTime UpdatedAt);
25 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubReviewer.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | using PullRequestReviewState = Octokit.GraphQL.Model.PullRequestReviewState;
4 |
5 | public record GithubReviewer
6 | {
7 | public string Author { get; set; }
8 |
9 | public PullRequestReviewState State { get; set; }
10 |
11 | public DateTimeOffset LastUpdated { get; set; }
12 |
13 | public string BodyText { get; set; }
14 |
15 | public string Url { get; set; }
16 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubTeam.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | internal class GithubTeam
4 | {
5 | public string Name { get; set; }
6 |
7 | public string Slug { get; set; }
8 |
9 | public string Id { get; set; }
10 |
11 | public List Members { get; set; }
12 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GithubThread.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | public record GithubThread
4 | {
5 | public List Comments { get; set; }
6 |
7 | public bool IsResolved { get; set; }
8 |
9 | public string Url { get; set; }
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/Author.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Author(
6 | [property: JsonPropertyName("login")] string Login);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/CommentNode.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record CommentNode(
6 | [property: JsonPropertyName("author")] Author Author,
7 | [property: JsonPropertyName("body")] string Body,
8 | [property: JsonPropertyName("url")] string Url,
9 | [property: JsonPropertyName("updatedAt")] DateTime UpdatedAt);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/CommentThreadNode.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record CommentThreadNode(
6 | [property: JsonPropertyName("isResolved")] bool IsResolved,
7 | [property: JsonPropertyName("isOutdated")] bool IsOutdated,
8 | [property: JsonPropertyName("isCollapsed")] bool IsCollapsed,
9 | [property: JsonPropertyName("comments")] Comments Comments);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/Comments.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Comments(
6 | [property: JsonPropertyName("totalCount")] int TotalCount,
7 | [property: JsonPropertyName("nodes")] IReadOnlyList Nodes);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/Data.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Data(
6 | [property: JsonPropertyName("repository")] Repository Repository);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/Edge.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Edge(
6 | [property: JsonPropertyName("node")] CommentThreadNode CommentThreadNode);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/GithubGraphQlPullRequestThreadsResponse.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record GithubGraphQlPullRequestThreadsResponse(
6 | [property: JsonPropertyName("data")] Data Data);
7 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/PullRequest.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record PullRequest(
6 | [property: JsonPropertyName("url")] string Url,
7 | [property: JsonPropertyName("reviewDecision")] string ReviewDecision,
8 | [property: JsonPropertyName("reviewThreads")] ReviewThreads ReviewThreads);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/Repository.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Repository(
6 | [property: JsonPropertyName("pullRequest")] PullRequest PullRequest);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/GraphQl/ReviewThreads.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models.GraphQl;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record ReviewThreads(
6 | [property: JsonPropertyName("edges")] IReadOnlyList Edges);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Models/Owner.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Models;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | public record Owner(
6 | [property: JsonPropertyName("login")] string Login,
7 | [property: JsonPropertyName("id")] int Id,
8 | [property: JsonPropertyName("node_id")] string NodeId,
9 | [property: JsonPropertyName("avatar_url")] string AvatarUrl,
10 | [property: JsonPropertyName("gravatar_id")] string GravatarId,
11 | [property: JsonPropertyName("url")] string Url,
12 | [property: JsonPropertyName("html_url")] string HtmlUrl,
13 | [property: JsonPropertyName("followers_url")] string FollowersUrl,
14 | [property: JsonPropertyName("following_url")] string FollowingUrl,
15 | [property: JsonPropertyName("gists_url")] string GistsUrl,
16 | [property: JsonPropertyName("starred_url")] string StarredUrl,
17 | [property: JsonPropertyName("subscriptions_url")] string SubscriptionsUrl,
18 | [property: JsonPropertyName("organizations_url")] string OrganizationsUrl,
19 | [property: JsonPropertyName("repos_url")] string ReposUrl,
20 | [property: JsonPropertyName("events_url")] string EventsUrl,
21 | [property: JsonPropertyName("received_events_url")] string ReceivedEventsUrl,
22 | [property: JsonPropertyName("type")] string Type,
23 | [property: JsonPropertyName("site_admin")] bool SiteAdmin);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Options/GithubOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Options;
2 |
3 | using Models;
4 |
5 | public abstract class GithubOptions
6 | {
7 | public bool IsEnabled { get; set; } = true;
8 | /**
9 | * Gets or sets personal Access Token, usually in the format of "{username}:{PAT}".
10 | */
11 | public string PersonalAccessToken { get; set; }
12 |
13 | public Func RepositoriesToScan { get; set; } = _ => true;
14 |
15 | internal abstract string CreateUriPathPrefix();
16 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Options/GithubOrganizationTeamOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Options;
2 |
3 | public class GithubOrganizationTeamOptions : GithubOptions
4 | {
5 | /**
6 | * Gets or sets the URL slug for the organization.
7 | */
8 | public string OrganizationSlug { get; set; }
9 | /**
10 | * Gets or sets the URL slug for the team.
11 | */
12 | public string TeamSlug { get; set; }
13 |
14 | internal override string CreateUriPathPrefix()
15 | {
16 | return $"orgs/{OrganizationSlug}/teams/{TeamSlug}/";
17 | }
18 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Options/GithubUserOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Options;
2 |
3 | public class GithubUserOptions : GithubOptions
4 | {
5 | /**
6 | * Gets or sets the URL slug for the user.
7 | */
8 | public string Username { get; set; }
9 |
10 | internal override string CreateUriPathPrefix()
11 | {
12 | return $"users/{Username}/";
13 | }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/BaseGitHubApiService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Http;
4 |
5 | internal abstract class BaseGitHubApiService
6 | {
7 | private readonly GithubHttpClient _githubHttpClient;
8 |
9 | protected BaseGitHubApiService(GithubHttpClient githubHttpClient)
10 | {
11 | _githubHttpClient = githubHttpClient;
12 | }
13 |
14 | protected async Task> Get(string path)
15 | {
16 | int arrayCount;
17 | var iteration = 1;
18 |
19 | var list = new List();
20 | do
21 | {
22 | var arrayResponse = await _githubHttpClient.Get>($"{path}?per_page=100&page={iteration}");
23 |
24 | if (arrayResponse?.Count is null or 0)
25 | {
26 | break;
27 | }
28 |
29 | arrayCount = arrayResponse.Count;
30 | iteration++;
31 | list.AddRange(arrayResponse);
32 | }
33 | while (arrayCount >= 100);
34 |
35 | return list;
36 | }
37 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GitHubPullRequestProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using System.Collections.Immutable;
4 | using EnumerableAsyncProcessor.Extensions;
5 | using Contracts;
6 | using Mappers;
7 | using Options;
8 | using TomLonghurst.PullRequestScanner.Models;
9 |
10 | internal class GitHubPullRequestProvider : IPullRequestProvider
11 | {
12 | private readonly GithubOptions _githubOptions;
13 | private readonly IGithubRepositoryService _githubRepositoryService;
14 | private readonly IGithubPullRequestService _githubPullRequestService;
15 | private readonly IGithubMapper _githubMapper;
16 |
17 | public GitHubPullRequestProvider(
18 | GithubOptions githubOptions,
19 | IGithubRepositoryService githubRepositoryService,
20 | IGithubPullRequestService githubPullRequestService,
21 | IGithubMapper githubMapper)
22 | {
23 | _githubOptions = githubOptions;
24 | _githubRepositoryService = githubRepositoryService;
25 | _githubPullRequestService = githubPullRequestService;
26 | _githubMapper = githubMapper;
27 |
28 | ValidateOptions();
29 | }
30 |
31 | public async Task> GetPullRequests()
32 | {
33 | if (_githubOptions?.IsEnabled != true)
34 | {
35 | return Array.Empty();
36 | }
37 |
38 | var repositories = await _githubRepositoryService.GetGitRepositories();
39 |
40 | var pullRequestsEnumerable = await repositories.ToAsyncProcessorBuilder()
41 | .SelectAsync(repo => _githubPullRequestService.GetPullRequests(repo))
42 | .ProcessInParallel(50, TimeSpan.FromSeconds(5));
43 |
44 | var pullRequests = pullRequestsEnumerable.SelectMany(x => x).ToImmutableList();
45 |
46 | var mappedPullRequests = pullRequests
47 | .Select(pr => _githubMapper.ToPullRequestModel(pr))
48 | .ToImmutableList();
49 |
50 | return mappedPullRequests;
51 | }
52 |
53 | private void ValidateOptions()
54 | {
55 | if (_githubOptions.IsEnabled != true)
56 | {
57 | return;
58 | }
59 |
60 | if (_githubOptions is GithubOrganizationTeamOptions githubOrganizationTeamOptions)
61 | {
62 | ValidatePopulated(githubOrganizationTeamOptions.OrganizationSlug, nameof(githubOrganizationTeamOptions.OrganizationSlug));
63 | ValidatePopulated(githubOrganizationTeamOptions.TeamSlug, nameof(githubOrganizationTeamOptions.TeamSlug));
64 | }
65 |
66 | if (_githubOptions is GithubUserOptions githubUserOptions)
67 | {
68 | ValidatePopulated(githubUserOptions.Username, nameof(githubUserOptions.Username));
69 | }
70 |
71 | ValidatePopulated(_githubOptions.PersonalAccessToken, nameof(_githubOptions.PersonalAccessToken));
72 |
73 | static void ValidatePopulated(string value, string propertyName)
74 | {
75 | if (string.IsNullOrEmpty(value))
76 | {
77 | throw new ArgumentNullException(propertyName);
78 | }
79 | }
80 | }
81 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GithubGraphQlClientProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using System.Reflection;
4 | using Octokit.GraphQL;
5 | using Options;
6 |
7 | internal class GithubGraphQlClientProvider : IGithubGraphQlClientProvider
8 | {
9 | public Connection GithubGraphQlClient { get; }
10 |
11 | public GithubGraphQlClientProvider(GithubOptions githubOptions)
12 | {
13 | var version = Assembly.GetAssembly(typeof(GithubGraphQlClientProvider))?.GetName()?.Version?.ToString() ?? "1.0";
14 |
15 | var accessToken = githubOptions.PersonalAccessToken;
16 |
17 | if (accessToken.Contains(':'))
18 | {
19 | accessToken = accessToken.Split(':').Last();
20 | }
21 |
22 | GithubGraphQlClient = new Connection(new ProductHeaderValue("pr-scanner", version), accessToken);
23 | }
24 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GithubPullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using EnumerableAsyncProcessor.Extensions;
4 | using Octokit;
5 | using Octokit.GraphQL;
6 | using Octokit.GraphQL.Model;
7 | using Http;
8 | using Models;
9 | using MergeableState = Octokit.MergeableState;
10 | using PullRequest = Octokit.PullRequest;
11 |
12 | internal class GithubPullRequestService : BaseGitHubApiService, IGithubPullRequestService
13 | {
14 | private readonly IGitHubClient _gitHubClient;
15 | private readonly IGithubQueryRunner _githubQueryRunner;
16 |
17 | public GithubPullRequestService(GithubHttpClient githubHttpClient, IGitHubClient gitHubClient, IGithubQueryRunner githubQueryRunner)
18 | : base(githubHttpClient)
19 | {
20 | _gitHubClient = gitHubClient;
21 | _githubQueryRunner = githubQueryRunner;
22 | }
23 |
24 | public async Task> GetPullRequests(GithubRepository repository)
25 | {
26 | var pullRequests = await _gitHubClient.PullRequest
27 | .GetAllForRepository(owner: repository.Owner.Login, name: repository.Name);
28 |
29 | var mapped = await pullRequests
30 | .SelectAsync(async x => new GithubPullRequest
31 | {
32 | Title = x.Title,
33 | Body = x.Body,
34 | Id = x.Id.ToString(),
35 | PullRequestNumber = x.Number,
36 | IsDraft = x.Draft,
37 | IsMerged = x.Merged,
38 | Mergeable = x.MergeableState?.Value ?? MergeableState.Unknown,
39 | State = x.State.Value,
40 | IsClosed = x.ClosedAt != null,
41 | Created = x.CreatedAt,
42 | Author = x.User.Login,
43 | ReviewDecision = await GetReviewDecision(repository, x),
44 | LockReason = x.ActiveLockReason?.Value,
45 | LastUpdated = x.UpdatedAt,
46 | Url = x.Url,
47 | RepositoryId = repository.Id.ToString(),
48 | RepositoryName = repository.Name,
49 | RepositoryUrl = repository.Url,
50 | ChecksStatus = await GetLastCommitCheckStatus(repository, x),
51 | Reviewers = await GetReviewers(repository, x),
52 | Labels = x.Labels.Select(x => x.Name).ToList(),
53 | Threads = await GetThreads(repository, x),
54 | })
55 | .ProcessInParallel();
56 |
57 | return mapped
58 | .Where(IsActiveOrRecentlyClosed);
59 | }
60 |
61 | private async Task> GetReviewers(GithubRepository repository, PullRequest pullRequest)
62 | {
63 | var query = new Query()
64 | .Repository(owner: repository.Owner.Login, name: repository.Name)
65 | .PullRequest(pullRequest.Number)
66 | .Reviews(null, null, null, null, null, null)
67 | .AllPages()
68 | .Select(r => new GithubReviewer
69 | {
70 | Author = r.Author.Login,
71 | State = r.State,
72 | LastUpdated = r.LastEditedAt ?? r.PublishedAt ?? r.SubmittedAt ?? r.CreatedAt,
73 | BodyText = r.BodyText,
74 | Url = r.Url,
75 | })
76 | .Compile();
77 |
78 | var reviewers = await _githubQueryRunner.RunQuery(query);
79 |
80 | return reviewers.ToList();
81 | }
82 |
83 | private async Task> GetThreads(GithubRepository repository, PullRequest pullRequest)
84 | {
85 | var threadQuery = new Query()
86 | .Repository(owner: repository.Owner.Login, name: repository.Name)
87 | .PullRequest(pullRequest.Number)
88 | .ReviewThreads()
89 | .AllPages()
90 | .Select(t => new GithubThread
91 | {
92 | IsResolved = t.IsResolved,
93 | Comments = t.Comments(null, null, null, null, null)
94 | .AllPages()
95 | .Select(c =>
96 | new GithubComment
97 | {
98 | Author = c.Author.Login,
99 | LastUpdated = c.UpdatedAt,
100 | Body = c.Body,
101 | Id = c.Id.Value,
102 | Url = c.Url
103 | }).ToList(),
104 | }).Compile();
105 |
106 | var threads = await _githubQueryRunner.RunQuery(threadQuery);
107 |
108 | return threads.ToList();
109 | }
110 |
111 | private async Task GetLastCommitCheckStatus(GithubRepository repository, PullRequest pullRequest)
112 | {
113 | var query = new Query()
114 | .Repository(owner: repository.Owner.Login, name: repository.Name)
115 | .PullRequest(pullRequest.Number)
116 | .Commits(null, null, 1, null)
117 | .Nodes
118 | .Select(x =>
119 | x.Commit == null ? StatusState.Expected :
120 | x.Commit.StatusCheckRollup == null ? StatusState.Expected : x.Commit.StatusCheckRollup.State).Compile();
121 |
122 | var statuses = await _githubQueryRunner.RunQuery(query);
123 |
124 | return statuses.LastOrDefault();
125 | }
126 |
127 | private async Task GetReviewDecision(GithubRepository repository, PullRequest pullRequest)
128 | {
129 | var query = new Query()
130 | .Repository(owner: repository.Owner.Login, name: repository.Name)
131 | .PullRequest(pullRequest.Number)
132 | .Select(x => x.ReviewDecision)
133 | .Compile();
134 |
135 | var reviewDecision = await _githubQueryRunner.RunQuery(query);
136 |
137 | return reviewDecision;
138 | }
139 |
140 | private bool IsActiveOrRecentlyClosed(GithubPullRequest githubPullRequest)
141 | {
142 | if (githubPullRequest.State == ItemState.Open)
143 | {
144 | return true;
145 | }
146 |
147 | if (githubPullRequest.LastUpdated >= DateTimeOffset.UtcNow.Date - TimeSpan.FromDays(1))
148 | {
149 | return true;
150 | }
151 |
152 | return false;
153 | }
154 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GithubQueryRunner.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using System.Net;
4 | using Octokit.GraphQL;
5 | using Polly;
6 |
7 | internal class GithubQueryRunner : IGithubQueryRunner
8 | {
9 | private readonly IGithubGraphQlClientProvider _githubGraphQlClientProvider;
10 |
11 | public GithubQueryRunner(IGithubGraphQlClientProvider githubGraphQlClientProvider)
12 | {
13 | _githubGraphQlClientProvider = githubGraphQlClientProvider;
14 | }
15 |
16 | public async Task RunQuery(ICompiledQuery query)
17 | {
18 | return await Policy.Handle(ShouldHandleException)
19 | .Or()
20 | .WaitAndRetryAsync(5, i => TimeSpan.FromSeconds(i * 2))
21 | .ExecuteAsync(() => _githubGraphQlClientProvider.GithubGraphQlClient.Run(query));
22 | }
23 |
24 | private bool ShouldHandleException(HttpRequestException httpRequestException)
25 | {
26 | if (httpRequestException.StatusCode is HttpStatusCode.RequestTimeout)
27 | {
28 | return true;
29 | }
30 |
31 | if ((int)httpRequestException.StatusCode >= 500)
32 | {
33 | return true;
34 | }
35 |
36 | return false;
37 | }
38 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GithubRepositoryService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Http;
4 | using Models;
5 | using Options;
6 |
7 | internal class GithubRepositoryService : BaseGitHubApiService, IGithubRepositoryService
8 | {
9 | private readonly GithubOptions _githubOptions;
10 |
11 | public GithubRepositoryService(GithubHttpClient githubHttpClient, GithubOptions githubOptions)
12 | : base(githubHttpClient)
13 | {
14 | _githubOptions = githubOptions;
15 | }
16 |
17 | public async Task> GetGitRepositories()
18 | {
19 | var gitRepositoryResponse = await Get(_githubOptions.CreateUriPathPrefix() + "repos");
20 |
21 | return gitRepositoryResponse
22 | .Where(x => !x.Disabled)
23 | .Where(x => !x.Archived)
24 | .Where(x => _githubOptions.RepositoriesToScan.Invoke(x))
25 | .ToList();
26 | }
27 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/GithubTeamMembersProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Octokit;
4 | using Contracts;
5 | using Models;
6 | using Options;
7 | using TomLonghurst.PullRequestScanner.Models;
8 |
9 | internal class GithubTeamMembersProvider : ITeamMembersProvider
10 | {
11 | private readonly IGitHubClient _gitHubClient;
12 | private readonly GithubOptions _githubOptions;
13 |
14 | public GithubTeamMembersProvider(IGitHubClient gitHubClient, GithubOptions githubOptions)
15 | {
16 | _gitHubClient = gitHubClient;
17 | _githubOptions = githubOptions;
18 | }
19 |
20 | private async Task GetUser(GithubUserOptions githubUserOptions)
21 | {
22 | var user = await _gitHubClient.User.Get(githubUserOptions.Username);
23 |
24 | return new GithubTeam
25 | {
26 | Name = user.Login,
27 | Id = user.Id.ToString(),
28 | Members =
29 | [
30 | new GithubMember
31 | {
32 | Id = user.Id.ToString(),
33 | DisplayName = user.Name ?? user.Login,
34 | UniqueName = user.Login,
35 | Email = user.Email,
36 | ImageUrl = user.AvatarUrl
37 | }
38 |
39 | ],
40 | Slug = user.Login,
41 | };
42 | }
43 |
44 | private async Task GetOrganisationTeam(GithubOrganizationTeamOptions githubOrganizationTeamOptions)
45 | {
46 | var team = await _gitHubClient.Organization.Team.GetByName(
47 | githubOrganizationTeamOptions.OrganizationSlug,
48 | githubOrganizationTeamOptions.TeamSlug);
49 |
50 | var members = await _gitHubClient
51 | .Organization
52 | .Team
53 | .GetAllMembers(team.Id);
54 |
55 | return new GithubTeam
56 | {
57 | Name = team.Name,
58 | Id = team.Id.ToString(),
59 | Slug = team.Slug,
60 | Members = members
61 | .Select(m =>
62 | new GithubMember
63 | {
64 | DisplayName = m.Name,
65 | UniqueName = m.Login,
66 | Id = m.Id.ToString(),
67 | Email = m.Email,
68 | ImageUrl = m.AvatarUrl
69 | }).ToList(),
70 | };
71 | }
72 |
73 | public async Task> GetTeamMembers()
74 | {
75 | if (!_githubOptions.IsEnabled)
76 | {
77 | return [];
78 | }
79 |
80 | if (_githubOptions is GithubOrganizationTeamOptions githubOrganizationTeamOptions)
81 | {
82 | var team = await GetOrganisationTeam(githubOrganizationTeamOptions);
83 | return team.Members;
84 | }
85 |
86 | if (_githubOptions is GithubUserOptions githubUserOptions)
87 | {
88 | var team = await GetUser(githubUserOptions);
89 | return team.Members;
90 | }
91 |
92 | return [];
93 | }
94 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/IGithubGraphQlClientProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Octokit.GraphQL;
4 |
5 | internal interface IGithubGraphQlClientProvider
6 | {
7 | Connection GithubGraphQlClient { get; }
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/IGithubPullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Models;
4 |
5 | internal interface IGithubPullRequestService
6 | {
7 | Task> GetPullRequests(GithubRepository repository);
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/IGithubQueryRunner.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Octokit.GraphQL;
4 |
5 | internal interface IGithubQueryRunner
6 | {
7 | Task RunQuery(ICompiledQuery query);
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/Services/IGithubRepositoryService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.GitHub.Services;
2 |
3 | using Models;
4 |
5 | internal interface IGithubRepositoryService
6 | {
7 | Task> GetGitRepositories();
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.GitHub/TomLonghurst.PullRequestScanner.GitHub.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 | latest
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/NugetVersionGeneratorModule.cs:
--------------------------------------------------------------------------------
1 | #pragma warning disable CS0162
2 |
3 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
4 |
5 | using Microsoft.Extensions.Logging;
6 | using ModularPipelines.Context;
7 | using ModularPipelines.Git.Extensions;
8 | using ModularPipelines.Modules;
9 |
10 | public class NugetVersionGeneratorModule : Module
11 | {
12 | protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
13 | {
14 | var gitVersionInformation = await context.Git().Versioning.GetGitVersioningInformation();
15 |
16 | if (gitVersionInformation.BranchName == "main")
17 | {
18 | return gitVersionInformation.SemVer!;
19 | }
20 |
21 | return $"{gitVersionInformation.Major}.{gitVersionInformation.Minor}.{gitVersionInformation.Patch}-{gitVersionInformation.PreReleaseLabel}-{gitVersionInformation.CommitsSinceVersionSource}";
22 | }
23 |
24 | protected override async Task OnAfterExecute(IPipelineContext context)
25 | {
26 | var moduleResult = await this;
27 | context.Logger.LogInformation("NuGet Version to Package: {Version}", moduleResult.Value);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/PackProjectsModule.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
2 |
3 | using Microsoft.Extensions.Logging;
4 | using ModularPipelines.Attributes;
5 | using ModularPipelines.Context;
6 | using ModularPipelines.DotNet.Extensions;
7 | using ModularPipelines.DotNet.Options;
8 | using ModularPipelines.Git.Extensions;
9 | using ModularPipelines.Models;
10 | using ModularPipelines.Modules;
11 | using File = ModularPipelines.FileSystem.File;
12 |
13 | [DependsOn]
14 | [DependsOn]
15 | [DependsOn]
16 | public class PackProjectsModule : Module>
17 | {
18 | protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
19 | {
20 | var results = new List();
21 | var packageVersion = await GetModule();
22 | var projectFiles = context.Git().RootDirectory!.GetFiles(f => GetProjectsPredicate(f, context));
23 | foreach (var projectFile in projectFiles)
24 | {
25 | results.Add(await context.DotNet().Pack(
26 | new DotNetPackOptions
27 | {
28 | ProjectSolution = projectFile.Path,
29 | Configuration = Configuration.Release,
30 | IncludeSource = !projectFile.Path.Contains("Analyzer"),
31 | Properties = new List
32 | {
33 | ("PackageVersion", packageVersion.Value!),
34 | ("Version", packageVersion.Value!),
35 | },
36 | }, cancellationToken));
37 | }
38 |
39 | return results;
40 | }
41 |
42 | private static bool GetProjectsPredicate(File file, IPipelineContext context)
43 | {
44 | var path = file.Path;
45 | if (!path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
46 | {
47 | return false;
48 | }
49 |
50 | if (path.Contains("Tests", StringComparison.OrdinalIgnoreCase)
51 | || path.Contains("Pipeline", StringComparison.OrdinalIgnoreCase)
52 | || path.Contains("Example", StringComparison.OrdinalIgnoreCase))
53 | {
54 | return false;
55 | }
56 |
57 | context.Logger.LogInformation("Found File: {File}", path);
58 | return true;
59 | }
60 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/PackageFilesRemovalModule.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
2 |
3 | using ModularPipelines.Context;
4 | using ModularPipelines.Git.Extensions;
5 | using ModularPipelines.Modules;
6 |
7 | public class PackageFilesRemovalModule : Module
8 | {
9 | protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
10 | {
11 | var packageFiles = context.Git().RootDirectory.GetFiles(path => path.Extension is ".nupkg");
12 |
13 | foreach (var packageFile in packageFiles)
14 | {
15 | packageFile.Delete();
16 | }
17 |
18 | return await NothingAsync();
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/PackagePathsParserModule.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
2 |
3 | using ModularPipelines.Attributes;
4 | using ModularPipelines.Context;
5 | using ModularPipelines.Modules;
6 | using File = ModularPipelines.FileSystem.File;
7 |
8 | [DependsOn]
9 | public class PackagePathsParserModule : Module>
10 | {
11 | protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
12 | {
13 | var packPackagesModuleResult = await GetModule();
14 |
15 | return packPackagesModuleResult.Value!
16 | .Select(x => x.StandardOutput)
17 | .Select(x => x.Split("Successfully created package '")[1])
18 | .Select(x => x.Split("'.")[0])
19 | .Select(x => new File(x))
20 | .ToList();
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/RunUnitTestsModule.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
2 |
3 | using ModularPipelines.Context;
4 | using ModularPipelines.DotNet;
5 | using ModularPipelines.DotNet.Extensions;
6 | using ModularPipelines.DotNet.Options;
7 | using ModularPipelines.Enums;
8 | using ModularPipelines.Git.Extensions;
9 | using ModularPipelines.Models;
10 | using ModularPipelines.Modules;
11 |
12 | public class RunUnitTestsModule : Module>
13 | {
14 | protected override async Task?> ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
15 | {
16 | var results = new List();
17 |
18 | foreach (var unitTestProjectFile in context
19 | .Git().RootDirectory!
20 | .GetFiles(file => file.Path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)
21 | && file.Path.Contains("UnitTests", StringComparison.OrdinalIgnoreCase)))
22 | {
23 | results.Add(await context.DotNet().Test(
24 | new DotNetTestOptions
25 | {
26 | ProjectSolutionDirectoryDllExe = unitTestProjectFile.Path,
27 | CommandLogging = CommandLogging.Input | CommandLogging.Error,
28 | }, cancellationToken));
29 | }
30 |
31 | return results;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Modules/UploadPackagesToNugetModule.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Modules;
2 |
3 | using EnumerableAsyncProcessor.Extensions;
4 | using Microsoft.Extensions.Options;
5 | using ModularPipelines.Attributes;
6 | using ModularPipelines.Context;
7 | using ModularPipelines.DotNet.Extensions;
8 | using ModularPipelines.DotNet.Options;
9 | using ModularPipelines.Models;
10 | using ModularPipelines.Modules;
11 | using Settings;
12 |
13 | [DependsOn]
14 | [DependsOn]
15 | public class UploadPackagesToNugetModule : Module
16 | {
17 | private readonly IOptions _options;
18 |
19 | public UploadPackagesToNugetModule(IOptions options)
20 | {
21 | _options = options;
22 | }
23 |
24 | protected override async Task ShouldSkip(IPipelineContext context)
25 | {
26 | await Task.Yield();
27 | var publishPackages =
28 | context.Environment.EnvironmentVariables.GetEnvironmentVariable("PUBLISH_PACKAGES")!;
29 |
30 | if (!bool.TryParse(publishPackages, out var shouldPublishPackages)
31 | || !shouldPublishPackages)
32 | {
33 | return SkipDecision.Skip("User hasn't selected to publish");
34 | }
35 |
36 | return SkipDecision.DoNotSkip;
37 | }
38 |
39 | protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
40 | {
41 | ArgumentNullException.ThrowIfNull(_options.Value.ApiKey);
42 |
43 | var packagePaths = await GetModule();
44 |
45 | return await packagePaths.Value!
46 | .SelectAsync(
47 | async nugetFile => await context.DotNet().Nuget.Push(
48 | new DotNetNugetPushOptions
49 | {
50 | Path = nugetFile,
51 | Source = "https://api.nuget.org/v3/index.json",
52 | ApiKey = _options.Value.ApiKey!,
53 | }, cancellationToken), cancellationToken: cancellationToken)
54 | .ProcessOneAtATime();
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Configuration;
2 | using Microsoft.Extensions.DependencyInjection;
3 | using ModularPipelines.Host;
4 | using TomLonghurst.PullRequestScanner.Pipeline.Modules;
5 | using TomLonghurst.PullRequestScanner.Pipeline.Settings;
6 |
7 | await PipelineHostBuilder.Create()
8 | .ConfigureAppConfiguration((_, builder) =>
9 | {
10 | builder.AddJsonFile("appsettings.json")
11 | .AddUserSecrets()
12 | .AddEnvironmentVariables();
13 | })
14 | .ConfigureServices((context, collection) =>
15 | {
16 | collection.Configure(context.Configuration.GetSection("NuGet"));
17 | })
18 | .AddModule()
19 | .AddModule()
20 | .AddModule()
21 | .AddModule()
22 | .AddModule()
23 | .AddModule()
24 | .ExecutePipelineAsync();
25 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/Settings/NuGetSettings.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Pipeline.Settings;
2 |
3 | public record NuGetSettings
4 | {
5 | public string? ApiKey { get; init; }
6 | }
7 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/TomLonghurst.PullRequestScanner.Pipeline.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net7.0
6 | enable
7 | enable
8 | latest
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Pipeline/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Debug",
5 | "System": "Information",
6 | "Microsoft": "Information"
7 | }
8 | },
9 | "NuGet": {
10 | "ApiKey": "Override from Secret Source"
11 | }
12 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Extensions/AdaptiveCardExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Extensions;
2 |
3 | using TomLonghurst.PullRequestScanner.Models;
4 | using Models;
5 |
6 | internal static class AdaptiveCardExtensions
7 | {
8 | internal static AdaptiveCardMentionedEntity[] ToAdaptiveCardMentionEntities(this IEnumerable teamMembers)
9 | {
10 | return teamMembers.Distinct()
11 | .Where(x => !string.IsNullOrEmpty(x.Email))
12 | .Select(x => new AdaptiveCardMentionedEntity(
13 | Type: "mention",
14 | Text: x.ToAtMarkupTag(),
15 | Mentioned: new Mentioned(Id: x.Email!, Name: x.DisplayOrUniqueName)))
16 | .ToArray();
17 | }
18 |
19 | internal static string ToAtMarkupTag(this TeamMember teamMember)
20 | {
21 | if (!string.IsNullOrEmpty(teamMember.Email))
22 | {
23 | return $"{teamMember.DisplayOrUniqueName}";
24 | }
25 |
26 | return teamMember.DisplayOrUniqueName;
27 | }
28 |
29 | internal static void MarkCardAsWrittenTo(this MicrosoftTeamsAdaptiveCard microsoftTeamsAdaptiveCard)
30 | {
31 | microsoftTeamsAdaptiveCard.AdditionalProperties["ShouldReturn"] = true;
32 | }
33 |
34 | internal static bool IsCardWrittenTo(this MicrosoftTeamsAdaptiveCard microsoftTeamsAdaptiveCard)
35 | {
36 | return microsoftTeamsAdaptiveCard.AdditionalProperties.TryGetValue("ShouldReturn", out var objBool)
37 | && objBool is true;
38 | }
39 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Extensions/PullRequestScannerBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Extensions;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.Extensions.DependencyInjection.Extensions;
5 | using TomLonghurst.PullRequestScanner.Extensions;
6 | using Http;
7 | using Mappers;
8 | using Options;
9 | using Services;
10 |
11 | public static class PullRequestScannerBuilderExtensions
12 | {
13 | [Obsolete("This adds all Microsoft Teams Webhook plugins. Please instead consider using the overload with the MicrosoftTeamsWebHookPublisherBuilder to add desired plugins one by one with better configuration")]
14 | public static PullRequestScannerBuilder AddMicrosoftTeamsWebHookPublisher(
15 | this PullRequestScannerBuilder pullRequestScannerBuilder, MicrosoftTeamsOptions microsoftTeamsOptions)
16 | {
17 | pullRequestScannerBuilder.Services.AddSingleton(microsoftTeamsOptions);
18 | return AddMicrosoftTeamsWebHookPublisher(pullRequestScannerBuilder);
19 | }
20 |
21 | [Obsolete("This adds all Microsoft Teams Webhook plugins. Please instead consider using the overload with the MicrosoftTeamsWebHookPublisherBuilder to add desired plugins one by one with better configuration")]
22 | public static PullRequestScannerBuilder AddMicrosoftTeamsWebHookPublisher(
23 | this PullRequestScannerBuilder pullRequestScannerBuilder, Func microsoftTeamsOptionsFactory)
24 | {
25 | pullRequestScannerBuilder.Services.AddSingleton(microsoftTeamsOptionsFactory);
26 | return AddMicrosoftTeamsWebHookPublisher(pullRequestScannerBuilder);
27 | }
28 |
29 | public static PullRequestScannerBuilder AddMicrosoftTeamsWebHookPublisher(
30 | this PullRequestScannerBuilder pullRequestScannerBuilder, MicrosoftTeamsOptions microsoftTeamsOptions,
31 | Action microsoftTeamsWebHookPublisherBuilder)
32 | {
33 | pullRequestScannerBuilder.Services.AddSingleton(microsoftTeamsOptions);
34 |
35 | microsoftTeamsWebHookPublisherBuilder(
36 | new MicrosoftTeamsWebHookPublisherBuilder(pullRequestScannerBuilder));
37 |
38 | return pullRequestScannerBuilder;
39 | }
40 |
41 | public static PullRequestScannerBuilder AddMicrosoftTeamsWebHookPublisher(
42 | this PullRequestScannerBuilder pullRequestScannerBuilder, Func microsoftTeamsOptionsFactory,
43 | Action microsoftTeamsWebHookPublisherBuilder)
44 | {
45 | pullRequestScannerBuilder.Services.AddSingleton(microsoftTeamsOptionsFactory);
46 |
47 | microsoftTeamsWebHookPublisherBuilder(
48 | new MicrosoftTeamsWebHookPublisherBuilder(pullRequestScannerBuilder));
49 |
50 | return pullRequestScannerBuilder;
51 | }
52 |
53 | private static PullRequestScannerBuilder AddMicrosoftTeamsWebHookPublisher(this PullRequestScannerBuilder pullRequestScannerBuilder)
54 | {
55 | pullRequestScannerBuilder.Services.TryAddTransient();
56 | pullRequestScannerBuilder.Services.TryAddTransient();
57 | pullRequestScannerBuilder.Services.TryAddTransient();
58 | pullRequestScannerBuilder.Services.TryAddTransient();
59 |
60 | pullRequestScannerBuilder.Services.AddHttpClient();
61 |
62 | return pullRequestScannerBuilder.AddPlugin(ActivatorUtilities.GetServiceOrCreateInstance);
63 | }
64 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Http/MicrosoftTeamsWebhookClient.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Http;
2 |
3 | using System.Text;
4 | using Microsoft.Extensions.Logging;
5 | using Newtonsoft.Json;
6 | using Polly;
7 | using Polly.Extensions.Http;
8 | using Models;
9 | using Options;
10 |
11 | internal class MicrosoftTeamsWebhookClient
12 | {
13 | private readonly HttpClient _httpClient;
14 | private readonly MicrosoftTeamsOptions _microsoftTeamsOptions;
15 | private readonly ILogger _logger;
16 |
17 | public MicrosoftTeamsWebhookClient(HttpClient httpClient, MicrosoftTeamsOptions microsoftTeamsOptions,
18 | ILogger logger)
19 | {
20 | _httpClient = httpClient;
21 | _microsoftTeamsOptions = microsoftTeamsOptions;
22 | _logger = logger;
23 | }
24 |
25 | public async Task CreateTeamsNotification(MicrosoftTeamsAdaptiveCard adaptiveCard)
26 | {
27 | ArgumentNullException.ThrowIfNull(_microsoftTeamsOptions.WebHookUri);
28 |
29 | var adaptiveTeamsCardJsonString = JsonConvert.SerializeObject(TeamsNotificationCardWrapper.Wrap(adaptiveCard), Formatting.None);
30 |
31 | _logger.LogTrace("Microsoft Teams Webhook Request Payload: {Payload}", adaptiveTeamsCardJsonString);
32 |
33 | try
34 | {
35 | var teamsNotificationResponse = await HttpPolicyExtensions.HandleTransientHttpError()
36 | .WaitAndRetryAsync(3, i => TimeSpan.FromSeconds(i))
37 | .ExecuteAsync(() =>
38 | {
39 | var cardsRequest = new HttpRequestMessage
40 | {
41 | Method = HttpMethod.Post,
42 | Content = new StringContent(adaptiveTeamsCardJsonString),
43 | RequestUri = _microsoftTeamsOptions.WebHookUri,
44 | };
45 |
46 | return _httpClient.SendAsync(cardsRequest);
47 | });
48 |
49 | _logger.LogTrace("Microsoft Teams Webhook Response: {Response}", await teamsNotificationResponse.Content.ReadAsStringAsync());
50 |
51 | teamsNotificationResponse.EnsureSuccessStatusCode();
52 | }
53 | catch (HttpRequestException e)
54 | {
55 | if (e.Message.StartsWith("Webhook message delivery failed with error: Microsoft Teams endpoint returned HTTP error 413"))
56 | {
57 | var byteCount = Encoding.Unicode.GetByteCount(adaptiveTeamsCardJsonString);
58 | throw new HttpRequestException($"Teams card payload is too big - {byteCount} bytes", e);
59 | }
60 |
61 | throw;
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/IPullRequestLeaderboardCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using TomLonghurst.PullRequestScanner.Models;
4 | using Models;
5 |
6 | internal interface IPullRequestLeaderboardCardMapper
7 | {
8 | IEnumerable Map(IReadOnlyList pullRequests);
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/IPullRequestStatusCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using Enums;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Models;
6 |
7 | internal interface IPullRequestStatusCardMapper
8 | {
9 | IEnumerable Map(IReadOnlyList pullRequests, PullRequestStatus pullRequestStatus);
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/IPullRequestsOverviewCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using TomLonghurst.PullRequestScanner.Models;
4 | using Models;
5 |
6 | internal interface IPullRequestsOverviewCardMapper
7 | {
8 | IEnumerable Map(IReadOnlyList pullRequests);
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/PullRequestLeaderboardCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using System.Collections.Concurrent;
4 | using AdaptiveCards;
5 | using TomLonghurst.PullRequestScanner.Extensions;
6 | using TomLonghurst.PullRequestScanner.Models;
7 | using Extensions;
8 | using Models;
9 |
10 | internal class PullRequestLeaderboardCardMapper : IPullRequestLeaderboardCardMapper
11 | {
12 | public IEnumerable Map(IReadOnlyList pullRequests)
13 | {
14 | return Map(pullRequests.ToList());
15 | }
16 |
17 | private static IEnumerable Map(List pullRequests)
18 | {
19 | if (!pullRequests.Any(x => x.Approvers.Any(a => a.Time.IsYesterday()))
20 | && !pullRequests.Any(x => x.AllComments.Any(c => c.LastUpdated.IsYesterday())))
21 | {
22 | yield break;
23 | }
24 |
25 | var teamsNotificationCard = new MicrosoftTeamsAdaptiveCard
26 | {
27 | MsTeams = new MicrosoftTeamsProperties
28 | {
29 | Width = "full",
30 | },
31 | Body =
32 | [
33 | new AdaptiveTextBlock
34 | {
35 | Weight = AdaptiveTextWeight.Bolder,
36 | Size = AdaptiveTextSize.ExtraLarge,
37 | Text = "Yesterday's Pull Request Reviewer Leaderboard"
38 | },
39 |
40 | new AdaptiveColumnSet
41 | {
42 | Columns =
43 | [
44 | new AdaptiveColumn
45 | {
46 | Items =
47 | [
48 | new AdaptiveTextBlock
49 | {
50 | Weight = AdaptiveTextWeight.Bolder,
51 | Text = "Image"
52 | }
53 | ],
54 | Width = "50px"
55 | },
56 |
57 | new AdaptiveColumn
58 | {
59 | Items =
60 | [
61 | new AdaptiveTextBlock
62 | {
63 | Weight = AdaptiveTextWeight.Bolder,
64 | Text = "Name"
65 | }
66 | ]
67 | },
68 |
69 | new AdaptiveColumn
70 | {
71 | Items =
72 | [
73 | new AdaptiveTextBlock
74 | {
75 | Weight = AdaptiveTextWeight.Bolder,
76 | Text = "Comments"
77 | }
78 | ]
79 | },
80 |
81 | new AdaptiveColumn
82 | {
83 | Items =
84 | [
85 | new AdaptiveTextBlock
86 | {
87 | Weight = AdaptiveTextWeight.Bolder,
88 | Text = "Pull Requests Reviewed"
89 | }
90 | ]
91 | }
92 | ]
93 | }
94 | ],
95 | };
96 |
97 | var personsCommentsAndReviews = new ConcurrentDictionary();
98 | foreach (var pullRequest in pullRequests)
99 | {
100 | var uniqueReviewers = pullRequest.UniqueReviewers;
101 | uniqueReviewers.ForEach(uniqueReviewer =>
102 | {
103 | var yesterdaysCommentCount = pullRequest.GetCommentCountWhere(uniqueReviewer, c => c.LastUpdated.IsYesterday());
104 | var hasVoted = pullRequest.HasVotedWhere(uniqueReviewer, a => a.Vote != Vote.NoVote && a.Time.IsYesterday());
105 |
106 | var record = personsCommentsAndReviews.GetOrAdd(uniqueReviewer, new PullRequestReviewLeaderboardModel());
107 |
108 | record.CommentsCount += yesterdaysCommentCount;
109 |
110 | if (yesterdaysCommentCount != 0 || hasVoted)
111 | {
112 | record.ReviewedCount++;
113 | }
114 | });
115 | }
116 |
117 | foreach (var personsCommentsAndReview in personsCommentsAndReviews
118 | .Where(x => x.Value.CommentsCount != 0 || x.Value.ReviewedCount != 0)
119 | .OrderByDescending(x => x.Value.CommentsCount)
120 | .ThenByDescending(x => x.Value.ReviewedCount))
121 | {
122 | teamsNotificationCard.MarkCardAsWrittenTo();
123 | teamsNotificationCard.Body.Add(
124 | new AdaptiveColumnSet
125 | {
126 | Columns =
127 | [
128 | new AdaptiveColumn
129 | {
130 | Items =
131 | [
132 | new AdaptiveImage
133 | {
134 | PixelWidth = 50,
135 | PixelHeight = 50,
136 | Url =
137 | new Uri(
138 | personsCommentsAndReview.Key.ImageUrls.FirstOrDefault()
139 | ??
140 | "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png")
141 | }
142 | ],
143 | Width = "50px"
144 | },
145 |
146 | new AdaptiveColumn
147 | {
148 | Items =
149 | [
150 | new AdaptiveTextBlock
151 | {
152 | Text = personsCommentsAndReview.Key.ToAtMarkupTag()
153 | }
154 | ]
155 | },
156 |
157 | new AdaptiveColumn
158 | {
159 | Items =
160 | [
161 | new AdaptiveTextBlock
162 | {
163 | Text = personsCommentsAndReview.Value.CommentsCount.ToString()
164 | }
165 | ]
166 | },
167 |
168 | new AdaptiveColumn
169 | {
170 | Items =
171 | [
172 | new AdaptiveTextBlock
173 | {
174 | Text = personsCommentsAndReview.Value.ReviewedCount.ToString()
175 | }
176 | ]
177 | }
178 | ],
179 | });
180 | }
181 |
182 | teamsNotificationCard.MsTeams.Entitities = personsCommentsAndReviews
183 | .Where(x => x.Value.CommentsCount != 0 || x.Value.ReviewedCount != 0)
184 | .Select(x => x.Key)
185 | .ToAdaptiveCardMentionEntities();
186 |
187 | if (!teamsNotificationCard.IsCardWrittenTo())
188 | {
189 | yield break;
190 | }
191 |
192 | yield return teamsNotificationCard;
193 | }
194 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/PullRequestStatusCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using System.Text;
4 | using AdaptiveCards;
5 | using Newtonsoft.Json;
6 | using Enums;
7 | using TomLonghurst.PullRequestScanner.Mappers;
8 | using TomLonghurst.PullRequestScanner.Models;
9 | using Extensions;
10 | using Models;
11 |
12 | internal class PullRequestStatusCardMapper : IPullRequestStatusCardMapper
13 | {
14 | public IEnumerable Map(IReadOnlyList pullRequests, PullRequestStatus pullRequestStatus)
15 | {
16 | return Map(pullRequests, pullRequestStatus, 0);
17 | }
18 |
19 | private static IEnumerable Map(IReadOnlyList pullRequests, PullRequestStatus pullRequestStatus, int cardCount)
20 | {
21 | var pullRequestsWithStatus = pullRequests
22 | .Where(x => x.PullRequestStatus == pullRequestStatus)
23 | .ToList();
24 |
25 | if (!pullRequestsWithStatus.Any())
26 | {
27 | yield break;
28 | }
29 |
30 | var teamsNotificationCard = new MicrosoftTeamsAdaptiveCard
31 | {
32 | MsTeams = new MicrosoftTeamsProperties
33 | {
34 | Width = "full",
35 | },
36 | Body =
37 | [
38 | new AdaptiveTextBlock
39 | {
40 | Weight = AdaptiveTextWeight.Bolder,
41 | Size = AdaptiveTextSize.ExtraLarge,
42 | Text = pullRequestStatus.GetMessage()
43 | }
44 | ],
45 | };
46 |
47 | var mentionedUsers = new List();
48 |
49 | foreach (var pullRequestsInRepo in pullRequestsWithStatus.GroupBy(x => x.Repository.Id))
50 | {
51 | var adaptiveContainer = new AdaptiveContainer
52 | {
53 | Spacing = AdaptiveSpacing.ExtraLarge,
54 | Style = AdaptiveContainerStyle.Emphasis,
55 | Items =
56 | [
57 | new AdaptiveTextBlock
58 | {
59 | Text =
60 | $"**Repository:** [{pullRequestsInRepo.First().Repository.Name}]({pullRequestsInRepo.First().Repository.Url})"
61 | },
62 |
63 | new AdaptiveColumnSet
64 | {
65 | Columns =
66 | [
67 | new AdaptiveColumn
68 | {
69 | Items =
70 | [
71 | new AdaptiveTextBlock
72 | {
73 | Weight = AdaptiveTextWeight.Bolder,
74 | Text = "Pull Request"
75 | }
76 | ]
77 | },
78 |
79 | new AdaptiveColumn
80 | {
81 | Items =
82 | [
83 | new AdaptiveTextBlock
84 | {
85 | Weight = AdaptiveTextWeight.Bolder,
86 | Text = "Author"
87 | }
88 | ],
89 | Width = "auto"
90 | }
91 | ]
92 | }
93 | ],
94 | };
95 |
96 | teamsNotificationCard.Body.Add(adaptiveContainer);
97 |
98 | foreach (var pullRequest in pullRequestsInRepo)
99 | {
100 | teamsNotificationCard.MarkCardAsWrittenTo();
101 | pullRequestsWithStatus.Remove(pullRequest);
102 | mentionedUsers.Add(pullRequest.Author);
103 | teamsNotificationCard.MsTeams.Entitities = mentionedUsers.ToAdaptiveCardMentionEntities();
104 |
105 | adaptiveContainer.Items.Add(new AdaptiveColumnSet
106 | {
107 | Columns =
108 | [
109 | new AdaptiveColumn
110 | {
111 | Items =
112 | [
113 | new AdaptiveTextBlock
114 | {
115 | Text = $"[#{pullRequest.Number}]({pullRequest.Url}) {pullRequest.Title}"
116 | }
117 | ]
118 | },
119 |
120 | new AdaptiveColumn
121 | {
122 | Items =
123 | [
124 | new AdaptiveTextBlock
125 | {
126 | Text = pullRequest.Author.ToAtMarkupTag()
127 | }
128 | ],
129 | Width = "auto"
130 | }
131 | ],
132 | });
133 |
134 | var jsonString = JsonConvert.SerializeObject(teamsNotificationCard, Formatting.None);
135 | if (Encoding.Unicode.GetByteCount(jsonString) > Constants.TeamsCardSizeLimit)
136 | {
137 | yield return teamsNotificationCard;
138 |
139 | foreach (var microsoftTeamsAdaptiveCard in Map(pullRequestsWithStatus.ToList(), pullRequestStatus, cardCount + 1))
140 | {
141 | yield return microsoftTeamsAdaptiveCard;
142 | }
143 |
144 | yield break;
145 | }
146 | }
147 | }
148 |
149 | if (!teamsNotificationCard.IsCardWrittenTo())
150 | {
151 | yield break;
152 | }
153 |
154 | yield return teamsNotificationCard;
155 | }
156 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Mappers/PullRequestsOverviewCardMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Mappers;
2 |
3 | using System.Text;
4 | using AdaptiveCards;
5 | using Newtonsoft.Json;
6 | using Enums;
7 | using TomLonghurst.PullRequestScanner.Extensions;
8 | using TomLonghurst.PullRequestScanner.Mappers;
9 | using TomLonghurst.PullRequestScanner.Models;
10 | using Extensions;
11 | using Models;
12 |
13 | internal class PullRequestsOverviewCardMapper : IPullRequestsOverviewCardMapper
14 | {
15 | public IEnumerable Map(IReadOnlyList pullRequests)
16 | {
17 | return Map(pullRequests, 1);
18 | }
19 |
20 | private static IEnumerable Map(IReadOnlyList pullRequests, int cardCount)
21 | {
22 | var repos = pullRequests
23 | .Where(x => x.IsActive)
24 | .OrderByDescending(x => x.Created)
25 | .GroupBy(x => x.Repository.Id)
26 | .ToMutableGrouping();
27 |
28 | var teamsNotificationCard = new MicrosoftTeamsAdaptiveCard
29 | {
30 | MsTeams = new MicrosoftTeamsProperties
31 | {
32 | Width = "full",
33 | },
34 | Body =
35 | [
36 | new AdaptiveTextBlock
37 | {
38 | Weight = AdaptiveTextWeight.Bolder,
39 | Size = AdaptiveTextSize.Large,
40 | Text = $"Pull Request Statuses {GetCardNumberString(cardCount)}"
41 | }
42 |
43 | ],
44 | };
45 |
46 | var mentionedUsers = new List();
47 |
48 | foreach (var repo in repos.ToList())
49 | {
50 | var adaptiveContainer = new AdaptiveContainer
51 | {
52 | Spacing = AdaptiveSpacing.ExtraLarge,
53 | Style = AdaptiveContainerStyle.Emphasis,
54 | Items =
55 | [
56 | new AdaptiveTextBlock
57 | {
58 | Text =
59 | $"**Repository:** [{repo.Values.First().Repository.Name}]({repo.Values.First().Repository.Url})"
60 | },
61 |
62 | new AdaptiveColumnSet
63 | {
64 | Columns =
65 | [
66 | new AdaptiveColumn
67 | {
68 | Items =
69 | [
70 | new AdaptiveTextBlock
71 | {
72 | Weight = AdaptiveTextWeight.Bolder,
73 | Text = "Pull Request"
74 | }
75 | ]
76 | },
77 |
78 | new AdaptiveColumn
79 | {
80 | Items =
81 | [
82 | new AdaptiveTextBlock
83 | {
84 | Weight = AdaptiveTextWeight.Bolder,
85 | Text = "Author"
86 | }
87 | ],
88 | Width = "150px"
89 | },
90 |
91 | new AdaptiveColumn
92 | {
93 | Items =
94 | [
95 | new AdaptiveTextBlock
96 | {
97 | Weight = AdaptiveTextWeight.Bolder,
98 | Text = "Status"
99 | }
100 | ],
101 | Width = "120px"
102 | },
103 |
104 | new AdaptiveColumn
105 | {
106 | Items =
107 | [
108 | new AdaptiveTextBlock
109 | {
110 | Weight = AdaptiveTextWeight.Bolder,
111 | Text = "Age"
112 | }
113 | ],
114 | Width = "50px"
115 | }
116 | ]
117 | }
118 | ],
119 | };
120 |
121 | teamsNotificationCard.Body.Add(adaptiveContainer);
122 |
123 | foreach (var pullRequest in repo.Values.ToList())
124 | {
125 | teamsNotificationCard.MarkCardAsWrittenTo();
126 | repo.Values.Remove(pullRequest);
127 | mentionedUsers.Add(pullRequest.Author);
128 | adaptiveContainer.Items.Add(new AdaptiveColumnSet
129 | {
130 | Columns =
131 | [
132 | new AdaptiveColumn
133 | {
134 | Items =
135 | [
136 | new AdaptiveTextBlock
137 | {
138 | Text = $"[#{pullRequest.Number}]({pullRequest.Url}) {pullRequest.Title}",
139 | Color = pullRequest.IsDraft ? AdaptiveTextColor.Accent : AdaptiveTextColor.Default
140 | }
141 | ]
142 | },
143 |
144 | new AdaptiveColumn
145 | {
146 | Items =
147 | [
148 | new AdaptiveTextBlock
149 | {
150 | Text = pullRequest.Author.ToAtMarkupTag(),
151 | Color = pullRequest.IsDraft ? AdaptiveTextColor.Accent : AdaptiveTextColor.Default
152 | }
153 | ],
154 | Width = "150px"
155 | },
156 |
157 | new AdaptiveColumn
158 | {
159 | Items =
160 | [
161 | new AdaptiveTextBlock
162 | {
163 | Text = pullRequest.PullRequestStatus.GetMessage(),
164 | Color = GetColorForStatus(pullRequest.PullRequestStatus)
165 | }
166 | ],
167 | Width = "120px"
168 | },
169 |
170 | new AdaptiveColumn
171 | {
172 | Items =
173 | [
174 | new AdaptiveTextBlock
175 | {
176 | Text = GetAge(pullRequest.Created),
177 | Color = GetColorForAge(pullRequest.Created)
178 | }
179 | ],
180 | Width = "50px"
181 | }
182 | ],
183 | });
184 |
185 | teamsNotificationCard.MsTeams.Entitities = mentionedUsers.ToAdaptiveCardMentionEntities();
186 |
187 | var jsonString = JsonConvert.SerializeObject(teamsNotificationCard, Formatting.None);
188 | if (Encoding.Unicode.GetByteCount(jsonString) > Constants.TeamsCardSizeLimit)
189 | {
190 | yield return teamsNotificationCard;
191 |
192 | foreach (var microsoftTeamsAdaptiveCard in Map(repos.SelectMany(x => x.Values).ToList(), cardCount + 1))
193 | {
194 | yield return microsoftTeamsAdaptiveCard;
195 | }
196 |
197 | yield break;
198 | }
199 | }
200 |
201 | repos.Remove(repo);
202 | }
203 |
204 | if (!teamsNotificationCard.IsCardWrittenTo())
205 | {
206 | yield break;
207 | }
208 |
209 | yield return teamsNotificationCard;
210 | }
211 |
212 | private static string GetAge(DateTimeOffset dateTime)
213 | {
214 | var timeSpanSinceCreation = DateTime.UtcNow - dateTime;
215 |
216 | if (timeSpanSinceCreation.TotalDays >= 1)
217 | {
218 | return $"{(int)timeSpanSinceCreation.TotalDays}d";
219 | }
220 |
221 | if (timeSpanSinceCreation.TotalHours >= 1)
222 | {
223 | return $"{(int)timeSpanSinceCreation.TotalHours}h";
224 | }
225 |
226 | return $"{(int)timeSpanSinceCreation.TotalMinutes}m";
227 | }
228 |
229 | private static AdaptiveTextColor GetColorForAge(DateTimeOffset dateTime)
230 | {
231 | var timeSpanSinceCreation = DateTime.UtcNow - dateTime;
232 |
233 | if (timeSpanSinceCreation.TotalDays is >= 3 and < 7)
234 | {
235 | return AdaptiveTextColor.Warning;
236 | }
237 |
238 | if (timeSpanSinceCreation.TotalDays >= 7)
239 | {
240 | return AdaptiveTextColor.Attention;
241 | }
242 |
243 | return AdaptiveTextColor.Default;
244 | }
245 |
246 | private static string GetCardNumberString(int cardNumber)
247 | {
248 | return cardNumber == 1 ? string.Empty : $"Part {cardNumber}";
249 | }
250 |
251 | private static AdaptiveTextColor GetColorForStatus(PullRequestStatus status)
252 | {
253 | return status switch
254 | {
255 | PullRequestStatus.FailingChecks => AdaptiveTextColor.Attention,
256 | PullRequestStatus.OutStandingComments => AdaptiveTextColor.Warning,
257 | PullRequestStatus.NeedsReviewing => AdaptiveTextColor.Warning,
258 | PullRequestStatus.MergeConflicts => AdaptiveTextColor.Attention,
259 | PullRequestStatus.Rejected => AdaptiveTextColor.Attention,
260 | PullRequestStatus.ReadyToMerge => AdaptiveTextColor.Good,
261 | PullRequestStatus.Completed => AdaptiveTextColor.Good,
262 | PullRequestStatus.FailedToMerge => AdaptiveTextColor.Attention,
263 | PullRequestStatus.Draft => AdaptiveTextColor.Accent,
264 | _ => AdaptiveTextColor.Default,
265 | };
266 | }
267 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/MicrosoftTeamsWebHookPublisherBuilder.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.Extensions.DependencyInjection.Extensions;
5 | using TomLonghurst.PullRequestScanner.Extensions;
6 | using Http;
7 | using Mappers;
8 | using Options;
9 | using Services;
10 |
11 | public class MicrosoftTeamsWebHookPublisherBuilder
12 | {
13 | private readonly PullRequestScannerBuilder _pullRequestScannerBuilder;
14 | private readonly IServiceCollection _services;
15 |
16 | internal MicrosoftTeamsWebHookPublisherBuilder(PullRequestScannerBuilder pullRequestScannerBuilder)
17 | {
18 | _pullRequestScannerBuilder = pullRequestScannerBuilder;
19 | _services = pullRequestScannerBuilder.Services;
20 | }
21 |
22 | public MicrosoftTeamsWebHookPublisherBuilder AddOverviewCardPublisher()
23 | {
24 | _services.TryAddTransient();
25 | _services.TryAddTransient(sp =>
26 | new PullRequestOverviewMicrosoftTeamsWebHookPublisher(sp.GetRequiredService(), sp.GetRequiredService()));
27 |
28 | _services.AddHttpClient();
29 |
30 | _pullRequestScannerBuilder.AddPlugin(sp => sp.GetRequiredService());
31 |
32 | return this;
33 | }
34 |
35 | public MicrosoftTeamsWebHookPublisherBuilder AddLeaderboardCardPublisher()
36 | {
37 | _services.TryAddTransient();
38 | _services.TryAddTransient(sp =>
39 | new PullRequestLeaderboardMicrosoftTeamsWebHookPublisher(sp.GetRequiredService(), sp.GetRequiredService()));
40 |
41 | _services.AddHttpClient();
42 |
43 | _pullRequestScannerBuilder.AddPlugin(sp => sp.GetRequiredService());
44 |
45 | return this;
46 | }
47 |
48 | public MicrosoftTeamsWebHookPublisherBuilder AddStatusCardsPublisher(MicrosoftTeamsStatusPublishOptions? microsoftTeamsStatusPublishOptions = null)
49 | {
50 | _services.AddSingleton(microsoftTeamsStatusPublishOptions ?? new MicrosoftTeamsStatusPublishOptions());
51 |
52 | _services.TryAddTransient();
53 | _services.TryAddTransient(sp =>
54 | new PullRequestStatusMicrosoftTeamsWebHookPublisher(sp.GetRequiredService(), sp.GetRequiredService(), microsoftTeamsStatusPublishOptions ?? new MicrosoftTeamsStatusPublishOptions()));
55 |
56 | _services.AddHttpClient();
57 |
58 | _pullRequestScannerBuilder.AddPlugin(sp => sp.GetRequiredService());
59 |
60 | return this;
61 | }
62 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/AdaptiveCardMentionedEntity.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | internal record AdaptiveCardMentionedEntity(
6 | [property: JsonPropertyName("type")] string Type,
7 | [property: JsonPropertyName("text")] string Text,
8 | [property: JsonPropertyName("mentioned")]
9 | Mentioned Mentioned);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/Attachment.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models;
2 |
3 | using Newtonsoft.Json;
4 |
5 | internal class Attachment
6 | {
7 | [JsonProperty("contentType")]
8 | public string ContentType { get; set; }
9 |
10 | [JsonProperty("content")]
11 | public MicrosoftTeamsAdaptiveCard Content { get; set; }
12 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/Mentioned.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models;
2 |
3 | using System.Text.Json.Serialization;
4 |
5 | internal record Mentioned(
6 | [property: JsonPropertyName("id")] string Id,
7 | [property: JsonPropertyName("name")] string Name);
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/MicrosoftTeamsAdaptiveCard.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models
2 | {
3 | using AdaptiveCards;
4 | using Newtonsoft.Json;
5 |
6 | internal class MicrosoftTeamsAdaptiveCard : AdaptiveCard
7 | {
8 | public MicrosoftTeamsAdaptiveCard()
9 | : base(new AdaptiveSchemaVersion(1, 3))
10 | {
11 | }
12 |
13 | [JsonProperty("msTeams")]
14 | public MicrosoftTeamsProperties MsTeams { get; set; }
15 | }
16 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/MicrosoftTeamsProperties.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models;
2 |
3 | using Newtonsoft.Json;
4 |
5 | internal class MicrosoftTeamsProperties
6 | {
7 | [JsonProperty("width")]
8 | public string Width { get; set; }
9 |
10 | [JsonProperty("entities")]
11 | public AdaptiveCardMentionedEntity[] Entitities { get; set; }
12 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Models/TeamsNotificationCardWrapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Models;
2 |
3 | using Newtonsoft.Json;
4 |
5 | internal class TeamsNotificationCardWrapper
6 | {
7 | [JsonProperty("type")]
8 | public string Type { get; set; }
9 |
10 | [JsonProperty("attachments")]
11 | public Attachment[] Attachments { get; set; }
12 |
13 | public static TeamsNotificationCardWrapper Wrap(MicrosoftTeamsAdaptiveCard adaptiveCard)
14 | {
15 | return new TeamsNotificationCardWrapper
16 | {
17 | Type = "message",
18 | Attachments =
19 | [
20 | new Attachment
21 | {
22 | ContentType = "application/vnd.microsoft.card.adaptive",
23 | Content = adaptiveCard
24 | }
25 | ],
26 | };
27 | }
28 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Options/MicrosoftTeamsOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Options;
2 |
3 | public class MicrosoftTeamsOptions
4 | {
5 | /**
6 | * Gets or sets the webhook URL to send Teams Cards to.
7 | */
8 | public Uri? WebHookUri { get; set; }
9 |
10 | public MicrosoftTeamsPublishOptions PublishOptions { get; set; } = new();
11 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Options/MicrosoftTeamsPublishOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Options;
2 |
3 | using Enums;
4 |
5 | public class MicrosoftTeamsPublishOptions
6 | {
7 | public bool PublishPullRequestOverviewCard { get; set; } = true;
8 |
9 | public bool PublishPullRequestReviewerLeaderboardCard { get; set; } = true;
10 |
11 | public List CardStatusesToPublish { get; set; } =
12 | [
13 | PullRequestStatus.MergeConflicts,
14 | PullRequestStatus.ReadyToMerge,
15 | PullRequestStatus.FailingChecks,
16 | PullRequestStatus.NeedsReviewing,
17 | PullRequestStatus.Rejected
18 | ];
19 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Options/MicrosoftTeamsStatusPublishOptions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Options;
2 |
3 | using Enums;
4 |
5 | public class MicrosoftTeamsStatusPublishOptions
6 | {
7 | public List StatusesToPublish { get; set; } =
8 | [
9 | PullRequestStatus.MergeConflicts,
10 | PullRequestStatus.ReadyToMerge,
11 | PullRequestStatus.FailingChecks,
12 | PullRequestStatus.NeedsReviewing,
13 | PullRequestStatus.Rejected
14 | ];
15 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/IMicrosoftTeamsWebHookPublisher.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Contracts;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Options;
6 |
7 | public interface IMicrosoftTeamsWebHookPublisher : IPullRequestPlugin
8 | {
9 | Task ExecuteAsync(IReadOnlyList pullRequests, MicrosoftTeamsPublishOptions microsoftTeamsPublishOptions);
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/MicrosoftTeamsWebHookPublisher.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Enums;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Http;
6 | using Mappers;
7 | using Models;
8 | using Options;
9 |
10 | internal class MicrosoftTeamsWebHookPublisher : IMicrosoftTeamsWebHookPublisher
11 | {
12 | private readonly IPullRequestsOverviewCardMapper _pullRequestsOverviewCardMapper;
13 | private readonly IPullRequestStatusCardMapper _pullRequestStatusCardMapper;
14 | private readonly IPullRequestLeaderboardCardMapper _pullRequestLeaderboardCardMapper;
15 | private readonly MicrosoftTeamsOptions _microsoftTeamsOptions;
16 | private readonly MicrosoftTeamsWebhookClient _microsoftTeamsWebhookClient;
17 |
18 | public MicrosoftTeamsWebHookPublisher(
19 | MicrosoftTeamsOptions microsoftTeamsOptions,
20 | MicrosoftTeamsWebhookClient microsoftTeamsWebhookClient,
21 | IPullRequestsOverviewCardMapper pullRequestsOverviewCardMapper,
22 | IPullRequestStatusCardMapper pullRequestStatusCardMapper,
23 | IPullRequestLeaderboardCardMapper pullRequestLeaderboardCardMapper)
24 | {
25 | _microsoftTeamsOptions = microsoftTeamsOptions;
26 | _microsoftTeamsWebhookClient = microsoftTeamsWebhookClient;
27 | _pullRequestsOverviewCardMapper = pullRequestsOverviewCardMapper;
28 | _pullRequestStatusCardMapper = pullRequestStatusCardMapper;
29 | _pullRequestLeaderboardCardMapper = pullRequestLeaderboardCardMapper;
30 | }
31 |
32 | public Task ExecuteAsync(IReadOnlyList pullRequests)
33 | {
34 | return ExecuteAsync(pullRequests, _microsoftTeamsOptions.PublishOptions);
35 | }
36 |
37 | public async Task ExecuteAsync(IReadOnlyList pullRequests, MicrosoftTeamsPublishOptions microsoftTeamsPublishOptions)
38 | {
39 | if (microsoftTeamsPublishOptions.PublishPullRequestOverviewCard)
40 | {
41 | await PublishPullRequestsOverview(pullRequests);
42 | }
43 |
44 | foreach (var pullRequestStatus in microsoftTeamsPublishOptions.CardStatusesToPublish?.ToArray() ?? Array.Empty())
45 | {
46 | await PublishStatusCard(pullRequests, pullRequestStatus);
47 | }
48 |
49 | if (microsoftTeamsPublishOptions.PublishPullRequestReviewerLeaderboardCard)
50 | {
51 | await PublishReviewerLeaderboard(pullRequests);
52 | }
53 | }
54 |
55 | public async Task PublishPullRequestsOverview(IReadOnlyList pullRequests)
56 | {
57 | await Publish(() => _pullRequestsOverviewCardMapper.Map(pullRequests));
58 | }
59 |
60 | public async Task PublishReviewerLeaderboard(IReadOnlyList pullRequests)
61 | {
62 | await Publish(() => _pullRequestLeaderboardCardMapper.Map(pullRequests));
63 | }
64 |
65 | public async Task PublishStatusCard(IReadOnlyList pullRequests, PullRequestStatus pullRequestStatus)
66 | {
67 | await Publish(() => _pullRequestStatusCardMapper.Map(pullRequests, pullRequestStatus));
68 | }
69 |
70 | private async Task Publish(Func> cardGenerator)
71 | {
72 | var cards = cardGenerator();
73 |
74 | foreach (var microsoftTeamsAdaptiveCard in cards)
75 | {
76 | await _microsoftTeamsWebhookClient.CreateTeamsNotification(microsoftTeamsAdaptiveCard);
77 | }
78 | }
79 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/MicrosoftTeamsWebHookPublisherBase.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Contracts;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Http;
6 | using Models;
7 |
8 | public abstract class MicrosoftTeamsWebHookPublisherBase : IPullRequestPlugin
9 | {
10 | private readonly MicrosoftTeamsWebhookClient _microsoftTeamsWebhookClient;
11 |
12 | internal MicrosoftTeamsWebHookPublisherBase(
13 | MicrosoftTeamsWebhookClient microsoftTeamsWebhookClient)
14 | {
15 | _microsoftTeamsWebhookClient = microsoftTeamsWebhookClient;
16 | }
17 |
18 | internal async Task Publish(Func> cardGenerator)
19 | {
20 | var cards = cardGenerator();
21 |
22 | foreach (var microsoftTeamsAdaptiveCard in cards)
23 | {
24 | await _microsoftTeamsWebhookClient.CreateTeamsNotification(microsoftTeamsAdaptiveCard);
25 | }
26 | }
27 |
28 | public abstract Task ExecuteAsync(IReadOnlyList pullRequests);
29 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/PullRequestLeaderboardMicrosoftTeamsWebHookPublisher.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Contracts;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Http;
6 | using Mappers;
7 |
8 | public class PullRequestLeaderboardMicrosoftTeamsWebHookPublisher : MicrosoftTeamsWebHookPublisherBase, IPullRequestPlugin
9 | {
10 | private readonly IPullRequestLeaderboardCardMapper _pullRequestLeaderboardCardMapper;
11 |
12 | internal PullRequestLeaderboardMicrosoftTeamsWebHookPublisher(MicrosoftTeamsWebhookClient microsoftTeamsWebhookClient, IPullRequestLeaderboardCardMapper pullRequestLeaderboardCardMapper)
13 | : base(microsoftTeamsWebhookClient)
14 | {
15 | _pullRequestLeaderboardCardMapper = pullRequestLeaderboardCardMapper;
16 | }
17 |
18 | public override Task ExecuteAsync(IReadOnlyList pullRequests)
19 | {
20 | return Publish(() => _pullRequestLeaderboardCardMapper.Map(pullRequests));
21 | }
22 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/PullRequestOverviewMicrosoftTeamsWebHookPublisher.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Contracts;
4 | using TomLonghurst.PullRequestScanner.Models;
5 | using Http;
6 | using Mappers;
7 |
8 | public class PullRequestOverviewMicrosoftTeamsWebHookPublisher : MicrosoftTeamsWebHookPublisherBase, IPullRequestPlugin
9 | {
10 | private readonly IPullRequestsOverviewCardMapper _pullRequestsOverviewCardMapper;
11 |
12 | internal PullRequestOverviewMicrosoftTeamsWebHookPublisher(MicrosoftTeamsWebhookClient microsoftTeamsWebhookClient, IPullRequestsOverviewCardMapper pullRequestsOverviewCardMapper)
13 | : base(microsoftTeamsWebhookClient)
14 | {
15 | _pullRequestsOverviewCardMapper = pullRequestsOverviewCardMapper;
16 | }
17 |
18 | public override Task ExecuteAsync(IReadOnlyList pullRequests)
19 | {
20 | return Publish(() => _pullRequestsOverviewCardMapper.Map(pullRequests));
21 | }
22 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/Services/PullRequestStatusMicrosoftTeamsWebHookPublisher.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.Services;
2 |
3 | using Contracts;
4 | using Enums;
5 | using TomLonghurst.PullRequestScanner.Models;
6 | using Http;
7 | using Mappers;
8 | using Options;
9 |
10 | public class PullRequestStatusMicrosoftTeamsWebHookPublisher : MicrosoftTeamsWebHookPublisherBase, IPullRequestPlugin
11 | {
12 | private readonly IPullRequestStatusCardMapper _pullRequestStatusCardMapper;
13 | private readonly MicrosoftTeamsStatusPublishOptions _options;
14 |
15 | internal PullRequestStatusMicrosoftTeamsWebHookPublisher(MicrosoftTeamsWebhookClient microsoftTeamsWebhookClient, IPullRequestStatusCardMapper pullRequestStatusCardMapper, MicrosoftTeamsStatusPublishOptions options)
16 | : base(microsoftTeamsWebhookClient)
17 | {
18 | _pullRequestStatusCardMapper = pullRequestStatusCardMapper;
19 | _options = options;
20 | }
21 |
22 | public override async Task ExecuteAsync(IReadOnlyList pullRequests)
23 | {
24 | foreach (var pullRequestStatus in _options.StatusesToPublish)
25 | {
26 | await ExecuteAsync(pullRequests, pullRequestStatus);
27 | }
28 | }
29 |
30 | public Task ExecuteAsync(IReadOnlyList pullRequests, PullRequestStatus pullRequestStatus)
31 | {
32 | return Publish(() => _pullRequestStatusCardMapper.Map(pullRequests, pullRequestStatus));
33 | }
34 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook/TomLonghurst.PullRequestScanner.Plugins.MicrosoftTeams.WebHook.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 | latest
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Constants.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner;
2 |
3 | public static class Constants
4 | {
5 | public const string PullRequestScannerIgnoreTag = "prscanner-ignore";
6 |
7 | public const string VstfsUniqueNamePrefix = "vstfs:///";
8 | public const string VstsDisplayName = "Microsoft.VisualStudio.Services.TFS";
9 |
10 | public static readonly int TeamsCardSizeLimit = 22000;
11 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Contracts/IPullRequestPlugin.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Contracts;
2 |
3 | using Models;
4 |
5 | public interface IPullRequestPlugin
6 | {
7 | Task ExecuteAsync(IReadOnlyList pullRequests);
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Contracts/IPullRequestProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Contracts;
2 |
3 | using Models;
4 |
5 | public interface IPullRequestProvider
6 | {
7 | Task> GetPullRequests();
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Contracts/ITeamMembersProvider.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Contracts;
2 |
3 | using Models;
4 |
5 | public interface ITeamMembersProvider
6 | {
7 | Task> GetTeamMembers();
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Enums/PullRequestStatus.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Enums;
2 |
3 | public enum PullRequestStatus
4 | {
5 | FailingChecks,
6 | OutStandingComments,
7 | NeedsReviewing,
8 | MergeConflicts,
9 | Rejected,
10 | ReadyToMerge,
11 | Completed,
12 | Abandoned,
13 | Draft,
14 | FailedToMerge,
15 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Exceptions/NoPullRequestPluginsRegisteredException.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Exceptions;
2 |
3 | public class NoPullRequestPluginsRegisteredException : PullRequestScannerException
4 | {
5 | public NoPullRequestPluginsRegisteredException()
6 | : base("No Pull Request Plugins have been registered")
7 | {
8 | }
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Exceptions/NoPullRequestProvidersRegisteredException.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Exceptions;
2 |
3 | public class NoPullRequestProvidersRegisteredException : PullRequestScannerException
4 | {
5 | public NoPullRequestProvidersRegisteredException()
6 | : base("No Pull Request Providers have been registered")
7 | {
8 | }
9 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Exceptions/PullRequestScannerException.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Exceptions;
2 |
3 | using System.Runtime.Serialization;
4 |
5 | public class PullRequestScannerException : Exception
6 | {
7 | public PullRequestScannerException()
8 | {
9 | }
10 |
11 | protected PullRequestScannerException(SerializationInfo info, StreamingContext context)
12 | : base(info, context)
13 | {
14 | }
15 |
16 | public PullRequestScannerException(string? message)
17 | : base(message)
18 | {
19 | }
20 |
21 | public PullRequestScannerException(string? message, Exception? innerException)
22 | : base(message, innerException)
23 | {
24 | }
25 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Extensions/DateTimeExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Extensions;
2 |
3 | public static class DateTimeExtensions
4 | {
5 | public static bool IsYesterday(this DateTimeOffset? dateTimeOffset)
6 | {
7 | if (dateTimeOffset is null)
8 | {
9 | return false;
10 | }
11 |
12 | return IsYesterday(dateTimeOffset.Value);
13 | }
14 |
15 | public static bool IsYesterday(this DateTimeOffset dateTimeOffset)
16 | {
17 | var today = DateTimeOffset.UtcNow.Date;
18 | var yesterday = today.AddDays(-1);
19 |
20 | return dateTimeOffset > yesterday && dateTimeOffset < today;
21 | }
22 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Extensions/DependencyInjectionExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Extensions;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | public static class DependencyInjectionExtensions
6 | {
7 | public static PullRequestScannerBuilder AddPullRequestScanner(this IServiceCollection services)
8 | {
9 | return new PullRequestScannerBuilder(services);
10 | }
11 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Extensions/EnumerableExtenions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Extensions;
2 |
3 | using Models;
4 |
5 | public static class EnumerableExtenions
6 | {
7 | public static List> ToMutableGrouping(this IEnumerable> grouping)
8 | {
9 | return grouping
10 | .Select(x => new MutableGroup(x.Key, x.ToList()))
11 | .ToList();
12 | }
13 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Extensions/PullRequestScannerBuilder.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Extensions;
2 |
3 | using Initialization.Microsoft.Extensions.DependencyInjection.Extensions;
4 | using Microsoft.Extensions.DependencyInjection;
5 | using Contracts;
6 | using Services;
7 |
8 | public class PullRequestScannerBuilder
9 | {
10 | public IServiceCollection Services { get; }
11 |
12 | internal PullRequestScannerBuilder(IServiceCollection services)
13 | {
14 | Services = services;
15 |
16 | Services
17 | .AddTransient()
18 | .AddTransient()
19 | .AddSingleton()
20 | .AddTransient();
21 |
22 | Services.AddLogging();
23 |
24 | Services.AddInitializers();
25 |
26 | Services.AddMemoryCache();
27 | }
28 |
29 | public PullRequestScannerBuilder AddPullRequestProvider(Func pullRequestProviderFactory)
30 | {
31 | Services.AddTransient(pullRequestProviderFactory);
32 | return this;
33 | }
34 |
35 | public PullRequestScannerBuilder AddPullRequestProvider()
36 | where TPullRequestProvider : class, IPullRequestProvider
37 | {
38 | Services.AddTransient();
39 | return this;
40 | }
41 |
42 | public PullRequestScannerBuilder AddPlugin(Func pullRequestPluginFactory)
43 | {
44 | Services.AddTransient(pullRequestPluginFactory);
45 | return this;
46 | }
47 |
48 | public PullRequestScannerBuilder AddPlugin()
49 | where TPullRequestPlugin : class, IPullRequestPlugin
50 | {
51 | Services.AddTransient();
52 | return this;
53 | }
54 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Extensions/UriExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Extensions;
2 |
3 | using System.Web;
4 |
5 | public static class UriExtensions
6 | {
7 | public static string AddQueryParam(
8 | this string? source, string key, string value)
9 | {
10 | string delim;
11 | if (source == null || !source.Contains('?'))
12 | {
13 | delim = "?";
14 | }
15 | else if (source.EndsWith("?") || source.EndsWith("&"))
16 | {
17 | delim = string.Empty;
18 | }
19 | else
20 | {
21 | delim = "&";
22 | }
23 |
24 | return source + delim + key + "=" + HttpUtility.UrlEncode(value);
25 | }
26 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/IHasCount.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner;
2 |
3 | public interface IHasCount
4 | {
5 | public int Count { get; init; }
6 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Mappers/PullRequestStatusMapper.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Mappers;
2 |
3 | using Enums;
4 |
5 | public static class PullRequestStatusMapper
6 | {
7 | public static string GetMessage(this PullRequestStatus pullRequestStatus)
8 | {
9 | return pullRequestStatus switch
10 | {
11 | PullRequestStatus.OutStandingComments => "Comments",
12 | PullRequestStatus.NeedsReviewing => "Needs reviewing",
13 | PullRequestStatus.MergeConflicts => "Merge conflicts",
14 | PullRequestStatus.Rejected => "Rejected",
15 | PullRequestStatus.ReadyToMerge => "Ready to merge",
16 | PullRequestStatus.FailingChecks => "Failing checks",
17 | PullRequestStatus.Completed => "Completed",
18 | PullRequestStatus.Abandoned => "Abandoned",
19 | PullRequestStatus.Draft => "Draft",
20 | PullRequestStatus.FailedToMerge => "Failed to merge",
21 | _ => throw new ArgumentOutOfRangeException(nameof(pullRequestStatus), pullRequestStatus, null),
22 | };
23 | }
24 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/Approver.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public record Approver
4 | {
5 | public TeamMember TeamMember { get; set; }
6 |
7 | public bool IsRequired { get; set; }
8 |
9 | public Vote Vote { get; set; }
10 |
11 | public DateTimeOffset? Time { get; set; }
12 |
13 | public PullRequest PullRequest { get; set; }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/Comment.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public record Comment
4 | {
5 | public CommentThread ParentCommentThread { get; set; }
6 |
7 | public TeamMember Author { get; set; }
8 |
9 | public DateTimeOffset LastUpdated { get; set; }
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/CommentThread.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public record CommentThread
4 | {
5 | public PullRequest ParentPullRequest { get; set; }
6 |
7 | public List Comments { get; set; } = new();
8 |
9 | public ThreadStatus Status { get; set; }
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/ITeamMember.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public interface ITeamMember
4 | {
5 | string DisplayName { get; }
6 |
7 | string UniqueName { get; }
8 |
9 | string Id { get; }
10 |
11 | string Email { get; }
12 |
13 | string ImageUrl { get; }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/MutableGroup.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public class MutableGroup
4 | {
5 | public MutableGroup(TKey key, IEnumerable values)
6 | {
7 | Key = key;
8 | Values = values.ToList();
9 | }
10 |
11 | public TKey Key { get; }
12 |
13 | public List Values { get; }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/PullRequest.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | using Enums;
4 |
5 | public record PullRequest
6 | {
7 | public string Title { get; set; }
8 |
9 | public string Description { get; set; }
10 |
11 | public string Url { get; set; }
12 |
13 | public string Id { get; set; }
14 |
15 | public string Number { get; set; }
16 |
17 | public Repository Repository { get; set; }
18 |
19 | public TeamMember Author { get; set; }
20 |
21 | public List CommentThreads { get; set; } = new();
22 |
23 | public List AllComments => CommentThreads.SelectMany(t => t.Comments).ToList();
24 |
25 | public DateTimeOffset Created { get; set; }
26 |
27 | public bool IsActive { get; set; }
28 |
29 | public PullRequestStatus PullRequestStatus { get; set; }
30 |
31 | public bool IsDraft { get; set; }
32 |
33 | public List Approvers { get; set; } = new();
34 |
35 | public string Platform { get; set; }
36 |
37 | public List Labels { get; set; } = new();
38 |
39 | public Vote Vote
40 | {
41 | get
42 | {
43 | if (Approvers?.Any(x => x.Vote == Vote.Rejected) == true)
44 | {
45 | return Vote.Rejected;
46 | }
47 |
48 | if (Approvers?.Any(x => x.Vote != Vote.Approved && x.IsRequired) == true)
49 | {
50 | return Vote.NoVote;
51 | }
52 |
53 | if (Approvers?.Any(x => x.Vote == Vote.Approved) == true)
54 | {
55 | return Vote.Approved;
56 | }
57 |
58 | return Vote.NoVote;
59 | }
60 | }
61 |
62 | public List UniqueReviewers
63 | {
64 | get
65 | {
66 | return Approvers
67 | .Where(x => x.Vote != Vote.NoVote)
68 | .Select(a => a.TeamMember)
69 | .Concat(AllComments.Select(c => c.Author))
70 | .Where(reviewer => reviewer.DisplayName != Constants.VstsDisplayName)
71 | .Where(reviewer => reviewer.UniqueNames.All(un => un.StartsWith(Constants.VstfsUniqueNamePrefix) != true))
72 | .Where(reviewer => reviewer != Author)
73 | .Distinct()
74 | .ToList();
75 | }
76 | }
77 |
78 | public int GetCommentCountWhere(TeamMember teamMember, Func condition)
79 | {
80 | if (teamMember == Author)
81 | {
82 | // We don't count comments on your own PR!
83 | return 0;
84 | }
85 |
86 | return CommentThreads
87 | .SelectMany(c => c.Comments)
88 | .Where(c => condition?.Invoke(c) ?? true)
89 | .Count(c => c.Author == teamMember);
90 | }
91 |
92 | public bool HasVotedWhere(TeamMember teamMember, Func condition)
93 | {
94 | if (teamMember == Author)
95 | {
96 | // You can't vote for your own PR
97 | return false;
98 | }
99 |
100 | return Approvers
101 | .Where(a => a.TeamMember == teamMember)
102 | .Where(a => condition?.Invoke(a) ?? true)
103 | .Any(x => x.Vote != Vote.NoVote);
104 | }
105 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/PullRequestReviewLeaderboardModel.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public class PullRequestReviewLeaderboardModel
4 | {
5 | public int CommentsCount { get; set; }
6 |
7 | public int ReviewedCount { get; set; }
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/Repository.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public record Repository
4 | {
5 | public string Name { get; set; }
6 |
7 | public string Id { get; set; }
8 |
9 | public string Url { get; set; }
10 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/TeamMember.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public class TeamMember : IEquatable
4 | {
5 | public string? DisplayName { get; set; }
6 |
7 | public List UniqueNames { get; } = new();
8 |
9 | public string? Email { get; set; }
10 |
11 | public List Ids { get; } = new();
12 |
13 | public List ImageUrls { get; } = new();
14 |
15 | public string DisplayOrUniqueName
16 | {
17 | get
18 | {
19 | if (!string.IsNullOrWhiteSpace(DisplayName))
20 | {
21 | return DisplayName;
22 | }
23 |
24 | var userName = UniqueNames.FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
25 |
26 | if (userName != null)
27 | {
28 | return userName;
29 | }
30 |
31 | return Email;
32 | }
33 | }
34 |
35 | public string UniqueIdentifier
36 | {
37 | get
38 | {
39 | var uniqueName = UniqueNames?.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
40 |
41 | if (!string.IsNullOrWhiteSpace(uniqueName))
42 | {
43 | return uniqueName;
44 | }
45 |
46 | if (!string.IsNullOrWhiteSpace(Email))
47 | {
48 | return Email;
49 | }
50 |
51 | var id = Ids?.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
52 |
53 | if (!string.IsNullOrWhiteSpace(id))
54 | {
55 | return id;
56 | }
57 |
58 | return DisplayName;
59 | }
60 | }
61 |
62 | public bool Equals(TeamMember? other)
63 | {
64 | if (other is null)
65 | {
66 | return false;
67 | }
68 |
69 | return UniqueIdentifier == other.UniqueIdentifier;
70 | }
71 |
72 | public override bool Equals(object? obj)
73 | {
74 | return Equals(obj as TeamMember);
75 | }
76 |
77 | public override int GetHashCode()
78 | {
79 | return UniqueIdentifier.GetHashCode();
80 | }
81 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/TeamMemberImpl.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public class TeamMemberImpl : ITeamMember
4 | {
5 | public string DisplayName { get; set; }
6 |
7 | public string UniqueName { get; set; }
8 |
9 | public string Id { get; set; }
10 |
11 | public string Email { get; set; }
12 |
13 | public string ImageUrl { get; set; }
14 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/ThreadStatus.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public enum ThreadStatus
4 | {
5 | Active,
6 | Closed,
7 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Models/Vote.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Models;
2 |
3 | public enum Vote
4 | {
5 | Approved,
6 | NoVote,
7 | Rejected,
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/IHasPlugins.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Contracts;
4 |
5 | public interface IHasPlugins
6 | {
7 | IEnumerable Plugins { get; }
8 |
9 | TPlugin GetPlugin()
10 | where TPlugin : IPullRequestPlugin;
11 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/IPluginService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Contracts;
4 | using Models;
5 |
6 | internal interface IPluginService : IHasPlugins
7 | {
8 | Task ExecuteAsync(IReadOnlyList pullRequests) => ExecuteAsync(pullRequests, null);
9 |
10 | Task ExecuteAsync(IReadOnlyList pullRequests, Func? predicate);
11 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/IPullRequestScanner.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Contracts;
4 | using Models;
5 |
6 | public interface IPullRequestScanner : IHasPlugins
7 | {
8 | Task> GetPullRequests();
9 |
10 | Task ExecutePluginsAsync() => ExecutePluginsAsync(null as Func);
11 |
12 | Task ExecutePluginsAsync(IReadOnlyList pullRequests) => ExecutePluginsAsync(pullRequests, null);
13 |
14 | Task ExecutePluginsAsync(Func? predicate);
15 |
16 | Task ExecutePluginsAsync(IReadOnlyList pullRequests, Func? predicate);
17 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/IPullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Models;
4 |
5 | internal interface IPullRequestService
6 | {
7 | Task> GetPullRequests();
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/ITeamMembersService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Models;
4 |
5 | public interface ITeamMembersService
6 | {
7 | TeamMember? FindTeamMember(string uniqueName, string id);
8 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/PluginService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using EnumerableAsyncProcessor.Extensions;
4 | using Contracts;
5 | using Exceptions;
6 | using Models;
7 |
8 | internal class PluginService : IPluginService
9 | {
10 | public IEnumerable Plugins { get; }
11 |
12 | private readonly Func _defaultPredicate = _ => true;
13 |
14 | public PluginService(IEnumerable plugins)
15 | {
16 | Plugins = plugins;
17 | }
18 |
19 | public async Task ExecuteAsync(IReadOnlyList pullRequests, Func? predicate = null)
20 | {
21 | if (!Plugins.Any())
22 | {
23 | throw new NoPullRequestPluginsRegisteredException();
24 | }
25 |
26 | await Plugins
27 | .Where(predicate ?? _defaultPredicate)
28 | .ToAsyncProcessorBuilder()
29 | .ForEachAsync(plugin => plugin.ExecuteAsync(pullRequests))
30 | .ProcessInParallel();
31 | }
32 |
33 | public TPlugin GetPlugin()
34 | where TPlugin : IPullRequestPlugin
35 | {
36 | return Plugins.OfType().Single();
37 | }
38 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/PullRequestScanner.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using Contracts;
4 | using Models;
5 |
6 | internal class PullRequestScanner : IPullRequestScanner
7 | {
8 | private readonly IPullRequestService _pullRequestService;
9 | private readonly IPluginService _pluginService;
10 |
11 | public IEnumerable Plugins => _pluginService.Plugins;
12 |
13 | public TPlugin GetPlugin()
14 | where TPlugin : IPullRequestPlugin
15 | => _pluginService.GetPlugin();
16 |
17 | public PullRequestScanner(IPullRequestService pullRequestService, IPluginService pluginService)
18 | {
19 | _pullRequestService = pullRequestService;
20 | _pluginService = pluginService;
21 | }
22 |
23 | public Task> GetPullRequests()
24 | {
25 | return _pullRequestService.GetPullRequests();
26 | }
27 |
28 | public async Task ExecutePluginsAsync(Func? predicate)
29 | {
30 | var pullRequests = await GetPullRequests();
31 |
32 | await ExecutePluginsAsync(pullRequests, predicate);
33 | }
34 |
35 | public Task ExecutePluginsAsync(IReadOnlyList pullRequests, Func? predicate)
36 | {
37 | if (pullRequests == null)
38 | {
39 | throw new ArgumentNullException(nameof(pullRequests));
40 | }
41 |
42 | return _pluginService.ExecuteAsync(pullRequests, predicate);
43 | }
44 | }
--------------------------------------------------------------------------------
/TomLonghurst.PullRequestScanner/Services/PullRequestService.cs:
--------------------------------------------------------------------------------
1 | namespace TomLonghurst.PullRequestScanner.Services;
2 |
3 | using System.Collections.Immutable;
4 | using Initialization.Microsoft.Extensions.DependencyInjection.Extensions;
5 | using Microsoft.Extensions.Caching.Memory;
6 | using Contracts;
7 | using Exceptions;
8 | using Models;
9 |
10 | internal class PullRequestService : IPullRequestService
11 | {
12 | private const string PullRequestsCacheKey = "PullRequests";
13 |
14 | private readonly IEnumerable