Skip to content

Commit 442d41b

Browse files
committed
wip: improve commit log UI
1 parent 0a1181f commit 442d41b

File tree

11 files changed

+491
-91
lines changed

11 files changed

+491
-91
lines changed

examples/ui_large_balance.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
use askama_axum::Template;
21
use axum::{routing::get, Router};
3-
use git_turbine::api::{IndexTemplate, PaidCommit};
2+
use git_turbine::api::{IndexTemplate, PaidCommit, RepoUrl};
43

5-
fn create_mock_commit(name: &str, amount: &str, timestamp: u64) -> PaidCommit {
4+
fn create_mock_commit(name: &str, amount: &str, timestamp: u64, commit_id: &str, message: &str) -> PaidCommit {
65
PaidCommit {
76
contributor_name: name.to_string(),
87
amount: amount.to_string(),
98
timestamp,
9+
commit_id: commit_id.to_string(),
10+
commit_message: message.to_string(),
11+
currency: "XMR".to_string(),
1012
}
1113
}
1214

@@ -18,8 +20,8 @@ async fn index() -> IndexTemplate {
1820
monero_block_height: 4_000_000,
1921
monero_network: "Main".to_string(),
2022
monero_wallet_address: "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge".to_string(),
21-
repository_url: "https://github.com/whale/project".to_string(),
22-
commits: vec![create_mock_commit("Whale", "1000000.00000", 1234567890)],
23+
repository_url: RepoUrl::new("https://github.com/whale/project".to_string()),
24+
commits: vec![create_mock_commit("Whale", "1000000.00000", 1234567890, "abc1234", "feat: major contribution to the project")],
2325
}
2426
}
2527

examples/ui_many_commits.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,39 @@
1-
use askama_axum::Template;
21
use axum::{routing::get, Router};
3-
use git_turbine::api::{IndexTemplate, PaidCommit};
2+
use git_turbine::api::{IndexTemplate, PaidCommit, RepoUrl};
43

5-
fn create_mock_commit(name: &str, amount: &str, timestamp: u64) -> PaidCommit {
4+
fn create_mock_commit(name: &str, amount: &str, timestamp: u64, commit_id: &str, message: &str) -> PaidCommit {
65
PaidCommit {
76
contributor_name: name.to_string(),
87
amount: amount.to_string(),
98
timestamp,
9+
commit_id: commit_id.to_string(),
10+
commit_message: message.to_string(),
11+
currency: "XMR".to_string(),
1012
}
1113
}
1214

1315
async fn index() -> IndexTemplate {
16+
let commit_messages = vec![
17+
"feat: initial commit",
18+
"fix: bug in payment logic",
19+
"docs: update README",
20+
"refactor: clean up code",
21+
"test: add unit tests",
22+
"feat: add new feature",
23+
"fix: resolve edge case",
24+
"style: format code",
25+
"chore: update dependencies",
26+
"perf: optimize algorithm",
27+
];
28+
1429
let commits: Vec<PaidCommit> = (0..20)
1530
.map(|i| {
1631
create_mock_commit(
1732
&format!("Contributor_{}", i),
1833
&format!("{:.5}", (i as f64) * 0.1),
1934
1234567890 + (i * 10),
35+
&format!("{:07x}", i * 123456),
36+
commit_messages[i as usize % commit_messages.len()],
2037
)
2138
})
2239
.collect();
@@ -28,7 +45,7 @@ async fn index() -> IndexTemplate {
2845
monero_block_height: 3_000_000,
2946
monero_network: "Main".to_string(),
3047
monero_wallet_address: "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge".to_string(),
31-
repository_url: "https://github.com/popular/repo".to_string(),
48+
repository_url: RepoUrl::new("https://github.com/popular/repo".to_string()),
3249
commits,
3350
}
3451
}

examples/ui_monero_disabled.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
use askama_axum::Template;
21
use axum::{routing::get, Router};
3-
use git_turbine::api::IndexTemplate;
2+
use git_turbine::api::{IndexTemplate, RepoUrl};
43

54
async fn index() -> IndexTemplate {
65
IndexTemplate {
@@ -10,7 +9,7 @@ async fn index() -> IndexTemplate {
109
monero_block_height: 0,
1110
monero_network: String::new(),
1211
monero_wallet_address: String::new(),
13-
repository_url: "https://github.com/fossable/turbine".to_string(),
12+
repository_url: RepoUrl::new("https://github.com/fossable/turbine".to_string()),
1413
commits: vec![],
1514
}
1615
}

examples/ui_monero_enabled.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
use askama_axum::Template;
21
use axum::{routing::get, Router};
3-
use git_turbine::api::{IndexTemplate, PaidCommit};
2+
use git_turbine::api::{IndexTemplate, PaidCommit, RepoUrl};
43

5-
fn create_mock_commit(name: &str, amount: &str, timestamp: u64) -> PaidCommit {
4+
fn create_mock_commit(name: &str, amount: &str, timestamp: u64, commit_id: &str, message: &str) -> PaidCommit {
65
PaidCommit {
76
contributor_name: name.to_string(),
87
amount: amount.to_string(),
98
timestamp,
9+
commit_id: commit_id.to_string(),
10+
commit_message: message.to_string(),
11+
currency: "XMR".to_string(),
1012
}
1113
}
1214

@@ -18,10 +20,10 @@ async fn index() -> IndexTemplate {
1820
monero_block_height: 2_500_000,
1921
monero_network: "Main".to_string(),
2022
monero_wallet_address: "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge".to_string(),
21-
repository_url: "https://github.com/fossable/turbine".to_string(),
23+
repository_url: RepoUrl::new("https://github.com/fossable/turbine".to_string()),
2224
commits: vec![
23-
create_mock_commit("Alice", "0.50000", 1234567890),
24-
create_mock_commit("Bob", "1.25000", 1234567900),
25+
create_mock_commit("Alice", "0.50000", 1234567890, "a1b2c3d", "fix: resolve payment calculation bug"),
26+
create_mock_commit("Bob", "1.25000", 1234567900, "e4f5g6h", "feat: add new cryptocurrency support"),
2527
],
2628
}
2729
}

examples/ui_special_characters.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
use askama_axum::Template;
21
use axum::{routing::get, Router};
3-
use git_turbine::api::{IndexTemplate, PaidCommit};
2+
use git_turbine::api::{IndexTemplate, PaidCommit, RepoUrl};
43

5-
fn create_mock_commit(name: &str, amount: &str, timestamp: u64) -> PaidCommit {
4+
fn create_mock_commit(name: &str, amount: &str, timestamp: u64, commit_id: &str, message: &str) -> PaidCommit {
65
PaidCommit {
76
contributor_name: name.to_string(),
87
amount: amount.to_string(),
98
timestamp,
9+
commit_id: commit_id.to_string(),
10+
commit_message: message.to_string(),
11+
currency: "XMR".to_string(),
1012
}
1113
}
1214

@@ -18,11 +20,11 @@ async fn index() -> IndexTemplate {
1820
monero_block_height: 2_000_000,
1921
monero_network: "Main".to_string(),
2022
monero_wallet_address: "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge".to_string(),
21-
repository_url: "https://github.com/test/repo".to_string(),
23+
repository_url: RepoUrl::new("https://github.com/test/repo".to_string()),
2224
commits: vec![
23-
create_mock_commit("John O'Brien", "0.50000", 1234567890),
24-
create_mock_commit("José García", "0.75000", 1234567900),
25-
create_mock_commit("<script>alert('xss')</script>", "0.25000", 1234567910),
25+
create_mock_commit("John O'Brien", "0.50000", 1234567890, "def5678", "fix: handle apostrophes in names"),
26+
create_mock_commit("José García", "0.75000", 1234567900, "ghi9012", "feat: añadir soporte para caracteres especiales"),
27+
create_mock_commit("<script>alert('xss')</script>", "0.25000", 1234567910, "jkl3456", "<script>alert('test')</script> in commit message"),
2628
],
2729
}
2830
}

examples/ui_zero_balance.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use axum::{routing::get, Router};
2+
use git_turbine::api::{IndexTemplate, RepoUrl};
3+
4+
async fn index() -> IndexTemplate {
5+
IndexTemplate {
6+
monero_enabled: true,
7+
monero_balance: "0.00000".to_string(),
8+
monero_balance_usd: "0.00".to_string(),
9+
monero_block_height: 2_800_000,
10+
monero_network: "Main".to_string(),
11+
monero_wallet_address: "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge".to_string(),
12+
repository_url: RepoUrl::new("https://github.com/fossable/turbine".to_string()),
13+
commits: vec![],
14+
}
15+
}
16+
17+
#[tokio::main]
18+
async fn main() {
19+
let app = Router::new()
20+
.route("/", get(index))
21+
.route("/assets/*file", get(git_turbine::api::assets));
22+
23+
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
24+
.await
25+
.expect("Failed to bind to port 8080");
26+
27+
axum::serve(listener, app).await.unwrap();
28+
}

src/api/mod.rs

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,34 @@ use axum_macros::debug_handler;
99
use cached::proc_macro::once;
1010
use rust_embed::Embed;
1111
use std::time::Duration;
12-
use tracing::{debug, error, info};
12+
13+
#[derive(Debug, Clone, Default)]
14+
pub struct RepoUrl(String);
15+
16+
impl RepoUrl {
17+
pub fn new(url: String) -> Self {
18+
Self(url)
19+
}
20+
21+
pub fn is_github(&self) -> bool {
22+
self.0.starts_with("https://github.com")
23+
}
24+
}
25+
26+
impl std::fmt::Display for RepoUrl {
27+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28+
write!(f, "{}", self.0)
29+
}
30+
}
1331

1432
#[derive(Debug, Clone)]
1533
pub struct PaidCommit {
1634
pub amount: String,
1735
pub timestamp: u64,
1836
pub contributor_name: String,
37+
pub commit_id: String,
38+
pub commit_message: String,
39+
pub currency: String,
1940
}
2041

2142
#[derive(Template, Debug, Clone, Default)]
@@ -26,7 +47,7 @@ pub struct IndexTemplate {
2647
pub monero_block_height: u64,
2748
pub monero_network: String,
2849
pub monero_wallet_address: String,
29-
pub repository_url: String,
50+
pub repository_url: RepoUrl,
3051
pub commits: Vec<PaidCommit>,
3152
pub monero_balance_usd: String,
3253
}
@@ -119,24 +140,35 @@ pub async fn refresh(State(state): State<AppState>) {
119140
crate::currency::Address::BTC(_) => todo!(),
120141
#[cfg(feature = "monero")]
121142
crate::currency::Address::XMR(address) => {
122-
let transfer_count = state.monero.count_transfers(&address).await.unwrap();
123-
debug!(count = transfer_count, address = ?address, "Transfers to XMR address");
124-
125-
for commit_id in contributor.commits.iter().skip(transfer_count) {
126-
match state
127-
.monero
128-
.transfer(
129-
&address,
130-
monero_rpc::monero::Amount::from_pico(
131-
contributor.compute_payout(commit_id.clone(), state.base_payout, state.max_payout_cap),
132-
),
133-
commit_id,
134-
)
135-
.await
136-
{
137-
Ok(_) => info!("Transfer complete"),
138-
Err(e) => error!(error=%e, "Transfer failed"),
139-
};
143+
debug!(address = ?address, total_commits = contributor.commits.len(), "Processing XMR contributor");
144+
145+
for commit_id in contributor.commits.iter() {
146+
// Check if this commit was already paid using its dedicated subaddress
147+
match state.monero.is_commit_paid(*commit_id).await {
148+
Ok(true) => {
149+
debug!(commit = ?commit_id, "Commit already paid, skipping");
150+
continue;
151+
}
152+
Ok(false) => {
153+
// Not paid yet, proceed with transfer
154+
let payout = contributor.compute_payout(*commit_id, state.base_payout, state.max_payout_cap);
155+
match state
156+
.monero
157+
.transfer(
158+
&address,
159+
monero_rpc::monero::Amount::from_pico(payout),
160+
commit_id,
161+
)
162+
.await
163+
{
164+
Ok(_) => info!(commit = ?commit_id, amount = payout, "Transfer complete"),
165+
Err(e) => error!(commit = ?commit_id, error=%e, "Transfer failed"),
166+
};
167+
}
168+
Err(e) => {
169+
error!(commit = ?commit_id, error=%e, "Failed to check if commit was paid");
170+
}
171+
}
140172
}
141173
}
142174
};

src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use axum::{
66
};
77
use chrono::Utc;
88
use clap::Args;
9-
use std::{path::PathBuf, process::ExitCode, sync::Arc};
9+
use std::{process::ExitCode, sync::Arc};
1010

1111
use tokio::{net::TcpListener, sync::Mutex};
1212
use tokio_schedule::{every, Job};

src/currency/monero.rs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ use std::{
2020
use std::{str::FromStr, sync::Mutex};
2121
use tracing::{debug, info, instrument};
2222

23+
/// Derive a deterministic subaddress index from a commit OID.
24+
/// This ensures each commit maps to a unique subaddress for idempotent payments.
25+
pub fn commit_to_subaddress_index(commit_id: Oid) -> u32 {
26+
let hash = commit_id.as_bytes();
27+
// Use first 4 bytes of commit hash as subaddress index
28+
u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]])
29+
}
30+
2331
#[derive(Clone, Debug)]
2432
pub struct MoneroState {
2533
pub wallet: WalletClient,
@@ -143,27 +151,29 @@ impl MoneroState {
143151
Ok(balance.balance)
144152
}
145153

146-
/// Count outbound transfers to the given address.
147-
pub async fn count_transfers(&self, address: &str) -> Result<usize> {
154+
/// Check if a specific commit has already been paid by checking its dedicated subaddress.
155+
/// This is stateless and idempotent - the commit OID deterministically maps to a subaddress.
156+
#[instrument(skip(self), ret)]
157+
pub async fn is_commit_paid(&self, commit_id: Oid) -> Result<bool> {
158+
let subaddress_index = commit_to_subaddress_index(commit_id);
159+
148160
let transfers = self
149161
.wallet
150162
.get_transfers(GetTransfersSelector {
151163
category_selector: HashMap::from([(GetTransfersCategory::Out, true)]),
152164
account_index: Some(self.account_index),
153-
subaddr_indices: None,
165+
subaddr_indices: Some(vec![subaddress_index]),
154166
block_height_filter: Some(BlockHeightFilter {
155167
min_height: Some(self.minimum_block_height),
156168
max_height: None,
157169
}),
158170
})
159171
.await?;
160172

161-
Ok(transfers
173+
Ok(!transfers
162174
.get(&GetTransfersCategory::Out)
163175
.unwrap_or(&vec![])
164-
.iter()
165-
.filter(|transfer| transfer.address.to_string() == address.to_string())
166-
.count())
176+
.is_empty())
167177
}
168178

169179
/// Get all outbound transfers.
@@ -188,16 +198,26 @@ impl MoneroState {
188198
.to_owned())
189199
}
190200

191-
/// Transfer the given amount of Monero.
192-
pub async fn transfer(&self, address: &str, amount: Amount, _commit_id: &Oid) -> Result<()> {
193-
info!(amount = ?amount, dest = ?address, "Transferring Monero");
201+
/// Transfer the given amount of Monero from a commit-specific subaddress.
202+
/// Each commit gets a unique subaddress derived from its OID, ensuring idempotent payments.
203+
pub async fn transfer(&self, address: &str, amount: Amount, commit_id: &Oid) -> Result<()> {
204+
let subaddress_index = commit_to_subaddress_index(*commit_id);
205+
206+
info!(
207+
amount = ?amount,
208+
dest = ?address,
209+
commit = ?commit_id,
210+
subaddr_index = subaddress_index,
211+
"Transferring Monero from commit-specific subaddress"
212+
);
213+
194214
self.wallet
195215
.transfer(
196216
HashMap::from([(Address::from_str(address)?, amount)]),
197217
TransferPriority::Default,
198218
TransferOptions {
199219
account_index: Some(self.account_index),
200-
subaddr_indices: None,
220+
subaddr_indices: Some(vec![subaddress_index]),
201221
mixin: None,
202222
ring_size: Some(16),
203223
unlock_time: None,

0 commit comments

Comments
 (0)