From a1ba706470d36a1654484e7c8d944ed2517a3ba0 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Wed, 15 Jul 2026 11:19:56 +0800 Subject: [PATCH 1/6] feat: enable item search --- app.py | 16 ++++++++++++---- config.toml | 5 +++++ docker/web/Dockerfile | 2 +- recommendations.py | 3 +++ requirements.txt | 2 +- tests/test_search.py | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 recommendations.py create mode 100644 tests/test_search.py diff --git a/app.py b/app.py index 320856d..b871abf 100644 --- a/app.py +++ b/app.py @@ -37,6 +37,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix from jobs import pull, upsert +from recommendations import recommendation_ids from utils import Base, get_cached, save_cache # create flask app @@ -346,7 +347,9 @@ def get_repo(category: str = ""): repo_id = None for _ in range(2): try: - repo_id = gorse_client.get_recommend(current_user.login, category)[0] + repo_id = recommendation_ids( + gorse_client.get_recommend(current_user.login, category) + )[0] break except UnknownObjectException: logging.warn("repo %s not found" % repo_id) @@ -670,8 +673,13 @@ def extension_recommend_v2(): mimetype="application/json", ) try: - recommended_items = gorse_client.get_recommend( - current_user.login, n=3, write_back_type="read", write_back_delay="24h" + recommended_items = recommendation_ids( + gorse_client.get_recommend( + current_user.login, + n=3, + write_back_type="read", + write_back_delay="24h", + ) ) github_client = Github(current_user.token["access_token"]) return Response( @@ -712,7 +720,7 @@ def extension_recommend_latency(user_id: str): # If the user is in Gorse. gorse_client.get_user(user_id) try: - repo_names = gorse_client.get_recommend(user_id, n=3) + repo_names = recommendation_ids(gorse_client.get_recommend(user_id, n=3)) return Response( json.dumps({"has_login": True, "recommend": repo_names}), mimetype="application/json", diff --git a/config.toml b/config.toml index cf3a997..3d61859 100644 --- a/config.toml +++ b/config.toml @@ -71,6 +71,11 @@ positive_feedback_ttl = 0 # The time-to-live (days) of items, 0 means disabled. The default value is 0. item_ttl = 0 +[recommend.search] + +# Item fields to index for search. Values use expr syntax. +columns = ["item.ItemId", "item.Comment"] + [[recommend.non-personalized]] # The name of the leaderboard. diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 27303ac..d9730a0 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -22,7 +22,7 @@ RUN pip3 install -r requirements.txt COPY --from=0 /frontend/dist ./frontend/dist # Copy backend -COPY app.py jobs.py gunicorn.conf.py utils.py ./ +COPY app.py jobs.py gunicorn.conf.py recommendations.py utils.py ./ # Start gitrec CMD PYTHONPATH=. gunicorn app:app -c gunicorn.conf.py diff --git a/recommendations.py b/recommendations.py new file mode 100644 index 0000000..78639f2 --- /dev/null +++ b/recommendations.py @@ -0,0 +1,3 @@ +def recommendation_ids(scores): + """Extract item IDs from PyGorse scored recommendations.""" + return [score.id for score in scores] diff --git a/requirements.txt b/requirements.txt index b680c15..0ac640c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ openai~=1.77.0 pickledb==1.3.2 protobuf==6.33.1 PyGithub==1.55 -PyGorse==0.4.10 +PyGorse==0.5.1 python-dateutil==2.8.1 python-dotenv==1.2.2 pytz==2022.1 diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..409d08d --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,40 @@ +import pathlib +import tomllib + +from recommendations import recommendation_ids + + +ROOT = pathlib.Path(__file__).parents[1] + + +def test_searches_repository_names_and_descriptions(): + with (ROOT / "config.toml").open("rb") as config_file: + config = tomllib.load(config_file) + + assert config["recommend"]["search"]["columns"] == [ + "item.ItemId", + "item.Comment", + ] + + +def test_uses_pygorse_with_search_items_support(): + requirements = (ROOT / "requirements.txt").read_text().splitlines() + + assert "PyGorse==0.5.1" in requirements + + +def test_web_image_includes_recommendation_helpers(): + dockerfile = (ROOT / "docker" / "web" / "Dockerfile").read_text() + + assert "COPY app.py jobs.py gunicorn.conf.py recommendations.py utils.py ./" in dockerfile + + +def test_extracts_item_ids_from_scored_recommendations(): + class Score: + def __init__(self, item_id): + self.id = item_id + + assert recommendation_ids([Score("gorse-io:gorse"), Score("gorse-io:gitrec")]) == [ + "gorse-io:gorse", + "gorse-io:gitrec", + ] From f12056ae7f1159afc362d8fcae0acc4f568df224 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Wed, 15 Jul 2026 12:58:38 +0800 Subject: [PATCH 2/6] refactor: inline recommendation IDs --- app.py | 16 ++++++++-------- docker/web/Dockerfile | 2 +- recommendations.py | 3 --- tests/test_search.py | 40 ---------------------------------------- 4 files changed, 9 insertions(+), 52 deletions(-) delete mode 100644 recommendations.py delete mode 100644 tests/test_search.py diff --git a/app.py b/app.py index b871abf..c981b41 100644 --- a/app.py +++ b/app.py @@ -37,7 +37,6 @@ from werkzeug.middleware.proxy_fix import ProxyFix from jobs import pull, upsert -from recommendations import recommendation_ids from utils import Base, get_cached, save_cache # create flask app @@ -347,9 +346,7 @@ def get_repo(category: str = ""): repo_id = None for _ in range(2): try: - repo_id = recommendation_ids( - gorse_client.get_recommend(current_user.login, category) - )[0] + repo_id = gorse_client.get_recommend(current_user.login, category)[0].id break except UnknownObjectException: logging.warn("repo %s not found" % repo_id) @@ -673,14 +670,15 @@ def extension_recommend_v2(): mimetype="application/json", ) try: - recommended_items = recommendation_ids( - gorse_client.get_recommend( + recommended_items = [ + score.id + for score in gorse_client.get_recommend( current_user.login, n=3, write_back_type="read", write_back_delay="24h", ) - ) + ] github_client = Github(current_user.token["access_token"]) return Response( json.dumps( @@ -720,7 +718,9 @@ def extension_recommend_latency(user_id: str): # If the user is in Gorse. gorse_client.get_user(user_id) try: - repo_names = recommendation_ids(gorse_client.get_recommend(user_id, n=3)) + repo_names = [ + score.id for score in gorse_client.get_recommend(user_id, n=3) + ] return Response( json.dumps({"has_login": True, "recommend": repo_names}), mimetype="application/json", diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index d9730a0..27303ac 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -22,7 +22,7 @@ RUN pip3 install -r requirements.txt COPY --from=0 /frontend/dist ./frontend/dist # Copy backend -COPY app.py jobs.py gunicorn.conf.py recommendations.py utils.py ./ +COPY app.py jobs.py gunicorn.conf.py utils.py ./ # Start gitrec CMD PYTHONPATH=. gunicorn app:app -c gunicorn.conf.py diff --git a/recommendations.py b/recommendations.py deleted file mode 100644 index 78639f2..0000000 --- a/recommendations.py +++ /dev/null @@ -1,3 +0,0 @@ -def recommendation_ids(scores): - """Extract item IDs from PyGorse scored recommendations.""" - return [score.id for score in scores] diff --git a/tests/test_search.py b/tests/test_search.py deleted file mode 100644 index 409d08d..0000000 --- a/tests/test_search.py +++ /dev/null @@ -1,40 +0,0 @@ -import pathlib -import tomllib - -from recommendations import recommendation_ids - - -ROOT = pathlib.Path(__file__).parents[1] - - -def test_searches_repository_names_and_descriptions(): - with (ROOT / "config.toml").open("rb") as config_file: - config = tomllib.load(config_file) - - assert config["recommend"]["search"]["columns"] == [ - "item.ItemId", - "item.Comment", - ] - - -def test_uses_pygorse_with_search_items_support(): - requirements = (ROOT / "requirements.txt").read_text().splitlines() - - assert "PyGorse==0.5.1" in requirements - - -def test_web_image_includes_recommendation_helpers(): - dockerfile = (ROOT / "docker" / "web" / "Dockerfile").read_text() - - assert "COPY app.py jobs.py gunicorn.conf.py recommendations.py utils.py ./" in dockerfile - - -def test_extracts_item_ids_from_scored_recommendations(): - class Score: - def __init__(self, item_id): - self.id = item_id - - assert recommendation_ids([Score("gorse-io:gorse"), Score("gorse-io:gitrec")]) == [ - "gorse-io:gorse", - "gorse-io:gitrec", - ] From 611a51a0945a79afc920871009977aabec98fc42 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Wed, 15 Jul 2026 13:10:17 +0800 Subject: [PATCH 3/6] feat: search neighbors for missing repositories --- app.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index c981b41..71e1340 100644 --- a/app.py +++ b/app.py @@ -625,6 +625,30 @@ def get_neighbors_v2(repo_name: str): n = int(request.args.get("n", default="3")) offset = int(request.args.get("offset", default="0")) scores = gorse_client.get_neighbors(repo_name.lower(), n, offset) + github_client = global_github_client + if current_user.is_authenticated: + github_client = Github(current_user.token["access_token"]) + + if len(scores) == 0: + try: + gorse_client.get_item(repo_name) + except gorse.GorseException as e: + if e.status_code != 404: + raise + repo = github_client.get_repo(repo_name.replace(":", "/")) + items = [] + if repo.description: + items = gorse_client.search_items(repo.description, n + offset) + scores = [ + {"Id": item["ItemId"], "Score": 0} + for item in items[offset : offset + n] + ] + if current_user.is_authenticated: + upsert.delay( + current_user.token["access_token"], + repo_name.replace(":", "/"), + ) + if not current_user.is_authenticated: response = Response( json.dumps({"is_authenticated": False, "scores": scores}), @@ -634,15 +658,6 @@ def get_neighbors_v2(repo_name: str): response.vary.add("Cookie") return response else: - # Upsert the repository if it doesn't exist in Gorse. - if len(scores) == 0: - try: - gorse_client.get_item(repo_name) - except gorse.GorseException as e: - if e.status_code == 404: - upsert.delay(current_user.token["access_token"], repo_name.replace(":", "/")) - - github_client = Github(current_user.token["access_token"]) response = Response( json.dumps( { From 0e82ab1d523c3bac79b1ea04bb36c462d7cf7acc Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Thu, 16 Jul 2026 13:01:57 +0800 Subject: [PATCH 4/6] fix: pass repository description from extension --- app.py | 8 ++++---- extension/background.js | 6 +++++- extension/content.js | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app.py b/app.py index 71e1340..8be0e4f 100644 --- a/app.py +++ b/app.py @@ -624,8 +624,9 @@ def get_neighbors_v2(repo_name: str): try: n = int(request.args.get("n", default="3")) offset = int(request.args.get("offset", default="0")) + description = request.args.get("description", default="") scores = gorse_client.get_neighbors(repo_name.lower(), n, offset) - github_client = global_github_client + github_client = None if current_user.is_authenticated: github_client = Github(current_user.token["access_token"]) @@ -635,10 +636,9 @@ def get_neighbors_v2(repo_name: str): except gorse.GorseException as e: if e.status_code != 404: raise - repo = github_client.get_repo(repo_name.replace(":", "/")) items = [] - if repo.description: - items = gorse_client.search_items(repo.description, n + offset) + if description: + items = gorse_client.search_items(description, n + offset) scores = [ {"Id": item["ItemId"], "Score": 0} for item in items[offset : offset + n] diff --git a/extension/background.js b/extension/background.js index f3f40f1..a5f84dc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -31,7 +31,11 @@ chrome.runtime.onMessage.addListener( sendResponse(r); }) } else if (request.neighbors) { - fetch(`https://gitrec.gorse.io/api/v2/neighbors/${request.neighbors}?offset=${request.offset}&n=6`, { + const params = new URLSearchParams({ offset: request.offset, n: 6 }); + if (request.description) { + params.set('description', request.description); + } + fetch(`https://gitrec.gorse.io/api/v2/neighbors/${request.neighbors}?${params}`, { credentials: 'include' }).then(r => r.json()).then(r => { sendResponse(r); diff --git a/extension/content.js b/extension/content.js index 89c3020..e3a5d81 100644 --- a/extension/content.js +++ b/extension/content.js @@ -23,7 +23,8 @@ $(document).ready(function () { }) function loadSimilarRepos() { - chrome.runtime.sendMessage({ neighbors: itemId, offset: similarOffset }, function (result) { + const description = $(".BorderGrid-cell p.f4.my-3").first().text().trim(); + chrome.runtime.sendMessage({ neighbors: itemId, offset: similarOffset, description: description }, function (result) { if (result.is_authenticated) { renderSimilarDiv(result); } else { From 209ae6c46baea1a66a26b9ada6b1b682f9ae47db Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Thu, 16 Jul 2026 13:15:35 +0800 Subject: [PATCH 5/6] docs: restore repository upsert comment --- app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app.py b/app.py index 8be0e4f..52410c2 100644 --- a/app.py +++ b/app.py @@ -630,6 +630,7 @@ def get_neighbors_v2(repo_name: str): if current_user.is_authenticated: github_client = Github(current_user.token["access_token"]) + # Upsert the repository if it doesn't exist in Gorse. if len(scores) == 0: try: gorse_client.get_item(repo_name) From 6d994df948b70280438a5d3fcfd1ef6eef08aecc Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sat, 18 Jul 2026 11:49:20 +0800 Subject: [PATCH 6/6] fix: preserve repository upsert flow --- app.py | 55 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index 52410c2..ca108c2 100644 --- a/app.py +++ b/app.py @@ -626,31 +626,20 @@ def get_neighbors_v2(repo_name: str): offset = int(request.args.get("offset", default="0")) description = request.args.get("description", default="") scores = gorse_client.get_neighbors(repo_name.lower(), n, offset) - github_client = None - if current_user.is_authenticated: - github_client = Github(current_user.token["access_token"]) - - # Upsert the repository if it doesn't exist in Gorse. - if len(scores) == 0: - try: - gorse_client.get_item(repo_name) - except gorse.GorseException as e: - if e.status_code != 404: - raise - items = [] - if description: - items = gorse_client.search_items(description, n + offset) - scores = [ - {"Id": item["ItemId"], "Score": 0} - for item in items[offset : offset + n] - ] - if current_user.is_authenticated: - upsert.delay( - current_user.token["access_token"], - repo_name.replace(":", "/"), - ) - if not current_user.is_authenticated: + if len(scores) == 0: + try: + gorse_client.get_item(repo_name) + except gorse.GorseException as e: + if e.status_code != 404: + raise + items = [] + if description: + items = gorse_client.search_items(description, n + offset) + scores = [ + {"Id": item["ItemId"], "Score": 0} + for item in items[offset : offset + n] + ] response = Response( json.dumps({"is_authenticated": False, "scores": scores}), mimetype="application/json", @@ -659,6 +648,24 @@ def get_neighbors_v2(repo_name: str): response.vary.add("Cookie") return response else: + # Upsert the repository if it doesn't exist in Gorse. + if len(scores) == 0: + try: + gorse_client.get_item(repo_name) + except gorse.GorseException as e: + if e.status_code == 404: + upsert.delay(current_user.token["access_token"], repo_name.replace(":", "/")) + items = [] + if description: + items = gorse_client.search_items(description, n + offset) + scores = [ + {"Id": item["ItemId"], "Score": 0} + for item in items[offset : offset + n] + ] + else: + raise + + github_client = Github(current_user.token["access_token"]) response = Response( json.dumps( {