diff --git a/README.md b/README.md index 499db1b..2321d2d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ export LABELS_USERNAME="" export LABELS_TOKEN="" ``` +You can override one or both of these values manually using the following CLI +options: + +```text +-u, --username TEXT GitHub username +-t, --token TEXT GitHub access token +``` + ## Usage Once you've installed **labels** and set up the environment variables, you're @@ -118,6 +126,8 @@ You can make the following changes to labels for your repo: labels file 🗑 - You can **edit** a label by changing the value for one or more parameters for that label 🎨 +- You can **merge** one label to another by setting the ``name`` of a label to +that of an existing label. The merged label will be deleted. - You can **create** a new label by adding a new section with your desired parameters 📝 @@ -137,15 +147,18 @@ labels sync -n -o hackebrot -r pytest-emoji ``` ```text -This would delete the following labels: - - dependencies +This would merge the following labels: + - dependencies to dependency This would update the following labels: - bug - good first issue +This would delete the following labels: + - dependencies This would create the following labels: - duplicate This would NOT modify the following labels: - code quality + - dependency - docs ``` diff --git a/src/labels/cli.py b/src/labels/cli.py index b904c82..6cb7723 100644 --- a/src/labels/cli.py +++ b/src/labels/cli.py @@ -164,8 +164,9 @@ def sync_cmd( On success this will also update the local labels file, so that section names match the `name` parameter. """ - labels_to_delete = {} + labels_to_merge = {} labels_to_update = {} + labels_to_delete = {} labels_to_create = {} labels_to_ignore = {} @@ -187,7 +188,12 @@ def sync_cmd( if local_label.params_dict == remote_label.params_dict: labels_to_ignore[remote_name] = local_label else: - labels_to_update[remote_name] = local_label + if ((remote_name != local_label.name) + and (local_label.name in remote_labels)): + # There is already a label with this name + labels_to_merge[remote_name] = local_label.name + else: + labels_to_update[remote_name] = local_label else: if remote_name == local_label.name: labels_to_create[local_label.name] = local_label @@ -212,18 +218,25 @@ def sync_cmd( if dryrun: # Do not modify remote labels, but only print info dryrun_echo( - labels_to_delete, labels_to_update, labels_to_create, labels_to_ignore + labels_to_merge, + labels_to_update, + labels_to_delete, + labels_to_create, + labels_to_ignore ) - sys.exit(0) + # sys.exit(0) + return failures = [] - for name in labels_to_delete.keys(): + # Merge has to occur before update and delete + for old_label, new_label in labels_to_merge.items(): try: - context.client.delete_label(repository, name=name) + context.client.merge_label( + repository, old_label=old_label, new_label=new_label) except LabelsException as exc: click.echo(str(exc), err=True) - failures.append(name) + failures.append(old_label) for name, label in labels_to_update.items(): try: @@ -232,6 +245,13 @@ def sync_cmd( click.echo(str(exc), err=True) failures.append(name) + for name in labels_to_delete.keys(): + try: + context.client.delete_label(repository, name=name) + except LabelsException as exc: + click.echo(str(exc), err=True) + failures.append(name) + for name, label in labels_to_create.items(): try: context.client.create_label(repository, label=label) @@ -253,23 +273,30 @@ def sync_cmd( def dryrun_echo( - labels_to_delete: Labels_Dict, + labels_to_merge: dict, labels_to_update: Labels_Dict, + labels_to_delete: Labels_Dict, labels_to_create: Labels_Dict, labels_to_ignore: Labels_Dict, ) -> None: """Print information about how labels would be updated on sync.""" - if labels_to_delete: - click.echo(f"This would delete the following labels:") - for name in labels_to_delete: - click.echo(f" - {name}") + if labels_to_merge: + click.echo(f"This would merge the following labels:") + for name in labels_to_merge: + click.echo(f"""\ +- {', '.join([' to '.join((old, new)) for old, new in labels_to_merge.items()])}""") if labels_to_update: click.echo(f"This would update the following labels:") for name in labels_to_update: click.echo(f" - {name}") + if labels_to_delete: + click.echo(f"This would delete the following labels:") + for name in labels_to_delete: + click.echo(f" - {name}") + if labels_to_create: click.echo(f"This would create the following labels:") for name in labels_to_create: diff --git a/src/labels/github.py b/src/labels/github.py index 8996b53..4f3b705 100644 --- a/src/labels/github.py +++ b/src/labels/github.py @@ -77,7 +77,27 @@ def list_labels(self, repo: Repository) -> typing.List[Label]: f"{response.reason}" ) - return [Label(**data) for data in response.json()] + json = response.json() + + next_page = response.links.get('next', None) + while next_page: + logger.debug(f"Requesting {next_page}") + response = self.session.get( + next_page['url'], + headers={"Accept": "application/vnd.github.symmetra-preview+json"}, + ) + + if response.status_code != 200: + raise GitHubException( + f"Error retrieving next page of labels: " + f"{response.status_code} - " + f"{response.reason}" + ) + + json.extend(response.json()) + next_page = response.links.get('next', None) + + return [Label(**data) for data in json] def get_label(self, repo: Repository, *, name: str) -> Label: """Return a single Label from the repository. @@ -150,6 +170,76 @@ def edit_label(self, repo: Repository, *, name: str, label: Label) -> Label: return Label(**response.json()) + def merge_label(self, repo: Repository, *, old_label: str, new_label: str) -> None: + """Merge a GitHub issue label to an existing label. + + - Add the target label to all issues with the old label. + - The old label will be deleted while processing labels to delete. + """ + logger = logging.getLogger("labels") + logger.debug(f"Requesting issues for label {old_label} in {repo.owner}/{repo.name}") # noqa: E501 + + response = self.session.get( + f"{self.base_url}/search/issues?q=label:{old_label}+repo:{repo.owner}/{repo.name}", # noqa: E501 + headers={"Accept": "application/vnd.github.symmetra-preview+json"}, + ) + + if response.status_code != 200: + raise GitHubException( + f"Error retrieving issues for label {old_label} in {repo.owner}/{repo.name}: " # noqa: E501 + f"{response.status_code} - " + f"{response.reason}" + ) + + json = response.json() + + next_page = response.links.get('next', None) + while next_page: + logger.debug(f"Requesting {next_page}") + response = self.session.get( + next_page['url'], + headers={"Accept": "application/vnd.github.symmetra-preview+json"}, + ) + + if response.status_code != 200: + raise GitHubException( + f"Error retrieving next page of issues for label {old_label}: " + f"{response.status_code} - " + f"{response.reason}" + ) + + json.extend(response.json()) + next_page = response.links.get('next', None) + + for issue in json['items']: + response = self.session.get( + f"{self.base_url}/repos/{repo.owner}/{repo.name}/issues/{issue['number']}/labels", # noqa: E501 + headers={"Accept": "application/vnd.github.symmetra-preview+json"}, + ) + + if response.status_code != 200: + raise GitHubException( + f"Error retrieving labels for {repo.owner}/{repo.name}/issue/{issue['number']}: " # noqa: E501 + f"{response.status_code} - " + f"{response.reason}" + ) + + labels = [l['name'] for l in response.json()] + + if new_label not in labels: + response = self.session.post( + f"{self.base_url}/repos/{repo.owner}/{repo.name}/issues/{issue['number']}/labels", # noqa: E501 + headers={"Accept": "application/vnd.github.symmetra-preview+json"}, + json={'labels': [f"{new_label}"]}, + ) + if response.status_code != 200: + raise GitHubException( + f"Error adding '{new_label}' for issue {repo.owner}/{repo.name}/issues/{issue['number']}: " # noqa: E501 + f"{response.status_code} - " + f"{response.reason}" + ) + logger.debug(f"Added label '{new_label}' to {repo.owner}/{repo.name}/issue/{issue['number']}") # noqa: E501 + def delete_label(self, repo: Repository, *, name: str) -> None: """Delete a GitHub issue label. diff --git a/tests/test_cli.py b/tests/test_cli.py index cae7e59..92952fb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -194,10 +194,10 @@ def test_sync_dryrun( assert result.exit_code == 0 output = ( - "This would delete the following labels:\n" - " - infra\n" "This would update the following labels:\n" " - bug\n" + "This would delete the following labels:\n" + " - infra\n" "This would create the following labels:\n" " - dependencies\n" "This would NOT modify the following labels:\n"