Skip to content

Commit a0fcb50

Browse files
authored
GH-46669: [CI][Archery] Automate Zulip and email notifications for Extra CI (#47546)
### Rationale for this change We plan to move more Crossbow CI jobs to the main Arrow repository. We have some reporting tools like the Zulip message notifications, the email report notifications and the nightlies report page: http://crossbow.voltrondata.com/ that we should replicate for the CI. ### What changes are included in this PR? Add GH action that will send the chat and email reports. Also it adds infrastructure to archery to work with Arrow CI. There's a lot of baggage around crossbow so I've decided to implement a new module for Arrow's `ci` where we can use the GitHub API and we don't require cloning and a lot of things that were required to work with crossbow. In the future we might be able to get rid of all the related `crossbow` CLI part. ### Are these changes tested? Via CI ### Are there any user-facing changes? No * GitHub Issue: #46669 Authored-by: Raúl Cumplido <[email protected]> Signed-off-by: Raúl Cumplido <[email protected]>
1 parent 916f62d commit a0fcb50

File tree

7 files changed

+373
-6
lines changed

7 files changed

+373
-6
lines changed

.github/workflows/cpp_extra.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,53 @@ jobs:
278278
cmake --build cpp/examples/minimal_build.build
279279
cd cpp/examples/minimal_build
280280
../minimal_build.build/arrow-example
281+
282+
report-extra-cpp:
283+
runs-on: ubuntu-latest
284+
needs:
285+
- docker
286+
- jni-macos
287+
# We don't have the job id as part of the context neither the job name.
288+
# The GitHub API exposes numeric id or job name but not the github.job (report-extra-cpp).
289+
# We match github.job to the name so we can pass it via context in order to be ignored on the report.
290+
# The job is still running.
291+
name: ${{ github.job }}
292+
if: github.event_name == 'schedule' && always()
293+
steps:
294+
- name: Checkout Arrow
295+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
296+
with:
297+
fetch-depth: 0
298+
- name: Setup Python
299+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
300+
with:
301+
python-version: 3
302+
- name: Setup Archery
303+
run: python3 -m pip install -e dev/archery[crossbow]
304+
- name: Send email
305+
env:
306+
GH_TOKEN: ${{ github.token }}
307+
SMTP_PASSWORD: ${{ secrets.ARROW_SMTP_PASSWORD }}
308+
run: |
309+
archery ci report-email \
310+
--ignore ${{ github.job }} \
311+
--recipient-email '[email protected]' \
312+
--repository ${{ github.repository }} \
313+
--send \
314+
--sender-email '[email protected]' \
315+
--sender-name Arrow \
316+
--smtp-port 587 \
317+
--smtp-server 'commit-email.info' \
318+
--smtp-user arrow \
319+
${{ github.run_id }}
320+
- name: Send chat message
321+
if: always()
322+
env:
323+
GH_TOKEN: ${{ github.token }}
324+
CHAT_WEBHOOK: ${{ secrets.ARROW_ZULIP_WEBHOOK }}
325+
run: |
326+
archery ci report-chat \
327+
--ignore ${{ github.job }} \
328+
--repository ${{ github.repository }} \
329+
--send \
330+
${{ github.run_id }}

dev/archery/archery/ci/cli.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import click
19+
20+
from .core import Workflow
21+
from ..crossbow.reports import ChatReport, EmailReport, ReportUtils
22+
23+
24+
@click.group()
25+
@click.option('--github-token', '-t', default=None,
26+
envvar=['GH_TOKEN'],
27+
help='OAuth token for GitHub authentication')
28+
@click.option('--output-file', metavar='<output>',
29+
type=click.File('w', encoding='utf8'), default='-',
30+
help='Capture output result into file.')
31+
@click.pass_context
32+
def ci(ctx, github_token, output_file):
33+
"""
34+
Tools for CI Extra jobs on GitHub actions.
35+
"""
36+
ctx.ensure_object(dict)
37+
ctx.obj['github_token'] = github_token
38+
ctx.obj['output'] = output_file
39+
40+
41+
@ci.command()
42+
@click.argument('workflow_id', required=True)
43+
@click.option('--send/--dry-run', default=False,
44+
help='Just display the report, don\'t send it.')
45+
@click.option('--repository', '-r', default='apache/arrow',
46+
help='The repository where the workflow is located.')
47+
@click.option('--ignore', '-i', default="",
48+
help='Job name to ignore from the list of jobs.')
49+
@click.option('--webhook', '-w', envvar=['CHAT_WEBHOOK'],
50+
help='Zulip/Slack Webhook address to send the report to.')
51+
@click.option('--extra-message-success', '-s', default=None,
52+
help='Extra message, will be appended if no failures.')
53+
@click.option('--extra-message-failure', '-f', default=None,
54+
help='Extra message, will be appended if there are failures.')
55+
@click.pass_obj
56+
def report_chat(obj, workflow_id, send, repository, ignore, webhook,
57+
extra_message_success, extra_message_failure):
58+
"""
59+
Send a chat report to a webhook showing success/failure
60+
of jobs in a workflow run.
61+
"""
62+
output = obj['output']
63+
64+
report_chat = ChatReport(
65+
report=Workflow(workflow_id, repository,
66+
ignore_job=ignore, gh_token=obj['github_token']),
67+
extra_message_success=extra_message_success,
68+
extra_message_failure=extra_message_failure
69+
)
70+
if send:
71+
ReportUtils.send_message(webhook, report_chat.render("workflow_report"))
72+
else:
73+
output.write(report_chat.render("workflow_report"))
74+
75+
76+
@ci.command()
77+
@click.argument('workflow_id', required=True)
78+
@click.option('--sender-name', '-n',
79+
help='Name to use for report e-mail.')
80+
@click.option('--sender-email', '-e',
81+
help='E-mail to use for report e-mail.')
82+
@click.option('--recipient-email', '-t',
83+
help='Where to send the e-mail report')
84+
@click.option('--smtp-user', '-u',
85+
help='E-mail address to use for SMTP login')
86+
@click.option('--smtp-password', '-P', envvar=['SMTP_PASSWORD'],
87+
help='SMTP password to use for report e-mail.')
88+
@click.option('--smtp-server', '-s', default='smtp.gmail.com',
89+
help='SMTP server to use for report e-mail.')
90+
@click.option('--smtp-port', '-p', default=465,
91+
help='SMTP port to use for report e-mail.')
92+
@click.option('--send/--dry-run', default=False,
93+
help='Just display the report, don\'t send it.')
94+
@click.option('--repository', '-r', default='apache/arrow',
95+
help='The repository where the workflow is located.')
96+
@click.option('--ignore', '-i', default="",
97+
help='Job name to ignore from the list of jobs.')
98+
@click.pass_obj
99+
def report_email(obj, workflow_id, sender_name, sender_email, recipient_email,
100+
smtp_user, smtp_password, smtp_server, smtp_port, send,
101+
repository, ignore):
102+
"""
103+
Send an email report showing success/failure of jobs in
104+
a Workflow run
105+
"""
106+
output = obj['output']
107+
108+
email_report = EmailReport(
109+
report=Workflow(workflow_id, repository,
110+
ignore_job=ignore, gh_token=obj['github_token']),
111+
sender_name=sender_name,
112+
sender_email=sender_email,
113+
recipient_email=recipient_email
114+
)
115+
116+
if send:
117+
ReportUtils.send_email(
118+
smtp_user=smtp_user,
119+
smtp_password=smtp_password,
120+
smtp_server=smtp_server,
121+
smtp_port=smtp_port,
122+
recipient_email=recipient_email,
123+
message=email_report.render("workflow_report")
124+
)
125+
else:
126+
output.write(email_report.render("workflow_report"))

dev/archery/archery/ci/core.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from functools import cached_property
19+
20+
import requests
21+
22+
23+
class Workflow:
24+
def __init__(self, workflow_id, repository, ignore_job, gh_token=None):
25+
self.workflow_id = workflow_id
26+
self.gh_token = gh_token
27+
self.repository = repository
28+
self.ignore_job = ignore_job
29+
self.headers = {
30+
'Accept': 'application/vnd.github.v3+json',
31+
}
32+
if self.gh_token:
33+
self.headers["Authorization"] = f"Bearer {self.gh_token}"
34+
workflow_resp = requests.get(
35+
f'https://api.github.com/repos/{repository}/actions/runs/{workflow_id}',
36+
headers=self.headers
37+
)
38+
if workflow_resp.status_code == 200:
39+
self.workflow_data = workflow_resp.json()
40+
41+
else:
42+
# TODO: We could send an error report instead
43+
raise Exception(
44+
f'Failed to fetch workflow data: {workflow_resp.status_code}')
45+
46+
@property
47+
def conclusion(self):
48+
return self.workflow_data.get('conclusion')
49+
50+
@property
51+
def jobs_url(self):
52+
return self.workflow_data.get('jobs_url')
53+
54+
@property
55+
def name(self):
56+
return self.workflow_data.get('name')
57+
58+
@property
59+
def url(self):
60+
return self.workflow_data.get('html_url')
61+
62+
@cached_property
63+
def jobs(self):
64+
jobs = []
65+
jobs_resp = requests.get(self.jobs_url, headers=self.headers)
66+
if jobs_resp.status_code == 200:
67+
jobs_data = jobs_resp.json()
68+
for job_data in jobs_data.get('jobs', []):
69+
if job_data.get('name') != self.ignore_job:
70+
job = Job(job_data)
71+
jobs.append(job)
72+
return jobs
73+
74+
def failed_jobs(self):
75+
return [job for job in self.jobs if not job.is_successful()]
76+
77+
def successful_jobs(self):
78+
return [job for job in self.jobs if job.is_successful()]
79+
80+
81+
class Job:
82+
def __init__(self, job_data):
83+
self.job_data = job_data
84+
85+
@property
86+
def conclusion(self):
87+
return self.job_data.get('conclusion')
88+
89+
@property
90+
def name(self):
91+
return self.job_data.get('name')
92+
93+
@property
94+
def url(self):
95+
return self.job_data.get('html_url')
96+
97+
def is_successful(self):
98+
return self.conclusion == 'success'

dev/archery/archery/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,8 @@ def linking_check_dependencies(obj, allowed, disallowed, paths):
874874
parent=archery)
875875
add_optional_command("crossbow", module=".crossbow.cli", function="crossbow",
876876
parent=archery)
877+
add_optional_command("ci", module=".ci.cli", function="ci",
878+
parent=archery)
877879

878880

879881
if __name__ == "__main__":

dev/archery/archery/crossbow/reports.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ def show(self, outstream, asset_callback=None, validate_patterns=True):
217217
class ChatReport(JinjaReport):
218218
templates = {
219219
'text': 'chat_nightly_report.txt.j2',
220+
'workflow_report': 'chat_nightly_workflow_report.txt.j2',
220221
}
221222
fields = [
222223
'report',
@@ -246,13 +247,19 @@ def send_message(cls, webhook, message):
246247
@classmethod
247248
def send_email(cls, smtp_user, smtp_password, smtp_server, smtp_port,
248249
recipient_email, message):
249-
import smtplib
250+
from smtplib import SMTP, SMTP_SSL
250251

251-
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
252-
server.ehlo()
253-
server.login(smtp_user, smtp_password)
254-
server.sendmail(smtp_user, recipient_email, message)
255-
server.close()
252+
if smtp_port == 465:
253+
smtp_cls = SMTP_SSL
254+
else:
255+
smtp_cls = SMTP
256+
with smtp_cls(smtp_server, smtp_port) as smtp:
257+
if smtp_port == 465:
258+
smtp.ehlo()
259+
else:
260+
smtp.starttls()
261+
smtp.login(smtp_user, smtp_password)
262+
smtp.sendmail(smtp_user, recipient_email, message)
256263

257264
@classmethod
258265
def write_csv(cls, report, add_headers=True):
@@ -267,6 +274,7 @@ class EmailReport(JinjaReport):
267274
templates = {
268275
'nightly_report': 'email_nightly_report.txt.j2',
269276
'token_expiration': 'email_token_expiration.txt.j2',
277+
'workflow_report': 'email_workflow_report.txt.j2',
270278
}
271279
fields = [
272280
'report',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#}
19+
*Extra CI GitHub report for <{{ report.url }}|{{ report.name }}>*
20+
{% if report.failed_jobs() %}
21+
:x: *{{ report.failed_jobs() | length }} failed jobs*
22+
{% for job in report.failed_jobs() -%}
23+
- <{{ job.url }}|{{ job.name }}>
24+
{% endfor %}
25+
{%- endif -%}
26+
{% if report.successful_jobs() %}
27+
28+
:tada: *{{ report.successful_jobs() | length }} successful jobs*
29+
{%- endif -%}
30+
31+
{% if extra_message_success and not report.failed_jobs() %}
32+
33+
{{ extra_message_success }}
34+
{%- endif -%}
35+
{% if extra_message_failure and report.failed_jobs() %}
36+
37+
{{ extra_message_failure }}
38+
{% endif %}

0 commit comments

Comments
 (0)