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
5 changes: 5 additions & 0 deletions .cursor/skills/review-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<connector>/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 <handle>` / `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.
Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/void-hackernews",
"crates/void-googlenews",
"crates/void-linkedin",
"crates/void-github",
]

[workspace.package]
Expand Down Expand Up @@ -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" }
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions crates/void-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
9 changes: 9 additions & 0 deletions crates/void-cli/src/commands/connector_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions crates/void-cli/src/commands/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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");
}
}
8 changes: 8 additions & 0 deletions crates/void-cli/src/commands/setup/config_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}");
}
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/void-cli/src/commands/setup/connection_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -18,6 +18,7 @@ pub(crate) async fn add_connection(cfg: &mut VoidConfig, store_path: &Path) -> a
"Hacker News",
"Google News",
"LinkedIn",
"GitHub",
],
);

Expand All @@ -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(())
Expand Down
66 changes: 66 additions & 0 deletions crates/void-cli/src/commands/setup/github.rs
Original file line number Diff line number Diff line change
@@ -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<usize> = 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 <owner/repo>");
Ok(())
}
1 change: 1 addition & 0 deletions crates/void-cli/src/commands/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions crates/void-cli/src/commands/setup/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand All @@ -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());
Expand Down
12 changes: 10 additions & 2 deletions crates/void-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,17 @@ pub fn parse_connector_type(s: &str) -> Option<ConnectorType> {
"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<Option<String>> {
match raw {
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions crates/void-core/src/config/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +130,10 @@ struct RawConnectionConfig {
#[serde(default)]
account_id: Option<String>,
#[serde(default)]
token: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
ignore_conversations: Option<Vec<String>>,
}

Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -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(),
}
}
}
7 changes: 7 additions & 0 deletions crates/void-core/src/config/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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):
#
Expand Down Expand Up @@ -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()
)
Expand Down
Loading