Repository: hackebrot/labels

Issue:
Infer labels owner and repo from git in working directory
Currently fetching and syncing labels with a GitHub repository requires the ``--repo`` and ``--owner`` CLI options. I think it would be awesome if we could infer this information from the current working directory and the corresponding Git repository 😃 

```text
labels fetch
```

```text
labels sync
```

Comments:

Patch:
diff --git a/src/labels/cli.py b/src/labels/cli.py
index 96c9a85..acf2150 100644
--- a/src/labels/cli.py
+++ b/src/labels/cli.py
@@ -6,7 +6,7 @@ import typing
 import click
 from requests.auth import HTTPBasicAuth
 
-from labels import __version__
+from labels import __version__, utils
 from labels.exceptions import LabelsException
 from labels.github import Client, Label
 from labels.io import write_labels, read_labels
@@ -50,8 +50,8 @@ def labels(ctx, username: str, token: str, verbose: bool) -> None:
 
 @labels.command("fetch")
 @click.pass_obj
-@click.option("-o", "--owner", help="GitHub owner name", type=str, required=True)
-@click.option("-r", "--repo", help="GitHub repository name", type=str, required=True)
+@click.option("-o", "--owner", help="GitHub owner name", type=str)
+@click.option("-r", "--repo", help="GitHub repository name", type=str)
 @click.option(
     "-f",
     "--filename",
@@ -60,13 +60,22 @@ def labels(ctx, username: str, token: str, verbose: bool) -> None:
     type=click.Path(),
     required=True,
 )
-def fetch_cmd(client: Client, owner: str, repo: str, filename: str) -> None:
+def fetch_cmd(
+    client: Client,
+    owner: typing.Optional[str],
+    repo: typing.Optional[str],
+    filename: str
+) -> None:
     """Fetch labels for a GitHub repository.
 
     This will write the labels information to disk to the specified filename.
     """
     try:
-        labels = client.list_labels(owner, repo)
+        inferred_owner, inferred_repo = utils.get_owner_and_repo_from_cwd()
+        labels = client.list_labels(
+            owner or inferred_owner,
+            repo or inferred_repo
+        )
     except LabelsException as exc:
         click.echo(str(exc))
         sys.exit(1)
@@ -79,8 +88,8 @@ def fetch_cmd(client: Client, owner: str, repo: str, filename: str) -> None:
 
 @labels.command("sync")
 @click.pass_obj
-@click.option("-o", "--owner", help="GitHub owner name", type=str, required=True)
-@click.option("-r", "--repo", help="GitHub repository name", type=str, required=True)
+@click.option("-o", "--owner", help="GitHub owner name", type=str)
+@click.option("-r", "--repo", help="GitHub repository name", type=str)
 @click.option("-n", "--dryrun", help="Do not modify remote labels", is_flag=True)
 @click.option(
     "-f",
@@ -91,7 +100,11 @@ def fetch_cmd(client: Client, owner: str, repo: str, filename: str) -> None:
     required=True,
 )
 def sync_cmd(
-    client: Client, owner: str, repo: str, filename: str, dryrun: bool
+    client: Client,
+    owner: typing.Optional[str],
+    repo: typing.Optional[str],
+    filename: str,
+    dryrun: bool
 ) -> None:
     """Sync labels with a GitHub repository.
 
@@ -105,6 +118,10 @@ def sync_cmd(
 
     local_labels = read_labels(filename)
 
+    inferred_owner, inferred_repo = utils.get_owner_and_repo_from_cwd()
+    owner = owner or inferred_owner
+    repo = repo or inferred_repo
+
     try:
         remote_labels = {l.name: l for l in client.list_labels(owner, repo)}
     except LabelsException as exc:
diff --git a/src/labels/utils.py b/src/labels/utils.py
new file mode 100644
index 0000000..2b82057
--- /dev/null
+++ b/src/labels/utils.py
@@ -0,0 +1,21 @@
+import re
+import subprocess
+import typing
+
+
+def get_owner_and_repo_from_cwd() -> typing.Tuple[str, str]:
+    """Return the owner and name of the remote named origin in the cwd."""
+    origin_url = (
+        subprocess.check_output(["git", "remote", "get-url", "origin"]).decode().strip()
+    )
+    return _extract_o_and_r(origin_url)
+
+
+def _extract_o_and_r(url: str) -> typing.Tuple[str, str]:
+    """Return the owner and repo name of a remote given its SSH or HTTPS url.
+
+    HTTPS url format -> 'https://github.com/user/repo.git'
+    SSH   url format -> 'git@github.com:user/repo.git'
+    """
+    parts = re.split(r"[@/:.]+", url)
+    return (parts[-3], parts[-2])


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7de740e..8e569a5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,7 @@
+import subprocess
+import shutil
 import typing
+from pathlib import Path
 
 import pytest
 import responses
@@ -48,6 +51,44 @@ def fixture_repo() -> str:
     return "cookiecutter"
 
 
+@pytest.fixture(name="tmp_local_repo")
+def fixture_tmp_local_repo(tmpdir, owner: str, repo: str) -> None:
+    """Return a temporary local git repository.
+
+    Mocks a repository cloned from
+    https://github.com/audreyr/cookiecutter.git
+    and within which a labels file for the sync test is created
+    ./tests/sync.toml
+    """
+    subprocess.call(
+        [
+            "git",
+            "-C",
+            str(tmpdir),
+            "init"
+        ]
+    )
+    subprocess.call(
+        [
+            "git",
+            "-C",
+            str(tmpdir),
+            "remote",
+            "add",
+            "origin",
+            f"https://github.com/{owner}/{repo}.git"
+        ]
+    )
+
+    # copy labels file for the sync test to the directory
+    tmp = Path(str(tmpdir), "tests")
+    tmp.mkdir(exist_ok=True)
+    perm = Path(__file__).parent.joinpath("sync.toml")
+    shutil.copy(perm, tmp)
+
+    return tmpdir
+
+
 @pytest.fixture(name="response_get_bug")
 def fixture_response_get_bug(base_url: str, owner: str, repo: str) -> Response_Label:
     """Return a dict respresenting the GitHub API response body for the bug
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ff87c0e..7eafe8b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -37,6 +37,67 @@ def test_fetch(
     assert result.exit_code == 0
 
 
+@pytest.mark.usefixtures("mock_list_labels")
+def test_fetch_without_owner_option(
+    run_cli: typing.Callable, repo: str, labels_file_write: str, tmp_local_repo
+) -> None:
+    """Test for the CLI fetch command without -o option supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "fetch",
+            "-r",
+            repo,
+            "-f",
+            labels_file_write,
+        )
+
+        assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures("mock_list_labels")
+def test_fetch_without_repo_option(
+    run_cli: typing.Callable, owner: str, labels_file_write: str, tmp_local_repo
+) -> None:
+    """Test for the CLI fetch command without -r option supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "fetch",
+            "-o",
+            owner,
+            "-f",
+            labels_file_write,
+        )
+
+        assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures("mock_list_labels")
+def test_fetch_without_owner_and_repo_options(
+    run_cli: typing.Callable, labels_file_write: str, tmp_local_repo
+) -> None:
+    """Test for the CLI fetch command without -o and -r options supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "fetch",
+            "-f",
+            labels_file_write,
+        )
+
+        assert result.exit_code == 0
+
+
 @pytest.mark.usefixtures("mock_sync")
 def test_sync(
     run_cli: typing.Callable, owner: str, repo: str, labels_file_sync: str
@@ -61,6 +122,70 @@ def test_sync(
     assert result.output == ""
 
 
+@pytest.mark.usefixtures("mock_sync")
+def test_sync_without_owner_option(
+    run_cli: typing.Callable, repo: str, labels_file_sync: str, tmp_local_repo
+) -> None:
+    """Test for the CLI sync command without the -o option supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "sync",
+            "-r",
+            repo,
+            "-f",
+            labels_file_sync,
+        )
+
+        assert result.exit_code == 0
+        assert result.output == ""
+
+
+@pytest.mark.usefixtures("mock_sync")
+def test_sync_without_repo_option(
+    run_cli: typing.Callable, owner: str, labels_file_sync: str, tmp_local_repo
+) -> None:
+    """Test for the CLI sync command without the -r option supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "sync",
+            "-o",
+            owner,
+            "-f",
+            labels_file_sync,
+        )
+
+        assert result.exit_code == 0
+        assert result.output == ""
+
+
+@pytest.mark.usefixtures("mock_sync")
+def test_sync_without_owner_and_repo_options(
+    run_cli: typing.Callable, labels_file_sync: str, tmp_local_repo
+) -> None:
+    """Test for the CLI sync command without the -o and -r options supplied."""
+    with tmp_local_repo.as_cwd():
+        result = run_cli(
+            "-u",
+            "hackebrot",
+            "-t",
+            "1234",
+            "sync",
+            "-f",
+            labels_file_sync,
+        )
+
+        assert result.exit_code == 0
+        assert result.output == ""
+
+
 @pytest.mark.usefixtures("mock_list_labels")
 def test_sync_dryrun(
     run_cli: typing.Callable, owner: str, repo: str, labels_file_sync: str
diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py
new file mode 100644
index 0000000..0bcd24e
--- /dev/null
+++ b/tests/test_fixtures.py
@@ -0,0 +1,39 @@
+import subprocess
+from pathlib import Path
+
+
+def test_fixture_tmp_local_repo(tmp_local_repo, owner: str, repo: str) -> None:
+    """Test that the tmp_local_repo fixture mocks a git repo cloned from
+    https://github.com/audreyr/cookiecutter.git
+    """
+    _origin_url = subprocess.check_output(
+        ["git", "-C", str(tmp_local_repo), "remote", "get-url", "origin"]
+    )
+    got_url = _origin_url.strip().decode()
+
+    expected_url = f"https://github.com/{owner}/{repo}.git"
+
+    assert expected_url == got_url
+
+
+def test_fixture_tmp_local_repo_contains_sync_file(
+    tmp_local_repo, labels_file_sync: str
+) -> None:
+    """Test that labels file for the sync test exists in the temp_local_repo fixture.
+    """
+    sync_file = Path(str(tmp_local_repo), labels_file_sync)
+
+    assert sync_file.exists()
+    assert sync_file.is_file()
+
+
+def test_sync_file_in_tmp_local_repo_is_appropriately_populated(
+    tmp_local_repo, labels_file_sync: str
+) -> None:
+    """Test that the labels file in the temporary directory is an
+    exact copy of the permanent labels file at labels/tests/sync.toml
+    """
+    with Path(str(tmp_local_repo), labels_file_sync).open() as f_tmp:
+        with Path(__file__).parent.joinpath("sync.toml").open() as f_perm:
+
+            assert f_tmp.readlines() == f_perm.readlines()
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..b5a53a1
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,33 @@
+from labels import utils
+
+
+def test_get_owner_and_repo_from_cwd(tmp_local_repo, owner: str, repo: str) -> None:
+    """Test that repo owner and name can be inferred from the
+    local git repo in the current working directory.
+    """
+    with tmp_local_repo.as_cwd():
+        assert utils.get_owner_and_repo_from_cwd() == (owner, repo)
+
+
+def test_extract_o_and_r_from_remote_https_url() -> None:
+    """Test extraction of owner and repo names from HTTPS remote url string."""
+    remote_url = "https://github.com/hackebrot/pytest-covfefe.git"
+    expected_owner = "hackebrot"
+    expected_repo = "pytest-covfefe"
+
+    gotten_owner, gotten_repo = utils._extract_o_and_r(remote_url)
+
+    assert gotten_owner == expected_owner
+    assert gotten_repo == expected_repo
+
+
+def test_extract_o_and_r_from_remote_ssh_url() -> None:
+    """Test extraction of owner and repo names from SSH remote url string."""
+    remote_url = "git@github.com:hackebrot/pytest-covfefe.git"
+    expected_owner = "hackebrot"
+    expected_repo = "pytest-covfefe"
+
+    gotten_owner, gotten_repo = utils._extract_o_and_r(remote_url)
+
+    assert gotten_owner == expected_owner
+    assert gotten_repo == expected_repo