Skip to content

Commit 0b31218

Browse files
committed
[ci] Add cleanupIssues to CI scripts.
1 parent c687848 commit 0b31218

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed

CI/cleanupIssues.graphql

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
query(
2+
$owner: String!
3+
$repo: String!
4+
$author: String
5+
$first: Int = 100 # GitHub limit
6+
$after: String = null
7+
) {
8+
repository(owner: $owner, name: $repo) {
9+
issues(
10+
states: CLOSED
11+
orderBy: {field: CREATED_AT, direction: ASC}
12+
filterBy: {createdBy: $author}
13+
first: $first
14+
after: $after
15+
) {
16+
totalCount
17+
pageInfo { hasNextPage, endCursor }
18+
edges {
19+
node {
20+
id
21+
number
22+
title
23+
state
24+
stateReason
25+
author { login, __typename }
26+
timelineItems(first: 20) {
27+
totalCount
28+
edges {
29+
node {
30+
__typename
31+
... on ClosedEvent {
32+
closer { __typename }
33+
}
34+
}
35+
}
36+
}
37+
}
38+
}
39+
}
40+
}
41+
}

CI/cleanupIssues.ps1

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Copyright (c) 2024 Matthias Wolf, Mawosoft.
2+
3+
<#
4+
.SYNOPSIS
5+
Delete obsolete bot-created issues.
6+
.DESCRIPTION
7+
Deletes obsolete issues created by a specified author or any bot.
8+
.OUTPUTS
9+
A single result object with properties Issues, Errors, and RateLimit.
10+
.NOTES
11+
An issue is considered obsolete and therefore deletable if:
12+
- The issue has been created by the specified author, or by any bot if no author is specified.
13+
- The issue has been closed as 'not planned', or -AnyClosed has been specified.
14+
- The issue doesn't have any comments and is not referenced anywhere.
15+
#>
16+
17+
#Requires -Version 7.4
18+
19+
using namespace System
20+
using namespace System.Collections
21+
using namespace System.Collections.Generic
22+
using namespace System.Text
23+
24+
[CmdletBinding(PositionalBinding = $false, SupportsShouldProcess = $true)]
25+
param(
26+
27+
# The name of the repository to process.
28+
[Parameter(Mandatory, Position = 0)]
29+
[ValidateNotNullOrEmpty()]
30+
[string]$Repo,
31+
32+
# The owner of the repository to process (default: 'mawosoft').
33+
[ValidateNotNullOrEmpty()]
34+
[string]$Owner = 'mawosoft',
35+
36+
# Pre-filter for issues created by the specified author (default: 'github-actions[bot]').
37+
# If set to null or empty, any issue created by a bot is a candidate (post-filter).
38+
[string]$Author = 'github-actions[bot]',
39+
40+
# Any closed issue is a candidate, not just those closed as 'not planned'.
41+
[switch]$AnyClosed,
42+
43+
# The GitHub token to use for authentication.
44+
[Parameter(Mandatory)]
45+
[ValidateNotNullOrEmpty()]
46+
[securestring]$Token
47+
)
48+
49+
$query = Get-Content -LiteralPath "$PSScriptRoot/cleanupIssues.graphql" -Raw
50+
$requestBody = [PSCustomObject]@{
51+
operationName = $null
52+
query = $query
53+
variables = @{
54+
owner = $Owner
55+
repo = $Repo
56+
author = $Author ? $Author : $null
57+
first = 100 # GitHub limit
58+
after = $null
59+
}
60+
}
61+
62+
$params = @{
63+
Uri = 'https://api.github.com/graphql'
64+
Authentication = 'Bearer'
65+
Token = $Token
66+
Method = 'Post'
67+
Body = $null
68+
ContentType = 'application/json'
69+
}
70+
71+
[HashSet[string]]$nonDeletableEvents = @(
72+
'ReferencedEvent'
73+
'CrossReferencedEvent'
74+
'IssueComment'
75+
)
76+
77+
$resultObject = [PSCustomObject]@{
78+
Issues = [ArrayList]::new()
79+
Errors = [ArrayList]::new()
80+
RateLimit = [PSCustomObject]@{
81+
Remaining = $null
82+
Reset = $null
83+
}
84+
}
85+
86+
function InvokeWebRequest {
87+
param([hashtable]$params)
88+
$response = $null
89+
$result = $null
90+
try {
91+
if ($resultObject.RateLimit.Remaining -eq 0) {
92+
throw 'Rate limit reached.'
93+
}
94+
$response = Invoke-WebRequest @params -SkipHttpErrorCheck -ProgressAction SilentlyContinue
95+
try {
96+
$remaining = $response.Headers['X-RateLimit-Remaining']?[0]
97+
if ($null -ne $remaining) {
98+
$resultObject.RateLimit.Remaining = [int]$remaining
99+
}
100+
$reset = $response.Headers['X-RateLimit-Reset']?[0]
101+
if ($null -ne $reset) {
102+
$resultObject.RateLimit.Reset = [datetime]::new(
103+
[long]$reset * [timespan]::TicksPerSecond + [datetime]::UnixEpoch.Ticks,
104+
[DateTimeKind]::Utc).ToLocalTime()
105+
}
106+
}
107+
catch {}
108+
$result = $response.Content | ConvertFrom-Json -Depth 64 -AsHashtable -NoEnumerate
109+
}
110+
catch {
111+
$result = @{
112+
errors = @(@{
113+
message = "$_"
114+
ResponseRawContent = ${response}?.RawContent
115+
})
116+
}
117+
}
118+
return $result
119+
}
120+
121+
try {
122+
$pageInfo = @{
123+
hasNextPage = $true
124+
endCursor = $null
125+
}
126+
127+
while ($pageInfo.hasNextPage) {
128+
$requestBody.variables.after = $pageInfo.endCursor
129+
$params.Body = $requestBody | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii
130+
$result = InvokeWebRequest $params
131+
if (${result}?['errors']) {
132+
$resultObject.Errors.AddRange($result['errors'])
133+
}
134+
if (-not ${result}?['data']) {
135+
break
136+
}
137+
$issues = $result['data']?['repository']?['issues']
138+
$pageInfo = $issues['pageInfo']
139+
foreach ($node in $issues['edges']) {
140+
$issue = $node['node']
141+
[bool]$canDelete = $true
142+
if (-not $Author -and $issue['author']['__typename'] -cne 'Bot') {
143+
$canDelete = $false
144+
}
145+
elseif (-not $AnyClosed -and $issue['stateReason'] -cne 'NOT_PLANNED') {
146+
$canDelete = $false
147+
}
148+
elseif ($issue['timelineItems']['totalCount'] -gt $issue['timelineItems']['edges'].Length) {
149+
$canDelete = $false
150+
}
151+
else {
152+
foreach ($item in $issue['timelineItems']['edges']) {
153+
$typeName = $item['node']['__typename']
154+
if ($typeName -ceq 'ClosedEvent') {
155+
if ($null -ne $item['node']['closer']) {
156+
# Closed by a commit or PR.
157+
$canDelete = $false
158+
break
159+
}
160+
}
161+
elseif ($nonDeletableEvents.Contains($typeName)) {
162+
$canDelete = $false
163+
break
164+
}
165+
}
166+
}
167+
if ($canDelete) {
168+
$null = $resultObject.Issues.Add([PSCustomObject]@{
169+
Id = $issue['id']
170+
Number = $issue['number']
171+
Title = $issue['title']
172+
State = $issue['state'] + ':' + $issue['stateReason']
173+
Author = $issue['author']['login']
174+
Timeline = $issue['timelineItems']['edges'].ForEach({ $_['node']['__typename'] }) -join ','
175+
DeleteStatus = 'pending'
176+
})
177+
}
178+
}
179+
}
180+
181+
if ($resultObject.Errors.Count -eq 0 -and $resultObject.Issues.Count -ne 0 -and
182+
$PSCmdlet.ShouldProcess("$Owner/$Repo", "Delete $($resultObject.Issues.Count) issues")) {
183+
# deleteIssue mutation may time out if there are too many in a single request.
184+
[int]$chunkSize = 10
185+
$sb = [StringBuilder]::new()
186+
$nodeVariables = [ArrayList]::new($chunkSize)
187+
for ([int]$issueIndex = 0; $issueIndex -lt $resultObject.Issues.Count; $issueIndex += $chunkSize) {
188+
[int]$chunkEnd = $resultObject.Issues.Count
189+
if (($chunkEnd - $issueIndex) -gt $chunkSize) { $chunkEnd = $issueIndex + $chunkSize }
190+
$nodeVariables.Clear()
191+
$null = $sb.Clear().AppendLine('mutation {')
192+
for ([int]$i = $issueIndex; $i -lt $chunkEnd; $i++) {
193+
$issue = $resultObject.Issues[$i]
194+
$issue.DeleteStatus = 'sent'
195+
$null = $sb.Append(' d').Append($i).Append(': deleteIssue(input: {issueId: "')
196+
$null = $sb.Append($issue.Id).AppendLine('"}) { clientMutationId }')
197+
$null = $nodeVariables.Add($issue.Id)
198+
}
199+
$null = $sb.AppendLine('}')
200+
$requestBody.variables = $null
201+
$requestBody.query = $sb.ToString()
202+
$params.Body = $requestBody | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii
203+
$result = InvokeWebRequest $params
204+
if (${result}?['errors']) {
205+
$resultObject.Errors.AddRange($result['errors'])
206+
}
207+
# GitHub may silently fail to delete issues (even via the Web interface).
208+
# Verify deletion by querying the issue nodes by id.
209+
$requestBody.variables = @{ ids = $nodeVariables }
210+
$requestBody.query = @'
211+
query ($ids: [ID!]!) {
212+
nodes(ids: $ids) { id, __typename }
213+
}
214+
'@
215+
$params.Body = $requestBody | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii
216+
$result = InvokeWebRequest $params
217+
$nodes = ${result}?['data']?['nodes']
218+
if (${nodes}?.Count -ne $nodeVariables.Count) {
219+
if (${result}?['errors']) {
220+
$resultObject.Errors.AddRange($result['errors'])
221+
}
222+
$null = $resultObject.Errors.Add(@{
223+
message = 'Querying deleted nodes: ' + $nodeVariables -join ','
224+
ResponseData = ${result}?['data']
225+
})
226+
}
227+
for ([int]$i = 0; $i -lt $nodeVariables.Count; $i++) {
228+
if ($null -eq $nodes[$i]) {
229+
$resultObject.Issues[$i + $issueIndex].DeleteStatus = 'deleted'
230+
}
231+
}
232+
}
233+
}
234+
}
235+
catch {
236+
$null = $resultObject.Errors.Add(@{message = "$_" })
237+
}
238+
239+
$resultObject

0 commit comments

Comments
 (0)