diff --git a/actions/assemble_message.py b/actions/assemble_message.py new file mode 100644 index 0000000..84b1da1 --- /dev/null +++ b/actions/assemble_message.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import codecs + + +from jinja2 import Environment + +from st2common.runners.base_action import Action + + +__all__ = [ + 'AssembleMessageAction' +] + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class AssembleMessageAction(Action): + def run(self, forum_posts, github_data, template_path): + """ + Build and return rendered text. + """ + + template_path = os.path.join(BASE_DIR, '../', template_path) + template_path = os.path.abspath(template_path) + + with codecs.open(template_path, encoding='utf-8') as fp: + template_data = fp.read() + + template_context = { + 'github_data': github_data, + 'forum_posts': forum_posts + } + + # Add all information to the template context and render the template + env = Environment(trim_blocks=True, lstrip_blocks=True) + rendered = env.from_string(template_data).render(template_context) + return rendered diff --git a/actions/assemble_message.yaml b/actions/assemble_message.yaml new file mode 100644 index 0000000..c3d3d63 --- /dev/null +++ b/actions/assemble_message.yaml @@ -0,0 +1,20 @@ +--- +name: assemble_message +pack: st2community +description: Retrieve various daily summary (new issues and PRs in all the repos, new forum posts, etc.) and assemble summary text. +runner_type: python-script +entry_point: assemble_message.py +enabled: true +parameters: + forum_posts: + type: array + items: + type: "object" + github_data: + type: array + items: + type: "object" + template_path: + description: "Path to the Jinja template file for the Slack message (relative to the pack directory)." + type: "string" + default: "etc/message_template.j2" diff --git a/actions/build-and-message.yaml b/actions/build-and-message.yaml deleted file mode 100644 index ca08fc7..0000000 --- a/actions/build-and-message.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: build-and-message -pack: st2community -description: Send update to given channel. -runner_type: orquesta -entry_point: workflows/build-and-message.yaml -enabled: true -parameters: - channel: - type: string - required: true - body: - type: string - required: true - user: - type: string - required: true - delta: - type: object - required: true - default: - days: 1 - minutes: 10 diff --git a/actions/build-text.yaml b/actions/build-text.yaml deleted file mode 100644 index 004e0b6..0000000 --- a/actions/build-text.yaml +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: build-text -pack: st2community -description: Render given text with information about PRs and Issues. -runner_type: python-script -entry_point: build_text.py -enabled: true -parameters: - body: - type: string - required: true - user: - type: string - required: true - delta: - type: object - required: true - default: - days: 1 - minutes: 10 diff --git a/actions/build_text.py b/actions/build_text.py deleted file mode 100644 index 3c3517d..0000000 --- a/actions/build_text.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python -"""Community Update Action -""" -from datetime import timedelta -from st2common.runners.base_action import Action - -from lib import community - -class BuildText(Action): - """Community Update Action - """ - def __init__(self, config): - super(BuildText, self).__init__(config) - self._token = config.get('token') - - def run(self, body=None, user=None, delta=None): - """Build and return rendered text - """ - time_delta = timedelta( - days=delta.get('days', 0), - hours=delta.get('hours', 0), - minutes=delta.get('minutes', 0), - seconds=delta.get('seconds', 0) - ) - - return { - 'body': community.build_text( - self._token, - body=body, - user=user, - delta=time_delta - ) - } diff --git a/actions/get_forum_posts.py b/actions/get_forum_posts.py new file mode 100644 index 0000000..4d8fa3e --- /dev/null +++ b/actions/get_forum_posts.py @@ -0,0 +1,30 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from st2common.runners.base_action import Action + +from lib.utils import get_timedelta_object_from_delta_arg +from lib.forum_posts import get_forum_posts + +__all__ = [ + 'GetForumPostsAction' +] + + +class GetForumPostsAction(Action): + def run(self, feed_url, delta): + time_delta = get_timedelta_object_from_delta_arg(delta) + result = get_forum_posts(feed_url=feed_url, delta=time_delta) + return result diff --git a/actions/get_forum_posts.yaml b/actions/get_forum_posts.yaml new file mode 100644 index 0000000..3eb01b8 --- /dev/null +++ b/actions/get_forum_posts.yaml @@ -0,0 +1,23 @@ +--- +name: get_forum_posts +pack: st2community +description: Retrieve recent forum posts. +runner_type: python-script +entry_point: get_forum_posts.py +enabled: true +parameters: + feed_url: + description: "Forum RSS feed URL." + type: string + required: true + # TODO: Temporary workaround until version with fix in https://github.com/StackStorm/st2/pull/4531 + # is deployed to cicd + default: "https://forum.stackstorm.com/latest.rss" + #default: "{{ config_context.forum_feed_url }}" + delta: + description: "Time period to retrieve forum posts for." + type: object + required: true + default: + days: 1 + minutes: 10 diff --git a/actions/get_github_issues.py b/actions/get_github_issues.py new file mode 100644 index 0000000..c267ffa --- /dev/null +++ b/actions/get_github_issues.py @@ -0,0 +1,36 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from github import Github + +from st2common.runners.base_action import Action + +from lib.utils import get_timedelta_object_from_delta_arg +from lib.github_issues import get_issues_and_prs_for_user + +__all__ = [ + 'GetGithubIssuesAction' +] + + +class GetGithubIssuesAction(Action): + #def run(self, token, username, delta): + def run(self, username, delta, token=None): + time_delta = get_timedelta_object_from_delta_arg(delta) + token = self.config['github_token'] + github = Github(token) + github_user = github.get_user(username) + result = get_issues_and_prs_for_user(github_user=github_user, time_delta=time_delta) + return result diff --git a/actions/get_github_issues.yaml b/actions/get_github_issues.yaml new file mode 100644 index 0000000..86c68c4 --- /dev/null +++ b/actions/get_github_issues.yaml @@ -0,0 +1,26 @@ +--- +name: get_github_issues +pack: st2community +description: Retrieve recent Github issues and pull requests. +runner_type: python-script +entry_point: get_github_issues.py +enabled: true +parameters: + token: + description: "Github token used to authenticate." + type: string + secret: true + # TODO: Uncomment when new version of st2 is deployed to cicd + #required: false + #default: "{{ config_context.github_token }}" + username: + description: "Username of the Github user / organization to retrieve issues for." + type: string + required: true + delta: + description: "Time period to retrieve issuesfor." + type: object + required: true + default: + days: 1 + minutes: 10 diff --git a/actions/lib/community.py b/actions/lib/community.py deleted file mode 100644 index 102dfeb..0000000 --- a/actions/lib/community.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -"""Daily update from Github -""" -from datetime import datetime, timedelta - -from github import Github -from jinja2 import Template - - -def _iterate_repos(user, func, **kwargs): - new_items = [] - for repo in user.get_repos(): - new_items += func(repo, **kwargs) - - return new_items - - -def _filter_by_date(items, date_attribute, delta): - filter_date = datetime.utcnow() - delta - filtered_items = [] - - for item in items: - if getattr(item, date_attribute) > filter_date: - filtered_items.append(item) - - return filtered_items - - -def _filter_prs(repo, delta=timedelta(days=1, minutes=10)): - return _filter_by_date(repo.get_pulls(), 'created_at', delta) - - -def _get_new_prs(user, delta): - return _iterate_repos(user, _filter_prs, delta=delta) - - -def _filter_issues(repo, delta=timedelta(days=1, minutes=10)): - issues = _filter_by_date(repo.get_issues(), 'created_at', delta) - for index, issue in enumerate(issues): - if issue.pull_request: - issues.pop(index) - return issues - - -def _get_new_issues(user, delta): - return _iterate_repos(user, _filter_issues, delta=delta) - - -def build_text( - token, - body=None, - user='StackStorm-Exchange', - delta=timedelta(days=1, minutes=10) -): - """build_text processes the new issues and PRs for a given time period and - returns slack message text to be sent. - """ - if body: - body = body.replace('[', '{') - body = body.replace(']', '}') - template = Template(body) if body is not None else Template( - "Good morning, @oncall. Here's your community update. Yesterday there " - "were **{{ new_issue_count }}** new issue(s), and " - "**{{ new_pull_count }}** new pull request(s).\n" - "{% if pulls %}\n*Pull Requests*\n" - "{% for pull in pulls %}* <{{ pull.base.repo.html_url }}|{{ pull.base." - "repo.name }}>: <{{ pull.html_url }}|{{ pull.title }}> by <{{ pull.use" - "r.html_url }}|{{ pull.user.name }}>\n" - "{% endfor %}{% endif %}{% if issues %}\n*Issues:*\n" - "{% for issue in issues %}* <{{ issue.repository.html_url }}|{{ issue." - "repository.name }}>: <{{ issue.html_url }}|{{ issue.title }}> by <{{ " - "issue.user.html_url }}|{{ issue.user.name }}>{% endfor %}{% endif %}" - ) - github = Github(token) - exchange = github.get_user(user) - pulls = _get_new_prs(exchange, delta=delta) - issues = _get_new_issues(exchange, delta=delta) - - return template.render( - new_issue_count=len(issues), - new_pull_count=len(pulls), - pulls=pulls, - issues=issues, - ) diff --git a/actions/lib/forum_posts.py b/actions/lib/forum_posts.py new file mode 100644 index 0000000..c330e51 --- /dev/null +++ b/actions/lib/forum_posts.py @@ -0,0 +1,46 @@ +import time + +from datetime import datetime +from datetime import timedelta + +import feedparser + +__all__ = [ + 'get_forum_posts' +] + + +def get_forum_posts(feed_url, delta=timedelta(days=1, minutes=10)): + """ + Retrieve forum posts which are been created between now - delta. + """ + feed = feedparser.parse(feed_url) + + filter_date = (datetime.utcnow() - delta) + + result = [] + for item in feed['items']: + published_parsed = item.get('published_parsed', None) + + if published_parsed: + published_dt = datetime.fromtimestamp(time.mktime(published_parsed)) + else: + published_dt = None + + if published_dt and (published_dt > filter_date): + item['published_dt'] = published_dt + result.append(item) + + # Items are sorted in the oldest to newest order + result = sorted(result, key=lambda x: x['published_dt']) + + # Remove complex types (datetime, etc) + # TODO: Add escape Jinja filter which is available to Orquesta workflows + keys_to_remove = ['published_dt', 'published_parsed', 'summary', 'summary_detail'] + for item in result: + for key in keys_to_remove: + if key in item: + del item[key] + + # Serialzie it to json and back to end up only with simple types + return result diff --git a/actions/lib/github_issues.py b/actions/lib/github_issues.py new file mode 100644 index 0000000..2a8449b --- /dev/null +++ b/actions/lib/github_issues.py @@ -0,0 +1,65 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import datetime + +__all__ = [ + 'get_issues_and_prs_for_repo', + 'get_issues_and_prs_for_user' +] + + +def get_issues_and_prs_for_repo(repo, time_delta): + """ + Retrieve new issues and PRs for the provided user and time period. + """ + result = { + 'issues': [], + 'pulls': [] + } + + since_dt = (datetime.now() - time_delta) + issues = list(repo.get_issues(since=since_dt)) + + for issue in issues: + # Convert Python object in a native Python type (dict) + issue_dict = issue.raw_data + issue_dict['repository'] = issue.repository.raw_data + + if issue.pull_request: + result['pulls'].append(issue_dict) + else: + result['issues'].append(issue_dict) + + return result + + +def get_issues_and_prs_for_user(github_user, time_delta): + """ + Retrieve issues and PRs for all the Github repos for the provided user. + """ + result = { + 'username': github_user.login.replace('-', '_').lower(), + 'username_friendly': github_user.login, + 'issues': [], + 'pulls': [] + } + + for repo in github_user.get_repos(): + repo_result = get_issues_and_prs_for_repo(repo=repo, time_delta=time_delta) + result['issues'].extend(repo_result['issues']) + result['pulls'].extend(repo_result['pulls']) + + return result diff --git a/actions/lib/utils.py b/actions/lib/utils.py new file mode 100644 index 0000000..576c6f0 --- /dev/null +++ b/actions/lib/utils.py @@ -0,0 +1,30 @@ +# Licensed to the StackStorm, Inc ('StackStorm') under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import timedelta + +__all__ = [ + 'get_timedelta_object_from_delta_arg' +] + +def get_timedelta_object_from_delta_arg(delta): + time_delta = timedelta( + days=delta.get('days', 0), + hours=delta.get('hours', 0), + minutes=delta.get('minutes', 0), + seconds=delta.get('seconds', 0) + ) + + return time_delta diff --git a/actions/retrieve_data_and_send_daily_stats_to_slack.yaml b/actions/retrieve_data_and_send_daily_stats_to_slack.yaml new file mode 100644 index 0000000..7b2f163 --- /dev/null +++ b/actions/retrieve_data_and_send_daily_stats_to_slack.yaml @@ -0,0 +1,25 @@ +--- +name: retrieve_data_and_send_daily_stats_to_slack +pack: st2community +description: Retrieve daily statistics and send message to Slack channel. +runner_type: orquesta +entry_point: workflows/retrieve_data_and_send_daily_stats_to_slack.yaml +enabled: true +parameters: + channel: + description: "Slack channel to send the message to." + type: string + required: true + github_users: + description: "Github users / organizations to retrieve issues and PRs for." + type: array + items: + type: "string" + required: true + delta: + description: "Time period to retrieve data for (from now - delta to now)." + type: object + required: true + default: + days: 1 + minutes: 10 diff --git a/actions/workflows/build-and-message.yaml b/actions/workflows/build-and-message.yaml deleted file mode 100644 index c471553..0000000 --- a/actions/workflows/build-and-message.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1.0 - -description: Send update to given channel. - -input: - - channel - - body - - user - - delta - -tasks: - buildMessage: - action: st2community.build-text - input: - body: <% ctx(body) %> - user: <% ctx(user) %> - delta: <% ctx(delta) %> - next: - - when: <% succeeded() %> - do: - - sendMessage - publish: - - message: <% result().result.body %> - sendMessage: - action: slack.chat.postMessage - input: - channel: <% ctx(channel) %> - text: <% ctx(message) %> diff --git a/actions/workflows/retrieve_data_and_send_daily_stats_to_slack.yaml b/actions/workflows/retrieve_data_and_send_daily_stats_to_slack.yaml new file mode 100644 index 0000000..cc1333c --- /dev/null +++ b/actions/workflows/retrieve_data_and_send_daily_stats_to_slack.yaml @@ -0,0 +1,51 @@ +version: 1.0 + +description: Retrieve data from various sources and daily summary to given Slack channel. + +input: + - github_users + - channel + - delta + +tasks: + get_forum_posts: + action: st2community.get_forum_posts + input: + delta: <% ctx(delta) %> + next: + - when: <% succeeded() %> + do: + - assemble_message + publish: + - forum_posts: <% result().result %> + get_github_issues: + action: st2community.get_github_issues + with: + items: <% ctx(github_users) %> + concurrency: 1 + input: + username: <% item() %> + delta: <% ctx(delta) %> + next: + - when: <% succeeded() %> + do: + - assemble_message + publish: + - github_data: <% task(get_github_issues).result.items.select($.result.result) %> + assemble_message: + join: all + action: st2community.assemble_message + input: + forum_posts: "<% ctx().forum_posts %>" + github_data: "<% ctx().github_data %>" + next: + - when: <% succeeded() %> + do: + - send_message_to_slack + publish: + - message: <% result().result %> + send_message_to_slack: + action: slack.chat.postMessage + input: + channel: <% ctx(channel) %> + text: <% ctx(message) %> diff --git a/config.schema.yaml b/config.schema.yaml index bf9d1fb..d2782bb 100644 --- a/config.schema.yaml +++ b/config.schema.yaml @@ -1,6 +1,11 @@ --- -token: +github_token: description: "Github authentication token." type: "string" secret: true required: true +forum_feed_url: + description: "URL to the forum RSS feed." + type: "string" + default: "https://forum.stackstorm.com/latest.rss" + required: true diff --git a/etc/message_template.j2 b/etc/message_template.j2 new file mode 100644 index 0000000..6b94764 --- /dev/null +++ b/etc/message_template.j2 @@ -0,0 +1,33 @@ +Good morning, team. Here's what happened yesterday at StackStorm. + +*Forum Posts* (*{{ forum_posts|length }}* new post(s)) +{% if forum_posts %} + {% for post in forum_posts %} + • <{{ post.link }}|{{ post.title }}> by {{ post.author }} + {% endfor %} +{% else %} +None. +{% endif %} + +{% for github_user_data in github_data %} +*{{ github_user_data['username_friendly'] }} Github Organization* + +*Issues* ({{ github_user_data['issues']|length}} new issue(s)): +{% if github_user_data['issues'] %} + {% for issue in github_user_data['issues'] %} + • <{{ issue.repository.html_url }}|{{ issue.repository.name }}>: <{{ issue.html_url }}|{{ issue.title }}> by <{{ issue.user.html_url }}|{{ issue.user.login }}> + {% endfor %} +{% else %} +None. +{% endif %} + +*Pull Requests* ({{ github_user_data['pulls']|length}} new pull request(s)): +{% if github_user_data['pulls'] %} + {% for issue in github_user_data['pulls'] %} + • <{{ issue.repository.html_url }}|{{ issue.repository.name }}>: <{{ issue.pull_request.html_url }}|{{ issue.title }}> by <{{ issue.user.html_url }}|{{ issue.user.login }}> + {% endfor %} +{% else %} +None. +{% endif %} + +{% endfor %} diff --git a/pack.yaml b/pack.yaml index f76cdd8..5af279d 100644 --- a/pack.yaml +++ b/pack.yaml @@ -2,7 +2,7 @@ ref: st2community name: st2community description: Pack to help manage Stackstorm's community. -version: 0.1.1 +version: 0.2.0 keywords: - StackStorm - Community diff --git a/requirements.txt b/requirements.txt index a0e999b..199c758 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PyGithub==1.43.4 Jinja2==2.10 +feedparser==5.2.1 diff --git a/rules/exchange.yaml b/rules/exchange.yaml deleted file mode 100644 index 10197c4..0000000 --- a/rules/exchange.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: "community_update" -pack: "st2community" -description: "Daily publish previous 24 hours statistics." -enabled: true - -trigger: - type: "core.st2.CronTimer" - parameters: - timezone: "UTC" - day_of_week: '*' - hour: 17 - minute: 30 - second: 0 - -action: - ref: "st2community.build-and-message" - parameters: - user: "StackStorm-Exchange" - channel: "#support" - delta: - days: 1 - minutes: 10 - body: | - Good morning, @oncall. Here's your community update. Yesterday there were *[[ new_issue_count ]]* new issue(s), and *[[ new_pull_count ]]* new pull request(s). - - [% if pulls %]*Pull Requests* - [% for pull in pulls %]• <[[ pull.base.repo.html_url ]]|[[ pull.base.repo.name ]]>: <[[ pull.html_url ]]|[[ pull.title ]]> by <[[ pull.user.html_url ]]|[[ pull.user.name ]]> - [% endfor %] - [% endif %][% if issues %]*Issues:* - [% for issue in issues %]• <[[ issue.repository.html_url ]]|[[ issue.repository.name ]]>: <[[ issue.html_url ]]|[[ issue.title ]]> by <[[ issue.user.html_url ]]|[[ issue.user.name ]]> - [% endfor %][% endif %] diff --git a/rules/post_daily_summary_to_slack.yaml b/rules/post_daily_summary_to_slack.yaml new file mode 100644 index 0000000..2ddd7ed --- /dev/null +++ b/rules/post_daily_summary_to_slack.yaml @@ -0,0 +1,25 @@ +--- +name: "post_daily_summary_to_slack" +pack: "st2community" +description: "Gather and publish various statistics for previous 24 hours to Slack." +enabled: true + +trigger: + type: "core.st2.CronTimer" + parameters: + timezone: "UTC" + day_of_week: '*' + hour: 17 + minute: 30 + second: 0 + +action: + ref: "st2community.retrieve_data_and_send_daily_stats_to_slack" + parameters: + channel: "#stackstorm" + github_users: + - "StackStorm" + - "StackStorm-Exchange" + delta: + days: 1 + minutes: 10 diff --git a/rules/st2.yaml b/rules/st2.yaml deleted file mode 100644 index 0e956fe..0000000 --- a/rules/st2.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: "stackstorm_update" -pack: "st2community" -description: "Daily publish previous 24 hours statistics." -enabled: true - -trigger: - type: "core.st2.CronTimer" - parameters: - timezone: "UTC" - day_of_week: '*' - hour: 17 - minute: 30 - second: 0 - -action: - ref: "st2community.build-and-message" - parameters: - user: "StackStorm" - channel: "#stackstorm" - delta: - days: 1 - minutes: 10 - body: | - Good morning, team. Here's what happened yesterday at ST2. There were *[[ new_issue_count ]]* new issue(s), and *[[ new_pull_count ]]* new pull request(s). - - [% if pulls %]*Pull Requests* - [% for pull in pulls %]• <[[ pull.base.repo.html_url ]]|[[ pull.base.repo.name ]]>: <[[ pull.html_url ]]|[[ pull.title ]]> by <[[ pull.user.html_url ]]|[[ pull.user.name ]]> - [% endfor %] - [% endif %][% if issues %]*Issues:* - [% for issue in issues %]• <[[ issue.repository.html_url ]]|[[ issue.repository.name ]]>: <[[ issue.html_url ]]|[[ issue.title ]]> by <[[ issue.user.html_url ]]|[[ issue.user.name ]]> - [% endfor %][% endif %]