5
0
mirror of https://github.com/odoo/runbot.git synced 2025-03-16 07:55:45 +07:00

[REF] runbot, runbot_cla: upgrade to Odoo 11.0

The previous code of runbot and runbot_cla was made for Odoo API version
8.0. This commit makes it work with Odoo API 11.0 and Python 3.

Also, the present refactoring splits the code into multiple files to
make it easier to read (I hope).

The main change due to Python 3 is the job locking mechanism:
Since PEP-446 file descriptors are non-inheritable by default.
A new method (os.set_inheritable) was introduced to explicitely make
fd inheritable. Also, the close_fds parameter of the subprocess.Popen
method is now True by default.

Finally, PEP-3151 changed the exception raised by fcntl.flock from IOError to OSError
(and IOError became an alias of OSError).

As a consequence of all that, the runbot locking mechanism to check if a
job is finished was not working in python3.
This commit is contained in:
Christophe Monniez 2018-02-28 10:31:05 +01:00
parent e33881c9b4
commit 21c31b4c3c
30 changed files with 2380 additions and 2674 deletions

View File

@ -1,2 +1,6 @@
import runbot
import res_config
# -*- coding: utf-8 -*-
from . import croninterval
from . import controllers
from . import models
from . import common

25
runbot/__manifest__.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
{
'name': "runbot",
'summary': "Runbot",
'description': "Runbot for Odoo 11.0",
'author': "Odoo SA",
'website': "http://runbot.odoo.com",
'category': 'Website',
'version': '2.0',
'depends': ['website', 'base'],
'data': [
'security/runbot_security.xml',
'security/ir.model.access.csv',
'security/ir.rule.csv',
'views/repo_views.xml',
'views/branch_views.xml',
'views/build_views.xml',
'views/res_config_settings_views.xml',
'templates/frontend.xml',
'templates/build.xml',
'templates/assets.xml',
'templates/nginx.xml',
'data/runbot_cron.xml'
],
}

View File

@ -1,20 +0,0 @@
{
'name': 'Runbot',
'category': 'Website',
'summary': 'Runbot',
'version': '1.3',
'description': "Runbot",
'author': 'Odoo SA',
'depends': ['website', 'base_setup'],
'external_dependencies': {
'python': ['matplotlib'],
},
'data': [
'runbot.xml',
'res_config_view.xml',
'security/runbot_security.xml',
'security/ir.model.access.csv',
'security/ir.rule.csv',
],
'installable': True,
}

101
runbot/common.py Normal file
View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
import contextlib
import fcntl
import itertools
import logging
import os
import psycopg2
import re
import socket
import time
from collections import OrderedDict
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
_logger = logging.getLogger(__name__)
def fqdn():
return socket.getfqdn()
def time2str(t):
time.strftime(DEFAULT_SERVER_DATETIME_FORMAT, t)
def dt2time(datetime):
"""Convert datetime to time"""
return time.mktime(time.strptime(datetime, DEFAULT_SERVER_DATETIME_FORMAT))
def now():
return time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
def lock(filename):
fd = os.open(filename, os.O_CREAT | os.O_RDWR, 0o600)
if hasattr(os, 'set_inheritable'):
os.set_inheritable(fd, True) # needed since pep-446
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def locked(filename):
result = False
try:
fd = os.open(filename, os.O_CREAT | os.O_RDWR, 0o600)
except OSError:
os.close(fd)
return False
try:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError: # since pep-3151 fcntl raises OSError and IOError is now an alias of OSError
result = True
finally:
os.close(fd)
return result
def grep(filename, string):
if os.path.isfile(filename):
return open(filename).read().find(string) != -1
return False
def uniq_list(l):
return OrderedDict.fromkeys(l).keys()
def flatten(list_of_lists):
return list(itertools.chain.from_iterable(list_of_lists))
def rfind(filename, pattern):
"""Determine in something in filename matches the pattern"""
if os.path.isfile(filename):
regexp = re.compile(pattern, re.M)
with open(filename, 'r') as f:
if regexp.findall(f.read()):
return True
return False
def s2human(time):
"""Convert a time in second into an human readable string"""
for delay, desc in [(86400, 'd'),(3600, 'h'),(60, 'm')]:
if time >= delay:
return str(int(time / delay)) + desc
return str(int(time)) + "s"
@contextlib.contextmanager
def local_pgadmin_cursor():
cnx = None
try:
cnx = psycopg2.connect("dbname=postgres")
cnx.autocommit = True # required for admin commands
yield cnx.cursor()
finally:
if cnx:
cnx.close()

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import frontend
from . import hook

View File

@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
import werkzeug
from odoo import http
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.website.controllers.main import QueryURL
from odoo.http import request
from ..common import uniq_list, flatten, s2human
class Runbot(http.Controller):
def build_info(self, build):
real_build = build.duplicate_id if build.state == 'duplicate' else build
return {
'id': build.id,
'name': build.name,
'state': real_build.state,
'result': real_build.result,
'guess_result': real_build.guess_result,
'subject': build.subject,
'author': build.author,
'committer': build.committer,
'dest': build.dest,
'real_dest': real_build.dest,
'job_age': s2human(real_build.job_age),
'job_time': s2human(real_build.job_time),
'job': real_build.job,
'domain': real_build.domain,
'host': real_build.host,
'port': real_build.port,
'server_match': real_build.server_match,
'duplicate_of': build.duplicate_id if build.state == 'duplicate' else False,
'coverage': build.branch_id.coverage,
}
@http.route(['/runbot', '/runbot/repo/<model("runbot.repo"):repo>'], website=True, auth='public', type='http')
def repo(self, repo=None, search='', limit='100', refresh='', **kwargs):
branch_obj = request.env['runbot.branch']
build_obj = request.env['runbot.build']
repo_obj = request.env['runbot.repo']
repo_ids = repo_obj.search([])
repos = repo_obj.browse(repo_ids)
if not repo and repos:
repo = repos[0].id
context = {
'repos': repos.ids,
'repo': repo,
'host_stats': [],
'pending_total': build_obj.search_count([('state', '=', 'pending')]),
'limit': limit,
'search': search,
'refresh': refresh,
}
build_ids = []
if repo:
filters = {key: kwargs.get(key, '1') for key in ['pending', 'testing', 'running', 'done', 'deathrow']}
domain = [('repo_id', '=', repo.id)]
domain += [('state', '!=', key) for key, value in iter(filters.items()) if value == '0']
if search:
domain += ['|', '|', ('dest', 'ilike', search), ('subject', 'ilike', search), ('branch_id.branch_name', 'ilike', search)]
build_ids = build_obj.search(domain, limit=int(limit))
branch_ids, build_by_branch_ids = [], {}
if build_ids:
branch_query = """
SELECT br.id FROM runbot_branch br INNER JOIN runbot_build bu ON br.id=bu.branch_id WHERE bu.id in %s
ORDER BY bu.sequence DESC
"""
sticky_dom = [('repo_id', '=', repo.id), ('sticky', '=', True)]
sticky_branch_ids = [] if search else branch_obj.search(sticky_dom).ids
request._cr.execute(branch_query, (tuple(build_ids.ids),))
branch_ids = uniq_list(sticky_branch_ids + [br[0] for br in request._cr.fetchall()])
build_query = """
SELECT
branch_id,
max(case when br_bu.row = 1 then br_bu.build_id end),
max(case when br_bu.row = 2 then br_bu.build_id end),
max(case when br_bu.row = 3 then br_bu.build_id end),
max(case when br_bu.row = 4 then br_bu.build_id end)
FROM (
SELECT
br.id AS branch_id,
bu.id AS build_id,
row_number() OVER (PARTITION BY branch_id) AS row
FROM
runbot_branch br INNER JOIN runbot_build bu ON br.id=bu.branch_id
WHERE
br.id in %s
GROUP BY br.id, bu.id
ORDER BY br.id, bu.id DESC
) AS br_bu
WHERE
row <= 4
GROUP BY br_bu.branch_id;
"""
request._cr.execute(build_query, (tuple(branch_ids),))
build_by_branch_ids = {
rec[0]: [r for r in rec[1:] if r is not None] for rec in request._cr.fetchall()
}
branches = branch_obj.browse(branch_ids)
build_ids = flatten(build_by_branch_ids.values())
build_dict = {build.id: build for build in build_obj.browse(build_ids)}
def branch_info(branch):
return {
'branch': branch,
'builds': [self.build_info(build_dict[build_id]) for build_id in build_by_branch_ids[branch.id]]
}
context.update({
'branches': [branch_info(b) for b in branches],
'testing': build_obj.search_count([('repo_id', '=', repo.id), ('state', '=', 'testing')]),
'running': build_obj.search_count([('repo_id', '=', repo.id), ('state', '=', 'running')]),
'pending': build_obj.search_count([('repo_id', '=', repo.id), ('state', '=', 'pending')]),
'qu': QueryURL('/runbot/repo/' + slug(repo), search=search, limit=limit, refresh=refresh, **filters),
'filters': filters,
})
# consider host gone if no build in last 100
build_threshold = max(build_ids or [0]) - 100
for result in build_obj.read_group([('id', '>', build_threshold)], ['host'], ['host']):
if result['host']:
context['host_stats'].append({
'host': result['host'],
'testing': build_obj.search_count([('state', '=', 'testing'), ('host', '=', result['host'])]),
'running': build_obj.search_count([('state', '=', 'running'), ('host', '=', result['host'])]),
})
return http.request.render('runbot.repo', context)
@http.route(['/runbot/build/<int:build_id>/kill'], type='http', auth="user", methods=['POST'], csrf=False)
def build_ask_kill(self, build_id, search=None, **post):
build = request.env['runbot.build'].browse(build_id)
build._ask_kill()
return werkzeug.utils.redirect('/runbot/repo/%s' % build.repo_id.id + ('?search=%s' % search if search else ''))
@http.route(['/runbot/build/<int:build_id>/force'], type='http', auth="public", methods=['POST'], csrf=False)
def build_force(self, build_id, search=None, **post):
build = request.env['runbot.build'].browse(build_id)
build._force()
return werkzeug.utils.redirect('/runbot/repo/%s' % build.repo_id.id + ('?search=%s' % search if search else ''))
@http.route(['/runbot/build/<int:build_id>'], type='http', auth="public", website=True)
def build(self, build_id, search=None, **post):
"""Events/Logs"""
Build = request.env['runbot.build']
Logging = request.env['ir.logging']
build = Build.browse([build_id])[0]
if not build.exists():
return request.not_found()
real_build = build.duplicate_id if build.state == 'duplicate' else build
# other builds
build_ids = Build.search([('branch_id', '=', build.branch_id.id)])
other_builds = Build.browse(build_ids)
domain = [('build_id', '=', real_build.id)]
log_type = request.params.get('type', '')
if log_type:
domain.append(('type', '=', log_type))
level = request.params.get('level', '')
if level:
domain.append(('level', '=', level.upper()))
if search:
domain.append(('message', 'ilike', search))
logging_ids = Logging.sudo().search(domain)
context = {
'repo': build.repo_id,
'build': self.build_info(build),
'br': {'branch': build.branch_id},
'logs': Logging.sudo().browse(logging_ids).ids,
'other_builds': other_builds.ids
}
return request.render("runbot.build", context)
@http.route(['/runbot/b/<branch_name>', '/runbot/<model("runbot.repo"):repo>/<branch_name>'], type='http', auth="public", website=True)
def fast_launch(self, branch_name=False, repo=False, **post):
"""Connect to the running Odoo instance"""
Build = request.env['runbot.build']
domain = [('branch_id.branch_name', '=', branch_name)]
if repo:
domain.extend([('branch_id.repo_id', '=', repo.id)])
order = "sequence desc"
else:
order = 'repo_id ASC, sequence DESC'
# Take the 10 lasts builds to find at least 1 running... Else no luck
builds = Build.search(domain, order=order, limit=10)
if builds:
last_build = False
for build in Build.browse(builds):
if build.state == 'running' or (build.state == 'duplicate' and build.duplicate_id.state == 'running'):
last_build = build if build.state == 'running' else build.duplicate_id
break
if not last_build:
# Find the last build regardless the state to propose a rebuild
last_build = Build.browse(builds[0])
if last_build.state != 'running':
url = "/runbot/build/%s?ask_rebuild=1" % last_build.id
else:
url = build.branch_id._get_branch_quickconnect_url(last_build.domain, last_build.dest)[build.branch_id.id]
else:
return request.not_found()
return werkzeug.utils.redirect(url)

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
import datetime
from odoo import http, tools
from odoo.http import request
class RunbotHook(http.Controller):
@http.route(['/runbot/hook/<int:repo_id>'], type='http', auth="public", website=True)
def hook(self, repo_id=None, **post):
# TODO if repo_id == None parse the json['repository']['ssh_url'] and find the right repo
repo = request.env['runbot.repo'].sudo().browse([repo_id])
repo.hook_time = datetime.datetime.now().strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
return ""

9
runbot/croninterval.py Normal file
View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
import odoo
from dateutil.relativedelta import relativedelta
# increase cron frequency from 0.016 Hz to 0.1 Hz to reduce starvation and improve throughput with many workers
# TODO: find a nicer way than monkey patch to accomplish this
odoo.service.server.SLEEP_INTERVAL = 10
odoo.addons.base.ir.ir_cron._intervalTypes['minutes'] = lambda interval: relativedelta(seconds=interval * 10)

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding='UTF-8'?>
<odoo>
<record model="ir.cron" id="runbot_repo_cron">
<field name="name">Runbot Cron</field>
<field name="model_id" ref="model_runbot_repo"/>
<field name="state">code</field>
<field name="code">model._cron()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import repo, branch, build, event
from . import res_config_settings

81
runbot/models/branch.py Normal file
View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
import logging
import re
from subprocess import CalledProcessError
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
_re_coverage = re.compile(r'\bcoverage\b')
class runbot_branch(models.Model):
_name = "runbot.branch"
_order = 'name'
_sql_constraints = [('branch_repo_uniq', 'unique (name,repo_id)', 'The branch must be unique per repository !')]
repo_id = fields.Many2one('runbot.repo', 'Repository', required=True, ondelete='cascade')
name = fields.Char('Ref Name', required=True)
branch_name = fields.Char(compute='_get_branch_name', type='char', string='Branch', readonly=1, store=True)
branch_url = fields.Char(compute='_get_branch_url', type='char', string='Branch url', readonly=1)
pull_head_name = fields.Char(compute='_get_pull_head_name', type='char', string='PR HEAD name', readonly=1, store=True)
sticky = fields.Boolean('Sticky')
coverage = fields.Boolean('Coverage')
state = fields.Char('Status')
modules = fields.Char("Modules to Install", help="Comma-separated list of modules to install and test.")
job_timeout = fields.Integer('Job Timeout (minutes)', help='For default timeout: Mark it zero')
# test_tags = fields.Char("Test tags", help="Tags for the --test-tags params (same syntax)") # keep for next version
@api.depends('name')
def _get_branch_name(self):
"""compute the branch name based on ref name"""
for branch in self:
if branch.name:
branch.branch_name = branch.name.split('/')[-1]
@api.depends('branch_name')
def _get_branch_url(self):
"""compute the branch url based on branch_name"""
for branch in self:
if branch.name:
if re.match('^[0-9]+$', branch.branch_name):
branch.branch_url = "https://%s/pull/%s" % (branch.repo_id.base, branch.branch_name)
else:
branch.branch_url = "https://%s/tree/%s" % (branch.repo_id.base, branch.branch_name)
def _get_pull_info(self):
self.ensure_one()
repo = self.repo_id
if repo.token and self.name.startswith('refs/pull/'):
pull_number = self.name[len('refs/pull/'):]
return repo._github('/repos/:owner/:repo/pulls/%s' % pull_number, ignore_errors=True) or {}
return {}
def _is_on_remote(self):
# check that a branch still exists on remote
self.ensure_one()
branch = self
repo = branch.repo_id
try:
repo._git(['ls-remote', '-q', '--exit-code', repo.name, branch.name])
except CalledProcessError:
return False
return True
def create(self, vals):
vals.setdefault('coverage', _re_coverage.search(vals.get('name') or '') is not None)
return super(runbot_branch, self).create(vals)
@api.depends('branch_name')
def _get_pull_head_name(self):
"""compute pull head name"""
for branch in self:
pi = self._get_pull_info()
if pi:
self.pull_head_name = pi['head']['ref']
def _get_branch_quickconnect_url(self, fqdn, dest):
self.ensure_one()
r = {}
r[self.id] = "http://%s/web/login?db=%s-all&login=admin&redirect=/web?debug=1" % (fqdn, dest)
return r

786
runbot/models/build.py Normal file
View File

@ -0,0 +1,786 @@
# -*- coding: utf-8 -*-
import glob
import logging
import operator
import os
import re
import resource
import shutil
import signal
import subprocess
import time
from subprocess import CalledProcessError
from ..common import dt2time, fqdn, now, locked, grep, time2str, rfind, uniq_list, local_pgadmin_cursor, lock
from odoo import models, fields, api
from odoo.tools import config, appdirs
_re_error = r'^(?:\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d{3} \d+ (?:ERROR|CRITICAL) )|(?:Traceback \(most recent call last\):)$'
_re_warning = r'^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d,\d{3} \d+ WARNING '
re_job = re.compile('_job_\d')
_logger = logging.getLogger(__name__)
class runbot_build(models.Model):
_name = "runbot.build"
_order = 'id desc'
branch_id = fields.Many2one('runbot.branch', 'Branch', required=True, ondelete='cascade', index=True)
repo_id = fields.Many2one(related='branch_id.repo_id')
name = fields.Char('Revno', required=True)
host = fields.Char('Host')
port = fields.Integer('Port')
dest = fields.Char(compute='_get_dest', type='char', string='Dest', readonly=1, store=True)
domain = fields.Char(compute='_get_domain', type='char', string='URL')
date = fields.Datetime('Commit date')
author = fields.Char('Author')
author_email = fields.Char('Author Email')
committer = fields.Char('Committer')
committer_email = fields.Char('Committer Email')
subject = fields.Text('Subject')
sequence = fields.Integer('Sequence')
modules = fields.Char("Modules to Install")
result = fields.Char('Result', default='') # ok, ko, warn, skipped, killed, manually_killed
guess_result = fields.Char(compute='_guess_result')
pid = fields.Integer('Pid')
state = fields.Char('Status', default='pending') # pending, testing, running, done, duplicate, deathrow
job = fields.Char('Job') # job_*
job_start = fields.Datetime('Job start')
job_end = fields.Datetime('Job end')
job_time = fields.Integer(compute='_get_time', string='Job time')
job_age = fields.Integer(compute='_get_age', string='Job age')
duplicate_id = fields.Many2one('runbot.build', 'Corresponding Build')
server_match = fields.Selection([('builtin', 'This branch includes Odoo server'),
('exact', 'branch/PR exact name'),
('prefix', 'branch whose name is a prefix of current one'),
('fuzzy', 'Fuzzy - common ancestor found'),
('default', 'No match found - defaults to master')],
string='Server branch matching')
def create(self, vals):
build_id = super(runbot_build, self).create(vals)
extra_info = {'sequence': self.id}
# detect duplicate
duplicate_id = None
domain = [
('repo_id', '=', build_id.repo_id.duplicate_id.id),
('name', '=', build_id.name),
('duplicate_id', '=', False),
'|', ('result', '=', False), ('result', '!=', 'skipped')
]
for duplicate in self.search(domain):
duplicate_id = duplicate.id
# Consider the duplicate if its closest branches are the same than the current build closest branches.
for extra_repo in build_id.repo_id.dependency_ids:
build_closest_name = build_id._get_closest_branch_name(extra_repo.id)[1]
duplicate_closest_name = duplicate._get_closest_branch_name(extra_repo.id)[1]
if build_closest_name != duplicate_closest_name:
duplicate_id = None
if duplicate_id:
extra_info.update({'state': 'duplicate', 'duplicate_id': duplicate_id})
build_id.write({'duplicate_id': build_id.id})
build_id.write(extra_info)
return build_id
def _reset(self):
self.write({'state': 'pending'})
def _branch_exists(self, branch_id):
Branch = self.env['runbot.branch']
branch = Branch.search([('id', '=', branch_id)])
if branch and branch[0]._is_on_remote():
return True
return False
def _get_closest_branch_name(self, target_repo_id):
"""Return (repo, branch name) of the closest common branch between build's branch and
any branch of target_repo or its duplicated repos.
Rules priority for choosing the branch from the other repo is:
1. Same branch name
2. A PR whose head name match
3. Match a branch which is the dashed-prefix of current branch name
4. Common ancestors (git merge-base)
Note that PR numbers are replaced by the branch name of the PR target
to prevent the above rules to mistakenly link PR of different repos together.
"""
self.ensure_one()
Branch = self.env['runbot.branch']
build = self
branch, repo = build.branch_id, build.repo_id
pi = branch._get_pull_info()
name = pi['base']['ref'] if pi else branch.branch_name
target_repo = self.env['runbot.repo'].browse(target_repo_id)
target_repo_ids = [target_repo.id]
r = target_repo.duplicate_id
while r:
if r.id in target_repo_ids:
break
target_repo_ids.append(r.id)
r = r.duplicate_id
_logger.debug('Search closest of %s (%s) in repos %r', name, repo.name, target_repo_ids)
sort_by_repo = lambda d: (not d['sticky'], # sticky first
target_repo_ids.index(d['repo_id'][0]),
-1 * len(d.get('branch_name', '')),
-1 * d['id'])
result_for = lambda d, match='exact': (d['repo_id'][0], d['name'], match)
fields = ['name', 'repo_id', 'sticky']
# 1. same name, not a PR
domain = [
('repo_id', 'in', target_repo_ids),
('branch_name', '=', name),
('name', '=like', 'refs/heads/%'),
]
targets = Branch.search_read(domain, fields, order='id DESC')
targets = sorted(targets, key=sort_by_repo)
if targets and self._branch_exists(targets[0]['id']):
return result_for(targets[0])
# 2. PR with head name equals
domain = [
('repo_id', 'in', target_repo_ids),
('pull_head_name', '=', name),
('name', '=like', 'refs/pull/%'),
]
pulls = Branch.search_read(domain, fields, order='id DESC')
pulls = sorted(pulls, key=sort_by_repo)
for pull in pulls:
pi = pull._get_pull_info()
if pi.get('state') == 'open':
return result_for(pull)
# 3. Match a branch which is the dashed-prefix of current branch name
branches = Branch.search_read(
[('repo_id', 'in', target_repo_ids), ('name', '=like', 'refs/heads/%')],
fields + ['branch_name'], order='id DESC',
)
branches = sorted(branches, key=sort_by_repo)
for branch in branches:
if name.startswith(branch['branch_name'] + '-') and self._branch_exists(branch['id']):
return result_for(branch, 'prefix')
# 4. Common ancestors (git merge-base)
for target_id in target_repo_ids:
common_refs = {}
self.env.cr.execute("""
SELECT b.name
FROM runbot_branch b,
runbot_branch t
WHERE b.repo_id = %s
AND t.repo_id = %s
AND b.name = t.name
AND b.name LIKE 'refs/heads/%%'
""", [repo.id, target_id])
for common_name, in self.env.cr.fetchall():
try:
commit = repo._git(['merge-base', branch['name'], common_name]).strip()
cmd = ['log', '-1', '--format=%cd', '--date=iso', commit]
common_refs[common_name] = repo._git(cmd).strip()
except CalledProcessError:
# If merge-base doesn't find any common ancestor, the command exits with a
# non-zero return code, resulting in subprocess.check_output raising this
# exception. We ignore this branch as there is no common ref between us.
continue
if common_refs:
b = sorted(common_refs.iteritems(), key=operator.itemgetter(1), reverse=True)[0][0]
return target_id, b, 'fuzzy'
# 5. last-resort value
return target_repo_id, 'master', 'default'
@api.depends('name', 'branch_id.name')
def _get_dest(self):
for build in self:
nickname = build.branch_id.name.split('/')[2]
nickname = re.sub(r'"|\'|~|\:', '', nickname)
nickname = re.sub(r'_|/|\.', '-', nickname)
build.dest = ("%05d-%s-%s" % (build.id, nickname[:32], build.name[:6])).lower()
def _get_domain(self):
domain = self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_domain', fqdn())
for build in self:
if build.repo_id.nginx:
build.domain = "%s.%s" % (build.dest, build.host)
else:
build.domain = "%s:%s" % (domain, build.port)
def _guess_result(self):
self.env.cr.execute("""
SELECT b.id,
CASE WHEN b.state != 'testing' THEN b.result
WHEN array_agg(l.level)::text[] && ARRAY['ERROR', 'CRITICAL'] THEN 'ko'
WHEN array_agg(l.level)::text[] && ARRAY['WARNING'] THEN 'warn'
ELSE 'ok'
END
FROM runbot_build b
LEFT JOIN ir_logging l ON (l.build_id = b.id AND l.level != 'INFO')
WHERE b.id IN %s
GROUP BY b.id
""", [tuple(self.ids)])
return dict(self.env.cr.fetchall())
def _get_time(self):
"""Return the time taken by the tests"""
for build in self:
if build.job_end:
build.job_time = int(dt2time(build.job_end) - dt2time(build.job_start))
elif build.job_start:
build.job_time = int(time.time() - dt2time(build.job_start))
def _get_age(self):
"""Return the time between job start and now"""
for build in self:
if build.job_start:
build.job_age = int(time.time() - dt2time(build.job_start))
def _force(self):
"""Force a rebuild"""
for build in self:
pending_ids = self.search([('state', '=', 'pending')], order='id', limit=1)
if pending_ids:
sequence = pending_ids[0].id
else:
sequence = self.search([], order='id desc', limit=1)[0].id
# Force it now
rebuild = True
if build.state == 'done' and build.result == 'skipped':
build.write({'state': 'pending', 'sequence': sequence, 'result': ''})
# or duplicate it
elif build.state in ['running', 'done', 'duplicate', 'deathrow']:
new_build = build.copy({'sequence': sequence})
build = new_build
else:
rebuild = False
if rebuild:
build._log('rebuild', 'Rebuild initiated by %s' % self.env.user.name)
def _skip(self):
"""Mark builds ids as skipped"""
self.write({'state': 'done', 'result': 'skipped'})
to_unduplicate = self.search([('id', 'in', self.ids), ('duplicate_id', '!=', False)])
to_unduplicate._force()
def _local_cleanup(self):
for build in self:
# Cleanup the *local* cluster
with local_pgadmin_cursor() as local_cr:
local_cr.execute("""
SELECT datname
FROM pg_database
WHERE pg_get_userbyid(datdba) = current_user
AND datname LIKE %s
""", [build.dest + '%'])
to_delete = local_cr.fetchall()
for db, in to_delete:
self._local_pg_dropdb(db)
# cleanup: find any build older than 7 days.
root = self.env['runbot.repo']._root()
build_dir = os.path.join(root, 'build')
builds = os.listdir(build_dir)
self.env.cr.execute("""
SELECT dest
FROM runbot_build
WHERE dest IN %s
AND (state != 'done' OR job_end > (now() - interval '7 days'))
""", [tuple(builds)])
actives = set(b[0] for b in self.env.cr.fetchall())
for b in builds:
path = os.path.join(build_dir, b)
if b not in actives and os.path.isdir(path) and not os.path.isabs(path):
shutil.rmtree(path)
# cleanup old unused databases
self.env.cr.execute("select id from runbot_build where state in ('testing', 'running')")
db_ids = [id[0] for id in self.env.cr.fetchall()]
if db_ids:
with local_pgadmin_cursor() as local_cr:
local_cr.execute("""
SELECT datname
FROM pg_database
WHERE pg_get_userbyid(datdba) = current_user
AND datname ~ '^[0-9]+-.*'
AND SUBSTRING(datname, '^([0-9]+)-.*')::int not in %s
""", [tuple(db_ids)])
to_delete = local_cr.fetchall()
for db, in to_delete:
self._local_pg_dropdb(db)
def _list_jobs(self):
"""List methods that starts with _job_[[:digit:]]"""
return sorted(job[1:] for job in dir(self) if re_job.match(job))
def _find_port(self):
# currently used port
ids = self.search([('state', 'not in', ['pending', 'done'])])
ports = set(i['port'] for i in ids.read(['port']))
# starting port
icp = self.env['ir.config_parameter']
port = int(icp.get_param('runbot.starting_port', default=2000))
# find next free port
while port in ports:
port += 2
return port
def _logger(self, *l):
l = list(l)
for build in self:
l[0] = "%s %s" % (build.dest, l[0])
_logger.debug(*l)
def _schedule(self):
"""schedule the build"""
jobs = self._list_jobs()
icp = self.env['ir.config_parameter']
# For retro-compatibility, keep this parameter in seconds
default_timeout = int(icp.get_param('runbot.timeout', default=1800)) / 60
for build in self:
if build.state == 'deathrow':
build._kill(result='manually_killed')
continue
elif build.state == 'pending':
# allocate port and schedule first job
port = self._find_port()
values = {
'host': fqdn(),
'port': port,
'state': 'testing',
'job': jobs[0],
'job_start': now(),
'job_end': False,
}
build.write(values)
else:
# check if current job is finished
lock_path = build._path('logs', '%s.lock' % build.job)
if locked(lock_path):
# kill if overpassed
timeout = (build.branch_id.job_timeout or default_timeout) * 60
if build.job != jobs[-1] and build.job_time > timeout:
build._logger('%s time exceded (%ss)', build.job, build.job_time)
build.write({'job_end': now()})
build._kill(result='killed')
continue
build._logger('%s finished', build.job)
# schedule
v = {}
# testing -> running
if build.job == jobs[-2]:
v['state'] = 'running'
v['job'] = jobs[-1]
v['job_end'] = now(),
# running -> done
elif build.job == jobs[-1]:
v['state'] = 'done'
v['job'] = ''
# testing
else:
v['job'] = jobs[jobs.index(build.job) + 1]
build.write(v)
build.refresh()
# run job
pid = None
if build.state != 'done':
build._logger('running %s', build.job)
job_method = getattr(self, '_' + build.job) # compute the job method to run
os.makedirs(build._path('logs'), exist_ok=True)
lock_path = build._path('logs', '%s.lock' % build.job)
log_path = build._path('logs', '%s.txt' % build.job)
try:
pid = job_method(build, lock_path, log_path)
build.write({'pid': pid})
except Exception:
_logger.exception('%s failed running method %s', build.dest, build.job)
build._log(build.job, "failed running job method, see runbot log")
build._kill(result='ko')
continue
# needed to prevent losing pids if multiple jobs are started and one them raise an exception
self.env.cr.commit()
if pid == -2:
# no process to wait, directly call next job
# FIXME find a better way that this recursive call
build._schedule()
# cleanup only needed if it was not killed
if build.state == 'done':
build._local_cleanup()
def _path(self, *l, **kw):
"""Return the repo build path"""
self.ensure_one()
build = self
root = self.env['runbot.repo']._root()
return os.path.join(root, 'build', build.dest, *l)
def _server(self, *l, **kw):
"""Return the build server path"""
self.ensure_one()
build = self
if os.path.exists(build._path('odoo')):
return build._path('odoo', *l)
return build._path('openerp', *l)
def _filter_modules(self, modules, available_modules, explicit_modules):
blacklist_modules = set(['auth_ldap', 'document_ftp', 'base_gengo',
'website_gengo', 'website_instantclick',
'pad', 'pad_project', 'note_pad',
'pos_cache', 'pos_blackbox_be'])
mod_filter = lambda m: (
m in available_modules and
(m in explicit_modules or (not m.startswith(('hw_', 'theme_', 'l10n_')) and
m not in blacklist_modules))
)
return uniq_list(filter(mod_filter, modules))
def _checkout(self):
for build in self:
# starts from scratch
if os.path.isdir(build._path()):
shutil.rmtree(build._path())
# runbot log path
os.makedirs(build._path("logs"), exist_ok=True)
os.makedirs(build._server('addons'), exist_ok=True)
# checkout branch
build.branch_id.repo_id._git_export(build.name, build._path())
has_server = os.path.isfile(build._server('__init__.py'))
server_match = 'builtin'
# build complete set of modules to install
modules_to_move = []
modules_to_test = ((build.branch_id.modules or '') + ',' +
(build.repo_id.modules or ''))
modules_to_test = list(filter(None, modules_to_test.split(','))) # ???
explicit_modules = set(modules_to_test)
_logger.debug("manual modules_to_test for build %s: %s", build.dest, modules_to_test)
if not has_server:
if build.repo_id.modules_auto == 'repo':
modules_to_test += [
os.path.basename(os.path.dirname(a))
for a in (glob.glob(build._path('*/__openerp__.py')) +
glob.glob(build._path('*/__manifest__.py')))
]
_logger.debug("local modules_to_test for build %s: %s", build.dest, modules_to_test)
for extra_repo in build.repo_id.dependency_ids:
repo_id, closest_name, server_match = build._get_closest_branch_name(extra_repo.id)
repo = self.env['runbot.repo'].browse(repo_id)
_logger.debug('branch %s of %s: %s match branch %s of %s',
build.branch_id.name, build.repo_id.name,
server_match, closest_name, repo.name)
build._log(
'Building environment',
'%s match branch %s of %s' % (server_match, closest_name, repo.name)
)
repo._git_export(closest_name, build._path())
# Finally mark all addons to move to openerp/addons
modules_to_move += [
os.path.dirname(module)
for module in (glob.glob(build._path('*/__openerp__.py')) +
glob.glob(build._path('*/__manifest__.py')))
]
# move all addons to server addons path
for module in uniq_list(glob.glob(build._path('addons/*')) + modules_to_move):
basename = os.path.basename(module)
addon_path = build._server('addons', basename)
if os.path.exists(addon_path):
build._log(
'Building environment',
'You have duplicate modules in your branches "%s"' % basename
)
if os.path.islink(addon_path) or os.path.isfile(addon_path):
os.remove(addon_path)
else:
shutil.rmtree(addon_path)
shutil.move(module, build._server('addons'))
available_modules = [
os.path.basename(os.path.dirname(a))
for a in (glob.glob(build._server('addons/*/__openerp__.py')) +
glob.glob(build._server('addons/*/__manifest__.py')))
]
if build.repo_id.modules_auto == 'all' or (build.repo_id.modules_auto != 'none' and has_server):
modules_to_test += available_modules
modules_to_test = self._filter_modules(modules_to_test,
set(available_modules), explicit_modules)
_logger.debug("modules_to_test for build %s: %s", build.dest, modules_to_test)
build.write({'server_match': server_match,
'modules': ','.join(modules_to_test)})
def _local_pg_dropdb(self, dbname):
with local_pgadmin_cursor() as local_cr:
local_cr.execute('DROP DATABASE IF EXISTS "%s"' % dbname)
# cleanup filestore
datadir = appdirs.user_data_dir()
paths = [os.path.join(datadir, pn, 'filestore', dbname) for pn in 'OpenERP Odoo'.split()]
cmd = ['rm', '-rf'] + paths
_logger.debug(' '.join(cmd))
subprocess.call(cmd)
def _local_pg_createdb(self, dbname):
self._local_pg_dropdb(dbname)
_logger.debug("createdb %s", dbname)
with local_pgadmin_cursor() as local_cr:
local_cr.execute("""CREATE DATABASE "%s" TEMPLATE template0 LC_COLLATE 'C' ENCODING 'unicode'""" % dbname)
def _log(self, func, message):
self.ensure_one()
_logger.debug("Build %s %s %s", self.id, func, message)
self.env['ir.logging'].create({
'build_id': self.id,
'level': 'INFO',
'type': 'runbot',
'name': 'odoo.runbot',
'message': message,
'path': 'runbot',
'func': func,
'line': '0',
})
def reset(self):
self.write({'state': 'pending'})
def _reap(self):
while True:
try:
pid, status, rusage = os.wait3(os.WNOHANG)
except OSError:
break
if pid == 0:
break
_logger.debug('reaping: pid: %s status: %s', pid, status)
def _kill(self, result=None):
host = fqdn()
for build in self:
if build.host != host:
continue
build._log('kill', 'Kill build %s' % build.dest)
if build.pid:
build._logger('killing %s', build.pid)
try:
os.killpg(build.pid, signal.SIGKILL)
except OSError:
pass
v = {'state': 'done', 'job': False}
if result:
v['result'] = result
build.write(v)
self.env.cr.commit()
build._github_status()
build._local_cleanup()
def _ask_kill(self):
self.ensure_one()
user = self.env.user
uid = self.env.uid
if self.state == 'pending':
self._skip(ids=self.ids)
self._log('_ask_kill', 'Skipping build %s, requested by %s (user #%s)' % (self.dest, user.name, uid))
elif self.state in ['testing', 'running']:
self.write({'state': 'deathrow'})
self._log('_ask_kill', 'Killing build %s, requested by %s (user #%s)' % (self.dest, user.name, uid))
def _cmd(self):
"""Return a tuple describing the command to start the build
First part is list with the command and parameters
Second part is a list of Odoo modules
"""
self.ensure_one()
build = self
bins = [
'odoo-bin', # >= 10.0
'openerp-server', # 9.0, 8.0
'openerp-server.py', # 7.0
'bin/openerp-server.py', # < 7.0
]
for server_path in map(build._path, bins):
if os.path.isfile(server_path):
break
# commandline
cmd = [
build._path(server_path),
"--xmlrpc-port=%d" % build.port,
]
# options
if grep(build._server("tools/config.py"), "no-xmlrpcs"):
cmd.append("--no-xmlrpcs")
if grep(build._server("tools/config.py"), "no-netrpc"):
cmd.append("--no-netrpc")
if grep(build._server("tools/config.py"), "log-db"):
logdb = self.env.cr.dbname
if config['db_host'] and grep(build._server('sql_db.py'), 'allow_uri'):
logdb = 'postgres://{cfg[db_user]}:{cfg[db_password]}@{cfg[db_host]}/{db}'.format(cfg=config, db=self.env.cr.dbname)
cmd += ["--log-db=%s" % logdb]
if grep(build._server('tools/config.py'), 'log-db-level'):
cmd += ["--log-db-level", '25']
if grep(build._server("tools/config.py"), "data-dir"):
datadir = build._path('datadir')
if not os.path.exists(datadir):
os.mkdir(datadir)
cmd += ["--data-dir", datadir]
# if build.branch_id.test_tags:
# cmd.extend(['--test_tags', "'%s'" % build.branch_id.test_tags]) # keep for next version
return cmd, build.modules
def _spawn(self, cmd, lock_path, log_path, cpu_limit=None, shell=False, env=None):
def preexec_fn():
os.setsid()
if cpu_limit:
# set soft cpulimit
soft, hard = resource.getrlimit(resource.RLIMIT_CPU)
r = resource.getrusage(resource.RUSAGE_SELF)
cpu_time = r.ru_utime + r.ru_stime
resource.setrlimit(resource.RLIMIT_CPU, (cpu_time + cpu_limit, hard))
# close parent files
os.closerange(3, os.sysconf("SC_OPEN_MAX"))
lock(lock_path)
out = open(log_path, "w")
_logger.debug("spawn: %s stdout: %s", ' '.join(cmd), log_path)
p = subprocess.Popen(cmd, stdout=out, stderr=out, preexec_fn=preexec_fn, shell=shell, env=env, close_fds=False)
return p.pid
def _github_status(self):
"""Notify github of failed/successful builds"""
runbot_domain = self.env['runbot.repo']._domain()
for build in self:
desc = "runbot build %s" % (build.dest,)
if build.state == 'testing':
state = 'pending'
elif build.state in ('running', 'done'):
state = 'error'
if build.result == 'ok':
state = 'success'
if build.result == 'ko':
state = 'failure'
desc += " (runtime %ss)" % (build.job_time,)
else:
continue
status = {
"state": state,
"target_url": "http://%s/runbot/build/%s" % (runbot_domain, build.id),
"description": desc,
"context": "ci/runbot"
}
_logger.debug("github updating status %s to %s", build.name, state)
build.repo_id._github('/repos/:owner/:repo/statuses/%s' % build.name, status, ignore_errors=True)
# Jobs definitions
# They all need "build, lock_pathn log_path" parameters
def _job_00_init(self, build, lock_path, log_path):
build._log('init', 'Init build environment')
# notify pending build - avoid confusing users by saying nothing
build._github_status()
build._checkout()
return -2
def _job_10_test_base(self, build, lock_path, log_path):
build._log('test_base', 'Start test base module')
# run base test
self._local_pg_createdb("%s-base" % build.dest)
cmd, mods = build._cmd()
if grep(build._server("tools/config.py"), "test-enable"):
cmd.append("--test-enable")
cmd += ['-d', '%s-base' % build.dest, '-i', 'base', '--stop-after-init', '--log-level=test', '--max-cron-threads=0']
return self._spawn(cmd, lock_path, log_path, cpu_limit=300)
def _job_20_test_all(self, build, lock_path, log_path):
build._log('test_all', 'Start test all modules')
self._local_pg_createdb("%s-all" % build.dest)
cmd, mods = build._cmd()
if grep(build._server("tools/config.py"), "test-enable"):
cmd.append("--test-enable")
cmd += ['-d', '%s-all' % build.dest, '-i', mods, '--stop-after-init', '--log-level=test', '--max-cron-threads=0']
env = None
if build.branch_id.coverage:
env = self._coverage_env(build)
available_modules = [
os.path.basename(os.path.dirname(a))
for a in (glob.glob(build._server('addons/*/__openerp__.py')) +
glob.glob(build._server('addons/*/__manifest__.py')))
]
bad_modules = set(available_modules) - set((mods or '').split(','))
omit = ['--omit', ','.join(build._server('addons', m) for m in bad_modules)] if bad_modules else []
cmd = ['coverage', 'run', '--branch', '--source', build._server()] + omit + cmd[:]
# reset job_start to an accurate job_20 job_time
build.write({'job_start': now()})
return self._spawn(cmd, lock_path, log_path, cpu_limit=2100, env=env)
def _coverage_env(self, build):
return dict(os.environ, COVERAGE_FILE=build._path('.coverage'))
def _job_21_coverage(self, build, lock_path, log_path):
if not build.branch_id.coverage:
return -2
cov_path = build._path('coverage')
os.makedirs(cov_path, exist_ok=True)
cmd = ["coverage", "html", "-d", cov_path, "--ignore-errors"]
return self._spawn(cmd, lock_path, log_path, env=self._coverage_env(build))
def _job_30_run(self, build, lock_path, log_path):
# adjust job_end to record an accurate job_20 job_time
build._log('run', 'Start running build %s' % build.dest)
log_all = build._path('logs', 'job_20_test_all.txt')
log_time = time.localtime(os.path.getmtime(log_all))
v = {
'job_end': time2str(log_time),
}
if grep(log_all, ".modules.loading: Modules loaded."):
if rfind(log_all, _re_error):
v['result'] = "ko"
elif rfind(log_all, _re_warning):
v['result'] = "warn"
elif not grep(build._server("test/common.py"), "post_install") or grep(log_all, "Initiating shutdown."):
v['result'] = "ok"
else:
v['result'] = "ko"
build.write(v)
build._github_status()
# run server
cmd, mods = build._cmd()
if os.path.exists(build._server('addons/im_livechat')):
cmd += ["--workers", "2"]
cmd += ["--longpolling-port", "%d" % (build.port + 1)]
cmd += ["--max-cron-threads", "1"]
else:
# not sure, to avoid old server to check other dbs
cmd += ["--max-cron-threads", "0"]
cmd += ['-d', "%s-all" % build.dest]
if grep(build._server("tools/config.py"), "db-filter"):
if build.repo_id.nginx:
cmd += ['--db-filter', '%d.*$']
else:
cmd += ['--db-filter', '%s.*$' % build.dest]
return self._spawn(cmd, lock_path, log_path, cpu_limit=None)

48
runbot/models/event.py Normal file
View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
TYPES = [(t, t.capitalize()) for t in 'client server runbot'.split()]
class runbot_event(models.Model):
_inherit = "ir.logging"
_order = 'id'
build_id = fields.Many2one('runbot.build', 'Build', index=True, ondelete='cascade')
type = fields.Selection(TYPES, string='Type', required=True, index=True)
@api.model_cr
def init(self):
parent_class = super(runbot_event, self)
if hasattr(parent_class, 'init'):
parent_class.init()
self._cr.execute("""
CREATE OR REPLACE FUNCTION runbot_set_logging_build() RETURNS TRIGGER AS $$
BEGIN
IF (new.build_id IS NULL AND new.dbname IS NOT NULL AND new.dbname != current_database()) THEN
UPDATE ir_logging l
SET build_id = split_part(new.dbname, '-', 1)::integer
WHERE l.id = new.id;
END IF;
RETURN NULL;
END;
$$ language plpgsql;
DO $$
BEGIN
CREATE TRIGGER runbot_new_logging
AFTER INSERT ON ir_logging
FOR EACH ROW
EXECUTE PROCEDURE runbot_set_logging_build();
EXCEPTION
WHEN duplicate_object THEN
END;
$$;
""")

309
runbot/models/repo.py Normal file
View File

@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
import datetime
import dateutil
import json
import logging
import os
import re
import requests
import signal
import subprocess
import time
from odoo import models, fields, api
from odoo.modules.module import get_module_resource
from odoo.tools import config
from ..common import fqdn, dt2time
_logger = logging.getLogger(__name__)
class runbot_repo(models.Model):
_name = "runbot.repo"
name = fields.Char('Repository', required=True)
# branch_ids = fields.One2many('runbot.branch', inverse_name='repo_id') # keep for next version
sequence = fields.Integer('Sequence')
path = fields.Char(compute='_get_path', string='Directory', readonly=True)
base = fields.Char(compute='_get_base_url', string='Base URL', readonly=True) # Could be renamed to a more explicit name like base_url
nginx = fields.Boolean('Nginx')
mode = fields.Selection([('disabled', 'Disabled'),
('poll', 'Poll'),
('hook', 'Hook')],
default='poll',
string="Mode", required=True, help="hook: Wait for webhook on /runbot/hook/<id> i.e. github push event")
hook_time = fields.Datetime('Last hook time')
duplicate_id = fields.Many2one('runbot.repo', 'Duplicate repo', help='Repository for finding duplicate builds')
modules = fields.Char("Modules to install", help="Comma-separated list of modules to install and test.")
modules_auto = fields.Selection([('none', 'None (only explicit modules list)'),
('repo', 'Repository modules (excluding dependencies)'),
('all', 'All modules (including dependencies)')],
default='repo',
string="Other modules to install automatically")
dependency_ids = fields.Many2many(
'runbot.repo', 'runbot_repo_dep_rel', column1='dependant_id', column2='dependency_id',
string='Extra dependencies',
help="Community addon repos which need to be present to run tests.")
token = fields.Char("Github token", groups="runbot.group_runbot_admin")
group_ids = fields.Many2many('res.groups', string='Limited to groups')
def _root(self):
"""Return root directory of repository"""
default = os.path.join(os.path.dirname(__file__), '../static')
return os.path.abspath(default)
@api.depends('name')
def _get_path(self):
"""compute the server path of repo from the name"""
root = self._root()
for repo in self:
name = repo.name
for i in '@:/':
name = name.replace(i, '_')
repo.path = os.path.join(root, 'repo', name)
@api.depends('name')
def _get_base_url(self):
for repo in self:
name = re.sub('.+@', '', repo.name)
name = re.sub('^https://', '', repo.name) # support https repo style
name = re.sub('.git$', '', name)
name = name.replace(':', '/')
repo.base = name
def _git(self, cmd):
"""Execute a git command 'cmd'"""
for repo in self:
cmd = ['git', '--git-dir=%s' % repo.path] + cmd
_logger.info("git command: %s", ' '.join(cmd))
return subprocess.check_output(cmd)
def _git_export(self, treeish, dest):
"""Export a git repo to dest"""
self.ensure_one()
_logger.debug('checkout %s %s %s', self.name, treeish, dest)
p1 = subprocess.Popen(['git', '--git-dir=%s' % self.path, 'archive', treeish], stdout=subprocess.PIPE)
p2 = subprocess.Popen(['tar', '-xmC', dest], stdin=p1.stdout, stdout=subprocess.PIPE)
p1.stdout.close() # Allow p1 to receive a SIGPIPE if p2 exits.
p2.communicate()[0]
def _github(self, url, payload=None, ignore_errors=False):
"""Return a http request to be sent to github"""
for repo in self:
if not repo.token:
return
try:
match_object = re.search('([^/]+)/([^/]+)/([^/.]+(.git)?)', repo.base)
if match_object:
url = url.replace(':owner', match_object.group(2))
url = url.replace(':repo', match_object.group(3))
url = 'https://api.%s%s' % (match_object.group(1), url)
session = requests.Session()
session.auth = (repo.token, 'x-oauth-basic')
session.headers.update({'Accept': 'application/vnd.github.she-hulk-preview+json'})
if payload:
response = session.post(url, data=json.dumps(payload))
else:
response = session.get(url)
response.raise_for_status()
return response.json()
except Exception:
if ignore_errors:
_logger.exception('Ignored github error %s %r', url, payload)
else:
raise
def _update_git(self):
""" Update the git repo on FS """
self.ensure_one()
repo = self
_logger.debug('repo %s updating branches', repo.name)
icp = self.env['ir.config_parameter']
max_age = int(icp.get_param('runbot.max_age', default=30))
Build = self.env['runbot.build']
Branch = self.env['runbot.branch']
if not os.path.isdir(os.path.join(repo.path)):
os.makedirs(repo.path)
if not os.path.isdir(os.path.join(repo.path, 'refs')):
_logger.info("Cloning repository '%s' in '%s'" % (repo.name, repo.path))
subprocess.call(['git', 'clone', '--bare', repo.name, repo.path])
# check for mode == hook
fname_fetch_head = os.path.join(repo.path, 'FETCH_HEAD')
if os.path.isfile(fname_fetch_head):
fetch_time = os.path.getmtime(fname_fetch_head)
if repo.mode == 'hook' and repo.hook_time and dt2time(repo.hook_time) < fetch_time:
t0 = time.time()
_logger.debug('repo %s skip hook fetch fetch_time: %ss ago hook_time: %ss ago',
repo.name, int(t0 - fetch_time), int(t0 - dt2time(repo.hook_time)))
return
repo._git(['fetch', '-p', 'origin', '+refs/heads/*:refs/heads/*'])
repo._git(['fetch', '-p', 'origin', '+refs/pull/*/head:refs/pull/*'])
fields = ['refname', 'objectname', 'committerdate:iso8601', 'authorname', 'authoremail', 'subject', 'committername', 'committeremail']
fmt = "%00".join(["%(" + field + ")" for field in fields])
git_refs = repo._git(['for-each-ref', '--format', fmt, '--sort=-committerdate', 'refs/heads', 'refs/pull'])
git_refs = git_refs.decode('utf-8').strip()
refs = [[field for field in line.split('\x00')] for line in git_refs.split('\n')]
self.env.cr.execute("""
WITH t (branch) AS (SELECT unnest(%s))
SELECT t.branch, b.id
FROM t LEFT JOIN runbot_branch b ON (b.name = t.branch)
WHERE b.repo_id = %s;
""", ([r[0] for r in refs], repo.id))
ref_branches = {r[0]: r[1] for r in self.env.cr.fetchall()}
for name, sha, date, author, author_email, subject, committer, committer_email in refs:
# create or get branch
# branch = repo.branch_ids.search([('name', '=', name), ('repo_id', '=', repo.id)])
# if not branch:
# _logger.debug('repo %s found new branch %s', repo.name, name)
# branch = self.branch_ids.create({
# 'repo_id': repo.id,
# 'name': name})
# keep for next version with a branch_ids field
if ref_branches.get(name):
branch_id = ref_branches[name]
else:
_logger.debug('repo %s found new branch %s', repo.name, name)
branch_id = Branch.create({'repo_id': repo.id, 'name': name}).id
branch = Branch.browse([branch_id])[0]
# skip the build for old branches (Could be checked before creating the branch in DB ?)
if dateutil.parser.parse(date[:19]) + datetime.timedelta(days=max_age) < datetime.datetime.now():
continue
# create build (and mark previous builds as skipped) if not found
build_ids = Build.search([('branch_id', '=', branch.id), ('name', '=', sha)])
if not build_ids:
_logger.debug('repo %s branch %s new build found revno %s', branch.repo_id.name, branch.name, sha)
build_info = {
'branch_id': branch.id,
'name': sha,
'author': author,
'author_email': author_email,
'committer': committer,
'committer_email': committer_email,
'subject': subject,
'date': dateutil.parser.parse(date[:19]),
}
if not branch.sticky:
# pending builds are skipped as we have a new ref
builds_to_skip = Build.search(
[('branch_id', '=', branch.id), ('state', '=', 'pending')],
order='sequence asc')
builds_to_skip._skip()
if builds_to_skip:
build_info['sequence'] = builds_to_skip[0].sequence
Build.create(build_info)
# skip old builds (if their sequence number is too low, they will not ever be built)
skippable_domain = [('repo_id', '=', repo.id), ('state', '=', 'pending')]
icp = self.env['ir.config_parameter']
running_max = int(icp.get_param('runbot.running_max', default=75))
builds_to_be_skipped = Build.search(skippable_domain, order='sequence desc', offset=running_max)
builds_to_be_skipped._skip()
def _update(self, repos):
""" Update the physical git reposotories on FS"""
for repo in repos:
try:
repo._update_git()
except Exception:
_logger.exception('Fail to update repo %s', repo.name)
def _scheduler(self, ids=None):
"""Schedule builds for the repository"""
icp = self.env['ir.config_parameter']
workers = int(icp.get_param('runbot.workers', default=6))
running_max = int(icp.get_param('runbot.running_max', default=75))
host = fqdn()
Build = self.env['runbot.build']
domain = [('repo_id', 'in', ids)]
domain_host = domain + [('host', '=', host)]
# schedule jobs (transitions testing -> running, kill jobs, ...)
build_ids = Build.search(domain_host + [('state', 'in', ['testing', 'running', 'deathrow'])])
build_ids._schedule()
# launch new tests
testing = Build.search_count(domain_host + [('state', '=', 'testing')])
pending = Build.search_count(domain + [('state', '=', 'pending')])
while testing < workers and pending > 0:
# find sticky pending build if any, otherwise, last pending (by id, not by sequence) will do the job
pending_ids = Build.search(domain + [('state', '=', 'pending'), ('branch_id.sticky', '=', True)], limit=1)
if not pending_ids:
pending_ids = Build.search(domain + [('state', '=', 'pending')], order="sequence", limit=1)
pending_ids._schedule()
# compute the number of testing and pending jobs again
testing = Build.search_count(domain_host + [('state', '=', 'testing')])
pending = Build.search_count(domain + [('state', '=', 'pending')])
# terminate and reap doomed build
build_ids = Build.search(domain_host + [('state', '=', 'running')]).ids
# sort builds: the last build of each sticky branch then the rest
sticky = {}
non_sticky = []
for build in Build.browse(build_ids):
if build.branch_id.sticky and build.branch_id.id not in sticky:
sticky[build.branch_id.id] = build.id
else:
non_sticky.append(build.id)
build_ids = list(sticky.values())
build_ids += non_sticky
# terminate extra running builds
Build.browse(build_ids)[running_max:]._kill()
Build.browse(build_ids)._reap()
def _domain(self):
return self.env.get('ir.config_parameter').get_param('runbot.runbot_domain', fqdn())
def _reload_nginx(self):
settings = {}
settings['port'] = config.get('http_port')
settings['runbot_static'] = os.path.join(get_module_resource('runbot', 'static'), '')
nginx_dir = os.path.join(self._root(), 'nginx')
settings['nginx_dir'] = nginx_dir
settings['re_escape'] = re.escape
nginx_repos = self.search([('nginx', '=', True)], order='id')
if nginx_repos:
builds = self.env['runbot.build'].search([('repo_id', 'in', nginx_repos.ids), ('state', '=', 'running')])
settings['builds'] = self.env['runbot.build'].browse(builds.ids)
nginx_config = self.env['ir.ui.view'].render_template("runbot.nginx_config", settings)
os.makedirs(nginx_dir, exist_ok=True)
open(os.path.join(nginx_dir, 'nginx.conf'), 'wb').write(nginx_config)
try:
_logger.debug('reload nginx')
pid = int(open(os.path.join(nginx_dir, 'nginx.pid')).read().strip(' \n'))
os.kill(pid, signal.SIGHUP)
except Exception:
_logger.debug('start nginx')
if subprocess.check_output(['/usr/sbin/nginx', '-p', nginx_dir, '-c', 'nginx.conf']):
# obscure nginx bug leaving orphan worker listening on nginx port
if not subprocess.check_output(['pkill', '-f', '-P1', 'nginx: worker']):
_logger.debug('failed to start nginx - orphan worker killed, retrying')
subprocess.call(['/usr/sbin/nginx', '-p', nginx_dir, '-c', 'nginx.conf'])
else:
_logger.debug('failed to start nginx - failed to kill orphan worker - oh well')
def _cron(self):
repos = self.search([('mode', '!=', 'disabled')])
self._update(repos)
self._scheduler(repos.ids)
self._reload_nginx()

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from .. import common
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
runbot_workers = fields.Integer('Total number of workers')
runbot_running_max = fields.Integer('Maximum number of running builds')
runbot_timeout = fields.Integer('Default timeout (in seconds)')
runbot_starting_port = fields.Integer('Starting port for running builds')
runbot_domain = fields.Char('Runbot domain')
runbot_max_age = fields.Integer('Max branch age (in days)')
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
get_param = self.env['ir.config_parameter'].sudo().get_param
res.update(runbot_workers=int(get_param('runbot.runbot_workers', default=6)),
runbot_running_max=int(get_param('runbot.runbot_running_max', default=75)),
runbot_timeout=int(get_param('runbot.runbot_timeout', default=1800)),
runbot_starting_port=int(get_param('runbot.runbot_starting_port', default=2000)),
runbot_domain=get_param('runbot.runbot_domain', default=common.fqdn()),
runbot_max_age=int(get_param('runbot.runbot_max_age', default=30)))
return res
@api.multi
def set_values(self):
super(ResConfigSettings, self).set_values()
set_param = self.env['ir.config_parameter'].sudo().set_param
set_param("runbot.runbot_workers", self.runbot_workers)
set_param("runbot.runbot_running_max", self.runbot_running_max)
set_param("runbot.runbot_timeout", self.runbot_timeout)
set_param("runbot.runbot_starting_port", self.runbot_starting_port)
set_param("runbot.runbot_domain", self.runbot_domain)
set_param("runbot.runbot_max_age", self.runbot_max_age)

View File

@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# OpenERP, Open Source Business Applications
# Copyright (C) 2004-2012 OpenERP S.A. (<http://openerp.com>).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
from openerp.osv import fields, osv
class runbot_config_settings(osv.osv_memory):
_name = 'runbot.config.settings'
_inherit = 'res.config.settings'
_columns = {
'default_workers': fields.integer('Total Number of Workers'),
'default_running_max': fields.integer('Maximum Number of Running Builds'),
'default_timeout': fields.integer('Default Timeout (in seconds)'),
'default_starting_port': fields.integer('Starting Port for Running Builds'),
'default_domain': fields.char('Runbot Domain'),
}
def get_default_parameters(self, cr, uid, fields, context=None):
icp = self.pool['ir.config_parameter']
workers = icp.get_param(cr, uid, 'runbot.workers', default=6)
running_max = icp.get_param(cr, uid, 'runbot.running_max', default=75)
timeout = icp.get_param(cr, uid, 'runbot.timeout', default=1800)
starting_port = icp.get_param(cr, uid, 'runbot.starting_port', default=2000)
runbot_domain = icp.get_param(cr, uid, 'runbot.domain', default='runbot.odoo.com')
return {
'default_workers': int(workers),
'default_running_max': int(running_max),
'default_timeout': int(timeout),
'default_starting_port': int(starting_port),
'default_domain': runbot_domain,
}
def set_default_parameters(self, cr, uid, ids, context=None):
config = self.browse(cr, uid, ids[0], context)
icp = self.pool['ir.config_parameter']
icp.set_param(cr, uid, 'runbot.workers', config.default_workers)
icp.set_param(cr, uid, 'runbot.running_max', config.default_running_max)
icp.set_param(cr, uid, 'runbot.timeout', config.default_timeout)
icp.set_param(cr, uid, 'runbot.starting_port', config.default_starting_port)
icp.set_param(cr, uid, 'runbot.domain', config.default_domain)
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<record id="view_runbot_configuration" model="ir.ui.view">
<field name="name">Configure Runbot</field>
<field name="model">runbot.config.settings</field>
<field name="arch" type="xml">
<form string="Configure Runbot" class= "oe_form_configuration" version="7.0">
<header>
<button string="Apply" type="object" name="execute" class="oe_highlight"/>
or
<button string="Cancel" type="object" name="cancel" class="oe_link"/>
</header>
<separator string="Runbot"/>
<group>
<label for="id" string="Settings"/>
<div>
<div>
<field name="default_workers" class="oe_inline"/>
<label for="default_workers"/>
</div>
<div>
<field name="default_running_max" class="oe_inline"/>
<label for="default_running_max"/>
</div>
<div>
<field name="default_timeout" class="oe_inline"/>
<label for="default_timeout"/>
</div>
<div>
<field name="default_starting_port" class="oe_inline"/>
<label for="default_starting_port"/>
</div>
<div>
<field name="default_domain" class="oe_inline"/>
<label for="default_domain"/>
</div>
</div>
</group>
</form>
</field>
</record>
<record id="action_runbot_configuration" model="ir.actions.act_window">
<field name="name">Configure Runbot</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">runbot.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
</record>
<menuitem id="menu_runbot_configuration" name="Runbot" parent="base.menu_config"
sequence="19" action="action_runbot_configuration"/>
</data>
</openerp>

File diff suppressed because it is too large Load Diff

View File

@ -1,785 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<menuitem id="menu_runbot_top" name="Runbot"/>
<menuitem id="menu_runbot" name="Runbot" parent="menu_runbot_top"/>
<!-- repos -->
<record id="view_repo_form" model="ir.ui.view">
<field name="model">runbot.repo</field>
<field name="arch" type="xml">
<form string="repos" version="7.0">
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" class="oe_inline"/></h1>
<button name="update" type="object" string="Update" groups="runbot.group_runbot_admin"/>
<button name="cron" type="object" string="Cron" groups="runbot.group_runbot_admin"/>
</div>
<group string="Params">
<field name="sequence"/>
<field name="mode"/>
<field name="nginx"/>
<field name="duplicate_id"/>
<field name="dependency_ids" widget="many2many_tags"/>
<field name="modules"/>
<field name="modules_auto"/>
<field name="token"/>
<field name="group_ids" widget="many2many_tags"/>
<field name="hook_time" readonly="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_repo_tree" model="ir.ui.view">
<field name="model">runbot.repo</field>
<field name="arch" type="xml">
<tree string="repos">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="mode"/>
</tree>
</field>
</record>
<record id="action_repo_form" model="ir.actions.act_window">
<field name="name">Repositories</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">runbot.repo</field>
<field name="view_type">form</field>
</record>
<menuitem id="menu_repo_form" action="action_repo_form" parent="menu_runbot"/>
<!-- Branches -->
<record id="view_branch_form" model="ir.ui.view">
<field name="model">runbot.branch</field>
<field name="arch" type="xml">
<form string="Branch" version="7.0">
<sheet>
<group>
<field name="repo_id"/>
<field name="name"/>
<field name="branch_name"/>
<field name="branch_url"/>
<field name="pull_head_name"/>
<field name="sticky"/>
<field name="job_timeout"/>
<field name="state"/>
<field name="modules"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_branch_tree" model="ir.ui.view">
<field name="model">runbot.branch</field>
<field name="arch" type="xml">
<tree string="Branches">
<field name="repo_id"/>
<field name="name"/>
<field name="sticky"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="view_branch_search" model="ir.ui.view">
<field name="model">runbot.branch</field>
<field name="arch" type="xml">
<search string="Search builds">
<field name="name"/>
<field name="state"/>
<filter string="Sticky" domain="[('sticky','=', True)]"/>
<separator />
<group expand="0" string="Group By...">
<filter string="Repo" domain="[]" context="{'group_by':'repo_id'}"/>
<filter string="Status" domain="[]" context="{'group_by':'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_branch" model="ir.actions.act_window">
<field name="name">Branches</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">runbot.branch</field>
<field name="view_type">form</field>
</record>
<menuitem id="menu_branch" action="action_branch" parent="menu_runbot"/>
<!-- Builds -->
<record id="view_build_form" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<form string="Build" version="7.0">
<header>
<button name="reset" type="object" string="Reset"/>
</header>
<sheet>
<group>
<field name="repo_id"/>
<field name="branch_id"/>
<field name="sequence"/>
<field name="name"/>
<field name="date"/>
<field name="author"/>
<field name="author_email"/>
<field name="committer"/>
<field name="committer_email"/>
<field name="subject"/>
<field name="port"/>
<field name="dest"/>
<field name="state"/>
<field name="result"/>
<field name="pid"/>
<field name="host"/>
<field name="job_start"/>
<field name="job_end"/>
<field name="job_time"/>
<field name="job_age"/>
<field name="duplicate_id"/>
<field name="modules"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_build_tree" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<tree string="Builds">
<field name="sequence"/>
<field name="repo_id"/>
<field name="dest"/>
<field name="date"/>
<field name="author"/>
<field name="committer"/>
<field name="state"/>
<field name="port"/>
<field name="job"/>
<field name="result"/>
<field name="pid"/>
<field name="host"/>
<field name="job_start"/>
<field name="job_time"/>
<field name="job_age"/>
</tree>
</field>
</record>
<record id="view_build_graph" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<graph string="Builds" type="pivot">
<field name="create_date" interval="week" type="row"/>
<field name="state" type="col"/>
</graph>
</field>
</record>
<record id="view_build_search" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<search string="Search builds">
<field name="branch_id"/>
<field name="name"/>
<field name="state"/>
<field name="dest"/>
<separator/>
<filter string="Pending" domain="[('state','=', 'pending')]"/>
<filter string="Testing" domain="[('state','=', 'testing')]"/>
<filter string="Running" domain="[('state','=', 'running')]"/>
<filter string="Done" domain="[('state','=','done')]"/>
<filter string="Duplicate" domain="[('state','=', 'duplicate')]"/>
<filter string="Deathrow" domain="[('state','=', 'deathrow')]"/>
<separator />
<group expand="0" string="Group By...">
<filter string="Repo" domain="[]" context="{'group_by':'repo_id'}"/>
<filter string="Branch" domain="[]" context="{'group_by':'branch_id'}"/>
<filter string="Status" domain="[]" context="{'group_by':'state'}"/>
<filter string="Result" domain="[]" context="{'group_by':'result'}"/>
<filter string="Start" domain="[]" context="{'group_by':'job_start'}"/>
<filter string="Host" domain="[]" context="{'group_by':'host'}"/>
<filter string="Create Date" domain="[]" context="{'group_by':'create_date'}"/>
</group>
</search>
</field>
</record>
<record id="action_build" model="ir.actions.act_window">
<field name="name">Builds</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">runbot.build</field>
<field name="view_type">form</field>
<field name="view_mode">graph,tree,form</field>
</record>
<menuitem id="menu_build" action="action_build" parent="menu_runbot"/>
<!-- Events -->
<record id="logging_action" model="ir.actions.act_window">
<field name="name">Events</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">ir.logging</field>
<field name="view_type">form</field>
</record>
<menuitem id="logging_menu" action="logging_action" parent="menu_runbot"/>
<!-- Website menu -->
<record id="website_menu" model="website.menu">
<field name="name">Runbot</field>
<field name="url">/runbot</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">1</field>
</record>
<!-- Templates -->
<template id="assets_frontend" inherit_id="website.assets_frontend" name="runbot.assets.frontend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/octicons/2.0.2/octicons.css"/>
<script type="text/javascript" src="/runbot/static/src/js/runbot.js"/>
</xpath>
</template>
<!-- Replace default menu ( Home / Contactus and co...) with 5 first repos) -->
<template id="inherits_branch_in_menu" inherit_id="website.layout" name="Inherits Show top 5 branches in menu">
<xpath expr="//t[@t-foreach=&quot;website.menu_id.child_id&quot;][@t-as=&quot;submenu&quot;]" position="replace">
<t t-if="repos" >
<t t-foreach="repos[:5]" t-as="re">
<li><a t-attf-href="/runbot/repo/{{slug(re)}}?search={{request.params.get('search', '')}}"><i class='fa fa-github' /> <t t-esc="re.name.split(':')[1]"/></a></li>
</t>
</t>
</xpath>
</template>
<!-- remove black bar with switcher d'apps -->
<template id="inherits_no_black_bar" inherit_id="website.user_navbar" name="Inherits No black user_navbar">
<xpath expr="//t[@t-if=&quot;website and menu_data&quot;]" position="attributes">
<attribute name="groups">base.group_website_publisher</attribute>
</xpath>
</template>
<template id="runbot.build_name">
<t t-if="bu['state']=='deathrow'"><i class="text-info fa fa-crosshairs"/> killing</t>
<t t-if="bu['state']=='pending'"><i class="text-default fa fa-pause"/> pending</t>
<t t-if="bu['state']=='testing'">
<t t-set="textklass" t-value="dict(ko='danger', warn='warning').get(bu['guess_result'], 'info')"/>
<span t-attf-class="text-{{textklass}}"><i class="fa fa-spinner fa-spin"/> testing</span> <t t-esc="bu['job']"/> <small t-if="not hide_time"><t t-esc="bu['job_time']"/></small>
</t>
<t t-if="bu['result']=='ok'"><i class="text-success fa fa-thumbs-up"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='ko'"><i class="text-danger fa fa-thumbs-down"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='warn'"><i class="text-warning fa fa-warning"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='skipped'"><i class="text-danger fa fa-ban"/> skipped</t>
<t t-if="bu['result']=='killed'"><i class="text-danger fa fa-times"/> killed</t>
<t t-if="bu['result']=='manually_killed'"><i class="text-danger fa fa-times"/> manually killed</t>
<t t-if="bu['server_match'] in ('default', 'fuzzy')">
<i class="text-warning fa fa-question-circle fa-fw"
title="Server branch cannot be determined exactly. Please use naming convention '9.0-my-branch' to build with '9.0' server branch."/>
</t>
</template>
<template id="runbot.build_button">
<div t-attf-class="pull-right">
<div t-attf-class="btn-group {{klass}}">
<a t-if="bu['state']=='running'" t-attf-href="http://{{bu['domain']}}/?db={{bu['dest']}}-all" class="btn btn-primary"><i class="fa fa-sign-in"/></a>
<a t-attf-href="/runbot/build/{{bu['id']}}" class="btn btn-default"><i class="fa fa-file-text-o"/></a>
<a t-attf-href="https://#{repo.base}/commit/#{bu['name']}" class="btn btn-default"><i class="fa fa-github"/></a>
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown"><i class="fa fa-cog"/><span class="caret"></span></button>
<ul class="dropdown-menu" role="menu">
<li t-if="bu['result']=='skipped'" groups="runbot.group_runbot_admin">
<a href="#" class="runbot-rebuild" t-att-data-runbot-build="bu['id']">Force Build <i class="fa fa-level-up"></i></a>
</li>
<t t-if="bu['state']=='running'">
<li><a t-attf-href="http://{{bu['domain']}}/?db={{bu['real_dest']}}-all">Connect all <i class="fa fa-sign-in"></i></a></li>
<li><a t-attf-href="http://{{bu['domain']}}/?db={{bu['real_dest']}}-base">Connect base <i class="fa fa-sign-in"></i></a></li>
<li><a t-attf-href="http://{{bu['domain']}}/">Connect <i class="fa fa-sign-in"></i></a></li>
</t>
<li t-if="bu['state'] in ['done','running','deathrow'] and bu_index==0" groups="runbot.group_user">
<a href="#" class="runbot-rebuild" t-att-data-runbot-build="bu['id']">Rebuild <i class="fa fa-refresh"/></a>
</li>
<li t-if="bu['state'] in ['pending','testing','running']" groups="runbot.group_user">
<a href="#" class="runbot-kill" t-att-data-runbot-build="bu['id']">Kill <i class="fa fa-crosshairs"/></a>
</li>
<li t-if="bu['state']!='testing' and bu['state']!='pending'" class="divider"></li>
<li><a t-attf-href="/runbot/build/{{bu['id']}}">Logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/logs/job_10_test_base.txt">Full base logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/logs/job_20_test_all.txt">Full all logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['coverage'] and bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/coverage/index.html">Coverage <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['state']!='pending'" class="divider"></li>
<li><a t-attf-href="{{br['branch'].branch_url}}">Branch or pull <i class="fa fa-github"/></a></li>
<li><a t-attf-href="https://{{repo.base}}/commit/{{bu['name']}}">Commit <i class="fa fa-github"/></a></li>
<li><a t-attf-href="https://{{repo.base}}/compare/{{br['branch'].branch_name}}">Compare <i class="fa fa-github"/></a></li>
<!-- TODO branch.pull from -->
<li class="divider"></li>
<li class="disabled"><a href="#">Runtime: <t t-esc="bu['job_time']"/>s</a></li>
<li class="disabled"><a href="#">Port: <t t-esc="bu['port']"/></a></li>
<li class="disabled"><a href="#">Age: <t t-esc="bu['job_age']"/></a></li>
</ul>
</div>
</div>
</template>
<template id="runbot.repo">
<t t-call='website.layout'>
<t t-set="head">
<t t-if="refresh">
<meta http-equiv="refresh" t-att-content="refresh"/>
</t>
<style>
.killed {
background-color: #aaa;
}
</style>
</t>
<div class="container-fluid">
<div class="row">
<div class='col-md-12'>
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<t t-if="repo">
<ul class="nav navbar-nav">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><b style="font-size: 18px;"><t t-esc="repo.base"/></b><b class="caret"></b></a>
<ul class="dropdown-menu">
<t t-foreach='repos' t-as='re'>
<li><a t-attf-href="/runbot/repo/{{slug(re)}}"><t t-esc="re.base"/></a></li>
</t>
</ul>
</li>
</ul>
</t>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<t t-if="repo">
<form class="navbar-form navbar-right" role="search" t-att-action="qu(search='')" method="get">
<div class="form-group">
<input type="search" name="search" class="form-control" placeholder="Search" t-att-value="search"/>
<button type="submit" class="btn btn-default">Search</button>
</div>
</form>
<form class="navbar-form navbar-right form-inline">
<div class="btn-group" t-if="repo">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
Filter <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li t-if="filters['pending']=='0'"><a t-att-href="qu(pending=1)">Pending</a></li>
<li t-if="filters['pending']=='1'"><a t-att-href="qu(pending='0')"><i class="fa fa-check"/> Pending</a></li>
<li t-if="filters['testing']=='0'"><a t-att-href="qu(testing=1)">Testing</a></li>
<li t-if="filters['testing']=='1'"><a t-att-href="qu(testing='0')"><i class="fa fa-check"/> Testing</a></li>
<li t-if="filters['running']=='0'"><a t-att-href="qu(running=1)">Running</a></li>
<li t-if="filters['running']=='1'"><a t-att-href="qu(running='0')"><i class="fa fa-check"/> Running</a></li>
<li t-if="filters['done']=='0'"><a t-att-href="qu(done=1)">Done</a></li>
<li t-if="filters['done']=='1'"><a t-att-href="qu(done='0')"><i class="fa fa-check"/> Done</a></li>
<li class="divider"></li>
<li t-att-class="'active' if limit=='100' else ''"><a t-att-href="qu(limit=100)">Show last 100</a></li>
<li t-att-class="'active' if limit=='1000' else ''"><a t-att-href="qu(limit=1000)">Show last 1000</a></li>
<li t-att-class="'active' if limit=='10000' else ''"><a t-att-href="qu(limit=10000)">Show last 10000</a></li>
</ul>
</div>
</form>
</t>
</div>
<p class="text-center">
<t t-foreach="host_stats" t-as="hs">
<span class="label label-default">
<t t-esc="hs['host']"/>: <t t-esc="hs['testing']"/> testing, <t t-esc="hs['running']"/> running
</span>&amp;nbsp;
</t>
<span class="label label-info">Pending: <t t-esc="pending_total"/></span>
</p>
</div>
</nav>
<div t-if="not repo" class="mb32">
<h1>No Repository yet.</h1>
</div>
<table t-if="repo" class="table table-condensed table-bordered">
<tr>
<th>Branch</th>
<td colspan="4" class="text-right">
<t t-esc="repo.base"/>:
<t t-esc="testing"/> testing,
<t t-esc="running"/> running,
<t t-esc="pending"/> pending.
</td>
</tr>
<tr t-foreach="branches" t-as="br">
<td>
<i t-if="br['branch'].sticky" class="fa fa-star" style="color: #f0ad4e" />
<b t-esc="br['branch'].branch_name"/>
<small><t t-esc="br['builds'][0]['job_age']"/></small><br/>
<div class="btn-group btn-group-xs">
<a t-attf-href="{{br['branch'].branch_url}}" class="btn btn-default btn-xs">Branch or pull <i class="fa fa-github"/></a>
<a t-attf-href="/runbot/#{repo.id}/#{br['branch'].branch_name}" class="btn btn-default btn-xs"><i class="fa fa-fast-forward" title="Quick Connect"/></a>
</div>
</td>
<t t-foreach="br['builds']" t-as="bu">
<t t-if="bu['state']=='pending'"><t t-set="klass">default</t></t>
<t t-if="bu['state']=='testing'"><t t-set="klass">info</t></t>
<t t-if="bu['state']=='deathrow'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ko'"><t t-set="klass">danger</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'warn'"><t t-set="klass">warning</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ok'"><t t-set="klass">success</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'skipped'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] in ['killed', 'manually_killed']"><t t-set="klass">killed</t></t>
<td t-attf-class="{{klass}}">
<t t-call="runbot.build_button"><t t-set="klass">btn-group-sm</t></t>
<t t-if="bu['subject']">
<span t-esc="bu['subject'][:32] + ('...' if bu['subject'][32:] else '') " t-att-title="bu['subject']"/>
<br/>
</t>
<t t-id="bu['author']">
<t t-esc="bu['author']"/>
<t t-if="bu['committer'] and bu['author'] != bu['committer']" t-id="bu['committer']">
(<span class="octicon octicon-arrow-right"></span>&amp;nbsp;<t t-esc="bu['committer']"/>)
</t>
<br/>
</t>
<small><t t-esc="bu['dest']"/> on <t t-esc="bu['host']"/></small><br/>
<t t-call="runbot.build_name"/>
</td>
</t>
</tr>
</table>
</div>
</div>
</div>
</t>
</template>
<template id="runbot.sticky-dashboard">
<t t-call='website.layout'>
<t t-set="head">
<t t-if="refresh">
<meta http-equiv="refresh" t-att-content="refresh"/>
</t>
<style>
.bg-killed {
background-color: #aaa;
}
h4 {
padding: 3px 0;
border-bottom: 1px solid grey;
}
.r-mb02 { margin-bottom: 0.2em; }
</style>
</t>
<div class="container-fluid">
<div class="row">
<div class='col-md-12'>
<div class="container-fluid">
<p class="text-center">
<t t-foreach="host_stats" t-as="hs">
<span class="label label-default">
<t t-esc="hs['host']"/>: <t t-esc="hs['testing']"/> testing, <t t-esc="hs['running']"/> running
</span>&amp;nbsp;
</t>
<span class="label label-info">Pending: <t t-esc="pending_total"/></span>
</p>
</div>
<t t-foreach="repo_dict.values()" t-as="repo">
<h4><span><t t-esc="repo['name']"/></span>
<small class="pull-right">
<t t-esc="repo['testing']"/> testing,
<t t-esc="repo['running']"/> running,
<t t-esc="repo['pending']"/> pending.
</small></h4>
<div t-foreach="repo['branches'].values()" t-as="br">
<div class="col-md-1">
<b t-esc="br['name']"/><br/>
<small><t t-esc="br['builds'][0]['job_age']"/></small>
</div>
<div class="col-md-11 r-mb02">
<t t-foreach="br['builds']" t-as="bu">
<t t-if="bu['state']=='pending'"><t t-set="klass">default</t></t>
<t t-if="bu['state']=='testing'"><t t-set="klass">info</t></t>
<t t-if="bu['state']=='deathrow'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ko'"><t t-set="klass">danger</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'warn'"><t t-set="klass">warning</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ok'"><t t-set="klass">success</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'skipped'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] in ['killed', 'manually_killed']"><t t-set="klass">killed</t></t>
<div t-attf-class="bg-{{klass}} col-md-4">
<i class="fa fa-at"></i>
<t t-esc="bu['author']"/>
<t t-if="bu['committer'] and bu['author'] != bu['committer']" t-id="bu['committer']">
(<i class="fa fa-sign-out"></i>&amp;nbsp;<t t-esc="bu['committer']"/>)
</t>
<br/>
<i class="fa fa-envelope-o"></i>
<a t-attf-href="https://#{repo['base']}/commit/#{bu['name']}"><t t-esc="bu['subject'][:32] + ('...' if bu['subject'][32:] else '') " t-att-title="bu['subject']"/></a>
<br/>
<t t-call="runbot.build_name"/><small><a t-attf-href="/runbot/build/{{bu['id']}}"><t t-esc="bu['dest']"/></a> on <t t-esc="bu['host']"/> <a t-if="bu['state'] == 'running'" t-attf-href="http://{{bu['domain']}}/?db={{bu['dest']}}-all"><i class="fa fa-sign-in"></i></a></small>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</template>
<template id="runbot.build">
<t t-call='website.layout'>
<div class="container" style="width: 100%;">
<div class="row" >
<div class='col-md-12'>
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" t-attf-href="/runbot/repo/#{ slug(repo) }"><b><t t-esc="repo.base"/></b></a>
<a class="navbar-brand" t-attf-href="/runbot/build/{{build['id']}}">
<t t-esc="build['dest']"/>
<t t-call="runbot.build_name">
<t t-set="bu" t-value="build"/>
</t>
</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<form class="navbar-form navbar-left form-inline">
<div class="btn-group">
<t t-call="runbot.build_button">
<t t-set="bu" t-value="build"/>
<t t-set="bu_index" t-value="-1"/>
<t t-set="klass" t-value="''"/>
</t>
</div>
</form>
<p class="navbar-text">
</p>
<form class="navbar-form navbar-left form-inline">
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
Filter <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li role="presentation" class="dropdown-header">Level</li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='debug')"><i t-if="level == 'debug'" class="fa fa-check"/> Debug</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='info')"><i t-if="level == 'info'" class="fa fa-check"/> Info</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='warning')"><i t-if="level == 'warning'" class="fa fa-check"/> Warning</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='error')"><i t-if="level == 'error'" class="fa fa-check"/> Error</a></li>
<li role="presentation" class="dropdown-header">Type</li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='runbot')"><i t-if="type == 'runbot'" class="fa fa-check"/> Runbot</a></li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='server')"><i t-if="type == 'server'" class="fa fa-check"/> Server</a></li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='client')"><i t-if="type == 'client'" class="fa fa-check"/> Client</a></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-default">Expand</button>
</div>
</form>
<form class="navbar-form navbar-left form-inline" t-attf-action="/runbot/build/#{build['id']}/force" method='POST' t-if="request.params.get('ask_rebuild')" groups="runbot.group_user">
<a href='#' class="btn btn-danger runbot-rebuild" t-attf-data-runbot-build="#{build['id']}" > <i class='fa fa-refresh'/> Force Rebuild</a>
</form>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Other builds <b class="caret"></b></a>
<ul class="dropdown-menu">
<t t-foreach='other_builds' t-as='other_build'>
<li><a t-attf-href="/runbot/build/{{other_build.id}}">
<t t-esc='other_build.dest'/>
<t t-call="runbot.build_name">
<t t-set="bu" t-value="other_build"/>
<t t-set="hide_time" t-value="True"></t>
</t>
</a></li>
</t>
</ul>
</li>
</ul>
<form class="navbar-form navbar-right" role="search" t-attf-action="/runbot/build/{{build['id']}}" method="get">
<div class="form-group">
<input type="search" name="search" class="form-control" placeholder="Search" t-att-value="search or ''"/>
<button type="submit" class="btn btn-default">Search</button>
</div>
</form>
</div>
</div>
</nav>
<p>
Subject: <t t-esc="build['subject']"/><br/>
Author: <t t-esc="build['author']"/><br/>
Committer: <t t-esc="build['committer']"/><br/>
</p>
<p t-if="build['duplicate_of']">Duplicate of <a t-attf-href="/runbot/build/#{build['duplicate_of'].id}"><t t-esc="build['duplicate_of'].dest"/></a></p>
<table class="table table-condensed table-striped">
<tr>
<th>Date</th>
<th>Level</th>
<th>Type</th>
<th>Message</th>
</tr>
<t t-foreach="logs" t-as="l">
<tr t-att-class="dict(ERROR='danger', WARNING='warning').get(l.level)">
<td style="white-space: nowrap;"><t t-esc="l.create_date"/></td>
<td><b t-esc="l.level"/></td>
<td><t t-esc="l.type"/></td>
<td>
<a t-if="l.type != 'runbot'" t-attf-href="https://{{repo.base}}/blob/{{build['name']}}/{{l.path}}#L{{l.line}}"><t t-esc="l.name"/>:<t t-esc="l.line"/></a> <t t-esc="l.func"/>
<t t-if="'\n' not in l.message"><t t-esc="l.message"/></t>
<pre t-if="'\n' in l.message" style="margin:0;padding:0; border: none;"><t t-esc="l.message"/></pre>
</td>
</tr>
</t>
</table>
</div>
</div>
</div>
</t>
</template>
<template id="runbot.webclient_config">
[global]
server.environment = "development"
server.socket_host = "0.0.0.0"
server.socket_port = %d
server.thread_pool = 10
tools.sessions.on = True
log.access_level = "INFO"
log.error_level = "INFO"
tools.csrf.on = False
tools.log_tracebacks.on = False
tools.cgitb.on = True
openerp.server.host = 'localhost'
openerp.server.port = '%d'
openerp.server.protocol = 'socket'
openerp.server.timeout = 450
[openerp-web]
dblist.filter = 'BOTH'
dbbutton.visible = True
company.url = ''
openerp.server.host = 'localhost'
openerp.server.port = '%d'
openerp.server.protocol = 'socket'
openerp.server.timeout = 450
</template>
<template id="runbot.nginx_config">
pid <t t-esc="nginx_dir"/>/nginx.pid;
error_log <t t-esc="nginx_dir"/>/error.log;
worker_processes 1;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
server_names_hash_max_size 512;
server_names_hash_bucket_size 256;
client_max_body_size 10M;
index index.html;
log_format full '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" $request_time';
access_log <t t-esc="nginx_dir"/>/access.log full;
error_log <t t-esc="nginx_dir"/>/error.log;
client_body_temp_path <t t-esc="nginx_dir"/>;
fastcgi_temp_path <t t-esc="nginx_dir"/>;
autoindex on;
gzip on;
gzip_types text/css text/plain application/xml application/json application/javascript;
proxy_temp_path <t t-esc="nginx_dir"/>;
proxy_read_timeout 600;
proxy_connect_timeout 600;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
server {
listen 8080 default;
location / { proxy_pass http://127.0.0.1:<t t-esc="port"/>; }
location /longpolling/im/poll { return 404; }
location /longpolling/poll { return 404; }
location /runbot/static/ {
alias <t t-esc="runbot_static"/>;
autoindex off;
location ~ /runbot/static/build/[^/]+/logs/ {
autoindex on;
}
}
}
<t t-foreach="builds" t-as="build">
server {
listen 8080;
server_name ~^<t t-raw="re_escape(build.dest)"/>[-.].*$;
location / { proxy_pass http://127.0.0.1:<t t-esc="build.port"/>; }
location /longpolling { proxy_pass http://127.0.0.1:<t t-esc="build.port + 1"/>; }
}
</t>
}
</template>
<template id="runbot.badge_default">&lt;?xml version="1.0"?&gt;
<svg t-attf-xmlns="http://www.w3.org/2000/svg" t-attf-width="{{left.width + right.width}}" height="18">
<!-- from https://github.com/badges/shields/tree/master/templates -->
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#000" stop-opacity=".5"/>
</linearGradient>
<rect rx="4" t-attf-width="{{ left.width + right.width }}" height="18" t-att-fill="left.color"/>
<rect rx="4" t-att-x="left.width" t-att-width="right.width" height="18" t-att-fill="right.color"/>
<rect t-att-x="left.width" width="4" height="18" t-att-fill="right.color"/>
<rect rx="4" t-attf-width="{{ left.width + right.width }}" height="18" fill="url(#smooth)"/>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text t-attf-x="{{left.width/2+1}}" y="13" fill="#010101" fill-opacity=".3"><t t-esc="left.text"/></text>
<text t-attf-x="{{left.width/2+1}}" y="12"><t t-esc="left.text"/></text>
<text t-attf-x="{{left.width+right.width/2-1}}" y="13" fill="#010101" fill-opacity=".3"><t t-esc="right.text"/></text>
<text t-attf-x="{{left.width+right.width/2-1}}" y="12"><t t-esc="right.text"/></text>
</g>
</svg>
</template>
<template id="runbot.badge_flat">&lt;?xml version="1.0"?&gt;
<svg t-attf-xmlns="http://www.w3.org/2000/svg" t-attf-width="{{left.width + right.width}}" height="20">
<!-- from https://github.com/badges/shields/tree/master/templates -->
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".1"/>
<stop offset=".1" stop-color="#fff" stop-opacity=".1"/>
<stop offset=".9" stop-color="#fff" stop-opacity=".1"/>
<stop offset="1" stop-color="#fff" stop-opacity=".1"/>
</linearGradient>
<rect rx="3" t-attf-width="{{ left.width + right.width }}" height="20" t-att-fill="left.color"/>
<rect rx="3" t-att-x="left.width" t-att-width="right.width" height="20" t-att-fill="right.color"/>
<rect t-att-x="left.width" width="4" height="20" t-att-fill="right.color"/>
<rect rx="3" t-attf-width="{{ left.width + right.width }}" height="20" fill="url(#smooth)"/>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text t-attf-x="{{left.width/2+1}}" y="15" fill="#010101" fill-opacity=".3"><t t-esc="left.text"/></text>
<text t-attf-x="{{left.width/2+1}}" y="14"><t t-esc="left.text"/></text>
<text t-attf-x="{{left.width+right.width/2-1}}" y="15" fill="#010101" fill-opacity=".3"><t t-esc="right.text"/></text>
<text t-attf-x="{{left.width+right.width/2-1}}" y="14"><t t-esc="right.text"/></text>
</g>
</svg>
</template>
</data>
<data noupdate="1">
<!-- Cron -->
<record model="ir.cron" id="repo_cron">
<field name='name'>Runbot Cron</field>
<field name='interval_number'>1</field>
<field name='interval_type'>minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False" />
<field name="model">runbot.repo</field>
<field name="function">_cron</field>
<field name="args">()</field>
</record>
</data>
</openerp>

View File

@ -0,0 +1,10 @@
<odoo>
<data>
<template id="assets_frontend" inherit_id="website.assets_frontend" name="runbot.assets.frontend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/octicons/2.0.2/octicons.css"/>
<script type="text/javascript" src="/runbot/static/src/js/runbot.js"/>
</xpath>
</template>
</data>
</odoo>

181
runbot/templates/build.xml Normal file
View File

@ -0,0 +1,181 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="runbot.build_name">
<t t-if="bu['state']=='deathrow'"><i class="text-info fa fa-crosshairs"/> killing</t>
<t t-if="bu['state']=='pending'"><i class="text-default fa fa-pause"/> pending</t>
<t t-if="bu['state']=='testing'">
<t t-set="textklass" t-value="dict(ko='danger', warn='warning').get(bu['guess_result'], 'info')"/>
<span t-attf-class="text-{{textklass}}"><i class="fa fa-spinner fa-spin"/> testing</span> <t t-esc="bu['job']"/> <small t-if="not hide_time"><t t-esc="bu['job_time']"/></small>
</t>
<t t-if="bu['result']=='ok'"><i class="text-success fa fa-thumbs-up"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='ko'"><i class="text-danger fa fa-thumbs-down"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='warn'"><i class="text-warning fa fa-warning"/><small t-if="not hide_time"> age <t t-esc="bu['job_age']"/> time <t t-esc="bu['job_time']"/></small></t>
<t t-if="bu['result']=='skipped'"><i class="text-danger fa fa-ban"/> skipped</t>
<t t-if="bu['result']=='killed'"><i class="text-danger fa fa-times"/> killed</t>
<t t-if="bu['result']=='manually_killed'"><i class="text-danger fa fa-times"/> manually killed</t>
<t t-if="bu['server_match'] in ('default', 'fuzzy')">
<i class="text-warning fa fa-question-circle fa-fw"
title="Server branch cannot be determined exactly. Please use naming convention '9.0-my-branch' to build with '9.0' server branch."/>
</t>
</template>
<template id="runbot.build_button">
<div t-attf-class="pull-right">
<div t-attf-class="btn-group {{klass}}">
<a t-if="bu['state']=='running'" t-attf-href="http://{{bu['domain']}}/?db={{bu['dest']}}-all" class="btn btn-primary"><i class="fa fa-sign-in"/></a>
<a t-attf-href="/runbot/build/{{bu['id']}}" class="btn btn-default"><i class="fa fa-file-text-o"/></a>
<a t-attf-href="https://#{repo.base}/commit/#{bu['name']}" class="btn btn-default"><i class="fa fa-github"/></a>
<button class="btn btn-default dropdown-toggle" data-toggle="dropdown"><i class="fa fa-cog"/><span class="caret"></span></button>
<ul class="dropdown-menu" role="menu">
<li t-if="bu['result']=='skipped'" groups="runbot.group_runbot_admin">
<a href="#" class="runbot-rebuild" t-att-data-runbot-build="bu['id']">Force Build <i class="fa fa-level-up"></i></a>
</li>
<t t-if="bu['state']=='running'">
<li><a t-attf-href="http://{{bu['domain']}}/?db={{bu['real_dest']}}-all">Connect all <i class="fa fa-sign-in"></i></a></li>
<li><a t-attf-href="http://{{bu['domain']}}/?db={{bu['real_dest']}}-base">Connect base <i class="fa fa-sign-in"></i></a></li>
<li><a t-attf-href="http://{{bu['domain']}}/">Connect <i class="fa fa-sign-in"></i></a></li>
</t>
<li t-if="bu['state'] in ['done','running','deathrow'] and bu_index==0" groups="runbot.group_user">
<a href="#" class="runbot-rebuild" t-att-data-runbot-build="bu['id']">Rebuild <i class="fa fa-refresh"/></a>
</li>
<li t-if="bu['state'] in ['pending','testing','running']" groups="runbot.group_user">
<a href="#" class="runbot-kill" t-att-data-runbot-build="bu['id']">Kill <i class="fa fa-crosshairs"/></a>
</li>
<li t-if="bu['state']!='testing' and bu['state']!='pending'" class="divider"></li>
<li><a t-attf-href="/runbot/build/{{bu['id']}}">Logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/logs/job_10_test_base.txt">Full base logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/logs/job_20_test_all.txt">Full all logs <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['coverage'] and bu['host']"><a t-attf-href="http://{{bu['host']}}/runbot/static/build/#{bu['real_dest']}/coverage/index.html">Coverage <i class="fa fa-file-text-o"/></a></li>
<li t-if="bu['state']!='pending'" class="divider"></li>
<li><a t-attf-href="{{br['branch'].branch_url}}">Branch or pull <i class="fa fa-github"/></a></li>
<li><a t-attf-href="https://{{repo.base}}/commit/{{bu['name']}}">Commit <i class="fa fa-github"/></a></li>
<li><a t-attf-href="https://{{repo.base}}/compare/{{br['branch'].branch_name}}">Compare <i class="fa fa-github"/></a></li>
<!-- TODO branch.pull from -->
<li class="divider"></li>
<li class="disabled"><a href="#">Runtime: <t t-esc="bu['job_time']"/>s</a></li>
<li class="disabled"><a href="#">Port: <t t-esc="bu['port']"/></a></li>
<li class="disabled"><a href="#">Age: <t t-esc="bu['job_age']"/></a></li>
</ul>
</div>
</div>
</template>
<!-- Event / Logs page -->
<template id="runbot.build">
<t t-call='website.layout'>
<div class="container" style="width: 100%;">
<div class="row" >
<div class='col-md-12'>
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" t-attf-href="/runbot/repo/#{ slug(repo) }"><b><t t-esc="repo.base"/></b></a>
<a class="navbar-brand" t-attf-href="/runbot/build/{{build['id']}}">
<t t-esc="build['dest']"/>
<t t-call="runbot.build_name">
<t t-set="bu" t-value="build"/>
</t>
</a>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<form class="navbar-form navbar-left form-inline">
<div class="btn-group">
<t t-call="runbot.build_button">
<t t-set="bu" t-value="build"/>
<t t-set="bu_index" t-value="-1"/>
<t t-set="klass" t-value="''"/>
</t>
</div>
</form>
<p class="navbar-text">
</p>
<form class="navbar-form navbar-left form-inline">
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
Filter <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li role="presentation" class="dropdown-header">Level</li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='debug')"><i t-if="level == 'debug'" class="fa fa-check"/> Debug</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='info')"><i t-if="level == 'info'" class="fa fa-check"/> Info</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='warning')"><i t-if="level == 'warning'" class="fa fa-check"/> Warning</a></li>
<li><a t-att-href="'?' + keep_query('search', 'type', level='error')"><i t-if="level == 'error'" class="fa fa-check"/> Error</a></li>
<li role="presentation" class="dropdown-header">Type</li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='runbot')"><i t-if="type == 'runbot'" class="fa fa-check"/> Runbot</a></li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='server')"><i t-if="type == 'server'" class="fa fa-check"/> Server</a></li>
<li><a t-att-href="'?' + keep_query('search', 'level',type='client')"><i t-if="type == 'client'" class="fa fa-check"/> Client</a></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-default">Expand</button>
</div>
</form>
<form class="navbar-form navbar-left form-inline" t-attf-action="/runbot/build/#{build['id']}/force" method='POST' t-if="request.params.get('ask_rebuild')" groups="runbot.group_user">
<a href='#' class="btn btn-danger runbot-rebuild" t-attf-data-runbot-build="#{build['id']}" > <i class='fa fa-refresh'/> Force Rebuild</a>
</form>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Other builds <b class="caret"></b></a>
<ul class="dropdown-menu">
<t t-foreach='other_builds' t-as='other_build'>
<li><a t-attf-href="/runbot/build/{{other_build.id}}">
<t t-esc='other_build.dest'/>
<t t-call="runbot.build_name">
<t t-set="bu" t-value="other_build"/>
<t t-set="hide_time" t-value="True"></t>
</t>
</a></li>
</t>
</ul>
</li>
</ul>
<form class="navbar-form navbar-right" role="search" t-attf-action="/runbot/build/{{build['id']}}" method="get">
<div class="form-group">
<input type="search" name="search" class="form-control" placeholder="Search" t-att-value="search or ''"/>
<button type="submit" class="btn btn-default">Search</button>
</div>
</form>
</div>
</div>
</nav>
<p>
Subject: <t t-esc="build['subject']"/><br/>
Author: <t t-esc="build['author']"/><br/>
Committer: <t t-esc="build['committer']"/><br/>
</p>
<p t-if="build['duplicate_of']">Duplicate of <a t-attf-href="/runbot/build/#{build['duplicate_of'].id}"><t t-esc="build['duplicate_of'].dest"/></a></p>
<table class="table table-condensed table-striped">
<tr>
<th>Date</th>
<th>Level</th>
<th>Type</th>
<th>Message</th>
</tr>
<t t-foreach="logs" t-as="l">
<tr t-att-class="dict(ERROR='danger', WARNING='warning').get(l.level)">
<td style="white-space: nowrap;"><t t-esc="l.create_date"/></td>
<td><b t-esc="l.level"/></td>
<td><t t-esc="l.type"/></td>
<td>
<a t-if="l.type != 'runbot'" t-attf-href="https://{{repo.base}}/blob/{{build['name']}}/{{l.path}}#L{{l.line}}"><t t-esc="l.name"/>:<t t-esc="l.line"/></a> <t t-esc="l.func"/>
<t t-if="'\n' not in l.message"><t t-esc="l.message"/></t>
<pre t-if="'\n' in l.message" style="margin:0;padding:0; border: none;"><t t-esc="l.message"/></pre>
</td>
</tr>
</t>
</table>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>

View File

@ -0,0 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Replace default menu ( Home / Contactus and co...) with 5 first repos) -->
<template id="inherits_branch_in_menu" inherit_id="website.layout" name="Inherits Show top 5 branches in menu">
<xpath expr="//t[@t-foreach=&quot;website.menu_id.child_id&quot;][@t-as=&quot;submenu&quot;]" position="replace">
<t t-if="repos" >
<t t-foreach="repos[:5]" t-as="re">
<li><a t-attf-href="/runbot/repo/{{slug(re)}}?search={{request.params.get('search', '')}}"><i class='fa fa-github' /> <t t-esc="re.name.split(':')[1]"/></a></li>
</t>
</t>
</xpath>
</template>
<!-- remove black bar with switcher d'apps -->
<template id="inherits_no_black_bar" inherit_id="website.user_navbar" name="Inherits No black user_navbar">
<xpath expr="//nav[@id='oe_main_menu_navbar']" position="attributes">
<attribute name="groups">base.group_website_publisher</attribute>
</xpath>
</template>
<!-- Frontend repository block -->
<template id="runbot.repo">
<t t-call='website.layout'>
<t t-set="head">
<t t-if="refresh">
<meta http-equiv="refresh" t-att-content="refresh"/>
</t>
<style>
.killed {
background-color: #aaa;
}
</style>
</t>
<div class="container-fluid">
<div class="row">
<div class='col-md-12'>
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<t t-if="repo">
<ul class="nav navbar-nav">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><b style="font-size: 18px;"><t t-esc="repo.base"/></b><b class="caret"></b></a>
<ul class="dropdown-menu">
<t t-foreach='repos' t-as='re'>
<li><a t-attf-href="/runbot/repo/{{slug(re)}}"><t t-esc="re.base"/></a></li>
</t>
</ul>
</li>
</ul>
</t>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<t t-if="repo">
<form class="navbar-form navbar-right" role="search" t-att-action="qu(search='')" method="get">
<div class="form-group">
<input type="search" name="search" class="form-control" placeholder="Search" t-att-value="search"/>
<button type="submit" class="btn btn-default">Search</button>
</div>
</form>
<form class="navbar-form navbar-right form-inline">
<div class="btn-group" t-if="repo">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
Filter <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
<li t-if="filters['pending']=='0'"><a t-att-href="qu(pending=1)">Pending</a></li>
<li t-if="filters['pending']=='1'"><a t-att-href="qu(pending='0')"><i class="fa fa-check"/> Pending</a></li>
<li t-if="filters['testing']=='0'"><a t-att-href="qu(testing=1)">Testing</a></li>
<li t-if="filters['testing']=='1'"><a t-att-href="qu(testing='0')"><i class="fa fa-check"/> Testing</a></li>
<li t-if="filters['running']=='0'"><a t-att-href="qu(running=1)">Running</a></li>
<li t-if="filters['running']=='1'"><a t-att-href="qu(running='0')"><i class="fa fa-check"/> Running</a></li>
<li t-if="filters['done']=='0'"><a t-att-href="qu(done=1)">Done</a></li>
<li t-if="filters['done']=='1'"><a t-att-href="qu(done='0')"><i class="fa fa-check"/> Done</a></li>
<li class="divider"></li>
<li t-att-class="'active' if limit=='100' else ''"><a t-att-href="qu(limit=100)">Show last 100</a></li>
<li t-att-class="'active' if limit=='1000' else ''"><a t-att-href="qu(limit=1000)">Show last 1000</a></li>
<li t-att-class="'active' if limit=='10000' else ''"><a t-att-href="qu(limit=10000)">Show last 10000</a></li>
</ul>
</div>
</form>
</t>
</div>
<p class="text-center">
<t t-foreach="host_stats" t-as="hs">
<span class="label label-default">
<t t-esc="hs['host']"/>: <t t-esc="hs['testing']"/> testing, <t t-esc="hs['running']"/> running
</span>&amp;nbsp;
</t>
<span class="label label-info">Pending: <t t-esc="pending_total"/></span>
</p>
</div>
</nav>
<div t-if="not repo" class="mb32">
<h1>No Repository yet.</h1>
</div>
<table t-if="repo" class="table table-condensed table-bordered">
<tr>
<th>Branch</th>
<td colspan="4" class="text-right">
<t t-esc="repo.base"/>:
<t t-esc="testing"/> testing,
<t t-esc="running"/> running,
<t t-esc="pending"/> pending.
</td>
</tr>
<tr t-foreach="branches" t-as="br">
<td>
<i t-if="br['branch'].sticky" class="fa fa-star" style="color: #f0ad4e" />
<b t-esc="br['branch'].branch_name"/>
<small><t t-esc="br['builds'][0]['job_age']"/></small><br/>
<div class="btn-group btn-group-xs">
<a t-attf-href="{{br['branch'].branch_url}}" class="btn btn-default btn-xs">Branch or pull <i class="fa fa-github"/></a>
<a t-attf-href="/runbot/#{repo.id}/#{br['branch'].branch_name}" class="btn btn-default btn-xs"><i class="fa fa-fast-forward" title="Quick Connect"/></a>
</div>
</td>
<t t-foreach="br['builds']" t-as="bu">
<t t-if="bu['state']=='pending'"><t t-set="klass">default</t></t>
<t t-if="bu['state']=='testing'"><t t-set="klass">info</t></t>
<t t-if="bu['state']=='deathrow'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ko'"><t t-set="klass">danger</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'warn'"><t t-set="klass">warning</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'ok'"><t t-set="klass">success</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] == 'skipped'"><t t-set="klass">default</t></t>
<t t-if="bu['state'] in ['running','done'] and bu['result'] in ['killed', 'manually_killed']"><t t-set="klass">killed</t></t>
<td t-attf-class="{{klass}}">
<t t-call="runbot.build_button"><t t-set="klass">btn-group-sm</t></t>
<t t-if="bu['subject']">
<span t-esc="bu['subject'][:32] + ('...' if bu['subject'][32:] else '') " t-att-title="bu['subject']"/>
<br/>
</t>
<t t-id="bu['author']">
<t t-esc="bu['author']"/>
<t t-if="bu['committer'] and bu['author'] != bu['committer']" t-id="bu['committer']">
(<span class="octicon octicon-arrow-right"></span>&amp;nbsp;<t t-esc="bu['committer']"/>)
</t>
<br/>
</t>
<small><t t-esc="bu['dest']"/> on <t t-esc="bu['host']"/></small><br/>
<t t-call="runbot.build_name"/>
</td>
</t>
</tr>
</table>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="runbot.nginx_config">
pid <t t-esc="nginx_dir"/>/nginx.pid;
error_log <t t-esc="nginx_dir"/>/error.log;
worker_processes 1;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
server_names_hash_max_size 512;
server_names_hash_bucket_size 256;
client_max_body_size 10M;
index index.html;
log_format full '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" $request_time';
access_log <t t-esc="nginx_dir"/>/access.log full;
error_log <t t-esc="nginx_dir"/>/error.log;
client_body_temp_path <t t-esc="nginx_dir"/>;
fastcgi_temp_path <t t-esc="nginx_dir"/>;
autoindex on;
gzip on;
gzip_types text/css text/plain application/xml application/json application/javascript;
proxy_temp_path <t t-esc="nginx_dir"/>;
proxy_read_timeout 600;
proxy_connect_timeout 600;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
server {
listen 8080 default;
location / { proxy_pass http://127.0.0.1:<t t-esc="port"/>; }
location /longpolling/im/poll { return 404; }
location /longpolling/poll { return 404; }
location /runbot/static/ {
alias <t t-esc="runbot_static"/>;
autoindex off;
location ~ /runbot/static/build/[^/]+/logs/ {
autoindex on;
}
}
}
<t t-foreach="builds" t-as="build">
server {
listen 8080;
server_name ~^<t t-raw="re_escape(build.dest)"/>[-.].*$;
location / { proxy_pass http://127.0.0.1:<t t-esc="build.port"/>; }
location /longpolling { proxy_pass http://127.0.0.1:<t t-esc="build.port + 1"/>; }
}
</t>
}
</template>
</data>
</odoo>

View File

@ -0,0 +1,60 @@
<odoo>
<data>
<record id="branch_form" model="ir.ui.view">
<field name="name">runbot.branch.form</field>
<field name="model">runbot.branch</field>
<field name="arch" type="xml">
<form>
<header>
</header>
<sheet>
<group name="branch_group">
<field name="repo_id"/>
<field name="name"/>
<field name="branch_name"/>
<field name="branch_url"/>
<field name="pull_head_name"/>
<field name="sticky"/>
<field name="coverage"/>
<field name="state"/>
<field name="modules"/>
<field name="job_timeout"/>
<!-- keep for next version
<field name="test_tags"/>
-->
</group>
</sheet>
</form>
</field>
</record>
<record id="branch_view_tree" model="ir.ui.view">
<field name="name">runbot.branch.tree</field>
<field name="model">runbot.branch</field>
<field name="arch" type="xml">
<tree string="Branches">
<field name="repo_id"/>
<field name="name"/>
<field name="sticky"/>
<field name="coverage"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="open_view_branch_tree" model="ir.actions.act_window">
<field name="name">Branches</field>
<field name="res_model">runbot.branch</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
name="Branches"
id="runbot_menu_branch_tree"
parent="runbot_menu_root"
sequence="20"
action="open_view_branch_tree"
/>
</data>
</odoo>

View File

@ -0,0 +1,108 @@
<odoo>
<data>
<record id="build_form" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<form string="Build">
<header>
<button name="reset" type="object" string="Reset"/>
</header>
<sheet>
<group>
<field name="repo_id"/>
<field name="branch_id"/>
<field name="sequence"/>
<field name="name"/>
<field name="date"/>
<field name="author"/>
<field name="author_email"/>
<field name="committer"/>
<field name="committer_email"/>
<field name="subject"/>
<field name="port"/>
<field name="dest"/>
<field name="state"/>
<field name="result"/>
<field name="pid"/>
<field name="host"/>
<field name="job_start"/>
<field name="job_end"/>
<field name="job_time"/>
<field name="job_age"/>
<field name="duplicate_id"/>
<field name="modules"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_build_tree" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<tree string="Builds">
<field name="sequence"/>
<field name="repo_id"/>
<field name="dest"/>
<field name="date"/>
<field name="author"/>
<field name="committer"/>
<field name="state"/>
<field name="port"/>
<field name="job"/>
<field name="result"/>
<field name="pid"/>
<field name="host"/>
<field name="job_start"/>
<field name="job_time"/>
<field name="job_age"/>
</tree>
</field>
</record>
<record id="view_build_pivot" model="ir.ui.view">
<field name="name">runbot.pivot</field>
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<pivot string="Builds analysis">
<field name="create_date" interval="week" type="row"/>
<field name="state" type="col"/>
</pivot>
</field>
</record>
<record id="view_build_search" model="ir.ui.view">
<field name="model">runbot.build</field>
<field name="arch" type="xml">
<search string="Search builds">
<field name="branch_id"/>
<field name="name"/>
<field name="state"/>
<field name="dest"/>
<separator/>
<filter string="Pending" domain="[('state','=', 'pending')]"/>
<filter string="Testing" domain="[('state','=', 'testing')]"/>
<filter string="Running" domain="[('state','=', 'running')]"/>
<filter string="Done" domain="[('state','=','done')]"/>
<filter string="Duplicate" domain="[('state','=', 'duplicate')]"/>
<filter string="Deathrow" domain="[('state','=', 'deathrow')]"/>
<separator />
<group expand="0" string="Group By...">
<filter string="Repo" domain="[]" context="{'group_by':'repo_id'}"/>
<filter string="Branch" domain="[]" context="{'group_by':'branch_id'}"/>
<filter string="Status" domain="[]" context="{'group_by':'state'}"/>
<filter string="Result" domain="[]" context="{'group_by':'result'}"/>
<filter string="Start" domain="[]" context="{'group_by':'job_start'}"/>
<filter string="Host" domain="[]" context="{'group_by':'host'}"/>
<filter string="Create Date" domain="[]" context="{'group_by':'create_date'}"/>
</group>
</search>
</field>
</record>
<record id="action_build" model="ir.actions.act_window">
<field name="name">Builds</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">runbot.build</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form,graph,pivot</field>
</record>
<menuitem id="menu_build" action="action_build" parent="runbot_menu_root"/>
</data>
</odoo>

View File

@ -0,0 +1,59 @@
<odoo>
<data>
<menuitem name="Runbot" id="runbot_menu_root"/>
<record id="repo_form" model="ir.ui.view">
<field name="name">runbot.repo.form</field>
<field name="model">runbot.repo</field>
<field name="arch" type="xml">
<form>
<header>
</header>
<sheet>
<group name="repo_group">
<field name="sequence"/>
<field name="name"/>
<field name="mode"/>
<field name="nginx"/>
<field name="duplicate_id"/>
<field name="dependency_ids" widget="many2many_tags"/>
<field name="modules"/>
<field name="modules_auto"/>
<field name="token"/>
<field name="group_ids" widget="many2many_tags"/>
<field name="hook_time"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="repo_view_tree" model="ir.ui.view">
<field name="name">runbot.repo.tree</field>
<field name="model">runbot.repo</field>
<field name="arch" type="xml">
<tree string="Repositories">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="mode"/>
</tree>
</field>
</record>
<record id="open_view_repo_tree" model="ir.actions.act_window">
<field name="name">Repositories</field>
<field name="res_model">runbot.repo</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
name="Repositories"
id="runbot_menu_repo_tree"
parent="runbot_menu_root"
sequence="10"
action="open_view_repo_tree"
/>
</data>
</odoo>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.runbot</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" data-string="Runbot" string="Runbot" data-key="runbot">
<h2>Runbot configuration</h2>
<div class="row mt16 o_settings_container">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_right_pane">
<div class="content-group">
<div class="mt-16 row">
<label for="runbot_workers" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_workers" style="width: 30%;"/>
</div>
<div class="mt-16 row">
<label for="runbot_running_max" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_running_max" style="width: 30%;"/>
</div>
</div>
</div>
<div class="o_setting_right_pane">
<div class="content-group">
<div class="content-group">
<div class="mt-16 row">
<label for="runbot_timeout" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_timeout" style="width: 30%;"/>
</div>
<div class="mt-16 row">
<label for="runbot_starting_port" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_starting_port" style="width: 30%;"/>
</div>
<div class="mt-16 row">
<label for="runbot_domain" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_domain" style="width: 30%;"/>
</div>
<div class="mt-16 row">
<label for="runbot_max_age" class="col-xs-3 o_light_label" style="width: 60%;"/>
<field name="runbot_max_age" style="width: 30%;"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
<record id="action_runbot_configuration" model="ir.actions.act_window">
<field name="name">Settings</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module' : 'runbot'}</field>
</record>
<menuitem id="menu_runbot_global_settings" name="Settings"
parent="runbot_menu_root" sequence="0" action="action_runbot_configuration" groups="base.group_system"/>
</data>
</odoo>

View File

@ -1 +1 @@
import runbot
from . import runbot

View File

@ -2,9 +2,9 @@
'name': 'Runbot CLA',
'category': 'Website',
'summary': 'Runbot CLA',
'version': '1.1',
'version': '2.0',
'description': "Runbot CLA",
'author': 'Odoo SA',
'depends': ['runbot'],
'data': [ ],
'data': [],
}

View File

@ -4,32 +4,36 @@ import glob
import logging
import re
import openerp
from openerp.tools import ustr
from odoo import models
_logger = logging.getLogger(__name__)
class runbot_build(openerp.models.Model):
class runbot_build(models.Model):
_inherit = "runbot.build"
def _job_05_check_cla(self, cr, uid, build, lock_path, log_path):
def _job_05_check_cla(self, build, lock_path, log_path):
cla_glob = glob.glob(build._path("doc/cla/*/*.md"))
if cla_glob:
cla = ''.join(open(f).read() for f in cla_glob)
cla = ustr(cla.lower())
description = "%s Odoo CLA signature check" % build.author
mo = re.search('[^ <@]+@[^ @>]+', build.author_email or '')
state = "failure"
if mo:
email = mo.group(0).lower()
if re.match('.*@(odoo|openerp|tinyerp)\.com$', email):
state = "success"
if cla.find(email) != -1:
state = "success"
_logger.info('CLA build:%s email:%s result:%s', build.dest, email, state)
else:
try:
cla = ''.join(open(f).read() for f in cla_glob)
if cla.lower().find(email) != -1:
state = "success"
except UnicodeDecodeError:
description = 'Invalid CLA encoding (must be utf-8)'
_logger.info('CLA build:%s email:%s result:%s', build.dest, email, state)
status = {
"state": state,
"target_url": "https://www.odoo.com/sign-cla",
"description": "%s Odoo CLA signature check" % build.author,
"description": description,
"context": "legal/cla"
}
build._log('check_cla', 'CLA %s' % state)