b7ba9f2498e8f76005b079a7c38ee022640bbad6
[integration.git] / ptl / edit_committers_info / edit_committers_list.py
1 """Automate the INFO.yaml update."""
2 """
3    Copyright 2021 Deutsche Telekom AG
4
5    Licensed under the Apache License, Version 2.0 (the "License");
6    you may not use this file except in compliance with the License.
7    You may obtain a copy of the License at
8
9        http://www.apache.org/licenses/LICENSE-2.0
10
11    Unless required by applicable law or agreed to in writing, software
12    distributed under the License is distributed on an "AS IS" BASIS,
13    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14    See the License for the specific language governing permissions and
15    limitations under the License.
16 """
17
18 from enum import Enum
19 from itertools import chain, zip_longest
20 from pathlib import Path
21 from typing import Dict, Iterator, List, Optional, Tuple
22
23 import click
24 import git
25 from ruamel.yaml import YAML
26 from ruamel.yaml.scalarstring import SingleQuotedScalarString
27
28
29 class CommitterActions(Enum):
30     """Committer Actions enum.
31
32     Available actions:
33      * Addition - will add the commiter with their info into
34         the committers list and the tsc information would be added
35      * Deletion - commiter will be deleted from the committers list
36         and the tsc information would be added
37
38     """
39
40     ADDITION = "Addition"
41     DELETION = "Deletion"
42
43
44 class CommitterChange:
45     """Class representing the change on the committers list which needs to be done."""
46
47     def __init__(
48         self,
49         name: str,
50         action: CommitterActions,
51         link: str,
52         email: str = None,
53         company: str = None,
54         committer_id: str = None,
55         timezone: str = None,
56     ) -> None:
57         """Initialize the change object.
58
59         Args:
60             name (str): Committer name
61             action (CommitterActions): Action to be done
62             link (str): Link to the TSC confirmation
63             email (str, optional): Committer's e-mail. Needed only for addition.
64                 Defaults to None.
65             company (str, optional): Committer's company name. Needed only for addition.
66                 Defaults to None.
67             committer_id (str, optional): Committer's LF ID. Needed only for addition.
68                 Defaults to None.
69             timezone (str, optional): Committer's timezone. Needed only for addition.
70                 Defaults to None.
71         """
72         self._committer_name: str = name
73         self._action: CommitterActions = action
74         self._link: str = link
75
76     @property
77     def action(self) -> CommitterActions:
78         """Enum representing an action which is going to be done by the change.
79
80         Returns:
81             CommitterActions: One of the CommittersActions enum value.
82
83         """
84         return self._action
85
86     @property
87     def committer_name(self) -> str:
88         """Committer name property.
89
90         Returns:
91             str: Name provided during the initialization.
92
93         """
94         return self._committer_name
95
96     @property
97     def tsc_change(self) -> Dict[str, str]:
98         """TSC change.
99
100         Dictionary which is going to be added into
101             INFO.yaml file 'tsc' section.
102         Values are different for Addition and Deletion
103             actions.
104
105         Returns:
106             Dict[str, str]: TSC section entry
107
108         """
109         # Start ignoring PyLintBear
110         match self.action:
111             case CommitterActions.ADDITION:
112                 return self.tsc_change_addition
113             case CommitterActions.DELETION:
114                 return self.tsc_change_deletion
115         # Stop ignoring
116
117     @property
118     def tsc_change_addition(self) -> Dict[str, str]:
119         """Addition tsc section entry value.
120
121         Value which is going to be added into 'tsc' section
122
123         Raises:
124             NotImplementedError: That method is not implemented yet
125
126         Returns:
127             Dict[str, str]: TSC section value
128         """
129         raise NotImplementedError
130
131     @property
132     def tsc_change_deletion(self) -> Dict[str, str]:
133         """Addition tsc section entry value.
134
135         Value which is going to be added into 'tsc' section
136
137         Returns:
138             Dict[str, str]: TSC section value
139         """
140         return {
141             "type": self.action.value,
142             "name": self.committer_name,
143             "link": self._link,
144         }
145
146
147 class YamlConfig:
148     """YAML config class which corresponds the configuration YAML file needed to be provided by the user.
149
150     Required YAML config structure:
151
152         ---
153         repos:  # List of the repositories which are going to be udated.
154                 # That tool is not smart enough to resolve some conflicts etc.
155                 # Please be sure that it would be possible to push the change to the gerrit.
156                 # Remember that commit-msg hook should be executed so add that script into .git/hooks dir
157           - path: abs_path_to_the_repo  # Local path to the repository
158             branch: master              # Branch which needs to be udated
159         committers:  # List of the committers which are going to be edited
160           - name: Committer Name  # The name of the committer which we would delete or add
161             action: Deletion|Addition  # Addition or deletion action
162             link: https://link.to.the.tcs.confirmation  # Link to the ONAP TSC action confirmation
163         commit:  # Configure the commit message
164           message:  # List of the commit message lines. That's optional
165             - "[INTEGRATION] My awesome first line!"
166             - "Even better second one!"
167           issue_id: INT-2008  # ONAP's JIRA Issue ID is required in the commit message
168     """
169
170     def __init__(self, yaml_file_path: Path) -> None:
171         """Initialize yaml config object.
172
173         Args:
174             yaml_file_path (Path): Path to the config file provided by the user
175
176         """
177         with yaml_file_path.open("r") as yaml_file:
178             self._yaml = YAML().load(yaml_file.read())
179
180     @property
181     def repos_data(self) -> Iterator[Tuple[Path, str]]:
182         """Repositories information iterator.
183
184         Returns the generator with the tuples on which:
185             * first element is a path to the repo
186             * second element is a branch name which
187                 is going to be used to prepare a change
188                 and later push into
189
190         Yields:
191             Iterator[Tuple[Path, str]]: Tuples of repository data: repo local abs path and branch name
192
193         """
194         for repo_info in self._yaml["repos"]:
195             yield (Path(repo_info["path"]), repo_info["branch"])
196
197     @property
198     def committers_changes(self) -> Iterator[CommitterChange]:
199         """Committer changes iterator.
200
201         Returns the generator with `CommitterChange` class instances
202
203         Yields:
204             Iterator[CommitterChange]: Committer changes generator
205
206         """
207         for committer_change in self._yaml["committers"]:
208             # Start ignoring PyLintBear
209             match action := CommitterActions(committer_change["action"]):
210                 case CommitterActions.ADDITION:
211                     continue  # TODO: Add addition support
212                 case CommitterActions.DELETION:
213                     yield CommitterChange(
214                         name=committer_change["name"],
215                         action=action,
216                         link=committer_change["link"],
217                     )
218             # Stop ignoring
219
220     @property
221     def issue_id(self) -> str:
222         """Onap's Jira issue id.
223
224         That issue id would be used in the commit message.
225
226         Returns:
227             str: ONAP's Jira issue ID
228
229         """
230         return self._yaml["commit"]["issue_id"]
231
232     @property
233     def commit_msg(self) -> Optional[List[str]]:
234         """Commit message lines list.
235
236         Optional, if user didn't provide it in the config file
237             it will returns None
238
239         Returns:
240             Optional[List[str]]: List of the commit message lines or None
241
242         """
243         return self._yaml["commit"].get("message")
244
245
246 class OnapRepo:
247     """ONAP repo class."""
248
249     def __init__(self, git_repo_path: Path, git_repo_branch: str) -> None:
250         """Initialize the Onap repo class object.
251
252         During that method an attempt will be made to change the branch to the one specified by the user.
253
254         Args:
255             git_repo_path (Path): Repository local abstract path
256             git_repo_branch (str): Branch name
257
258         Raises:
259             ValueError: Branch provided by the user doesn't exist
260
261         """
262         self._repo: git.Repo = git.Repo(git_repo_path)
263         self._branch: str = git_repo_branch
264         if self._repo.head.ref.name != self._branch:
265             for branch in self._repo.branches:
266                 if branch.name == self._branch:
267                     branch.checkout()
268                     break
269             else:
270                 raise ValueError(
271                     f"Branch {self._branch} doesn't exist in {self._repo.working_dir} repo"
272                 )
273
274     @property
275     def git(self) -> git.Repo:
276         """Git repository object.
277
278         Returns:
279             git.Repo: Repository object.
280
281         """
282         return self._repo
283
284     @property
285     def info_file_path_abs(self) -> Path:
286         """Absolute path to the repositories INFO.yaml file.
287
288         Concanenated repository working tree directory and INFO.yaml
289
290         Returns:
291             Path: Repositories INFO.yaml file abs path
292
293         """
294         return Path(self._repo.working_tree_dir, "INFO.yaml")
295
296     def push_the_change(self, issue_id: str, commit_msg: List[str] = None) -> None:
297         """Push the change to the repository.
298
299         INFO.yaml file will be added to index and then the commit message has to be created.
300         If used doesn't provide commit message in the config file the default one will be used.
301         Commit command will look:
302         `git commit -m <First line> -m <Second line> ... -m <Last line> -m Issue-ID: <issue ID> -s`
303         And push command:
304         `git push origin HEAD:refs/for/<branch defined by user>`
305
306         Args:
307             issue_id (str): ONAP's Jira issue ID
308             commit_msg (List[str], optional): Commit message lines. Defaults to None.
309
310         """
311         index = self.git.index
312         index.add(["INFO.yaml"])
313         if not commit_msg:
314             commit_msg = ["Edit INFO.yaml file."]
315         commit_msg_with_m = list(
316             chain.from_iterable(zip_longest([], commit_msg, fillvalue="-m"))
317         )
318         self.git.git.execute(
319             [
320                 "git",
321                 "commit",
322                 *commit_msg_with_m,
323                 "-m",
324                 "That change was done by automated integration tool to maintain commiters list in INFO.yaml",
325                 "-m",
326                 f"Issue-ID: {issue_id}",
327                 "-s",
328             ]
329         )
330         self.git.git.execute(["git", "push", "origin", f"HEAD:refs/for/{self._branch}"])
331         print(f"Pushed successfully to {self._repo} respository")
332
333
334 class InfoYamlLoader(YAML):
335     """Yaml loader class.
336
337     Contains the options which are same as used in the INFO.yaml file.
338     After making changes and save INFO.yaml file would have same format as before.
339     Several options are set:
340         * indent - 4
341         * sequence dash indent - 4
342         * sequence item indent - 6
343         * explicit start (triple dashes at the file beginning '---')
344         * preserve quotes - keep the quotes for all strings loaded from the file.
345             It doesn't mean that all new strings would also have quotas.
346             To make new strings be stored with quotas ruamel.yaml.scalarstring.SingleQuotedScalarString
347             class needs to be used.
348     """
349
350     def __init__(self, *args, **kwargs) -> None:
351         """Initialize loader object."""
352         super().__init__(*args, **kwargs)
353         self.preserve_quotes = True
354         self.indent = 4
355         self.sequence_dash_offset = 4
356         self.sequence_indent = 6
357         self.explicit_start = True
358
359
360 class InfoYamlFile:
361     """Class to store information about INFO.yaml file.
362
363     It's context manager class, so it's possible to use it by
364     ```
365     with InfoTamlFile(Path(...)) as info_file:
366         ...
367     ```
368     It's recommended because at the end all changes are going to be
369         saved on the same path as provided by the user (INFO.yaml will
370         be overrited)
371
372     """
373
374     def __init__(self, info_yaml_file_path: Path) -> None:
375         """Initialize the object.
376
377         Args:
378             info_yaml_file_path (Path): Path to the INFO.yaml file
379
380         """
381         self._info_yaml_file_path: Path = info_yaml_file_path
382         self._yml = InfoYamlLoader()
383         with info_yaml_file_path.open("r") as info:
384             self._info = self._yml.load(info.read())
385
386     def __enter__(self):
387         """Enter context manager."""
388         return self
389
390     def __exit__(self, *_):
391         """Exit context manager.
392
393         File is going to be saved now.
394
395         """
396         with self._info_yaml_file_path.open("w") as info:
397             self._yml.dump(self._info, info)
398
399     def perform_committer_change(self, committer_change: CommitterChange) -> None:
400         """Perform the committer change action.
401
402         Depends on the action change the right method is going to be executed:
403          * delete_committer for Deletion.
404         For the addition action ValueError exception is going to be raised as
405             it's not supported yet
406
407         Args:
408             committer_change (CommitterChange): Committer change object
409
410         Raises:
411             ValueError: Addition action called - not supported yet
412
413         """
414         match committer_change.action:
415             case CommitterActions.ADDITION:
416                 raise ValueError("Addition action not supported")
417             case CommitterActions.DELETION:
418                 self.delete_committer(committer_change.committer_name)
419         self.add_tsc_change(committer_change)
420
421     def delete_committer(self, name: str) -> None:
422         """Delete commiter action execution.
423
424         Based on the name commiter is going to be removed from the INFO.yaml 'committers' section.
425
426         Args:
427             name (str): Committer name to delete.
428
429         Raises:
430             ValueError: Committer not found on the list
431
432         """
433         for index, committer in enumerate(self._info["committers"]):
434             if committer["name"] == name:
435                 del self._info["committers"][index]
436                 return
437         raise ValueError(f"Committer {name} is not on the committer list")
438
439     def add_tsc_change(self, committer_change: CommitterChange) -> None:
440         """Add Technical Steering Committee entry.
441
442         All actions need to be confirmed by the TSC. That entry proves that
443             TSC was informed and approved the change.
444
445         Args:
446             committer_change (CommitterChange): Committer change object.
447
448         """
449         self._info["tsc"]["changes"].append(
450             {
451                 key: SingleQuotedScalarString(value)
452                 for key, value in committer_change.tsc_change.items()
453             }
454         )
455
456
457 @click.command()
458 @click.option(
459     "--changes_yaml_file_path",
460     "changes_yaml_file_path",
461     required=True,
462     type=click.Path(exists=True),
463     help="Path to the file where chages are described",
464 )
465 def update_infos(changes_yaml_file_path):
466     """Run the tool."""
467     yaml_config = YamlConfig(Path(changes_yaml_file_path))
468     for repo, branch in yaml_config.repos_data:
469         onap_repo = OnapRepo(repo, branch)
470         with InfoYamlFile(onap_repo.info_file_path_abs) as info:
471             for committer_change in yaml_config.committers_changes:
472                 info.perform_committer_change(committer_change)
473         onap_repo.push_the_change(yaml_config.issue_id, yaml_config.commit_msg)
474
475
476 if __name__ == "__main__":
477     update_infos()