Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/forge/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ pub fn generate_access_token(
}
}

/// `admin repo set-pull-floor`: new pull requests of `owner/name` are numbered above `floor`, so
/// the numbers a repository had on the forge it was migrated from are never given out again. The
/// floor only rises: a lower value leaves it as it is.
pub fn repo_set_pull_floor(data_dir: &Path, owner: &str, name: &str, floor: i64) -> Result<()> {
if floor < 0 {
bail!("pull floor must not be negative");
}
let conn = db::open_for_admin(data_dir)?;
let repo = crate::repos::find_row(&conn, owner, name)?
.filter(|r| r.state == "live")
.ok_or_else(|| anyhow!("repository does not exist [{owner}/{name}]"))?;
conn.execute(
"UPDATE repos SET pull_floor = MAX(pull_floor, ?2) WHERE id = ?1",
rusqlite::params![repo.id, floor],
)?;
Ok(())
}

/// `admin org create`.
pub fn org_create(data_dir: &Path, name: &str, owner: &str) -> Result<()> {
if names::check(name).is_err() {
Expand Down
31 changes: 31 additions & 0 deletions crates/forge/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,25 @@ enum AdminCmd {
#[command(subcommand)]
cmd: OrgCmd,
},
Repo {
#[command(subcommand)]
cmd: RepoCmd,
},
}

#[derive(Subcommand)]
enum RepoCmd {
/// Number new pull requests above FLOOR (a migrated repository's highest old number).
SetPullFloor {
#[arg(long)]
owner: String,
#[arg(long)]
name: String,
#[arg(long)]
floor: i64,
#[arg(long)]
data_dir: PathBuf,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -259,6 +278,18 @@ fn run_admin(cmd: AdminCmd) -> Result<()> {
admin::org_create(&data_dir, &name, &owner)?;
println!("Organization '{name}' has been successfully created!");
}
AdminCmd::Repo {
cmd:
RepoCmd::SetPullFloor {
owner,
name,
floor,
data_dir,
},
} => {
admin::repo_set_pull_floor(&data_dir, &owner, &name, floor)?;
println!("Pull requests of {owner}/{name} are numbered above {floor}");
}
}
Ok(())
}
Expand Down
3 changes: 3 additions & 0 deletions crates/forge/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ const MIGRATIONS: &[&str] = &[
created_unix INTEGER NOT NULL
);
CREATE UNIQUE INDEX merge_intents_one_pending ON merge_intents (repo_id, pull_number) WHERE state = 'pending';",
// 6: a repository migrated from another forge keeps its old pull request numbers retired: new
// ones start above `pull_floor` (set by `sc-forge admin repo set-pull-floor`).
"ALTER TABLE repos ADD COLUMN pull_floor INTEGER NOT NULL DEFAULT 0;",
];

/// Every table the current schema creates; `/healthz` probes each, so a missing or unreadable one
Expand Down
39 changes: 28 additions & 11 deletions crates/forge/src/pulls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,14 @@ pub fn list(
Ok((rows, total))
}

/// One above both the repository's highest PR number and its pull-number floor.
const NEXT_NUMBER: &str =
"SELECT MAX(COALESCE((SELECT MAX(number) FROM pulls WHERE repo_id = ?1), 0), \
COALESCE((SELECT pull_floor FROM repos WHERE id = ?1), 0)) + 1";

/// The number the next PR of the repository gets.
pub fn next_number(conn: &Connection, repo_id: i64) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COALESCE(MAX(number), 0) + 1 FROM pulls WHERE repo_id = ?1",
[repo_id],
|r| r.get(0),
)
conn.query_row(NEXT_NUMBER, [repo_id], |r| r.get(0))
}

/// Insert a PR with the next number of the repository (PRs and, one day, issues share it).
Expand All @@ -198,11 +199,7 @@ pub fn insert(
now: i64,
) -> rusqlite::Result<Pull> {
let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
let number: i64 = tx.query_row(
"SELECT COALESCE(MAX(number), 0) + 1 FROM pulls WHERE repo_id = ?1",
[repo_id],
|r| r.get(0),
)?;
let number: i64 = tx.query_row(NEXT_NUMBER, [repo_id], |r| r.get(0))?;
tx.execute(
"INSERT INTO pulls (repo_id, number, head_ref, base_ref, base_sha, head_sha, title, body, \
state, creator_id, created_unix, updated_unix) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'open', ?9, ?10, ?10)",
Expand Down Expand Up @@ -757,10 +754,11 @@ mod tests {
use super::*;

fn conn() -> (Connection, std::path::PathBuf, i64, i64) {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!(
"scl-forge-pulls-{}-{}",
std::process::id(),
crate::timefmt::now()
N.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
));
let _ = std::fs::remove_dir_all(&d);
let conn = crate::db::open_for_admin(&d).unwrap();
Expand All @@ -778,6 +776,25 @@ mod tests {
(conn, d, repo.id, owner.id)
}

#[test]
fn a_pull_floor_retires_every_number_up_to_it() {
let (c, d, repo, user) = conn();
c.execute("UPDATE repos SET pull_floor = 41 WHERE id = ?1", [repo])
.unwrap();
assert_eq!(next_number(&c, repo).unwrap(), 42);
let a = insert(&c, repo, "f", "main", "b1", "h1", "t", "", user, 5).unwrap();
assert_eq!(a.number, 42);
c.execute("UPDATE repos SET pull_floor = 10 WHERE id = ?1", [repo])
.unwrap();
let b = insert(&c, repo, "g", "main", "b1", "h2", "t", "", user, 6).unwrap();
assert_eq!(
b.number, 43,
"a floor below the highest number changes nothing"
);
assert_eq!(next_number(&c, repo).unwrap(), 44);
let _ = std::fs::remove_dir_all(&d);
}

#[test]
fn numbers_are_sequential_and_one_open_pull_per_target_pair() {
let (c, d, repo, user) = conn();
Expand Down
20 changes: 20 additions & 0 deletions crates/forge/tests/e2e_pulls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,26 @@ fn create_validates_and_the_object_has_the_recorded_shape() {
assert_eq!(r.json()["number"], 2);
}

#[test]
fn a_pull_floor_set_on_a_running_server_retires_the_old_numbers() {
let w = world();
w.commit("f1", "f1.txt", Some("main"));
scl_forge::admin::repo_set_pull_floor(&w.s.dir, "siteadmin", "w", 41).unwrap();
scl_forge::admin::repo_set_pull_floor(&w.s.dir, "siteadmin", "w", 10).unwrap();
assert_eq!(
w.open("f1")["number"],
42,
"a lower floor does not reopen retired numbers"
);
assert_eq!(
w.api("GET", "/pulls/1", None).status,
404,
"an old number names nothing"
);
assert!(scl_forge::admin::repo_set_pull_floor(&w.s.dir, "siteadmin", "nope", 1).is_err());
assert!(scl_forge::admin::repo_set_pull_floor(&w.s.dir, "siteadmin", "w", -1).is_err());
}

#[test]
fn the_head_follows_the_branch_and_lists_are_ordered_and_paged() {
let w = world();
Expand Down
9 changes: 9 additions & 0 deletions docs/FORGE-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ sc-forge admin user create --data-dir /data --username <admin> --password <pw> -
sc-forge admin user generate-access-token --data-dir /data --username <admin> --token-name bootstrap --scopes all --raw
```

Migrating a repository from another forge: after pushing its branches and tags, retire its old pull request numbers so
references to them answer 404 instead of naming a new pull request (works while the server runs):

```sh
sc-forge admin repo set-pull-floor --data-dir /data --owner <org> --name <repo> --floor <highest old number>
```

This adds schema version 6 (the `pull_floor` column); an older binary refuses a data directory this build has opened.

## Serving

```sh
Expand Down
Loading