Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changes to allow MergeBot to post to the Commit Status API #19

Merged
merged 4 commits into from
Jul 19, 2017
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
54 changes: 50 additions & 4 deletions mergebot_backend/github_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
BOT_NAME = 'asfgit'
GITHUB_SECRET = 'mergebot.secret'

COMMENT_PAYLOAD = '{{"body": "{body}"}}'
COMMIT_STATUS_PAYLOAD = """
{{
"state": "{state}",
"target_url": "{url}",
"description": "{description}",
"context": "{context}"
}}
"""
COMMIT_STATE_PENDING = 'pending'
COMMIT_STATE_SUCCESS = 'success'
COMMIT_STATE_ERROR = 'error' # error represents a problem with MergeBot.
COMMIT_STATE_FAILURE = 'failure' # failure represents a problem with the PR.


class GithubHelper(object):
"""GithubHelper is a lightweight authenticated wrapper for the Github API.
Expand Down Expand Up @@ -60,17 +74,16 @@ def get(self, endpoint):
return resp.json()
resp.raise_for_status()

def post(self, content, endpoint):
def post(self, payload, endpoint):
"""post makes a POST request against a specified endpoint.

Args:
content: data to send in POST body.
payload: data to send in POST body.
endpoint: URL to which to make the POST request. URL is relative
to https://api.github.com/repos/{self.org}/{self.repo}/
Returns:
None if request returned successfully, error otherwise.
"""
payload = '{{"body": "{}"}}'.format(content)
url = urlparse.urljoin(self.repo_url, endpoint)
try:
resp = requests.post(url, data=payload, auth=(BOT_NAME,
Expand All @@ -97,6 +110,7 @@ def __init__(self, helper, pr_object):
self.helper = helper
self.pr_num = pr_object['number']
self.head_sha = pr_object['head']['sha']
self.statuses_url = pr_object['statuses_url']
self.comments_url = pr_object['comments_url']
self.updated = dateutil.parser.parse(pr_object['updated_at'])
self.metadata = {}
Expand Down Expand Up @@ -155,14 +169,46 @@ def post_info(self, content, logger):
"""
self.post_pr_comment('Info: {}'.format(content), logger)

def post_commit_status(self, state, url, description, context, logger):
""" Posts a commit status update to this PR.
Copy link

@markflyhigh markflyhigh Jul 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct me if I'm wrong, it looks like a function that posts/updates merge status to the particular PR. If that's true, how about something like post/update_merge_status? since commit makes me think of a specific commit in a PR.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is actually attached to a particular commit, and is using the "Commit Status API" from GitHub.

https://developer.github.com/v3/repos/statuses/

Statuses need to be attached to specific commits since they are what represent the HEAD of a particular pull request. GitHub doesn't have any other way to do this.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it.


Args:
state: State for status; can be 'pending', 'success', 'error', or
'failure'.
url: URL at which more information can be found.
description: Description to show on commit status.
context: Unique name for this particular status line.
logger: Logger to use for reporting failure.
"""
payload = COMMIT_STATUS_PAYLOAD.format(
state=state,
url=url,
description=description,
context=context
)
err = self.helper.post(payload, self.statuses_url)
if err:
logger.error(
"Couldn't set commit status for pr {pr_num}: {err}. Context: "
"{context}, State: {state}, Description: {description}, URL: "
"{url}. Falling back to posting a comment.".format(
pr_num=self.pr_num, err=err, context=context, state=state,
description=description, url=url))
self.post_pr_comment(
'{context}: PR {pr_num} in state {state} with description '
'{description}. URL: {url}.'.format(
context=context, pr_num=self.pr_num, state=state,
description=description, url=url), logger)

def post_pr_comment(self, content, logger):
"""Posts a PR comment to Github.

Args:
content: the content to post.
logger: logger to send error to if post fails.
"""
err = self.helper.post(content, self.comments_url)
payload = COMMENT_PAYLOAD.format(body=content)
err = self.helper.post(payload, self.comments_url)
if err is not None:
logger.error(
"Couldn't post comment '{cmt}' to Github: {err}.".format(
Expand Down
106 changes: 93 additions & 13 deletions mergebot_backend/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from threading import Thread

from mergebot_backend import db_publisher
from mergebot_backend import github_helper
from mergebot_backend.log_helper import get_logger

TMP_DIR_FMT = '/tmp/{dir}'
Expand All @@ -24,6 +25,8 @@
JENKINS_STARTED_MSG = ('Job verification started. Verification job is [here]'
'({build_url}) (may still be pending; if page 404s, '
'check job status page [here]({job_url})).')
MERGEBOT_MAIN_URL = 'http://mergebot-vm.apache.org/'
MERGEBOT_ITEM_URL = 'http://mergebot-vm.apache.org/{name}/{number}'


class Terminate(Exception):
Expand Down Expand Up @@ -142,7 +145,7 @@ class GitMerger(Merger):
APACHE_GIT = 'https://gitbox.apache.org/repos/asf/{repo}.git'
GITHUB_REPO_URL = 'https://github.com/{org}/{repo}.git'

JOB_START_TIMEOUT = 300
JOB_START_TIMEOUT = 600
WAIT_INTERVAL = 10

def __init__(self, config, work_queue, pipe):
Expand Down Expand Up @@ -190,6 +193,9 @@ def run(self):
return
time.sleep(1)

if not isinstance(pr, github_helper.GithubPR):
raise AttributeError

pr_num = pr.get_num()
self.merge_logger.info(
'Starting work on PR#{pr_num}.'.format(pr_num=pr_num))
Expand All @@ -200,12 +206,26 @@ def run(self):
info="Requested by {user} at {time}.".format(
user=pr.metadata['asf_id'], time=pr.metadata['created']))
try:
pr.post_info(
'MergeBot starting work on PR#{pr_num}.'.format(
pr_num=pr_num),
self.merge_logger)
pr.post_commit_status(
state=github_helper.COMMIT_STATE_PENDING,
url=MERGEBOT_ITEM_URL.format(
name=self.config.name, number=pr_num),
description='Started Work',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start merging or start running jenkins build?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This indicates that MergeBot has begun work on this PR. I could add additional commit status updates which show when the status changes in intermediary ways, if that's something you think would be useful.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's clear enough. I have better understanding after saw the real case :)

context='MergeBot: Merge',
logger=self.merge_logger)
self.merge_git_pr(pr)
except BaseException as exc:
pr.post_commit_status(
state=github_helper.COMMIT_STATE_ERROR,
url=MERGEBOT_ITEM_URL.format(
name=self.config.name, number=pr_num),
description='Unexpected Error: {exc}.'.format(exc=exc),
context='MergeBot: Merge',
logger=self.merge_logger)
# In the case of failure, we still post back to the pull request
# because otherwise if mergebot is stopped and restarted we will
# try this pull request again. If mergebot is modified to work
# with webhooks this can be removed.
pr.post_error(
'MergeBot encountered an unexpected error while processing '
'this PR: {exc}.'.format(exc=exc), self.merge_logger)
Expand All @@ -222,7 +242,7 @@ def run(self):

def fetch_from_queue(self):
"""fetch_from_queue tries to pull a PR off of the queue.

Raises:
merge.Terminate if we receive the termination signal.
Returns:
Expand Down Expand Up @@ -254,12 +274,22 @@ def fetch_from_queue(self):

def flush_queue(self):
"""flush_queue posts a shutdown message to a PR in the queue.

Raises:
Queue.Empty if there is nothing in the queue.
"""
pr = self.work_queue.get_nowait()
self.publisher.publish_dequeue(item_id=pr.get_num())
pr.post_commit_status(
state=github_helper.COMMIT_STATE_ERROR,
url=MERGEBOT_MAIN_URL,
description='MergeBot shutdown; resubmit when mergebot is back up.',
context='MergeBot: Merge',
logger=self.merge_logger)
# In the case of shutdown, we still post back to the pull request
# because otherwise if mergebot is stopped and restarted we will try
# this pull request again. If mergebot is modified to work with webhooks
# this can be removed.
pr.post_info('MergeBot shutting down; please resubmit when MergeBot is '

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to post similar content comparing to line 286? I guess it's another comment just right next to the above one?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by this -- in this section we're both updating the commit status (line ~286), and posting a comment on the PR indicating the same message.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously thought the status commit is the place where people commenting on a PR. I think it's good now 👍

'back up.', self.merge_logger)

Expand Down Expand Up @@ -289,6 +319,17 @@ def merge_git_pr(self, pr):
_set_up(tmp_dir)
self.execute_merge_lifecycle(pr, tmp_dir, pr_logger)
except AssertionError as exc:
pr.post_commit_status(
state=github_helper.COMMIT_STATE_FAILURE,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why "failure" not "error"?

Also i'm thinking if someone that first use mergebot, how can they tell difference between failure and error status? Do I miss anything that contains more instructions?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like the description tell the difference.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AssertionErrors indicate something has gone preventably wrong in the PR (as mentioned in the above comment) and thus FAILURE is the appropriate level.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it.

url=MERGEBOT_ITEM_URL.format(
name=self.config.name, number=pr.get_num()),
description=exc.message,
context='MergeBot: Merge',
logger=self.merge_logger)
# In the case of failure, we still post back to the pull request
# because otherwise if mergebot is stopped and restarted we will
# try this pull request again. If mergebot is modified to work with
# webhooks this can be removed.
pr.post_error(exc, pr_logger)
self.publisher.publish_item_status(
item_id=pr_num, status='ERROR',
Expand Down Expand Up @@ -340,8 +381,19 @@ def execute_merge_lifecycle(self, pr, tmp_dir, pr_logger):
self.publisher.publish_item_status(item_id=pr_num, status='PREPARE')
run_cmds(self.PREPARE_CMDS, pr_vars, tmp_dir, pr_logger)
self.publisher.publish_item_status(item_id=pr_num, status='MERGE')
# Success commit status needs to be posted before actual success because
# we lose the ability to update a commit status after the PR is closed.
# If FINAL_CMDS are not successful, we'll update the commit status with
# an error as part of the exception handling process, so the user won't
# see success turn into failure.
pr.post_commit_status(
state=github_helper.COMMIT_STATE_SUCCESS,
url=MERGEBOT_ITEM_URL.format(
name=self.config.name, number=pr_num),
description='Merge Succeeded!',
context='MergeBot: Merge',
logger=self.merge_logger)
run_cmds(self.FINAL_CMDS, pr_vars, tmp_dir, pr_logger)
pr.post_info('PR merge succeeded!', pr_logger)
pr_logger.info(
'Merge for {pr_num} completed successfully.'.format(pr_num=pr_num))

Expand All @@ -363,6 +415,15 @@ def verify_pr_via_jenkins(self, job, build_num, pr, pr_logger):
pr_num = pr.get_num()
self.publisher.publish_item_status(item_id=pr_num,
status='WAIT_ON_JOB_START')
job_url = urlparse.urljoin(
self.config.jenkins_location, '/job/{job_name}/'.format(
job_name=self.config.verification_job_name))
pr.post_commit_status(
state=github_helper.COMMIT_STATE_PENDING,
url=job_url,
description='Starting verification job.',
context='MergeBot: Verification Job',
logger=self.merge_logger)
while not build and wait_secs < self.JOB_START_TIMEOUT:
try:
build = job.get_build(build_num)
Expand All @@ -372,18 +433,25 @@ def verify_pr_via_jenkins(self, job, build_num, pr, pr_logger):
time.sleep(self.WAIT_INTERVAL)
wait_secs += self.WAIT_INTERVAL

job_url = urlparse.urljoin(
self.config.jenkins_location, '/job/{job_name}/'.format(
job_name=self.config.verification_job_name))
if not build:
pr_logger.error('Timed out trying to find the verification job.')
pr.post_commit_status(
state=github_helper.COMMIT_STATE_ERROR,
url=job_url,
description='Timed out waiting for job start.',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes Jenkins doesn't have enough executors so job will be queued. Is it possible that job is queued and timed out on mergebot side?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this will still find queued jobs, but I'll investigate that and get back to you.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't appear to find queued jobs. I've bumped the timeout to 10 minutes to compensate -- if jobs are waiting longer than 10 minutes we probably need new executors.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be resource waste if timeout here since Jenkins will run the queued job anyway. Another issue is created and we can address it in another PR.

context='MergeBot: Verification Job',
logger=self.merge_logger)
raise AssertionError(JENKINS_TIMEOUT_ERR.format(url=job_url))

pr_logger.info("Build #{build_num} found.".format(build_num=build_num))
build_url = urlparse.urljoin(job_url, str(build_num))

pr.post_info(JENKINS_STARTED_MSG.format(build_url=build_url,
job_url=job_url), pr_logger)
pr.post_commit_status(
state=github_helper.COMMIT_STATE_PENDING,
url=build_url,
description='Verification job running.',
context='MergeBot: Verification Job',
logger=self.merge_logger)
self.publisher.publish_item_status(
item_id=pr_num, status='JOB_FOUND',
info='Build URL: {build_url}'.format(build_url=build_url))
Expand All @@ -403,7 +471,19 @@ def verify_pr_via_jenkins(self, job, build_num, pr, pr_logger):
# find job status anywhere on the object.
build = job.get_build(build_num)
if build.get_status() == "SUCCESS":
pr.post_commit_status(
state=github_helper.COMMIT_STATE_SUCCESS,
url=build_url,
description='Verification job succeeded!',
context='MergeBot: Verification Job',
logger=self.merge_logger)
return True
pr.post_commit_status(
state=github_helper.COMMIT_STATE_FAILURE,
url=build_url,
description='Verification job failed.',
context='MergeBot: Verification Job',
logger=self.merge_logger)
return False


Expand Down
9 changes: 7 additions & 2 deletions mergebot_backend/mergebot_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from mergebot_backend.log_helper import get_logger

BOT_NAME = 'asfgit'
MERGEBOT_PROJ_URL = 'http://mergebot-vm.apache.org/{name}'


def create_poller(config, comm_pipe):
Expand Down Expand Up @@ -213,8 +214,12 @@ def merge_git(self, pull):
contract with search_github_pr since other commands could fail.
"""
self.l.info('Command was merge, adding to merge queue.')
pull.post_info('Adding PR to work queue; current position: '
'{pos}.'.format(pos=self.work_queue.qsize() + 1), self.l)
pull.post_commit_status(
state=github_helper.COMMIT_STATE_PENDING,
url=MERGEBOT_PROJ_URL.format(name=self.config.name),
description='In Queue',
context='MergeBot: Merge',
logger=self.l)
self.publisher.publish_enqueue(item_id=pull.get_num())
self.work_queue.put(pull)
return True
9 changes: 5 additions & 4 deletions mergebot_backend/tests/merge_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ class MergeTest(unittest.TestCase):

def setUp(self):
self.config = MergeBotConfig(
name='test', github_org='test', repository='test',
name='test', proj_name='test', github_org='test', repository='test',
merge_branch='test', verification_branch='test', scm_type='github',
jenkins_location='http://test.test', verification_job_name='test')
jenkins_location='http://test.test', prepare_command='test',
verification_job_name='test')

@patch('mergebot_backend.db_publisher.DBPublisher')
@patch('mergebot_backend.merge.get_logger')
Expand Down Expand Up @@ -135,8 +136,8 @@ def test_execute_merge_lifecycle_validation_success(
mock_jenkins.return_value = {'test': mock_job}
mock_verify.return_value = True
gm.execute_merge_lifecycle(mock_pr, '/tmp', mock_logger)
self.assertEqual(gm.publisher.publish_item_status.call_count, 3)
self.assertEqual(mock_run_cmds.call_count, 3)
self.assertEqual(gm.publisher.publish_item_status.call_count, 4)
self.assertEqual(mock_run_cmds.call_count, 4)

@patch('logging.Logger')
@patch('mergebot_backend.github_helper.GithubPR')
Expand Down
Loading