Repository: ansible-collections/community.general

Issue:
github_repo always reports changed status
### Summary

When using the `github_repo` module to create a repository, the module always reports "changed" even when the repository already exists. Consider the following playbook:

```
- hosts: localhost
  gather_facts: false
  vars:
    github_token: "{{ lookup('env', 'GITHUB_TOKEN') }}"
  tasks:
    - name: create repository
      community.general.github_repo:
          access_token: "{{ github_token }}"
          name: "ansible-sandbox"
          state: present
          organization: "oddbit-dot-com"
          private: false
```

Running it once produces:

```
TASK [create repository] *********************************************************************
changed: [localhost]
```

Running it one or more additional times reports:

```
TASK [create repository] *********************************************************************
changed: [localhost]
```

### Issue Type

Bug Report

### Component Name

github_repo

### Ansible Version

```console (paste below)
$ ansible --version
ansible 2.10.8
  config file = /home/lars/.ansible.cfg
  configured module search path = ['/home/lars/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/lars/.local/lib/python3.9/site-packages/ansible
  executable location = /home/lars/.local/bin/ansible
  python version = 3.9.4 (default, Apr  6 2021, 00:00:00) [GCC 10.2.1 20201125 (Red Hat 10.2.1-9)]
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
COMMAND_WARNINGS(/home/lars/.ansible.cfg) = False
RETRY_FILES_ENABLED(/home/lars/.ansible.cfg) = False
```


### OS / Environment

Fedora 33

### Steps to Reproduce

(see the playbook in the description)

### Expected Results

I expect the module to report "ok" rather than "changed" if the named repository already exists.

### Actual Results

_No response_

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/source_control/github/github_repo.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/source_control/github/github_repo.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @atorrescogollo @stpierre
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@larsks thanks for reporting this issue.
``github_repo`` module is used to create and edit repo when state is set to ``present``
Existing repo will be edited if the module is run with a different value of ``private`` or ``description``, so could you please check that the ``descrption`` of the repository was already empty value before ?
The repo was *created* using `github_repo`. In the example output I showed above, the first output created the repository. The only configuration in place (`private`, `description`, etc) was put in place by the `github_repo` module. As indicated, no matter how many times you run it, it continues to report `changed` status.

Resolving patch:
diff --git a/plugins/modules/source_control/github/github_repo.py b/plugins/modules/source_control/github/github_repo.py
--- a/plugins/modules/source_control/github/github_repo.py
+++ b/plugins/modules/source_control/github/github_repo.py
@@ -42,16 +42,18 @@
   description:
     description:
     - Description for the repository.
+    - Defaults to empty if I(force_defaults=true), which is the default in this module.
+    - Defaults to empty if I(force_defaults=false) when creating a new repository.
     - This is only used when I(state) is C(present).
     type: str
-    default: ''
     required: false
   private:
     description:
-    - Whether the new repository should be private or not.
+    - Whether the repository should be private or not.
+    - Defaults to C(false) if I(force_defaults=true), which is the default in this module.
+    - Defaults to C(false) if I(force_defaults=false) when creating a new repository.
     - This is only used when I(state) is C(present).
     type: bool
-    default: no
     required: false
   state:
     description:
@@ -72,6 +74,14 @@
     type: str
     default: 'https://api.github.com'
     version_added: "3.5.0"
+  force_defaults:
+    description:
+    - Overwrite current I(description) and I(private) attributes with defaults if set to C(true), which currently is the default.
+    - The default for this option will be deprecated in a future version of this collection, and eventually change to C(false).
+    type: bool
+    default: true
+    required: false
+    version_added: 4.1.0
 requirements:
 - PyGithub>=1.54
 notes:
@@ -92,6 +102,7 @@
     description: "Just for fun"
     private: yes
     state: present
+    force_defaults: no
   register: result
 
 - name: Delete the repository
@@ -117,7 +128,7 @@
 
 GITHUB_IMP_ERR = None
 try:
-    from github import Github, GithubException
+    from github import Github, GithubException, GithubObject
     from github.GithubException import UnknownObjectException
     HAS_GITHUB_PACKAGE = True
 except Exception:
@@ -135,7 +146,7 @@ def authenticate(username=None, password=None, access_token=None, api_url=None):
         return Github(base_url=api_url, login_or_token=username, password=password)
 
 
-def create_repo(gh, name, organization=None, private=False, description='', check_mode=False):
+def create_repo(gh, name, organization=None, private=None, description=None, check_mode=False):
     result = dict(
         changed=False,
         repo=dict())
@@ -151,16 +162,21 @@ def create_repo(gh, name, organization=None, private=False, description='', chec
     except UnknownObjectException:
         if not check_mode:
             repo = target.create_repo(
-                name=name, private=private, description=description)
+                name=name,
+                private=GithubObject.NotSet if private is None else private,
+                description=GithubObject.NotSet if description is None else description,
+            )
             result['repo'] = repo.raw_data
 
         result['changed'] = True
 
     changes = {}
-    if repo is None or repo.raw_data['private'] != private:
-        changes['private'] = private
-    if repo is None or repo.raw_data['description'] != description:
-        changes['description'] = description
+    if private is not None:
+        if repo is None or repo.raw_data['private'] != private:
+            changes['private'] = private
+    if description is not None:
+        if repo is None or repo.raw_data['description'] not in (description, description or None):
+            changes['description'] = description
 
     if changes:
         if not check_mode:
@@ -193,6 +209,10 @@ def delete_repo(gh, name, organization=None, check_mode=False):
 
 
 def run_module(params, check_mode=False):
+    if params['force_defaults']:
+        params['description'] = params['description'] or ''
+        params['private'] = params['private'] or False
+
     gh = authenticate(
         username=params['username'], password=params['password'], access_token=params['access_token'],
         api_url=params['api_url'])
@@ -216,17 +236,17 @@ def run_module(params, check_mode=False):
 
 def main():
     module_args = dict(
-        username=dict(type='str', required=False, default=None),
-        password=dict(type='str', required=False, default=None, no_log=True),
-        access_token=dict(type='str', required=False,
-                          default=None, no_log=True),
+        username=dict(type='str'),
+        password=dict(type='str', no_log=True),
+        access_token=dict(type='str', no_log=True),
         name=dict(type='str', required=True),
         state=dict(type='str', required=False, default="present",
                    choices=["present", "absent"]),
         organization=dict(type='str', required=False, default=None),
-        private=dict(type='bool', required=False, default=False),
-        description=dict(type='str', required=False, default=''),
+        private=dict(type='bool'),
+        description=dict(type='str'),
         api_url=dict(type='str', required=False, default='https://api.github.com'),
+        force_defaults=dict(type='bool', default=True),
     )
     module = AnsibleModule(
         argument_spec=module_args,


Test patch:
diff --git a/tests/unit/plugins/modules/source_control/github/test_github_repo.py b/tests/unit/plugins/modules/source_control/github/test_github_repo.py
--- a/tests/unit/plugins/modules/source_control/github/test_github_repo.py
+++ b/tests/unit/plugins/modules/source_control/github/test_github_repo.py
@@ -71,6 +71,28 @@ def get_repo_mock(url, request):
     return response(200, content, headers, None, 5, request)
 
 
+@urlmatch(netloc=r'api\.github\.com(:[0-9]+)?$', path=r'/repos/.*/.*', method="get")
+def get_private_repo_mock(url, request):
+    match = re.search(
+        r"api\.github\.com(:[0-9]+)?/repos/(?P<org>[^/]+)/(?P<repo>[^/]+)", request.url)
+    org = match.group("org")
+    repo = match.group("repo")
+
+    # https://docs.github.com/en/rest/reference/repos#get-a-repository
+    headers = {'content-type': 'application/json'}
+    content = {
+        "name": repo,
+        "full_name": "{0}/{1}".format(org, repo),
+        "url": "https://api.github.com/repos/{0}/{1}".format(org, repo),
+        "private": True,
+        "description": "This your first repo!",
+        "default_branch": "master",
+        "allow_rebase_merge": True
+    }
+    content = json.dumps(content).encode("utf-8")
+    return response(200, content, headers, None, 5, request)
+
+
 @urlmatch(netloc=r'api\.github\.com(:[0-9]+)?$', path=r'/orgs/.*/repos', method="post")
 def create_new_org_repo_mock(url, request):
     match = re.search(
@@ -83,8 +105,8 @@ def create_new_org_repo_mock(url, request):
     content = {
         "name": repo['name'],
         "full_name": "{0}/{1}".format(org, repo['name']),
-        "private": repo['private'],
-        "description": repo['description']
+        "private": repo.get('private', False),
+        "description": repo.get('description')
     }
     content = json.dumps(content).encode("utf-8")
     return response(201, content, headers, None, 5, request)
@@ -99,8 +121,8 @@ def create_new_user_repo_mock(url, request):
     content = {
         "name": repo['name'],
         "full_name": "{0}/{1}".format("octocat", repo['name']),
-        "private": repo['private'],
-        "description": repo['description']
+        "private": repo.get('private', False),
+        "description": repo.get('description')
     }
     content = json.dumps(content).encode("utf-8")
     return response(201, content, headers, None, 5, request)
@@ -120,8 +142,8 @@ def patch_repo_mock(url, request):
         "name": repo,
         "full_name": "{0}/{1}".format(org, repo),
         "url": "https://api.github.com/repos/{0}/{1}".format(org, repo),
-        "private": body['private'],
-        "description": body['description'],
+        "private": body.get('private', False),
+        "description": body.get('description'),
         "default_branch": "master",
         "allow_rebase_merge": True
     }
@@ -160,11 +182,34 @@ def test_create_new_org_repo(self):
             "description": "Just for fun",
             "private": False,
             "state": "present",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
 
         self.assertEqual(result['changed'], True)
         self.assertEqual(result['repo']['private'], False)
+        self.assertEqual(result['repo']['description'], 'Just for fun')
+
+    @with_httmock(get_orgs_mock)
+    @with_httmock(get_repo_notfound_mock)
+    @with_httmock(create_new_org_repo_mock)
+    def test_create_new_org_repo_incomplete(self):
+        result = github_repo.run_module({
+            'username': None,
+            'password': None,
+            "access_token": "mytoken",
+            "organization": "MyOrganization",
+            "name": "myrepo",
+            "description": None,
+            "private": None,
+            "state": "present",
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
+        })
+
+        self.assertEqual(result['changed'], True)
+        self.assertEqual(result['repo']['private'], False)
+        self.assertEqual(result['repo']['description'], None)
 
     @with_httmock(get_user_mock)
     @with_httmock(get_repo_notfound_mock)
@@ -179,7 +224,8 @@ def test_create_new_user_repo(self):
             "description": "Just for fun",
             "private": True,
             "state": "present",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
         self.assertEqual(result['changed'], True)
         self.assertEqual(result['repo']['private'], True)
@@ -197,11 +243,31 @@ def test_patch_existing_org_repo(self):
             "description": "Just for fun",
             "private": True,
             "state": "present",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
         self.assertEqual(result['changed'], True)
         self.assertEqual(result['repo']['private'], True)
 
+    @with_httmock(get_orgs_mock)
+    @with_httmock(get_private_repo_mock)
+    def test_idempotency_existing_org_private_repo(self):
+        result = github_repo.run_module({
+            'username': None,
+            'password': None,
+            "access_token": "mytoken",
+            "organization": "MyOrganization",
+            "name": "myrepo",
+            "description": None,
+            "private": None,
+            "state": "present",
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
+        })
+        self.assertEqual(result['changed'], False)
+        self.assertEqual(result['repo']['private'], True)
+        self.assertEqual(result['repo']['description'], 'This your first repo!')
+
     @with_httmock(get_orgs_mock)
     @with_httmock(get_repo_mock)
     @with_httmock(delete_repo_mock)
@@ -215,7 +281,8 @@ def test_delete_org_repo(self):
             "description": "Just for fun",
             "private": False,
             "state": "absent",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
         self.assertEqual(result['changed'], True)
 
@@ -232,7 +299,8 @@ def test_delete_user_repo(self):
             "description": "Just for fun",
             "private": False,
             "state": "absent",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
         self.assertEqual(result['changed'], True)
 
@@ -249,7 +317,8 @@ def test_delete_org_repo_notfound(self):
             "description": "Just for fun",
             "private": True,
             "state": "absent",
-            "api_url": "https://api.github.com"
+            "api_url": "https://api.github.com",
+            "force_defaults": False,
         })
         self.assertEqual(result['changed'], False)