Skip to content

Commit 9ced022

Browse files
committed
gitlab: add gitlab integration
1 parent 7ad9b48 commit 9ced022

File tree

4 files changed

+435
-3
lines changed

4 files changed

+435
-3
lines changed

buildbot_nix/buildbot_nix/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from buildbot.www.authz import Authz
2626
from buildbot.www.authz.endpointmatchers import EndpointMatcherBase, Match
2727

28+
from buildbot_nix.gitlab_project import GitlabBackend
2829
from buildbot_nix.pull_based.backend import PullBasedBacked
2930

3031
if TYPE_CHECKING:
@@ -1270,6 +1271,9 @@ def configure(self, config: dict[str, Any]) -> None:
12701271
if self.config.gitea is not None:
12711272
backends["gitea"] = GiteaBackend(self.config.gitea, self.config.url)
12721273

1274+
if self.config.gitlab is not None:
1275+
backends["gitlab"] = GitlabBackend(self.config.gitlab, self.config.url)
1276+
12731277
if self.config.pull_based is not None:
12741278
backends["pull_based"] = PullBasedBacked(self.config.pull_based)
12751279

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import os
2+
import signal
3+
from pathlib import Path
4+
from typing import Any
5+
from urllib.parse import urlparse
6+
7+
from buildbot.changes.base import ChangeSource
8+
from buildbot.config.builder import BuilderConfig
9+
from buildbot.plugins import util
10+
from buildbot.reporters.base import ReporterBase
11+
from buildbot.reporters.gitlab import GitLabStatusPush
12+
from buildbot.www.auth import AuthBase
13+
from buildbot.www.avatar import AvatarBase
14+
from pydantic import BaseModel
15+
from twisted.logger import Logger
16+
from twisted.python import log
17+
18+
from buildbot_nix.common import (
19+
ThreadDeferredBuildStep,
20+
atomic_write_file,
21+
filter_repos_by_topic,
22+
http_request,
23+
model_dump_project_cache,
24+
model_validate_project_cache,
25+
paginated_github_request,
26+
slugify_project_name,
27+
)
28+
from buildbot_nix.models import GitlabConfig, Interpolate
29+
from buildbot_nix.nix_status_generator import BuildNixEvalStatusGenerator
30+
from buildbot_nix.projects import GitBackend, GitProject
31+
32+
tlog = Logger()
33+
34+
35+
class NamespaceData(BaseModel):
36+
path: str
37+
kind: str
38+
39+
40+
class RepoData(BaseModel):
41+
id: int
42+
name_with_namespace: str
43+
path: str
44+
path_with_namespace: str
45+
ssh_url_to_repo: str
46+
web_url: str
47+
namespace: NamespaceData
48+
default_branch: str
49+
topics: list[str]
50+
51+
52+
class GitlabProject(GitProject):
53+
config: GitlabConfig
54+
data: RepoData
55+
56+
def __init__(self, config: GitlabConfig, data: RepoData) -> None:
57+
self.config = config
58+
self.data = data
59+
60+
def get_project_url(self) -> str:
61+
url = urlparse(self.config.instance_url)
62+
return f"{url.scheme}://git:%(secret:{self.config.token_file})s@{url.hostname}/{self.data.path_with_namespace}"
63+
64+
def create_change_source(self) -> ChangeSource | None:
65+
return None
66+
67+
@property
68+
def pretty_type(self) -> str:
69+
return "Gitlab"
70+
71+
@property
72+
def type(self) -> str:
73+
return "gitlab"
74+
75+
@property
76+
def nix_ref_type(self) -> str:
77+
return "gitlab"
78+
79+
@property
80+
def repo(self) -> str:
81+
return self.data.path
82+
83+
@property
84+
def owner(self) -> str:
85+
return self.data.namespace.path
86+
87+
@property
88+
def name(self) -> str:
89+
return self.data.name_with_namespace
90+
91+
@property
92+
def url(self) -> str:
93+
return self.data.web_url
94+
95+
@property
96+
def project_id(self) -> str:
97+
return slugify_project_name(self.data.path_with_namespace)
98+
99+
@property
100+
def default_branch(self) -> str:
101+
return self.data.default_branch
102+
103+
@property
104+
def topics(self) -> list[str]:
105+
return self.data.topics
106+
107+
@property
108+
def belongs_to_org(self) -> bool:
109+
return self.data.namespace.kind == "group"
110+
111+
@property
112+
def private_key_path(self) -> Path | None:
113+
return None
114+
115+
@property
116+
def known_hosts_path(self) -> Path | None:
117+
return None
118+
119+
120+
class GitlabBackend(GitBackend):
121+
config: GitlabConfig
122+
instance_url: str
123+
124+
def __init__(self, config: GitlabConfig, instance_url: str) -> None:
125+
self.config = config
126+
self.instance_url = instance_url
127+
128+
def create_reload_builder(self, worker_names: list[str]) -> BuilderConfig:
129+
factory = util.BuildFactory()
130+
factory.addStep(
131+
ReloadGitlabProjects(self.config, self.config.project_cache_file),
132+
)
133+
factory.addStep(
134+
CreateGitlabProjectHooks(
135+
self.config,
136+
self.instance_url,
137+
)
138+
)
139+
return util.BuilderConfig(
140+
name=self.reload_builder_name,
141+
workernames=worker_names,
142+
factory=factory,
143+
)
144+
145+
def create_reporter(self) -> ReporterBase:
146+
return GitLabStatusPush(
147+
token=Interpolate(self.config.token),
148+
context=Interpolate("buildbot/%(prop:status_name)s"),
149+
baseURL=self.config.instance_url,
150+
generators=[
151+
BuildNixEvalStatusGenerator(),
152+
],
153+
)
154+
155+
def create_change_hook(self) -> dict[str, Any]:
156+
return dict(secret=self.config.webhook_secret)
157+
158+
def load_projects(self) -> list["GitProject"]:
159+
if not self.config.project_cache_file.exists():
160+
return []
161+
162+
repos: list[RepoData] = filter_repos_by_topic(
163+
self.config.topic,
164+
sorted(
165+
model_validate_project_cache(RepoData, self.config.project_cache_file),
166+
key=lambda repo: repo.path_with_namespace,
167+
),
168+
lambda repo: repo.topics,
169+
)
170+
tlog.info(f"Loading {len(repos)} cached repos.")
171+
172+
return [
173+
GitlabProject(self.config, RepoData.model_validate(repo)) for repo in repos
174+
]
175+
176+
def are_projects_cached(self) -> bool:
177+
return self.config.project_cache_file.exists()
178+
179+
def create_auth(self) -> AuthBase:
180+
raise NotImplementedError
181+
182+
def create_avatar_method(self) -> AvatarBase | None:
183+
return None
184+
185+
@property
186+
def reload_builder_name(self) -> str:
187+
return "reload-gitlab-projects"
188+
189+
@property
190+
def type(self) -> str:
191+
return "gitlab"
192+
193+
@property
194+
def pretty_type(self) -> str:
195+
return "Gitlab"
196+
197+
@property
198+
def change_hook_name(self) -> str:
199+
return "gitlab"
200+
201+
202+
class ReloadGitlabProjects(ThreadDeferredBuildStep):
203+
name = "reload_gitlab_projects"
204+
205+
config: GitlabConfig
206+
project_cache_file: Path
207+
208+
def __init__(
209+
self,
210+
config: GitlabConfig,
211+
project_cache_file: Path,
212+
**kwargs: Any,
213+
) -> None:
214+
self.config = config
215+
self.project_cache_file = project_cache_file
216+
super().__init__(**kwargs)
217+
218+
def run_deferred(self) -> None:
219+
repos: list[RepoData] = filter_repos_by_topic(
220+
self.config.topic,
221+
refresh_projects(self.config, self.project_cache_file),
222+
lambda repo: repo.topics,
223+
)
224+
atomic_write_file(self.project_cache_file, model_dump_project_cache(repos))
225+
226+
def run_post(self) -> Any:
227+
return util.SUCCESS
228+
229+
230+
class CreateGitlabProjectHooks(ThreadDeferredBuildStep):
231+
name = "create_gitlab_project_hooks"
232+
233+
config: GitlabConfig
234+
instance_url: str
235+
236+
def __init__(self, config: GitlabConfig, instance_url: str, **kwargs: Any) -> None:
237+
self.config = config
238+
self.instance_url = instance_url
239+
super().__init__(**kwargs)
240+
241+
def run_deferred(self) -> None:
242+
repos = model_validate_project_cache(RepoData, self.config.project_cache_file)
243+
for repo in repos:
244+
create_project_hook(
245+
token=self.config.token,
246+
webhook_secret=self.config.webhook_secret,
247+
project_id=repo.id,
248+
gitlab_url=self.config.instance_url,
249+
instance_url=self.instance_url,
250+
)
251+
252+
def run_post(self) -> Any:
253+
os.kill(os.getpid(), signal.SIGHUP)
254+
return util.SUCCESS
255+
256+
257+
def refresh_projects(config: GitlabConfig, cache_file: Path) -> list[RepoData]:
258+
# access level 40 == Maintainer. See https://docs.gitlab.com/api/members/#roles
259+
return [
260+
RepoData.model_validate(repo)
261+
for repo in paginated_github_request(
262+
f"{config.instance_url}/api/v4/projects?min_access_level=40&pagination=keyset&per_page=100&order_by=id&sort=asc",
263+
config.token,
264+
)
265+
]
266+
267+
268+
def create_project_hook(
269+
token: str,
270+
webhook_secret: str,
271+
project_id: int,
272+
gitlab_url: str,
273+
instance_url: str,
274+
) -> None:
275+
hook_url = instance_url + "change_hook/gitlab"
276+
for hook in paginated_github_request(
277+
f"{gitlab_url}/api/v4/projects/{project_id}/hooks",
278+
token,
279+
):
280+
if hook["url"] == hook_url:
281+
log.msg(f"hook for gitlab project {project_id} already exists")
282+
return
283+
log.msg(f"creating hook for gitlab project {project_id}")
284+
http_request(
285+
f"{gitlab_url}/api/v4/projects/{project_id}/hooks",
286+
method="POST",
287+
headers={
288+
"Authorization": f"Bearer {token}",
289+
"Accept": "application/json",
290+
"Content-Type": "application/json",
291+
},
292+
data=dict(
293+
name="buildbot hook",
294+
url=hook_url,
295+
enable_ssl_verification=True,
296+
token=webhook_secret,
297+
# We don't need to be informed of most events
298+
confidential_issues_events=False,
299+
confidential_note_events=False,
300+
deployment_events=False,
301+
feature_flag_events=False,
302+
issues_events=False,
303+
job_events=False,
304+
merge_requests_events=False,
305+
note_events=False,
306+
pipeline_events=False,
307+
releases_events=False,
308+
wiki_page_events=False,
309+
resource_access_token_events=False,
310+
),
311+
)
312+
313+
314+
if __name__ == "__main__":
315+
c = GitlabConfig(
316+
topic=None,
317+
)
318+
319+
print(refresh_projects(c, Path("deleteme-gitlab-cache")))

buildbot_nix/buildbot_nix/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,33 @@ def oauth_secret(self) -> str:
165165
return read_secret_file(self.oauth_secret_file)
166166

167167

168+
class GitlabConfig(BaseModel):
169+
instance_url: str = Field(default="https://gitlab.com")
170+
topic: str | None
171+
172+
token_file: Path = Field(default=Path("gitlab-token"))
173+
webhook_secret_file: Path = Field(default=Path("gitlab-webhook-secret"))
174+
175+
oauth_id: str | None
176+
oauth_secret_file: Path | None
177+
178+
project_cache_file: Path = Field(default=Path("gitlab-project-cache.json"))
179+
180+
@property
181+
def token(self) -> str:
182+
return read_secret_file(self.token_file)
183+
184+
@property
185+
def webhook_secret(self) -> str:
186+
return read_secret_file(self.webhook_secret_file)
187+
188+
@property
189+
def oauth_secret(self) -> str:
190+
if self.oauth_secret_file is None:
191+
raise InternalError
192+
return read_secret_file(self.oauth_secret_file)
193+
194+
168195
class PostBuildStep(BaseModel):
169196
name: str
170197
environment: Mapping[str, str | Interpolate]
@@ -273,6 +300,7 @@ class BuildbotNixConfig(BaseModel):
273300
eval_worker_count: int | None = None
274301
gitea: GiteaConfig | None = None
275302
github: GitHubConfig | None = None
303+
gitlab: GitlabConfig | None
276304
pull_based: PullBasedConfig | None
277305
outputs_path: Path | None = None
278306
post_build_steps: list[PostBuildStep] = []

0 commit comments

Comments
 (0)