Skip to content

Commit d18eb0e

Browse files
committed
Enable closed workflow
1 parent e181cfc commit d18eb0e

5 files changed

Lines changed: 239 additions & 3 deletions

File tree

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ popcorn admin create-leaderboard <dir> # Create leaderboard from problem directo
154154
popcorn admin delete-leaderboard <name> # Delete a leaderboard
155155
popcorn admin delete-leaderboard <name> --force # Force delete with submissions
156156

157+
# Invite management
158+
popcorn admin generate-invites --leaderboards lb1 lb2 --count 5 # Generate invite codes
159+
popcorn admin list-invites <leaderboard> # List invites for a leaderboard
160+
popcorn admin revoke-invite <code> # Revoke an invite code
161+
157162
# Update problems from GitHub
158163
popcorn admin update-problems
159164
popcorn admin update-problems --problem-set nvidia --force

docs/helion-hackathon.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.s
2525
# 2. Register
2626
popcorn register discord
2727

28-
# 3. Setup a project (downloads the submission template for you)
28+
# 3. Join the challenge with your invite code
29+
popcorn join <YOUR_INVITE_CODE>
30+
31+
# 4. Setup a project (downloads the submission template for you)
2932
popcorn setup
3033
# Select "Helion Kernel Challenge", then pick a problem and GPU
3134
```

src/cmd/admin.rs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ pub enum AdminAction {
3939
#[arg(long)]
4040
force: bool,
4141
},
42+
/// Generate invite codes for leaderboard(s)
43+
GenerateInvites {
44+
/// Leaderboard names to grant access to
45+
#[arg(long, required = true, num_args = 1..)]
46+
leaderboards: Vec<String>,
47+
48+
/// Number of invite codes to generate (1-10000)
49+
#[arg(long, default_value = "1")]
50+
count: u32,
51+
},
52+
/// List invite codes for a leaderboard
53+
ListInvites {
54+
/// Leaderboard name
55+
leaderboard: String,
56+
},
57+
/// Revoke an invite code
58+
RevokeInvite {
59+
/// The invite code to revoke
60+
code: String,
61+
},
4262
/// Update problems from a GitHub repository (mirrors Discord /admin update-problems)
4363
UpdateProblems {
4464
/// Problem set name (e.g., "nvidia", "pmpp_v2"). If not specified, updates all.
@@ -56,6 +76,10 @@ pub enum AdminAction {
5676
/// Force update even if task definition changed significantly
5777
#[arg(long)]
5878
force: bool,
79+
80+
/// Set leaderboard visibility to closed (requires invite to access)
81+
#[arg(long)]
82+
closed: bool,
5983
},
6084
}
6185

@@ -108,27 +132,109 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> {
108132
println!("Deleted leaderboard '{}'", name);
109133
println!("{}", serde_json::to_string_pretty(&result)?);
110134
}
135+
AdminAction::GenerateInvites {
136+
leaderboards,
137+
count,
138+
} => {
139+
let result = service::admin_generate_invites(&client, &leaderboards, count).await?;
140+
let codes = result["codes"].as_array().map(|arr| arr.len()).unwrap_or(0);
141+
let lbs = result["leaderboards"]
142+
.as_array()
143+
.map(|arr| {
144+
arr.iter()
145+
.filter_map(|v| v.as_str())
146+
.collect::<Vec<_>>()
147+
.join(", ")
148+
})
149+
.unwrap_or_default();
150+
println!("Generated {} invite code(s) for: {}", codes, lbs);
151+
if let Some(arr) = result["codes"].as_array() {
152+
for code in arr {
153+
println!(" {}", code.as_str().unwrap_or("???"));
154+
}
155+
}
156+
}
157+
AdminAction::ListInvites { leaderboard } => {
158+
let result = service::admin_list_invites(&client, &leaderboard).await?;
159+
let invites = result["invites"].as_array();
160+
match invites {
161+
Some(arr) if arr.is_empty() => {
162+
println!("No invites for '{}'", leaderboard);
163+
}
164+
Some(arr) => {
165+
let claimed = arr
166+
.iter()
167+
.filter(|i| i["user_id"].as_str().is_some())
168+
.count();
169+
println!(
170+
"Invites for '{}': {} total, {} claimed, {} unclaimed\n",
171+
leaderboard,
172+
arr.len(),
173+
claimed,
174+
arr.len() - claimed,
175+
);
176+
let header = format!(
177+
"{:<26} {:<16} {:<20} {}",
178+
"CODE", "STATUS", "CLAIMED BY", "CREATED"
179+
);
180+
println!("{header}");
181+
println!("{}", "-".repeat(82));
182+
for invite in arr {
183+
let code = invite["code"].as_str().unwrap_or("???");
184+
let user = invite["user_name"]
185+
.as_str()
186+
.or_else(|| invite["user_id"].as_str());
187+
let status = if user.is_some() {
188+
"claimed"
189+
} else {
190+
"unclaimed"
191+
};
192+
let user_display = user.unwrap_or("-");
193+
let created = invite["created_at"].as_str().unwrap_or("-");
194+
println!(
195+
"{:<26} {:<16} {:<20} {}",
196+
code, status, user_display, created,
197+
);
198+
}
199+
}
200+
None => {
201+
println!("{}", serde_json::to_string_pretty(&result)?);
202+
}
203+
}
204+
}
205+
AdminAction::RevokeInvite { code } => {
206+
let result = service::admin_revoke_invite(&client, &code).await?;
207+
let was_claimed = result["was_claimed"].as_bool().unwrap_or(false);
208+
println!(
209+
"Revoked invite code '{}' (was {})",
210+
code,
211+
if was_claimed { "claimed" } else { "unclaimed" }
212+
);
213+
}
111214
AdminAction::UpdateProblems {
112215
problem_set,
113216
repository,
114217
branch,
115218
force,
219+
closed,
116220
} => {
117221
println!(
118-
"Updating problems from {}/tree/{}{}...",
222+
"Updating problems from {}/tree/{}{}{}...",
119223
repository,
120224
branch,
121225
problem_set
122226
.as_ref()
123227
.map(|ps| format!(" (problem set: {})", ps))
124-
.unwrap_or_default()
228+
.unwrap_or_default(),
229+
if closed { " (closed)" } else { "" },
125230
);
126231
let result = service::admin_update_problems(
127232
&client,
128233
problem_set.as_deref(),
129234
&repository,
130235
&branch,
131236
force,
237+
closed,
132238
)
133239
.await?;
134240

src/cmd/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ mod setup;
1010
mod submissions;
1111
mod submit;
1212

13+
use crate::service;
14+
1315
pub use admin::AdminAction;
1416

1517
#[derive(Serialize, Deserialize, Debug, Default)]
@@ -138,6 +140,11 @@ enum Commands {
138140
#[arg(long)]
139141
no_tui: bool,
140142
},
143+
/// Join a closed leaderboard using an invite code
144+
Join {
145+
/// The invite code
146+
code: String,
147+
},
141148
/// Admin commands (requires POPCORN_ADMIN_TOKEN env var)
142149
Admin {
143150
#[command(subcommand)]
@@ -209,6 +216,29 @@ pub async fn execute(cli: Cli) -> Result<()> {
209216
.await
210217
}
211218
}
219+
Some(Commands::Join { code }) => {
220+
let config = load_config()?;
221+
let cli_id = config.cli_id.ok_or_else(|| {
222+
anyhow!(
223+
"cli_id not found in config file ({}). Please run `popcorn register` first.",
224+
get_config_path()
225+
.map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string())
226+
)
227+
})?;
228+
let client = service::create_client(Some(cli_id))?;
229+
let result = service::join_with_invite(&client, &code).await?;
230+
let leaderboards = result["leaderboards"]
231+
.as_array()
232+
.map(|arr| {
233+
arr.iter()
234+
.filter_map(|v| v.as_str())
235+
.collect::<Vec<_>>()
236+
.join(", ")
237+
})
238+
.unwrap_or_default();
239+
println!("Joined leaderboard(s): {}", leaderboards);
240+
Ok(())
241+
}
212242
Some(Commands::Admin { action }) => admin::handle_admin(action).await,
213243
Some(Commands::Submissions { action }) => {
214244
let config = load_config()?;

src/service/mod.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ pub async fn admin_update_problems(
188188
repository: &str,
189189
branch: &str,
190190
force: bool,
191+
closed: bool,
191192
) -> Result<Value> {
192193
let base_url =
193194
env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?;
@@ -202,6 +203,10 @@ pub async fn admin_update_problems(
202203
payload["problem_set"] = serde_json::Value::String(ps.to_string());
203204
}
204205

206+
if closed {
207+
payload["visibility"] = serde_json::Value::String("closed".to_string());
208+
}
209+
205210
let resp = client
206211
.post(format!("{}/admin/update-problems", base_url))
207212
.json(&payload)
@@ -212,6 +217,61 @@ pub async fn admin_update_problems(
212217
handle_admin_response(resp).await
213218
}
214219

220+
/// Generate invite codes for one or more leaderboards
221+
pub async fn admin_generate_invites(
222+
client: &Client,
223+
leaderboards: &[String],
224+
count: u32,
225+
) -> Result<Value> {
226+
let base_url =
227+
env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?;
228+
229+
let payload = serde_json::json!({
230+
"leaderboards": leaderboards,
231+
"count": count,
232+
});
233+
234+
let resp = client
235+
.post(format!("{}/admin/invites", base_url))
236+
.json(&payload)
237+
.timeout(Duration::from_secs(30))
238+
.send()
239+
.await?;
240+
241+
handle_admin_response(resp).await
242+
}
243+
244+
/// List invite codes for a leaderboard
245+
pub async fn admin_list_invites(client: &Client, leaderboard_name: &str) -> Result<Value> {
246+
let base_url =
247+
env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?;
248+
249+
let resp = client
250+
.get(format!(
251+
"{}/admin/leaderboards/{}/invites",
252+
base_url, leaderboard_name
253+
))
254+
.timeout(Duration::from_secs(30))
255+
.send()
256+
.await?;
257+
258+
handle_admin_response(resp).await
259+
}
260+
261+
/// Revoke an invite code
262+
pub async fn admin_revoke_invite(client: &Client, code: &str) -> Result<Value> {
263+
let base_url =
264+
env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?;
265+
266+
let resp = client
267+
.delete(format!("{}/admin/invites/{}", base_url, code))
268+
.timeout(Duration::from_secs(30))
269+
.send()
270+
.await?;
271+
272+
handle_admin_response(resp).await
273+
}
274+
215275
/// Helper to handle admin API responses
216276
async fn handle_admin_response(resp: reqwest::Response) -> Result<Value> {
217277
let status = resp.status();
@@ -447,6 +507,38 @@ pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Resu
447507
.map_err(|e| anyhow!("Failed to parse response: {}", e))
448508
}
449509

510+
/// Claim an invite code to join closed leaderboard(s)
511+
pub async fn join_with_invite(client: &Client, code: &str) -> Result<Value> {
512+
let base_url =
513+
env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?;
514+
515+
let payload = serde_json::json!({ "code": code });
516+
517+
let resp = client
518+
.post(format!("{}/user/join", base_url))
519+
.json(&payload)
520+
.timeout(Duration::from_secs(30))
521+
.send()
522+
.await?;
523+
524+
let status = resp.status();
525+
if !status.is_success() {
526+
let error_text = resp.text().await?;
527+
let detail = serde_json::from_str::<Value>(&error_text)
528+
.ok()
529+
.and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string));
530+
return Err(anyhow!(
531+
"Server returned status {}: {}",
532+
status,
533+
detail.unwrap_or(error_text)
534+
));
535+
}
536+
537+
resp.json()
538+
.await
539+
.map_err(|e| anyhow!("Failed to parse response: {}", e))
540+
}
541+
450542
pub async fn submit_solution<P: AsRef<Path>>(
451543
client: &Client,
452544
filepath: P,

0 commit comments

Comments
 (0)