integrations: Support handling batch updates for Clubhouse.

As the user can select multiple stories and edit multiple
properties at the same time, this can generate requests
without a "primary_id" containing multiple actions, while
each action contains multiple changes.

Fixes: #18022
This commit is contained in:
PIG208 2021-04-12 01:47:20 +08:00 committed by Tim Abbott
parent e1a37d2e0a
commit 9ac55a8cf6
5 changed files with 611 additions and 0 deletions

View File

@ -0,0 +1,233 @@
{
"id": "60723fdc-2c6d-4b31-b160-ef4d438dc5bc",
"changed_at": "2021-04-11T00:16:28.845Z",
"version": "v1",
"member_id": "6071752f-e16e-4f79-b41e-7c78b76aa4bd",
"actions": [
{
"id": 17,
"entity_type": "story",
"action": "update",
"name": "asd4",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/17",
"changes": {
"story_type": {
"new": "bug",
"old": "feature"
},
"epic_id": {
"new": 23,
"old": 29
},
"requested_by_id": {
"new": "60723f5f-28ca-4ec2-a3a2-37b2dc5606ae",
"old": "6071752f-e16e-4f79-b41e-7c78b76aa4bd"
},
"label_ids": {
"adds": [
8
]
},
"group_id": {
"new": "6071adb0-641f-46c4-b5e1-4945dae399ea",
"old": "6071752f-ece0-4772-854c-05a9666c480f"
},
"workflow_state_id": {
"new": 500000010,
"old": 500000006
},
"follower_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"owner_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"position": {
"new": 42147811328,
"old": 32147483648
},
"project_id": {
"new": 28,
"old": 2
},
"deadline": {
"new": "2021-04-12T16:00:00Z",
"old": "2021-04-11T16:00:00Z"
}
}
},
{
"id": 26,
"entity_type": "story",
"action": "update",
"name": "new1",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/26",
"changes": {
"story_type": {
"new": "bug",
"old": "feature"
},
"epic_id": {
"new": 23,
"old": 29
},
"requested_by_id": {
"new": "60723f5f-28ca-4ec2-a3a2-37b2dc5606ae",
"old": "6071752f-e16e-4f79-b41e-7c78b76aa4bd"
},
"label_ids": {
"adds": [
8
]
},
"group_id": {
"new": "6071adb0-641f-46c4-b5e1-4945dae399ea",
"old": "6071752f-ece0-4772-854c-05a9666c480f"
},
"workflow_state_id": {
"new": 500000010,
"old": 500000006
},
"follower_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"owner_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"position": {
"new": 42147942400,
"old": 32147680256
},
"project_id": {
"new": 28,
"old": 2
},
"deadline": {
"new": "2021-04-12T16:00:00Z",
"old": "2021-04-11T16:00:00Z"
}
}
},
{
"id": 27,
"entity_type": "story",
"action": "update",
"name": "new2",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/27",
"changes": {
"story_type": {
"new": "bug",
"old": "feature"
},
"epic_id": {
"new": 23,
"old": 29
},
"requested_by_id": {
"new": "60723f5f-28ca-4ec2-a3a2-37b2dc5606ae",
"old": "6071752f-e16e-4f79-b41e-7c78b76aa4bd"
},
"label_ids": {
"adds": [
8
]
},
"group_id": {
"new": "6071adb0-641f-46c4-b5e1-4945dae399ea",
"old": "6071752f-ece0-4772-854c-05a9666c480f"
},
"workflow_state_id": {
"new": 500000010,
"old": 500000006
},
"follower_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"owner_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
},
"position": {
"new": 42147876864,
"old": 32147614720
},
"project_id": {
"new": 28,
"old": 2
},
"deadline": {
"new": "2021-04-12T16:00:00Z",
"old": "2021-04-11T16:00:00Z"
}
}
}
],
"references": [
{
"id": "6071752f-ece0-4772-854c-05a9666c480f",
"entity_type": "group",
"name": "Team 1"
},
{
"id": 500000010,
"entity_type": "workflow-state",
"name": "Ready for Review",
"type": "started"
},
{
"id": 500000006,
"entity_type": "workflow-state",
"name": "In Development",
"type": "started"
},
{
"id": 8,
"entity_type": "label",
"name": "low priority",
"app_url": "https://app.clubhouse.io/pig208/label/8"
},
{
"id": 23,
"entity_type": "epic",
"name": "testeipc",
"app_url": "https://app.clubhouse.io/pig208/epic/23"
},
{
"id": 2,
"entity_type": "project",
"name": "Product Development",
"app_url": "https://app.clubhouse.io/pig208/project/2"
},
{
"id": "6071adb0-641f-46c4-b5e1-4945dae399ea",
"entity_type": "group",
"name": "team2"
},
{
"id": 29,
"entity_type": "epic",
"name": "epic",
"app_url": "https://app.clubhouse.io/pig208/epic/29"
},
{
"id": 28,
"entity_type": "project",
"name": "test2",
"app_url": "https://app.clubhouse.io/pig208/project/28"
}
]
}

View File

@ -0,0 +1,133 @@
{
"id": "60723fdc-2c6d-4b31-b160-ef4d438dc5bc",
"changed_at": "2021-04-11T00:16:28.845Z",
"version": "v1",
"member_id": "6071752f-e16e-4f79-b41e-7c78b76aa4bd",
"actions": [
{
"id": 17,
"entity_type": "story",
"action": "update",
"name": "asd4",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/17",
"changes": {
"story_type": {
"new": "bug",
"old": "feature"
}
}
},
{
"id": 26,
"entity_type": "story",
"action": "update",
"name": "new1",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/26",
"changes": {
"epic_id": {
"new": 23,
"old": 29
}
}
},
{
"id": 27,
"entity_type": "story",
"action": "update",
"name": "new2",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/27",
"changes": {
"label_ids": {
"adds": [
8
]
}
}
},
{
"id": 28,
"entity_type": "story",
"action": "update",
"name": "new3",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/28",
"changes": {
"workflow_state_id": {
"new": 500000010,
"old": 500000006
}
}
},
{
"id": 29,
"entity_type": "story",
"action": "update",
"name": "new4",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/29",
"changes": {
"project_id": {
"new": 28,
"old": 2
}
}
}
],
"references": [
{
"id": "6071752f-ece0-4772-854c-05a9666c480f",
"entity_type": "group",
"name": "Team 1"
},
{
"id": 500000010,
"entity_type": "workflow-state",
"name": "Ready for Review",
"type": "started"
},
{
"id": 500000006,
"entity_type": "workflow-state",
"name": "In Development",
"type": "started"
},
{
"id": 8,
"entity_type": "label",
"name": "low priority",
"app_url": "https://app.clubhouse.io/pig208/label/8"
},
{
"id": 23,
"entity_type": "epic",
"name": "testeipc",
"app_url": "https://app.clubhouse.io/pig208/epic/23"
},
{
"id": 2,
"entity_type": "project",
"name": "Product Development",
"app_url": "https://app.clubhouse.io/pig208/project/2"
},
{
"id": "6071adb0-641f-46c4-b5e1-4945dae399ea",
"entity_type": "group",
"name": "team2"
},
{
"id": 29,
"entity_type": "epic",
"name": "epic",
"app_url": "https://app.clubhouse.io/pig208/epic/29"
},
{
"id": 28,
"entity_type": "project",
"name": "test2",
"app_url": "https://app.clubhouse.io/pig208/project/28"
}
]
}

View File

@ -0,0 +1,52 @@
{
"id": "60723fdc-2c6d-4b31-b160-ef4d438dc5bc",
"changed_at": "2021-04-11T00:16:28.845Z",
"version": "v1",
"member_id": "6071752f-e16e-4f79-b41e-7c78b76aa4bd",
"actions": [
{
"id": 17,
"entity_type": "story",
"action": "update",
"name": "asd4",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/17",
"changes": {
"deadline": {
"new": "2021-04-12T16:00:00Z",
"old": "2021-04-11T16:00:00Z"
}
}
},
{
"id": 26,
"entity_type": "story",
"action": "update",
"name": "new1",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/26",
"changes": {
"owner_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
}
}
},
{
"id": 27,
"entity_type": "story",
"action": "update",
"name": "new2",
"story_type": "bug",
"app_url": "https://app.clubhouse.io/pig208/story/27",
"changes": {
"follower_ids": {
"adds": [
"60723f5f-28ca-4ec2-a3a2-37b2dc5606ae"
]
}
}
}
]
}

View File

@ -289,6 +289,95 @@ class ClubhouseWebhookTest(WebhookTestCase):
"story_update_add_github_branch", "Testing pull requests with Story", expected_message "story_update_add_github_branch", "Testing pull requests with Story", expected_message
) )
@patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
def test_story_update_batch(self, check_send_webhook_message_mock: MagicMock) -> None:
payload = self.get_body("story_update_everything_at_once")
self.client_post(self.url, payload, content_type="application/json")
expected_message = "The story [{name}]({url}) was moved from Epic **epic** to **testeipc**, Project **Product Development** to **test2**, and changed from type **feature** to **bug**, and added with the new label **low priority** (In Development -> Ready for Review)."
request, user_profile = (
check_send_webhook_message_mock.call_args_list[0][0][0],
check_send_webhook_message_mock.call_args_list[0][0][1],
)
expected_list = [
call(
request,
user_profile,
"asd4",
expected_message.format(
name="asd4", url="https://app.clubhouse.io/pig208/story/17"
),
),
call(
request,
user_profile,
"new1",
expected_message.format(
name="new1", url="https://app.clubhouse.io/pig208/story/26"
),
),
call(
request,
user_profile,
"new2",
expected_message.format(
name="new2", url="https://app.clubhouse.io/pig208/story/27"
),
),
]
self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
@patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
def test_story_update_batch_each_with_one_change(
self, check_send_webhook_message_mock: MagicMock
) -> None:
payload = self.get_body("story_update_multiple_at_once")
self.client_post(self.url, payload, content_type="application/json")
expected_messages = [
(
"asd4",
"The type of the story [asd4](https://app.clubhouse.io/pig208/story/17) was changed from **feature** to **bug**.",
),
(
"new1",
"The story [new1](https://app.clubhouse.io/pig208/story/26) was moved from **epic** to **testeipc**.",
),
(
"new2",
"The label **low priority** was added to the story [new2](https://app.clubhouse.io/pig208/story/27).",
),
(
"new3",
"State of the story [new3](https://app.clubhouse.io/pig208/story/28) was changed from **In Development** to **Ready for Review**.",
),
(
"new4",
"The story [new4](https://app.clubhouse.io/pig208/story/29) was moved from the **Product Development** project to **test2**.",
),
]
request, user_profile = (
check_send_webhook_message_mock.call_args_list[0][0][0],
check_send_webhook_message_mock.call_args_list[0][0][1],
)
expected_list = [
call(
request,
user_profile,
expected_message[0],
expected_message[1],
)
for expected_message in expected_messages
]
self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
@patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
def test_story_update_batch_not_supported_ignore(
self, check_send_webhook_message_mock: MagicMock
) -> None:
payload = self.get_body("story_update_multiple_not_supported")
result = self.client_post(self.url, payload, content_type="application/json")
self.assertFalse(check_send_webhook_message_mock.called)
self.assert_json_success(result)
@patch("zerver.webhooks.clubhouse.view.check_send_webhook_message") @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
def test_empty_post_request_body_ignore( def test_empty_post_request_body_ignore(
self, check_send_webhook_message_mock: MagicMock self, check_send_webhook_message_mock: MagicMock

View File

@ -60,6 +60,10 @@ STORY_GITHUB_PR_TEMPLATE = (
) )
STORY_GITHUB_COMMENT_PR_TEMPLATE = "Existing GitHub PR [#{name}]({url}) associated with story {name_template}{workflow_state_template}." STORY_GITHUB_COMMENT_PR_TEMPLATE = "Existing GitHub PR [#{name}]({url}) associated with story {name_template}{workflow_state_template}."
STORY_GITHUB_BRANCH_TEMPLATE = "New GitHub branch [{name}]({url}) associated with story {name_template}{workflow_state_template}." STORY_GITHUB_BRANCH_TEMPLATE = "New GitHub branch [{name}]({url}) associated with story {name_template}{workflow_state_template}."
STORY_UPDATE_BATCH_TEMPLATE = "The story {name_template} {templates}{workflow_state_template}."
STORY_UPDATE_BATCH_CHANGED_TEMPLATE = "{operation} from {sub_templates}"
STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE = "{entity_type} **{old}** to **{new}**"
STORY_UPDATE_BATCH_ADD_REMOVE_TEMPLATE = "{operation} with {entity}"
def get_action_with_primary_id(payload: Dict[str, Any]) -> Dict[str, Any]: def get_action_with_primary_id(payload: Dict[str, Any]) -> Dict[str, Any]:
@ -73,6 +77,10 @@ def get_action_with_primary_id(payload: Dict[str, Any]) -> Dict[str, Any]:
def get_event(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]: def get_event(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
event = "{}_{}".format(action["entity_type"], action["action"]) event = "{}_{}".format(action["entity_type"], action["action"])
# We only consider the change to be a batch update only if there are multiple stories (thus there is no primary_id)
if event == "story_update" and payload.get("primary_id") is None:
return "{}_{}".format(event, "batch")
if event in IGNORED_EVENTS: if event in IGNORED_EVENTS:
return None return None
@ -497,6 +505,101 @@ def get_story_update_owner_body(payload: Dict[str, Any], action: Dict[str, Any])
return STORY_UPDATE_OWNER_TEMPLATE.format(**kwargs) return STORY_UPDATE_OWNER_TEMPLATE.format(**kwargs)
def get_story_update_batch_body(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
# When the user selects one or more stories with the checkbox, they can perform
# a batch update on multiple stories while changing multiple attribtues at the
# same time.
changes = action["changes"]
kwargs = {
"name_template": STORY_NAME_TEMPLATE.format(
name=action["name"],
app_url=action["app_url"],
),
"workflow_state_template": "",
}
templates = []
last_change = "other"
move_sub_templates = []
if "epic_id" in changes:
last_change = "epic"
move_sub_templates.append(
STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
entity_type="Epic",
old=get_reference_by_id(payload, changes["epic_id"].get("old")).get("name"),
new=get_reference_by_id(payload, changes["epic_id"].get("new")).get("name"),
)
)
if "project_id" in changes:
last_change = "project"
move_sub_templates.append(
STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
entity_type="Project",
old=get_reference_by_id(payload, changes["project_id"].get("old")).get("name"),
new=get_reference_by_id(payload, changes["project_id"].get("new")).get("name"),
)
)
if len(move_sub_templates) > 0:
templates.append(
STORY_UPDATE_BATCH_CHANGED_TEMPLATE.format(
operation="was moved",
sub_templates=", ".join(move_sub_templates),
)
)
if "story_type" in changes:
last_change = "type"
templates.append(
STORY_UPDATE_BATCH_CHANGED_TEMPLATE.format(
operation="{} changed".format("was" if len(templates) == 0 else "and"),
sub_templates=STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
entity_type="type",
old=changes["story_type"].get("old"),
new=changes["story_type"].get("new"),
),
)
)
if "label_ids" in changes:
last_change = "label"
labels = get_story_joined_label_list(payload, action, changes["label_ids"].get("adds"))
templates.append(
STORY_UPDATE_BATCH_ADD_REMOVE_TEMPLATE.format(
operation="{} added".format("was" if len(templates) == 0 else "and"),
entity="the new label{plural} {labels}".format(
plural="s" if len(changes["label_ids"]) > 1 else "", labels=labels
),
)
)
if "workflow_state_id" in changes:
last_change = "state"
kwargs.update(
workflow_state_template=TRAILING_WORKFLOW_STATE_CHANGE_TEMPLATE.format(
old=get_reference_by_id(payload, changes["workflow_state_id"].get("old")).get(
"name"
),
new=get_reference_by_id(payload, changes["workflow_state_id"].get("new")).get(
"name"
),
)
)
# Use the default template for state change if it is the only one change.
if len(templates) <= 1 or (len(templates) == 0 and last_change == "state"):
event: str = "{}_{}".format("story_update", last_change)
alternative_body_func = EVENT_BODY_FUNCTION_MAPPER.get(event)
# If last_change is not one of "epic", "project", "type", "label" and "state"
# we should ignore the action as there is no way for us to render the changes.
if alternative_body_func is None:
return None
return alternative_body_func(payload, action)
kwargs.update(templates=", ".join(templates))
return STORY_UPDATE_BATCH_TEMPLATE.format(**kwargs)
def get_entity_name( def get_entity_name(
payload: Dict[str, Any], action: Dict[str, Any], entity: Optional[str] = None payload: Dict[str, Any], action: Dict[str, Any], entity: Optional[str] = None
) -> Optional[str]: ) -> Optional[str]:
@ -570,6 +673,7 @@ EVENT_BODY_FUNCTION_MAPPER: Dict[str, Callable[[Dict[str, Any], Dict[str, Any]],
"story_update_state": get_story_update_state_body, "story_update_state": get_story_update_state_body,
"epic_update_name": partial(get_update_name_body, entity="epic"), "epic_update_name": partial(get_update_name_body, entity="epic"),
"story_update_name": partial(get_update_name_body, entity="story"), "story_update_name": partial(get_update_name_body, entity="story"),
"story_update_batch": get_story_update_batch_body,
} }
EVENT_TOPIC_FUNCTION_MAPPER = { EVENT_TOPIC_FUNCTION_MAPPER = {