Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions loom/adaptor/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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
Comment on lines 184 to 248

await self._emit(envelope)
Expand Down
74 changes: 69 additions & 5 deletions loom/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -286,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,
Expand Down Expand Up @@ -647,11 +668,22 @@ 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":
return ("rss", src.get("url"))
return (
"rss",
src.get("url"),
tuple(sorted(src.get("title_filter", []))),
)
if kind == "arxiv":
return (
"arxiv",
Expand Down Expand Up @@ -712,13 +744,32 @@ def _add_github_source(config: LoomConfig, args: argparse.Namespace) -> None:
"events": events,
"state": args.state,
}
if args.include_labels:
entry["include_labels"] = [
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()]
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.")
Expand Down Expand Up @@ -747,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.")


Expand Down Expand Up @@ -790,7 +847,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":
Expand Down
3 changes: 3 additions & 0 deletions loom/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment on lines 168 to 169
include_labels=src.get("include_labels") or [],
keywords=src.get("keywords") or [],
authors=src.get("authors") or [],
group=src.get("group") or default_group,
)
)
Expand Down
35 changes: 27 additions & 8 deletions loom/webui/frontend/src/pages/config/ConfigFormEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,31 @@ interface SourceFieldDef {
options?: string[]
default?: string | number
placeholder?: string
section?: "config" | "filter"
}

const SOURCE_FIELDS: Record<string, SourceFieldDef[]> = {
github: [
{ 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" },
{ 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" },
Expand Down Expand Up @@ -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}
/>
))}
</div>
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -545,8 +550,8 @@ function SourceCard({
</select>
</Field>

{/* Schema-driven fields for the current source kind */}
{schemaFields.map((f) => (
{/* Config fields */}
{configFields.map((f) => (
<SourceField key={f.key} def={f} value={source[f.key]} onChange={(v) => setField(f.key, v)} />
))}

Expand All @@ -564,6 +569,20 @@ function SourceCard({
</Field>
))}
</div>

{/* Filter section — only shown when the source kind has filter fields */}
{filterFields.length > 0 && (
<>
<div className="mt-3 mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</div>
<div className="grid grid-cols-2 gap-3">
{filterFields.map((f) => (
<SourceField key={f.key} def={f} value={source[f.key]} onChange={(v) => setField(f.key, v)} />
))}
</div>
</>
)}
</div>
)}
</div>
Expand Down
Loading