mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 23:45:44 +07:00
[ADD] runbot_merge: staging reifier
Add experimental support for creating submodule-based commits for stagings (and batches), and pushing those in ancillary repositories. Fixes #768
This commit is contained in:
parent
0fd254b5fe
commit
2ace06957a
@ -10,6 +10,7 @@
|
|||||||
'models/crons/git_maintenance.xml',
|
'models/crons/git_maintenance.xml',
|
||||||
'models/crons/cleanup_scratch_branches.xml',
|
'models/crons/cleanup_scratch_branches.xml',
|
||||||
'models/crons/issues_closer.xml',
|
'models/crons/issues_closer.xml',
|
||||||
|
'models/crons/staging_reifier.xml',
|
||||||
'data/runbot_merge.pull_requests.feedback.template.csv',
|
'data/runbot_merge.pull_requests.feedback.template.csv',
|
||||||
'views/res_partner.xml',
|
'views/res_partner.xml',
|
||||||
'views/runbot_merge_project.xml',
|
'views/runbot_merge_project.xml',
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
from . import git_maintenance
|
from . import git_maintenance
|
||||||
from . import cleanup_scratch_branches
|
from . import cleanup_scratch_branches
|
||||||
from . import issues_closer
|
from . import issues_closer
|
||||||
|
from . import staging_reifier
|
||||||
|
350
runbot_merge/models/crons/staging_reifier.py
Normal file
350
runbot_merge/models/crons/staging_reifier.py
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
""" Implements the reification of stagings into cross-repo git commits
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import datetime
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from subprocess import PIPE
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from ....runbot_merge import git
|
||||||
|
from ..batch import Batch
|
||||||
|
from ..pull_requests import Branch, StagingCommits
|
||||||
|
|
||||||
|
|
||||||
|
class Project(models.Model):
|
||||||
|
_inherit = 'runbot_merge.project'
|
||||||
|
|
||||||
|
repo_flat_layout = fields.Char()
|
||||||
|
repo_pythonpath_layout = fields.Char()
|
||||||
|
|
||||||
|
|
||||||
|
class Stagings(models.Model):
|
||||||
|
_inherit = 'runbot_merge.stagings'
|
||||||
|
|
||||||
|
# can not make these required because we need to have created the staging
|
||||||
|
# in order to know what its id is (also makes handling of batches easier)
|
||||||
|
# and NOT NULL can't be deferred
|
||||||
|
hash_flat_layout = fields.Char()
|
||||||
|
hash_pythonpath_layout = fields.Char()
|
||||||
|
|
||||||
|
id: int
|
||||||
|
target: Branch
|
||||||
|
batch_ids: Batch
|
||||||
|
commits: StagingCommits
|
||||||
|
staged_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Repository(models.Model):
|
||||||
|
_inherit = 'runbot_merge.repository'
|
||||||
|
|
||||||
|
name: str
|
||||||
|
pythonpath_location = fields.Char(
|
||||||
|
help="Where the submodule should be located in the pythonpath layout, "
|
||||||
|
"if empty defaults to the root, unless `pythonpath_link_path` is "
|
||||||
|
"set, then defaults to `.repos`."
|
||||||
|
""
|
||||||
|
"%(fullname)s, %(owner)s, and %(name)s are available as"
|
||||||
|
"placeholders in the path",
|
||||||
|
)
|
||||||
|
pythonpath_link_path = fields.Char(
|
||||||
|
help="Where the repository should be symlinked in the pythonpath layout,"
|
||||||
|
" leave empty to not symlink."
|
||||||
|
""
|
||||||
|
"%(fullname)s, %(owner)s, and %(name)s are available as"
|
||||||
|
"placeholders in the path",
|
||||||
|
)
|
||||||
|
pythonpath_link_target = fields.Char(
|
||||||
|
help="Where the symlink should point to inside the repository, "
|
||||||
|
"leave empty for the root",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Reifier(models.Model):
|
||||||
|
_name = 'runbot_merge.staging.reifier'
|
||||||
|
_description = "transcriptor of staging into cross-repo git commits"
|
||||||
|
_order = 'create_date asc, id asc'
|
||||||
|
|
||||||
|
staging_id: Stagings = fields.Many2one('runbot_merge.stagings', required=True)
|
||||||
|
previous_staging_id: Stagings = fields.Many2one('runbot_merge.stagings')
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
self.env.ref('runbot_merge.staging_reifier')._trigger()
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
projects = self.env['runbot_merge.project']
|
||||||
|
branches = set()
|
||||||
|
while r := self.search([], limit=1):
|
||||||
|
reify(r.staging_id, r.previous_staging_id)
|
||||||
|
projects |= r.staging_id.target.project_id
|
||||||
|
branches.add(r.staging_id.target)
|
||||||
|
r.unlink()
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
if name_flat := project.repo_flat_layout:
|
||||||
|
git.get_local(SimpleNamespace(
|
||||||
|
name=name_flat,
|
||||||
|
project_id=project,
|
||||||
|
)).push('origin', 'refs/heads/*:refs/heads/*')
|
||||||
|
if name_pythonpath := project.repo_pythonpath_layout:
|
||||||
|
git.get_local(SimpleNamespace(
|
||||||
|
name=name_pythonpath,
|
||||||
|
project_id=project,
|
||||||
|
)).push('origin', 'refs/heads/*:refs/heads/*')
|
||||||
|
def reify(staging: Stagings, prev: Stagings) -> None:
|
||||||
|
project = staging.target.project_id
|
||||||
|
repos = project.repo_ids.having_branch(staging.target)
|
||||||
|
|
||||||
|
repo_flat = None
|
||||||
|
commit_flat = prev.hash_flat_layout
|
||||||
|
if name_flat := project.repo_flat_layout:
|
||||||
|
repo_flat = git.get_local(SimpleNamespace(
|
||||||
|
name=name_flat,
|
||||||
|
project_id=project,
|
||||||
|
)).with_config(check=True, encoding="utf-8", stdout=PIPE)
|
||||||
|
|
||||||
|
repo_pythonpath = None
|
||||||
|
commit_pythonpath = prev.hash_pythonpath_layout
|
||||||
|
if name_pythonpath := project.repo_pythonpath_layout:
|
||||||
|
repo_pythonpath = git.get_local(SimpleNamespace(
|
||||||
|
name=name_pythonpath,
|
||||||
|
project_id=project,
|
||||||
|
)).with_config(check=True, encoding="utf-8", stdout=PIPE)
|
||||||
|
|
||||||
|
if prev:
|
||||||
|
commits = {c.repository_id.name: c.commit_id.sha for c in prev.commits}
|
||||||
|
else:
|
||||||
|
# if there is no `prev` then we need to run the staging in reverse in
|
||||||
|
# order to get all the "root" commits for the staging, then we need to
|
||||||
|
# get their parents in order to know what state the repos were in
|
||||||
|
# before the staging, and *that* lets us figure out the per-batch state
|
||||||
|
commits = {c.repository_id.name: c.commit_id.sha for c in staging.commits}
|
||||||
|
|
||||||
|
# need to work off of the PR commits, because if no PR touches a
|
||||||
|
# repository then `commits` could hold *the first commit in the
|
||||||
|
# repository*, in which case we don't want to remove it
|
||||||
|
pr_commits = {}
|
||||||
|
for batch in reversed(staging.batch_ids):
|
||||||
|
# FIXME: broken if the PR has more than one commit and was rebased...
|
||||||
|
pr_commits.update(
|
||||||
|
(pr.repository.name, json.loads(pr.commits_map)[''])
|
||||||
|
for pr in batch.prs
|
||||||
|
)
|
||||||
|
|
||||||
|
for r, c in pr_commits.items():
|
||||||
|
repo = git.get_local(SimpleNamespace(
|
||||||
|
name=r,
|
||||||
|
project_id=project,
|
||||||
|
)).with_config(encoding="utf-8", stdout=PIPE)
|
||||||
|
if parent := repo.rev_parse('--revs-only', c + '~').stdout.strip():
|
||||||
|
commits[r] = parent
|
||||||
|
else:
|
||||||
|
# if a PR's commit has no parent then the PR itself introduced
|
||||||
|
# the repo (what?) in which case we don't have the repo at the
|
||||||
|
# staging and should ignore it
|
||||||
|
del commits[r]
|
||||||
|
|
||||||
|
for batch in staging.batch_ids:
|
||||||
|
commits.update(
|
||||||
|
(pr.repository.name, json.loads(pr.commits_map)[''])
|
||||||
|
for pr in batch.prs
|
||||||
|
)
|
||||||
|
prs = batch.prs.sorted('repository').mapped('display_name')
|
||||||
|
meta = json.dumps({
|
||||||
|
'staging': staging.id,
|
||||||
|
'batch': batch.id,
|
||||||
|
'label': batch.name,
|
||||||
|
'pull_requests': prs,
|
||||||
|
'commits': commits,
|
||||||
|
})
|
||||||
|
|
||||||
|
message = f"{staging.id}.{batch.id}: {batch.name}\n\n- " + "\n- ".join(prs)
|
||||||
|
if repo_flat:
|
||||||
|
tree_flat = layout_flat(
|
||||||
|
repo_flat, {'meta.json': GitBlob(meta)}, repos, commits)
|
||||||
|
commit_flat = repo_flat.commit_tree(
|
||||||
|
tree=tree_flat,
|
||||||
|
message=message,
|
||||||
|
parents=[commit_flat] if commit_flat else [],
|
||||||
|
author=(project.github_name, project.github_email, staging.staged_at.isoformat(timespec='seconds')),
|
||||||
|
committer=(project.github_name, project.github_email, staging.staged_at.isoformat(timespec='seconds')),
|
||||||
|
).stdout.strip()
|
||||||
|
if repo_pythonpath:
|
||||||
|
tree_pythonpath = layout_pythonpath(
|
||||||
|
repo_pythonpath,
|
||||||
|
{'meta.json': GitBlob(meta)},
|
||||||
|
repos,
|
||||||
|
commits,
|
||||||
|
)
|
||||||
|
commit_pythonpath = repo_pythonpath.commit_tree(
|
||||||
|
tree=tree_pythonpath,
|
||||||
|
message=message,
|
||||||
|
parents=[commit_pythonpath] if commit_pythonpath else [],
|
||||||
|
author=('robodoo', 'robodoo@odoo.com', staging.staged_at.isoformat(timespec='seconds')),
|
||||||
|
committer=('robodoo', 'robodoo@odoo.com', staging.staged_at.isoformat(timespec='seconds')),
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
if repo_flat:
|
||||||
|
repo_flat.update_ref(
|
||||||
|
f'refs/heads/{staging.target.name}',
|
||||||
|
commit_flat,
|
||||||
|
prev.hash_flat_layout or '',
|
||||||
|
)
|
||||||
|
if repo_pythonpath:
|
||||||
|
repo_pythonpath.update_ref(
|
||||||
|
f'refs/heads/{staging.target.name}',
|
||||||
|
commit_pythonpath,
|
||||||
|
prev.hash_pythonpath_layout or '',
|
||||||
|
)
|
||||||
|
staging.write({
|
||||||
|
'hash_flat_layout': commit_flat,
|
||||||
|
'hash_pythonpath_layout': commit_pythonpath,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_submodule(repo: str, path: str) -> str:
|
||||||
|
_, n = repo.split('/')
|
||||||
|
return f"""[submodule.{n}]
|
||||||
|
path = {path}
|
||||||
|
url = https://github.com/{repo}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def layout_flat(
|
||||||
|
repo: git.Repo,
|
||||||
|
tree_init: dict[str, GitObject],
|
||||||
|
repos: Repository,
|
||||||
|
commits: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
gitmodules = "\n".join(
|
||||||
|
make_submodule(repo.name, repo.name.split('/')[-1])
|
||||||
|
for repo in repos
|
||||||
|
if repo.name in commits
|
||||||
|
)
|
||||||
|
|
||||||
|
return GitTree({
|
||||||
|
**tree_init,
|
||||||
|
".gitmodules": GitBlob(gitmodules),
|
||||||
|
**{
|
||||||
|
repo.name.split('/')[-1]: GitCommit(cc)
|
||||||
|
for repo in repos
|
||||||
|
if (cc := commits.get(repo.name))
|
||||||
|
}
|
||||||
|
}).write(repo, 0)
|
||||||
|
|
||||||
|
def layout_pythonpath(
|
||||||
|
repo: git.Repo,
|
||||||
|
tree_init: dict[str, GitObject],
|
||||||
|
repos: Repository,
|
||||||
|
commits: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
gitmodules = []
|
||||||
|
tree = GitTree(tree_init)
|
||||||
|
for repo_id in repos:
|
||||||
|
commit = commits.get(repo_id.name)
|
||||||
|
if commit is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
owner, name = repo_id.name.split('/')
|
||||||
|
tmpl = {'fullname': repo_id.name, 'owner': owner, 'name': name}
|
||||||
|
if p := repo_id.pythonpath_location:
|
||||||
|
path = p % tmpl
|
||||||
|
elif repo_id.pythonpath_link_path:
|
||||||
|
path = ".repos/%(name)s" % tmpl
|
||||||
|
else:
|
||||||
|
path = '%(name)s' % tmpl
|
||||||
|
link = (repo_id.pythonpath_link_path or '') % tmpl
|
||||||
|
target = repo_id.pythonpath_link_target or ''
|
||||||
|
|
||||||
|
tree.set(path, GitCommit(commit))
|
||||||
|
gitmodules.append(make_submodule(repo_id.name, path))
|
||||||
|
|
||||||
|
if link:
|
||||||
|
if target:
|
||||||
|
target = f"{path}/{target}"
|
||||||
|
else:
|
||||||
|
target = path
|
||||||
|
tree.set(link, GitLink(target))
|
||||||
|
|
||||||
|
tree.set(".gitmodules", GitBlob("\n".join(gitmodules)))
|
||||||
|
return tree.write(repo, 0)
|
||||||
|
|
||||||
|
class Commit(TypedDict):
|
||||||
|
staging_id: int
|
||||||
|
commit_id: int
|
||||||
|
repository_id: int
|
||||||
|
|
||||||
|
class GitObject(metaclass=abc.ABCMeta):
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def mode(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def type(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def write(self, repo: git.Repo, depth: int) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def assert_tree(self) -> GitTree:
|
||||||
|
assert isinstance(self, GitTree)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitCommit(GitObject):
|
||||||
|
sha: str
|
||||||
|
mode = "160000"
|
||||||
|
type = "commit"
|
||||||
|
|
||||||
|
def write(self, repo: git.Repo, _: int) -> str:
|
||||||
|
return self.sha
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitTree(GitObject):
|
||||||
|
members: dict[str, GitObject]
|
||||||
|
mode = "40000"
|
||||||
|
type = "tree"
|
||||||
|
|
||||||
|
def set(self, path: str, obj: GitObject) -> None:
|
||||||
|
p, _, target = path.rpartition('/')
|
||||||
|
assert target, f"{path!r} can't be empty"
|
||||||
|
if p:
|
||||||
|
for part in p.split('/'):
|
||||||
|
self = self.members.setdefault(part, GitTree({})).assert_tree()
|
||||||
|
self.members[target] = obj
|
||||||
|
|
||||||
|
def write(self, repo: git.Repo, depth: int) -> str:
|
||||||
|
assert all(self.members), \
|
||||||
|
f"One of the tree entries has an empty filename {self.members}"
|
||||||
|
return repo.with_config(input="".join(
|
||||||
|
f"{obj.mode} {obj.type} {obj.write(repo, depth+1)}\t{name}\n"
|
||||||
|
for name, obj in self.members.items()
|
||||||
|
)).mktree().stdout.strip()
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitLink(GitObject):
|
||||||
|
reference: str
|
||||||
|
mode = "120000"
|
||||||
|
type = "blob"
|
||||||
|
|
||||||
|
def write(self, repo: git.Repo, depth: int) -> str:
|
||||||
|
target = "/".join(itertools.repeat("..", depth)) + "/" + self.reference
|
||||||
|
return repo.with_config(input=target).hash_object("--stdin", "-w").stdout.strip()
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitBlob(GitObject):
|
||||||
|
content: str
|
||||||
|
mode = "100644"
|
||||||
|
type = "blob"
|
||||||
|
|
||||||
|
def write(self, repo: git.Repo, _: int) -> str:
|
||||||
|
return repo.with_config(input=self.content).hash_object('--stdin', '-w').stdout.strip()
|
23
runbot_merge/models/crons/staging_reifier.xml
Normal file
23
runbot_merge/models/crons/staging_reifier.xml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="access_staging_reifier" model="ir.model.access">
|
||||||
|
<field name="name">Access to staging reifier is useless</field>
|
||||||
|
<field name="model_id" ref="model_runbot_merge_staging_reifier"/>
|
||||||
|
<field name="perm_read">0</field>
|
||||||
|
<field name="perm_create">0</field>
|
||||||
|
<field name="perm_write">0</field>
|
||||||
|
<field name="perm_unlink">0</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.cron" id="staging_reifier">
|
||||||
|
<field name="name">Reifiy stagings to cross-repo commits</field>
|
||||||
|
<field name="model_id" ref="model_runbot_merge_staging_reifier"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._run()</field>
|
||||||
|
<!--
|
||||||
|
nota: even though this is only triggered, numbercall has to be
|
||||||
|
non-zero because the counter is taken in account by cron triggers
|
||||||
|
-->
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
@ -2435,6 +2435,37 @@ class Stagings(models.Model):
|
|||||||
})
|
})
|
||||||
if self.issues_to_close:
|
if self.issues_to_close:
|
||||||
self.env['runbot_merge.issues_closer'].create(self.issues_to_close)
|
self.env['runbot_merge.issues_closer'].create(self.issues_to_close)
|
||||||
|
|
||||||
|
# FIXME: error prone, should probably store the previous
|
||||||
|
# staging on creation instead?
|
||||||
|
last2 = self.with_context(active_search=False).search([
|
||||||
|
('state', '=', 'success'),
|
||||||
|
('target', '=', self.target.id),
|
||||||
|
], order='id desc', limit=2)
|
||||||
|
if len(last2) == 2:
|
||||||
|
_self, previous = last2
|
||||||
|
elif self.target != project.branch_ids[:1]:
|
||||||
|
_self = last2
|
||||||
|
previous = self.search([
|
||||||
|
('state', '=', 'success'),
|
||||||
|
('target', '=', project.branch_ids[:1].id),
|
||||||
|
('active', '=', False),
|
||||||
|
], order='id desc', limit=1)
|
||||||
|
else:
|
||||||
|
# no previous branch
|
||||||
|
_self = last2
|
||||||
|
previous = self.browse()
|
||||||
|
if self == _self:
|
||||||
|
self.env['runbot_merge.staging.reifier'].create({
|
||||||
|
'previous_staging_id': previous.id,
|
||||||
|
'staging_id': self.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
_logger.warning(
|
||||||
|
"Got different self (%s) and last success (%s)",
|
||||||
|
self,
|
||||||
|
_self,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self.write({'active': False})
|
self.write({'active': False})
|
||||||
elif self.state == 'failure' or self.is_timed_out():
|
elif self.state == 'failure' or self.is_timed_out():
|
||||||
|
95
runbot_merge/tests/test_staging_reifier.py
Normal file
95
runbot_merge/tests/test_staging_reifier.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import copy
|
||||||
|
import pprint
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils import Commit, to_pr
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic(make_repo, project, env, setreviewers, config, users, partners, pytestconfig):
|
||||||
|
repos = {}
|
||||||
|
#region project setup
|
||||||
|
project.repo_pythonpath_layout = make_repo('pythonpath', hooks=False).name
|
||||||
|
for name, conf in zip('abcd', [
|
||||||
|
# flat-style repo (odoo/documentation)
|
||||||
|
{},
|
||||||
|
# flat repo with symlink into (odoo/odoo)
|
||||||
|
{
|
||||||
|
'pythonpath_location': '%(name)s',
|
||||||
|
'pythonpath_link_path': 'community/odoo/addons',
|
||||||
|
'pythonpath_link_target': 'b',
|
||||||
|
},
|
||||||
|
# modules directory style
|
||||||
|
{'pythonpath_location': '%(name)s/odoo/addons'},
|
||||||
|
# upgrade style
|
||||||
|
{
|
||||||
|
'pythonpath_link_path': '%(name)s/odoo/upgrade',
|
||||||
|
'pythonpath_link_target': 'd',
|
||||||
|
},
|
||||||
|
]):
|
||||||
|
r = repos[name] = make_repo(name)
|
||||||
|
env['runbot_merge.repository'].create({
|
||||||
|
'project_id': project.id,
|
||||||
|
'name': r.name,
|
||||||
|
'required_statuses': 'default',
|
||||||
|
'group_id': False,
|
||||||
|
**conf,
|
||||||
|
})
|
||||||
|
setreviewers(*project.repo_ids)
|
||||||
|
env['runbot_merge.events_sources'].create([{'repository': r.name} for r in project.repo_ids])
|
||||||
|
#endregion
|
||||||
|
#region repos setup
|
||||||
|
for repo_name, r in repos.items():
|
||||||
|
with r:
|
||||||
|
r.make_commits(
|
||||||
|
None,
|
||||||
|
Commit('initial', tree={
|
||||||
|
'x': '1',
|
||||||
|
f'{repo_name}/b': '2',
|
||||||
|
f'{repo_name}/c': '3',
|
||||||
|
}),
|
||||||
|
ref='heads/master',
|
||||||
|
)
|
||||||
|
#endregion
|
||||||
|
#region setup PR
|
||||||
|
with (r := repos['b']):
|
||||||
|
[c] = r.make_commits('master', Commit('second', tree={'b/c': '42'}), ref='heads/other')
|
||||||
|
pr = r.make_pr(target='master', title='title', head='other')
|
||||||
|
env.run_crons()
|
||||||
|
with (r := repos['b']):
|
||||||
|
pr.post_comment('hansen r+', config['role_reviewer']['token'])
|
||||||
|
r.post_status(c, 'success')
|
||||||
|
env.run_crons()
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
pr_id = to_pr(env, pr)
|
||||||
|
assert not pr_id.blocked
|
||||||
|
staging = env['runbot_merge.stagings'].search([])
|
||||||
|
assert staging
|
||||||
|
for r in repos.values():
|
||||||
|
with r:
|
||||||
|
r.post_status('staging.master', 'success')
|
||||||
|
env.run_crons()
|
||||||
|
assert staging.state == 'success'
|
||||||
|
assert not staging.active
|
||||||
|
assert staging.hash_pythonpath_layout
|
||||||
|
|
||||||
|
repo_pythonpath = copy.copy(repos['a'])
|
||||||
|
repo_pythonpath.name = project.repo_pythonpath_layout
|
||||||
|
c = repo_pythonpath.commit('master')
|
||||||
|
t = repo_pythonpath.read_tree(c, recursive=True)
|
||||||
|
del t['.gitmodules']
|
||||||
|
del t['meta.json']
|
||||||
|
|
||||||
|
def name(repo_name):
|
||||||
|
return repos[repo_name].name.split('/')[1]
|
||||||
|
def ref(repo_name):
|
||||||
|
return '@' + repos[repo_name].commit('master').id
|
||||||
|
assert t == {
|
||||||
|
name('a'): ref('a'),
|
||||||
|
name('b'): ref('b'),
|
||||||
|
'community/odoo/addons': f'../../../{name("b")}/b',
|
||||||
|
f'{name("c")}/odoo/addons': ref('c'),
|
||||||
|
f'.repos/{name("d")}': ref('d'),
|
||||||
|
f'{name("d")}/odoo/upgrade': f'../../../.repos/{name("d")}/d',
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user