Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
235 lines
7.9 KiB
Rust
235 lines
7.9 KiB
Rust
//! `pr-create --title <t> --head <branch> [--base <b>] [body sources]
|
|
//! [--draft] [--push] [--remote <name>] [repo]` — create a PR. Prints
|
|
//! the PR URL.
|
|
//!
|
|
//! With `--push` the local `--head` branch is pushed to `--remote`
|
|
//! (default `forge`) before the API call, and forgejo's post-push
|
|
//! "Create a new pull request" / "Visit the existing pull request"
|
|
//! hint block is filtered out of git's stderr (we print the canonical
|
|
//! URL ourselves once the API returns). Other git stderr passes
|
|
//! through. Default behaviour is unchanged: no push unless asked.
|
|
//! Closes the auto-push half of #222 per operator decision (opt-in
|
|
//! flag).
|
|
|
|
use std::process::{Command, Stdio};
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Args as ClapArgs;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::body;
|
|
use crate::client::Client;
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// PR title.
|
|
#[arg(long)]
|
|
title: String,
|
|
/// Head branch.
|
|
#[arg(long)]
|
|
head: String,
|
|
/// Base branch (default: main).
|
|
#[arg(long, default_value = "main")]
|
|
base: String,
|
|
/// Inline body text.
|
|
#[arg(long, conflicts_with = "body_file")]
|
|
body: Option<String>,
|
|
/// Read body from a file. `-` means stdin.
|
|
#[arg(long = "body-file")]
|
|
body_file: Option<String>,
|
|
/// Open as draft.
|
|
#[arg(long)]
|
|
draft: bool,
|
|
/// Push the local `--head` branch to `--remote` before creating
|
|
/// the PR. Suppresses forgejo's "Create / Visit a pull request"
|
|
/// hint block (we print the URL ourselves).
|
|
#[arg(long)]
|
|
push: bool,
|
|
/// Remote name to push to when `--push` is set. Defaults to the
|
|
/// hyperhive convention `forge`.
|
|
#[arg(long, default_value = "forge")]
|
|
remote: String,
|
|
}
|
|
|
|
/// # Errors
|
|
///
|
|
/// Propagates any I/O error from the body input (`--body-file`,
|
|
/// stdin) or the `--push` shellout to git, any transport error from
|
|
/// the Forgejo REST call (network unreachable, 4xx/5xx response,
|
|
/// token missing/invalid), and any I/O error from writing the PR
|
|
/// URL to stdout.
|
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
|
let body = body::resolve(args.body.as_deref(), args.body_file.as_deref())?.unwrap_or_default();
|
|
if args.push {
|
|
push_branch(&args.remote, &args.head)?;
|
|
}
|
|
let repo = client.repo();
|
|
let payload = json!({
|
|
"title": args.title,
|
|
"head": args.head,
|
|
"base": args.base,
|
|
"body": body,
|
|
"draft": args.draft,
|
|
"allow_maintainer_edit": true,
|
|
});
|
|
let resp = client.post_json(&format!("/repos/{repo}/pulls"), &payload)?;
|
|
if let Some(url) = resp.get("html_url").and_then(Value::as_str) {
|
|
println!("{url}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run `git push <remote> <head>` and pass stderr through with the
|
|
/// post-push PR-hint block filtered out. On failure, bubble up
|
|
/// git's full stderr so the user sees why the push didn't land.
|
|
fn push_branch(remote: &str, head: &str) -> Result<()> {
|
|
let output = Command::new("git")
|
|
.args(["push", remote, head])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.with_context(|| format!("failed to spawn `git push {remote} {head}`"))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("`git push {remote} {head}` failed:\n{stderr}");
|
|
}
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let filtered = strip_pr_hint_block(&stderr);
|
|
if !filtered.is_empty() {
|
|
eprint!("{filtered}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop the 3-line PR-hint block forgejo appends after push:
|
|
///
|
|
/// ```text
|
|
/// remote: Create a new pull request for 'BRANCH':
|
|
/// remote: http://.../compare/...
|
|
/// remote:
|
|
/// ```
|
|
///
|
|
/// or the existing-PR variant. Other `remote:` lines (and everything
|
|
/// else) pass through unchanged.
|
|
fn strip_pr_hint_block(stderr: &str) -> String {
|
|
let mut out = String::with_capacity(stderr.len());
|
|
let mut lines = stderr.split_inclusive('\n').peekable();
|
|
while let Some(line) = lines.next() {
|
|
if is_pr_hint_opener(line) {
|
|
// Eat up to two follow-on `remote:` lines (URL + blank).
|
|
for _ in 0..2 {
|
|
if lines.peek().is_some_and(|next| is_remote_line(next)) {
|
|
lines.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
out.push_str(line);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn remote_content(line: &str) -> Option<&str> {
|
|
line.trim_end_matches(['\n', '\r'])
|
|
.trim_start()
|
|
.strip_prefix("remote:")
|
|
.map(str::trim_start)
|
|
}
|
|
|
|
fn is_remote_line(line: &str) -> bool {
|
|
remote_content(line).is_some()
|
|
}
|
|
|
|
fn is_pr_hint_opener(line: &str) -> bool {
|
|
remote_content(line).is_some_and(|c| {
|
|
c.starts_with("Create a new pull request")
|
|
|| c.starts_with("Visit the existing pull request")
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn strips_create_pr_hint_block() {
|
|
let stderr = "\
|
|
remote: Create a new pull request for 'feat/foo':\n\
|
|
remote: http://localhost:3000/hyperhive/hyperhive/compare/main...feat/foo\n\
|
|
remote: \n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
* [new branch] feat/foo -> feat/foo\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Create a new pull request"));
|
|
assert!(!cleaned.contains("compare/main"));
|
|
assert!(cleaned.contains("[new branch]"));
|
|
assert!(cleaned.contains("To http://localhost:3000/hyperhive/hyperhive.git"));
|
|
}
|
|
|
|
#[test]
|
|
fn strips_visit_existing_pr_hint_block() {
|
|
let stderr = "\
|
|
remote: Visit the existing pull request:\n\
|
|
remote: http://localhost:3000/hyperhive/hyperhive/pulls/527 merges into main\n\
|
|
remote: \n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
55430bb..bdfd437 feat/519-unify-prompts -> feat/519-unify-prompts\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Visit the existing pull request"));
|
|
assert!(!cleaned.contains("/pulls/527"));
|
|
assert!(cleaned.contains("55430bb..bdfd437"));
|
|
}
|
|
|
|
#[test]
|
|
fn passes_unrelated_remote_lines_through() {
|
|
// Real-world: pre-receive hooks, lint output, anything the
|
|
// forge or hooks decide to print via the sideband. Only the
|
|
// PR-hint block opener triggers the skip.
|
|
let stderr = "\
|
|
remote: pre-receive hook says: looks fine\n\
|
|
remote: actions queued: 2\n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert_eq!(cleaned, stderr);
|
|
}
|
|
|
|
#[test]
|
|
fn handles_empty_stderr() {
|
|
assert_eq!(strip_pr_hint_block(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn stops_eating_after_two_followups() {
|
|
// Defensive: if the forge ever drops the trailing blank
|
|
// `remote:` line, we still only eat what we expect (URL line)
|
|
// and pass the next line through.
|
|
let stderr = "\
|
|
remote: Create a new pull request for 'feat/foo':\n\
|
|
remote: http://localhost:3000/.../compare/...\n\
|
|
To http://localhost:3000/hyperhive/hyperhive.git\n\
|
|
* [new branch] feat/foo -> feat/foo\n";
|
|
let cleaned = strip_pr_hint_block(stderr);
|
|
assert!(!cleaned.contains("Create a new pull request"));
|
|
assert!(!cleaned.contains("/compare/"));
|
|
assert!(cleaned.contains("To http://"));
|
|
assert!(cleaned.contains("[new branch]"));
|
|
}
|
|
|
|
#[test]
|
|
fn opener_detector_recognises_both_phrasings() {
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Create a new pull request for 'x':\n"
|
|
));
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Visit the existing pull request:\n"
|
|
));
|
|
assert!(!is_pr_hint_opener("remote: some other thing\n"));
|
|
assert!(!is_pr_hint_opener("To http://example.com\n"));
|
|
// Trailing-CRLF safety on windows-cloned forge clones.
|
|
assert!(is_pr_hint_opener(
|
|
"remote: Create a new pull request for 'x':\r\n"
|
|
));
|
|
}
|
|
}
|