1from __future__ import unicode_literals
2
3import logging
4import re
5
6from rbtools.api.errors import APIError
7from rbtools.clients.errors import InvalidRevisionSpecError
8from rbtools.utils.match_score import Score
9from rbtools.utils.repository import get_repository_id
10from rbtools.utils.users import get_user
11
12
13def get_draft_or_current_value(field_name, review_request):
14    """Returns the draft or current field value from a review request.
15
16    If a draft exists for the supplied review request, return the draft's
17    field value for the supplied field name, otherwise return the review
18    request's field value for the supplied field name.
19    """
20    if review_request.draft:
21        fields = review_request.draft[0]
22    else:
23        fields = review_request
24
25    return fields[field_name]
26
27
28def get_possible_matches(review_requests, summary, description, limit=5):
29    """Returns a sorted list of tuples of score and review request.
30
31    Each review request is given a score based on the summary and
32    description provided. The result is a sorted list of tuples containing
33    the score and the corresponding review request, sorted by the highest
34    scoring review request first.
35    """
36    candidates = []
37
38    # Get all potential matches.
39    for review_request in review_requests.all_items:
40        summary_pair = (get_draft_or_current_value('summary', review_request),
41                        summary)
42        description_pair = (get_draft_or_current_value('description',
43                                                       review_request),
44                            description)
45        score = Score.get_match(summary_pair, description_pair)
46        candidates.append((score, review_request))
47
48    # Sort by summary and description on descending rank.
49    sorted_candidates = sorted(
50        candidates,
51        key=lambda m: (m[0].summary_score, m[0].description_score),
52        reverse=True
53    )
54
55    return sorted_candidates[:limit]
56
57
58def get_revisions(tool, cmd_args):
59    """Returns the parsed revisions from the command line arguments.
60
61    These revisions are used for diff generation and commit message
62    extraction. They will be cached for future calls.
63    """
64    # Parse the provided revisions from the command line and generate
65    # a spec or set of specialized extra arguments that the SCMClient
66    # can use for diffing and commit lookups.
67    try:
68        revisions = tool.parse_revision_spec(cmd_args)
69    except InvalidRevisionSpecError:
70        if not tool.supports_diff_extra_args:
71            raise
72
73        revisions = None
74
75    return revisions
76
77
78def find_review_request_by_change_id(api_client, api_root, repository_info,
79                                     repository_name, revisions):
80    """Ask ReviewBoard for the review request ID for the tip revision.
81
82    Note that this function calls the ReviewBoard API with the only_fields
83    paramater, thus the returned review request will contain only the fields
84    specified by the only_fields variable.
85
86    If no review request is found, None will be returned instead.
87    """
88    only_fields = 'id,commit_id,changenum,status,url,absolute_url'
89    change_id = revisions['tip']
90    logging.debug('Attempting to find review request from tip revision ID: %s',
91                  change_id)
92    # Strip off any prefix that might have been added by the SCM.
93    change_id = change_id.split(':', 1)[1]
94
95    optional_args = {}
96
97    if change_id.isdigit():
98        # Populate integer-only changenum field also for compatibility
99        # with older API versions
100        optional_args['changenum'] = int(change_id)
101
102    user = get_user(api_client, api_root, auth_required=True)
103    repository_id = get_repository_id(
104        repository_info, api_root, repository_name)
105
106    # Don't limit query to only pending requests because it's okay to stamp a
107    # submitted review.
108    review_requests = api_root.get_review_requests(repository=repository_id,
109                                                   from_user=user.username,
110                                                   commit_id=change_id,
111                                                   only_links='self',
112                                                   only_fields=only_fields,
113                                                   **optional_args)
114
115    if review_requests:
116        count = review_requests.total_results
117
118        # Only one review can be associated with a specific commit ID.
119        if count > 0:
120            assert count == 1, '%d review requests were returned' % count
121            review_request = review_requests[0]
122            logging.debug('Found review request %s with status %s',
123                          review_request.id, review_request.status)
124
125            if review_request.status != 'discarded':
126                return review_request
127
128    return None
129
130
131def guess_existing_review_request(repository_info, repository_name,
132                                  api_root, api_client, tool, revisions,
133                                  guess_summary, guess_description,
134                                  is_fuzzy_match_func=None,
135                                  no_commit_error=None,
136                                  submit_as=None, additional_fields=None):
137    """Try to guess the existing review request ID if it is available.
138
139    The existing review request is guessed by comparing the existing
140    summary and description to the current post's summary and description,
141    respectively. The current post's summary and description are guessed if
142    they are not provided.
143
144    If the summary and description exactly match those of an existing
145    review request, that request is immediately returned. Otherwise,
146    the user is prompted to select from a list of potential matches,
147    sorted by the highest ranked match first.
148
149    Note that this function calls the ReviewBoard API with the only_fields
150    paramater, thus the returned review request will contain only the fields
151    specified by the only_fields variable.
152    """
153    only_fields = [
154        'id', 'summary', 'description', 'draft', 'url', 'absolute_url',
155        'bugs_closed', 'status', 'public'
156    ]
157
158    if additional_fields:
159        only_fields += additional_fields
160
161    if submit_as:
162        username = submit_as
163    else:
164        user = get_user(api_client, api_root, auth_required=True)
165        username = user.username
166
167    repository_id = get_repository_id(
168        repository_info, api_root, repository_name)
169
170    try:
171        # Get only pending requests by the current user for this
172        # repository.
173        review_requests = api_root.get_review_requests(
174            repository=repository_id,
175            from_user=username,
176            status='pending',
177            expand='draft',
178            only_fields=','.join(only_fields),
179            only_links='diffs,draft',
180            show_all_unpublished=True)
181
182        if not review_requests:
183            raise ValueError('No existing review requests to update for '
184                             'user %s'
185                             % username)
186    except APIError as e:
187        raise ValueError('Error getting review requests for user %s: %s'
188                         % (username, e))
189
190    summary = None
191    description = None
192
193    if not guess_summary or not guess_description:
194        try:
195            commit_message = tool.get_commit_message(revisions)
196
197            if commit_message:
198                if not guess_summary:
199                    summary = commit_message['summary']
200
201                if not guess_description:
202                    description = commit_message['description']
203            elif callable(no_commit_error):
204                no_commit_error()
205        except NotImplementedError:
206            raise ValueError('--summary and --description are required.')
207
208    if not summary and not description:
209        return None
210
211    possible_matches = get_possible_matches(review_requests, summary,
212                                            description)
213    exact_match_count = num_exact_matches(possible_matches)
214
215    for score, review_request in possible_matches:
216        # If the score is the only exact match, return the review request
217        # ID without confirmation, otherwise prompt.
218        if ((score.is_exact_match() and exact_match_count == 1) or
219            (callable(is_fuzzy_match_func) and
220             is_fuzzy_match_func(review_request))):
221            return review_request
222
223    return None
224
225
226def num_exact_matches(possible_matches):
227    """Returns the number of exact matches in the possible match list."""
228    count = 0
229
230    for score, request in possible_matches:
231        if score.is_exact_match():
232            count += 1
233
234    return count
235
236
237def parse_review_request_url(url):
238    """Parse a review request URL and return its component parts.
239
240    Args:
241        url (unicode):
242            The URL to parse.
243
244    Returns:
245        tuple:
246        A 3-tuple consisting of the server URL, the review request ID, and the
247        diff revision.
248    """
249    regex = (r'^(?P<server_url>https?:\/\/.*\/(?:\/s\/[^\/]+\/)?)'
250             r'r\/(?P<review_request_id>\d+)'
251             r'\/?(diff\/(?P<diff_id>\d+-?\d*))?\/?')
252    match = re.match(regex, url)
253
254    if match:
255        server_url = match.group('server_url')
256        request_id = match.group('review_request_id')
257        diff_id = match.group('diff_id')
258        return (server_url, request_id, diff_id)
259
260    return (None, None, None)
261