diff --git a/.claude/skills/rust-doc-comments/SKILL.md b/.claude/skills/rust-doc-comments/SKILL.md new file mode 100644 index 0000000..572ef2b --- /dev/null +++ b/.claude/skills/rust-doc-comments/SKILL.md @@ -0,0 +1,91 @@ +--- +name: rust-doc-comments +description: Audit and fix Rust doc comments to follow rustdoc conventions, ensuring docs.rs compatibility. Use when writing new Rust code, reviewing existing comments, or after refactoring. +allowed-tools: Read, Grep, Glob, Edit, Bash +argument-hint: "[file or directory path]" +--- + +# Rust Doc Comment Conventions + +Audit the target path (default: `src/`) and rewrite all comments to follow rustdoc / docs.rs conventions. + +Target: `$ARGUMENTS` (fallback to `src/` if empty) + +## What to fix + +### 1. Delete style/separator lines + +These are purely decorative and generate no documentation: + +```rust +// BAD +// --------------------------------------------------------------------------- +// Section title +// --------------------------------------------------------------------------- + +// GOOD — just delete them, use doc comments on the item below instead +``` + +### 2. Module-level docs use `//!` + +Every module file (`mod.rs`, `lib.rs`, named modules) should open with `//!`: + +```rust +//! Brief one-line summary of this module's responsibility. +//! +//! Optional longer description with [`links`](crate::path) to related items. +``` + +Keep it concise — 1–3 lines for leaf modules, up to a short paragraph for important ones. + +### 3. Public items use `///` + +All `pub` types, functions, methods, enum variants, and significant fields get `///`: + +```rust +/// Truncates `s` to at most `max_chars` characters, appending `"…"` on overflow. +pub fn truncate(s: &str, max_chars: usize) -> String { ... } +``` + +Rules: +- First line is a **single sentence** summary (shows in module index on docs.rs). +- Use `` `backticks` `` for parameter names, types, and code fragments. +- Use [`intra-doc links`] for cross-references: `[`Event`]`, `[`Event::IssueCreated`]`, `[`crate::event`]`. +- Document panics under `# Panics`, errors under `# Errors`, only when non-obvious. +- Don't doc trivial getters, simple struct fields, or items whose name is already self-explanatory. + +### 4. Internal comments stay as `//` + +Implementation details, inline clarifications, and TODOs remain plain `//`: + +```rust +// Linear sometimes sends state as a flat string +.or_else(|| old_state.as_str()) +``` + +Don't convert these to `///` — they describe *how*, not *what*. + +### 5. No redundant / echo comments + +```rust +// BAD — just repeats the code +/// Creates a new Foo. +pub fn new() -> Foo { ... } + +// GOOD — adds value +/// Initializes with sensible defaults; use [`with_config`](Self::with_config) +/// for custom settings. +pub fn new() -> Foo { ... } + +// ALSO GOOD — obvious constructor, just skip the doc +pub fn new() -> Foo { ... } +``` + +## Execution steps + +1. `Grep` for `// ---` separator lines across the target — delete all of them. +2. Check each `.rs` file for `//!` module docs — add where missing. +3. `Grep` for `pub fn`, `pub struct`, `pub enum`, `pub trait` without a preceding `///` — add doc comments. +4. Verify no `///` on private internals unless genuinely helpful. +5. Run `cargo clippy` — zero warnings. +6. Run `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps` — zero warnings. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0387123..d0471ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,47 @@ name: Rust CI -# Trigger: run on push to main or on pull requests targeting main on: push: - branches: [ "main" ] + branches: [main] + paths: ["**.rs", "Cargo.toml", "Cargo.lock", ".github/workflows/ci.yml"] pull_request: - branches: [ "main" ] + branches: [main] + paths: ["**.rs", "Cargo.toml", "Cargo.lock", ".github/workflows/ci.yml"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true -# Force colored output in cargo logs for easier reading env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: Build and Test + fmt: + name: Format runs-on: ubuntu-latest - steps: - # 1. Check out the repository - - name: Checkout repository - uses: actions/checkout@v4 - - # 2. Install the stable Rust toolchain with fmt and clippy components - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt - - # 3. Cache compiled dependencies to speed up CI (avoids recompiling tokio, reqwest, etc.) - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - - # 4. Verify the project compiles successfully - - name: Check compilation - run: cargo check - - # 5. Enforce code formatting (mirrors local pre-commit rules) - - name: Check format - run: cargo fmt --all -- --check + components: rustfmt + - run: cargo fmt --all -- --check - # 6. Run Clippy lints — warnings are treated as errors - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets --all-features -- -D warnings - # 7. Run all unit and integration tests - - name: Run tests - run: cargo test \ No newline at end of file + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test diff --git a/.gitignore b/.gitignore index e54c5b5..636e0e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # ------------------------------------------------------ # 1. Rust 构建产物 (最重要!这部分通常很大且不需要上传) # ------------------------------------------------------ -/target/ +target/ # ------------------------------------------------------ # 2. 环境变量与密钥 (绝对安全防线) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3a3389..fe3c117 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: - id: cargo-clippy name: cargo clippy description: Run Linter to check code style and potential issues - entry: cargo clippy --all-targets --all-features -- -D warnings + entry: cargo clippy --all-targets -- -D warnings language: system types: [rust] pass_filenames: false \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 90c9b00..53bf37f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,12 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -26,7 +70,6 @@ dependencies = [ "axum-core", "axum-macros", "bytes", - "form_urlencoded", "futures-util", "http", "http-body", @@ -34,7 +77,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -42,13 +85,11 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", - "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -67,7 +108,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -108,6 +148,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" @@ -136,6 +182,26 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -155,6 +221,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -178,13 +253,22 @@ dependencies = [ ] [[package]] -name = "errno" -version = "0.3.14" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "libc", - "windows-sys 0.61.2", + "atomic", + "pear", + "serde", + "uncased", + "version_check", ] [[package]] @@ -202,6 +286,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -209,6 +308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -217,6 +317,40 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.31" @@ -229,8 +363,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -273,6 +412,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -395,6 +540,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -497,6 +666,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "ipnet" version = "2.11.0" @@ -521,56 +696,70 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.182" +name = "jsonwebtoken" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] [[package]] -name = "linear-lark-bridge" +name = "larkstack" version = "0.1.0" dependencies = [ "axum", + "figment", + "getrandom 0.2.17", "hex", "hmac", + "http-body-util", + "octocrab", "reqwest", "serde", "serde_json", "sha2", "tokio", + "tower-service", "tracing", "tracing-subscriber", + "wasm-bindgen", + "web-time", + "worker", ] [[package]] -name = "litemap" -version = "0.8.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "lock_api" -version = "0.4.14" +name = "libc" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "log" @@ -593,6 +782,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -631,6 +826,75 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "octocrab" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b97f949a7cb04608441c2ddb28e15a377e8b5142c2d1835ad2686d434de8558" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "chrono", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tower", + "tower-http", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -638,26 +902,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "pear" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" dependencies = [ - "lock_api", - "parking_lot_core", + "inlinable_string", + "pear_codegen", + "yansi", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "pear_codegen" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", ] [[package]] @@ -666,6 +940,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -687,6 +981,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -705,6 +1005,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quinn" version = "0.11.9" @@ -804,15 +1117,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -936,10 +1240,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "secrecy" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] [[package]] name = "serde" @@ -951,6 +1258,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1034,13 +1352,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "simple_asn1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "errno", - "libc", + "num-bigint", + "num-traits", + "thiserror", + "time", ] [[package]] @@ -1055,6 +1375,27 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.6.2" @@ -1137,6 +1478,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1171,9 +1543,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1200,6 +1570,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1211,6 +1594,7 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -1232,6 +1616,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1252,7 +1637,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1320,6 +1704,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.23" @@ -1342,6 +1735,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1388,9 +1782,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -1401,9 +1795,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -1415,9 +1809,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1425,9 +1819,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -1438,18 +1832,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -1462,6 +1869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] @@ -1474,12 +1882,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1642,12 +2103,76 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "worker" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index ba0e1bb..9a74740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,68 @@ [package] -name = "linear-lark-bridge" +name = "larkstack" +description = "" version = "0.1.0" -edition = "2021" +edition = "2024" +authors = ["S3anJia", "AprilNEA "] +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "larkstack" +path = "src/main.rs" +required-features = ["native"] + +[features] +default = ["native"] +native = [ + "tokio/rt-multi-thread", + "tokio/time", + "tokio/net", + "tokio/macros", + "axum/tokio", + "axum/http1", + "reqwest/rustls-tls", + "dep:figment", + "dep:tracing-subscriber", +] +cf-worker = [ + "dep:worker", + "dep:tower-service", + "dep:getrandom", + "dep:web-time", + "dep:wasm-bindgen", + "dep:http-body-util", +] [dependencies] -axum = { version = "0.8", features = ["macros"] } -tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +# shared +axum = { version = "0.8", default-features = false, features = [ + "json", + "macros", +] } +tokio = { version = "1", default-features = false, features = ["sync"] } +reqwest = { version = "0.12", default-features = false, features = ["json"] } +octocrab = { version = "0.42", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" hmac = "0.12" sha2 = "0.10" hex = "0.4" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } + + +# native only +figment = { version = "0.10", features = ["env"], optional = true } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", +], optional = true } + +# cf-worker only +worker = { version = "0.7", features = ["http"], optional = true } +tower-service = { version = "0.3", optional = true } +getrandom = { version = "0.2", features = ["js"], optional = true } +web-time = { version = "1", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +http-body-util = { version = "0.1", optional = true } diff --git a/LICENSE b/LICENSE-APACHE similarity index 100% rename from LICENSE rename to LICENSE-APACHE diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..d2f268e --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 AprilNEA LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 20a79d8..22d8572 100644 --- a/README.md +++ b/README.md @@ -4,83 +4,59 @@
-

LarkStack-Linear

+

LarkStack-Linear

- A high-performance, type-safe middleware written in Rust that integrates Linear with Lark / Feishu. + Rust middleware that syncs Linear events to Lark / Feishu notifications.
- Built with Axum 0.8 & Tokio for zero-delay workspace integration. + Runs as a native server (Tokio) or a Cloudflare Worker (WASM).

- CI Status - Rust Version - Railway + Rust Version License


-## ✨ Features +## Features -- 📢 **Group Notifications (Phase 1)**: Automatically pushes Interactive Cards to a designated Lark group when Linear Issues are created or updated. Cards are color-coded based on priority. Includes a **500ms DebounceMap** window to coalesce rapid-fire updates and prevent notification spam. +- **Group notifications** — Issue create / update posts an interactive card to a Lark group, color-coded by priority. Rapid-fire updates are debounced into a single message. +- **DM on assign** — Assigning an issue sends a private message to the assignee's Lark account, matched by email. +- **Link previews** — Paste a `linear.app` URL in Lark and it unfurls into a summary card via Linear's GraphQL API. +- **Webhook signature verification** — HMAC-SHA256 for Linear, token verification for Lark. +## Endpoints -- 👤 **Direct Message on Assign (Phase 2)**: Automatically sends a private DM to a team member when an issue is assigned to them. Matches the assignee's Linear email with their Lark account email natively—no manual ID mapping required! -- 🔗 **Rich Link Previews (Phase 3)**: When a user pastes a `linear.app` link in Lark, the bridge handles Lark's `url_verification` challenge, fetches issue details via Linear's GraphQL API, and unfurls the link into a detailed summary card. -- 🛡️ **Secure by Default**: Implements strict HMAC-SHA256 signature verification for Linear webhooks and natively handles Lark's Event callbacks. - -## 🏗️ Architecture & Tech Stack - -Following a robust refactoring, the codebase is highly modularized: -- **Framework**: `axum 0.8` + `tokio` (Async runtime) + `reqwest 0.12`. -- **Handlers**: Clean separation between `POST /webhook` (Linear) and `POST /lark/event` (Lark callbacks). -- **Lark Module**: Decoupled `cards.rs` (pure UI builders) and `bot.rs` (tenant token caching & HTTP client). - -### API Endpoints | Method | Path | Purpose | | :--- | :--- | :--- | | `POST` | `/webhook` | Linear webhook receiver | | `POST` | `/lark/event` | Lark event callback (challenge + link preview) | -| `GET` | `/health` | Health check (returns `"ok"`) | +| `GET` | `/health` | Health check | -## ⚙️ Configuration +## Quick Start -Ensure the following environment variables are set. +```bash +export LINEAR_WEBHOOK_SECRET=your_secret +export LARK_WEBHOOK_URL=https://open.larksuite.com/open-apis/bot/v2/hook/xxx +cargo run +``` -

- Linear API Configuration -
- Configure your Webhook and API Keys in the Linear Workspace Settings. -

+See [Configuration](./docs/configuration.md) for the full environment variable reference. -| Variable | Required | Description | -| :--- | :---: | :--- | -| `LINEAR_WEBHOOK_SECRET` | ✅ | Used for HMAC signature verification. | -| `LINEAR_API_KEY` | For Phase 3 | GraphQL API access for link previews. | -| `LARK_WEBHOOK_URL` | ✅ | Group notification target. | -| `LARK_APP_ID` | For Phase 2 | Bot app ID for tenant token. | -| `LARK_APP_SECRET` | For Phase 2 | Bot app secret. | -| `LARK_VERIFICATION_TOKEN`| For Phase 3 | Lark event callback verification. | -| `PORT` | ❌ | Defaults to `3000`. | +## Deployment -## 🚀 Deployment (Railway) - -Optimized for [Railway](https://railway.app/) using a highly efficient multi-stage `Dockerfile` to keep the image size minimal and deployment times fast. - -

- Railway Variables Configuration -
- Zero-config deployment: Just paste your environment variables into Railway. -

+| Platform | Guide | +| :--- | :--- | +| Railway / Docker | [docs/deploy-railway.md](./docs/deploy-railway.md) | +| Cloudflare Workers | [docs/deploy-cloudflare-workers.md](./docs/deploy-cloudflare-workers.md) | -## 💻 Local Development & Testing +## Local Development -1. **Create a Test Environment**: Set up a private Lark group with a new Custom Bot and create a "Local Debug" webhook in Linear. -2. **Start a Local Tunnel**: Expose your local server using `ngrok http 3000`. -3. **Run the Server**: Use `cargo run` and point your Linear webhook to `https:///webhook`. -4. **Code Quality**: This project uses `prek` (local `fmt`/`clippy` gatekeeper) and strict GitHub Actions (`cargo clippy -- -D warnings`) to enforce code standards. +1. Create a Lark test group with a custom bot. Add a webhook in Linear. +2. `ngrok http 3000` to get a public URL. +3. `cargo run`, point the Linear webhook to `https:///webhook`. -## 📝 License +## License -[MIT License](./LICENSE) \ No newline at end of file +[MIT](./LICENSE) diff --git a/README_zh.md b/README_zh.md index 189af27..1b46d50 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,83 +4,59 @@
-

LarkStack-Linear 🌉

+

LarkStack-Linear

- 基于 Rust 构建的高性能、类型安全的中间件,将 Linear 的动态无缝同步至飞书。 + Rust 中间件,把 Linear 事件同步到飞书通知。
- 由 Axum 0.8 & Tokio 强力驱动,实现毫秒级工作流整合。 + 支持原生服务器 (Tokio) 和 Cloudflare Worker (WASM) 两种部署方式。

- CI Status - Rust Version - Railway + Rust Version License


-## ✨ 核心特性 +## 功能 -- 📢 **群组动态广播 (Phase 1)**:实时监听 Linear Webhook,生成按优先级变色的飞书 Interactive Card(例如:Urgent = 红色,High = 橙色)。底层自带 **500ms 防抖 (DebounceMap)** 机制,完美合并高频更新,拒绝消息轰炸。 +- **群聊通知** — Issue 创建或更新时发一张按优先级配色的卡片到飞书群。连续更新会防抖合并,不刷屏。 +- **指派私聊** — Issue 分配后通过邮箱匹配,自动给指派人发飞书私聊。 +- **链接预览** — 在飞书粘贴 `linear.app` 链接,自动展开成摘要卡片(通过 Linear GraphQL API)。 +- **签名校验** — Linear 用 HMAC-SHA256 验签,飞书用 token 校验。 +## 路由 -- 👤 **精准私聊提醒 (Phase 2)**:当 Issue 被分配时,自动提取邮箱并通过飞书 Bot API 发送私聊。直接进行底层邮箱映射,彻底免除手动维护用户 ID 表的烦恼! -- 🔗 **富文本链接预览 (Phase 3)**:原生处理飞书 `url_verification` 握手挑战。在飞书中粘贴 `linear.app` 链接,系统会自动通过 Linear GraphQL API 抓取详情并展开为精美的预览卡片。 -- 🛡️ **金融级安全校验**:全链路引入 HMAC-SHA256 严密验签,防止任何伪造的 Webhook 请求。 - -## 🏗️ 架构与技术栈 - -经过深度重构,代码库实现了高内聚低耦合的模块化设计: -- **核心框架**:`axum 0.8` + `tokio` (全特性异步运行时) + `reqwest 0.12`。 -- **路由解耦**:完全隔离 `POST /webhook` (处理 Linear 侧) 与 `POST /lark/event` (处理飞书侧) 业务流。 -- **飞书模块化**:纯函数构建器 `cards.rs` 与带有 Token 缓存的异步网络层 `bot.rs` 完美分离。 - -### API 路由节点 | Method | Path | 用途 | | :--- | :--- | :--- | | `POST` | `/webhook` | 接收 Linear Webhook | -| `POST` | `/lark/event` | 接收飞书事件回调 (Challenge 验证 + 链接预览) | -| `GET` | `/health` | 健康检查 (返回 `"ok"`) | +| `POST` | `/lark/event` | 飞书事件回调 (Challenge 验证 + 链接预览) | +| `GET` | `/health` | 健康检查 | -## ⚙️ 环境变量配置 +## 快速开始 -请确保在运行或部署前注入以下环境变量(本地调试可使用 `.env` 文件): +```bash +export LINEAR_WEBHOOK_SECRET=your_secret +export LARK_WEBHOOK_URL=https://open.larksuite.com/open-apis/bot/v2/hook/xxx +cargo run +``` -

- Linear API Configuration -
- 在 Linear 的 Workspace Settings 中配置 Webhook 密钥及个人 API Key。 -

+完整环境变量说明见 [Configuration](./docs/configuration.md)。 -| 变量名 | 是否必填 | 作用描述 | -| :--- | :---: | :--- | -| `LINEAR_WEBHOOK_SECRET` | ✅ | 用于验证 Webhook 的 HMAC 签名 | -| `LINEAR_API_KEY` | Phase 3 需要 | 用于 GraphQL API 获取链接预览详情 | -| `LARK_WEBHOOK_URL` | ✅ | 接收群组通知的机器人 Webhook 地址 | -| `LARK_APP_ID` | Phase 2 需要 | 飞书自建应用的 App ID(获取 Tenant Token) | -| `LARK_APP_SECRET` | Phase 2 需要 | 飞书自建应用的密钥 | -| `LARK_VERIFICATION_TOKEN`| Phase 3 需要 | 用于飞书事件回调的 Challenge 握手验证 | -| `PORT` | ❌ | Axum 监听端口 (默认 `3000`) | +## 部署 -## 🚀 极速部署 (Railway) - -本项目已为 [Railway](https://railway.app/) 深度优化。内置多阶段构建的 `Dockerfile`,确保镜像体积最小化及秒级启动。 - -

- Railway Variables Configuration -
- 极其丝滑的云原生体验:在 Railway 面板一键注入所有环境变量即可上线。 -

+| 平台 | 文档 | +| :--- | :--- | +| Railway / Docker | [docs/deploy-railway.md](./docs/deploy-railway.md) | +| Cloudflare Workers | [docs/deploy-cloudflare-workers.md](./docs/deploy-cloudflare-workers.md) | -## 💻 本地开发与无损调试 +## 本地开发 -1. **构建沙盒环境**:拉一个只有你自己的飞书测试群并添加专属 Bot。在 Linear 中新建一个 "Local Debug" Webhook。 -2. **内网穿透**:使用 `ngrok http 3000` 将本地服务暴露至公网。 -3. **启动服务**:执行 `cargo run`,并将 ngrok 的公网域名填入 Linear 的测试 Webhook 中。 -4. **严苛的代码门禁**:本项目配置了 `prek` 作为本地门禁,并依赖 GitHub Actions 执行严格的 CI 流水线 (`cargo clippy -- -D warnings`),确保每一行提交都符合高标准工程规范。 +1. 建一个飞书测试群,加自定义 Bot。在 Linear 新建 Webhook。 +2. `ngrok http 3000` 拿到公网地址。 +3. `cargo run`,把 ngrok 地址填进 Linear webhook。 -## 📝 开源协议 +## 许可证 -[MIT License](./LICENSE) \ No newline at end of file +[MIT](./LICENSE) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0beddea --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,32 @@ +# Configuration + +Set these environment variables before running. On Railway / Docker, add them in the +platform dashboard. On Cloudflare Workers, use `wrangler.toml` vars and `wrangler secret put`. + +

+ Linear API Configuration +
+ Webhook and API key settings in Linear's workspace settings. +

+ +## Environment Variables + +| Variable | Required | Description | +| :--- | :---: | :--- | +| `LINEAR_WEBHOOK_SECRET` | Yes | HMAC-SHA256 signature verification for Linear webhooks | +| `LARK_WEBHOOK_URL` | Yes | Lark group chat webhook URL | +| `LARK_APP_ID` | Optional | Bot app ID — enables DM-on-assign | +| `LARK_APP_SECRET` | Optional | Bot app secret — pair with `LARK_APP_ID` | +| `LINEAR_API_KEY` | Optional | GraphQL API access — enables link previews | +| `LARK_VERIFICATION_TOKEN` | Optional | Lark event callback verification | +| `PORT` | No | Listen port, defaults to `3000` (ignored on CF Workers) | +| `DEBOUNCE_DELAY_MS` | No | Debounce window in ms, defaults to `5000` | + +## Feature Tiers + +The two required variables give you group notifications. Optional variables unlock +additional features incrementally: + +1. **Base** (`LINEAR_WEBHOOK_SECRET` + `LARK_WEBHOOK_URL`) — group chat cards for issue create / update / comment. +2. **DM on assign** (+ `LARK_APP_ID` + `LARK_APP_SECRET`) — private message to the assignee when an issue is assigned. +3. **Link previews** (+ `LINEAR_API_KEY` + `LARK_VERIFICATION_TOKEN`) — paste a `linear.app` URL in Lark and it unfurls into a summary card. diff --git a/docs/deploy-cloudflare-workers.md b/docs/deploy-cloudflare-workers.md new file mode 100644 index 0000000..bb9d5ae --- /dev/null +++ b/docs/deploy-cloudflare-workers.md @@ -0,0 +1,112 @@ +# Deploy to Cloudflare Workers + +LarkStack supports deploying as a Cloudflare Worker via the `cf-worker` feature flag. +The debounce logic runs on a Durable Object with alarms, so no persistent server is needed. + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 18 +- [wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/) CLI (`npm i -g wrangler`) +- Rust toolchain with `wasm32-unknown-unknown` target: + ```bash + rustup target add wasm32-unknown-unknown + ``` +- [`worker-build`](https://crates.io/crates/worker-build): + ```bash + cargo install worker-build + ``` + +## 1. Configure `wrangler.toml` + +The repo includes a ready-to-use `wrangler.toml`. Edit the `[vars]` section to fill in +non-secret values: + +```toml +[vars] +LARK_WEBHOOK_URL = "https://open.larksuite.com/open-apis/bot/v2/hook/xxx" +DEBOUNCE_DELAY_MS = "5000" +``` + +> `PORT` is ignored on Workers — Cloudflare handles routing. + +## 2. Set Secrets + +Secrets must **not** go in `wrangler.toml`. Use the CLI: + +```bash +wrangler secret put LINEAR_WEBHOOK_SECRET +# paste your Linear webhook signing secret + +# Optional — needed for DM-on-assign (Phase 2): +wrangler secret put LARK_APP_ID +wrangler secret put LARK_APP_SECRET + +# Optional — needed for link previews (Phase 3): +wrangler secret put LINEAR_API_KEY +wrangler secret put LARK_VERIFICATION_TOKEN +``` + +## 3. Build & Deploy + +```bash +wrangler deploy +``` + +On the first deploy, Wrangler runs the build command defined in `wrangler.toml`: + +``` +cargo install worker-build && worker-build --release +``` + +This compiles the crate with `--features cf-worker --target wasm32-unknown-unknown` +and generates the JS shim at `build/worker/shim.mjs`. + +The `[[migrations]]` block in `wrangler.toml` automatically creates the +`DebounceObject` Durable Object class on first deploy. + +## 4. Set Up Webhooks + +After deploying, Wrangler prints your Worker URL (e.g. `https://larkstack..workers.dev`). + +| Service | URL to configure | +| :--- | :--- | +| Linear Webhook | `https://larkstack.xxx.workers.dev/webhook` | +| Lark Event Callback | `https://larkstack.xxx.workers.dev/lark/event` | + +## Local Development + +```bash +wrangler dev +``` + +This starts a local Workers runtime with Durable Object support. +Use `ngrok` to expose it if you need Linear / Lark to reach the local instance. + +## How It Differs from Native (Railway / Docker) + +| | Native (`cargo run`) | Cloudflare Worker | +| :--- | :--- | :--- | +| Runtime | Tokio multi-thread | V8 isolate (single-thread) | +| Debounce | In-memory `DebounceMap` + `tokio::spawn` | Durable Object + alarm | +| Config | Environment variables via `figment` | `wrangler.toml` vars + secrets | +| TLS | rustls | Handled by Cloudflare edge | +| Cold start | N/A (long-running) | ~50 ms (WASM) | + +## Troubleshooting + +**`DEBOUNCER binding not found`** +— The Durable Object binding is missing. Make sure `wrangler.toml` has: +```toml +[durable_objects] +bindings = [{ name = "DEBOUNCER", class_name = "DebounceObject" }] +``` +And the `[[migrations]]` block is present for first deploy. + +**`alarm: no event in storage`** +— The alarm fired but storage was empty. This can happen if a DO instance is +evicted and recreated. It's harmless — the log message is informational. + +**Build fails with missing `wasm32-unknown-unknown`** +```bash +rustup target add wasm32-unknown-unknown +``` diff --git a/docs/deploy-railway.md b/docs/deploy-railway.md new file mode 100644 index 0000000..261bf6a --- /dev/null +++ b/docs/deploy-railway.md @@ -0,0 +1,29 @@ +# Deploy to Railway (Docker) + +The repo includes a multi-stage `Dockerfile` optimized for [Railway](https://railway.app/). + +## Steps + +1. Create a new project on Railway and connect this repository. +2. Add environment variables in the Railway dashboard. See [Configuration](./configuration.md) + for the full list. + +

+ Railway Variables Configuration +
+ Paste your environment variables into Railway and deploy. +

+ +3. Railway auto-detects the `Dockerfile` and builds on push. +4. Set the Linear webhook URL to `https://.up.railway.app/webhook`. +5. Set the Lark event callback URL to `https://.up.railway.app/lark/event`. + +## Manual Docker Build + +```bash +docker build -t larkstack . +docker run -p 3000:3000 \ + -e LINEAR_WEBHOOK_SECRET=your_secret \ + -e LARK_WEBHOOK_URL=https://open.larksuite.com/open-apis/bot/v2/hook/xxx \ + larkstack +``` diff --git a/src/config.rs b/src/config.rs index 277d245..ffe7112 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,337 @@ +use std::collections::HashMap; + +#[cfg(not(feature = "cf-worker"))] +use figment::{Figment, providers::Env}; use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tracing::info; -use crate::{debounce::DebounceMap, lark::LarkBotClient, linear::LinearClient}; +use crate::{sinks::lark::LarkBotClient, sources::linear::client::LinearClient}; -pub struct AppState { +#[cfg(not(feature = "cf-worker"))] +use crate::debounce::DebounceMap; + +#[derive(Debug, Deserialize, Serialize)] +pub struct LinearConfig { + pub webhook_secret: String, + pub api_key: Option, +} + +#[cfg(not(feature = "cf-worker"))] +impl LinearConfig { + pub fn from_env() -> Result> { + Figment::new() + .merge(Env::prefixed("LINEAR_")) + .extract() + .map_err(Box::new) + } +} + +#[cfg(feature = "cf-worker")] +impl LinearConfig { + pub fn from_worker_env(env: &worker::Env) -> Result { + Ok(Self { + webhook_secret: env + .secret("LINEAR_WEBHOOK_SECRET") + .map_err(|e| format!("LINEAR_WEBHOOK_SECRET: {e}"))? + .to_string(), + api_key: env.secret("LINEAR_API_KEY").ok().map(|s| s.to_string()), + }) + } +} + +impl LinearConfig { + pub fn graphql_client(&self, http: &Client) -> Option { + self.api_key.as_ref().map(|key| { + info!("LINEAR_API_KEY set – link preview enabled"); + LinearClient::new(key.clone(), http.clone()) + }) + } +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct LarkConfig { + #[serde(default)] + pub webhook_url: String, + pub target_chat_id: Option, + pub app_id: Option, + pub app_secret: Option, + pub verification_token: Option, +} + +#[cfg(not(feature = "cf-worker"))] +impl LarkConfig { + pub fn from_env() -> Result> { + Figment::new() + .merge(figment::providers::Serialized::defaults(Self::default())) + .merge(Env::prefixed("LARK_")) + .extract() + .map_err(Box::new) + } +} + +#[cfg(feature = "cf-worker")] +impl LarkConfig { + pub fn from_worker_env(env: &worker::Env) -> Result { + Ok(Self { + webhook_url: env + .var("LARK_WEBHOOK_URL") + .map(|v| v.to_string()) + .unwrap_or_default(), + target_chat_id: env.var("LARK_TARGET_CHAT_ID").ok().map(|v| v.to_string()), + app_id: env.var("LARK_APP_ID").ok().map(|v| v.to_string()), + app_secret: env.secret("LARK_APP_SECRET").ok().map(|s| s.to_string()), + verification_token: env + .secret("LARK_VERIFICATION_TOKEN") + .ok() + .map(|s| s.to_string()), + }) + } +} + +impl LarkConfig { + pub fn bot_client(&self, http: &Client) -> Option { + match (&self.app_id, &self.app_secret) { + (Some(id), Some(secret)) => { + info!("lark bot configured – Bot API notifications enabled"); + Some(LarkBotClient::new(id.clone(), secret.clone(), http.clone())) + } + _ => { + info!("LARK_APP_ID/LARK_APP_SECRET not set – Bot API notifications disabled"); + None + } + } + } +} + +fn default_alert_labels() -> Vec { + vec!["bug".into(), "urgent".into(), "p0".into()] +} + +#[derive(Debug)] +pub struct GitHubConfig { pub webhook_secret: String, - pub lark_webhook_url: String, + pub user_map: HashMap, + pub alert_labels: Vec, + pub repo_whitelist: Vec, + pub pat: Option, +} + +#[cfg(not(feature = "cf-worker"))] +impl GitHubConfig { + pub fn from_env() -> Option { + let secret = std::env::var("GITHUB_WEBHOOK_SECRET").ok()?; + + let user_map: HashMap = std::env::var("GITHUB_USER_MAP") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let alert_labels: Vec = std::env::var("GITHUB_ALERT_LABELS") + .ok() + .map(|s| s.split(',').map(|l| l.trim().to_lowercase()).collect()) + .unwrap_or_else(default_alert_labels); + + let repo_whitelist: Vec = std::env::var("GITHUB_REPO_WHITELIST") + .ok() + .map(|s| { + s.split(',') + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()) + .collect() + }) + .unwrap_or_default(); + + let pat = std::env::var("GITHUB_PAT").ok(); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + pat, + }) + } +} + +#[cfg(feature = "cf-worker")] +impl GitHubConfig { + pub fn from_worker_env(env: &worker::Env) -> Option { + let secret = env.secret("GITHUB_WEBHOOK_SECRET").ok()?.to_string(); + + let user_map: HashMap = env + .var("GITHUB_USER_MAP") + .ok() + .and_then(|v| serde_json::from_str(&v.to_string()).ok()) + .unwrap_or_default(); + + let alert_labels: Vec = env + .var("GITHUB_ALERT_LABELS") + .ok() + .map(|v| { + v.to_string() + .split(',') + .map(|l| l.trim().to_lowercase()) + .collect() + }) + .unwrap_or_else(default_alert_labels); + + let repo_whitelist: Vec = env + .var("GITHUB_REPO_WHITELIST") + .ok() + .map(|v| { + v.to_string() + .split(',') + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()) + .collect() + }) + .unwrap_or_default(); + + let pat = env.secret("GITHUB_PAT").ok().map(|s| s.to_string()); + + Some(Self { + webhook_secret: secret, + user_map, + alert_labels, + repo_whitelist, + pat, + }) + } +} + +fn default_port() -> u16 { + 3000 +} + +fn default_debounce() -> u64 { + 5000 +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ServerConfig { + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_debounce")] + pub debounce_delay_ms: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: default_port(), + debounce_delay_ms: default_debounce(), + } + } +} + +#[cfg(not(feature = "cf-worker"))] +impl ServerConfig { + pub fn from_env() -> Result> { + Figment::new() + .merge(figment::providers::Serialized::defaults(Self::default())) + .merge(Env::raw().only(&["PORT", "DEBOUNCE_DELAY_MS"])) + .extract() + .map_err(Box::new) + } +} + +#[cfg(feature = "cf-worker")] +impl ServerConfig { + pub fn from_worker_env(env: &worker::Env) -> Result { + Ok(Self { + port: env + .var("PORT") + .ok() + .and_then(|v| v.to_string().parse().ok()) + .unwrap_or_else(default_port), + debounce_delay_ms: env + .var("DEBOUNCE_DELAY_MS") + .ok() + .and_then(|v| v.to_string().parse().ok()) + .unwrap_or_else(default_debounce), + }) + } +} + +/// Shared application state, wrapped in `Arc` and passed to every handler. +pub struct AppState { + pub linear: LinearConfig, + pub lark: LarkConfig, + pub server: ServerConfig, + pub github: Option, pub http: Client, pub lark_bot: Option, pub linear_client: Option, - pub lark_verification_token: Option, + #[cfg(not(feature = "cf-worker"))] pub update_debounce: DebounceMap, + #[cfg(feature = "cf-worker")] + pub env: worker::Env, +} + +#[cfg(not(feature = "cf-worker"))] +impl AppState { + pub fn from_env() -> Self { + let linear = LinearConfig::from_env().expect("invalid linear config"); + let lark = LarkConfig::from_env().expect("invalid lark config"); + let server = ServerConfig::from_env().expect("invalid server config"); + let github = GitHubConfig::from_env(); + + let http = Client::new(); + let lark_bot = lark.bot_client(&http); + let linear_client = linear.graphql_client(&http); + + if lark.verification_token.is_some() { + info!("LARK_VERIFICATION_TOKEN set – event verification enabled"); + } + if lark.target_chat_id.is_some() { + info!("LARK_TARGET_CHAT_ID set – Bot API group chat enabled"); + } + if let Some(gh) = &github { + info!("GITHUB_WEBHOOK_SECRET set – GitHub webhook source enabled"); + if !gh.repo_whitelist.is_empty() { + info!("GitHub repo whitelist: {:?}", gh.repo_whitelist); + } + if gh.pat.is_some() { + info!("GITHUB_PAT set – outbound GitHub API enabled"); + } + } + info!("debounce delay: {}ms", server.debounce_delay_ms); + + Self { + linear, + lark, + server, + github, + http, + lark_bot, + linear_client, + update_debounce: DebounceMap::new(), + } + } +} + +#[cfg(feature = "cf-worker")] +impl AppState { + pub fn from_worker_env(env: worker::Env) -> Self { + let linear = LinearConfig::from_worker_env(&env).expect("invalid linear config"); + let lark = LarkConfig::from_worker_env(&env).expect("invalid lark config"); + let server = ServerConfig::from_worker_env(&env).expect("invalid server config"); + let github = GitHubConfig::from_worker_env(&env); + + let http = Client::new(); + let lark_bot = lark.bot_client(&http); + let linear_client = linear.graphql_client(&http); + + Self { + linear, + lark, + server, + github, + http, + lark_bot, + linear_client, + env, + } + } } diff --git a/src/debounce.rs b/src/debounce.rs index 641885f..0856c23 100644 --- a/src/debounce.rs +++ b/src/debounce.rs @@ -1,74 +1,81 @@ -use std::collections::HashMap; +//! Coalesces rapid-fire events on the same entity into a single notification. -use tokio::sync::{oneshot, Mutex}; +use std::collections::HashMap; -use crate::models::Issue; +use tokio::sync::{Mutex, oneshot}; -/// How long to wait after the last update before firing the notification. -/// Burst duplicates from Linear arrive within ~100ms of each other; 500ms -/// is comfortably above that while keeping notifications responsive. -pub const DEBOUNCE_MS: u64 = 500; +use crate::event::Event; +/// A pending notification waiting for the debounce window to expire. pub struct PendingUpdate { - /// Latest issue state (replaced on every new update for this issue). - pub issue: Issue, - pub url: String, - /// Accumulated change descriptions from all coalesced updates. - pub changes: Vec, + /// The latest event state (replaced on every new update). + pub event: Event, /// Email to DM if any update in the window changed the assignee. pub dm_email: Option, /// Send on this to cancel the currently-scheduled timer task. cancel_tx: oneshot::Sender<()>, } +/// Thread-safe map of entity keys to their pending debounced updates. pub struct DebounceMap(Mutex>); +impl Default for DebounceMap { + fn default() -> Self { + Self::new() + } +} + impl DebounceMap { pub fn new() -> Self { Self(Mutex::new(HashMap::new())) } - /// Insert or merge an update for the given issue. + /// Inserts or merges an update for `key`. /// - /// If there is already a pending update for this issue, the old timer task - /// is cancelled, the change descriptions are merged (deduplicating exact - /// matches), and the issue state is replaced with the latest. + /// When an entry already exists the old timer is cancelled, change + /// descriptions are merged (deduplicating exact matches), and the event + /// is replaced with the latest state. A create followed by updates stays + /// a create. /// - /// Returns a `oneshot::Receiver` the caller should `select!` against a + /// Returns a [`oneshot::Receiver`] the caller should `select!` against a /// sleep — if it fires, a newer update has taken over. pub async fn upsert( &self, - issue_id: String, - issue: Issue, - url: String, - changes: Vec, + key: String, + event: Event, dm_email: Option, ) -> oneshot::Receiver<()> { let mut map = self.0.lock().await; - let (merged_changes, merged_dm_email) = if let Some(existing) = map.remove(&issue_id) { - // Cancel the old timer task. + let (merged_event, merged_dm_email) = if let Some(existing) = map.remove(&key) { let _ = existing.cancel_tx.send(()); + // Accumulate change descriptions; skip exact duplicates. - let mut all = existing.changes; - for c in &changes { + let mut all: Vec = existing.event.changes().to_vec(); + for c in event.changes() { if !all.contains(c) { all.push(c.clone()); } } - // Prefer the latest DM email if present. - (all, dm_email.or(existing.dm_email)) + + // A create followed by updates is still a "create". + let mut merged = if existing.event.is_issue_created() { + event.promote_to_created() + } else { + event + }; + merged.set_changes(all); + + (merged, dm_email.or(existing.dm_email)) } else { - (changes, dm_email) + (event, dm_email) }; let (cancel_tx, cancel_rx) = oneshot::channel(); map.insert( - issue_id, + key, PendingUpdate { - issue, - url, - changes: merged_changes, + event: merged_event, dm_email: merged_dm_email, cancel_tx, }, @@ -76,8 +83,8 @@ impl DebounceMap { cancel_rx } - /// Remove and return the pending update for the given issue, if any. - pub async fn take(&self, issue_id: &str) -> Option { - self.0.lock().await.remove(issue_id) + /// Removes and returns the pending update for `key`, if any. + pub async fn take(&self, key: &str) -> Option { + self.0.lock().await.remove(key) } } diff --git a/src/debounce_do.rs b/src/debounce_do.rs new file mode 100644 index 0000000..0df5f61 --- /dev/null +++ b/src/debounce_do.rs @@ -0,0 +1,136 @@ +//! Durable Object-based debounce for Cloudflare Workers. +//! +//! Replaces the in-memory [`DebounceMap`](crate::debounce::DebounceMap) with a +//! Durable Object that uses alarms to coalesce rapid-fire events. + +use std::time::Duration; + +use worker::*; + +use crate::event::Event; + +#[durable_object] +pub struct DebounceObject { + state: State, + env: Env, +} + +impl DurableObject for DebounceObject { + fn new(state: State, env: Env) -> Self { + Self { state, env } + } + + async fn fetch(&self, mut req: Request) -> Result { + let body: serde_json::Value = serde_json::from_str( + &req.text() + .await + .map_err(|e| Error::RustError(format!("read body: {e}")))?, + ) + .map_err(|e| Error::RustError(format!("parse json: {e}")))?; + + let event: Event = serde_json::from_value(body["event"].clone()) + .map_err(|e| Error::RustError(format!("parse event: {e}")))?; + let dm_email: Option = + serde_json::from_value(body["dm_email"].clone()).unwrap_or(None); + let delay_ms: u64 = serde_json::from_value(body["delay_ms"].clone()) + .map_err(|e| Error::RustError(format!("parse delay: {e}")))?; + + let storage = self.state.storage(); + + // Merge with existing event if any (same logic as DebounceMap::upsert). + let (merged_event, merged_dm_email) = + if let Some(existing) = storage.get::("event").await? { + let mut all: Vec = existing.changes().to_vec(); + for c in event.changes() { + if !all.contains(c) { + all.push(c.clone()); + } + } + + let mut merged = if existing.is_issue_created() { + event.promote_to_created() + } else { + event + }; + merged.set_changes(all); + + let existing_dm: Option = + storage.get::("dm_email").await.unwrap_or(None); + (merged, dm_email.or(existing_dm)) + } else { + (event, dm_email) + }; + + storage.put("event", &merged_event).await?; + if let Some(ref email) = merged_dm_email { + storage.put("dm_email", email).await?; + } + + // Schedule (or reschedule) the alarm. + storage.set_alarm(Duration::from_millis(delay_ms)).await?; + + Response::ok("scheduled") + } + + async fn alarm(&self) -> Result { + let storage = self.state.storage(); + + let event: Event = storage + .get("event") + .await? + .ok_or_else(|| Error::RustError("alarm: no event in storage".into()))?; + let dm_email: Option = storage.get::("dm_email").await.unwrap_or(None); + + storage.delete_all().await?; + + let http = reqwest::Client::new(); + + // Build the card once, then deliver via Bot API or webhook fallback. + let card = crate::sinks::lark::cards::build_lark_card(&event); + + let app_id = self.env.var("LARK_APP_ID").ok().map(|v| v.to_string()); + let app_secret = self + .env + .secret("LARK_APP_SECRET") + .ok() + .map(|s| s.to_string()); + let target_chat_id = self + .env + .var("LARK_TARGET_CHAT_ID") + .ok() + .map(|v| v.to_string()); + + let bot = match (app_id, app_secret) { + (Some(id), Some(secret)) => Some(crate::sinks::lark::LarkBotClient::new( + id, + secret, + http.clone(), + )), + _ => None, + }; + + match (&bot, &target_chat_id) { + (Some(b), Some(chat_id)) => { + if let Err(e) = b.send_to_chat(chat_id, &card.card).await { + worker::console_error!("failed to send card to chat: {e}"); + } + } + _ => { + let webhook_url = self + .env + .var("LARK_WEBHOOK_URL") + .map(|v| v.to_string()) + .unwrap_or_default(); + if !webhook_url.is_empty() { + crate::sinks::lark::webhook::send_lark_card(&http, &webhook_url, &card).await; + } + } + } + + if let (Some(ref email), Some(ref b)) = (&dm_email, &bot) { + crate::sinks::lark::try_dm(&event, b, email).await; + } + + Response::ok("dispatched") + } +} diff --git a/src/dispatch.rs b/src/dispatch.rs new file mode 100644 index 0000000..65af8c4 --- /dev/null +++ b/src/dispatch.rs @@ -0,0 +1,13 @@ +//! Routes an [`Event`] to every registered sink. + +use crate::{config::AppState, event::Event, sinks}; + +/// Sends `event` to all sinks. If `dm_email` is provided, a direct message +/// is also sent to that address. +pub async fn dispatch(event: &Event, state: &AppState, dm_email: Option<&str>) { + sinks::lark::notify(event, state).await; + + if let (Some(email), Some(bot)) = (dm_email, &state.lark_bot) { + sinks::lark::try_dm(event, bot, email).await; + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..5af19b5 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,228 @@ +//! Unified event model — the middle layer between sources and sinks. +//! +//! Every source converts its platform-specific payload into an [`Event`], +//! which sinks consume without knowing the origin. + +use serde::{Deserialize, Serialize}; + +/// Issue priority level, normalized across all sources. +#[derive(Serialize, Deserialize)] +pub enum Priority { + None, + Urgent, + High, + Medium, + Low, +} + +impl Priority { + /// Convert a Linear numeric priority (`0`–`4`) to [`Priority`]. + pub fn from_linear(value: u8) -> Self { + match value { + 1 => Self::Urgent, + 2 => Self::High, + 3 => Self::Medium, + 4 => Self::Low, + _ => Self::None, + } + } + + /// Human-readable label (e.g. `"Urgent"`). + pub fn label(&self) -> &'static str { + match self { + Self::Urgent => "Urgent", + Self::High => "High", + Self::Medium => "Medium", + Self::Low => "Low", + Self::None => "None", + } + } + + /// Colored circle emoji for display. + pub fn emoji(&self) -> &'static str { + match self { + Self::Urgent => "🔴", + Self::High => "🟠", + Self::Medium => "🟡", + Self::Low => "🔵", + Self::None => "⚪", + } + } + + /// `"{emoji} {label}"` combined string. + pub fn display(&self) -> String { + format!("{} {}", self.emoji(), self.label()) + } +} + +/// Abbreviated commit info for push events. +#[derive(Serialize, Deserialize, Clone)] +pub struct CommitSummary { + pub sha_short: String, + pub message_line: String, + pub author: String, +} + +/// A normalized event produced by a source and consumed by sinks. +#[derive(Serialize, Deserialize)] +pub enum Event { + // --- Linear events --- + IssueCreated { + #[allow(dead_code)] + source: String, + identifier: String, + title: String, + description: Option, + status: String, + priority: Priority, + assignee: Option, + #[allow(dead_code)] + assignee_email: Option, + url: String, + changes: Vec, + }, + IssueUpdated { + #[allow(dead_code)] + source: String, + identifier: String, + title: String, + description: Option, + status: String, + priority: Priority, + assignee: Option, + #[allow(dead_code)] + assignee_email: Option, + url: String, + changes: Vec, + }, + CommentCreated { + #[allow(dead_code)] + source: String, + identifier: String, + issue_title: String, + author: String, + body: String, + url: String, + }, + + // --- GitHub events --- + PrOpened { + repo: String, + number: u64, + title: String, + author: String, + head_branch: String, + base_branch: String, + additions: u64, + deletions: u64, + url: String, + }, + PrReviewRequested { + repo: String, + number: u64, + title: String, + author: String, + reviewer: String, + reviewer_lark_id: Option, + url: String, + }, + PrMerged { + repo: String, + number: u64, + title: String, + author: String, + merged_by: String, + url: String, + }, + IssueLabeledAlert { + repo: String, + number: u64, + title: String, + label: String, + author: String, + url: String, + }, + BranchPush { + repo: String, + branch: String, + pusher: String, + commits: Vec, + compare_url: String, + }, + WorkflowRunFailed { + repo: String, + workflow_name: String, + branch: String, + actor: String, + conclusion: String, + url: String, + }, + SecretScanningAlert { + repo: String, + secret_type: String, + url: String, + }, + DependabotAlert { + repo: String, + package: String, + severity: String, + summary: String, + url: String, + }, +} + +impl Event { + /// Returns the accumulated change descriptions (empty for non-issue events). + pub fn changes(&self) -> &[String] { + match self { + Event::IssueCreated { changes, .. } | Event::IssueUpdated { changes, .. } => changes, + _ => &[], + } + } + + /// Replaces the change descriptions (no-op for non-issue events). + pub fn set_changes(&mut self, new_changes: Vec) { + match self { + Event::IssueCreated { changes, .. } | Event::IssueUpdated { changes, .. } => { + *changes = new_changes; + } + _ => {} + } + } + + /// Returns `true` if this is an [`Event::IssueCreated`]. + pub fn is_issue_created(&self) -> bool { + matches!(self, Event::IssueCreated { .. }) + } + + /// Promotes an [`Event::IssueUpdated`] to [`Event::IssueCreated`], + /// preserving all fields. Other variants are returned unchanged. + pub fn promote_to_created(self) -> Self { + match self { + Event::IssueUpdated { + source, + identifier, + title, + description, + status, + priority, + assignee, + assignee_email, + url, + changes, + } => Event::IssueCreated { + source, + identifier, + title, + description, + status, + priority, + assignee, + assignee_email, + url, + changes, + }, + other => other, + } + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index f0b34b3..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,61 +0,0 @@ -mod lark_event; -mod webhook; - -pub use lark_event::lark_event_handler; -pub use webhook::webhook_handler; - -use tracing::{error, info}; - -use crate::{ - config::AppState, - lark::build_assign_dm_card, - models::{Issue, LarkMessage}, -}; - -// --------------------------------------------------------------------------- -// Health-check -// --------------------------------------------------------------------------- - -pub async fn health() -> &'static str { - "ok" -} - -// --------------------------------------------------------------------------- -// Shared helpers (used by both webhook and lark_event sub-modules) -// --------------------------------------------------------------------------- - -/// Send a card message to the configured Lark group webhook. -async fn send_lark_card(state: &AppState, card: &LarkMessage) { - match state - .http - .post(&state.lark_webhook_url) - .json(card) - .send() - .await - { - Ok(resp) => { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if status.is_success() { - info!("lark notification sent: {text}"); - } else { - error!("lark returned {status}: {text}"); - } - } - Err(e) => { - error!("failed to send lark notification: {e}"); - } - } -} - -/// DM the assignee (graceful degradation — no-op if bot is not configured). -async fn try_dm_assignee(state: &AppState, issue: &Issue, url: &str, email: &str) { - let Some(ref bot) = state.lark_bot else { - return; - }; - - let card = build_assign_dm_card(issue, url); - if let Err(e) = bot.send_dm(email, &card).await { - error!("failed to DM assignee {email}: {e}"); - } -} diff --git a/src/handlers/webhook.rs b/src/handlers/webhook.rs deleted file mode 100644 index 164c5e4..0000000 --- a/src/handlers/webhook.rs +++ /dev/null @@ -1,226 +0,0 @@ -use std::sync::Arc; - -use axum::{ - body::Bytes, - extract::State, - http::{HeaderMap, StatusCode}, -}; -use tracing::{error, info, warn}; - -use crate::{ - config::AppState, - debounce::{PendingUpdate, DEBOUNCE_MS}, - lark::{build_lark_card, CardEvent}, - models::{Actor, CommentData, Issue, LinearPayload, UpdatedFrom}, - utils::{build_change_fields, verify_signature}, -}; - -use super::send_lark_card; - -// --------------------------------------------------------------------------- -// Webhook handler -// --------------------------------------------------------------------------- - -pub async fn webhook_handler( - State(state): State>, - headers: HeaderMap, - body: Bytes, -) -> StatusCode { - // 1. Signature verification - let signature = match headers - .get("linear-signature") - .and_then(|v| v.to_str().ok()) - { - Some(s) => s, - None => { - warn!("missing linear-signature header"); - return StatusCode::UNAUTHORIZED; - } - }; - - if !verify_signature(&state.webhook_secret, &body, signature) { - warn!("invalid webhook signature"); - return StatusCode::UNAUTHORIZED; - } - - // 2. Deserialize payload - let payload: LinearPayload = match serde_json::from_slice(&body) { - Ok(p) => p, - Err(e) => { - error!("failed to parse payload: {e}"); - return StatusCode::BAD_REQUEST; - } - }; - - // 3. Dispatch based on (kind, action) - let card = match (payload.kind.as_str(), payload.action.as_str()) { - ("Issue", "create") => { - let issue: Issue = match serde_json::from_value(payload.data.clone()) { - Ok(i) => i, - Err(e) => { - error!("failed to parse Issue data: {e}"); - return StatusCode::BAD_REQUEST; - } - }; - info!( - "processing Issue create – {} {}", - issue.identifier, issue.title - ); - - let card_msg = build_lark_card(&CardEvent::IssueCreated { - issue: &issue, - url: &payload.url, - }); - - // Phase 2: DM assignee on create if assignee is set - if let Some(ref assignee) = issue.assignee { - if let Some(ref email) = assignee.email { - super::try_dm_assignee(&state, &issue, &payload.url, email).await; - } - } - - card_msg - } - ("Issue", "update") => { - let issue: Issue = match serde_json::from_value(payload.data.clone()) { - Ok(i) => i, - Err(e) => { - error!("failed to parse Issue data: {e}"); - return StatusCode::BAD_REQUEST; - } - }; - - let changes = build_change_fields(&issue, &payload.updated_from); - - info!( - "queuing debounced Issue update – {} {} (changes: {})", - issue.identifier, - issue.title, - if changes.is_empty() { - "none detected".to_string() - } else { - changes.join(", ") - } - ); - - // Resolve DM email if the assignee changed in this update. - let dm_email: Option = payload.updated_from.as_ref().and_then(|uf| { - serde_json::from_value::(uf.clone()) - .ok() - .and_then(|uf| { - if uf.assignee_id.is_some() { - issue.assignee.as_ref().and_then(|a| a.email.clone()) - } else { - None - } - }) - }); - - let issue_id = issue.id.clone(); - - // Merge with any pending update for this issue and (re)start the timer. - let cancel_rx = state - .update_debounce - .upsert( - issue_id.clone(), - issue, - payload.url.clone(), - changes, - dm_email, - ) - .await; - - // Spawn the timer task; whichever fires first wins. - let state2 = Arc::clone(&state); - tokio::spawn(async move { - tokio::select! { - _ = tokio::time::sleep(std::time::Duration::from_millis(DEBOUNCE_MS)) => { - if let Some(p) = state2.update_debounce.take(&issue_id).await { - send_update_notification(&state2, p).await; - } - } - _ = cancel_rx => { - // A newer update cancelled this task; the replacement task will fire. - } - } - }); - - return StatusCode::OK; - } - ("Comment", "create") => { - let comment: CommentData = match serde_json::from_value(payload.data.clone()) { - Ok(c) => c, - Err(e) => { - error!("failed to parse Comment data: {e}"); - return StatusCode::BAD_REQUEST; - } - }; - - // Try to get actor from the top-level payload (Linear sends it sometimes) - let actor: Option = payload - .data - .get("user") - .and_then(|u| serde_json::from_value(u.clone()).ok()); - - let issue_ref = comment - .issue - .as_ref() - .map(|i| i.identifier.as_str()) - .unwrap_or("?"); - info!("processing Comment create on {}", issue_ref); - - build_lark_card(&CardEvent::CommentCreated { - comment: &comment, - url: &payload.url, - actor: actor.as_ref(), - }) - } - _ => { - info!( - "ignoring event: type={}, action={}", - payload.kind, payload.action - ); - return StatusCode::OK; - } - }; - - // 4. Send Lark group card - send_lark_card(&state, &card).await; - StatusCode::OK -} - -// --------------------------------------------------------------------------- -// Debounced update sender -// --------------------------------------------------------------------------- - -async fn send_update_notification(state: &AppState, pending: PendingUpdate) { - let PendingUpdate { - issue, - url, - changes, - dm_email, - .. - } = pending; - - info!( - "sending debounced update for {} – changes: {}", - issue.identifier, - if changes.is_empty() { - "none".to_string() - } else { - changes.join(", ") - } - ); - - let card_msg = build_lark_card(&CardEvent::IssueUpdated { - issue: &issue, - url: &url, - changes, - }); - - send_lark_card(state, &card_msg).await; - - if let Some(ref email) = dm_email { - super::try_dm_assignee(state, &issue, &url, email).await; - } -} diff --git a/src/lark/bot.rs b/src/lark/bot.rs deleted file mode 100644 index daaef9f..0000000 --- a/src/lark/bot.rs +++ /dev/null @@ -1,117 +0,0 @@ -use reqwest::Client; -use serde_json::json; -use tokio::sync::Mutex; -use tracing::info; - -use crate::models::LarkCard; - -// --------------------------------------------------------------------------- -// Phase 2: Lark Bot API client (DM via app) -// --------------------------------------------------------------------------- - -pub struct LarkBotClient { - app_id: String, - app_secret: String, - token: Mutex, - http: Client, -} - -struct CachedToken { - value: String, - expires_at: std::time::Instant, -} - -impl LarkBotClient { - pub fn new(app_id: String, app_secret: String, http: Client) -> Self { - Self { - app_id, - app_secret, - token: Mutex::new(CachedToken { - value: String::new(), - expires_at: std::time::Instant::now(), - }), - http, - } - } - - async fn get_token(&self) -> Result { - let mut cached = self.token.lock().await; - - // Refresh 5 minutes before expiry - if !cached.value.is_empty() - && cached.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(300) - { - return Ok(cached.value.clone()); - } - - let resp = self - .http - .post("https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal") - .json(&json!({ - "app_id": self.app_id, - "app_secret": self.app_secret, - })) - .send() - .await - .map_err(|e| format!("token request failed: {e}"))?; - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("token response parse failed: {e}"))?; - - let code = body.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); - if code != 0 { - return Err(format!("token API error: {body}")); - } - - let token = body - .get("tenant_access_token") - .and_then(|v| v.as_str()) - .ok_or_else(|| "missing tenant_access_token in response".to_string())? - .to_string(); - - let expire = body.get("expire").and_then(|v| v.as_u64()).unwrap_or(7200); - - cached.value = token.clone(); - cached.expires_at = std::time::Instant::now() + std::time::Duration::from_secs(expire); - - info!("refreshed lark bot tenant access token (expires in {expire}s)"); - Ok(token) - } - - pub async fn send_dm(&self, email: &str, card: &LarkCard) -> Result<(), String> { - let token = self.get_token().await?; - - let payload = json!({ - "receive_id": email, - "msg_type": "interactive", - "content": serde_json::to_string(card).unwrap_or_default(), - }); - - let resp = self - .http - .post("https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=email") - .header("Authorization", format!("Bearer {token}")) - .json(&payload) - .send() - .await - .map_err(|e| format!("DM request failed: {e}"))?; - - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - - if status.is_success() { - let parsed: serde_json::Value = - serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); - let code = parsed.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); - if code != 0 { - return Err(format!("DM API returned code {code}: {body}")); - } - info!("DM sent to {email}"); - Ok(()) - } else { - Err(format!("DM request returned {status}: {body}")) - } - } -} diff --git a/src/lark/cards.rs b/src/lark/cards.rs deleted file mode 100644 index 9d6f36b..0000000 --- a/src/lark/cards.rs +++ /dev/null @@ -1,298 +0,0 @@ -use serde_json::{json, Value}; - -use crate::{ - models::{Actor, CommentData, Issue, LarkCard, LarkHeader, LarkMessage, LarkTitle}, - utils::{priority_color, priority_display, truncate}, -}; - -// --------------------------------------------------------------------------- -// Shared card-element helpers (deduplicated) -// --------------------------------------------------------------------------- - -/// Build the standard "Status / Priority / Assignee" fields block. -pub fn build_fields(status: &str, priority: &str, assignee: Option<&str>) -> Value { - let assignee = assignee.unwrap_or("Unassigned"); - let mut fields = vec![ - json!({ - "is_short": true, - "text": { - "tag": "lark_md", - "content": format!("**Status:** {status}"), - } - }), - json!({ - "is_short": true, - "text": { - "tag": "lark_md", - "content": format!("**Priority:** {priority}"), - } - }), - json!({ - "is_short": true, - "text": { - "tag": "lark_md", - "content": format!("**Assignee:** {assignee}"), - } - }), - ]; - // Drop the assignee field when it was explicitly omitted (None) - // — caller can pass Some("Unassigned") to keep it visible. - if assignee == "Unassigned" && fields.len() == 3 { - // keep it; we always show all three for consistency - } - let _ = &mut fields; // suppress unused-mut if branch is empty - json!({ "tag": "div", "fields": fields }) -} - -/// Build a standard "View in Linear" action-button element. -pub fn build_action_button(url: &str) -> Value { - json!({ - "tag": "action", - "actions": [{ - "tag": "button", - "text": { "tag": "plain_text", "content": "View in Linear" }, - "type": "primary", - "url": url, - }] - }) -} - -// --------------------------------------------------------------------------- -// Card event enum & card builder -// --------------------------------------------------------------------------- - -pub enum CardEvent<'a> { - IssueCreated { - issue: &'a Issue, - url: &'a str, - }, - IssueUpdated { - issue: &'a Issue, - url: &'a str, - changes: Vec, - }, - CommentCreated { - comment: &'a CommentData, - url: &'a str, - actor: Option<&'a Actor>, - }, -} - -pub fn build_lark_card(event: &CardEvent) -> LarkMessage { - match event { - CardEvent::IssueCreated { issue, url } => build_issue_created_card(issue, url), - CardEvent::IssueUpdated { - issue, - url, - changes, - } => build_issue_updated_card(issue, url, changes), - CardEvent::CommentCreated { - comment, - url, - actor, - } => build_comment_created_card(comment, url, *actor), - } -} - -fn build_issue_created_card(issue: &Issue, url: &str) -> LarkMessage { - let color = priority_color(issue.priority); - let assignee_name = issue - .assignee - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("Unassigned"); - - let mut elements = vec![]; - - // Title - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}**", issue.title), - } - })); - - // Description summary (truncated ~200 chars) - if let Some(desc) = &issue.description { - let trimmed = desc.trim(); - if !trimmed.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": truncate(trimmed, 200), - } - })); - } - } - - elements.push(build_fields( - &issue.state.name, - &priority_display(issue.priority), - Some(assignee_name), - )); - elements.push(build_action_button(url)); - - LarkMessage { - msg_type: "interactive", - card: LarkCard { - header: LarkHeader { - template: color.to_string(), - title: LarkTitle { - content: format!("[Linear] Created: {}", issue.identifier), - tag: "plain_text", - }, - }, - elements, - }, - } -} - -fn build_issue_updated_card(issue: &Issue, url: &str, changes: &[String]) -> LarkMessage { - let color = priority_color(issue.priority); - let assignee_name = issue - .assignee - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("Unassigned"); - - let mut elements = vec![]; - - // Title - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}**", issue.title), - } - })); - - // Change lines - if !changes.is_empty() { - let change_text = changes.join("\n"); - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": change_text, - } - })); - } - - elements.push(build_fields( - &issue.state.name, - &priority_display(issue.priority), - Some(assignee_name), - )); - elements.push(build_action_button(url)); - - LarkMessage { - msg_type: "interactive", - card: LarkCard { - header: LarkHeader { - template: color.to_string(), - title: LarkTitle { - content: format!("[Linear] Updated: {}", issue.identifier), - tag: "plain_text", - }, - }, - elements, - }, - } -} - -fn build_comment_created_card( - comment: &CommentData, - url: &str, - actor: Option<&Actor>, -) -> LarkMessage { - let commenter = actor.map(|a| a.name.as_str()).unwrap_or("Someone"); - let issue_ref = comment - .issue - .as_ref() - .map(|i| format!("{}: {}", i.identifier, i.title)) - .unwrap_or_else(|| "an issue".to_string()); - - let mut elements = vec![]; - - // Who commented on what - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}** commented on **{}**", commenter, issue_ref), - } - })); - - // Truncated comment body - let body = truncate(comment.body.trim(), 200); - if !body.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": body, - } - })); - } - - elements.push(build_action_button(url)); - - let identifier = comment - .issue - .as_ref() - .map(|i| i.identifier.as_str()) - .unwrap_or("?"); - - LarkMessage { - msg_type: "interactive", - card: LarkCard { - header: LarkHeader { - template: "blue".to_string(), - title: LarkTitle { - content: format!("[Linear] Comment: {}", identifier), - tag: "plain_text", - }, - }, - elements, - }, - } -} - -// --------------------------------------------------------------------------- -// Build DM card for assignee notification (Phase 2) -// --------------------------------------------------------------------------- - -pub fn build_assign_dm_card(issue: &Issue, url: &str) -> LarkCard { - let mut elements = vec![]; - - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!( - "You've been assigned to **{}**\n{}", - issue.identifier, issue.title - ), - } - })); - - elements.push(build_fields( - &issue.state.name, - &priority_display(issue.priority), - None, - )); - elements.push(build_action_button(url)); - - LarkCard { - header: LarkHeader { - template: priority_color(issue.priority).to_string(), - title: LarkTitle { - content: format!("[Linear] Assigned: {}", issue.identifier), - tag: "plain_text", - }, - }, - elements, - } -} diff --git a/src/lark/mod.rs b/src/lark/mod.rs deleted file mode 100644 index 2bfb873..0000000 --- a/src/lark/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod bot; -pub mod cards; - -pub use bot::LarkBotClient; -pub use cards::{build_assign_dm_card, build_lark_card, CardEvent}; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5caad0a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,80 @@ +#[cfg(all(feature = "native", feature = "cf-worker"))] +compile_error!("features `native` and `cf-worker` are mutually exclusive"); + +pub mod config; +pub mod dispatch; +pub mod event; +pub mod sinks; +pub mod sources; +pub mod utils; + +#[cfg(not(feature = "cf-worker"))] +pub mod debounce; + +#[cfg(feature = "cf-worker")] +pub mod debounce_do; + +#[cfg(feature = "cf-worker")] +mod cf_entry { + use std::sync::Arc; + + use axum::http::StatusCode; + use worker::*; + + use crate::config::AppState; + + #[event(fetch)] + async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result { + let state = Arc::new(AppState::from_worker_env(env)); + + let (parts, body) = req.into_parts(); + let body_bytes = read_body(body).await?; + + match (parts.method.as_str(), parts.uri.path()) { + ("POST", "/webhook") => { + let status = crate::sources::linear::webhook_handler( + axum::extract::State(state), + parts.headers, + body_bytes, + ) + .await; + text_response(status, "") + } + ("POST", "/github/webhook") => { + let status = crate::sources::github::webhook_handler( + axum::extract::State(state), + parts.headers, + body_bytes, + ) + .await; + text_response(status, "") + } + ("POST", "/lark/event") => { + let (status, axum::Json(json)) = + crate::sinks::lark::lark_event_handler(axum::extract::State(state), body_bytes) + .await; + json_response(status, &json) + } + ("GET", "/health") => text_response(StatusCode::OK, "ok"), + _ => text_response(StatusCode::NOT_FOUND, "not found"), + } + } + + async fn read_body(body: Body) -> Result { + use http_body_util::BodyExt; + body.collect() + .await + .map(|c| c.to_bytes()) + .map_err(|e| Error::RustError(format!("read body: {e}"))) + } + + fn text_response(status: StatusCode, text: &str) -> Result { + Response::ok(text)?.with_status(status.as_u16()).try_into() + } + + fn json_response(status: StatusCode, json: &serde_json::Value) -> Result { + Response::from_json(json)? + .with_status(status.as_u16()) + .try_into() + } +} diff --git a/src/main.rs b/src/main.rs index 45e2158..b15f8d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,16 @@ -mod config; -mod debounce; -mod handlers; -mod lark; -mod linear; -mod models; -mod utils; - -use std::{env, sync::Arc}; +use std::sync::Arc; use axum::{ - routing::{get, post}, Router, + routing::{get, post}, }; -use reqwest::Client; -use tracing::{info, warn}; +use tracing::info; -use config::AppState; -use debounce::DebounceMap; -use handlers::{health, lark_event_handler, webhook_handler}; -use lark::LarkBotClient; -use linear::LinearClient; +use larkstack::config::AppState; + +async fn health() -> &'static str { + "ok" +} #[tokio::main] async fn main() { @@ -29,52 +20,25 @@ async fn main() { ) .init(); - let webhook_secret = - env::var("LINEAR_WEBHOOK_SECRET").expect("LINEAR_WEBHOOK_SECRET must be set"); - let lark_webhook_url = env::var("LARK_WEBHOOK_URL").unwrap_or_else(|_| { - warn!("LARK_WEBHOOK_URL not set – lark notifications will fail"); - String::new() - }); - let port = env::var("PORT").unwrap_or_else(|_| "3000".into()); - - let lark_bot = match (env::var("LARK_APP_ID"), env::var("LARK_APP_SECRET")) { - (Ok(app_id), Ok(app_secret)) => { - info!("lark bot configured – DM notifications enabled"); - Some(LarkBotClient::new(app_id, app_secret, Client::new())) - } - _ => { - info!("LARK_APP_ID/LARK_APP_SECRET not set – DM notifications disabled"); - None - } - }; - - let linear_client = env::var("LINEAR_API_KEY").ok().map(|api_key| { - info!("LINEAR_API_KEY set – link preview enabled"); - LinearClient::new(api_key, Client::new()) - }); - - let lark_verification_token = env::var("LARK_VERIFICATION_TOKEN").ok(); - if lark_verification_token.is_some() { - info!("LARK_VERIFICATION_TOKEN set – event verification enabled"); - } - - let state = Arc::new(AppState { - webhook_secret, - lark_webhook_url, - http: Client::new(), - lark_bot, - linear_client, - lark_verification_token, - update_debounce: DebounceMap::new(), - }); + let state = Arc::new(AppState::from_env()); + let addr = format!("0.0.0.0:{}", state.server.port); let app = Router::new() - .route("/webhook", post(webhook_handler)) - .route("/lark/event", post(lark_event_handler)) + .route( + "/webhook", + post(larkstack::sources::linear::webhook_handler), + ) + .route( + "/github/webhook", + post(larkstack::sources::github::webhook_handler), + ) + .route( + "/lark/event", + post(larkstack::sinks::lark::lark_event_handler), + ) .route("/health", get(health)) .with_state(state); - let addr = format!("0.0.0.0:{port}"); info!("listening on {addr}"); let listener = tokio::net::TcpListener::bind(&addr) diff --git a/src/sinks/lark/bot.rs b/src/sinks/lark/bot.rs new file mode 100644 index 0000000..8c8062d --- /dev/null +++ b/src/sinks/lark/bot.rs @@ -0,0 +1,182 @@ +//! Lark Bot API client for sending direct messages via tenant access token. + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use tracing::info; + +#[cfg(not(feature = "cf-worker"))] +use std::time::Instant; +#[cfg(feature = "cf-worker")] +use web_time::Instant; + +use super::models::LarkCard; + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct TokenRequest<'a> { + app_id: &'a str, + app_secret: &'a str, +} + +#[derive(Deserialize)] +struct TokenResponse { + code: i64, + tenant_access_token: Option, + #[serde(default = "default_expire")] + expire: u64, +} + +fn default_expire() -> u64 { + 7200 +} + +#[derive(Serialize)] +struct SendMessagePayload { + receive_id: String, + msg_type: &'static str, + content: String, +} + +#[derive(Deserialize)] +struct LarkApiResponse { + code: i64, +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// Authenticated Lark bot that can send interactive-card DMs. +pub struct LarkBotClient { + app_id: String, + app_secret: String, + token: Mutex, + http: Client, +} + +struct CachedToken { + value: String, + expires_at: Instant, +} + +impl LarkBotClient { + pub fn new(app_id: String, app_secret: String, http: Client) -> Self { + Self { + app_id, + app_secret, + token: Mutex::new(CachedToken { + value: String::new(), + expires_at: Instant::now(), + }), + http, + } + } + + /// Returns a valid tenant access token, refreshing it when necessary. + async fn get_token(&self) -> Result { + let mut cached = self.token.lock().await; + + if !cached.value.is_empty() + && cached.expires_at > Instant::now() + std::time::Duration::from_secs(300) + { + return Ok(cached.value.clone()); + } + + let req = TokenRequest { + app_id: &self.app_id, + app_secret: &self.app_secret, + }; + + let resp = self + .http + .post("https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal") + .json(&req) + .send() + .await + .map_err(|e| format!("token request failed: {e}"))?; + + let token_resp: TokenResponse = resp + .json() + .await + .map_err(|e| format!("token response parse failed: {e}"))?; + + if token_resp.code != 0 { + return Err(format!("token API error code {}", token_resp.code)); + } + + let token = token_resp + .tenant_access_token + .ok_or_else(|| "missing tenant_access_token in response".to_string())?; + + cached.value = token.clone(); + cached.expires_at = Instant::now() + std::time::Duration::from_secs(token_resp.expire); + + info!( + "refreshed lark bot tenant access token (expires in {}s)", + token_resp.expire + ); + Ok(token) + } + + /// Sends an interactive card message and checks the response code. + async fn send_card(&self, url: &str, receive_id: &str, card: &LarkCard) -> Result<(), String> { + let token = self.get_token().await?; + + let payload = SendMessagePayload { + receive_id: receive_id.to_string(), + msg_type: "interactive", + content: serde_json::to_string(card).unwrap_or_default(), + }; + + let resp = self + .http + .post(url) + .header("Authorization", format!("Bearer {token}")) + .json(&payload) + .send() + .await + .map_err(|e| format!("send_card request failed: {e}"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + if !status.is_success() { + return Err(format!("send_card returned {status}: {body}")); + } + + let api_resp: LarkApiResponse = + serde_json::from_str(&body).map_err(|e| format!("response parse failed: {e}"))?; + + if api_resp.code != 0 { + return Err(format!("Lark API returned code {}: {body}", api_resp.code)); + } + + Ok(()) + } + + /// Sends an interactive card to a user identified by `email`. + pub async fn send_dm(&self, email: &str, card: &LarkCard) -> Result<(), String> { + self.send_card( + "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=email", + email, + card, + ) + .await + .inspect(|()| info!("DM sent to {email}")) + } + + /// Sends an interactive card to a group chat identified by `chat_id`. + pub async fn send_to_chat(&self, chat_id: &str, card: &LarkCard) -> Result<(), String> { + self.send_card( + "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=chat_id", + chat_id, + card, + ) + .await + .inspect(|()| info!("card sent to chat {chat_id}")) + } +} diff --git a/src/sinks/lark/cards.rs b/src/sinks/lark/cards.rs new file mode 100644 index 0000000..272c7e7 --- /dev/null +++ b/src/sinks/lark/cards.rs @@ -0,0 +1,592 @@ +//! Converts [`Event`]s and [`LinearIssueData`] into Lark interactive cards. + +use serde_json::{Value, json}; + +use crate::{ + event::{Event, Priority}, + sources::linear::models::LinearIssueData, + utils::truncate, +}; + +use super::models::{LarkCard, LarkHeader, LarkMessage, LarkTitle}; + +/// Returns the Lark header color template for a given priority. +fn priority_color(priority: &Priority) -> &'static str { + match priority { + Priority::Urgent => "red", + Priority::High => "orange", + Priority::Medium => "yellow", + _ => "blue", + } +} + +/// Builds a "Status / Priority / Assignee" fields block. +fn build_fields(status: &str, priority: &str, assignee: Option<&str>) -> Value { + let assignee = assignee.unwrap_or("Unassigned"); + let fields = vec![ + json!({ + "is_short": true, + "text": { + "tag": "lark_md", + "content": format!("**Status:** {status}"), + } + }), + json!({ + "is_short": true, + "text": { + "tag": "lark_md", + "content": format!("**Priority:** {priority}"), + } + }), + json!({ + "is_short": true, + "text": { + "tag": "lark_md", + "content": format!("**Assignee:** {assignee}"), + } + }), + ]; + json!({ "tag": "div", "fields": fields }) +} + +/// Builds a "View in Linear" action button element. +fn build_action_button(url: &str) -> Value { + build_link_button(url, "View in Linear") +} + +fn build_link_button(url: &str, label: &str) -> Value { + json!({ + "tag": "action", + "actions": [{ + "tag": "button", + "text": { "tag": "plain_text", "content": label }, + "type": "primary", + "url": url, + }] + }) +} + +fn md_div(content: &str) -> Value { + json!({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": content, + } + }) +} + +fn build_card(color: &str, header_text: String, elements: Vec) -> LarkMessage { + LarkMessage { + msg_type: "interactive", + card: LarkCard { + header: LarkHeader { + template: color.to_string(), + title: LarkTitle { + content: header_text, + tag: "plain_text", + }, + }, + elements, + }, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Formats an [`Event`] as a [`LarkMessage`] for group webhook delivery. +pub fn build_lark_card(event: &Event) -> LarkMessage { + match event { + Event::IssueCreated { + identifier, + title, + description, + status, + priority, + assignee, + url, + changes, + .. + } => build_issue_card( + "Created", + identifier, + title, + description.as_deref(), + status, + priority, + assignee.as_deref(), + url, + changes, + ), + Event::IssueUpdated { + identifier, + title, + status, + priority, + assignee, + url, + changes, + .. + } => build_issue_card( + "Updated", + identifier, + title, + None, + status, + priority, + assignee.as_deref(), + url, + changes, + ), + Event::CommentCreated { + identifier, + issue_title, + author, + body, + url, + .. + } => build_comment_card(identifier, issue_title, author, body, url), + + // --- GitHub events --- + Event::PrOpened { + repo, + number, + title, + author, + head_branch, + base_branch, + additions, + deletions, + url, + } => build_pr_opened_card( + repo, + *number, + title, + author, + head_branch, + base_branch, + *additions, + *deletions, + url, + ), + Event::PrReviewRequested { + repo, + number, + title, + author, + reviewer, + reviewer_lark_id, + url, + } => build_pr_review_requested_card( + repo, + *number, + title, + author, + reviewer, + reviewer_lark_id.as_deref(), + url, + ), + Event::PrMerged { + repo, + number, + title, + author, + merged_by, + url, + } => build_pr_merged_card(repo, *number, title, author, merged_by, url), + Event::IssueLabeledAlert { + repo, + number, + title, + label, + author, + url, + } => build_issue_labeled_card(repo, *number, title, label, author, url), + Event::BranchPush { + repo, + branch, + pusher, + commits, + compare_url, + } => build_branch_push_card(repo, branch, pusher, commits, compare_url), + Event::WorkflowRunFailed { + repo, + workflow_name, + branch, + actor, + url, + .. + } => build_workflow_failed_card(repo, workflow_name, branch, actor, url), + Event::SecretScanningAlert { + repo, + secret_type, + url, + } => build_secret_scanning_card(repo, secret_type, url), + Event::DependabotAlert { + repo, + package, + severity, + summary, + url, + } => build_dependabot_card(repo, package, severity, summary, url), + } +} + +/// Builds a DM card for assignment or review-request notifications. +/// +/// Returns `None` for event types that do not support DM notifications. +pub fn build_assign_dm_card(event: &Event) -> Option { + match event { + Event::IssueCreated { + identifier, + title, + status, + priority, + url, + .. + } + | Event::IssueUpdated { + identifier, + title, + status, + priority, + url, + .. + } => { + let mut elements = vec![]; + elements.push(md_div(&format!( + "You've been assigned to **{}**\n{}", + identifier, title + ))); + elements.push(build_fields(status, &priority.display(), None)); + elements.push(build_action_button(url)); + + Some(LarkCard { + header: LarkHeader { + template: priority_color(priority).to_string(), + title: LarkTitle { + content: format!("[Linear] Assigned: {identifier}"), + tag: "plain_text", + }, + }, + elements, + }) + } + Event::PrReviewRequested { + repo, + number, + title, + author, + url, + .. + } => { + let mut elements = vec![]; + elements.push(md_div(&format!( + "**{author}** requested your review on **#{number}**\n{title}" + ))); + elements.push(md_div(&format!("**Repository:** {repo}"))); + elements.push(build_link_button(url, "View on GitHub")); + + Some(LarkCard { + header: LarkHeader { + template: "yellow".to_string(), + title: LarkTitle { + content: format!("[{repo}] Review Requested #{number}"), + tag: "plain_text", + }, + }, + elements, + }) + } + _ => None, + } +} + +/// Builds an inline preview card from GraphQL-fetched issue data. +/// +/// This is used for Lark link unfurling and does **not** go through [`Event`]. +pub fn build_preview_card(issue: &LinearIssueData) -> LarkCard { + let priority = Priority::from_linear(issue.priority); + let color = priority_color(&priority); + let assignee = issue + .assignee + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("Unassigned"); + + let mut elements = vec![]; + + elements.push(md_div(&format!("**{}**", issue.title))); + + if let Some(desc) = &issue.description { + let trimmed = desc.trim(); + if !trimmed.is_empty() { + elements.push(md_div(&truncate(trimmed, 200))); + } + } + + elements.push(build_fields( + &issue.state.name, + &priority.display(), + Some(assignee), + )); + elements.push(build_action_button(&issue.url)); + + LarkCard { + header: LarkHeader { + template: color.to_string(), + title: LarkTitle { + content: format!("[Linear] {}", issue.identifier), + tag: "plain_text", + }, + }, + elements, + } +} + +// --------------------------------------------------------------------------- +// Linear card builders (private) +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_issue_card( + action: &str, + identifier: &str, + title: &str, + description: Option<&str>, + status: &str, + priority: &Priority, + assignee: Option<&str>, + url: &str, + changes: &[String], +) -> LarkMessage { + let color = priority_color(priority); + let assignee_name = assignee.unwrap_or("Unassigned"); + + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + + if let Some(desc) = description { + let trimmed = desc.trim(); + if !trimmed.is_empty() { + elements.push(md_div(&truncate(trimmed, 200))); + } + } + + if !changes.is_empty() { + elements.push(md_div(&changes.join("\n"))); + } + + elements.push(build_fields( + status, + &priority.display(), + Some(assignee_name), + )); + elements.push(build_action_button(url)); + + build_card(color, format!("[Linear] {action}: {identifier}"), elements) +} + +fn build_comment_card( + identifier: &str, + issue_title: &str, + author: &str, + body: &str, + url: &str, +) -> LarkMessage { + let issue_ref = if issue_title.is_empty() { + "an issue".to_string() + } else { + format!("{identifier}: {issue_title}") + }; + + let mut elements = vec![]; + + elements.push(md_div(&format!( + "**{author}** commented on **{issue_ref}**" + ))); + + let body = truncate(body.trim(), 200); + if !body.is_empty() { + elements.push(md_div(&body)); + } + + elements.push(build_action_button(url)); + + build_card("blue", format!("[Linear] Comment: {identifier}"), elements) +} + +// --------------------------------------------------------------------------- +// GitHub card builders (private) +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_pr_opened_card( + repo: &str, + number: u64, + title: &str, + author: &str, + head_branch: &str, + base_branch: &str, + additions: u64, + deletions: u64, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Branch:** `{head_branch}` → `{base_branch}`\n**Changes:** +{additions} / -{deletions}" + ))); + elements.push(md_div(&format!("**Author:** {author}"))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card("purple", format!("[{repo}] PR Opened #{number}"), elements) +} + +fn build_pr_review_requested_card( + repo: &str, + number: u64, + title: &str, + author: &str, + reviewer: &str, + reviewer_lark_id: Option<&str>, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + + let reviewer_display = match reviewer_lark_id { + Some(email) => format!(""), + None => reviewer.to_string(), + }; + elements.push(md_div(&format!( + "**Reviewer:** {reviewer_display}\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card( + "yellow", + format!("[{repo}] Review Requested #{number}"), + elements, + ) +} + +fn build_pr_merged_card( + repo: &str, + number: u64, + title: &str, + author: &str, + merged_by: &str, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Merged by:** {merged_by}\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card("green", format!("[{repo}] PR Merged #{number}"), elements) +} + +fn build_issue_labeled_card( + repo: &str, + number: u64, + title: &str, + label: &str, + author: &str, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**{title}**"))); + elements.push(md_div(&format!( + "**Label:** `{label}`\n**Author:** {author}" + ))); + elements.push(build_link_button(url, "View on GitHub")); + + build_card("red", format!("[{repo}] Issue Alert #{number}"), elements) +} + +fn build_branch_push_card( + repo: &str, + branch: &str, + pusher: &str, + commits: &[crate::event::CommitSummary], + compare_url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**Pushed by:** {pusher}"))); + + if !commits.is_empty() { + let commit_lines: Vec = commits + .iter() + .map(|c| format!("`{}` {} — {}", c.sha_short, c.message_line, c.author)) + .collect(); + elements.push(md_div(&commit_lines.join("\n"))); + } + + elements.push(build_link_button(compare_url, "Compare Changes")); + + build_card("blue", format!("[{repo}] Push to {branch}"), elements) +} + +fn build_workflow_failed_card( + repo: &str, + workflow_name: &str, + branch: &str, + actor: &str, + url: &str, +) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!("**Workflow:** {workflow_name}"))); + elements.push(md_div(&format!( + "**Branch:** `{branch}`\n**Triggered by:** {actor}" + ))); + elements.push(build_link_button(url, "View Workflow Run")); + + build_card("red", format!("[{repo}] CI Failed"), elements) +} + +fn build_secret_scanning_card(repo: &str, secret_type: &str, url: &str) -> LarkMessage { + let mut elements = vec![]; + + elements.push(md_div(&format!( + "**Secret type:** {secret_type}\n\nA leaked credential was detected in the repository. Rotate this secret immediately." + ))); + elements.push(build_link_button(url, "View Alert")); + + build_card("red", format!("[{repo}] Secret Leaked"), elements) +} + +fn build_dependabot_card( + repo: &str, + package: &str, + severity: &str, + summary: &str, + url: &str, +) -> LarkMessage { + let color = if severity == "critical" { + "red" + } else { + "orange" + }; + + let mut elements = vec![]; + + elements.push(md_div(&format!( + "**Package:** `{package}`\n**Severity:** {severity}" + ))); + elements.push(md_div(summary)); + elements.push(build_link_button(url, "View Alert")); + + build_card(color, format!("[{repo}] Dependabot Alert"), elements) +} diff --git a/src/handlers/lark_event.rs b/src/sinks/lark/event_handler.rs similarity index 84% rename from src/handlers/lark_event.rs rename to src/sinks/lark/event_handler.rs index e37fd32..371d8f3 100644 --- a/src/handlers/lark_event.rs +++ b/src/sinks/lark/event_handler.rs @@ -1,17 +1,18 @@ +//! Axum handler for `POST /lark/event` — Lark platform callbacks including +//! challenge verification and URL link preview (unfurl). + use std::sync::Arc; -use axum::{body::Bytes, extract::State, http::StatusCode, Json}; +use axum::{Json, body::Bytes, extract::State, http::StatusCode}; use tracing::{error, info, warn}; -use crate::{ - config::AppState, - linear::{build_preview_card, extract_identifier_from_url}, -}; +use crate::{config::AppState, sources::linear::client::extract_identifier_from_url}; -// --------------------------------------------------------------------------- -// Phase 3: Lark event handler (link preview / unfurl) -// --------------------------------------------------------------------------- +use super::cards::build_preview_card; +/// Handles incoming Lark event callbacks. +/// +/// Supports `url_verification` challenges and `url.preview.get` link previews. pub async fn lark_event_handler( State(state): State>, body: Bytes, @@ -27,7 +28,6 @@ pub async fn lark_event_handler( } }; - // Challenge verification if body_value.get("type").and_then(|v| v.as_str()) == Some("url_verification") { let challenge = body_value .get("challenge") @@ -40,8 +40,7 @@ pub async fn lark_event_handler( ); } - // Verify token if configured - if let Some(ref expected_token) = state.lark_verification_token { + if let Some(ref expected_token) = state.lark.verification_token { let token = body_value .get("header") .and_then(|h| h.get("token")) @@ -56,10 +55,8 @@ pub async fn lark_event_handler( } } - // Log full event body so we can inspect event_type and structure info!("lark event received: {body_value}"); - // Handle URL preview event let event_type = body_value .get("header") .and_then(|h| h.get("event_type")) @@ -74,6 +71,7 @@ pub async fn lark_event_handler( (StatusCode::OK, Json(serde_json::json!({}))) } +/// Fetches a Linear issue and returns an inline preview card. async fn handle_link_preview( state: &AppState, body: &serde_json::Value, @@ -83,13 +81,11 @@ async fn handle_link_preview( return (StatusCode::OK, Json(serde_json::json!({}))); }; - // Extract the URL from the event let url = body .get("event") .and_then(|e| e.get("url")) .and_then(|v| v.as_str()) .or_else(|| { - // Some Lark event formats nest it differently body.get("event") .and_then(|e| e.get("body")) .and_then(|b| b.get("url")) diff --git a/src/sinks/lark/mod.rs b/src/sinks/lark/mod.rs new file mode 100644 index 0000000..b8450ac --- /dev/null +++ b/src/sinks/lark/mod.rs @@ -0,0 +1,48 @@ +//! Lark (Feishu) notification sink — group webhook cards and bot DMs. + +mod bot; +pub mod cards; +pub mod event_handler; +pub mod models; +pub(crate) mod webhook; + +pub use bot::LarkBotClient; +pub use event_handler::lark_event_handler; + +use tracing::error; + +use crate::{config::AppState, event::Event}; + +/// Sends a card notification for `event` to the Lark group. +/// +/// Prefers Bot API (`target_chat_id`) when available, falls back to the +/// simple webhook (`webhook_url`). +pub async fn notify(event: &Event, state: &AppState) { + let card = cards::build_lark_card(event); + + match (&state.lark_bot, &state.lark.target_chat_id) { + (Some(bot), Some(chat_id)) => { + if let Err(e) = bot.send_to_chat(chat_id, &card.card).await { + error!("failed to send card to chat {chat_id}: {e}"); + } + } + _ if !state.lark.webhook_url.is_empty() => { + webhook::send_lark_card(&state.http, &state.lark.webhook_url, &card).await; + } + _ => { + error!( + "no Lark delivery method configured (need LARK_TARGET_CHAT_ID + bot, or LARK_WEBHOOK_URL)" + ); + } + } +} + +/// DMs the assignee about `event` (no-op when `bot` is `None` or event +/// does not support DM notifications). +pub async fn try_dm(event: &Event, bot: &LarkBotClient, email: &str) { + if let Some(card) = cards::build_assign_dm_card(event) + && let Err(e) = bot.send_dm(email, &card).await + { + error!("failed to DM {email}: {e}"); + } +} diff --git a/src/sinks/lark/models.rs b/src/sinks/lark/models.rs new file mode 100644 index 0000000..6625d32 --- /dev/null +++ b/src/sinks/lark/models.rs @@ -0,0 +1,31 @@ +//! Serializable Lark interactive card structures. + +use serde::Serialize; + +/// A complete Lark card message ready to POST. +#[derive(Serialize)] +pub struct LarkMessage { + pub msg_type: &'static str, + pub card: LarkCard, +} + +/// The card body (header + elements). +#[derive(Serialize, Clone)] +pub struct LarkCard { + pub header: LarkHeader, + pub elements: Vec, +} + +/// Card header with a color template and title. +#[derive(Serialize, Clone)] +pub struct LarkHeader { + pub template: String, + pub title: LarkTitle, +} + +/// Plain-text title used inside a [`LarkHeader`]. +#[derive(Serialize, Clone)] +pub struct LarkTitle { + pub content: String, + pub tag: &'static str, +} diff --git a/src/sinks/lark/webhook.rs b/src/sinks/lark/webhook.rs new file mode 100644 index 0000000..8527f64 --- /dev/null +++ b/src/sinks/lark/webhook.rs @@ -0,0 +1,24 @@ +//! Sends card messages to the Lark group webhook. + +use reqwest::Client; +use tracing::{error, info}; + +use super::models::LarkMessage; + +/// POSTs a card message to the given Lark webhook URL. +pub async fn send_lark_card(http: &Client, webhook_url: &str, card: &LarkMessage) { + match http.post(webhook_url).json(card).send().await { + Ok(resp) => { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.is_success() { + info!("lark notification sent: {text}"); + } else { + error!("lark returned {status}: {text}"); + } + } + Err(e) => { + error!("failed to send lark notification: {e}"); + } + } +} diff --git a/src/sinks/mod.rs b/src/sinks/mod.rs new file mode 100644 index 0000000..58f69f7 --- /dev/null +++ b/src/sinks/mod.rs @@ -0,0 +1,3 @@ +//! Notification sinks that format and deliver [`Event`](crate::event::Event)s. + +pub mod lark; diff --git a/src/sources/github/handler.rs b/src/sources/github/handler.rs new file mode 100644 index 0000000..f7b2eb2 --- /dev/null +++ b/src/sources/github/handler.rs @@ -0,0 +1,484 @@ +//! Axum handler for `POST /github/webhook` — receives GitHub webhook payloads, +//! converts them to [`Event`]s, and dispatches immediately (no debounce). + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use octocrab::models::webhook_events::{ + WebhookEvent, WebhookEventPayload, + payload::{ + DependabotAlertWebhookEventAction, IssuesWebhookEventAction, PullRequestWebhookEventAction, + SecretScanningAlertWebhookEventAction, WorkflowRunWebhookEventAction, + }, +}; +use tracing::{info, warn}; + +use crate::{ + config::{AppState, GitHubConfig}, + dispatch, + event::{CommitSummary, Event}, +}; + +use super::utils::{branch_from_ref, verify_github_signature}; + +const MAX_COMMITS: usize = 5; + +/// Minimal struct for lightweight repo-name extraction before full deserialization. +#[derive(serde::Deserialize)] +struct RepoProbe { + repository: RepoName, +} + +#[derive(serde::Deserialize)] +struct RepoName { + name: String, +} + +// --------------------------------------------------------------------------- +// Thin helper structs for octocrab payloads stored as serde_json::Value +// --------------------------------------------------------------------------- + +#[derive(serde::Deserialize)] +struct WorkflowRunData { + conclusion: Option, + name: String, + head_branch: String, + actor: WorkflowRunActor, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct WorkflowRunActor { + login: String, +} + +#[derive(serde::Deserialize)] +struct SecretScanningAlertData { + secret_type_display_name: Option, + secret_type: String, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct DependabotAlertData { + severity: String, + dependency: Option, + security_advisory: Option, + html_url: String, +} + +#[derive(serde::Deserialize)] +struct DependabotDependency { + package: Option, +} + +#[derive(serde::Deserialize)] +struct DependabotPackage { + name: String, +} + +#[derive(serde::Deserialize)] +struct DependabotAdvisory { + summary: String, +} + +/// Handles incoming GitHub webhook requests. +/// +/// 1. Verifies the `X-Hub-Signature-256` HMAC header. +/// 2. Routes by the `X-GitHub-Event` header via octocrab's `WebhookEvent`. +/// 3. Converts to an [`Event`] and dispatches immediately. +pub async fn webhook_handler( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let github = match &state.github { + Some(cfg) => cfg, + None => { + warn!("received GitHub webhook but GITHUB_WEBHOOK_SECRET not configured"); + return StatusCode::NOT_FOUND; + } + }; + + let signature = match headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + { + Some(s) => s, + None => { + warn!("missing x-hub-signature-256 header"); + return StatusCode::UNAUTHORIZED; + } + }; + + if !verify_github_signature(&github.webhook_secret, &body, signature) { + warn!("invalid GitHub webhook signature"); + return StatusCode::UNAUTHORIZED; + } + + // Repo whitelist filter — skip events from repos not on the list. + if !github.repo_whitelist.is_empty() { + match serde_json::from_slice::(&body) { + Ok(probe) => { + if !github.repo_whitelist.contains(&probe.repository.name) { + info!( + "ignoring event from non-whitelisted repo: {}", + probe.repository.name + ); + return StatusCode::OK; + } + } + Err(_) => { + warn!("could not extract repository name for whitelist check"); + } + } + } + + let event_type = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let webhook = match WebhookEvent::try_from_header_and_body(event_type, &body) { + Ok(ev) => ev, + Err(e) => { + warn!("failed to parse GitHub webhook event: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + // Extract repo full_name from the top-level repository field. + let repo = webhook + .repository + .as_ref() + .and_then(|r| r.full_name.as_ref()) + .cloned() + .unwrap_or_default(); + + match webhook.specific { + WebhookEventPayload::PullRequest(payload) => { + handle_pull_request(&state, github, &repo, *payload).await + } + WebhookEventPayload::Issues(payload) => { + handle_issues(&state, github, &repo, *payload).await + } + WebhookEventPayload::Push(payload) => handle_push(&state, &repo, *payload).await, + WebhookEventPayload::WorkflowRun(payload) => { + handle_workflow_run(&state, &repo, *payload).await + } + WebhookEventPayload::SecretScanningAlert(payload) => { + handle_secret_scanning(&state, &repo, *payload).await + } + WebhookEventPayload::DependabotAlert(payload) => { + handle_dependabot(&state, &repo, *payload).await + } + _ => { + info!("ignoring GitHub event type: {event_type}"); + StatusCode::OK + } + } +} + +async fn handle_pull_request( + state: &Arc, + github: &GitHubConfig, + repo: &str, + payload: octocrab::models::webhook_events::payload::PullRequestWebhookEventPayload, +) -> StatusCode { + let pr = &payload.pull_request; + let number = payload.number; + let title = pr.title.clone().unwrap_or_default(); + let author = pr + .user + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_default(); + let html_url = pr + .html_url + .as_ref() + .map(|u| u.to_string()) + .unwrap_or_default(); + + match payload.action { + PullRequestWebhookEventAction::Opened => { + info!("GitHub PR opened: {repo}#{number}"); + let event = Event::PrOpened { + repo: repo.to_string(), + number, + title, + author, + head_branch: pr.head.ref_field.clone(), + base_branch: pr.base.ref_field.clone(), + additions: pr.additions.unwrap_or(0), + deletions: pr.deletions.unwrap_or(0), + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + PullRequestWebhookEventAction::ReviewRequested => { + let reviewer = match &payload.requested_reviewer { + Some(u) => u.login.clone(), + None => { + info!("review_requested without requested_reviewer, ignoring"); + return StatusCode::OK; + } + }; + + info!("GitHub review requested: {repo}#{number} reviewer={reviewer}"); + + let reviewer_lark_id = github.user_map.get(&reviewer).cloned(); + let dm_email = reviewer_lark_id.clone(); + + let event = Event::PrReviewRequested { + repo: repo.to_string(), + number, + title, + author, + reviewer, + reviewer_lark_id, + url: html_url, + }; + dispatch::dispatch(&event, state, dm_email.as_deref()).await; + StatusCode::OK + } + PullRequestWebhookEventAction::Closed if pr.merged_at.is_some() => { + let merged_by = pr + .merged_by + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_else(|| author.clone()); + + info!("GitHub PR merged: {repo}#{number} by {merged_by}"); + + let event = Event::PrMerged { + repo: repo.to_string(), + number, + title, + author, + merged_by, + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK + } + _ => { + info!("ignoring pull_request action for {repo}#{number}"); + StatusCode::OK + } + } +} + +async fn handle_issues( + state: &Arc, + github: &GitHubConfig, + repo: &str, + payload: octocrab::models::webhook_events::payload::IssuesWebhookEventPayload, +) -> StatusCode { + if payload.action != IssuesWebhookEventAction::Labeled { + info!("ignoring issues action"); + return StatusCode::OK; + } + + let label = match &payload.label { + Some(l) => l.name.clone(), + None => return StatusCode::OK, + }; + + if !github.alert_labels.contains(&label.to_lowercase()) { + info!("ignoring non-alert label: {label}"); + return StatusCode::OK; + } + + let issue = &payload.issue; + let number = issue.number; + let title = issue.title.clone(); + let author = issue.user.login.clone(); + let html_url = issue.html_url.to_string(); + + info!("GitHub issue labeled alert: {repo}#{number} label={label}"); + + let event = Event::IssueLabeledAlert { + repo: repo.to_string(), + number, + title, + label, + author, + url: html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_push( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::PushWebhookEventPayload, +) -> StatusCode { + let branch = branch_from_ref(&payload.r#ref); + + if !is_protected_branch(branch) { + info!("ignoring push to non-protected branch: {branch}"); + return StatusCode::OK; + } + + info!( + "GitHub push to {repo}@{branch}: {} commit(s)", + payload.commits.len() + ); + + let commits: Vec = payload + .commits + .iter() + .take(MAX_COMMITS) + .map(|c| CommitSummary { + sha_short: c.id.chars().take(7).collect(), + message_line: c.message.lines().next().unwrap_or("").to_string(), + author: c.author.user.name.clone(), + }) + .collect(); + + let event = Event::BranchPush { + repo: repo.to_string(), + branch: branch.to_string(), + pusher: payload.pusher.user.name.clone(), + commits, + compare_url: payload.compare.to_string(), + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +fn is_protected_branch(branch: &str) -> bool { + matches!(branch, "main" | "master") || branch.starts_with("release") +} + +async fn handle_workflow_run( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::WorkflowRunWebhookEventPayload, +) -> StatusCode { + if payload.action != WorkflowRunWebhookEventAction::Completed { + info!("ignoring workflow_run action"); + return StatusCode::OK; + } + + let run: WorkflowRunData = match serde_json::from_value(payload.workflow_run) { + Ok(r) => r, + Err(e) => { + warn!("failed to parse workflow_run data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let conclusion = run.conclusion.unwrap_or_else(|| "unknown".to_string()); + + if conclusion != "failure" { + info!("ignoring workflow_run with conclusion: {conclusion}"); + return StatusCode::OK; + } + + info!( + "GitHub workflow_run failed: {repo} workflow={} branch={}", + run.name, run.head_branch + ); + + let event = Event::WorkflowRunFailed { + repo: repo.to_string(), + workflow_name: run.name, + branch: run.head_branch, + actor: run.actor.login, + conclusion, + url: run.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_secret_scanning( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::SecretScanningAlertWebhookEventPayload, +) -> StatusCode { + if payload.action != SecretScanningAlertWebhookEventAction::Created { + info!("ignoring secret_scanning_alert action"); + return StatusCode::OK; + } + + let alert: SecretScanningAlertData = match serde_json::from_value(payload.alert) { + Ok(a) => a, + Err(e) => { + warn!("failed to parse secret_scanning_alert data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let secret_type = alert + .secret_type_display_name + .as_deref() + .unwrap_or(&alert.secret_type); + + info!("GitHub secret scanning alert: {repo} type={secret_type}"); + + let event = Event::SecretScanningAlert { + repo: repo.to_string(), + secret_type: secret_type.to_string(), + url: alert.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} + +async fn handle_dependabot( + state: &Arc, + repo: &str, + payload: octocrab::models::webhook_events::payload::DependabotAlertWebhookEventPayload, +) -> StatusCode { + if payload.action != DependabotAlertWebhookEventAction::Created { + info!("ignoring dependabot_alert action"); + return StatusCode::OK; + } + + let alert: DependabotAlertData = match serde_json::from_value(payload.alert) { + Ok(a) => a, + Err(e) => { + warn!("failed to parse dependabot_alert data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let severity = alert.severity.to_lowercase(); + + if severity != "critical" && severity != "high" { + info!("ignoring dependabot_alert with severity: {severity}"); + return StatusCode::OK; + } + + let package = alert + .dependency + .as_ref() + .and_then(|d| d.package.as_ref()) + .map(|p| p.name.as_str()) + .unwrap_or("unknown"); + let summary = alert + .security_advisory + .as_ref() + .map(|a| a.summary.as_str()) + .unwrap_or("No summary available"); + + info!("GitHub dependabot alert: {repo} pkg={package} severity={severity}"); + + let event = Event::DependabotAlert { + repo: repo.to_string(), + package: package.to_string(), + severity, + summary: summary.to_string(), + url: alert.html_url, + }; + dispatch::dispatch(&event, state, None).await; + StatusCode::OK +} diff --git a/src/sources/github/mod.rs b/src/sources/github/mod.rs new file mode 100644 index 0000000..12296a1 --- /dev/null +++ b/src/sources/github/mod.rs @@ -0,0 +1,7 @@ +//! GitHub webhook source — receives PR, push, and issue events and converts +//! them to the unified [`Event`](crate::event::Event) model. + +mod handler; +mod utils; + +pub use handler::webhook_handler; diff --git a/src/sources/github/utils.rs b/src/sources/github/utils.rs new file mode 100644 index 0000000..4383c51 --- /dev/null +++ b/src/sources/github/utils.rs @@ -0,0 +1,18 @@ +//! GitHub-specific helpers: signature verification and branch extraction. + +/// Verifies the `X-Hub-Signature-256` header. +/// +/// GitHub sends the signature as `sha256=`. This strips the prefix +/// and delegates to the shared HMAC verifier. +pub fn verify_github_signature(secret: &str, body: &[u8], header_value: &str) -> bool { + let Some(hex_sig) = header_value.strip_prefix("sha256=") else { + return false; + }; + crate::utils::verify_hmac_sha256(secret, body, hex_sig) +} + +/// Extracts the short branch name from a full git ref +/// (e.g. `"refs/heads/main"` → `"main"`). +pub fn branch_from_ref(git_ref: &str) -> &str { + git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref) +} diff --git a/src/linear.rs b/src/sources/linear/client.rs similarity index 52% rename from src/linear.rs rename to src/sources/linear/client.rs index 6c03faf..f0776d5 100644 --- a/src/linear.rs +++ b/src/sources/linear/client.rs @@ -1,16 +1,11 @@ +//! Linear GraphQL API client for fetching issue data (used by link previews). + use reqwest::Client; use serde_json::json; -use crate::{ - lark::cards::{build_action_button, build_fields}, - models::{LarkCard, LarkHeader, LarkTitle, LinearIssueData}, - utils::{priority_color, priority_display, truncate}, -}; - -// --------------------------------------------------------------------------- -// Phase 3: Linear GraphQL client -// --------------------------------------------------------------------------- +use super::models::LinearIssueData; +/// Client for the Linear GraphQL API. pub struct LinearClient { api_key: String, http: Client, @@ -21,11 +16,11 @@ impl LinearClient { Self { api_key, http } } + /// Fetches a single issue by its identifier (e.g. `"ABX-16"`). pub async fn fetch_issue_by_identifier( &self, - identifier: &str, // e.g. "ABX-16" + identifier: &str, ) -> Result { - // Parse "ABX-16" → team_key = "ABX", number = 16 let (team_key, number_str) = identifier .rsplit_once('-') .ok_or_else(|| format!("invalid identifier format: {identifier}"))?; @@ -90,76 +85,21 @@ impl LinearClient { } } -/// Extract issue identifier from a Linear URL like -/// `https://linear.app/workspace/issue/LIN-123/some-slug` +/// Extracts an issue identifier (e.g. `"LIN-123"`) from a Linear URL like +/// `https://linear.app/workspace/issue/LIN-123/some-slug`. pub fn extract_identifier_from_url(url: &str) -> Option { - // Match /issue/IDENT pattern let parts: Vec<&str> = url.split('/').collect(); for (i, part) in parts.iter().enumerate() { - if *part == "issue" { - if let Some(ident) = parts.get(i + 1) { - // Identifier looks like "TEAM-123" - if ident.contains('-') - && ident - .split('-') - .next_back() - .map(|n| n.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(false) - { - return Some(ident.to_string()); - } - } + if *part == "issue" + && let Some(ident) = parts.get(i + 1) + && ident.contains('-') + && ident + .split('-') + .next_back() + .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())) + { + return Some(ident.to_string()); } } None } - -pub fn build_preview_card(issue: &LinearIssueData) -> LarkCard { - let color = priority_color(issue.priority); - let assignee = issue - .assignee - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("Unassigned"); - - let mut elements = vec![]; - - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": format!("**{}**", issue.title), - } - })); - - if let Some(desc) = &issue.description { - let trimmed = desc.trim(); - if !trimmed.is_empty() { - elements.push(json!({ - "tag": "div", - "text": { - "tag": "lark_md", - "content": truncate(trimmed, 200), - } - })); - } - } - - elements.push(build_fields( - &issue.state.name, - &priority_display(issue.priority), - Some(assignee), - )); - elements.push(build_action_button(&issue.url)); - - LarkCard { - header: LarkHeader { - template: color.to_string(), - title: LarkTitle { - content: format!("[Linear] {}", issue.identifier), - tag: "plain_text", - }, - }, - elements, - } -} diff --git a/src/sources/linear/handler.rs b/src/sources/linear/handler.rs new file mode 100644 index 0000000..e644c51 --- /dev/null +++ b/src/sources/linear/handler.rs @@ -0,0 +1,310 @@ +//! Axum handler for `POST /webhook` — receives Linear webhook payloads, +//! converts them to [`Event`]s, and feeds them through debounce / dispatch. + +use std::sync::Arc; + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use tracing::{error, info, warn}; + +use crate::{ + config::AppState, + dispatch, + event::{Event, Priority}, +}; + +#[cfg(not(feature = "cf-worker"))] +use crate::debounce::PendingUpdate; + +use super::{ + models::{Actor, CommentData, Issue, LinearPayload, UpdatedFrom}, + utils::{build_change_fields, verify_signature}, +}; + +/// Handles incoming Linear webhook requests. +/// +/// 1. Verifies the `linear-signature` HMAC header. +/// 2. Deserializes the [`LinearPayload`]. +/// 3. Converts to an [`Event`] and either debounces (issues) or dispatches +/// immediately (comments). +pub async fn webhook_handler( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let signature = match headers + .get("linear-signature") + .and_then(|v| v.to_str().ok()) + { + Some(s) => s, + None => { + warn!("missing linear-signature header"); + return StatusCode::UNAUTHORIZED; + } + }; + + if !verify_signature(&state.linear.webhook_secret, &body, signature) { + warn!("invalid webhook signature"); + return StatusCode::UNAUTHORIZED; + } + + let payload: LinearPayload = match serde_json::from_slice(&body) { + Ok(p) => p, + Err(e) => { + error!("failed to parse payload: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + match (payload.kind.as_str(), payload.action.as_str()) { + ("Issue", "create") => { + let issue: Issue = match serde_json::from_value(payload.data.clone()) { + Ok(i) => i, + Err(e) => { + error!("failed to parse Issue data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + info!( + "queuing debounced Issue create – {} {}", + issue.identifier, issue.title + ); + + let dm_email = issue.assignee.as_ref().and_then(|a| a.email.clone()); + let issue_id = issue.id.clone(); + + let event = issue_to_event(&issue, &payload.url, vec![], true); + + schedule_debounce(&state, issue_id, event, dm_email).await; + + StatusCode::OK + } + ("Issue", "update") => { + let issue: Issue = match serde_json::from_value(payload.data.clone()) { + Ok(i) => i, + Err(e) => { + error!("failed to parse Issue data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let changes = build_change_fields(&issue, &payload.updated_from); + + info!( + "queuing debounced Issue update – {} {} (changes: {})", + issue.identifier, + issue.title, + if changes.is_empty() { + "none detected".to_string() + } else { + changes.join(", ") + } + ); + + let dm_email: Option = payload.updated_from.as_ref().and_then(|uf| { + serde_json::from_value::(uf.clone()) + .ok() + .and_then(|uf| { + if uf.assignee_id.is_some() { + issue.assignee.as_ref().and_then(|a| a.email.clone()) + } else { + None + } + }) + }); + + let issue_id = issue.id.clone(); + + let event = issue_to_event(&issue, &payload.url, changes, false); + + schedule_debounce(&state, issue_id, event, dm_email).await; + + StatusCode::OK + } + ("Comment", "create") => { + let comment: CommentData = match serde_json::from_value(payload.data.clone()) { + Ok(c) => c, + Err(e) => { + error!("failed to parse Comment data: {e}"); + return StatusCode::BAD_REQUEST; + } + }; + + let actor: Option = payload + .data + .get("user") + .and_then(|u| serde_json::from_value(u.clone()).ok()); + + let identifier = comment + .issue + .as_ref() + .map(|i| i.identifier.clone()) + .unwrap_or_else(|| "?".into()); + let issue_title = comment + .issue + .as_ref() + .map(|i| i.title.clone()) + .unwrap_or_default(); + let author = actor.map(|a| a.name).unwrap_or_else(|| "Someone".into()); + + info!("processing Comment create on {identifier}"); + + let event = Event::CommentCreated { + source: "linear".into(), + identifier, + issue_title, + author, + body: comment.body, + url: payload.url, + }; + + dispatch::dispatch(&event, &state, None).await; + StatusCode::OK + } + _ => { + info!( + "ignoring event: type={}, action={}", + payload.kind, payload.action + ); + StatusCode::OK + } + } +} + +#[cfg(not(feature = "cf-worker"))] +async fn schedule_debounce( + state: &Arc, + issue_id: String, + event: Event, + dm_email: Option, +) { + let cancel_rx = state + .update_debounce + .upsert(issue_id.clone(), event, dm_email) + .await; + + let state2 = Arc::clone(state); + let delay = state.server.debounce_delay_ms; + tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(delay)) => { + if let Some(p) = state2.update_debounce.take(&issue_id).await { + send_debounced_notification(&state2, p).await; + } + } + _ = cancel_rx => {} + } + }); +} + +#[cfg(feature = "cf-worker")] +async fn schedule_debounce( + state: &Arc, + issue_id: String, + event: Event, + dm_email: Option, +) { + let payload = serde_json::json!({ + "event": event, + "dm_email": dm_email, + "delay_ms": state.server.debounce_delay_ms, + }); + + let body = serde_json::to_string(&payload).unwrap(); + + let stub = match state.env.durable_object("DEBOUNCER") { + Ok(ns) => match ns.get_by_name(&issue_id) { + Ok(stub) => stub, + Err(e) => { + error!("failed to get DO stub: {e}"); + return; + } + }, + Err(e) => { + error!("DEBOUNCER binding not found: {e}"); + return; + } + }; + + let mut init = worker::RequestInit::new(); + init.with_method(worker::Method::Post) + .with_body(Some(wasm_bindgen::JsValue::from_str(&body))); + + let req = match worker::Request::new_with_init("https://do/schedule", &init) { + Ok(r) => r, + Err(e) => { + error!("failed to build DO request: {e}"); + return; + } + }; + + if let Err(e) = stub.fetch_with_request(req).await { + error!("failed to schedule debounce via DO: {e}"); + } +} + +/// Converts a Linear [`Issue`] into an [`Event`]. +fn issue_to_event(issue: &Issue, url: &str, changes: Vec, is_create: bool) -> Event { + let fields = ( + "linear".to_string(), + issue.identifier.clone(), + issue.title.clone(), + issue.description.clone(), + issue.state.name.clone(), + Priority::from_linear(issue.priority), + issue.assignee.as_ref().map(|a| a.name.clone()), + issue.assignee.as_ref().and_then(|a| a.email.clone()), + url.to_string(), + changes, + ); + + if is_create { + Event::IssueCreated { + source: fields.0, + identifier: fields.1, + title: fields.2, + description: fields.3, + status: fields.4, + priority: fields.5, + assignee: fields.6, + assignee_email: fields.7, + url: fields.8, + changes: fields.9, + } + } else { + Event::IssueUpdated { + source: fields.0, + identifier: fields.1, + title: fields.2, + description: fields.3, + status: fields.4, + priority: fields.5, + assignee: fields.6, + assignee_email: fields.7, + url: fields.8, + changes: fields.9, + } + } +} + +#[cfg(not(feature = "cf-worker"))] +async fn send_debounced_notification(state: &AppState, pending: PendingUpdate) { + let kind = if pending.event.is_issue_created() { + "create" + } else { + "update" + }; + let changes = pending.event.changes(); + let changes_str = if changes.is_empty() { + "none".to_string() + } else { + changes.join(", ") + }; + + info!("sending debounced {kind} – changes: {changes_str}"); + + dispatch::dispatch(&pending.event, state, pending.dm_email.as_deref()).await; +} diff --git a/src/sources/linear/mod.rs b/src/sources/linear/mod.rs new file mode 100644 index 0000000..99d1778 --- /dev/null +++ b/src/sources/linear/mod.rs @@ -0,0 +1,9 @@ +//! Linear webhook source — receives issue/comment events and converts them +//! to the unified [`Event`](crate::event::Event) model. + +pub mod client; +mod handler; +pub mod models; +pub mod utils; + +pub use handler::webhook_handler; diff --git a/src/models.rs b/src/sources/linear/models.rs similarity index 65% rename from src/models.rs rename to src/sources/linear/models.rs index 81b0a55..b658f92 100644 --- a/src/models.rs +++ b/src/sources/linear/models.rs @@ -1,9 +1,8 @@ -use serde::{Deserialize, Serialize}; +//! Deserialization types for Linear webhook payloads and GraphQL responses. -// --------------------------------------------------------------------------- -// Linear webhook models -// --------------------------------------------------------------------------- +use serde::Deserialize; +/// Top-level Linear webhook payload. #[derive(Debug, Deserialize)] pub struct LinearPayload { pub action: String, @@ -15,6 +14,7 @@ pub struct LinearPayload { pub updated_from: Option, } +/// Issue data embedded in a webhook payload. #[derive(Debug, Deserialize)] pub struct Issue { #[allow(dead_code)] @@ -38,6 +38,7 @@ pub struct Assignee { pub email: Option, } +/// Previous field values sent in an `"update"` webhook for change detection. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdatedFrom { @@ -51,6 +52,7 @@ pub struct UpdatedFrom { pub assignee_id: Option, } +/// Comment data embedded in a webhook payload. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CommentData { @@ -66,6 +68,7 @@ pub struct CommentIssue { pub title: String, } +/// Actor (user) attached to a webhook event. #[derive(Debug, Deserialize)] pub struct Actor { pub name: String, @@ -73,37 +76,7 @@ pub struct Actor { pub email: Option, } -// --------------------------------------------------------------------------- -// Lark card models -// --------------------------------------------------------------------------- - -#[derive(Serialize)] -pub struct LarkMessage { - pub msg_type: &'static str, - pub card: LarkCard, -} - -#[derive(Serialize, Clone)] -pub struct LarkCard { - pub header: LarkHeader, - pub elements: Vec, -} - -#[derive(Serialize, Clone)] -pub struct LarkHeader { - pub template: String, - pub title: LarkTitle, -} - -#[derive(Serialize, Clone)] -pub struct LarkTitle { - pub content: String, - pub tag: &'static str, -} - -// --------------------------------------------------------------------------- -// Linear GraphQL Client Models -// --------------------------------------------------------------------------- +/// Issue data returned by the Linear GraphQL API. #[derive(Debug, Deserialize)] pub struct LinearIssueData { pub title: String, diff --git a/src/sources/linear/utils.rs b/src/sources/linear/utils.rs new file mode 100644 index 0000000..9fbbe84 --- /dev/null +++ b/src/sources/linear/utils.rs @@ -0,0 +1,59 @@ +//! Linear-specific helpers: signature verification and change detection. + +use crate::event::Priority; + +use super::models::{Issue, UpdatedFrom}; + +/// Verifies the `linear-signature` header using HMAC-SHA256. +pub fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { + crate::utils::verify_hmac_sha256(secret, body, signature) +} + +/// Compares the current [`Issue`] state against `updated_from` and returns +/// human-readable change descriptions (e.g. `"**Status:** Todo → In Progress"`). +pub fn build_change_fields(issue: &Issue, updated_from: &Option) -> Vec { + let mut changes = Vec::new(); + + let Some(uf_value) = updated_from else { + return changes; + }; + + let Ok(uf) = serde_json::from_value::(uf_value.clone()) else { + return changes; + }; + + if let Some(old_state) = &uf.state { + let old_name = old_state + .get("name") + .and_then(|v| v.as_str()) + // Linear sometimes sends state as a flat string + .or_else(|| old_state.as_str()) + .unwrap_or("Unknown"); + changes.push(format!("**Status:** {} → {}", old_name, issue.state.name)); + } + + if let Some(old_priority) = uf.priority { + changes.push(format!( + "**Priority:** {} → {}", + Priority::from_linear(old_priority).display(), + Priority::from_linear(issue.priority).display() + )); + } + + if uf.assignee_id.is_some() || uf.assignee.is_some() { + let old_name = uf + .assignee + .as_ref() + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unassigned"); + let new_name = issue + .assignee + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or("Unassigned"); + changes.push(format!("**Assignee:** {} → {}", old_name, new_name)); + } + + changes +} diff --git a/src/sources/mod.rs b/src/sources/mod.rs new file mode 100644 index 0000000..0f55a99 --- /dev/null +++ b/src/sources/mod.rs @@ -0,0 +1,4 @@ +//! Webhook receivers that normalize platform payloads into [`Event`](crate::event::Event)s. + +pub mod github; +pub mod linear; diff --git a/src/utils.rs b/src/utils.rs index 4ca868f..28bf132 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,116 +1,19 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; -use crate::models::{Issue, UpdatedFrom}; - -// --------------------------------------------------------------------------- -// Signature verification -// --------------------------------------------------------------------------- - -pub fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool { +/// Computes HMAC-SHA256 over `body` using `secret` and compares the +/// hex-encoded result against `expected_hex`. +pub fn verify_hmac_sha256(secret: &str, body: &[u8], expected_hex: &str) -> bool { let Ok(mut mac) = Hmac::::new_from_slice(secret.as_bytes()) else { return false; }; mac.update(body); - let expected = hex::encode(mac.finalize().into_bytes()); - expected == signature -} - -// --------------------------------------------------------------------------- -// Priority helpers -// --------------------------------------------------------------------------- - -pub fn priority_color(priority: u8) -> &'static str { - match priority { - 1 => "red", - 2 => "orange", - 3 => "yellow", - _ => "blue", // 0 (No priority) and 4 (Low) - } + let computed = hex::encode(mac.finalize().into_bytes()); + computed == expected_hex } -pub fn priority_label(priority: u8) -> &'static str { - match priority { - 1 => "Urgent", - 2 => "High", - 3 => "Medium", - 4 => "Low", - _ => "None", - } -} - -fn priority_emoji(priority: u8) -> &'static str { - match priority { - 1 => "🔴", - 2 => "🟠", - 3 => "🟡", - 4 => "🔵", - _ => "⚪", - } -} - -pub fn priority_display(priority: u8) -> String { - format!("{} {}", priority_emoji(priority), priority_label(priority)) -} - -// --------------------------------------------------------------------------- -// Change detection for Issue updates -// --------------------------------------------------------------------------- - -pub fn build_change_fields(issue: &Issue, updated_from: &Option) -> Vec { - let mut changes = Vec::new(); - - let Some(uf_value) = updated_from else { - return changes; - }; - - let Ok(uf) = serde_json::from_value::(uf_value.clone()) else { - return changes; - }; - - // Status change - if let Some(old_state) = &uf.state { - let old_name = old_state - .get("name") - .and_then(|v| v.as_str()) - // Linear sometimes sends state as a flat string - .or_else(|| old_state.as_str()) - .unwrap_or("Unknown"); - changes.push(format!("**Status:** {} → {}", old_name, issue.state.name)); - } - - // Priority change - if let Some(old_priority) = uf.priority { - changes.push(format!( - "**Priority:** {} → {}", - priority_display(old_priority), - priority_display(issue.priority) - )); - } - - // Assignee change - if uf.assignee_id.is_some() || uf.assignee.is_some() { - let old_name = uf - .assignee - .as_ref() - .and_then(|a| a.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("Unassigned"); - let new_name = issue - .assignee - .as_ref() - .map(|a| a.name.as_str()) - .unwrap_or("Unassigned"); - changes.push(format!("**Assignee:** {} → {}", old_name, new_name)); - } - - changes -} - -// --------------------------------------------------------------------------- -// Truncate text helper -// --------------------------------------------------------------------------- - +/// Truncates `s` to at most `max_chars` characters, appending `"…"` when +/// truncation occurs. pub fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() diff --git a/tests/fixtures/github_pr_opened.json b/tests/fixtures/github_pr_opened.json new file mode 100644 index 0000000..d624c8c --- /dev/null +++ b/tests/fixtures/github_pr_opened.json @@ -0,0 +1,312 @@ +{ + "action": "opened", + "number": 2, + "pull_request": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2", + "id": 279147437, + "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3", + "html_url": "https://github.com/Codertocat/Hello-World/pull/2", + "diff_url": "https://github.com/Codertocat/Hello-World/pull/2.diff", + "patch_url": "https://github.com/Codertocat/Hello-World/pull/2.patch", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "number": 2, + "state": "open", + "locked": false, + "title": "Update the README with new information.", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "body": "This is a pretty simple change that we need to pull into master.", + "created_at": "2019-05-15T15:20:33Z", + "updated_at": "2019-05-15T15:20:33Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": null, + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_teams": [], + "labels": [], + "milestone": null, + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits", + "review_comments_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments", + "review_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "head": { + "label": "Codertocat:changes", + "ref": "changes", + "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "base": { + "label": "Codertocat:master", + "ref": "master", + "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + } + }, + "_links": { + "self": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2" }, + "html": { "href": "https://github.com/Codertocat/Hello-World/pull/2" }, + "issue": { "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2" }, + "comments": { "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments" }, + "review_comments": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments" }, + "review_comment": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}" }, + "commits": { "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits" }, + "statuses": { "href": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821" } + }, + "author_association": "OWNER", + "auto_merge": null, + "active_lock_reason": null, + "draft": false, + "merged": false, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 1, + "additions": 1, + "deletions": 1, + "changed_files": 1 + }, + "repository": { + "id": 186853002, + "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": false, + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "created_at": "2019-05-15T15:19:25Z", + "updated_at": "2019-05-15T15:19:27Z", + "pushed_at": "2019-05-15T15:20:32Z", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..e46d03c --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,18 @@ +name = "larkstack" +main = "build/worker/shim.mjs" +compatibility_date = "2024-01-01" + +[build] +command = "cargo install worker-build && worker-build --release" + +[durable_objects] +bindings = [{ name = "DEBOUNCER", class_name = "DebounceObject" }] + +[[migrations]] +tag = "v1" +new_classes = ["DebounceObject"] + +[vars] +LARK_WEBHOOK_URL = "" +PORT = "3000" +DEBOUNCE_DELAY_MS = "5000"