From 23ad8b347766b33d356f1c94abc8065f71f97abd Mon Sep 17 00:00:00 2001 From: "sebastien.heurtematte" Date: Thu, 12 Nov 2026 10:44:27 +0100 Subject: [PATCH 1/2] fix(repo): stop reporting code scanning languages added by GitHub Code scanning default setup analyzes the languages that GitHub detects in a repository, and GitHub adds the languages it detects later on. As code_scanning_default_languages could neither be configured before the repository contains code, nor follow what GitHub detects, every repository with default setup drifted from its configuration: e.g. check-sync of eclipse-xfsc reported 7 language changes out of 10, hiding the actual drift. The configured languages are now the minimum set of languages to analyze: - without configured languages (an empty list), the languages are left to GitHub: they are not compared, and an empty list is no longer sent to GitHub - a difference is only reported for a configured language that is not analyzed, javascript and typescript being analyzed together; the change adds it to the analyzed languages instead of replacing them - when updating the languages, the ones analyzed by the current default setup are kept, as the apply of a merged pull request compares the configuration to the previous one, not to the live settings A language that GitHub analyzes can thus no longer be removed by otterdog. Signed-off-by: sebastien.heurtematte Co-Authored-By: Claude Opus 5.5 --- .../organization/repository/index.md | 2 +- docs/userguide/guides/repository-security.md | 28 +++++- otterdog/models/repository.py | 52 +++++++++- otterdog/providers/github/rest/repo_client.py | 14 +++ .../webapp/home/playground-reference.json | 4 +- tests/models/test_repository.py | 95 +++++++++++++++++++ .../github/integration/test_repositories.py | 21 ++++ .../providers/github/rest/test_repo_client.py | 62 +++++++++++- 8 files changed, 268 insertions(+), 10 deletions(-) diff --git a/docs/reference/organization/repository/index.md b/docs/reference/organization/repository/index.md index 9335da2c..c69c8498 100644 --- a/docs/reference/organization/repository/index.md +++ b/docs/reference/organization/repository/index.md @@ -20,7 +20,7 @@ Definition of a Repository for a GitHub organization, the following properties a | _auto_init_ | boolean | If the repository shall be auto-initialized during creation | only considered during creation | | _code_scanning_default_setup_enabled_ | boolean | If the repo has default code scanning enabled | | | _code_scanning_default_query_suite_ | string | The query suite to use for default code scanning | `default` or `extended`, only taken into account when `code_scanning_default_setup_enabled` is set to true | -| _code_scanning_default_languages_ | list[string] | The languages to analyze with code scanning default setup | `actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript`, `python`, `ruby`, `swift`, `rust` or `typescript`, only taken into account when `code_scanning_default_setup_enabled` is set to true. The deprecated value `javascript-typescript` is translated to `javascript` and `typescript` | +| _code_scanning_default_languages_ | list[string] | The languages to analyze at least with code scanning default setup | `actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript`, `python`, `ruby`, `swift`, `rust` or `typescript`, only taken into account when `code_scanning_default_setup_enabled` is set to true. An empty list leaves the languages to GitHub, languages that GitHub analyzes in addition are kept. The deprecated value `javascript-typescript` is translated to `javascript` and `typescript` | | _custom_properties_ | dict\[string, string \| list\[string\]\] | The custom properties to set for this repository | | | _default_branch_ | string | Name of the default branch | | | _delete_branch_on_merge_ | boolean | If branches shall automatically be deleted after a merge | | diff --git a/docs/userguide/guides/repository-security.md b/docs/userguide/guides/repository-security.md index 39dbae73..39318a18 100644 --- a/docs/userguide/guides/repository-security.md +++ b/docs/userguide/guides/repository-security.md @@ -75,11 +75,29 @@ of a public repository, see Default setup lets GitHub configure and run CodeQL. It is configured with: - `code_scanning_default_setup_enabled`: enables default setup -- `code_scanning_default_languages`: the languages to analyze +- `code_scanning_default_languages`: the languages to analyze at least - `code_scanning_default_query_suite`: `default` or `extended` The languages and the query suite are only taken into account when default setup is enabled. +### Languages analyzed by GitHub + +Default setup analyzes the CodeQL languages that GitHub detects in the repository, and GitHub adds the languages +that it detects later on, e.g. once Python code is pushed to the repository, see +[About setup types for code scanning](https://docs.github.com/en/code-security/concepts/code-scanning/setup-types). +The configured languages are thus the minimum set of languages to analyze: + +- without configured languages (an empty list), the languages to analyze are left to GitHub, `plan` and + `check-sync` do not report the languages that GitHub analyzes +- a configured language that is not analyzed is added to the analyzed languages +- languages that GitHub analyzes in addition to the configured ones are kept, they are not removed and not + reported as a difference + +!!! tip "Recommendation" + + Leave the languages to GitHub, and only configure a language that GitHub does not analyze on its own, e.g. a + compiled language. + ### Supported languages `code_scanning_default_languages` only accepts the language identifiers of CodeQL, not the names of the languages @@ -136,11 +154,11 @@ orgs.newOrg('my-project', 'my-org') { } ``` -1. Without configured languages, the languages to analyze are left to GitHub. Once the repository contains code, - configure the languages explicitly, so that the configuration shows what is analyzed. +1. Without configured languages, the languages to analyze are left to GitHub, see + [Languages analyzed by GitHub](#languages-analyzed-by-github). -Once the repository contains code, e.g. Java, Kotlin and TypeScript code as well as workflows, the languages are -configured with their CodeQL identifiers: +Once the repository contains code, e.g. Java, Kotlin and TypeScript code as well as workflows, languages to analyze +at least are configured with their CodeQL identifiers: ``` jsonnet orgs.newOrg('my-project', 'my-org') { diff --git a/otterdog/models/repository.py b/otterdog/models/repository.py index b6ef4cac..74286fe3 100644 --- a/otterdog/models/repository.py +++ b/otterdog/models/repository.py @@ -963,6 +963,53 @@ def is_key_valid_for_diff_computation(self, key: str, expected_object: Self) -> return True + def get_difference_from(self, other: Self) -> dict[str, Change[Any]]: + diff: dict[str, Change[Any]] = super().get_difference_from(other) + + languages_change = diff.get("code_scanning_default_languages") + if languages_change is not None: + languages_change = self._get_code_scanning_languages_change(languages_change) + if languages_change is None: + diff.pop("code_scanning_default_languages") + else: + diff["code_scanning_default_languages"] = languages_change + + return diff + + @classmethod + def _get_code_scanning_languages_change(cls, change: Change[Any]) -> Change[Any] | None: + """ + Returns the change of the code scanning default languages that needs to be applied, if any. + + Default setup analyzes the languages that GitHub detects in the repository and adds the languages + detected later on, the configured languages are thus the minimum set of languages to analyze: + - without any configured language, the languages to analyze are left to GitHub + - languages analyzed in addition to the configured ones are kept + """ + expected_languages = change.to_value + if not isinstance(expected_languages, list) or len(expected_languages) == 0: + return None + + current_languages = list(change.from_value) if isinstance(change.from_value, list) else [] + analyzed_languages = {cls._get_code_scanning_analyzed_language(x) for x in current_languages} + + missing_languages: list[str] = [] + for language in expected_languages: + if language not in missing_languages and ( + cls._get_code_scanning_analyzed_language(language) not in analyzed_languages + ): + missing_languages.append(language) + + if len(missing_languages) == 0: + return None + + return Change(change.from_value, current_languages + missing_languages) + + @classmethod + def _get_code_scanning_analyzed_language(cls, language: str) -> str: + # CodeQL analyzes JavaScript and TypeScript together, analyzing one of them analyzes both + return "javascript-typescript" if language in cls._combined_code_scanning_languages else language + def include_for_live_patch(self, context: LivePatchContext) -> bool: import fnmatch @@ -1212,7 +1259,10 @@ async def get_mapping_to_provider( if "code_scanning_default_languages" in data: mapping.pop("code_scanning_default_languages") - code_scanning_mapping["languages"] = S("code_scanning_default_languages") + # without any configured language, GitHub analyzes the languages it detects in the repository + languages = data["code_scanning_default_languages"] + if is_set_and_valid(languages) and len(languages) > 0: + code_scanning_mapping["languages"] = S("code_scanning_default_languages") if len(code_scanning_mapping) > 0: mapping["code_scanning_default_config"] = code_scanning_mapping diff --git a/otterdog/providers/github/rest/repo_client.py b/otterdog/providers/github/rest/repo_client.py index a77ceade..f6b490c9 100644 --- a/otterdog/providers/github/rest/repo_client.py +++ b/otterdog/providers/github/rest/repo_client.py @@ -698,6 +698,16 @@ async def _fill_code_scanning_config(self, org_id: str, repo_name: str, repo_dat config["languages"] = [lang for lang in config["languages"] if lang != "javascript-typescript"] repo_data["code_scanning_default_config"] = config + async def _get_analyzed_code_scanning_languages(self, org_id: str, repo_name: str) -> list[str]: + current_data: dict[str, Any] = {} + await self._fill_code_scanning_config(org_id, repo_name, current_data) + + current_config = current_data.get("code_scanning_default_config", {}) + if current_config.get("state") != "configured": + return [] + + return list(current_config.get("languages", [])) + @staticmethod def _to_default_setup_update_languages(languages: list[str]) -> list[str]: # GitHub reports 'javascript' and 'typescript' separately for the default setup, but the @@ -714,6 +724,10 @@ async def _update_code_scanning_config(self, org_id: str, repo_name: str, code_s languages = code_scanning.get("languages") if isinstance(languages, list): + if len(languages) > 0: + # the configured languages are the minimum set of languages to analyze, keep the ones analyzed + # by the current default setup, e.g. added by GitHub when it detected a new language + languages = [*await self._get_analyzed_code_scanning_languages(org_id, repo_name), *languages] code_scanning = {**code_scanning, "languages": self._to_default_setup_update_languages(languages)} try: diff --git a/otterdog/webapp/home/playground-reference.json b/otterdog/webapp/home/playground-reference.json index aa9d71b7..45ad825d 100644 --- a/otterdog/webapp/home/playground-reference.json +++ b/otterdog/webapp/home/playground-reference.json @@ -597,8 +597,8 @@ }, "code_scanning_default_languages": { "type": "list[string]", - "description": "The languages to analyze with code scanning default setup", - "notes": "`actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript`, `python`, `ruby`, `swift`, `rust` or `typescript`, only taken into account when `code_scanning_default_setup_enabled` is set to true. The deprecated value `javascript-typescript` is translated to `javascript` and `typescript`" + "description": "The languages to analyze at least with code scanning default setup", + "notes": "`actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript`, `python`, `ruby`, `swift`, `rust` or `typescript`, only taken into account when `code_scanning_default_setup_enabled` is set to true. An empty list leaves the languages to GitHub, languages that GitHub analyzes in addition are kept. The deprecated value `javascript-typescript` is translated to `javascript` and `typescript`" }, "custom_properties": { "type": "dict[string, string | list[string]]", diff --git a/tests/models/test_repository.py b/tests/models/test_repository.py index b9fab512..928635ff 100644 --- a/tests/models/test_repository.py +++ b/tests/models/test_repository.py @@ -854,3 +854,98 @@ async def test_explicit_single_code_scanning_language_must_be_detected(configure assert len(failures) == 1 assert f"'{missing}'" in failures[0] + + +def _code_scanning_repo(repository_test, enabled, languages): + repo = Repository.from_model_data(repository_test.model_data) + repo.code_scanning_default_setup_enabled = enabled + repo.code_scanning_default_query_suite = "default" + repo.code_scanning_default_languages = languages + return repo + + +@pytest.mark.parametrize( + "configured,analyzed", + [ + # without configured languages, the languages to analyze are left to GitHub + ([], ["actions", "python"]), + ([], None), + # the configured languages are a minimum, GitHub might analyze additional ones + (["actions"], ["actions", "go", "python"]), + (["actions", "python"], ["python", "actions"]), + # CodeQL analyzes JavaScript and TypeScript together + (["typescript"], ["javascript"]), + (["javascript", "typescript"], ["actions", "javascript", "typescript"]), + ], +) +def test_code_scanning_languages_analyzed_by_github_are_no_difference(repository_test, configured, analyzed): + expected = _code_scanning_repo(repository_test, True, configured) + current = _code_scanning_repo(repository_test, True, analyzed) + + assert expected.get_difference_from(current) == {} + + +@pytest.mark.parametrize( + "configured,analyzed,applied", + [ + (["actions", "java-kotlin"], ["actions", "python"], ["actions", "python", "java-kotlin"]), + (["c-cpp", "c-cpp"], ["actions"], ["actions", "c-cpp"]), + (["javascript", "typescript"], ["python"], ["python", "javascript", "typescript"]), + (["actions"], [], ["actions"]), + (["actions"], None, ["actions"]), + ], +) +def test_missing_code_scanning_languages_are_added_to_the_analyzed_ones(repository_test, configured, analyzed, applied): + expected = _code_scanning_repo(repository_test, True, configured) + current = _code_scanning_repo(repository_test, True, analyzed) + + assert expected.get_difference_from(current) == {"code_scanning_default_languages": Change(analyzed, applied)} + + +def test_code_scanning_languages_are_added_when_enabling_default_setup(repository_test): + expected = _code_scanning_repo(repository_test, True, ["actions"]) + current = _code_scanning_repo(repository_test, False, []) + + assert expected.get_difference_from(current) == { + "code_scanning_default_setup_enabled": Change(False, True), + "code_scanning_default_languages": Change([], ["actions"]), + } + + +def test_live_patch_ignores_code_scanning_languages_added_by_github(repository_test): + from otterdog.models import LivePatchContext + from otterdog.models.organization_settings import OrganizationSettings + + org_settings = OrganizationSettings.from_model_data( + repository_test.load_json_resource("otterdog-org-settings.json") + ) + context = LivePatchContext("test-org", "", False, False, "*", org_settings, org_settings) + + expected = _code_scanning_repo(repository_test, True, []) + current = _code_scanning_repo(repository_test, True, ["actions", "go", "python"]) + + patches = [] + Repository.generate_live_patch(expected, current, None, context, patches.append) + + assert patches == [] + + +@pytest.mark.parametrize("languages", [[], None]) +async def test_code_scanning_without_languages_to_provider(repository_test, languages): + repo = _code_scanning_repo(repository_test, True, languages) + + provider_data = await repo.to_provider_data(repository_test.org_id, repository_test.provider) + + assert provider_data["code_scanning_default_config"] == {"state": "configured", "query_suite": "default"} + + +async def test_code_scanning_languages_to_provider(repository_test): + repo = _code_scanning_repo(repository_test, True, ["actions", "python"]) + + provider_data = await repo.to_provider_data(repository_test.org_id, repository_test.provider) + + assert provider_data["code_scanning_default_config"] == { + "state": "configured", + "query_suite": "default", + "languages": ["actions", "python"], + } diff --git a/tests/providers/github/integration/test_repositories.py b/tests/providers/github/integration/test_repositories.py index d5c4c9fb..f4ebec61 100644 --- a/tests/providers/github/integration/test_repositories.py +++ b/tests/providers/github/integration/test_repositories.py @@ -126,3 +126,24 @@ async def test_update_repo_propagates_unavailable_code_scanning_when_enabled(git REPO_NAME, {"code_scanning_default_config": {"state": "configured"}}, ) + + +async def test_update_repo_keeps_code_scanning_languages_analyzed_by_github(github: GitHubProviderTestKit): + """Configured languages are added to the ones analyzed by the current default setup, which are kept.""" + github.http.expect( + "GET", + CODE_SCANNING_URL, + response_json={"state": "configured", "languages": ["actions", "javascript-typescript", "python"]}, + ) + github.http.expect( + "PATCH", + CODE_SCANNING_URL, + request_json={"languages": ["actions", "python", "java-kotlin"]}, + response_json={"state": "configured"}, + ) + + await github.provider.update_repo( + ORG_ID, + REPO_NAME, + {"code_scanning_default_config": {"languages": ["java-kotlin"]}}, + ) diff --git a/tests/providers/github/rest/test_repo_client.py b/tests/providers/github/rest/test_repo_client.py index 3ad438be..7c39e74f 100644 --- a/tests/providers/github/rest/test_repo_client.py +++ b/tests/providers/github/rest/test_repo_client.py @@ -96,7 +96,15 @@ async def test_update_code_scanning_config_combines_javascript_and_typescript(se async def mock_request_json(method, url, data=None): return {} - mocked_restapi = pretend.stub(requester=pretend.stub(request_json=pretend.call_recorder(mock_request_json))) + async def mock_request_raw(method, url): + return (200, json.dumps({"state": "not-configured", "languages": []})) + + mocked_restapi = pretend.stub( + requester=pretend.stub( + request_json=pretend.call_recorder(mock_request_json), + request_raw=mock_request_raw, + ) + ) repo_client = RepoClient(mocked_restapi) code_scanning = {"state": "configured", "languages": languages} @@ -113,6 +121,58 @@ async def mock_request_json(method, url, data=None): # the config passed by the caller is left untouched assert code_scanning == {"state": "configured", "languages": languages} + @pytest.mark.parametrize( + "current_config,languages,expected_languages", + [ + ( + {"state": "configured", "languages": ["actions", "python"]}, + ["actions", "java-kotlin"], + ["actions", "python", "java-kotlin"], + ), + ( + {"state": "configured", "languages": ["javascript", "javascript-typescript", "typescript"]}, + ["actions", "javascript"], + ["javascript-typescript", "actions"], + ), + ( + {"state": "not-configured", "languages": ["python"]}, + ["actions"], + ["actions"], + ), + ], + ) + async def test_update_code_scanning_config_keeps_analyzed_languages( + self, current_config, languages, expected_languages + ): + async def mock_request_json(method, url, data=None): + return {} + + async def mock_request_raw(method, url): + return (200, json.dumps(current_config)) + + mocked_restapi = pretend.stub( + requester=pretend.stub( + request_json=pretend.call_recorder(mock_request_json), + request_raw=pretend.call_recorder(mock_request_raw), + ) + ) + repo_client = RepoClient(mocked_restapi) + + await repo_client._update_code_scanning_config( + "test-org", "test-repo", {"state": "configured", "languages": languages} + ) + + assert repo_client.requester.request_raw.calls == [ + pretend.call("GET", "/repos/test-org/test-repo/code-scanning/default-setup"), + ] + assert repo_client.requester.request_json.calls == [ + pretend.call( + "PATCH", + "/repos/test-org/test-repo/code-scanning/default-setup", + data={"state": "configured", "languages": expected_languages}, + ), + ] + async def test_update_code_scanning_config_without_languages(self): async def mock_request_json(method, url, data=None): return {} From 943a45ae3052b938893395b2dec73377daea21fe Mon Sep 17 00:00:00 2001 From: "sebastien.heurtematte" Date: Thu, 12 Nov 2026 10:59:36 +0100 Subject: [PATCH 2/2] fix(repo): only apply the configured code scanning languages Address the review of the pull request: - When applying a merged pull request, the current repository is the previous configuration. The change of the languages added the missing ones to the current languages, so a language removed from the configuration was applied again, e.g. changing the languages from python to java-kotlin also sent python. The change now goes from the configured languages that are already analyzed to the configured languages: it still only shows the missing ones, and the languages analyzed on GitHub are only added when the change is applied, from the live settings. - The update treated a failed read of the current default setup like a default setup that is not configured, and could thus remove the languages analyzed in addition to the configured ones. It now fails without updating the default setup. The read also keeps the value javascript-typescript, which is translated anyway, so that the analysis of JavaScript and TypeScript is kept. Also align the documentation with the languages being a minimum: a new repository does not need a second change to configure its languages, and the troubleshooting guide explains why a language removed from the configuration is still analyzed. Signed-off-by: sebastien.heurtematte Co-Authored-By: Claude Opus 5.5 --- docs/userguide/guides/repository-security.md | 6 ++-- docs/userguide/guides/troubleshooting.md | 11 +++++-- otterdog/models/repository.py | 21 ++++++------ otterdog/providers/github/rest/repo_client.py | 19 ++++++++--- tests/models/test_repository.py | 32 +++++++++++++------ .../github/integration/test_repositories.py | 24 ++++++++++++-- .../providers/github/rest/test_repo_client.py | 24 ++++++++++++++ 7 files changed, 107 insertions(+), 30 deletions(-) diff --git a/docs/userguide/guides/repository-security.md b/docs/userguide/guides/repository-security.md index 39318a18..cd94a815 100644 --- a/docs/userguide/guides/repository-security.md +++ b/docs/userguide/guides/repository-security.md @@ -140,9 +140,9 @@ The languages supported by CodeQL are listed in are shown in the sidebar of the repository: a language that is not detected, e.g. `python` for a repository without Python code, is reported as error, with the list of detected languages. Only `actions` is always accepted. -As a consequence, languages can only be configured for repositories that already exist and contain code: create -the repository first, then configure the languages in a second change. A new repository can enable default setup -without languages: +As a consequence, languages can only be configured for repositories that already exist and contain code. A new +repository enables default setup without languages, GitHub then analyzes the languages it detects once code is +pushed: ``` jsonnet orgs.newOrg('my-project', 'my-org') { diff --git a/docs/userguide/guides/troubleshooting.md b/docs/userguide/guides/troubleshooting.md index 45c2d6b6..9efae9a7 100644 --- a/docs/userguide/guides/troubleshooting.md +++ b/docs/userguide/guides/troubleshooting.md @@ -44,8 +44,9 @@ Its **Validate** button also has the configuration of an organization validated **` has 'code_scanning_default_languages' configured while the repository does not yet exist.`** : Languages are checked against the languages detected in the repository, which requires the repository to exist. - Create the repository first, with default setup enabled but without languages, then configure the languages in a - second change, see [Languages must be detected in the repository](repository-security.md#languages-must-be-detected-in-the-repository). + Create the repository with default setup enabled but without languages: GitHub analyzes the languages it detects + once code is pushed. Only configure a language in a second change if GitHub does not analyze it on its own, e.g. a + compiled language, see [Languages analyzed by GitHub](repository-security.md#languages-analyzed-by-github). **` has 'code_scanning_default_languages' configured with '' but this language is not detected in the repository. Detected languages: ...`** @@ -163,6 +164,12 @@ Its **Validate** button also has the configuration of an organization validated configuration is removed when deletions are applied. Add it to the configuration instead, see the warning in [Otterdog specifics](rulesets-vs-branch-protection.md#otterdog-specifics). +**A language removed from `code_scanning_default_languages` is still analyzed** + +: The configured languages are the minimum set of languages to analyze: languages that GitHub analyzes in addition + are kept, and `plan` does not report them. To stop analyzing a language, change the default setup in the GitHub + web interface, see [Languages analyzed by GitHub](repository-security.md#languages-analyzed-by-github). + **A rotated secret is not updated on GitHub** : Otterdog cannot detect that the value of a credential changed in the credential provider. Update the secret diff --git a/otterdog/models/repository.py b/otterdog/models/repository.py index 74286fe3..06d793e5 100644 --- a/otterdog/models/repository.py +++ b/otterdog/models/repository.py @@ -985,25 +985,28 @@ def _get_code_scanning_languages_change(cls, change: Change[Any]) -> Change[Any] detected later on, the configured languages are thus the minimum set of languages to analyze: - without any configured language, the languages to analyze are left to GitHub - languages analyzed in addition to the configured ones are kept + + The change goes from the configured languages that are already analyzed to the configured languages, so + that it only shows the missing ones. It does not contain the other languages of the current object, which + is the previous configuration when applying a merged pull request: the languages analyzed on GitHub are + only kept when the change is applied, see RepoClient._update_code_scanning_config. """ expected_languages = change.to_value if not isinstance(expected_languages, list) or len(expected_languages) == 0: return None - current_languages = list(change.from_value) if isinstance(change.from_value, list) else [] + current_languages = change.from_value if isinstance(change.from_value, list) else [] analyzed_languages = {cls._get_code_scanning_analyzed_language(x) for x in current_languages} - missing_languages: list[str] = [] - for language in expected_languages: - if language not in missing_languages and ( - cls._get_code_scanning_analyzed_language(language) not in analyzed_languages - ): - missing_languages.append(language) + configured_languages = list(dict.fromkeys(expected_languages)) + already_analyzed_languages = [ + x for x in configured_languages if cls._get_code_scanning_analyzed_language(x) in analyzed_languages + ] - if len(missing_languages) == 0: + if len(already_analyzed_languages) == len(configured_languages): return None - return Change(change.from_value, current_languages + missing_languages) + return Change(already_analyzed_languages, configured_languages) @classmethod def _get_code_scanning_analyzed_language(cls, language: str) -> str: diff --git a/otterdog/providers/github/rest/repo_client.py b/otterdog/providers/github/rest/repo_client.py index f6b490c9..ff0c6365 100644 --- a/otterdog/providers/github/rest/repo_client.py +++ b/otterdog/providers/github/rest/repo_client.py @@ -699,14 +699,23 @@ async def _fill_code_scanning_config(self, org_id: str, repo_name: str, repo_dat repo_data["code_scanning_default_config"] = config async def _get_analyzed_code_scanning_languages(self, org_id: str, repo_name: str) -> list[str]: - current_data: dict[str, Any] = {} - await self._fill_code_scanning_config(org_id, repo_name, current_data) + _logger.debug("retrieving analyzed code scanning languages for '%s/%s'", org_id, repo_name) - current_config = current_data.get("code_scanning_default_config", {}) - if current_config.get("state") != "configured": + status, body = await self.requester.request_raw( + "GET", f"/repos/{org_id}/{repo_name}/code-scanning/default-setup" + ) + if status != 200: + # without the analyzed languages, the update would remove the ones that are not configured + raise RuntimeError( + f"failed to retrieve the code scanning config of repo '{org_id}/{repo_name}', " + f"needed to keep its analyzed languages: {status}: {body}" + ) + + config = json.loads(body) + if config.get("state") != "configured": return [] - return list(current_config.get("languages", [])) + return list(config.get("languages", [])) @staticmethod def _to_default_setup_update_languages(languages: list[str]) -> list[str]: diff --git a/tests/models/test_repository.py b/tests/models/test_repository.py index 928635ff..a5e54f41 100644 --- a/tests/models/test_repository.py +++ b/tests/models/test_repository.py @@ -886,20 +886,34 @@ def test_code_scanning_languages_analyzed_by_github_are_no_difference(repository @pytest.mark.parametrize( - "configured,analyzed,applied", + "configured,current,change", [ - (["actions", "java-kotlin"], ["actions", "python"], ["actions", "python", "java-kotlin"]), - (["c-cpp", "c-cpp"], ["actions"], ["actions", "c-cpp"]), - (["javascript", "typescript"], ["python"], ["python", "javascript", "typescript"]), - (["actions"], [], ["actions"]), - (["actions"], None, ["actions"]), + # the change only shows the missing languages, the other analyzed languages are kept when it is applied + (["actions", "java-kotlin"], ["actions", "python"], Change(["actions"], ["actions", "java-kotlin"])), + (["c-cpp", "c-cpp"], ["actions"], Change([], ["c-cpp"])), + (["typescript", "python"], ["javascript", "go"], Change(["typescript"], ["typescript", "python"])), + (["actions"], [], Change([], ["actions"])), + (["actions"], None, Change([], ["actions"])), + # when applying a merged pull request, the current languages are the ones of the previous configuration, + # a language removed from the configuration must not be applied + (["java-kotlin"], ["python"], Change([], ["java-kotlin"])), ], ) -def test_missing_code_scanning_languages_are_added_to_the_analyzed_ones(repository_test, configured, analyzed, applied): +def test_missing_code_scanning_languages_are_planned(repository_test, configured, current, change): expected = _code_scanning_repo(repository_test, True, configured) - current = _code_scanning_repo(repository_test, True, analyzed) + current = _code_scanning_repo(repository_test, True, current) + + assert expected.get_difference_from(current) == {"code_scanning_default_languages": change} + + +async def test_only_configured_code_scanning_languages_are_sent_to_provider(repository_test): + expected = _code_scanning_repo(repository_test, True, ["java-kotlin"]) + previous = _code_scanning_repo(repository_test, True, ["python"]) + + changes = expected.get_difference_from(previous) + provider_data = await Repository.changes_to_provider(repository_test.org_id, changes, repository_test.provider) - assert expected.get_difference_from(current) == {"code_scanning_default_languages": Change(analyzed, applied)} + assert provider_data == {"code_scanning_default_config": {"languages": ["java-kotlin"]}} def test_code_scanning_languages_are_added_when_enabling_default_setup(repository_test): diff --git a/tests/providers/github/integration/test_repositories.py b/tests/providers/github/integration/test_repositories.py index f4ebec61..777f86b7 100644 --- a/tests/providers/github/integration/test_repositories.py +++ b/tests/providers/github/integration/test_repositories.py @@ -133,12 +133,15 @@ async def test_update_repo_keeps_code_scanning_languages_analyzed_by_github(gith github.http.expect( "GET", CODE_SCANNING_URL, - response_json={"state": "configured", "languages": ["actions", "javascript-typescript", "python"]}, + response_json={ + "state": "configured", + "languages": ["actions", "javascript", "javascript-typescript", "python", "typescript"], + }, ) github.http.expect( "PATCH", CODE_SCANNING_URL, - request_json={"languages": ["actions", "python", "java-kotlin"]}, + request_json={"languages": ["actions", "javascript-typescript", "python", "java-kotlin"]}, response_json={"state": "configured"}, ) @@ -147,3 +150,20 @@ async def test_update_repo_keeps_code_scanning_languages_analyzed_by_github(gith REPO_NAME, {"code_scanning_default_config": {"languages": ["java-kotlin"]}}, ) + + +async def test_update_repo_fails_if_analyzed_code_scanning_languages_cannot_be_read(github: GitHubProviderTestKit): + """Without the analyzed languages, updating the languages would remove the ones that are not configured.""" + github.http.expect( + "GET", + CODE_SCANNING_URL, + response_status=500, + response_json={"message": "Server Error"}, + ) + + with pytest.raises(RuntimeError, match="needed to keep its analyzed languages"): + await github.provider.update_repo( + ORG_ID, + REPO_NAME, + {"code_scanning_default_config": {"languages": ["java-kotlin"]}}, + ) diff --git a/tests/providers/github/rest/test_repo_client.py b/tests/providers/github/rest/test_repo_client.py index 7c39e74f..e64a6e57 100644 --- a/tests/providers/github/rest/test_repo_client.py +++ b/tests/providers/github/rest/test_repo_client.py @@ -173,6 +173,30 @@ async def mock_request_raw(method, url): ), ] + @pytest.mark.parametrize("status_code", [403, 404, 500]) + async def test_update_code_scanning_config_fails_if_analyzed_languages_cannot_be_read(self, status_code): + async def mock_request_json(method, url, data=None): + return {} + + async def mock_request_raw(method, url): + return (status_code, "error") + + mocked_restapi = pretend.stub( + requester=pretend.stub( + request_json=pretend.call_recorder(mock_request_json), + request_raw=mock_request_raw, + ) + ) + repo_client = RepoClient(mocked_restapi) + + with pytest.raises(RuntimeError, match="needed to keep its analyzed languages"): + await repo_client._update_code_scanning_config( + "test-org", "test-repo", {"state": "configured", "languages": ["java-kotlin"]} + ) + + # the languages analyzed in addition to the configured ones would be removed by an update + assert repo_client.requester.request_json.calls == [] + async def test_update_code_scanning_config_without_languages(self): async def mock_request_json(method, url, data=None): return {}