File: reviews-email

package info (click to toggle)
charliecloud 0.43-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 3,116 kB
  • sloc: python: 6,021; sh: 4,284; ansic: 3,863; makefile: 598
file content (196 lines) | stat: -rwxr-xr-x 6,822 bytes parent folder | download
1
2
3
4
5
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env python3

# This script e-mails an outstanding MR review request.
#
# Configuration is via environment variables:
#
# In addition to sending the e-mail (or instead of if --to is not given), it
# prints the message body to stdout.
#
# Caveats/gotchas:
#
#   1. The error handling is pretty loose because we can just look at the
#      exception in the CI job log.
#
#   2. 1998 called and wants its HTML back.
#
#   3. The python-gitlab docs [1] have decent examples, but the API reference
#   is nearly useless. Many objects do have a pprint() method that helps. The
#   Gitlab REST API docs [2] do not appear to be comprehensive.
#
# [1]: https://python-gitlab.readthedocs.io/en/stable
# [2]: https://docs.gitlab.com/ee/api


import argparse
import email
import io
import os
import smtplib
import sys

import gitlab  # a.k.a. python-gitlab


### Constants ###

PAGE_LENGTH = 100            # max 100
PROJECT_ID = os.environ.get("CI_PROJECT_ID", "charliecloud/charliecloud")

# Review request states appear to be undocumented; there is an example [1] but
# nothing about what the possible values are. There is an open issue
# requesting documentation for this [2], and a comment there points to GraphQL
# docs [3] that list the values below (in all caps).
#
# [1]: https://docs.gitlab.com/ee/api/merge_requests.html#get-single-merge-request-reviewers
# [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/440445
# [3]: https://docs.gitlab.com/ee/api/graphql/reference/#mergerequestreviewstate
RR_STATES_OK = { "approved", "requested_changes", "reviewed" }
RR_STATES_DELINQUENT = { "review_started", "unapproved", "unreviewed" }
RR_STATES_ALL = RR_STATES_DELINQUENT | RR_STATES_OK

### Globals ###

body = io.StringIO()


### Main ###

def main():

   ap = argparse.ArgumentParser(
           formatter_class=argparse.ArgumentDefaultsHelpFormatter)
   ap.add_argument("--to", metavar="ADDR",
                   help="recipient e-mail address")
   ap.add_argument("--from", metavar="ADDR", dest="from_",
                   default="noreply@charliecloud.io",
                   help="sender address")
   ap.add_argument("--host", metavar="HOST",
                   default="localhost",
                   help="SMTP server to use")
   ap.add_argument("--port", metavar="PORT", type=int,
                   default=smtplib.SMTP_PORT,
                   help="TCP port on SMTP server")
   ap.add_argument("--user", metavar="NAME",
                   help="username for SMTP login")
   ap.add_argument("--pass", metavar="WORD", dest="pass_",
                   help="password for SMTP login")
   ap.add_argument("--verbose", "-v", action="store_true",
                   help="log SMTP transaction")
   args = ap.parse_args()
   if ((args.user is None) + (args.pass_ is None) == 1):
      ap.error("both --user and --pass must be given together")

   gl = gitlab.Gitlab("https://gitlab.com", per_page=PAGE_LENGTH)
   pj = gl.projects.get(PROJECT_ID)
   OUT("<!DOCTYPE html>")
   OUT("<html><body>")

   OUT("<p><table>")
   OUT("<tr><td>project:</td><td><a href='%s'>%s</a> %d</td>"
       % (pj.web_url, pj.path_with_namespace, pj.id))

   # analyze projects
   mrs = pj.mergerequests.list(state="opened", get_all=True,
                               order_by="created_at", sort="asc")
   delinquents = dict()
   OUT("<tr><td>MRs open:&nbsp;</td> <td>%d</td>" % len(mrs))
   OUT("<tr><td>job:</td> <td>%s</td></tr>" % os.environ.get("CI_JOB_URL"))
   OUT("</table></p>")
   for (n, mr) in enumerate(mrs, 1):
      revs = mr.reviewer_details.list()
      LOG("%d. MR !%d:" % (n, mr.iid))
      delinquents_p = False
      for rev in revs:
         LOG("%s: %s" % (rev.user["username"], rev.state))
         if (rev.state not in RR_STATES_ALL):
            LOG("bad state: %s" % rev.state)
            sys.exit(1)
         if (rev.state in RR_STATES_DELINQUENT):
            delinquents_p = True
      if (not delinquents_p):
         LOG("no delinquents")
         continue
      # at least one delinquent, so include MR in the e-mail
      OUT()
      OUT("<p>")
      OUT("<strong>%d. MR <a href='%s'>!%d</a>: %s</strong><br/>"
          % (n, mr.web_url, mr.iid, mr.title))
      OUT("<table>")
      for d in mr.reviewer_details.list():
         OUT("<tr> <td><strong>%s:</strong>&nbsp;</td> <td>%s"
             % (d.user["username"], d.state))
         if (d.state not in RR_STATES_DELINQUENT):
            OUT(" ok")
         else:
            OUT(" <font color=red><blink>DELINQUENT</blink></font>")
            delinquents[d.user["username"]] = d.user
         OUT("</td></tr>")
      for i in mr.closes_issues():
         OUT("<tr> <td>closes:</td> <td><a href='%s'>#%d</a>: %s</td> </tr>"
             % (i.web_url, i.iid, i.title))
      OUT("</table></p>")

   # call out delinquents by name
   OUT()
   OUT("<p>")
   OUT("<strong>found %d delinquents:</strong><br/>" % len(delinquents))
   OUT("<table>")
   for u in sorted(delinquents.values(), key=lambda x: x["username"]):
      OUT("<tr> <td>%s&nbsp;</td> <td>%s</td> </tr>"
          % (u["username"], u["name"]))
   OUT("</table></p>")

   OUT("</body></html>")

   # send e-mail
   LOG()
   if (not args.to):
      LOG("--to not given, skipping e-mail")
   elif (len(delinquents) == 0):
      LOG("no delinquents, skipping e-mail")
   else:
      LOG("sending e-mail to %s ..." % args.to)
      msg = email.message.EmailMessage()
      # Use a boring subject line rather than the fancy one because the latter
      # is silently not delivered by at least one provider, I guess because it
      # looks too spammy?
      msg["Subject"] = "delinquent reviews report"
      #msg["Subject"] = "🤯🤯🤯 You WON’T BELIEVE who is DeLiNqUeNt!! CLICK HERE to find out!!! 🤯🤯🤯"
      msg["From"] = args.from_
      msg["To"] = args.to
      msg.set_content("no plain text, only HTML, sorry")
      msg.add_alternative(body.getvalue(), subtype="html")
      smtp = smtplib.SMTP(args.host) # https://github.com/python/cpython/issues/80275
      if (args.verbose):
         smtp.set_debuglevel(1)
      LOG("SMTP: connecting to %s:%s" % (args.host, args.port))
      smtp.connect(args.host, args.port)
      smtp.ehlo()
      smtp.starttls()
      smtp.ehlo()
      if (args.user):
         smtp.login(args.user, args.pass_)
      refused = smtp.send_message(msg)
      if (len(refused) > 0):
         LOG("SMTP: %d recipients refused: %s" % (len(refused), refused))
      quit = smtp.quit()
      LOG("SMTP: session closed: %d %s" % (quit[0], quit[1].decode("utf8")))

   LOG("done")



### Functions ###

def LOG(*args, **kwargs):
   print(*args, **kwargs, file=sys.stderr)

def OUT(*args, **kwargs):
   print(*args, **kwargs, file=sys.stdout)
   print(*args, **kwargs, file=body)

### Bootstrap ###

if (__name__ == "__main__"):
   main()