diff --git a/.cursor/skills/review-pr/SKILL.md b/.cursor/skills/review-pr/SKILL.md index 2156e7a..cef6bfd 100644 --- a/.cursor/skills/review-pr/SKILL.md +++ b/.cursor/skills/review-pr/SKILL.md @@ -366,6 +366,11 @@ git checkout main Append new, durable review insights here (newest first), per Step 5. +- **"Hot"/feed polling connectors often write a `*_last_sync` sync_state they never read.** A feed connector (Reddit `hot`, HN) re-fetches the top-N listing every poll and dedups via `db.message_exists`, then writes a `*_last_sync` timestamp purely for display. That is *not* the resumable cursor the sync contract envisions (HN's `last_max_item_id` is) — it's fine because a "hot" listing isn't cursor-paginable, but confirm dedup-before-ingest exists and treat the write-only state as informational, not a backfill cursor. Bonus: comment/thread re-sync gated on `already_exists || matches` re-pulls every still-hot matched post each cycle (rate-limited by a fixed sleep) — bounded, acceptable, but note the repeated requests. +- **Even first-party / owner-authored PRs bundle unrelated commits.** A self-authored feature PR may sneak in an off-topic commit (e.g. editing a `.cursor/skills/*` doc) that fails "one logical change per PR" and lacks a CHANGELOG entry. Run the effective-delta and changelog checks regardless of author; don't relax Step 1 because the author owns the repo. +- **New-connector crates often ship a dead `error.rs`.** A `void-/src/error.rs` may define a `thiserror` enum (e.g. `GitHubError`) that's never used because the code threads `anyhow::Result` end-to-end. Because the type is `pub`, clippy/`dead_code` won't flag it. Grep the crate for the error type name — if it appears only in its own definition, it's scaffolding to wire in or drop (Should-fix/Nit, not a blocker). +- **`void mute ` / `ignore_conversations` matching is substring-on-name-OR-external_id.** `conversation_matches_ignore` (case-insensitive `.contains`) means any connector that sets a conversation's `name` to a human handle (e.g. `owner/repo`) automatically supports `void mute owner/repo` and config patterns like `["kubernetes"]` (which match *all* repos containing that substring — note the over-broad match when reviewing docs examples). No connector-side mute code is needed; verify the claim by checking that `name`/`external_id` carries the mutable handle. +- **Polling cursors that only advance on *included* items can stall.** When a `since`/cursor sync filters items (e.g. only `reason == "mention"`) and calls `update_cursor` *after* the filter `continue`, a poll batch of only filtered-out items never advances the cursor, so the same window is re-fetched every cycle. Harmless when a `message_exists`/dedup guard exists (idempotent re-fetch), but flag the wasted requests as a Nit; without dedup it would be a correctness/cost bug. - **Stale branches masquerade as big PRs.** A `CONFLICTING` PR with a noisy multi-commit diff is usually cut from an old `main` with one or more commits already merged independently. Compute the effective delta first (commit-headline / per-file `git log origin/main` checks) — the real change is often a single commit. - **Removal PRs are easy to leave half-done.** Verify completeness against *current* `main`: grep the whole tree for leftover references and check docs/config tables, `.github/ISSUE_TEMPLATE`, README, and CHANGELOG. Watch for files the PR deletes that `main` has since restructured (e.g. `api.rs` → `api/`) — the stale deletion misses the new files. - **CI signals are subtle.** "no checks reported" ≠ passing (CI never ran); `UNSTABLE` + `MERGEABLE` means non-required checks aren't blocking and `--auto` merges immediately. Treat a local `./scripts/check.sh` pass as the real gate. diff --git a/Cargo.lock b/Cargo.lock index 3e69c40..e716551 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4432,6 +4432,7 @@ dependencies = [ "uuid", "void-calendar", "void-core", + "void-github", "void-gmail", "void-googlenews", "void-hackernews", @@ -4464,6 +4465,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "void-github" +version = "0.10.3" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "void-core", + "wiremock", +] + [[package]] name = "void-gmail" version = "0.10.3" diff --git a/Cargo.toml b/Cargo.toml index a9f0e6f..89d0964 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/void-hackernews", "crates/void-googlenews", "crates/void-linkedin", + "crates/void-github", ] [workspace.package] @@ -83,3 +84,4 @@ void-telegram = { path = "crates/void-telegram" } void-hackernews = { path = "crates/void-hackernews" } void-googlenews = { path = "crates/void-googlenews" } void-linkedin = { path = "crates/void-linkedin" } +void-github = { path = "crates/void-github" } diff --git a/README.md b/README.md index 0a646fb..008a2b0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) [![Rust](https://img.shields.io/badge/rust-1.95%2B-orange.svg)](Cargo.toml) -**One inbox for everything.** `void` unifies WhatsApp, Telegram, Slack, Gmail, Google Calendar, LinkedIn, Hacker News, and Google News into a single local-first command-line tool — one inbox, one search index, one set of commands. +**One inbox for everything.** `void` unifies WhatsApp, Telegram, Slack, Gmail, Google Calendar, LinkedIn, GitHub, Hacker News, and Google News into a single local-first command-line tool — one inbox, one search index, one set of commands. It is built for terminals, shell scripts, and AI agents: @@ -150,14 +150,14 @@ A background daemon keeps a local SQLite database in sync with every connected s void ◄──────────► SQLite (FTS5) ◄────── sync daemon ──────► services │ WhatsApp │ Telegram │ Slack ──── push (WebSocket / MTProto) - Gmail │ Calendar │ LinkedIn │ HN │ Google News ──── polling + Gmail │ Calendar │ LinkedIn │ GitHub │ HN │ Google News ──── polling ``` | Crate | Role | |-------|------| | `void-core` | Config, database, models, hooks, `Connector` trait, sync engine | | `void-cli` | The `void` binary: clap commands, output formatting | -| `void-slack`, `void-gmail`, `void-calendar`, `void-whatsapp`, `void-telegram`, `void-hackernews`, `void-googlenews`, `void-linkedin` | One crate per connector | +| `void-slack`, `void-gmail`, `void-calendar`, `void-whatsapp`, `void-telegram`, `void-hackernews`, `void-googlenews`, `void-linkedin`, `void-github` | One crate per connector | All data stays on your machine in `~/.local/share/void` — no external database, no Docker, no cloud. Layout details: [Configuration](docs/configuration.md#data-storage-layout). diff --git a/crates/void-cli/Cargo.toml b/crates/void-cli/Cargo.toml index 64a6f58..ca11dc5 100644 --- a/crates/void-cli/Cargo.toml +++ b/crates/void-cli/Cargo.toml @@ -20,6 +20,7 @@ void-telegram = { workspace = true } void-hackernews = { workspace = true } void-googlenews = { workspace = true } void-linkedin = { workspace = true } +void-github = { workspace = true } clap = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/void-cli/src/commands/connector_factory.rs b/crates/void-cli/src/commands/connector_factory.rs index e3269d8..f4d6757 100644 --- a/crates/void-cli/src/commands/connector_factory.rs +++ b/crates/void-cli/src/commands/connector_factory.rs @@ -304,6 +304,15 @@ pub fn build_connector( sync_cfg.linkedin_backfill_days, ))) } + (ConnectorType::GitHub, ConnectionSettings::GitHub { token, username }) => { + let poll_secs = crate::context::config().sync.github_poll_interval_secs; + Ok(Arc::new(void_github::connector::GitHubConnector::new( + &connection.id, + token.clone(), + username.clone(), + poll_secs, + ))) + } _ => anyhow::bail!( "Mismatched connector type and settings for '{}': type={}, settings don't match", connection.id, diff --git a/crates/void-cli/src/commands/reply.rs b/crates/void-cli/src/commands/reply.rs index c7262eb..6d533ff 100644 --- a/crates/void-cli/src/commands/reply.rs +++ b/crates/void-cli/src/commands/reply.rs @@ -161,6 +161,7 @@ fn build_reply_id( ConnectorType::HackerNews => msg_external_id.to_string(), ConnectorType::GoogleNews => msg_external_id.to_string(), ConnectorType::LinkedIn => format!("{conv_external_id}:{msg_external_id}"), + ConnectorType::GitHub => msg_external_id.to_string(), } } @@ -214,4 +215,14 @@ mod tests { let id = build_reply_id(ConnectorType::GoogleNews, "feed", "article-7"); assert_eq!(id, "article-7"); } + + #[test] + fn build_reply_id_github_uses_message_external_id_only() { + let id = build_reply_id( + ConnectorType::GitHub, + "github_github_owner_repo", + "github_github_notification_1", + ); + assert_eq!(id, "github_github_notification_1"); + } } diff --git a/crates/void-cli/src/commands/setup/config_ui.rs b/crates/void-cli/src/commands/setup/config_ui.rs index 3eb5361..83d0cd9 100644 --- a/crates/void-cli/src/commands/setup/config_ui.rs +++ b/crates/void-cli/src/commands/setup/config_ui.rs @@ -32,6 +32,10 @@ pub(crate) fn show_configuration(config_path: &Path, cfg: &VoidConfig) { " linkedin_backfill_days = {}", cfg.sync.linkedin_backfill_days ); + eprintln!( + " github_poll_interval_secs = {}", + cfg.sync.github_poll_interval_secs + ); eprintln!(); if cfg.connections.is_empty() { @@ -122,6 +126,10 @@ pub(crate) fn show_configuration(config_path: &Path, cfg: &VoidConfig) { eprintln!(" dsn: {dsn}"); eprintln!(" account_id: {account_id}"); } + config::ConnectionSettings::GitHub { token, username } => { + eprintln!(" token: {}", config::redact_token(token)); + eprintln!(" username: {username}"); + } } } } diff --git a/crates/void-cli/src/commands/setup/connection_menu.rs b/crates/void-cli/src/commands/setup/connection_menu.rs index 4af61bd..ae89271 100644 --- a/crates/void-cli/src/commands/setup/connection_menu.rs +++ b/crates/void-cli/src/commands/setup/connection_menu.rs @@ -4,7 +4,7 @@ use void_core::config::VoidConfig; use super::auth::authenticate_connection; use super::prompt::{prompt, select, separator}; -use super::{calendar, gmail, googlenews, hackernews, linkedin, slack, telegram, whatsapp}; +use super::{calendar, github, gmail, googlenews, hackernews, linkedin, slack, telegram, whatsapp}; pub(crate) async fn add_connection(cfg: &mut VoidConfig, store_path: &Path) -> anyhow::Result<()> { let choice = select( @@ -18,6 +18,7 @@ pub(crate) async fn add_connection(cfg: &mut VoidConfig, store_path: &Path) -> a "Hacker News", "Google News", "LinkedIn", + "GitHub", ], ); @@ -31,6 +32,7 @@ pub(crate) async fn add_connection(cfg: &mut VoidConfig, store_path: &Path) -> a 5 => hackernews::setup_hackernews(cfg, true)?, 6 => googlenews::setup_googlenews(cfg, true)?, 7 => linkedin::setup_linkedin(cfg, store_path, true).await?, + 8 => github::setup_github(cfg, true).await?, _ => {} } Ok(()) diff --git a/crates/void-cli/src/commands/setup/github.rs b/crates/void-cli/src/commands/setup/github.rs new file mode 100644 index 0000000..c58a691 --- /dev/null +++ b/crates/void-cli/src/commands/setup/github.rs @@ -0,0 +1,66 @@ +use void_core::config::{ConnectionConfig, ConnectionSettings, VoidConfig}; +use void_core::models::ConnectorType; + +use super::auth::{pick_connector_action, ConnectorAction}; +use super::prompt::{prompt, prompt_default}; + +pub(crate) async fn setup_github(cfg: &mut VoidConfig, add_only: bool) -> anyhow::Result<()> { + eprintln!("🐙 GITHUB"); + eprintln!(); + eprintln!("Syncs GitHub activity into your inbox:"); + eprintln!(" • Open PRs requesting your review"); + eprintln!(" • Comments on your pull requests"); + eprintln!(" • @mentions of your handle"); + eprintln!(); + eprintln!("Create a Personal Access Token with at least the `notifications` scope."); + eprintln!("For private repositories, also grant `repo` (classic) or Pull requests read access (fine-grained)."); + + if !add_only { + let existing: Vec = cfg + .connections + .iter() + .enumerate() + .filter(|(_, a)| a.connector_type == ConnectorType::GitHub) + .map(|(i, _)| i) + .collect(); + + let action = pick_connector_action("GitHub", &existing, cfg); + match action { + ConnectorAction::Skip => return Ok(()), + ConnectorAction::Keep => return Ok(()), + ConnectorAction::Replace(idx) => { + cfg.connections.remove(idx); + } + ConnectorAction::Add => {} + } + } + + eprintln!(); + let token = prompt("GitHub Personal Access Token: "); + if token.trim().is_empty() { + anyhow::bail!("GitHub token is required"); + } + + let client = void_github::api::GitHubClient::new(token.trim()); + let user = client.current_user().await?; + eprintln!(" ✓ Token valid for @{}", user.login); + + let username = prompt_default("GitHub username", &user.login); + + let connection_id = prompt_default("\nAccount name", "github"); + + let connection = ConnectionConfig { + id: connection_id, + connector_type: ConnectorType::GitHub, + ignore_conversations: vec![], + settings: ConnectionSettings::GitHub { + token: token.trim().to_string(), + username, + }, + }; + + cfg.connections.push(connection); + eprintln!(" ✓ GitHub configured."); + eprintln!(" Mute repositories with: void mute "); + Ok(()) +} diff --git a/crates/void-cli/src/commands/setup/mod.rs b/crates/void-cli/src/commands/setup/mod.rs index cb2e7a9..4acd246 100644 --- a/crates/void-cli/src/commands/setup/mod.rs +++ b/crates/void-cli/src/commands/setup/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod auth; mod calendar; mod config_ui; pub(crate) mod connection_menu; +mod github; mod gmail; mod googlenews; mod hackernews; diff --git a/crates/void-cli/src/commands/setup/wizard.rs b/crates/void-cli/src/commands/setup/wizard.rs index 05fa811..d6ecf3d 100644 --- a/crates/void-cli/src/commands/setup/wizard.rs +++ b/crates/void-cli/src/commands/setup/wizard.rs @@ -3,7 +3,7 @@ use std::path::Path; use void_core::config::VoidConfig; use super::prompt::{confirm_default_yes, separator}; -use super::{calendar, gmail, googlenews, hackernews, linkedin, slack, telegram, whatsapp}; +use super::{calendar, github, gmail, googlenews, hackernews, linkedin, slack, telegram, whatsapp}; use crate::commands::sync; pub(crate) async fn run_full_wizard( @@ -14,7 +14,7 @@ pub(crate) async fn run_full_wizard( eprintln!(); eprintln!("This wizard will guide you through connecting your"); eprintln!("communication services (Gmail, Slack, WhatsApp, Telegram,"); - eprintln!("Google Calendar, Hacker News, Google News, LinkedIn) to Void."); + eprintln!("Google Calendar, Hacker News, Google News, LinkedIn, GitHub) to Void."); eprintln!(); separator(); @@ -34,6 +34,8 @@ pub(crate) async fn run_full_wizard( separator(); linkedin::setup_linkedin(cfg, store_path, false).await?; separator(); + github::setup_github(cfg, false).await?; + separator(); cfg.save(config_path)?; eprintln!("Configuration saved to {}", config_path.display()); diff --git a/crates/void-cli/src/output.rs b/crates/void-cli/src/output.rs index 4c98f81..4377f71 100644 --- a/crates/void-cli/src/output.rs +++ b/crates/void-cli/src/output.rs @@ -85,16 +85,17 @@ pub fn parse_connector_type(s: &str) -> Option { "hackernews" | "hn" => Some(ConnectorType::HackerNews), "googlenews" | "gn" => Some(ConnectorType::GoogleNews), "linkedin" | "li" => Some(ConnectorType::LinkedIn), + "github" | "gh" => Some(ConnectorType::GitHub), _ => None, } } const KNOWN_CONNECTORS: &str = - "whatsapp, slack, gmail, calendar, telegram, hackernews, googlenews, linkedin"; + "whatsapp, slack, gmail, calendar, telegram, hackernews, googlenews, linkedin, github"; /// Shared `--connector` flag description for list/search commands (see [`resolve_connector_filter`]). pub const CONNECTOR_FILTER_HELP: &str = - "Filter by connector (slack, gmail, whatsapp, calendar, telegram, hackernews, googlenews, linkedin)"; + "Filter by connector (slack, gmail, whatsapp, calendar, telegram, hackernews, googlenews, linkedin, github)"; pub fn resolve_connector_filter(raw: Option<&str>) -> anyhow::Result> { match raw { @@ -181,6 +182,13 @@ mod tests { assert_eq!(parse_connector_type("LI"), Some(ConnectorType::LinkedIn)); } + #[test] + fn parse_connector_type_github() { + assert_eq!(parse_connector_type("github"), Some(ConnectorType::GitHub)); + assert_eq!(parse_connector_type("gh"), Some(ConnectorType::GitHub)); + assert_eq!(parse_connector_type("GH"), Some(ConnectorType::GitHub)); + } + #[test] fn parse_connector_type_unknown_returns_none() { assert_eq!(parse_connector_type("unknown"), None); diff --git a/crates/void-core/src/config/connection.rs b/crates/void-core/src/config/connection.rs index 1fdcfa6..dc5359f 100644 --- a/crates/void-core/src/config/connection.rs +++ b/crates/void-core/src/config/connection.rs @@ -74,6 +74,14 @@ impl<'de> Deserialize<'de> for ConnectionConfig { .account_id .ok_or_else(|| serde::de::Error::missing_field("account_id"))?, }, + ConnectorType::GitHub => ConnectionSettings::GitHub { + token: raw + .token + .ok_or_else(|| serde::de::Error::missing_field("token"))?, + username: raw + .username + .ok_or_else(|| serde::de::Error::missing_field("username"))?, + }, }; Ok(ConnectionConfig { id: raw.id, @@ -122,6 +130,10 @@ struct RawConnectionConfig { #[serde(default)] account_id: Option, #[serde(default)] + token: Option, + #[serde(default)] + username: Option, + #[serde(default)] ignore_conversations: Option>, } @@ -174,6 +186,10 @@ pub enum ConnectionSettings { dsn: String, account_id: String, }, + GitHub { + token: String, + username: String, + }, } // Manual `Debug` so secret-bearing fields are redacted: a stray `debug!(?config)` @@ -246,6 +262,11 @@ impl std::fmt::Debug for ConnectionSettings { .field("dsn", dsn) .field("account_id", account_id) .finish(), + Self::GitHub { token, username } => f + .debug_struct("GitHub") + .field("token", &redact_token(token)) + .field("username", username) + .finish(), } } } diff --git a/crates/void-core/src/config/paths.rs b/crates/void-core/src/config/paths.rs index d3f6b6d..6760a8a 100644 --- a/crates/void-core/src/config/paths.rs +++ b/crates/void-core/src/config/paths.rs @@ -81,6 +81,7 @@ hackernews_poll_interval_secs = 3600 googlenews_poll_interval_secs = 3600 linkedin_poll_interval_secs = 1800 linkedin_backfill_days = 15 +github_poll_interval_secs = 120 # Example connections (uncomment and fill in): # @@ -134,6 +135,12 @@ linkedin_backfill_days = 15 # api_key = "your-unipile-api-key" # dsn = "https://api1.unipile.com:13111" # account_id = "your-unipile-account-id" +# +# [[connections]] +# id = "github" +# type = "github" +# token = "ghp_..." +# username = "your-github-handle" "#, default_store_path_template() ) diff --git a/crates/void-core/src/config/void_config.rs b/crates/void-core/src/config/void_config.rs index c004e4a..b731a56 100644 --- a/crates/void-core/src/config/void_config.rs +++ b/crates/void-core/src/config/void_config.rs @@ -150,6 +150,8 @@ pub struct SyncConfig { pub linkedin_poll_interval_secs: u64, #[serde(default = "default_linkedin_backfill_days")] pub linkedin_backfill_days: u64, + #[serde(default = "default_github_poll")] + pub github_poll_interval_secs: u64, } impl Default for SyncConfig { @@ -161,6 +163,7 @@ impl Default for SyncConfig { googlenews_poll_interval_secs: default_googlenews_poll(), linkedin_poll_interval_secs: default_linkedin_poll(), linkedin_backfill_days: default_linkedin_backfill_days(), + github_poll_interval_secs: default_github_poll(), } } } @@ -189,6 +192,10 @@ fn default_linkedin_backfill_days() -> u64 { 15 } +fn default_github_poll() -> u64 { + 120 +} + impl VoidConfig { /// Parse config from a string without writing migrations or sidecar changes. pub fn parse(content: &str) -> Result { @@ -273,6 +280,7 @@ impl VoidConfig { "hackernews" => ConnectorType::HackerNews, "googlenews" => ConnectorType::GoogleNews, "linkedin" => ConnectorType::LinkedIn, + "github" => ConnectorType::GitHub, _ => return None, }; self.connections.iter().find(|a| a.connector_type == target) diff --git a/crates/void-core/src/models/connector.rs b/crates/void-core/src/models/connector.rs index 2998755..8861719 100644 --- a/crates/void-core/src/models/connector.rs +++ b/crates/void-core/src/models/connector.rs @@ -11,6 +11,7 @@ pub enum ConnectorType { HackerNews, GoogleNews, LinkedIn, + GitHub, } impl std::fmt::Display for ConnectorType { @@ -24,6 +25,7 @@ impl std::fmt::Display for ConnectorType { Self::HackerNews => write!(f, "hackernews"), Self::GoogleNews => write!(f, "googlenews"), Self::LinkedIn => write!(f, "linkedin"), + Self::GitHub => write!(f, "github"), } } } @@ -40,6 +42,7 @@ impl ConnectorType { Self::HackerNews => "HN", Self::GoogleNews => "GN", Self::LinkedIn => "LI", + Self::GitHub => "GH", } } } diff --git a/crates/void-core/src/models/tests.rs b/crates/void-core/src/models/tests.rs index 1f0dd5f..ddaff75 100644 --- a/crates/void-core/src/models/tests.rs +++ b/crates/void-core/src/models/tests.rs @@ -9,6 +9,7 @@ fn connector_type_display() { assert_eq!(ConnectorType::Telegram.to_string(), "telegram"); assert_eq!(ConnectorType::HackerNews.to_string(), "hackernews"); assert_eq!(ConnectorType::LinkedIn.to_string(), "linkedin"); + assert_eq!(ConnectorType::GitHub.to_string(), "github"); } #[test] @@ -20,6 +21,7 @@ fn connector_type_badges() { assert_eq!(ConnectorType::Telegram.badge(), "TG"); assert_eq!(ConnectorType::HackerNews.badge(), "HN"); assert_eq!(ConnectorType::LinkedIn.badge(), "LI"); + assert_eq!(ConnectorType::GitHub.badge(), "GH"); } #[test] diff --git a/crates/void-github/Cargo.toml b/crates/void-github/Cargo.toml new file mode 100644 index 0000000..f96928e --- /dev/null +++ b/crates/void-github/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "void-github" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "GitHub adapter for Void CLI" + +[dependencies] +void-core = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +tokio-util = { workspace = true } +reqwest = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +wiremock = { workspace = true } +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/void-github/src/api.rs b/crates/void-github/src/api.rs new file mode 100644 index 0000000..253b256 --- /dev/null +++ b/crates/void-github/src/api.rs @@ -0,0 +1,338 @@ +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; + +const DEFAULT_BASE_URL: &str = "https://api.github.com"; +const GITHUB_USER_AGENT: &str = "void-cli-github-connector"; + +#[derive(Debug, Clone, Deserialize)] +pub struct GhUser { + pub login: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GhRepository { + pub full_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GhSubject { + pub title: String, + #[serde(rename = "type")] + pub subject_type: String, + pub url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GhNotification { + pub id: String, + pub reason: String, + pub updated_at: String, + pub subject: GhSubject, + pub repository: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GhSearchUser { + pub login: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GhSearchIssue { + pub id: u64, + pub number: u64, + pub title: String, + pub html_url: String, + pub repository_url: String, + pub user: GhSearchUser, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GhSearchResponse { + items: Vec, +} + +pub struct GitHubClient { + http: Client, + base_url: String, + token: String, +} + +impl GitHubClient { + pub fn new(token: impl Into) -> Self { + Self { + http: Client::new(), + base_url: DEFAULT_BASE_URL.to_string(), + token: token.into(), + } + } + + /// Override the API base URL (used by tests to point at a mock server). + pub fn with_base_url(base_url: impl Into, token: impl Into) -> Self { + Self { + http: Client::new(), + base_url: base_url.into(), + token: token.into(), + } + } + + fn auth_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { + self.http + .request(method, url) + .header(AUTHORIZATION, format!("Bearer {}", self.token)) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, GITHUB_USER_AGENT) + } + + pub async fn current_user(&self) -> anyhow::Result { + let url = format!("{}/user", self.base_url); + let resp = self.auth_request(reqwest::Method::GET, &url).send().await?; + if resp.status() == StatusCode::UNAUTHORIZED { + anyhow::bail!("GitHub token is invalid or expired"); + } + if !resp.status().is_success() { + anyhow::bail!("GitHub /user failed with status {}", resp.status()); + } + Ok(resp.json().await?) + } + + pub async fn review_requested_prs(&self) -> anyhow::Result> { + let mut page = 1; + let mut all = Vec::new(); + + loop { + let url = format!( + "{}/search/issues?q=is:pr+is:open+review-requested:@me&per_page=100&page={page}", + self.base_url + ); + let resp = self.auth_request(reqwest::Method::GET, &url).send().await?; + if resp.status() == StatusCode::UNAUTHORIZED { + anyhow::bail!("GitHub token is invalid or expired"); + } + if !resp.status().is_success() { + anyhow::bail!( + "GitHub search failed with status {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + let body: GhSearchResponse = resp.json().await?; + if body.items.is_empty() { + break; + } + let count = body.items.len(); + all.extend(body.items); + if count < 100 { + break; + } + page += 1; + } + + Ok(all) + } + + pub async fn notifications(&self, since: Option<&str>) -> anyhow::Result> { + let mut page = 1; + let mut all = Vec::new(); + + loop { + let mut url = format!( + "{}/notifications?all=false&participating=true&per_page=100&page={page}", + self.base_url + ); + if let Some(since) = since { + url.push_str("&since="); + url.push_str(since); + } + + let resp = self.auth_request(reqwest::Method::GET, &url).send().await?; + if resp.status() == StatusCode::UNAUTHORIZED { + anyhow::bail!("GitHub token is invalid or expired"); + } + if !resp.status().is_success() { + anyhow::bail!( + "GitHub notifications failed with status {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + let body: Vec = resp.json().await?; + if body.is_empty() { + break; + } + let count = body.len(); + all.extend(body); + if count < 100 { + break; + } + page += 1; + } + + Ok(all) + } +} + +pub fn repo_full_name_from_url(repository_url: &str) -> Option { + let prefix = "https://api.github.com/repos/"; + repository_url + .strip_prefix(prefix) + .map(|rest| rest.trim_end_matches('/').to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn repo_full_name_from_url_parses_owner_repo() { + assert_eq!( + repo_full_name_from_url("https://api.github.com/repos/octocat/Hello-World"), + Some("octocat/Hello-World".to_string()) + ); + } + + #[tokio::test] + async fn current_user_returns_login_from_mock_server() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "login": "octocat" + }))) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let user = client.current_user().await.unwrap(); + assert_eq!(user.login, "octocat"); + } + + #[tokio::test] + async fn review_requested_prs_paginates() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search/issues")) + .and(query_param("page", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "items": [{ + "id": 1, + "number": 42, + "title": "Fix bug", + "html_url": "https://github.com/octocat/Hello-World/pull/42", + "repository_url": "https://api.github.com/repos/octocat/Hello-World", + "user": { "login": "octocat" }, + "updated_at": "2024-01-01T12:00:00Z" + }] + }))) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let prs = client.review_requested_prs().await.unwrap(); + assert_eq!(prs.len(), 1); + assert_eq!(prs[0].number, 42); + } + + #[tokio::test] + async fn notifications_sends_bearer_token() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/notifications")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let notifs = client.notifications(None).await.unwrap(); + assert!(notifs.is_empty()); + } + + #[tokio::test] + async fn current_user_errors_on_unauthorized() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "bad-token"); + let err = client.current_user().await.unwrap_err(); + assert!(err.to_string().contains("invalid or expired")); + } + + #[tokio::test] + async fn current_user_errors_on_server_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let err = client.current_user().await.unwrap_err(); + assert!(err.to_string().contains("500")); + } + + #[tokio::test] + async fn review_requested_prs_errors_on_unauthorized() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search/issues")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "bad-token"); + let err = client.review_requested_prs().await.unwrap_err(); + assert!(err.to_string().contains("invalid or expired")); + } + + #[tokio::test] + async fn review_requested_prs_errors_on_rate_limit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search/issues")) + .respond_with(ResponseTemplate::new(403).set_body_string("rate limit exceeded")) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let err = client.review_requested_prs().await.unwrap_err(); + assert!(err.to_string().contains("403")); + } + + #[tokio::test] + async fn notifications_errors_on_server_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/notifications")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + let err = client.notifications(None).await.unwrap_err(); + assert!(err.to_string().contains("500")); + } + + #[tokio::test] + async fn current_user_errors_on_malformed_json() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/user")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + + let client = GitHubClient::with_base_url(server.uri(), "test-token"); + assert!(client.current_user().await.is_err()); + } +} diff --git a/crates/void-github/src/connector/mod.rs b/crates/void-github/src/connector/mod.rs new file mode 100644 index 0000000..76c50bc --- /dev/null +++ b/crates/void-github/src/connector/mod.rs @@ -0,0 +1,102 @@ +mod sync; + +use std::sync::Arc; + +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; +use void_core::connector::Connector; +use void_core::db::Database; +use void_core::models::{ConnectorType, HealthStatus, MessageContent}; + +pub struct GitHubConnector { + config_id: String, + token: String, + username: String, + poll_interval_secs: u64, +} + +impl GitHubConnector { + pub fn new( + connection_id: &str, + token: String, + username: String, + poll_interval_secs: u64, + ) -> Self { + Self { + config_id: connection_id.to_string(), + token, + username, + poll_interval_secs, + } + } +} + +#[async_trait] +impl Connector for GitHubConnector { + fn connector_type(&self) -> ConnectorType { + ConnectorType::GitHub + } + + fn connection_id(&self) -> &str { + &self.config_id + } + + async fn authenticate(&mut self) -> anyhow::Result<()> { + let client = crate::api::GitHubClient::new(&self.token); + let user = client.current_user().await?; + if !self.username.eq_ignore_ascii_case(&user.login) { + tracing::warn!( + configured = %self.username, + actual = %user.login, + "GitHub username differs from configured value; using configured username" + ); + } + Ok(()) + } + + async fn start_sync(&self, db: Arc, cancel: CancellationToken) -> anyhow::Result<()> { + sync::run_sync( + &db, + &self.config_id, + &self.token, + self.poll_interval_secs, + cancel, + ) + .await + } + + async fn health_check(&self) -> anyhow::Result { + let client = crate::api::GitHubClient::new(&self.token); + match client.current_user().await { + Ok(user) => Ok(HealthStatus { + connection_id: self.config_id.clone(), + connector_type: ConnectorType::GitHub, + ok: true, + message: format!("Authenticated as @{}", user.login), + last_sync: None, + message_count: None, + }), + Err(e) => Ok(HealthStatus { + connection_id: self.config_id.clone(), + connector_type: ConnectorType::GitHub, + ok: false, + message: e.to_string(), + last_sync: None, + message_count: None, + }), + } + } + + async fn send_message(&self, _to: &str, _content: MessageContent) -> anyhow::Result { + anyhow::bail!("GitHub is a read-only connector") + } + + async fn reply( + &self, + _message_id: &str, + _content: MessageContent, + _in_thread: bool, + ) -> anyhow::Result { + anyhow::bail!("GitHub is a read-only connector") + } +} diff --git a/crates/void-github/src/connector/sync.rs b/crates/void-github/src/connector/sync.rs new file mode 100644 index 0000000..baa0725 --- /dev/null +++ b/crates/void-github/src/connector/sync.rs @@ -0,0 +1,372 @@ +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use chrono::{DateTime, Utc}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; +use void_core::db::Database; +use void_core::models::{Conversation, ConversationKind, Message}; + +use crate::api::{repo_full_name_from_url, GhNotification, GitHubClient}; + +const NOTIFICATIONS_CURSOR_KEY: &str = "github_notifications_since"; +const IDLE_THRESHOLD: Duration = Duration::from_secs(3 * 60); + +pub(super) async fn run_sync( + db: &Arc, + connection_id: &str, + token: &str, + poll_interval_secs: u64, + cancel: CancellationToken, +) -> anyhow::Result<()> { + let client = GitHubClient::new(token); + + info!(connection_id, "running initial GitHub sync"); + if let Err(e) = poll_github(&client, db, connection_id, &cancel).await { + error!(connection_id, error = %e, "initial GitHub sync failed"); + } + + let mut interval = tokio::time::interval(Duration::from_secs(poll_interval_secs)); + interval.tick().await; + let mut last_poll = SystemTime::now(); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!(connection_id, "GitHub sync cancelled"); + break; + } + _ = interval.tick() => { + let elapsed = last_poll.elapsed().unwrap_or_default(); + if elapsed > IDLE_THRESHOLD { + warn!( + connection_id, + idle_secs = elapsed.as_secs(), + "GitHub sync was idle, catching up" + ); + void_core::status!( + "[github:{connection_id}] sync idle for {}s, catching up", + elapsed.as_secs(), + ); + } else { + info!(connection_id, "polling GitHub"); + } + if let Err(e) = poll_github(&client, db, connection_id, &cancel).await { + error!(connection_id, error = %e, "GitHub poll error"); + } + last_poll = SystemTime::now(); + } + } + } + Ok(()) +} + +async fn poll_github( + client: &GitHubClient, + db: &Arc, + connection_id: &str, + cancel: &CancellationToken, +) -> anyhow::Result<()> { + if cancel.is_cancelled() { + return Ok(()); + } + + sync_review_requests(client, db, connection_id).await?; + if cancel.is_cancelled() { + return Ok(()); + } + sync_notifications(client, db, connection_id).await?; + Ok(()) +} + +async fn sync_review_requests( + client: &GitHubClient, + db: &Arc, + connection_id: &str, +) -> anyhow::Result<()> { + let prs = client.review_requested_prs().await?; + for pr in prs { + let repo_full_name = repo_full_name_from_url(&pr.repository_url) + .unwrap_or_else(|| "unknown/unknown".to_string()); + let (conv_id, conv_external_id) = + upsert_repo_conversation(db, connection_id, &repo_full_name)?; + + let external_id = format!( + "github_{connection_id}_review_{repo_full_name}_{}", + pr.number + ); + if db.message_exists(connection_id, &external_id)? { + continue; + } + + let body = format!( + "Review requested: {}\n{}\nAuthor: @{}", + pr.title, pr.html_url, pr.user.login + ); + let timestamp = parse_github_timestamp(&pr.updated_at); + + let msg = Message { + id: format!("{connection_id}-review-{}", pr.id), + conversation_id: conv_id, + connection_id: connection_id.to_string(), + connector: "github".to_string(), + external_id: external_id.clone(), + sender: pr.user.login.clone(), + sender_name: Some(pr.user.login.clone()), + sender_avatar_url: None, + body: Some(body.clone()), + timestamp, + synced_at: Some(Utc::now().timestamp()), + is_archived: false, + reply_to_id: None, + media_type: None, + metadata: Some(serde_json::json!({ + "kind": "review_request", + "pr_number": pr.number, + "repo": repo_full_name, + "html_url": pr.html_url, + })), + context_id: Some(conv_external_id), + context: None, + }; + db.upsert_message(&msg)?; + log_new_message( + connection_id, + &repo_full_name, + "review request", + &pr.user.login, + &pr.title, + ); + } + + Ok(()) +} + +async fn sync_notifications( + client: &GitHubClient, + db: &Arc, + connection_id: &str, +) -> anyhow::Result<()> { + let since = db + .get_sync_state(connection_id, NOTIFICATIONS_CURSOR_KEY)? + .filter(|value| !value.is_empty()); + + let notifications = client.notifications(since.as_deref()).await?; + let mut latest_updated: Option = since; + + for notification in notifications { + // Advance the cursor for every notification (even filtered-out ones) so a + // batch that contains only irrelevant notifications still moves `since` + // forward instead of re-fetching the same window every poll. + update_latest_cursor(&mut latest_updated, ¬ification.updated_at); + + if !should_include_notification(¬ification) { + continue; + } + + let external_id = format!("github_{connection_id}_notification_{}", notification.id); + if db.message_exists(connection_id, &external_id)? { + continue; + } + + let repo_full_name = notification + .repository + .as_ref() + .map(|repo| repo.full_name.clone()) + .unwrap_or_else(|| "github/mentions".to_string()); + + let (conv_id, conv_external_id) = + upsert_repo_conversation(db, connection_id, &repo_full_name)?; + + let reason_label = notification.notification_reason_label(); + let subject_url = notification.subject.url.clone().unwrap_or_default(); + let body = if subject_url.is_empty() { + format!( + "{}: {}\nReason: {}", + reason_label, notification.subject.title, notification.reason + ) + } else { + format!( + "{}: {}\nReason: {}\n{}", + reason_label, notification.subject.title, notification.reason, subject_url + ) + }; + + let timestamp = parse_github_timestamp(¬ification.updated_at); + let sender = notification + .repository + .as_ref() + .map(|repo| repo.full_name.clone()) + .unwrap_or_else(|| "github".to_string()); + + let msg = Message { + id: format!("{connection_id}-notification-{}", notification.id), + conversation_id: conv_id, + connection_id: connection_id.to_string(), + connector: "github".to_string(), + external_id: external_id.clone(), + sender: sender.clone(), + sender_name: Some(sender.clone()), + sender_avatar_url: None, + body: Some(body.clone()), + timestamp, + synced_at: Some(Utc::now().timestamp()), + is_archived: false, + reply_to_id: None, + media_type: None, + metadata: Some(serde_json::json!({ + "kind": notification.reason, + "subject_type": notification.subject.subject_type, + "repo": repo_full_name, + "subject_url": notification.subject.url, + })), + context_id: Some(conv_external_id), + context: None, + }; + db.upsert_message(&msg)?; + log_new_message( + connection_id, + &repo_full_name, + reason_label, + &sender, + ¬ification.subject.title, + ); + } + + if let Some(latest) = latest_updated { + db.set_sync_state(connection_id, NOTIFICATIONS_CURSOR_KEY, &latest)?; + } + + Ok(()) +} + +fn should_include_notification(notification: &GhNotification) -> bool { + match notification.reason.as_str() { + "mention" => true, + "author" => notification.subject.subject_type == "PullRequest", + _ => false, + } +} + +impl GhNotification { + fn notification_reason_label(&self) -> &'static str { + match self.reason.as_str() { + "mention" => "Mention", + "author" => "PR comment", + _ => "Notification", + } + } +} + +fn upsert_repo_conversation( + db: &Arc, + connection_id: &str, + repo_full_name: &str, +) -> anyhow::Result<(String, String)> { + let conv_external_id = format!("github_{connection_id}_{repo_full_name}"); + let conv_id = format!("{connection_id}-{repo_full_name}"); + let conv = Conversation { + id: conv_id.clone(), + connection_id: connection_id.to_string(), + connector: "github".to_string(), + external_id: conv_external_id.clone(), + name: Some(repo_full_name.to_string()), + kind: ConversationKind::Channel, + last_message_at: None, + unread_count: 0, + is_muted: false, + metadata: None, + }; + db.upsert_conversation(&conv)?; + Ok((conv_id, conv_external_id)) +} + +fn parse_github_timestamp(value: &str) -> i64 { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|_| Utc::now().timestamp()) +} + +fn update_latest_cursor(latest: &mut Option, candidate: &str) { + match latest { + Some(current) if candidate <= current.as_str() => {} + slot => *slot = Some(candidate.to_string()), + } +} + +fn log_new_message(connection_id: &str, repo: &str, kind: &str, sender: &str, preview: &str) { + let preview = preview.chars().take(80).collect::(); + eprintln!("[github:{connection_id}] (new) {repo} — {kind} — {sender}: {preview}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_notification(reason: &str, subject_type: &str) -> GhNotification { + GhNotification { + id: "1".to_string(), + reason: reason.to_string(), + updated_at: "2024-01-02T12:00:00Z".to_string(), + subject: crate::api::GhSubject { + title: "Test PR".to_string(), + subject_type: subject_type.to_string(), + url: Some("https://api.github.com/repos/o/r/pulls/1".to_string()), + }, + repository: Some(crate::api::GhRepository { + full_name: "owner/repo".to_string(), + }), + } + } + + #[test] + fn includes_mention_notifications() { + let n = make_notification("mention", "Issue"); + assert!(should_include_notification(&n)); + } + + #[test] + fn includes_author_notifications_for_pull_requests_only() { + let pr = make_notification("author", "PullRequest"); + assert!(should_include_notification(&pr)); + + let issue = make_notification("author", "Issue"); + assert!(!should_include_notification(&issue)); + } + + #[test] + fn ignores_other_notification_reasons() { + let n = make_notification("review_requested", "PullRequest"); + assert!(!should_include_notification(&n)); + } + + #[test] + fn update_latest_cursor_keeps_newest_timestamp() { + let mut latest = Some("2024-01-01T00:00:00Z".to_string()); + update_latest_cursor(&mut latest, "2024-01-02T00:00:00Z"); + assert_eq!(latest.as_deref(), Some("2024-01-02T00:00:00Z")); + + update_latest_cursor(&mut latest, "2023-12-31T00:00:00Z"); + assert_eq!(latest.as_deref(), Some("2024-01-02T00:00:00Z")); + } + + #[test] + fn repo_full_name_parsed_for_search_issue() { + use crate::api::GhSearchIssue; + let issue = GhSearchIssue { + id: 1, + number: 7, + title: "Fix".to_string(), + html_url: "https://github.com/o/r/pull/7".to_string(), + repository_url: "https://api.github.com/repos/o/r".to_string(), + user: crate::api::GhSearchUser { + login: "alice".to_string(), + }, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + assert_eq!( + repo_full_name_from_url(&issue.repository_url).as_deref(), + Some("o/r") + ); + } +} diff --git a/crates/void-github/src/lib.rs b/crates/void-github/src/lib.rs new file mode 100644 index 0000000..49278f1 --- /dev/null +++ b/crates/void-github/src/lib.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod connector; diff --git a/docs/configuration.md b/docs/configuration.md index 7d6a1fa..d24391e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,7 @@ calendar_poll_interval_secs = 60 hackernews_poll_interval_secs = 3600 linkedin_poll_interval_secs = 1800 linkedin_backfill_days = 15 +github_poll_interval_secs = 120 [[connections]] id = "whatsapp" @@ -72,6 +73,13 @@ type = "linkedin" api_key = "your-unipile-api-key" dsn = "https://api1.unipile.com:13111" account_id = "your-unipile-account-id" + +[[connections]] +id = "github" +type = "github" +token = "ghp_..." +username = "your-github-handle" +ignore_conversations = ["facebook/react"] ``` ## `[store]` @@ -92,6 +100,7 @@ Polling intervals for connectors that poll (push-based connectors — WhatsApp, | `hackernews_poll_interval_secs` | 3600 | | `linkedin_poll_interval_secs` | 1800 | | `linkedin_backfill_days` | 15 | +| `github_poll_interval_secs` | 120 | ## `[[connections]]` @@ -100,7 +109,7 @@ Each connection is one account on one service. Every connection has: | Field | Required | Description | |-------|----------|-------------| | `id` | yes | Unique name you choose — used by `--connection ` | -| `type` | yes | One of `whatsapp`, `telegram`, `slack`, `gmail`, `calendar`, `hackernews`, `linkedin` | +| `type` | yes | One of `whatsapp`, `telegram`, `slack`, `gmail`, `calendar`, `hackernews`, `googlenews`, `linkedin`, `github` | | `ignore_conversations` | no | List of conversations to auto-mute (see below) | Per-type fields: @@ -114,6 +123,7 @@ Per-type fields: | `calendar` | — | `credentials_file`, `calendar_ids` (default: primary) | | `hackernews` | — | `keywords` (default: `[]`), `min_score` (default: 0) | | `linkedin` | `api_key`, `dsn`, `account_id` (Unipile) | — | +| `github` | `token`, `username` | — | You can declare multiple connections of the same type (two Slack workspaces, several Gmail accounts, …) — give each a distinct `id`. diff --git a/docs/connectors.md b/docs/connectors.md index 3ae3845..d07459b 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -12,6 +12,7 @@ Every connector is added through the same flow: run `void setup`, pick the servi | [LinkedIn](#linkedin-unipile) | Unipile API key | Unipile API polling | | [Hacker News](#hacker-news) | None — public API | HN API polling | | [Google News](#google-news) | None — public RSS | Google News RSS polling | +| [GitHub](#github) | Personal Access Token | GitHub REST API polling | ## WhatsApp @@ -119,6 +120,32 @@ void gn config To follow several editions (e.g. French and US news), add one connection per edition — each is targetable with `--connection `. +## GitHub + +GitHub syncs actionable activity into your inbox (read-only): + +- Open pull requests requesting your review +- Comments on pull requests you authored +- @mentions of your handle + +1. Create a [GitHub Personal Access Token](https://github.com/settings/tokens) with at least the `notifications` scope +2. For private repositories, also grant `repo` (classic PAT) or Pull requests read access (fine-grained PAT) +3. Run `void setup`, select GitHub, and paste the token + +```toml +[[connections]] +id = "github" +type = "github" +token = "ghp_..." +username = "your-github-handle" +``` + +Each repository appears as its own conversation. Mute noisy repos with `void mute owner/repo` or add them to `ignore_conversations`: + +```toml +ignore_conversations = ["facebook/react", "kubernetes"] +``` + ## Multiple accounts Add as many connections as you want, including several of the same type. Target a specific one anywhere with `--connection `: