From f27d0fa7715a9848558bab5f232ad69e07d67057 Mon Sep 17 00:00:00 2001 From: JaredforReal Date: Wed, 6 May 2026 11:55:40 +0800 Subject: [PATCH 1/4] update Signed-off-by: JaredforReal --- loom/adaptor/github.py | 28 +++++++++++++++++++--- loom/cli/main.py | 53 +++++++++++++++++++++++++++++++++++++++--- loom/daemon.py | 3 +++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/loom/adaptor/github.py b/loom/adaptor/github.py index 87cafbb..95b99db 100644 --- a/loom/adaptor/github.py +++ b/loom/adaptor/github.py @@ -43,9 +43,16 @@ class GitHubSourceConfig: poll_interval: int = DEFAULT_POLL_INTERVAL state: str = "all" # "open", "closed", "all" labels_filter: list[str] = field(default_factory=list) + include_labels: list[str] = field(default_factory=list) + keywords: list[str] = field(default_factory=list) + authors: list[str] = field(default_factory=list) events: list[str] = field(default_factory=lambda: ["issues", "pull_requests"]) group: str = "" + def __post_init__(self) -> None: + if self.labels_filter and not self.include_labels: + self.include_labels = self.labels_filter + class GitHubAdaptor(BaseAdaptor): """Polls GitHub repos for updated issues and pull requests.""" @@ -177,6 +184,9 @@ async def _poll_source(self, key: str, config: GitHubSourceConfig) -> None: "direction": "asc", "per_page": 100, } + # Server-side label filter when single label (API does AND for multiple) + if len(config.include_labels) == 1: + params["labels"] = config.include_labels[0] # Conditional request with ETag headers: dict[str, str] = {} @@ -219,10 +229,22 @@ async def _poll_source(self, key: str, config: GitHubSourceConfig) -> None: if not is_pr and "issues" not in config.events: continue - # Filter by labels if configured - if config.labels_filter: + # Filter by labels if configured (client-side OR match) + if config.include_labels: item_labels = {lbl["name"] for lbl in item.get("labels", [])} - if not any(lbl in item_labels for lbl in config.labels_filter): + if not any(lbl in item_labels for lbl in config.include_labels): + continue + + # Filter by keywords (title + body, case-insensitive OR) + if config.keywords: + text = f"{item.get('title', '')} {item.get('body', '')}".lower() + if not any(kw.lower() in text for kw in config.keywords): + continue + + # Filter by authors (case-insensitive OR) + if config.authors: + login = (item.get("user") or {}).get("login", "") + if login.lower() not in {a.lower() for a in config.authors}: continue await self._emit(envelope) diff --git a/loom/cli/main.py b/loom/cli/main.py index 12fcc4e..9396033 100644 --- a/loom/cli/main.py +++ b/loom/cli/main.py @@ -244,6 +244,20 @@ def _build_parser() -> argparse.ArgumentParser: default="all", help="Issue/PR state filter (open, closed, all)", ) + source_add.add_argument( + "--include-labels", + type=str, + required=False, + default=None, + help="GitHub: only include issues/PRs with these labels (comma-separated, OR match)", + ) + source_add.add_argument( + "--authors", + type=str, + required=False, + default=None, + help="GitHub: only include issues/PRs by these users (comma-separated)", + ) source_add.add_argument( "--url", type=str, @@ -647,7 +661,14 @@ def _source_dup_key(src: dict[str, Any]) -> tuple: """Return an identity tuple used to detect duplicate sources.""" kind = src.get("kind") if kind == "github": - return ("github", src.get("owner"), src.get("repo")) + return ( + "github", + src.get("owner"), + src.get("repo"), + tuple(sorted(src.get("include_labels", []))), + tuple(sorted(src.get("keywords", []))), + tuple(sorted(src.get("authors", []))), + ) if kind == "gmail": return ("gmail", str(Path(src.get("client_secrets", "")).expanduser())) if kind == "rss": @@ -712,13 +733,32 @@ def _add_github_source(config: LoomConfig, args: argparse.Namespace) -> None: "events": events, "state": args.state, } + if args.include_labels: + entry["include_labels"] = [ + lable.strip() for lable in args.include_labels.split(",") if lable.strip() + ] + if args.keywords: + entry["keywords"] = [k.strip() for k in args.keywords.split(",") if k.strip()] + if args.authors: + entry["authors"] = [a.strip() for a in args.authors.split(",") if a.strip()] if args.group: entry["group"] = args.group if _source_exists(config, entry): print(f" Skipped (already exists): {repo}") continue config.sources.append(entry) - print(f" Added: {repo} (events={events}, interval={args.interval}s, state={args.state})") + filters = [] + if entry.get("include_labels"): + filters.append(f"labels={entry['include_labels']}") + if entry.get("keywords"): + filters.append(f"keywords={entry['keywords']}") + if entry.get("authors"): + filters.append(f"authors={entry['authors']}") + filter_str = f", filters=[{', '.join(filters)}]" if filters else "" + print( + f"Added: {repo}", + f"(events={events}, interval={args.interval}s, state={args.state}{filter_str})", + ) tok = "provided" if args.token else "GITHUB_TOKEN env" print(f"\nGitHub source(s) saved to config. Token: {tok}") print("Run `loom daemon` to start monitoring.") @@ -790,7 +830,14 @@ def _add_arxiv_source(config: LoomConfig, args: argparse.Namespace) -> None: def _describe_source(src: dict[str, object]) -> str: kind = src.get("kind") if kind == "github": - return f"{src.get('owner', '?')}/{src.get('repo', '?')}" + parts = [f"{src.get('owner', '?')}/{src.get('repo', '?')}"] + if src.get("include_labels"): + parts.append(f"labels={','.join(src['include_labels'])}") # type: ignore[arg-type] + if src.get("keywords"): + parts.append(f"kw={','.join(src['keywords'])}") # type: ignore[arg-type] + if src.get("authors"): + parts.append(f"by={','.join(src['authors'])}") # type: ignore[arg-type] + return " ".join(parts) if kind == "gmail": return f"Gmail ({src.get('query', 'is:unread')})" if kind == "rss": diff --git a/loom/daemon.py b/loom/daemon.py index f479f26..0c414b3 100644 --- a/loom/daemon.py +++ b/loom/daemon.py @@ -167,6 +167,9 @@ def _build_adaptors( state=src.get("state", "all"), events=src.get("events", ["issues", "pull_requests"]), labels_filter=src.get("labels_filter"), + include_labels=src.get("include_labels") or [], + keywords=src.get("keywords") or [], + authors=src.get("authors") or [], group=src.get("group") or default_group, ) ) From c088d0a603753b9ec00ad0e64703e974d9307aec Mon Sep 17 00:00:00 2001 From: JaredforReal Date: Wed, 6 May 2026 15:31:34 +0800 Subject: [PATCH 2/4] to RSS, expand Signed-off-by: JaredforReal --- loom/cli/main.py | 21 ++++++++++-- .../src/pages/config/ConfigFormEditor.tsx | 33 +++++++++++++++---- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/loom/cli/main.py b/loom/cli/main.py index 9396033..c8c71fc 100644 --- a/loom/cli/main.py +++ b/loom/cli/main.py @@ -300,6 +300,13 @@ def _build_parser() -> argparse.ArgumentParser: default=50, help="Max papers per poll for arxiv (default 50)", ) + source_add.add_argument( + "--title-filter", + type=str, + required=False, + default=None, + help="RSS: only include entries whose title matches any keyword (comma-separated)", + ) source_add.add_argument( "--token", type=str, @@ -672,7 +679,11 @@ def _source_dup_key(src: dict[str, Any]) -> tuple: if kind == "gmail": return ("gmail", str(Path(src.get("client_secrets", "")).expanduser())) if kind == "rss": - return ("rss", src.get("url")) + return ( + "rss", + src.get("url"), + tuple(sorted(src.get("title_filter", []))), + ) if kind == "arxiv": return ( "arxiv", @@ -787,13 +798,19 @@ def _add_rss_source(config: LoomConfig, args: argparse.Namespace) -> None: "url": args.url, "poll_interval": args.interval, } + if args.title_filter: + entry["title_filter"] = [t.strip() for t in args.title_filter.split(",") if t.strip()] if args.group: entry["group"] = args.group if _source_exists(config, entry): print(f"Source already exists, skipping: {_describe_source(entry)}") return config.sources.append(entry) - print(f"RSS source saved: {args.url} (interval={args.interval}s)") + filters = [] + if entry.get("title_filter"): + filters.append(f"title_filter={entry['title_filter']}") + filter_str = f", filters=[{', '.join(filters)}]" if filters else "" + print(f"RSS source saved: {args.url} (interval={args.interval}s{filter_str})") print("Run `loom daemon` to start monitoring.") diff --git a/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx b/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx index 7bc3180..9bf384b 100644 --- a/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx +++ b/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx @@ -45,6 +45,7 @@ interface SourceFieldDef { options?: string[] default?: string | number placeholder?: string + section?: "config" | "filter" } const SOURCE_FIELDS: Record = { @@ -53,17 +54,22 @@ const SOURCE_FIELDS: Record = { { key: "repo", label: "Repo", type: "text", placeholder: "hello-world" }, { key: "poll_interval", label: "Poll interval (s)", type: "number", default: 120 }, { key: "state", label: "State", type: "select", options: ["all", "open", "closed"], default: "all" }, + { key: "events", label: "Events", type: "tags", placeholder: "issues, pull_requests", section: "filter" }, + { key: "include_labels", label: "Labels (any match)", type: "tags", placeholder: "bug, enhancement", section: "filter" }, + { key: "keywords", label: "Keywords (title/body)", type: "tags", placeholder: "CUDA, quantization", section: "filter" }, + { key: "authors", label: "Authors", type: "tags", placeholder: "username", section: "filter" }, ], rss: [ { key: "url", label: "Feed URL", type: "text", placeholder: "https://example.com/feed.xml" }, { key: "poll_interval", label: "Poll interval (s)", type: "number", default: 300 }, + { key: "title_filter", label: "Title filter (any match)", type: "tags", placeholder: "keyword1, keyword2", section: "filter" }, ], arxiv: [ { key: "categories", label: "Categories", type: "tags", placeholder: "cs.AI, cs.CL" }, - { key: "keywords", label: "Keywords", type: "tags", placeholder: "LLM, reasoning" }, { key: "query", label: "Query (override)", type: "text", placeholder: "cat:cs.AI AND ti:agent" }, { key: "poll_interval", label: "Poll interval (s)", type: "number", default: 43200 }, { key: "max_results", label: "Max results", type: "number", default: 50 }, + { key: "keywords", label: "Keywords", type: "tags", placeholder: "LLM, reasoning", section: "filter" }, ], gmail: [ { key: "query", label: "Gmail query", type: "text", default: "is:unread -in:chats newer_than:1d" }, @@ -406,10 +412,7 @@ export function ConfigFormEditor({ value, onChange }: ConfigFormEditorProps) { const sources = config.sources.filter((_, j) => j !== i) update({ ...config, sources }) }} - defaultExpanded={ - i === config.sources.length - 1 && - Object.keys(s).filter((k) => !SOURCE_COMMON_FIELDS.has(k) && s[k] !== undefined && s[k] !== "").length === 0 - } + defaultExpanded={true} /> ))} @@ -437,6 +440,8 @@ function SourceCard({ }) { const [expanded, setExpanded] = useState(defaultExpanded) const schemaFields = SOURCE_FIELDS[source.kind] ?? [] + const configFields = schemaFields.filter((f) => f.section !== "filter") + const filterFields = schemaFields.filter((f) => f.section === "filter") const schemaKeys = new Set(schemaFields.map((f) => f.key)) const extraEntries = Object.entries(source).filter( ([k]) => !SOURCE_COMMON_FIELDS.has(k) && !schemaKeys.has(k), @@ -545,8 +550,8 @@ function SourceCard({ - {/* Schema-driven fields for the current source kind */} - {schemaFields.map((f) => ( + {/* Config fields */} + {configFields.map((f) => ( setField(f.key, v)} /> ))} @@ -564,6 +569,20 @@ function SourceCard({ ))} + + {/* Filter section — only shown when the source kind has filter fields */} + {filterFields.length > 0 && ( + <> +
+ Filters +
+
+ {filterFields.map((f) => ( + setField(f.key, v)} /> + ))} +
+ + )} )} From 22cf00aa50d3d1fd49353e57aebc393ac9720c24 Mon Sep 17 00:00:00 2001 From: JaredforReal Date: Wed, 6 May 2026 15:50:30 +0800 Subject: [PATCH 3/4] github state to filter Signed-off-by: JaredforReal --- loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx b/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx index 9bf384b..3aae3a3 100644 --- a/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx +++ b/loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx @@ -53,7 +53,7 @@ const SOURCE_FIELDS: Record = { { key: "owner", label: "Owner", type: "text", placeholder: "octocat" }, { key: "repo", label: "Repo", type: "text", placeholder: "hello-world" }, { key: "poll_interval", label: "Poll interval (s)", type: "number", default: 120 }, - { key: "state", label: "State", type: "select", options: ["all", "open", "closed"], default: "all" }, + { key: "state", label: "State", type: "select", options: ["all", "open", "closed"], default: "all", section: "filter" }, { key: "events", label: "Events", type: "tags", placeholder: "issues, pull_requests", section: "filter" }, { key: "include_labels", label: "Labels (any match)", type: "tags", placeholder: "bug, enhancement", section: "filter" }, { key: "keywords", label: "Keywords (title/body)", type: "tags", placeholder: "CUDA, quantization", section: "filter" }, From 0a905ff8480ac71231b25e8d3b5aa35e41226599 Mon Sep 17 00:00:00 2001 From: JaredforReal Date: Wed, 6 May 2026 16:03:47 +0800 Subject: [PATCH 4/4] fix typo Signed-off-by: JaredforReal --- loom/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loom/cli/main.py b/loom/cli/main.py index c8c71fc..88409cd 100644 --- a/loom/cli/main.py +++ b/loom/cli/main.py @@ -746,7 +746,7 @@ def _add_github_source(config: LoomConfig, args: argparse.Namespace) -> None: } if args.include_labels: entry["include_labels"] = [ - lable.strip() for lable in args.include_labels.split(",") if lable.strip() + label.strip() for label in args.include_labels.split(",") if label.strip() ] if args.keywords: entry["keywords"] = [k.strip() for k in args.keywords.split(",") if k.strip()]