diff --git a/README.md b/README.md index 3b246e1..ea3b54d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ docker run --name \ Refer to the [Bitbucket payload documentation](https://confluence.atlassian.com/bitbucketserver0721/event-payload-1115665959.html?utm_campaign=in-app-help&utm_medium=in-app-help&utm_source=stash#Eventpayload-pullrequest) for details on the data structure of events. +## Migration: Cards v2 + +The bot now sends the Google Chat message in the Cards v2 format (`cardsV2`, `decoratedText`, `buttonList`) instead of the deprecated `cards` format. Anything that reads or filters the payload sent to Google Chat must look for `cardsV2` (each card has `cardId` `pr-`). Routes, token, supported events and environment variables are unchanged. + ## Request Payload ### Payload example @@ -71,9 +75,9 @@ Accept-Encoding: gzip, deflate Accept: */* Connection: keep-alive Content-Type: application/json; charset=UTF-8 -Content-Length: 958 +Content-Length: 1083 -{"cards": [{"header": {"title": "pr:comment:added | (GFP) REPOSITORY", "imageUrl": "https://cdn-icons-png.flaticon.com/512/6125/6125001.png"}, "sections": [{"widgets": [{"keyValue": {"icon": "PERSON", "topLabel": "author", "content": "mail@example.com"}}, {"keyValue": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/7201/7201872.png", "topLabel": "Pull Request", "content": "test 3"}}, {"keyValue": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/6577/6577243.png", "topLabel": "ID Pull Request", "content": "1 (version: 5)"}}, {"keyValue": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/1721/1721936.png", "topLabel": "Status", "content": "OPEN"}}]}, {"widgets": [{"keyValue": {"icon": "DESCRIPTION", "topLabel": "Comment by sample", "content": "Test"}}]}, {"widgets": [{"buttons": [{"textButton": {"text": "Review PR", "onClick": {"openLink": {"url": "https://bitbucket.org/bitbucket/projects/KEY/repos/REPOSITORY/pull-requests/1"}}}}]}]}]}]} +{"cardsV2": [{"cardId": "pr-1", "card": {"header": {"title": "pr:comment:added | (GFP) REPOSITORY", "imageUrl": "https://cdn-icons-png.flaticon.com/512/6125/6125001.png"}, "sections": [{"widgets": [{"decoratedText": {"startIcon": {"knownIcon": "PERSON"}, "topLabel": "author", "text": "mail@example.com"}}, {"decoratedText": {"startIcon": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/7201/7201872.png"}, "topLabel": "Pull Request", "text": "test 3"}}, {"decoratedText": {"startIcon": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/6577/6577243.png"}, "topLabel": "ID Pull Request", "text": "1 (version: 5)"}}, {"decoratedText": {"startIcon": {"iconUrl": "https://cdn-icons-png.flaticon.com/512/1721/1721936.png"}, "topLabel": "Status", "text": "OPEN"}}]}, {"widgets": [{"decoratedText": {"startIcon": {"knownIcon": "DESCRIPTION"}, "topLabel": "Comment by sample", "text": "Test"}}]}, {"widgets": [{"buttonList": {"buttons": [{"text": "Review PR", "onClick": {"openLink": {"url": "https://bitbucket.org/bitbucket/projects/KEY/repos/REPOSITORY/pull-requests/1"}}}]}}]}]}}]} ``` diff --git a/app/app.py b/app/app.py index bb03c11..78065ba 100644 --- a/app/app.py +++ b/app/app.py @@ -30,23 +30,22 @@ def __init__(self, url, event): self.url = url + @staticmethod + def detail(icon, label, text): + ''' + Section with one decoratedText widget (icon is a knownIcon) + + ''' + return {"widgets": [{"decoratedText": {"startIcon": {"knownIcon": icon}, "topLabel": label, "text": text}}]} + + def pr_approved(self, event): ''' Add approved comment with properly template ''' - comment_template = { - "widgets": [ - { - "keyValue": { - "icon": "STAR", - "topLabel": f"Review by {event['participant']['user']['name']}", - "content": str(event['participant']['status']) - } - } - ] - } - return comment_template + return self.detail("STAR", f"Review by {event['participant']['user']['name']}", + str(event['participant']['status'])) def pr_modified(self, event): @@ -54,20 +53,9 @@ def pr_modified(self, event): Add modified function with properly template ''' - comment_template = { - "widgets": [ - { - "keyValue": { - "icon": "DESCRIPTION", - "topLabel": f"Modified by {event['actor']['name']}", - "content": str(f"""Previous title: {event['previousTitle']} - Previous description: {event['previousDescription']} - """) - } - } - ] - } - return comment_template + return self.detail("DESCRIPTION", f"Modified by {event['actor']['name']}", + f"Previous title: {event['previousTitle']}
" + f"Previous description: {event['previousDescription']}") def pr_comment_add(self, event): @@ -75,18 +63,8 @@ def pr_comment_add(self, event): Add comment function with properly template ''' - comment_template = { - "widgets": [ - { - "keyValue": { - "icon": "DESCRIPTION", - "topLabel": f"Comment by {event['comment']['author']['name']}", - "content": str(event['comment']['text']) - } - } - ] - } - return comment_template + return self.detail("DESCRIPTION", f"Comment by {event['comment']['author']['name']}", + str(event['comment']['text'])) def prepare_template(self, message): @@ -94,75 +72,47 @@ def prepare_template(self, message): Prepare final template before send ''' - base_template = { - "cards": [ - { - "header": { - "title": f"{self.pr_event_key} | ({self.pr_project}) {self.pr_repository}", - "imageUrl": "https://cdn-icons-png.flaticon.com/512/6125/6125001.png" - }, - "sections": [ - { - "widgets": [ - { - "keyValue": { - "icon": "PERSON", - "topLabel": "author", - "content": str(self.author) - } - }, - { - "keyValue": { - "iconUrl": "https://cdn-icons-png.flaticon.com/512/7201/7201872.png", - "topLabel": "Pull Request", - "content": str(f"{self.pr_title}") - } - }, - { - "keyValue": { - "iconUrl": "https://cdn-icons-png.flaticon.com/512/6577/6577243.png", - "topLabel": "ID Pull Request", - "content": str(f"{self.pr_id} (version: {self.pr_version})") - } - }, - { - "keyValue": { - "iconUrl": "https://cdn-icons-png.flaticon.com/512/1721/1721936.png", - "topLabel": "Status", - "content": str(self.pr_state) - } - } - ] - }, - { - "widgets": [ + def meta(icon, label, text): + return {"decoratedText": {"startIcon": icon, "topLabel": label, "text": str(text)}} + + card = { + "header": { + "title": f"{self.pr_event_key} | ({self.pr_project}) {self.pr_repository}", + "imageUrl": "https://cdn-icons-png.flaticon.com/512/6125/6125001.png" + }, + "sections": [ + { + "widgets": [ + meta({"knownIcon": "PERSON"}, "author", self.author), + meta({"iconUrl": "https://cdn-icons-png.flaticon.com/512/7201/7201872.png"}, + "Pull Request", self.pr_title), + meta({"iconUrl": "https://cdn-icons-png.flaticon.com/512/6577/6577243.png"}, + "ID Pull Request", f"{self.pr_id} (version: {self.pr_version})"), + meta({"iconUrl": "https://cdn-icons-png.flaticon.com/512/1721/1721936.png"}, + "Status", self.pr_state) + ] + }, + { + "widgets": [ + { + "buttonList": { + "buttons": [ { - "buttons": [ - { - "textButton": { - "text": "Review PR", - "onClick": { - "openLink": { - "url": str(self.pr_link) - } - } - } - } - ] + "text": "Review PR", + "onClick": {"openLink": {"url": str(self.pr_link)}} } - ] - } - ] + ] } - ] } + ] + } + ] + } if message: - l_sections = base_template["cards"][0]["sections"] - l_sections.insert(1, message) - base_template["cards"][0]["sections"] = l_sections + card["sections"].insert(1, message) - return base_template + return {"cardsV2": [{"cardId": f"pr-{self.pr_id}", "card": card}]} def send_message(self, message=None): diff --git a/tests/conftest.py b/tests/conftest.py index f9faa1a..b395615 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import os import pytest +from unittest.mock import MagicMock from app.app import app as flask_app @@ -16,3 +17,10 @@ def client(app): @pytest.fixture def valid_token(): return 'my-token' + +@pytest.fixture +def post(monkeypatch): + """Replace requests.post so no test opens the network.""" + mock = MagicMock(return_value=MagicMock(text='ok')) + monkeypatch.setattr('app.app.requests.post', mock) + return mock diff --git a/tests/test_app.py b/tests/test_app.py index 3728d37..d0a2cef 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,69 +1,98 @@ - import json import os -def test_invalid_token(client): +import pytest + +ICON_PEOPLE = "https://cdn-icons-png.flaticon.com/512/6125/6125001.png" +ICON_URLS = [ + "https://cdn-icons-png.flaticon.com/512/7201/7201872.png", + "https://cdn-icons-png.flaticon.com/512/6577/6577243.png", + "https://cdn-icons-png.flaticon.com/512/1721/1721936.png", +] + + +def send(client, token, post, sample): + """Post a sample event and return (response, JSON sent to Google Chat).""" + path = os.path.join(os.path.dirname(__file__), '../samples', sample) + with open(path, 'r', encoding='utf-8') as f: + event = json.load(f) + response = client.post('/', query_string={'token': token}, json=event) + sent = json.loads(post.call_args.kwargs['data']) if post.called else None + return response, event, sent + + +def check_base(event, sent): + """Assert the parts common to every event; return the card.""" + assert 'cards' not in sent + assert sent['cardsV2'][0]['cardId'] == f"pr-{event['pullRequest']['id']}" + card = sent['cardsV2'][0]['card'] + repo = event['pullRequest']['fromRef']['repository'] + assert card['header'] == { + "title": f"{event['eventKey']} | ({repo['project']['key']}) {repo['name']}", + "imageUrl": ICON_PEOPLE, + } + meta = [w['decoratedText'] for w in card['sections'][0]['widgets']] + assert [m['topLabel'] for m in meta] == ['author', 'Pull Request', 'ID Pull Request', 'Status'] + assert meta[0]['startIcon'] == {'knownIcon': 'PERSON'} + assert [m['startIcon']['iconUrl'] for m in meta[1:]] == ICON_URLS + button = card['sections'][-1]['widgets'][0]['buttonList']['buttons'][0] + assert button['text'] == 'Review PR' + assert button['onClick']['openLink']['url'] == event['pullRequest']['links']['self'][0]['href'] + assert 'keyValue' not in json.dumps(sent) and 'textButton' not in json.dumps(sent) + return card + + +def test_invalid_token(client, post): response = client.post('/', query_string={'token': 'wrong-token'}, json={}) assert response.status_code == 403 assert b'Invalid token' in response.data + post.assert_not_called() + -def test_empty_event(client, valid_token): +def test_empty_event(client, valid_token, post): response = client.post('/', query_string={'token': valid_token}, json={}) assert response.status_code == 400 assert b'event empty' in response.data + post.assert_not_called() -def test_pr_opened(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_opened.json') - with open(json_path, 'r') as f: - event = json.load(f) - - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') - assert response.status_code == 200 - -def test_pr_modified(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_modified.json') - with open(json_path, 'r') as f: - event = json.load(f) - - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') - assert response.status_code == 200 - -def test_pr_comment_added(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_add_comment.json') - with open(json_path, 'r') as f: - event = json.load(f) - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') +@pytest.mark.parametrize('sample', ['sample_pr_opened.json', 'sample_pr_merged.json', 'sample_pr_declined.json']) +def test_pr_without_detail(client, valid_token, post, sample): + response, event, sent = send(client, valid_token, post, sample) assert response.status_code == 200 + assert len(check_base(event, sent)['sections']) == 2 -def test_pr_approved(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_approved.json') - with open(json_path, 'r') as f: - event = json.load(f) - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') +@pytest.mark.parametrize('sample', ['sample_pr_approved.json', 'sample_pr_needs_work.json']) +def test_pr_review(client, valid_token, post, sample): + response, event, sent = send(client, valid_token, post, sample) assert response.status_code == 200 + section = check_base(event, sent)['sections'] + assert len(section) == 3 + text = section[1]['widgets'][0]['decoratedText'] + assert text['topLabel'] == f"Review by {event['participant']['user']['name']}" + assert text['text'] == event['participant']['status'] + assert text['startIcon'] == {'knownIcon': 'STAR'} -def test_pr_declined(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_approved.json') - with open(json_path, 'r') as f: - event = json.load(f) - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') +def test_pr_comment_added(client, valid_token, post): + response, event, sent = send(client, valid_token, post, 'sample_pr_add_comment.json') assert response.status_code == 200 + section = check_base(event, sent)['sections'] + assert len(section) == 3 + text = section[1]['widgets'][0]['decoratedText'] + assert text['topLabel'] == f"Comment by {event['comment']['author']['name']}" + assert text['text'] == event['comment']['text'] + assert text['startIcon'] == {'knownIcon': 'DESCRIPTION'} -def test_pr_needs_work(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_needs_work.json') - with open(json_path, 'r') as f: - event = json.load(f) - - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') - assert response.status_code == 200 - -def test_pr_merged(client, valid_token): - json_path = os.path.join(os.path.dirname(__file__), '../samples/sample_pr_merged.json') - with open(json_path, 'r') as f: - event = json.load(f) - response = client.post('/', query_string={'token': valid_token}, json=event, content_type='application/json') +def test_pr_modified(client, valid_token, post): + response, event, sent = send(client, valid_token, post, 'sample_pr_modified.json') assert response.status_code == 200 + section = check_base(event, sent)['sections'] + assert len(section) == 3 + text = section[1]['widgets'][0]['decoratedText'] + assert text['topLabel'] == f"Modified by {event['actor']['name']}" + assert text['text'] == (f"Previous title: {event['previousTitle']}
" + f"Previous description: {event['previousDescription']}") + assert '\n' not in text['text']