Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ docker run --name <container-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-<PR id>`). Routes, token, supported events and environment variables are unchanged.

## Request Payload

### Payload example
Expand Down Expand Up @@ -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"}}}]}}]}]}}]}

```

Expand Down
150 changes: 50 additions & 100 deletions app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,139 +30,89 @@ 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):
'''
Add modified function with properly template

'''
comment_template = {
"widgets": [
{
"keyValue": {
"icon": "DESCRIPTION",
"topLabel": f"Modified by {event['actor']['name']}",
"content": str(f"""<b>Previous title</b>: {event['previousTitle']}
<b>Previous description</b>: {event['previousDescription']}
""")
}
}
]
}
return comment_template
return self.detail("DESCRIPTION", f"Modified by {event['actor']['name']}",
f"<b>Previous title</b>: {event['previousTitle']}<br>"
f"<b>Previous description</b>: {event['previousDescription']}")


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):
'''
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):
Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import pytest
from unittest.mock import MagicMock

from app.app import app as flask_app

Expand All @@ -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
123 changes: 76 additions & 47 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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"<b>Previous title</b>: {event['previousTitle']}<br>"
f"<b>Previous description</b>: {event['previousDescription']}")
assert '\n' not in text['text']
Loading