1# This file is part of Buildbot.  Buildbot is free software: you can
2# redistribute it and/or modify it under the terms of the GNU General Public
3# License as published by the Free Software Foundation, version 2.
4#
5# This program is distributed in the hope that it will be useful, but WITHOUT
6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
7# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
8# details.
9#
10# You should have received a copy of the GNU General Public License along with
11# this program; if not, write to the Free Software Foundation, Inc., 51
12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
13#
14# Copyright Buildbot Team Members
15
16
17import jinja2
18
19from twisted.internet import defer
20
21from buildbot import util
22from buildbot.process.properties import Properties
23from buildbot.process.results import CANCELLED
24from buildbot.process.results import EXCEPTION
25from buildbot.process.results import FAILURE
26from buildbot.process.results import SUCCESS
27from buildbot.process.results import WARNINGS
28from buildbot.process.results import statusToString
29from buildbot.reporters import utils
30from buildbot.warnings import warn_deprecated
31
32
33def get_detected_status_text(mode, results, previous_results):
34    if results == FAILURE:
35        if ('change' in mode or 'problem' in mode) and previous_results is not None \
36                and previous_results != FAILURE:
37            text = "new failure"
38        else:
39            text = "failed build"
40    elif results == WARNINGS:
41        text = "problem in the build"
42    elif results == SUCCESS:
43        if "change" in mode and previous_results is not None and previous_results != results:
44            text = "restored build"
45        else:
46            text = "passing build"
47    elif results == EXCEPTION:
48        text = "build exception"
49    else:
50        text = "{} build".format(statusToString(results))
51
52    return text
53
54
55def get_message_summary_text(build, results):
56    t = build['state_string']
57    if t:
58        t = ": " + t
59    else:
60        t = ""
61
62    if results == SUCCESS:
63        text = "Build succeeded!"
64    elif results == WARNINGS:
65        text = "Build Had Warnings{}".format(t)
66    elif results == CANCELLED:
67        text = "Build was cancelled"
68    else:
69        text = "BUILD FAILED{}".format(t)
70
71    return text
72
73
74def get_message_source_stamp_text(source_stamps):
75    text = ""
76
77    for ss in source_stamps:
78        source = ""
79
80        if ss['branch']:
81            source += "[branch {}] ".format(ss['branch'])
82
83        if ss['revision']:
84            source += str(ss['revision'])
85        else:
86            source += "HEAD"
87
88        if ss['patch'] is not None:
89            source += " (plus patch)"
90
91        discriminator = ""
92        if ss['codebase']:
93            discriminator = " '{}'".format(ss['codebase'])
94
95        text += "Build Source Stamp{}: {}\n".format(discriminator, source)
96
97    return text
98
99
100def get_projects_text(source_stamps, master):
101    projects = set()
102
103    for ss in source_stamps:
104        if ss['project']:
105            projects.add(ss['project'])
106
107    if not projects:
108        projects = [master.config.title]
109
110    return ', '.join(list(projects))
111
112
113def create_context_for_build(mode, build, master, blamelist):
114    buildset = build['buildset']
115    ss_list = buildset['sourcestamps']
116    results = build['results']
117
118    if 'prev_build' in build and build['prev_build'] is not None:
119        previous_results = build['prev_build']['results']
120    else:
121        previous_results = None
122
123    return {
124        'results': build['results'],
125        'mode': mode,
126        'buildername': build['builder']['name'],
127        'workername': build['properties'].get('workername', ["<unknown>"])[0],
128        'buildset': buildset,
129        'build': build,
130        'projects': get_projects_text(ss_list, master),
131        'previous_results': previous_results,
132        'status_detected': get_detected_status_text(mode, results, previous_results),
133        'build_url': utils.getURLForBuild(master, build['builder']['builderid'], build['number']),
134        'buildbot_url': master.config.buildbotURL,
135        'blamelist': blamelist,
136        'summary': get_message_summary_text(build, results),
137        'sourcestamps': get_message_source_stamp_text(ss_list)
138    }
139
140
141def create_context_for_worker(master, worker):
142    return {
143        'buildbot_title': master.config.title,
144        'buildbot_url': master.config.buildbotURL,
145        'worker': worker,
146    }
147
148
149class MessageFormatterBase(util.ComparableMixin):
150
151    template_type = 'plain'
152
153    def __init__(self, ctx=None, want_properties=True, wantProperties=None,
154                 want_steps=False, wantSteps=None, wantLogs=None,
155                 want_logs=False, want_logs_content=False):
156        if ctx is None:
157            ctx = {}
158        self.context = ctx
159        if wantProperties is not None:
160            warn_deprecated('3.4.0', f'{self.__class__.__name__}: wantProperties has been '
161                                     'deprecated, use want_properties')
162            self.want_properties = wantProperties
163        else:
164            self.want_properties = want_properties
165        if wantSteps is not None:
166            warn_deprecated('3.4.0', f'{self.__class__.__name__}: wantSteps has been deprecated, ' +
167                                     'use want_steps')
168            self.want_steps = wantSteps
169        else:
170            self.want_steps = want_steps
171        if wantLogs is not None:
172            warn_deprecated('3.4.0', f'{self.__class__.__name__}: wantLogs has been deprecated, ' +
173                                     'use want_logs and want_logs_content')
174        else:
175            wantLogs = False
176
177        self.want_logs = want_logs or wantLogs
178        self.want_logs_content = want_logs_content or wantLogs
179
180    def buildAdditionalContext(self, master, ctx):
181        pass
182
183    @defer.inlineCallbacks
184    def render_message_dict(self, master, context):
185        """Generate a buildbot reporter message and return a dictionary
186           containing the message body, type and subject."""
187
188        ''' This is an informal description of what message dictionaries are expected to be
189            produced. It is an internal API and expected to change even within bugfix releases, if
190            needed.
191
192            The message dictionary contains the 'body', 'type' and 'subject' keys:
193
194              - 'subject' is a string that defines a subject of the message. It's not necessarily
195                used on all reporters. It may be None.
196
197              - 'type' must be 'plain', 'html' or 'json'.
198
199              - 'body' is the content of the message. It may be None. The type of the data depends
200                on the value of the 'type' parameter:
201
202                - 'plain': Must be a string
203
204                - 'html': Must be a string
205
206                - 'json': Must be a non-encoded jsonnable value. The root element must be either
207                  of dictionary, list or string. This must not change during all invocations of
208                  a particular instance of the formatter.
209
210            In case of a report being created for multiple builds (e.g. in the case of a buildset),
211            the values returned by message formatter are concatenated. If this is not possible
212            (e.g. if the body is a dictionary), any subsequent messages are ignored.
213        '''
214        yield self.buildAdditionalContext(master, context)
215        context.update(self.context)
216
217        return {
218            'body': (yield self.render_message_body(context)),
219            'type': self.template_type,
220            'subject': (yield self.render_message_subject(context))
221        }
222
223    def render_message_body(self, context):
224        return None
225
226    def render_message_subject(self, context):
227        return None
228
229    def format_message_for_build(self, master, build, **kwargs):
230        # Known kwargs keys: mode, users
231        raise NotImplementedError
232
233
234class MessageFormatterEmpty(MessageFormatterBase):
235    def format_message_for_build(self, master, build, **kwargs):
236        return {
237            'body': None,
238            'type': 'plain',
239            'subject': None
240        }
241
242
243class MessageFormatterFunction(MessageFormatterBase):
244
245    def __init__(self, function, template_type, **kwargs):
246        super().__init__(**kwargs)
247        self.template_type = template_type
248        self._function = function
249
250    @defer.inlineCallbacks
251    def format_message_for_build(self, master, build, **kwargs):
252        msgdict = yield self.render_message_dict(master, {'build': build})
253        return msgdict
254
255    def render_message_body(self, context):
256        return self._function(context)
257
258    def render_message_subject(self, context):
259        return None
260
261
262class MessageFormatterRenderable(MessageFormatterBase):
263
264    template_type = 'plain'
265
266    def __init__(self, template, subject=None):
267        super().__init__()
268        self.template = template
269        self.subject = subject
270
271    @defer.inlineCallbacks
272    def format_message_for_build(self, master, build, **kwargs):
273        msgdict = yield self.render_message_dict(master, {'build': build, 'master': master})
274        return msgdict
275
276    @defer.inlineCallbacks
277    def render_message_body(self, context):
278        props = Properties.fromDict(context['build']['properties'])
279        props.master = context['master']
280
281        body = yield props.render(self.template)
282        return body
283
284    @defer.inlineCallbacks
285    def render_message_subject(self, context):
286        props = Properties.fromDict(context['build']['properties'])
287        props.master = context['master']
288
289        body = yield props.render(self.subject)
290        return body
291
292
293default_body_template = '''\
294The Buildbot has detected a {{ status_detected }} on builder {{ buildername }} while building {{ projects }}.
295Full details are available at:
296    {{ build_url }}
297
298Buildbot URL: {{ buildbot_url }}
299
300Worker for this Build: {{ workername }}
301
302Build Reason: {{ build['properties'].get('reason', ["<unknown>"])[0] }}
303Blamelist: {{ ", ".join(blamelist) }}
304
305{{ summary }}
306
307Sincerely,
308 -The Buildbot
309'''  # noqa pylint: disable=line-too-long
310
311
312class MessageFormatterBaseJinja(MessageFormatterBase):
313    compare_attrs = ['body_template', 'subject_template', 'template_type']
314    subject_template = None
315    template_type = 'plain'
316
317    def __init__(self, template=None, subject=None, template_type=None, **kwargs):
318        if template is None:
319            template = default_body_template
320
321        self.body_template = jinja2.Template(template)
322
323        if subject is not None:
324            self.subject_template = jinja2.Template(subject)
325
326        if template_type is not None:
327            self.template_type = template_type
328
329        super().__init__(**kwargs)
330
331    def buildAdditionalContext(self, master, ctx):
332        pass
333
334    def render_message_body(self, context):
335        return self.body_template.render(context)
336
337    def render_message_subject(self, context):
338        if self.subject_template is None:
339            return None
340        return self.subject_template.render(context)
341
342
343class MessageFormatter(MessageFormatterBaseJinja):
344    @defer.inlineCallbacks
345    def format_message_for_build(self, master, build, users=None, mode=None):
346        ctx = create_context_for_build(mode, build, master, users)
347        msgdict = yield self.render_message_dict(master, ctx)
348        return msgdict
349
350
351default_missing_template = '''\
352The Buildbot working for '{{buildbot_title}}' has noticed that the worker named {{worker.name}} went away.
353
354It last disconnected at {{worker.last_connection}}.
355
356{% if 'admin' in worker['workerinfo'] %}
357The admin on record (as reported by WORKER:info/admin) was {{worker.workerinfo.admin}}.
358{% endif %}
359
360Sincerely,
361 -The Buildbot
362'''  # noqa pylint: disable=line-too-long
363
364
365class MessageFormatterMissingWorker(MessageFormatterBaseJinja):
366    template_filename = 'missing_mail.txt'
367
368    def __init__(self, template=None, **kwargs):
369        if template is None:
370            template = default_missing_template
371        super().__init__(template=template, **kwargs)
372
373    @defer.inlineCallbacks
374    def formatMessageForMissingWorker(self, master, worker):
375        ctx = create_context_for_worker(master, worker)
376        msgdict = yield self.render_message_dict(master, ctx)
377        return msgdict
378