fastapi

Форк
0
417 строк · 12.4 Кб
1
import logging
2
import random
3
import sys
4
import time
5
from pathlib import Path
6
from typing import Any, Dict, List, Union, cast
7

8
import httpx
9
from github import Github
10
from pydantic import BaseModel, BaseSettings, SecretStr
11

12
awaiting_label = "awaiting-review"
13
lang_all_label = "lang-all"
14
approved_label = "approved-2"
15
translations_path = Path(__file__).parent / "translations.yml"
16

17
github_graphql_url = "https://api.github.com/graphql"
18
questions_translations_category_id = "DIC_kwDOCZduT84CT5P9"
19

20
all_discussions_query = """
21
query Q($category_id: ID) {
22
  repository(name: "fastapi", owner: "tiangolo") {
23
    discussions(categoryId: $category_id, first: 100) {
24
      nodes {
25
        title
26
        id
27
        number
28
        labels(first: 10) {
29
          edges {
30
            node {
31
              id
32
              name
33
            }
34
          }
35
        }
36
      }
37
    }
38
  }
39
}
40
"""
41

42
translation_discussion_query = """
43
query Q($after: String, $discussion_number: Int!) {
44
  repository(name: "fastapi", owner: "tiangolo") {
45
    discussion(number: $discussion_number) {
46
      comments(first: 100, after: $after) {
47
        edges {
48
          cursor
49
          node {
50
            id
51
            url
52
            body
53
          }
54
        }
55
      }
56
    }
57
  }
58
}
59
"""
60

61
add_comment_mutation = """
62
mutation Q($discussion_id: ID!, $body: String!) {
63
  addDiscussionComment(input: {discussionId: $discussion_id, body: $body}) {
64
    comment {
65
      id
66
      url
67
      body
68
    }
69
  }
70
}
71
"""
72

73
update_comment_mutation = """
74
mutation Q($comment_id: ID!, $body: String!) {
75
  updateDiscussionComment(input: {commentId: $comment_id, body: $body}) {
76
    comment {
77
      id
78
      url
79
      body
80
    }
81
  }
82
}
83
"""
84

85

86
class Comment(BaseModel):
87
    id: str
88
    url: str
89
    body: str
90

91

92
class UpdateDiscussionComment(BaseModel):
93
    comment: Comment
94

95

96
class UpdateCommentData(BaseModel):
97
    updateDiscussionComment: UpdateDiscussionComment
98

99

100
class UpdateCommentResponse(BaseModel):
101
    data: UpdateCommentData
102

103

104
class AddDiscussionComment(BaseModel):
105
    comment: Comment
106

107

108
class AddCommentData(BaseModel):
109
    addDiscussionComment: AddDiscussionComment
110

111

112
class AddCommentResponse(BaseModel):
113
    data: AddCommentData
114

115

116
class CommentsEdge(BaseModel):
117
    node: Comment
118
    cursor: str
119

120

121
class Comments(BaseModel):
122
    edges: List[CommentsEdge]
123

124

125
class CommentsDiscussion(BaseModel):
126
    comments: Comments
127

128

129
class CommentsRepository(BaseModel):
130
    discussion: CommentsDiscussion
131

132

133
class CommentsData(BaseModel):
134
    repository: CommentsRepository
135

136

137
class CommentsResponse(BaseModel):
138
    data: CommentsData
139

140

141
class AllDiscussionsLabelNode(BaseModel):
142
    id: str
143
    name: str
144

145

146
class AllDiscussionsLabelsEdge(BaseModel):
147
    node: AllDiscussionsLabelNode
148

149

150
class AllDiscussionsDiscussionLabels(BaseModel):
151
    edges: List[AllDiscussionsLabelsEdge]
152

153

154
class AllDiscussionsDiscussionNode(BaseModel):
155
    title: str
156
    id: str
157
    number: int
158
    labels: AllDiscussionsDiscussionLabels
159

160

161
class AllDiscussionsDiscussions(BaseModel):
162
    nodes: List[AllDiscussionsDiscussionNode]
163

164

165
class AllDiscussionsRepository(BaseModel):
166
    discussions: AllDiscussionsDiscussions
167

168

169
class AllDiscussionsData(BaseModel):
170
    repository: AllDiscussionsRepository
171

172

173
class AllDiscussionsResponse(BaseModel):
174
    data: AllDiscussionsData
175

176

177
class Settings(BaseSettings):
178
    github_repository: str
179
    input_token: SecretStr
180
    github_event_path: Path
181
    github_event_name: Union[str, None] = None
182
    httpx_timeout: int = 30
183
    input_debug: Union[bool, None] = False
184

185

186
class PartialGitHubEventIssue(BaseModel):
187
    number: int
188

189

190
class PartialGitHubEvent(BaseModel):
191
    pull_request: PartialGitHubEventIssue
192

193

194
def get_graphql_response(
195
    *,
196
    settings: Settings,
197
    query: str,
198
    after: Union[str, None] = None,
199
    category_id: Union[str, None] = None,
200
    discussion_number: Union[int, None] = None,
201
    discussion_id: Union[str, None] = None,
202
    comment_id: Union[str, None] = None,
203
    body: Union[str, None] = None,
204
) -> Dict[str, Any]:
205
    headers = {"Authorization": f"token {settings.input_token.get_secret_value()}"}
206
    # some fields are only used by one query, but GraphQL allows unused variables, so
207
    # keep them here for simplicity
208
    variables = {
209
        "after": after,
210
        "category_id": category_id,
211
        "discussion_number": discussion_number,
212
        "discussion_id": discussion_id,
213
        "comment_id": comment_id,
214
        "body": body,
215
    }
216
    response = httpx.post(
217
        github_graphql_url,
218
        headers=headers,
219
        timeout=settings.httpx_timeout,
220
        json={"query": query, "variables": variables, "operationName": "Q"},
221
    )
222
    if response.status_code != 200:
223
        logging.error(
224
            f"Response was not 200, after: {after}, category_id: {category_id}"
225
        )
226
        logging.error(response.text)
227
        raise RuntimeError(response.text)
228
    data = response.json()
229
    if "errors" in data:
230
        logging.error(f"Errors in response, after: {after}, category_id: {category_id}")
231
        logging.error(response.text)
232
        raise RuntimeError(response.text)
233
    return cast(Dict[str, Any], data)
234

235

236
def get_graphql_translation_discussions(*, settings: Settings):
237
    data = get_graphql_response(
238
        settings=settings,
239
        query=all_discussions_query,
240
        category_id=questions_translations_category_id,
241
    )
242
    graphql_response = AllDiscussionsResponse.parse_obj(data)
243
    return graphql_response.data.repository.discussions.nodes
244

245

246
def get_graphql_translation_discussion_comments_edges(
247
    *, settings: Settings, discussion_number: int, after: Union[str, None] = None
248
):
249
    data = get_graphql_response(
250
        settings=settings,
251
        query=translation_discussion_query,
252
        discussion_number=discussion_number,
253
        after=after,
254
    )
255
    graphql_response = CommentsResponse.parse_obj(data)
256
    return graphql_response.data.repository.discussion.comments.edges
257

258

259
def get_graphql_translation_discussion_comments(
260
    *, settings: Settings, discussion_number: int
261
):
262
    comment_nodes: List[Comment] = []
263
    discussion_edges = get_graphql_translation_discussion_comments_edges(
264
        settings=settings, discussion_number=discussion_number
265
    )
266

267
    while discussion_edges:
268
        for discussion_edge in discussion_edges:
269
            comment_nodes.append(discussion_edge.node)
270
        last_edge = discussion_edges[-1]
271
        discussion_edges = get_graphql_translation_discussion_comments_edges(
272
            settings=settings,
273
            discussion_number=discussion_number,
274
            after=last_edge.cursor,
275
        )
276
    return comment_nodes
277

278

279
def create_comment(*, settings: Settings, discussion_id: str, body: str):
280
    data = get_graphql_response(
281
        settings=settings,
282
        query=add_comment_mutation,
283
        discussion_id=discussion_id,
284
        body=body,
285
    )
286
    response = AddCommentResponse.parse_obj(data)
287
    return response.data.addDiscussionComment.comment
288

289

290
def update_comment(*, settings: Settings, comment_id: str, body: str):
291
    data = get_graphql_response(
292
        settings=settings,
293
        query=update_comment_mutation,
294
        comment_id=comment_id,
295
        body=body,
296
    )
297
    response = UpdateCommentResponse.parse_obj(data)
298
    return response.data.updateDiscussionComment.comment
299

300

301
if __name__ == "__main__":
302
    settings = Settings()
303
    if settings.input_debug:
304
        logging.basicConfig(level=logging.DEBUG)
305
    else:
306
        logging.basicConfig(level=logging.INFO)
307
    logging.debug(f"Using config: {settings.json()}")
308
    g = Github(settings.input_token.get_secret_value())
309
    repo = g.get_repo(settings.github_repository)
310
    if not settings.github_event_path.is_file():
311
        raise RuntimeError(
312
            f"No github event file available at: {settings.github_event_path}"
313
        )
314
    contents = settings.github_event_path.read_text()
315
    github_event = PartialGitHubEvent.parse_raw(contents)
316

317
    # Avoid race conditions with multiple labels
318
    sleep_time = random.random() * 10  # random number between 0 and 10 seconds
319
    logging.info(
320
        f"Sleeping for {sleep_time} seconds to avoid "
321
        "race conditions and multiple comments"
322
    )
323
    time.sleep(sleep_time)
324

325
    # Get PR
326
    logging.debug(f"Processing PR: #{github_event.pull_request.number}")
327
    pr = repo.get_pull(github_event.pull_request.number)
328
    label_strs = {label.name for label in pr.get_labels()}
329
    langs = []
330
    for label in label_strs:
331
        if label.startswith("lang-") and not label == lang_all_label:
332
            langs.append(label[5:])
333
    logging.info(f"PR #{pr.number} has labels: {label_strs}")
334
    if not langs or lang_all_label not in label_strs:
335
        logging.info(f"PR #{pr.number} doesn't seem to be a translation PR, skipping")
336
        sys.exit(0)
337

338
    # Generate translation map, lang ID to discussion
339
    discussions = get_graphql_translation_discussions(settings=settings)
340
    lang_to_discussion_map: Dict[str, AllDiscussionsDiscussionNode] = {}
341
    for discussion in discussions:
342
        for edge in discussion.labels.edges:
343
            label = edge.node.name
344
            if label.startswith("lang-") and not label == lang_all_label:
345
                lang = label[5:]
346
                lang_to_discussion_map[lang] = discussion
347
    logging.debug(f"Using translations map: {lang_to_discussion_map}")
348

349
    # Messages to create or check
350
    new_translation_message = f"Good news everyone! 😉 There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}. 🎉 This requires 2 approvals from native speakers to be merged. 🤓"
351
    done_translation_message = f"~There's a new translation PR to be reviewed: #{pr.number} by @{pr.user.login}~ Good job! This is done. 🍰☕"
352

353
    # Normally only one language, but still
354
    for lang in langs:
355
        if lang not in lang_to_discussion_map:
356
            log_message = f"Could not find discussion for language: {lang}"
357
            logging.error(log_message)
358
            raise RuntimeError(log_message)
359
        discussion = lang_to_discussion_map[lang]
360
        logging.info(
361
            f"Found a translation discussion for language: {lang} in discussion: #{discussion.number}"
362
        )
363

364
        already_notified_comment: Union[Comment, None] = None
365
        already_done_comment: Union[Comment, None] = None
366

367
        logging.info(
368
            f"Checking current comments in discussion: #{discussion.number} to see if already notified about this PR: #{pr.number}"
369
        )
370
        comments = get_graphql_translation_discussion_comments(
371
            settings=settings, discussion_number=discussion.number
372
        )
373
        for comment in comments:
374
            if new_translation_message in comment.body:
375
                already_notified_comment = comment
376
            elif done_translation_message in comment.body:
377
                already_done_comment = comment
378
        logging.info(
379
            f"Already notified comment: {already_notified_comment}, already done comment: {already_done_comment}"
380
        )
381

382
        if pr.state == "open" and awaiting_label in label_strs:
383
            logging.info(
384
                f"This PR seems to be a language translation and awaiting reviews: #{pr.number}"
385
            )
386
            if already_notified_comment:
387
                logging.info(
388
                    f"This PR #{pr.number} was already notified in comment: {already_notified_comment.url}"
389
                )
390
            else:
391
                logging.info(
392
                    f"Writing notification comment about PR #{pr.number} in Discussion: #{discussion.number}"
393
                )
394
                comment = create_comment(
395
                    settings=settings,
396
                    discussion_id=discussion.id,
397
                    body=new_translation_message,
398
                )
399
                logging.info(f"Notified in comment: {comment.url}")
400
        elif pr.state == "closed" or approved_label in label_strs:
401
            logging.info(f"Already approved or closed PR #{pr.number}")
402
            if already_done_comment:
403
                logging.info(
404
                    f"This PR #{pr.number} was already marked as done in comment: {already_done_comment.url}"
405
                )
406
            elif already_notified_comment:
407
                updated_comment = update_comment(
408
                    settings=settings,
409
                    comment_id=already_notified_comment.id,
410
                    body=done_translation_message,
411
                )
412
                logging.info(f"Marked as done in comment: {updated_comment.url}")
413
        else:
414
            logging.info(
415
                f"There doesn't seem to be anything to be done about PR #{pr.number}"
416
            )
417
    logging.info("Finished")
418

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.