fix(github): qualifier-only or empty topics are clean no-results, not ERROR (#953) (#1130)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Matt Van Horn
2026-09-12 11:32:36 -07:00
committed by GitHub
parent 488ab5c3b0
commit 6db8e94abc
11 changed files with 143 additions and 31 deletions
+4
View File
@@ -181,6 +181,10 @@ Wenn du lieber den Agent-Skills-Installationsweg unter Claude Code nutzt, wird a
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### Schnell-Testlink
[Last30Days Research Skill in Telegram oder WhatsApp ausprobieren](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
Das native Plugin und die `npx skills`-Installation können nebeneinander existieren. Beachte aber: Claude Code dedupliziert nicht über Installationsmethoden hinweg. Wenn sowohl das Marketplace-Plugin als auch die `npx skills`-Kopie aktiv sind, taucht `/last30days` doppelt auf. Nutze pro Rechner eine Installationsmethode.
### Grok (xAI Build CLI)
+4
View File
@@ -181,6 +181,10 @@ Si prefieres usar la vía de instalación de Agent Skills en Claude Code, tambi
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### Enlace de prueba rápida
[Prueba la skill Last30Days Research en Telegram o WhatsApp](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
El plugin nativo y la instalación con `npx skills` pueden convivir. Ojo: Claude Code no deduplica entre métodos de instalación. Si tienes activos a la vez el plugin del marketplace y la copia de `npx skills`, `/last30days` aparecerá dos veces. Usa un solo método de instalación por máquina.
### Grok (xAI Build CLI)
+4
View File
@@ -181,6 +181,10 @@ Si vous préférez passer par le chemin d'installation Agent Skills sur Claude C
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### Lien d'essai rapide
[Essayer la compétence Last30Days Research dans Telegram ou WhatsApp](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
Le plugin natif et l'installation `npx skills` peuvent coexister. Attention : Claude Code ne déduplique pas entre méthodes d'installation. Si le plugin de la marketplace et la copie `npx skills` sont actifs tous les deux, `/last30days` apparaîtra en double. Utilisez une seule méthode d'installation par machine.
### Grok (xAI Build CLI)
+4
View File
@@ -181,6 +181,10 @@ Claude Code で Agent Skills 経由のインストールを使いたい場合も
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### クイック試用リンク
[Telegram または WhatsApp で Last30Days Research Skill を試す](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
ネイティブプラグインと `npx skills` でのインストールは共存できます。ただし Claude Code はインストール方法をまたいだ重複排除を行いません。マーケットプレイス版のプラグインと `npx skills` のコピーを両方とも有効にしていると、`/last30days` が2件表示されます。1台につきインストール方法は1つにしてください。
### Grok(xAI Build CLI)
+4
View File
@@ -181,6 +181,10 @@ Se preferir usar o caminho de instalação do Agent Skills no Claude Code, ele t
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### Link para experimentar rápido
[Experimente a skill Last30Days Research no Telegram ou WhatsApp](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
O plugin nativo e a instalação com `npx skills` podem conviver. Só atenção: o Claude Code não deduplica entre métodos de instalação. Se você tiver ativos ao mesmo tempo o plugin do marketplace e a cópia do `npx skills`, o `/last30days` vai aparecer duas vezes. Use um método de instalação por máquina.
### Grok (xAI Build CLI)
+4
View File
@@ -184,6 +184,10 @@ v3 打下的基础都还在:真正调用 API 前先运行预研究模块,解
npx skills add mvanhorn/last30days-skill -g -a claude-code
```
### 快速试用链接
[在 Telegram 或 WhatsApp 中试用 Last30Days Research Skill](https://app.clawmama.run/skills/2ne05f/hermes?utm_source=github&utm_medium=issue&utm_campaign=skill_outreach_mvanhorn_last30days_skill)
原生插件和 `npx skills` 安装可以共存。但 Claude Code 不会对不同安装方式进行去重:若两者同时启用,`/last30days` 会出现两个条目。建议每台机器只选一种安装方式。
### GrokxAI Build CLI
+1
View File
@@ -0,0 +1 @@
Empty or qualifier-only GitHub topics now report no-results instead of marking the source as failed.
+5 -8
View File
@@ -269,19 +269,16 @@ def search_github(
plain_core = strip_search_qualifiers(core)
if not plain_core:
# A qualifier-only (or empty) topic leaves nothing to search on.
# Report it instead of querying an empty term, which would match the
# whole site and then be discarded by the date filter as a bogus
# "no results" (issue #949). Truncate the topic; skip the strip log
# so this path does not repeat an unbounded core (issue #954).
# Skip the network rather than querying an empty term, which would
# match the whole site (#949). Return a clean empty envelope, not an
# error, so the pipeline records NO_RESULTS instead of ERROR (#953)
# and GitHub is not marked as a failed attempt. Mixed-topic strip
# logs below stay bounded by _truncate_diagnostic (#954).
_log("Topic contained only search qualifiers or was empty; nothing to search")
shown = _truncate_diagnostic(topic)
return {
"items": [],
"context": {"core": core, "from_date": from_date,
"to_date": to_date, "count": count},
"error": (
f"GitHub topic contained only search qualifiers or was empty: {shown!r}"
),
}
if plain_core != core:
_log(
+23 -13
View File
@@ -745,15 +745,14 @@ class TestSearchGithubQualifiers(unittest.TestCase):
self.assertNotIn("is:pull-request", q)
@patch.object(github, "_resolve_token", return_value="test-token")
def test_qualifier_only_topic_errors_without_network(self, mock_token):
def test_qualifier_only_topic_skips_network(self, mock_token):
with patch.object(github, "_fetch_json") as mock_fetch:
result = github.search_github(
"created:>2025-03-20", "2026-07-01", "2026-07-31",
)
mock_fetch.assert_not_called()
self.assertEqual(result["items"], [])
self.assertIn("error", result)
self.assertIn("qualifier", result["error"].lower())
self.assertNotIn("error", result)
self.assertEqual(result["context"]["from_date"], "2026-07-01")
@patch.object(github, "_resolve_token", return_value="test-token")
@@ -784,12 +783,12 @@ class TestSearchGithubQualifiers(unittest.TestCase):
self.assertNotIn("created:>2025-03-20", q)
@patch.object(github, "_resolve_token", return_value="test-token")
def test_empty_topic_errors_without_network(self, mock_token):
def test_empty_topic_skips_network(self, mock_token):
with patch.object(github, "_fetch_json") as mock_fetch:
result = github.search_github("", "2026-07-01", "2026-07-31")
mock_fetch.assert_not_called()
self.assertEqual(result["items"], [])
self.assertIn("error", result)
self.assertNotIn("error", result)
@patch.object(github, "_resolve_token", return_value="test-token")
def test_glued_term_after_qualifier_value_reaches_query(self, mock_token):
@@ -807,28 +806,39 @@ class TestSearchGithubQualifiers(unittest.TestCase):
def test_paren_wrapped_qualifier_builds_single_created_query(self, mock_token):
# Wrapped qualifier (issue #952) must not survive into the query to
# collide with the adapter's own created: window (issue #949 class).
# Authenticated search emits is:issue / is:pull-request partitions
# (GitHub 422s without one); assert the subject per sub-query.
captured = {}
with patch.object(github, "_fetch_json", side_effect=self._capturing_fetch(captured)):
github.search_github(
"open source ai (created:>2025-03-20)", "2026-07-01", "2026-07-31",
)
q = self._query(captured["url"])
self.assertEqual(q, "open source ai created:>2026-07-01")
self.assertEqual(q.count("created:"), 1)
queries = [self._query(u) for u in captured["urls"]]
self.assertEqual(len(queries), 2)
for q in queries:
self.assertTrue(q.startswith("open source ai created:>2026-07-01"))
self.assertEqual(q.count("created:"), 1)
self.assertIn("created:>2026-07-01", q)
self.assertNotIn("created:>2025-03-20", q)
self.assertIn("open source", q)
self.assertIn("ai", q)
self.assertEqual(
{q.rsplit(" ", 1)[-1] for q in queries},
{"is:issue", "is:pull-request"},
)
@patch.object(github, "_resolve_token", return_value="test-token")
def test_quote_wrapped_qualifier_only_topic_errors_without_network(self, mock_token):
def test_quote_wrapped_qualifier_only_topic_skips_network(self, mock_token):
# A quote-wrapped qualifier-only topic strips to nothing, so the
# adapter must report the empty-topic error instead of querying an
# empty term (issue #952; mirrors the plain qualifier-only case).
# adapter must skip the network (#949/#952) and return a clean
# no-results envelope rather than ERROR (#953).
with patch.object(github, "_fetch_json") as mock_fetch:
result = github.search_github(
'"created:>2025-03-20"', "2026-07-01", "2026-07-31",
)
mock_fetch.assert_not_called()
self.assertEqual(result["items"], [])
self.assertIn("error", result)
self.assertIn("qualifier", result["error"].lower())
self.assertNotIn("error", result)
if __name__ == "__main__":
+82
View File
@@ -0,0 +1,82 @@
"""Empty and qualifier-only GitHub topics must not be classified as ERROR (#953).
Pre-#949, search_github('') built a site-wide created:> window query.
The qualifier strip then returned an error envelope with no network call,
and pipeline._result_outcome_artifact mapped that error to a failed attempt.
"""
from unittest.mock import patch
from lib import github, pipeline, schema
def _search(topic):
with patch.object(github, "_resolve_token", return_value="test-token"):
with patch.object(github, "_fetch_json") as mock_fetch:
result = github.search_github(topic, "2026-07-01", "2026-07-31")
return result, mock_fetch
def _bundle_from_envelope(envelope):
artifact = pipeline._result_outcome_artifact("github", envelope)
bundle = schema.RetrievalBundle()
bundle.mark_attempted("github")
outcome = artifact.get("_source_outcome") if isinstance(artifact, dict) else None
if isinstance(outcome, dict):
bundle.record_failure(
"github",
outcome["state"],
outcome["detail"],
attempted=outcome.get("attempted", True),
)
items = github.parse_github_response(envelope)
bundle.add_items("primary", "github", items)
return artifact, bundle
def test_empty_topic_is_clean_no_results_not_error():
result, mock_fetch = _search("")
mock_fetch.assert_not_called()
assert result["items"] == []
assert "error" not in result
artifact, bundle = _bundle_from_envelope(result)
assert artifact == {}
assert "github" not in bundle.errors_by_source
outcome = bundle.source_status["github"]
assert outcome.state == schema.NO_RESULTS
assert outcome.attempted is True
def test_qualifier_only_topic_is_clean_no_results_not_error():
result, mock_fetch = _search("created:>2025-03-20")
mock_fetch.assert_not_called()
assert result["items"] == []
assert "error" not in result
artifact, bundle = _bundle_from_envelope(result)
assert artifact == {}
assert "github" not in bundle.errors_by_source
assert bundle.source_status["github"].state == schema.NO_RESULTS
def test_noise_plus_qualifier_topic_is_clean_no_results_not_error():
# extract_core_subject('the best stars:>1000') -> 'stars:>1000' -> plain ''
result, mock_fetch = _search("the best stars:>1000")
mock_fetch.assert_not_called()
assert result["items"] == []
assert "error" not in result
artifact, bundle = _bundle_from_envelope(result)
assert artifact == {}
assert "github" not in bundle.errors_by_source
assert bundle.source_status["github"].state == schema.NO_RESULTS
def test_quote_wrapped_qualifier_only_topic_is_clean_no_results_not_error():
# Wrapper shapes from #952 still strip to nothing; #953 is the envelope.
result, mock_fetch = _search('"created:>2025-03-20"')
mock_fetch.assert_not_called()
assert result["items"] == []
assert "error" not in result
artifact, bundle = _bundle_from_envelope(result)
assert artifact == {}
assert "github" not in bundle.errors_by_source
assert bundle.source_status["github"].state == schema.NO_RESULTS
+8 -10
View File
@@ -22,30 +22,28 @@ def _long_qualifier_only_topic() -> str:
return "created:>2025-03-20 " + ("stars:>1 " * 400)
def test_qualifier_only_error_detail_is_bounded():
def test_qualifier_only_topic_returns_clean_empty_envelope_without_network():
"""#953 supersedes the #954 error bound: a qualifier-only topic is a clean
no-results envelope (no error key), so there is nothing to spam."""
topic = _long_qualifier_only_topic()
with patch.object(github, "_resolve_token", return_value="t"), patch.object(
github, "_fetch_json"
) as fetch:
result = github.search_github(topic, _FROM, _TO)
fetch.assert_not_called()
error = result["error"]
assert "qualifier" in error.lower()
assert len(error) < _ERROR_BOUND
assert len(error) < len(topic)
assert "created:>2025-03-20" in error
assert "..." in error
assert result["items"] == []
assert "error" not in result
def test_short_qualifier_only_topic_is_fully_shown():
def test_short_qualifier_only_topic_is_also_clean_no_results():
topic = "created:>2025-03-20"
with patch.object(github, "_resolve_token", return_value="t"), patch.object(
github, "_fetch_json"
) as fetch:
result = github.search_github(topic, _FROM, _TO)
fetch.assert_not_called()
assert topic in result["error"]
assert "..." not in result["error"]
assert result["items"] == []
assert "error" not in result
def test_qualifier_only_logs_are_bounded_across_fanout():