Skip to content

Commit 30ef714

Browse files
extend source code linker to work with any repo (#84)
so far score was hardcoded
1 parent 9c7cef3 commit 30ef714

5 files changed

Lines changed: 164 additions & 22 deletions

File tree

src/extensions/score_metamodel/metamodel.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
needs_types_base_options:
1616
optional_options:
17-
source_code_link: ^https://github.com/eclipse-score/.*$
17+
source_code_link: ^https://github.com/.*
1818
# Custom semantic validation rules
1919
prohibited_words:
2020
# req-Id: tool_req__docs_common_attr_title

src/extensions/score_source_code_linker/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from sphinx_needs.data import NeedsMutable, SphinxNeedsData, NeedsInfoType
2020
from sphinx_needs.logging import get_logger
2121

22-
from src.extensions.score_source_code_linker.parse_source_files import GITHUB_BASE_URL
22+
from src.extensions.score_source_code_linker.parse_source_files import (
23+
get_github_base_url,
24+
)
2325

2426
LOGGER = get_logger(__name__)
2527
LOGGER.setLevel("DEBUG")
@@ -108,6 +110,7 @@ def add_source_link(app: Sphinx, env: BuildEnvironment) -> None:
108110
# For some reason the prefix 'sphinx_needs internally' is CAPSLOCKED.
109111
# So we have to make sure we uppercase the prefixes
110112
prefixes = [x["id_prefix"].upper() for x in app.config.needs_external_needs]
113+
github_base_url = get_github_base_url() + "/blob/"
111114
try:
112115
with open(path) as f:
113116
gh_json = json.load(f)
@@ -117,7 +120,7 @@ def add_source_link(app: Sphinx, env: BuildEnvironment) -> None:
117120
if need is None:
118121
# NOTE: manipulating link to remove git-hash,
119122
# making the output file location more readable
120-
files = [x.replace(GITHUB_BASE_URL, "").split("/", 1)[-1] for x in link]
123+
files = [x.replace(github_base_url, "").split("/", 1)[-1] for x in link]
121124
LOGGER.warning(
122125
f"Could not find {id} in the needs id's. "
123126
+ f"Found in file(s): {files}",

src/extensions/score_source_code_linker/parse_source_files.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import json
1616
import logging
1717
import os
18+
import sys
1819
import subprocess
1920

21+
2022
# Importing from collections.abc as typing.Callable is deprecated since Python 3.9
2123
from collections.abc import Callable
2224
from pathlib import Path
@@ -28,7 +30,63 @@
2830
"# req-Id:",
2931
]
3032

31-
GITHUB_BASE_URL = "https://github.com/eclipse-score/score/blob/"
33+
34+
def get_github_base_url() -> str:
35+
git_root = find_git_root()
36+
repo = get_github_repo_info(git_root)
37+
return f"https://github.com/{repo}"
38+
39+
40+
def parse_git_output(str_line: str) -> str:
41+
if len(str_line.split()) < 2:
42+
logger.warning(
43+
f"Got wrong input line from 'get_github_repo_info'. Input: {str_line}. Expected example: 'origin git@github.com:user/repo.git'"
44+
)
45+
return ""
46+
url = str_line.split()[1] # Get the URL part
47+
# Handle SSH format (git@github.com:user/repo.git)
48+
if url.startswith("git@"):
49+
path = url.split(":")[1]
50+
else:
51+
path = "/".join(url.split("/")[3:]) # Get part after github.com/
52+
return path.replace(".git", "")
53+
54+
55+
def get_github_repo_info(git_root_cwd: Path) -> str:
56+
process = subprocess.run(
57+
["git", "remote", "-v"], capture_output=True, text=True, cwd=git_root_cwd
58+
)
59+
repo = ""
60+
for line in process.stdout.split("\n"):
61+
if "origin" in line and "(fetch)" in line:
62+
repo = parse_git_output(line)
63+
break
64+
else:
65+
# If we do not find 'origin' we just take the first line
66+
logger.info(
67+
"Did not find origin remote name. Will now take first result from: 'git remote -v'"
68+
)
69+
repo = parse_git_output(process.stdout.split("\n")[0])
70+
assert repo != "", (
71+
"Remote repository is not defined. Make sure you have a remote set. Check this via 'git remote -v'"
72+
)
73+
return repo
74+
75+
76+
def find_git_root():
77+
"""
78+
This is copied from 'find_runfiles' as the import does not work for some reason.
79+
This should be fixed.
80+
"""
81+
git_root = Path(__file__).resolve()
82+
while not (git_root / ".git").exists():
83+
git_root = git_root.parent
84+
if git_root == Path("/"):
85+
sys.exit(
86+
"Could not find git root. Please run this script from the "
87+
"root of the repository."
88+
)
89+
return git_root
3290

3391

3492
def get_git_hash(file_path: str) -> str:
@@ -48,7 +106,7 @@ def get_git_hash(file_path: str) -> str:
48106
try:
49107
abs_path = Path(file_path).resolve()
50108
if not os.path.isfile(abs_path):
51-
print(f"File not found: {abs_path}", flush=True)
109+
logger.warning(f"File not found: {abs_path}")
52110
return "file_not_found"
53111
result = subprocess.run(
54112
["git", "log", "-n", "1", "--pretty=format:%H", "--", abs_path],
@@ -61,12 +119,13 @@ def get_git_hash(file_path: str) -> str:
61119
assert all(c in "0123456789abcdef" for c in decoded_result)
62120
return decoded_result
63121
except Exception as e:
64-
print(f"Unexpected error: {abs_path}: {e}", flush=True)
122+
logger.warning(f"Unexpected error: {abs_path}: {e}")
65123
return "error"
66124

67125

68126
def extract_requirements(
69127
source_file: str,
128+
github_base_url: str,
70129
git_hash_func: Callable[[str], str] | None = get_git_hash,
71130
) -> dict[str, list[str]]:
72131
"""
@@ -110,7 +169,7 @@ def extract_requirements(
110169
check_tag = cleaned_line.split(":")[1].strip()
111170
if check_tag:
112171
req_id = cleaned_line.split(":")[-1].strip()
113-
link = f"{GITHUB_BASE_URL}{hash}/{source_file}#L{line_number}"
172+
link = f"{github_base_url}/blob/{hash}/{source_file}#L{line_number}"
114173
requirement_mapping[req_id].append(link)
115174
return requirement_mapping
116175

@@ -124,11 +183,13 @@ def extract_requirements(
124183

125184
logger.info(f"Parsing source files: {args.inputs}")
126185

186+
# Finding the GH URL
187+
gh_base_url = get_github_base_url()
127188
requirement_mappings: dict[str, list[str]] = collections.defaultdict(list)
128189
for input in args.inputs:
129190
with open(input) as f:
130191
for source_file in f:
131-
rm = extract_requirements(source_file.strip())
192+
rm = extract_requirements(source_file.strip(), gh_base_url)
132193
for k, v in rm.items():
133194
requirement_mappings[k].extend(v)
134195
with open(args.output, "w") as f:

src/extensions/score_source_code_linker/tests/test_requirement_links.py

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@
1212
# *******************************************************************************
1313
from collections import defaultdict
1414
from collections.abc import Callable
15+
from gettext import find
1516
from pathlib import Path
1617

1718
import pytest
19+
import logging
1820
from pytest import TempPathFactory
1921
from src.extensions.score_source_code_linker.parse_source_files import (
20-
GITHUB_BASE_URL,
22+
get_github_base_url,
23+
find_git_root,
24+
get_github_repo_info,
2125
extract_requirements,
2226
get_git_hash,
27+
parse_git_output,
28+
logger as scl_logger,
2329
)
2430

2531

@@ -74,39 +80,42 @@ def dummy_git_hash_func(input: str) -> Callable[[str], str]:
7480

7581
def test_extract_requirements(create_tmp_files: Path):
7682
root_dir = create_tmp_files
77-
83+
github_base_url = get_github_base_url()
7884
results_dict1 = extract_requirements(
79-
str(root_dir / "testfile.txt"), dummy_git_hash_func("no-hash")
85+
str(root_dir / "testfile.txt"), github_base_url, dummy_git_hash_func("no-hash")
8086
)
8187
expected_dict1: dict[str, list[str]] = defaultdict(list)
8288
expected_dict1["TEST_REQ__LINKED_ID"].append(
83-
f"{GITHUB_BASE_URL}no-hash/{root_dir}/testfile.txt#L7"
89+
f"{github_base_url}/blob/no-hash/{root_dir}/testfile.txt#L7"
8490
)
8591
expected_dict1["TEST_REQ__LINKED_TRACE"].append(
86-
f"{GITHUB_BASE_URL}no-hash/{root_dir}/testfile.txt#L11"
92+
f"{github_base_url}/blob/no-hash/{root_dir}/testfile.txt#L11"
8793
)
8894

8995
# Assumed random hash here to test if passed correctly
9096
results_dict2 = extract_requirements(
9197
str(root_dir / "testfile2.txt"),
98+
github_base_url,
9299
dummy_git_hash_func("aacce4887ceea1f884135242a8c182db1447050"),
93100
)
94101
expected_dict2: dict[str, list[str]] = defaultdict(list)
95102
expected_dict2["TEST_REQ__LINKED_DIFFERENT_FILE"].append(
96-
f"{GITHUB_BASE_URL}aacce4887ceea1f884135242a8c182db1447050/{root_dir}/testfile2.txt#L3"
103+
f"{github_base_url}/blob/aacce4887ceea1f884135242a8c182db1447050/{root_dir}/testfile2.txt#L3"
97104
)
98105

99-
results_dict3 = extract_requirements(str(root_dir / "testfile3.txt"))
106+
results_dict3 = extract_requirements(
107+
str(root_dir / "testfile3.txt"), github_base_url
108+
)
100109
expected_dict3: dict[str, list[str]] = defaultdict(list)
101110

102111
# if there is no git-hash returned from command.
103112
# This happens if the file is new and not committed yet.
104113
results_dict4 = extract_requirements(
105-
str(root_dir / "testfile2.txt"), dummy_git_hash_func("")
114+
str(root_dir / "testfile2.txt"), github_base_url, dummy_git_hash_func("")
106115
)
107116
expected_dict4: dict[str, list[str]] = defaultdict(list)
108117
expected_dict4["TEST_REQ__LINKED_DIFFERENT_FILE"].append(
109-
f"{GITHUB_BASE_URL}/{root_dir}/testfile2.txt#L3"
118+
f"{github_base_url}/blob//{root_dir}/testfile2.txt#L3"
110119
)
111120

112121
assert results_dict1 == expected_dict1
@@ -118,3 +127,62 @@ def test_extract_requirements(create_tmp_files: Path):
118127
def test_get_git_hash():
119128
assert get_git_hash("testfile.x") == "file_not_found"
120129
assert get_git_hash("") == "file_not_found"
130+
131+
132+
# These tests aren't great / exhaustive, but an okay first step into the right direction.
133+
134+
135+
def test_get_github_repo_info():
136+
# I'd argue the happy path is tested with the other ones?
137+
with pytest.raises(AssertionError):
138+
get_github_repo_info(Path("."))
139+
140+
141+
git_test_data_ok = [
142+
(
143+
"origin https://github.com/eclipse-score/test-repo.git (fetch)",
144+
"eclipse-score/test-repo",
145+
),
146+
(
147+
"origin git@github.com:eclipse-score/test-repo.git (fetch)",
148+
"eclipse-score/test-repo",
149+
),
150+
("origin git@github.com:eclipse-score/test-repo.git", "eclipse-score/test-repo"),
151+
("upstream git@github.com:upstream/repo.git (fetch)", "upstream/repo"),
152+
]
153+
154+
155+
@pytest.mark.parametrize("input,output", git_test_data_ok)
156+
def test_parse_git_output_ok(input, output):
157+
assert output == parse_git_output(input)
158+
159+
160+
git_test_data_bad = [
161+
("origin ", ""),
162+
(
163+
" ",
164+
"",
165+
),
166+
]
167+
168+
169+
@pytest.mark.parametrize("input,output", git_test_data_bad)
170+
def test_parse_git_output_bad(caplog, input, output):
171+
with caplog.at_level(logging.WARNING, logger=scl_logger.name):
172+
result = parse_git_output(input)
173+
assert len(caplog.messages) == 1
174+
assert caplog.records[0].levelname == "WARNING"
175+
assert (
176+
f"Got wrong input line from 'get_github_repo_info'. Input: {input}. Expected example: 'origin git@github.com:user/repo.git'"
177+
in caplog.records[0].message
178+
)
179+
assert output == result
180+
181+
182+
def test_get_github_base_url():
183+
# Not really a great test imo.
184+
git_root = find_git_root()
185+
repo = get_github_repo_info(git_root)
186+
expected = f"https://github.com/{repo}"
187+
actual = get_github_base_url()
188+
assert expected == actual

src/extensions/score_source_code_linker/tests/test_source_link.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
1616

1717
import pytest
1818
from pytest import TempPathFactory
19-
from src.extensions.score_source_code_linker.parse_source_files import GITHUB_BASE_URL
19+
from src.extensions.score_source_code_linker.parse_source_files import (
20+
get_github_base_url,
21+
)
2022
from sphinx.testing.util import SphinxTestApp
2123
from sphinx_needs.data import SphinxNeedsData
2224

2325

26+
def construct_gh_url() -> str:
27+
gh = get_github_base_url()
28+
return f"{gh}/blob/"
29+
30+
2431
@pytest.fixture(scope="session")
2532
def sphinx_base_dir(tmp_path_factory: TempPathFactory) -> Path:
2633
return tmp_path_factory.mktemp("sphinx")
@@ -105,22 +112,24 @@ def basic_needs():
105112

106113
@pytest.fixture(scope="session")
107114
def example_source_link_text_all_ok():
115+
github_base_url = construct_gh_url()
108116
return {
109117
"TREQ_ID_1": [
110-
f"{GITHUB_BASE_URL}aacce4887ceea1f884135242a8c182db1447050/tools/sources/implementation1.py#L2",
111-
f"{GITHUB_BASE_URL}/tools/sources/implementation_2_new_file.py#L20",
118+
f"{github_base_url}aacce4887ceea1f884135242a8c182db1447050/tools/sources/implementation1.py#L2",
119+
f"{github_base_url}/tools/sources/implementation_2_new_file.py#L20",
112120
],
113121
"TREQ_ID_2": [
114-
f"{GITHUB_BASE_URL}f53f50a0ab1186329292e6b28b8e6c93b37ea41/tools/sources/implementation1.py#L18"
122+
f"{github_base_url}f53f50a0ab1186329292e6b28b8e6c93b37ea41/tools/sources/implementation1.py#L18"
115123
],
116124
}
117125

118126

119127
@pytest.fixture(scope="session")
120128
def example_source_link_text_non_existent():
129+
github_base_url = construct_gh_url()
121130
return {
122131
"TREQ_ID_200": [
123-
f"{GITHUB_BASE_URL}f53f50a0ab1186329292e6b28b8e6c93b37ea41/tools/sources/bad_implementation.py#L17"
132+
f"{github_base_url}f53f50a0ab1186329292e6b28b8e6c93b37ea41/tools/sources/bad_implementation.py#L17"
124133
],
125134
}
126135

@@ -132,6 +141,7 @@ def test_source_link_integration_ok(
132141
example_source_link_text_all_ok: dict[str, list[str]],
133142
sphinx_base_dir: Path,
134143
):
144+
github_url = construct_gh_url()
135145
app = sphinx_app_setup(basic_conf, basic_needs, example_source_link_text_all_ok)
136146
try:
137147
app.build()

0 commit comments

Comments
 (0)