|
1 | | -#!python |
2 | | - |
3 | | -# DEPENDENCIES |
4 | | -# python3 -m pip install python-graphql-client |
5 | | - |
6 | | -#Ref : Action in python https://www.python-engineer.com/posts/run-python-github-actions/ |
7 | | - |
8 | | -# source: https://github.com/sofa-framework/sofa/blob/master/scripts/comment-close-old-discussions.py |
9 | | - |
10 | | - |
11 | 1 | import os |
12 | | -from datetime import datetime, timedelta, date |
13 | | -from python_graphql_client import GraphqlClient |
14 | | -from dateutil.relativedelta import relativedelta |
15 | | - |
16 | | - |
17 | | -client = GraphqlClient(endpoint="https://api.github.com/graphql") |
18 | | -github_token = os.environ['GITHUB_TOKEN'] |
19 | | - |
20 | | - |
21 | | -# List of the repository to scan |
22 | | -repos=[['XRPLF', 'XRPL-Standards']] |
23 | | - |
24 | | - |
25 | | -# Format the reference date (with which the last reply will be compared) |
26 | | -# Today |
27 | | -date_today = date.today() |
28 | | -# warning delay = 2-month delay for warning |
29 | | -delay_warning = relativedelta(days = 90) |
30 | | -date_reference_warning = date_today - delay_warning |
31 | | -# closing delay = 2+2.5-month delay for closing |
32 | | -delay_closing = relativedelta(days = 90) + relativedelta(days = 30) |
33 | | -date_reference_closing = date_today - delay_closing |
34 | | - |
35 | | -# Check if the "createdAt" is older than the "date_reference" |
36 | | -def isOlderThan(date_reference, createdAt): |
37 | | - # Format date of creation YYYY-MM-DD |
38 | | - creation_date = createdAt[:-10] |
39 | | - creation_date = datetime.strptime(creation_date, '%Y-%m-%d') |
40 | | - |
41 | | - if creation_date.date() > date_reference: |
42 | | - return False |
43 | | - else : |
44 | | - return True |
45 | | - |
46 | | -# Returns true of the date "createdAt" is more than the warning delay |
47 | | -def isToBeWarned(createdAt): |
48 | | - return isOlderThan(date_reference_warning, createdAt) |
49 | | - |
50 | | - |
51 | | -# Returns true of the date "createdAt" is more than the closing delay |
52 | | -def isToBeClosed(createdAt): |
53 | | - return isOlderThan(date_reference_closing, createdAt) |
54 | | - |
55 | | - |
56 | | -def computeListOfDiscussionToProcess(): |
57 | | - for repo in repos: |
58 | | - |
59 | | - owner = repo[0] |
60 | | - name = repo[1] |
61 | | - |
62 | | - has_next_page = True |
63 | | - after_cursor = None |
64 | | - |
65 | | - to_be_warned_discussion_number = [] |
66 | | - to_be_warned_discussion_id = [] |
67 | | - to_be_warned_discussion_author = [] |
68 | | - |
69 | | - to_be_closed_discussion_number = [] |
70 | | - to_be_closed_discussion_id = [] |
71 | | - to_be_closed_discussion_author = [] |
72 | | - |
73 | | - while has_next_page: |
74 | | - # Trigger the query on discussions |
75 | | - data = client.execute( |
76 | | - query = make_query_discussions(owner, name, after_cursor), |
77 | | - headers = {"Authorization": "Bearer {}".format(github_token)}, |
78 | | - ) |
79 | | - |
80 | | - # Process each discussion |
81 | | - for discussion in data["data"]["repository"]["discussions"]["nodes"]: |
82 | | - |
83 | | - # Save original author of the discussion |
84 | | - discussionAuthor = discussion["author"]["login"] |
85 | | - |
86 | | - # Detect the last comment |
87 | | - lastCommentId = len(discussion["comments"]["nodes"]) - 1 |
88 | | - |
89 | | - # Pass to the next discussion item if : |
90 | | - # no comment in the discussion OR discussion is answered OR closed |
91 | | - if(lastCommentId < 0 or discussion["closed"] == True or discussion["isAnswered"] == True ): |
| 2 | +import requests |
| 3 | +from datetime import datetime, timedelta |
| 4 | + |
| 5 | +GITHUB_TOKEN = os.environ['GITHUB_TOKEN'] |
| 6 | +REPO = os.environ['GITHUB_REPOSITORY'] |
| 7 | +API_URL = f"https://api.github.com/repos/{REPO}/discussions" |
| 8 | +HEADERS = { |
| 9 | + "Authorization": f"token {GITHUB_TOKEN}", |
| 10 | + "Accept": "application/vnd.github.v3+json" |
| 11 | +} |
| 12 | + |
| 13 | +STALE_LABEL = "Stale" |
| 14 | +STALE_COMMENT = "This discussion has been marked as stale due to inactivity for 90 days. If there is no further activity, it will be closed in 14 days." |
| 15 | + |
| 16 | +def get_discussions(): |
| 17 | + discussions = [] |
| 18 | + page = 1 |
| 19 | + while True: |
| 20 | + resp = requests.get(f"{API_URL}?per_page=100&page={page}", headers=HEADERS) |
| 21 | + if resp.status_code != 200: |
| 22 | + break |
| 23 | + data = resp.json() |
| 24 | + if not data: |
| 25 | + break |
| 26 | + discussions.extend(data) |
| 27 | + page += 1 |
| 28 | + return discussions |
| 29 | + |
| 30 | +def get_comments(discussion_number): |
| 31 | + url = f"{API_URL}/{discussion_number}/comments" |
| 32 | + resp = requests.get(url, headers=HEADERS) |
| 33 | + if resp.status_code != 200: |
| 34 | + return [] |
| 35 | + return resp.json() |
| 36 | + |
| 37 | +def add_label(discussion_number, label): |
| 38 | + print(f"Adding label '{label}' to discussion #{discussion_number}") |
| 39 | + # url = f"{API_URL}/{discussion_number}/labels" |
| 40 | + # requests.post(url, headers=HEADERS, json={"labels": [label]}) |
| 41 | + |
| 42 | +def post_comment(discussion_number, body): |
| 43 | + print(f"Posting comment to discussion #{discussion_number}") |
| 44 | + # url = f"{API_URL}/{discussion_number}/comments" |
| 45 | + # requests.post(url, headers=HEADERS, json={"body": body}) |
| 46 | + |
| 47 | +def close_and_lock(discussion_number): |
| 48 | + print(f"Closing and locking discussion #{discussion_number}") |
| 49 | + # url = f"{API_URL}/{discussion_number}" |
| 50 | + # requests.patch(url, headers=HEADERS, json={"state": "closed", "locked": True}) |
| 51 | + |
| 52 | +def main(): |
| 53 | + now = datetime.utcnow() |
| 54 | + discussions = get_discussions() |
| 55 | + for d in discussions: |
| 56 | + number = d['number'] |
| 57 | + labels = [l['name'] for l in d.get('labels', [])] |
| 58 | + last_updated = datetime.strptime(d['updated_at'], "%Y-%m-%dT%H:%M:%SZ") |
| 59 | + comments = get_comments(number) |
| 60 | + stale_comment = next((c for c in comments if STALE_COMMENT in c['body']), None) |
| 61 | + |
| 62 | + # Mark as stale after 90 days |
| 63 | + if (now - last_updated).days >= 90 and STALE_LABEL not in labels: |
| 64 | + add_label(number, STALE_LABEL) |
| 65 | + post_comment(number, STALE_COMMENT) |
92 | 66 | continue |
93 | 67 |
|
94 | | - lastReplyOnLastComment = len(discussion["comments"]["nodes"][lastCommentId]["replies"]["nodes"]) - 1 |
95 | | - |
96 | | - # No replies on the last comment |
97 | | - if(lastReplyOnLastComment < 0): |
98 | | - author = discussion["comments"]["nodes"][lastCommentId]["author"]["login"] |
99 | | - dateLastMessage = discussion["comments"]["nodes"][lastCommentId]["createdAt"] |
100 | | - # Select the last reply of the last comment |
101 | | - else: |
102 | | - author = discussion["comments"]["nodes"][lastCommentId]["replies"]["nodes"][lastReplyOnLastComment]["author"]["login"] |
103 | | - dateLastMessage = discussion["comments"]["nodes"][lastCommentId]["replies"]["nodes"][lastReplyOnLastComment]["createdAt"] |
104 | | - |
105 | | - authorAsList = [author] |
106 | | - |
107 | | - if isToBeClosed(dateLastMessage) == True: |
108 | | - to_be_closed_discussion_number.append(discussion["number"]) |
109 | | - to_be_closed_discussion_id.append(discussion["id"]) |
110 | | - to_be_closed_discussion_author.append(discussionAuthor) |
111 | | - elif isToBeWarned(dateLastMessage) == True and author != "github-actions": |
112 | | - to_be_warned_discussion_number.append(discussion["number"]) |
113 | | - to_be_warned_discussion_id.append(discussion["id"]) |
114 | | - to_be_warned_discussion_author.append(discussionAuthor) |
115 | | - |
116 | | - |
117 | | - # save if request has another page to browse and its cursor pointers |
118 | | - has_next_page = data["data"]["repository"]["discussions"]["pageInfo"]["hasNextPage"] |
119 | | - after_cursor = data["data"]["repository"]["discussions"]["pageInfo"]["endCursor"] |
120 | | - return [to_be_warned_discussion_number,to_be_warned_discussion_id,to_be_warned_discussion_author,to_be_closed_discussion_number,to_be_closed_discussion_id,to_be_closed_discussion_author] |
121 | | - |
122 | | - |
123 | | -# Query to access all discussions |
124 | | -def make_query_discussions(owner, name, after_cursor=None): |
125 | | - query = """ |
126 | | - query { |
127 | | - repository(owner: "%s" name: "%s") { |
128 | | - discussions(answered: false, first: 10, after:AFTER) { |
129 | | - totalCount |
130 | | - pageInfo { |
131 | | - hasNextPage |
132 | | - endCursor |
133 | | - } |
134 | | - nodes { |
135 | | - id |
136 | | - number |
137 | | - isAnswered |
138 | | - closed |
139 | | - author { |
140 | | - login |
141 | | - } |
142 | | - comments (first: 100) { |
143 | | - nodes { |
144 | | - createdAt |
145 | | - author { |
146 | | - login |
147 | | - } |
148 | | - replies (first: 100) { |
149 | | - nodes { |
150 | | - createdAt |
151 | | - author { |
152 | | - login |
153 | | - } |
154 | | - } |
155 | | - } |
156 | | - } |
157 | | - } |
158 | | - } |
159 | | - } |
160 | | - } |
161 | | - }""" % (owner, name) |
162 | | - return query.replace("AFTER", '"{}"'.format(after_cursor) if after_cursor else "null") |
163 | | - |
164 | | - |
165 | | - |
166 | | - |
167 | | -def make_github_warning_comment(discussion_id, discussion_author): |
168 | | - message = ":warning: :warning: :warning:<br>@"+str(discussion_author)+"<br>Feedback has been given to you by the project reviewers, however we have not received a response from you. Without further news in the coming weeks, this discussion will be automatically closed in order to keep this forum clean and fresh :seedling: Thank you for your understanding" |
169 | | - |
170 | | - query = """ |
171 | | - mutation { |
172 | | - addDiscussionComment(input: {body: "%s", discussionId: "%s"}) { |
173 | | - comment { |
174 | | - id |
175 | | - } |
176 | | - } |
177 | | - } |
178 | | -""" % (message, discussion_id) |
179 | | - return query |
180 | | - |
181 | | - |
182 | | - |
183 | | -def make_github_closing_comment(discussion_id, discussion_author): |
184 | | - message = ":warning: :warning: :warning:<br>@"+str(discussion_author)+"<br>In accordance with our forum management policy, the last reply is more than 4 months old and is therefore closed. Our objective is to keep the forum up to date and offer the best support experience.<br><br>Please feel free to reopen it if the topic is still active by providing us with an update. Please feel free to open a new thread at any time - we'll be happy to help where and when we can." |
185 | | - |
186 | | - query = """ |
187 | | - mutation { |
188 | | - addDiscussionComment(input: {body: "%s", discussionId: "%s"}) { |
189 | | - comment { |
190 | | - id |
191 | | - } |
192 | | - } |
193 | | - } |
194 | | -""" % (message, discussion_id) |
195 | | - return query |
196 | | - |
197 | | - |
198 | | - |
199 | | -def close_github_discussion(discussion_id): |
200 | | - |
201 | | - query = """ |
202 | | - mutation { |
203 | | - closeDiscussion(input: {discussionId: "%s"}) { |
204 | | - discussion { |
205 | | - id |
206 | | - } |
207 | | - } |
208 | | - } |
209 | | -""" % discussion_id |
210 | | - return query |
211 | | - |
212 | | - |
213 | | - |
214 | | - |
215 | | - |
216 | | - |
217 | | -#========================================================== |
218 | | -# STEPS computed by the script |
219 | | -#========================================================== |
220 | | -# 1 - get the discussion to be warned and closed |
221 | | -result = computeListOfDiscussionToProcess() |
222 | | -to_be_warned_discussion_number = result[0] |
223 | | -to_be_warned_discussion_id = result[1] |
224 | | -to_be_warned_discussion_author = result[2] |
225 | | -to_be_closed_discussion_number = result[3] |
226 | | -to_be_closed_discussion_id = result[4] |
227 | | -to_be_closed_discussion_author = result[5] |
228 | | -#========================================================== |
229 | | -# 2- do it using github API |
230 | | -if(len(to_be_warned_discussion_id)!=len(to_be_warned_discussion_author)): |
231 | | - print('Error: size of both vectors number/author for discussions to be warned is different') |
232 | | - exit(1) |
233 | | -if(len(to_be_closed_discussion_id)!=len(to_be_closed_discussion_author)): |
234 | | - print('Error: size of both vectors number/author for discussions to be closed is different') |
235 | | - exit(1) |
236 | | - |
237 | | -print("** Output lists **") |
238 | | -print("******************") |
239 | | -print("Nb discussions to be WARNED = "+str(len(to_be_warned_discussion_number))) |
240 | | -print("Nb discussions to be CLOSED = "+str(len(to_be_closed_discussion_number))) |
241 | | -print("******************") |
242 | | -print("to_be_warned_discussion_number = "+str(to_be_warned_discussion_number)) |
243 | | -print("to_be_warned_discussion_id = "+str(to_be_warned_discussion_id)) |
244 | | -print("to_be_warned_discussion_author = "+str(to_be_warned_discussion_author)) |
245 | | -print("******************") |
246 | | -print("to_be_closed_discussion_number = "+str(to_be_closed_discussion_number)) |
247 | | -print("to_be_closed_discussion_id = "+str(to_be_closed_discussion_id)) |
248 | | -print("to_be_closed_discussion_author = "+str(to_be_closed_discussion_author)) |
249 | | -print("******************") |
250 | | -print("******************") |
251 | | - |
252 | | -#========================================================== |
253 | | -# WARNING step |
254 | | -print("** WARNING step **") |
255 | | - |
256 | | -for index, discussion_id in enumerate(to_be_warned_discussion_id): |
257 | | - print("to_be_warned_discussion_number[index] = "+str(to_be_warned_discussion_number[index])) |
258 | | - print("to_be_warned_discussion_author[index] = "+str(to_be_warned_discussion_author[index])) |
259 | | - print("discussion_id = "+str(discussion_id)) |
260 | | - # Warning comment |
261 | | - data = client.execute( |
262 | | - query = make_github_warning_comment( discussion_id, to_be_warned_discussion_author[index] ), |
263 | | - headers = {"Authorization": "Bearer {}".format(github_token)}, |
264 | | - ) |
265 | | - print(data) |
266 | | -print("******************") |
267 | | -print("******************") |
268 | | - |
269 | | -#========================================================== |
270 | | -# CLOSING step |
271 | | -print("** CLOSING step **") |
272 | | - |
273 | | -for index, discussion_id in enumerate(to_be_closed_discussion_id): |
274 | | - print("to_be_closed_discussion_number[index] = "+str(to_be_closed_discussion_number[index])) |
275 | | - print("to_be_closed_discussion_author[index] = "+str(to_be_closed_discussion_author[index])) |
276 | | - print("discussion_id = "+str(discussion_id)) |
277 | | - |
278 | | - continue # dummy |
279 | | - # Closing comment |
280 | | - data = client.execute( |
281 | | - query = make_github_closing_comment( discussion_id, to_be_closed_discussion_author[index] ), |
282 | | - headers = {"Authorization": "Bearer {}".format(github_token)}, |
283 | | - ) |
284 | | - print(data) |
285 | | - |
286 | | - # Close discussion |
287 | | - data = client.execute( |
288 | | - query = close_github_discussion( discussion_id ), |
289 | | - headers = {"Authorization": "Bearer {}".format(github_token)}, |
290 | | - ) |
291 | | - print(data) |
292 | | - |
293 | | -#========================================================== |
| 68 | + # Close and lock after 14 days of being stale |
| 69 | + if STALE_LABEL in labels and stale_comment: |
| 70 | + stale_time = datetime.strptime(stale_comment['created_at'], "%Y-%m-%dT%H:%M:%SZ") |
| 71 | + if (now - stale_time).days >= 14: |
| 72 | + # Check for any comments after stale comment |
| 73 | + recent_comments = [c for c in comments if datetime.strptime(c['created_at'], "%Y-%m-%dT%H:%M:%SZ") > stale_time] |
| 74 | + if not recent_comments: |
| 75 | + close_and_lock(number) |
| 76 | + |
| 77 | +if __name__ == "__main__": |
| 78 | + main() |
0 commit comments