New repository page with board and starting files; assetlinks.json; hung tests stopped; changelog on the WASM tier (closes #360, closes #365, closes #370) 40a43921ef42
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c58d19d26330
- When
- · 2026-11-04 00:58 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
40a43921ef42001b58c1630c49f43567e245de7c681663036114c23ff60d75e2- Depends on
- 07b1a5bbf481 0bc95135954d 15d3f6f7871b 18e381ea88c2 19bb31ff1f5c 1f78655a0466 306aec1839aa 380996db84e5 3b1bc27fe805 5c6e92fd8d71 5d90e0e045e2 62ceb2eec18c 66fd9d6b1452 705d11b01a2c 8a48dd830178 8f3d2ccfe6fc 9008bf6c2b8c 93be9ce4653e a36bab6b9ffc aeed63c00ad7 b43a9ab0498f b5668108487f b64e2537d702 c26267b3aba6 cb7b3cd45dc0 d23464c0ac60 de505df5cf8d e1ec9fbf538e ef20fdc14e8b f2d2776b2a14 fbd7c8551932 fe0bb950a300
- Depended on by
- 1062a0f8e985
- In bookmarks
- main
- In tags
- v0.9.24
- Edits
- 65 atoms, 4 tree operations, 25 files
Browse the tree at this change
added .flow/checks.wasm +0 −0
No line changes.
added crates/hub/migrations-pg/0106_project_repos.sql +9 −0
| 1 | + | -- A repository's boards (oxygenhub#360): a board made or chosen with a new |
|
| 2 | + | -- repository takes its issues and proposals as they open, each a card at |
|
| 3 | + | -- the bottom of the board's first column. |
|
| 4 | + | CREATE TABLE project_repos ( |
|
| 5 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 6 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | PRIMARY KEY (project_id, repo_id) |
|
| 8 | + | ); |
|
| 9 | + | CREATE INDEX project_repos_by_repo ON project_repos (repo_id); |
added crates/hub/migrations/0106_project_repos.sql +9 −0
| 1 | + | -- A repository's boards (oxygenhub#360): a board made or chosen with a new |
|
| 2 | + | -- repository takes its issues and proposals as they open, each a card at |
|
| 3 | + | -- the bottom of the board's first column. |
|
| 4 | + | CREATE TABLE project_repos ( |
|
| 5 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 6 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | PRIMARY KEY (project_id, repo_id) |
|
| 8 | + | ); |
|
| 9 | + | CREATE INDEX project_repos_by_repo ON project_repos (repo_id); |
added crates/hub/src/repo_setup.rs +257 −0
| 1 | + | //! What a new repository starts with (oxygenhub#360), shared by the New |
|
| 2 | + | //! repository page and the API: a name check before anything is made, the |
|
| 3 | + | //! files it starts with (a README, a licence, an `.oxyignore` for a |
|
| 4 | + | //! language), committed as its creator the way the starter's are (#347), |
|
| 5 | + | //! and a project board made or chosen with it, which then takes its issues |
|
| 6 | + | //! and proposals as they open. |
|
| 7 | + | ||
| 8 | + | use crate::auth::roles; |
|
| 9 | + | use crate::db::{self, projects as pdb}; |
|
| 10 | + | use crate::repos::RepoName; |
|
| 11 | + | use crate::Shared; |
|
| 12 | + | use oxy_core::repo::FileEdit; |
|
| 13 | + | ||
| 14 | + | /// Whether `owner/name` may be made by `account`: `Ok` with its address, |
|
| 15 | + | /// or why not, said as the page and the API say it. |
|
| 16 | + | pub async fn check_name(app: &Shared, account: &db::accounts::Account, owner: &str, name: &str) -> Result<RepoName, String> { |
|
| 17 | + | let Some(n) = RepoName::new(owner.trim(), name.trim()) else { |
|
| 18 | + | return Err("A repository name is letters, digits, dots, dashes and underscores, up to 64.".into()); |
|
| 19 | + | }; |
|
| 20 | + | if n.name.ends_with(crate::wiki::SUFFIX) { |
|
| 21 | + | return Err("A name cannot end in .wiki: that is the address of a repository's wiki.".into()); |
|
| 22 | + | } |
|
| 23 | + | if !roles::may_create_under(&app.db, account, &n.owner).await.map_err(|e| e.to_string())? { |
|
| 24 | + | return Err(format!("You cannot create repositories under {}.", n.owner)); |
|
| 25 | + | } |
|
| 26 | + | if db::repos::get(&app.db, &n.owner, &n.name).await.map_err(|e| e.to_string())?.is_some() || app.repos.exists(&n) { |
|
| 27 | + | return Err(format!("{n} exists.")); |
|
| 28 | + | } |
|
| 29 | + | Ok(n) |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /// A board for a new repository: none, a new one under its owner with this |
|
| 33 | + | /// title, or the owner's board of this number. |
|
| 34 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 35 | + | pub enum Board { |
|
| 36 | + | None, |
|
| 37 | + | New(String), |
|
| 38 | + | Existing(i64), |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl Board { |
|
| 42 | + | /// The form's or the API's choice: `new` with a title (the |
|
| 43 | + | /// repository's name when empty), a board number, or nothing. |
|
| 44 | + | pub fn parse(choice: &str, title: &str, repo: &str) -> Result<Board, String> { |
|
| 45 | + | match choice.trim() { |
|
| 46 | + | "" | "none" => Ok(Board::None), |
|
| 47 | + | "new" => { |
|
| 48 | + | let t: String = title.trim().chars().take(100).collect(); |
|
| 49 | + | Ok(Board::New(if t.is_empty() { repo.to_string() } else { t })) |
|
| 50 | + | } |
|
| 51 | + | n => n.parse().map(Board::Existing).map_err(|_| format!("No board {n}.")), |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// Checked before the repository is made: a chosen board must be the |
|
| 57 | + | /// owner's, open, and one `account` may change. |
|
| 58 | + | pub async fn check_board(app: &Shared, account: &db::accounts::Account, owner: &str, board: &Board) -> Result<(), String> { |
|
| 59 | + | match board { |
|
| 60 | + | Board::None => Ok(()), |
|
| 61 | + | Board::New(_) => match crate::web::projects::rights_of(app, Some(account), owner).await.map_err(|e| e.to_string())? { |
|
| 62 | + | Some(r) if r.edit => Ok(()), |
|
| 63 | + | _ => Err(format!("You cannot make boards under {owner}.")), |
|
| 64 | + | }, |
|
| 65 | + | Board::Existing(n) => { |
|
| 66 | + | let p = pdb::get(&app.db, owner, *n).await.map_err(|e| e.to_string())?.ok_or(format!("{owner} has no board {n}."))?; |
|
| 67 | + | if p.closed { |
|
| 68 | + | return Err(format!("Board {n} is closed.")); |
|
| 69 | + | } |
|
| 70 | + | match crate::web::projects::rights_of(app, Some(account), owner).await.map_err(|e| e.to_string())? { |
|
| 71 | + | Some(r) if r.edit => Ok(()), |
|
| 72 | + | _ => Err(format!("You cannot change {owner}'s boards.")), |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | /// Make or find the board and link it to the repository; its number. |
|
| 79 | + | pub async fn link_board(app: &Shared, row: &db::repos::RepoRow, board: &Board, public: bool) -> sqlx::Result<Option<i64>> { |
|
| 80 | + | let p = match board { |
|
| 81 | + | Board::None => return Ok(None), |
|
| 82 | + | Board::New(title) => pdb::create(&app.db, &row.owner, title, &format!("Work on {}/{}", row.owner, row.name), public).await?, |
|
| 83 | + | Board::Existing(n) => match pdb::get(&app.db, &row.owner, *n).await? { |
|
| 84 | + | Some(p) => p, |
|
| 85 | + | None => return Ok(None), |
|
| 86 | + | }, |
|
| 87 | + | }; |
|
| 88 | + | pdb::link_repo(&app.db, p.id, row.id).await?; |
|
| 89 | + | Ok(Some(p.number)) |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /// The licences offered, with what each allows, for the page. |
|
| 93 | + | pub const LICENCES: &[(&str, &str)] = &[ |
|
| 94 | + | ("MIT", "use, change and share it freely, keeping the notice"), |
|
| 95 | + | ("BSD-2-Clause", "as MIT, in the BSD's words"), |
|
| 96 | + | ("ISC", "as MIT, in fewer words"), |
|
| 97 | + | ]; |
|
| 98 | + | ||
| 99 | + | /// The languages an `.oxyignore` is offered for. |
|
| 100 | + | pub const IGNORES: &[&str] = &["Rust", "Node", "Python", "Go", "Java and Kotlin"]; |
|
| 101 | + | ||
| 102 | + | /// What a new repository starts with: the files, by path. |
|
| 103 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 104 | + | pub struct Start { |
|
| 105 | + | pub readme: bool, |
|
| 106 | + | /// One of [`LICENCES`], or empty. |
|
| 107 | + | pub licence: String, |
|
| 108 | + | /// One of [`IGNORES`], or empty. |
|
| 109 | + | pub ignore: String, |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | impl Start { |
|
| 113 | + | pub fn is_empty(&self) -> bool { |
|
| 114 | + | !self.readme && self.licence.is_empty() && self.ignore.is_empty() |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | /// The files, for a repository `name` described as `description`, |
|
| 118 | + | /// whose copyright `holder` keeps in `year`. |
|
| 119 | + | pub fn files(&self, name: &RepoName, description: &str, holder: &str, year: i32) -> Result<Vec<(String, String)>, String> { |
|
| 120 | + | let mut out = Vec::new(); |
|
| 121 | + | if self.readme { |
|
| 122 | + | let about = if description.trim().is_empty() { String::new() } else { format!("{}\n\n", description.trim()) }; |
|
| 123 | + | out.push(("README.md".to_string(), format!("# {}\n\n{about}", name.name))); |
|
| 124 | + | } |
|
| 125 | + | if !self.licence.is_empty() { |
|
| 126 | + | let text = licence(&self.licence, holder, year).ok_or(format!("No licence {}.", self.licence))?; |
|
| 127 | + | out.push(("LICENSE".to_string(), text)); |
|
| 128 | + | } |
|
| 129 | + | if !self.ignore.is_empty() { |
|
| 130 | + | let text = ignore(&self.ignore).ok_or(format!("No .oxyignore for {}.", self.ignore))?; |
|
| 131 | + | out.push((".oxyignore".to_string(), text.to_string())); |
|
| 132 | + | } |
|
| 133 | + | Ok(out) |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | fn licence(id: &str, holder: &str, year: i32) -> Option<String> { |
|
| 138 | + | Some(match id { |
|
| 139 | + | "MIT" => format!( |
|
| 140 | + | "MIT License\n\nCopyright (c) {year} {holder}\n\n\ |
|
| 141 | + | Permission is hereby granted, free of charge, to any person obtaining a copy\n\ |
|
| 142 | + | of this software and associated documentation files (the \"Software\"), to deal\n\ |
|
| 143 | + | in the Software without restriction, including without limitation the rights\n\ |
|
| 144 | + | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n\ |
|
| 145 | + | copies of the Software, and to permit persons to whom the Software is\n\ |
|
| 146 | + | furnished to do so, subject to the following conditions:\n\n\ |
|
| 147 | + | The above copyright notice and this permission notice shall be included in all\n\ |
|
| 148 | + | copies or substantial portions of the Software.\n\n\ |
|
| 149 | + | THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n\ |
|
| 150 | + | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n\ |
|
| 151 | + | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n\ |
|
| 152 | + | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n\ |
|
| 153 | + | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n\ |
|
| 154 | + | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n\ |
|
| 155 | + | SOFTWARE.\n" |
|
| 156 | + | ), |
|
| 157 | + | "BSD-2-Clause" => format!( |
|
| 158 | + | "BSD 2-Clause License\n\nCopyright (c) {year}, {holder}\n\n\ |
|
| 159 | + | Redistribution and use in source and binary forms, with or without\n\ |
|
| 160 | + | modification, are permitted provided that the following conditions are met:\n\n\ |
|
| 161 | + | 1. Redistributions of source code must retain the above copyright notice, this\n \ |
|
| 162 | + | list of conditions and the following disclaimer.\n\n\ |
|
| 163 | + | 2. Redistributions in binary form must reproduce the above copyright notice,\n \ |
|
| 164 | + | this list of conditions and the following disclaimer in the documentation\n \ |
|
| 165 | + | and/or other materials provided with the distribution.\n\n\ |
|
| 166 | + | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n\ |
|
| 167 | + | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n\ |
|
| 168 | + | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n\ |
|
| 169 | + | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\n\ |
|
| 170 | + | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\n\ |
|
| 171 | + | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\n\ |
|
| 172 | + | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\n\ |
|
| 173 | + | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\n\ |
|
| 174 | + | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n\ |
|
| 175 | + | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" |
|
| 176 | + | ), |
|
| 177 | + | "ISC" => format!( |
|
| 178 | + | "ISC License\n\nCopyright (c) {year} {holder}\n\n\ |
|
| 179 | + | Permission to use, copy, modify, and/or distribute this software for any\n\ |
|
| 180 | + | purpose with or without fee is hereby granted, provided that the above\n\ |
|
| 181 | + | copyright notice and this permission notice appear in all copies.\n\n\ |
|
| 182 | + | THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\n\ |
|
| 183 | + | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n\ |
|
| 184 | + | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\n\ |
|
| 185 | + | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n\ |
|
| 186 | + | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n\ |
|
| 187 | + | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n\ |
|
| 188 | + | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" |
|
| 189 | + | ), |
|
| 190 | + | _ => return None, |
|
| 191 | + | }) |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | fn ignore(lang: &str) -> Option<&'static str> { |
|
| 195 | + | Some(match lang { |
|
| 196 | + | "Rust" => "# Build output\ntarget/\n", |
|
| 197 | + | "Node" => "# Dependencies and build output\nnode_modules/\ndist/\n*.log\n", |
|
| 198 | + | "Python" => "# Caches, environments and build output\n__pycache__/\n*.pyc\n.venv/\nbuild/\ndist/\n*.egg-info/\n", |
|
| 199 | + | "Go" => "# Build output\nbin/\n*.test\n", |
|
| 200 | + | "Java and Kotlin" => "# Build output and tool state\nbuild/\n.gradle/\nout/\n*.class\n", |
|
| 201 | + | _ => return None, |
|
| 202 | + | }) |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | /// Commit `files` to the new repository's default bookmark as `by`, signed |
|
| 206 | + | /// as their delegate (as the starter's are); a repository with no files |
|
| 207 | + | /// yet only. |
|
| 208 | + | pub async fn commit_start( |
|
| 209 | + | app: &Shared, |
|
| 210 | + | row: &db::repos::RepoRow, |
|
| 211 | + | by: &db::accounts::Account, |
|
| 212 | + | files: Vec<(String, String)>, |
|
| 213 | + | ) -> Result<(), String> { |
|
| 214 | + | if files.is_empty() { |
|
| 215 | + | return Ok(()); |
|
| 216 | + | } |
|
| 217 | + | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 218 | + | let signer = crate::web::edit::signer_for(app, by.id).await?; |
|
| 219 | + | let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect(); |
|
| 220 | + | let edits: Vec<FileEdit> = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect(); |
|
| 221 | + | let b = row.default_bookmark.clone(); |
|
| 222 | + | crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, format!("Start with {}", paths.join(", ")), signer) |
|
| 223 | + | .await |
|
| 224 | + | .map_err(|e| e.to_string())?; |
|
| 225 | + | db::accounts::audit(&app.db, Some(by), "repo.started", &name.to_string(), &paths.join(", ")).await.ok(); |
|
| 226 | + | Ok(()) |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | #[cfg(test)] |
|
| 230 | + | mod tests { |
|
| 231 | + | use super::*; |
|
| 232 | + | ||
| 233 | + | #[test] |
|
| 234 | + | fn boards_and_starting_files() { |
|
| 235 | + | assert_eq!(Board::parse("", "", "demo").unwrap(), Board::None); |
|
| 236 | + | assert_eq!(Board::parse("new", " ", "demo").unwrap(), Board::New("demo".into())); |
|
| 237 | + | assert_eq!(Board::parse("new", "Roadmap", "demo").unwrap(), Board::New("Roadmap".into())); |
|
| 238 | + | assert_eq!(Board::parse("4", "", "demo").unwrap(), Board::Existing(4)); |
|
| 239 | + | assert!(Board::parse("four", "", "demo").is_err()); |
|
| 240 | + | let name = RepoName::new("andy", "demo").unwrap(); |
|
| 241 | + | let s = Start { readme: true, licence: "MIT".into(), ignore: "Rust".into() }; |
|
| 242 | + | let files = s.files(&name, "A demo.", "Andy McCall", 2026).unwrap(); |
|
| 243 | + | let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect(); |
|
| 244 | + | assert_eq!(paths, ["README.md", "LICENSE", ".oxyignore"]); |
|
| 245 | + | assert_eq!(files[0].1, "# demo\n\nA demo.\n\n"); |
|
| 246 | + | assert!(files[1].1.starts_with("MIT License\n\nCopyright (c) 2026 Andy McCall\n")); |
|
| 247 | + | assert_eq!(files[2].1, "# Build output\ntarget/\n"); |
|
| 248 | + | for (id, _) in LICENCES { |
|
| 249 | + | assert!(licence(id, "X", 2026).unwrap().contains("2026"), "{id}"); |
|
| 250 | + | } |
|
| 251 | + | for lang in IGNORES { |
|
| 252 | + | assert!(ignore(lang).is_some(), "{lang}"); |
|
| 253 | + | } |
|
| 254 | + | assert!(Start { licence: "GPL".into(), ..Default::default() }.files(&name, "", "X", 2026).is_err()); |
|
| 255 | + | assert!(Start::default().is_empty()); |
|
| 256 | + | } |
|
| 257 | + | } |
modified .config/nextest.toml +3 −0
| … | 2 unchanged lines |
||
| 3 | 3 | # (the job's `junit` artifact). |
|
| 4 | 4 | [profile.ci] |
|
| 5 | 5 | fail-fast = false |
|
| 6 | + | # A test still running after ten minutes is stopped and fails (#365): |
|
| 7 | + | # a hang in run #537 held its runner for an hour. |
|
| 8 | + | slow-timeout = { period = "60s", terminate-after = 10 } |
|
| 6 | 9 | ||
| 7 | 10 | [profile.ci.junit] |
|
| 8 | 11 | path = "junit.xml" |
modified .flow/pipeline.kdl +17 −0
| … | 3 unchanged lines |
||
| 4 | 4 | // from the pinned subrepos in deps/, which the runner puts in the workspace. |
|
| 5 | 5 | schema-version 1 |
|
| 6 | 6 | ||
| 7 | + | // Cheap checks on the WASM tier (oxygenflow#96): OxygenFlow's checks module, |
|
| 8 | + | // copied from oxygenflow/.flow/checks.wasm (built there from |
|
| 9 | + | // crates/flow-checks; copy it again when it changes). No image and no KVM, |
|
| 10 | + | // so any runner with the WASM tier takes it: a phone, plex, windows-1. |
|
| 11 | + | sandbox "wasm" { |
|
| 12 | + | runtime "wasm" |
|
| 13 | + | memory-limit "64MiB" |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | // The changelog's shape, and a section for the version Cargo.toml says, so a |
|
| 17 | + | // release never finds its notes missing. |
|
| 18 | + | job "changelog" { |
|
| 19 | + | sandbox "wasm" |
|
| 20 | + | scope "CHANGELOG.md" "Cargo.toml" ".flow/checks.wasm" |
|
| 21 | + | exec ".flow/checks.wasm" "changelog" "CHANGELOG.md" "Cargo.toml" |
|
| 22 | + | } |
|
| 23 | + | ||
| 7 | 24 | // The OxygenFlow build image: Rust with rustfmt and clippy, Node, every |
|
| 8 | 25 | // crate of Cargo.lock vendored, and this workspace's dependencies prebuilt |
|
| 9 | 26 | // in /opt/warm/oxygenhub, which .flow/warm-target.sh copies onto a job's |
|
| … | 171 unchanged lines |
modified .flow/test-postgres.sh +3 −1
| … | 18 unchanged lines |
||
| 19 | 19 | unset LD_PRELOAD NSS_WRAPPER_PASSWD NSS_WRAPPER_GROUP |
|
| 20 | 20 | echo "flow: $(postgres --version) on 127.0.0.1:55432" |
|
| 21 | 21 | export OXYHUB_DATABASE_URL=postgres://[email protected]:55432/postgres OXYHUB_DATABASE_SCHEMA='*' |
|
| 22 | - | exec sh .flow/warm-target.sh cargo test -p oxygenhub --features postgres --locked |
|
| 22 | + | # Under nextest, as the test job: a test that hangs is stopped at the CI |
|
| 23 | + | # profile's limit and reported, not left holding the runner (#365). |
|
| 24 | + | exec sh .flow/warm-target.sh cargo nextest run -p oxygenhub --features postgres --locked --profile ci |
modified CHANGELOG.md +3 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A fresh New repository page (#360):** owner and name side by side with a live check that the name is free and valid and the address it will have; visibility as three described choices; **Start with** a README, a licence (MIT, BSD 2-Clause or ISC, with what each allows) and an `.oxyignore` for a language, committed as you with the hub signing as your delegate (it says what to do first when you have not delegated, before anything is made); and a **project board** with it: a new one (Backlog, Doing, Done, on by default for an organisation) or one of the owner's, which takes the repository's issues and proposals as they open. Storage moves under its own heading. The API's create takes the same (`project: {"new": TITLE} | {"number": N}`, `readme`, `licence`, `ignore`), and `oh repo create OWNER/NAME [--project new:TITLE|N] [--readme] [--licence MIT] [--ignore Rust]`. |
|
| 8 | + | - **oxygenhub.io links open the Android app (#370):** `/.well-known/assetlinks.json` vouches for the app (its package and release key's fingerprint, `[push] android_apps` to change), so Android opens the hub's links in it once the app claims them (oxygenhub-android#36). |
|
| 9 | + | - **The changelog is checked on the WASM tier:** a `changelog` job in the pipeline runs OxygenFlow's checks module (no image, no KVM; a phone or plex takes it) and fails when the changelog's sections are out of shape or Cargo.toml's version has none. |
|
| 7 | 10 | - **Board views (#361):** a board shows what matters now and other views of the same cards. |
|
| 8 | 11 | - **Saved views** as tabs across the top: a layout with its filter, lanes and order, saved under a name and shared by its address (`?view=NAME`). |
|
| 9 | 12 | - **One filter bar** for every view: words, `repo:`, `assignee:`, `label:`, `milestone:` (quote a title with spaces), `state:`, `is:open|closed`, `age:>14d` and `age:<7d`. |
|
| … | 469 unchanged lines |
modified assets/hub.js +55 −0
| … | 1070 unchanged lines |
||
| 1071 | 1071 | }); |
|
| 1072 | 1072 | }); |
|
| 1073 | 1073 | })(); |
|
| 1074 | + | ||
| 1075 | + | // The New repository page (oxygenhub#360): the name checked as it is |
|
| 1076 | + | // typed, the board choices shown as they apply, an owner's own boards. |
|
| 1077 | + | (function () { |
|
| 1078 | + | var form = document.querySelector("[data-new-repo]"); |
|
| 1079 | + | if (!form) return; |
|
| 1080 | + | var owner = form.querySelector("#owner"); |
|
| 1081 | + | var name = form.querySelector("#name"); |
|
| 1082 | + | var out = form.querySelector("#name-check"); |
|
| 1083 | + | var timer = null; |
|
| 1084 | + | function check() { |
|
| 1085 | + | var n = name.value.trim(); |
|
| 1086 | + | if (!n) { out.textContent = ""; out.className = "small name-check"; return; } |
|
| 1087 | + | fetch("/new/check?owner=" + encodeURIComponent(owner.value) + "&name=" + encodeURIComponent(n), { credentials: "same-origin" }) |
|
| 1088 | + | .then(function (r) { return r.json(); }) |
|
| 1089 | + | .then(function (v) { |
|
| 1090 | + | out.textContent = v.ok ? v.message + " Its address: " + v.address : v.message; |
|
| 1091 | + | out.className = "small name-check " + (v.ok ? "ok-text" : "err-text"); |
|
| 1092 | + | }) |
|
| 1093 | + | .catch(function () {}); |
|
| 1094 | + | } |
|
| 1095 | + | function later() { clearTimeout(timer); timer = setTimeout(check, 250); } |
|
| 1096 | + | name.addEventListener("input", later); |
|
| 1097 | + | var orgs = (owner.getAttribute("data-orgs") || "").split(" ").filter(Boolean); |
|
| 1098 | + | var list = form.querySelector("[data-board-list]"); |
|
| 1099 | + | var title = form.querySelector(".board-title"); |
|
| 1100 | + | var existing = form.querySelector(".board-existing"); |
|
| 1101 | + | function boards() { |
|
| 1102 | + | var chosen = form.querySelector("input[name=board]:checked"); |
|
| 1103 | + | var v = chosen ? chosen.value : ""; |
|
| 1104 | + | if (title) title.hidden = v !== "new"; |
|
| 1105 | + | if (existing) existing.hidden = v !== "existing"; |
|
| 1106 | + | if (list) { |
|
| 1107 | + | var first = null; |
|
| 1108 | + | Array.prototype.forEach.call(list.options, function (o) { |
|
| 1109 | + | var mine = o.getAttribute("data-owner") === owner.value; |
|
| 1110 | + | o.hidden = !mine; o.disabled = !mine; |
|
| 1111 | + | if (mine && !first) first = o; |
|
| 1112 | + | }); |
|
| 1113 | + | if (list.selectedOptions.length && list.selectedOptions[0].disabled && first) first.selected = true; |
|
| 1114 | + | var exRadio = form.querySelector("[data-board-existing]"); |
|
| 1115 | + | if (exRadio) exRadio.parentElement.hidden = !first; |
|
| 1116 | + | } |
|
| 1117 | + | } |
|
| 1118 | + | owner.addEventListener("change", function () { |
|
| 1119 | + | // An organisation's repository has a board by default. |
|
| 1120 | + | var neu = form.querySelector("[data-board-new]"); |
|
| 1121 | + | if (neu && orgs.indexOf(owner.value) >= 0) neu.checked = true; |
|
| 1122 | + | boards(); |
|
| 1123 | + | later(); |
|
| 1124 | + | }); |
|
| 1125 | + | form.querySelectorAll("input[name=board]").forEach(function (r) { r.addEventListener("change", boards); }); |
|
| 1126 | + | if (orgs.indexOf(owner.value) >= 0) { var n0 = form.querySelector("[data-board-new]"); if (n0) n0.checked = true; } |
|
| 1127 | + | boards(); |
|
| 1128 | + | })(); |
modified crates/hub-api/src/lib.rs +24 −0
| … | 986 unchanged lines |
||
| 987 | 987 | pub description: String, |
|
| 988 | 988 | #[serde(default = "public")] |
|
| 989 | 989 | pub visibility: Visibility, |
|
| 990 | + | /// A project board with it (#360): `{"new": "Title"}` (an empty title |
|
| 991 | + | /// is the repository's name) or `{"number": N}`, the owner's board; its |
|
| 992 | + | /// issues and proposals then join that board as they open. |
|
| 993 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 994 | + | pub project: Option<RepoProject>, |
|
| 995 | + | /// Files it starts with, committed as the caller (#360): a README, a |
|
| 996 | + | /// licence (`MIT`, `BSD-2-Clause`, `ISC`), an `.oxyignore` for a |
|
| 997 | + | /// language (`Rust`, `Node`, `Python`, `Go`, `Java and Kotlin`). |
|
| 998 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 999 | + | pub readme: bool, |
|
| 1000 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1001 | + | pub licence: Option<String>, |
|
| 1002 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1003 | + | pub ignore: Option<String>, |
|
| 1004 | + | } |
|
| 1005 | + | ||
| 1006 | + | /// A new repository's board: a new one, or the owner's existing one. |
|
| 1007 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1008 | + | pub struct RepoProject { |
|
| 1009 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1010 | + | pub new: Option<String>, |
|
| 1011 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1012 | + | pub number: Option<i64>, |
|
| 990 | 1013 | } |
|
| 991 | 1014 | ||
| 992 | 1015 | fn public() -> Visibility { |
|
| … | 718 unchanged lines |
||
| 1711 | 1734 | ("NewProject", schemars::schema_for!(NewProject)), |
|
| 1712 | 1735 | ("ProjectEdit", schemars::schema_for!(ProjectEdit)), |
|
| 1713 | 1736 | ("NewColumn", schemars::schema_for!(NewColumn)), |
|
| 1737 | + | ("RepoProject", schemars::schema_for!(RepoProject)), |
|
| 1714 | 1738 | ("ProjectView", schemars::schema_for!(ProjectView)), |
|
| 1715 | 1739 | ("BoardCharts", schemars::schema_for!(BoardCharts)), |
|
| 1716 | 1740 | ("ColumnEdit", schemars::schema_for!(ColumnEdit)), |
|
| … | 336 unchanged lines |
modified crates/hub/src/api/mod.rs +22 −0
| … | 1075 unchanged lines |
||
| 1076 | 1076 | if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) { |
|
| 1077 | 1077 | return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into())); |
|
| 1078 | 1078 | } |
|
| 1079 | + | // Its board and starting files, checked before anything is made (#360). |
|
| 1080 | + | use crate::repo_setup::{Board, Start}; |
|
| 1081 | + | let board = match &body.project { |
|
| 1082 | + | None => Board::None, |
|
| 1083 | + | Some(p) => match (&p.new, p.number) { |
|
| 1084 | + | (Some(t), None) => Board::New(if t.trim().is_empty() { name.name.clone() } else { t.trim().chars().take(100).collect() }), |
|
| 1085 | + | (None, Some(n)) => Board::Existing(n), |
|
| 1086 | + | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "project is {\"new\": TITLE} or {\"number\": N}".into())), |
|
| 1087 | + | }, |
|
| 1088 | + | }; |
|
| 1089 | + | crate::repo_setup::check_board(&app, me, &name.owner, &board).await.map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?; |
|
| 1090 | + | let start = |
|
| 1091 | + | Start { readme: body.readme, licence: body.licence.clone().unwrap_or_default(), ignore: body.ignore.clone().unwrap_or_default() }; |
|
| 1092 | + | let holder = if me.display_name.trim().is_empty() { me.username.clone() } else { me.display_name.clone() }; |
|
| 1093 | + | let files = start |
|
| 1094 | + | .files(&name, &body.description, &holder, time::OffsetDateTime::now_utc().year()) |
|
| 1095 | + | .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 1096 | + | if !files.is_empty() { |
|
| 1097 | + | crate::web::edit::signer_for(&app, me.id).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1098 | + | } |
|
| 1079 | 1099 | app.repos.create_async(&name, None).await?; |
|
| 1080 | 1100 | let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?; |
|
| 1081 | 1101 | db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok(); |
|
| 1102 | + | crate::repo_setup::link_board(&app, &row, &board, body.visibility == Visibility::Public).await?; |
|
| 1103 | + | crate::repo_setup::commit_start(&app, &row, me, files).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))?; |
|
| 1082 | 1104 | Ok((StatusCode::CREATED, Json(repository(&app, &row)))) |
|
| 1083 | 1105 | } |
|
| 1084 | 1106 | ||
| … | 2433 unchanged lines |
modified crates/hub/src/config.rs +11 −0
| … | 64 unchanged lines |
||
| 65 | 65 | /// elsewhere (tests). |
|
| 66 | 66 | pub fcm_url: String, |
|
| 67 | 67 | pub token_url: String, |
|
| 68 | + | /// The Android apps this hub's links open (App Links, #370), each |
|
| 69 | + | /// `PACKAGE:SHA256` with the release key's certificate fingerprint as |
|
| 70 | + | /// colon-separated hex; served at `/.well-known/assetlinks.json`. The |
|
| 71 | + | /// official app by default; empty for none. |
|
| 72 | + | pub android_apps: Vec<String>, |
|
| 68 | 73 | } |
|
| 69 | 74 | ||
| 75 | + | /// The OxygenHub Android app's package and its release key's SHA-256 |
|
| 76 | + | /// certificate fingerprint (oxygenhub-android CHANGELOG, 0.1.0). |
|
| 77 | + | pub const OFFICIAL_ANDROID_APP: &str = |
|
| 78 | + | "io.oxygenhub.android:B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48"; |
|
| 79 | + | ||
| 70 | 80 | impl Default for Push { |
|
| 71 | 81 | fn default() -> Self { |
|
| 72 | 82 | Push { |
|
| 73 | 83 | fcm_project: String::new(), |
|
| 74 | 84 | fcm_url: "https://fcm.googleapis.com".into(), |
|
| 75 | 85 | token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(), |
|
| 86 | + | android_apps: vec![OFFICIAL_ANDROID_APP.into()], |
|
| 76 | 87 | } |
|
| 77 | 88 | } |
|
| 78 | 89 | } |
|
| … | 621 unchanged lines |
modified crates/hub/src/db/issues.rs +5 −2
| … | 71 unchanged lines |
||
| 72 | 72 | pub async fn create(pool: &Pool, i: NewIssue<'_>) -> sqlx::Result<IssueRow> { |
|
| 73 | 73 | let number = super::proposals::next_number(pool, i.repo_id).await?; |
|
| 74 | 74 | let now = now_ms(); |
|
| 75 | - | sqlx::query_as("INSERT INTO issues (repo_id, number, title, body, author_id, author, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *") |
|
| 75 | + | let row: IssueRow = sqlx::query_as("INSERT INTO issues (repo_id, number, title, body, author_id, author, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *") |
|
| 76 | 76 | .bind(i.repo_id) |
|
| 77 | 77 | .bind(number) |
|
| 78 | 78 | .bind(i.title) |
|
| … | 3 unchanged lines |
||
| 82 | 82 | .bind(now) |
|
| 83 | 83 | .bind(now) |
|
| 84 | 84 | .fetch_one(pool) |
|
| 85 | - | .await |
|
| 85 | + | .await?; |
|
| 86 | + | // Onto the boards the repository is linked to (#360). |
|
| 87 | + | super::projects::opened(pool, row.repo_id, "issue", row.number, &row.author).await?; |
|
| 88 | + | Ok(row) |
|
| 86 | 89 | } |
|
| 87 | 90 | ||
| 88 | 91 | pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<IssueRow>> { |
|
| … | 338 unchanged lines |
modified crates/hub/src/db/projects.rs +38 −0
| … | 793 unchanged lines |
||
| 794 | 794 | .await?; |
|
| 795 | 795 | touch(pool, project_id).await |
|
| 796 | 796 | } |
|
| 797 | + | ||
| 798 | + | /// Link a board to a repository (#360): its issues and proposals join |
|
| 799 | + | /// the board as they open. |
|
| 800 | + | pub async fn link_repo(pool: &Pool, project_id: i64, repo_id: i64) -> sqlx::Result<()> { |
|
| 801 | + | sqlx::query("INSERT INTO project_repos (project_id, repo_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") |
|
| 802 | + | .bind(project_id) |
|
| 803 | + | .bind(repo_id) |
|
| 804 | + | .execute(pool) |
|
| 805 | + | .await?; |
|
| 806 | + | Ok(()) |
|
| 807 | + | } |
|
| 808 | + | ||
| 809 | + | /// The boards a repository is linked to. |
|
| 810 | + | pub async fn boards_of_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> { |
|
| 811 | + | sqlx::query_scalar("SELECT project_id FROM project_repos WHERE repo_id = $1 ORDER BY project_id").bind(repo_id).fetch_all(pool).await |
|
| 812 | + | } |
|
| 813 | + | ||
| 814 | + | /// Issue or proposal `number` of `repo_id` opened (#360): a card for it at |
|
| 815 | + | /// the bottom of the first column of each board the repository is linked |
|
| 816 | + | /// to; an open board only. |
|
| 817 | + | pub async fn opened(pool: &Pool, repo_id: i64, kind: &str, number: i64, by: &str) -> sqlx::Result<()> { |
|
| 818 | + | for project_id in boards_of_repo(pool, repo_id).await? { |
|
| 819 | + | let closed: Option<bool> = |
|
| 820 | + | sqlx::query_scalar("SELECT closed FROM projects WHERE id = $1").bind(project_id).fetch_optional(pool).await?; |
|
| 821 | + | if closed != Some(false) { |
|
| 822 | + | continue; |
|
| 823 | + | } |
|
| 824 | + | if let Some(first) = columns(pool, project_id).await?.first() { |
|
| 825 | + | add_card( |
|
| 826 | + | pool, |
|
| 827 | + | project_id, |
|
| 828 | + | NewCard { column_id: first.id, kind, repo_id: Some(repo_id), number: Some(number), note: "", added_by: by }, |
|
| 829 | + | ) |
|
| 830 | + | .await?; |
|
| 831 | + | } |
|
| 832 | + | } |
|
| 833 | + | Ok(()) |
|
| 834 | + | } |
modified crates/hub/src/db/proposals.rs +5 −2
| … | 124 unchanged lines |
||
| 125 | 125 | pub async fn create(pool: &Pool, p: NewProposal<'_>) -> sqlx::Result<ProposalRow> { |
|
| 126 | 126 | let number = next_number(pool, p.repo_id).await?; |
|
| 127 | 127 | let now = now_ms(); |
|
| 128 | - | sqlx::query_as( |
|
| 128 | + | let row: ProposalRow = sqlx::query_as( |
|
| 129 | 129 | "INSERT INTO proposals (repo_id, number, title, body, author_id, author, state, target, source_kind, source_repo_id, source_bookmark, source_changes, created_ms, updated_ms) \ |
|
| 130 | 130 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *", |
|
| 131 | 131 | ) |
|
| … | 12 unchanged lines |
||
| 144 | 144 | .bind(now) |
|
| 145 | 145 | .bind(now) |
|
| 146 | 146 | .fetch_one(pool) |
|
| 147 | - | .await |
|
| 147 | + | .await?; |
|
| 148 | + | // Onto the boards the repository is linked to (#360). |
|
| 149 | + | super::projects::opened(pool, row.repo_id, "proposal", row.number, &row.author).await?; |
|
| 150 | + | Ok(row) |
|
| 148 | 151 | } |
|
| 149 | 152 | ||
| 150 | 153 | pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<ProposalRow>> { |
|
| … | 299 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 43 unchanged lines |
||
| 44 | 44 | pub mod push_protection; |
|
| 45 | 45 | pub mod queue; |
|
| 46 | 46 | pub mod render; |
|
| 47 | + | pub mod repo_setup; |
|
| 47 | 48 | pub mod reports; |
|
| 48 | 49 | pub mod repos; |
|
| 49 | 50 | pub mod runner_updates; |
|
| … | 382 unchanged lines |
modified crates/hub/src/web/home.rs +19 −0
| … | 450 unchanged lines |
||
| 451 | 451 | .into_response() |
|
| 452 | 452 | } |
|
| 453 | 453 | ||
| 454 | + | /// `/.well-known/assetlinks.json` (#370): the Android apps whose App Links |
|
| 455 | + | /// this hub vouches for, so its links open them rather than the browser. |
|
| 456 | + | pub async fn assetlinks(State(app): State<Shared>) -> Response { |
|
| 457 | + | let apps: Vec<serde_json::Value> = app |
|
| 458 | + | .config |
|
| 459 | + | .push |
|
| 460 | + | .android_apps |
|
| 461 | + | .iter() |
|
| 462 | + | .filter_map(|a| { |
|
| 463 | + | let (package, fp) = a.split_once(':')?; |
|
| 464 | + | Some(serde_json::json!({ |
|
| 465 | + | "relation": ["delegate_permission/common.handle_all_urls"], |
|
| 466 | + | "target": { "namespace": "android_app", "package_name": package, "sha256_cert_fingerprints": [fp.to_ascii_uppercase()] } |
|
| 467 | + | })) |
|
| 468 | + | }) |
|
| 469 | + | .collect(); |
|
| 470 | + | ([(header::CONTENT_TYPE, "application/json")], serde_json::Value::Array(apps).to_string()).into_response() |
|
| 471 | + | } |
|
| 472 | + | ||
| 454 | 473 | /// The hub's TLS certificate (hub spec §15.5), for clients of a |
|
| 455 | 474 | /// developer edition with a self-signed certificate: save it and point |
|
| 456 | 475 | /// `oxy` at it with `OXY_CA` or `oxy config remote.<name>.ca`. |
|
| … | 41 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 86 unchanged lines |
||
| 87 | 87 | .route("/health", get(home::health)) |
|
| 88 | 88 | .route("/about", get(home::about)) |
|
| 89 | 89 | .route("/.well-known/oxyhub-key", get(home::hub_key)) |
|
| 90 | + | .route("/.well-known/assetlinks.json", get(home::assetlinks)) |
|
| 90 | 91 | .route("/metrics", get(crate::metrics::metrics)) |
|
| 91 | 92 | .route("/log", get(transparency::page)) |
|
| 92 | 93 | .route("/log/checkpoint", get(crate::tlog::checkpoint)) |
|
| … | 46 unchanged lines |
||
| 139 | 140 | .route("/settings/tokens/{id}/delete", post(settings::tokens_delete)) |
|
| 140 | 141 | .route("/settings/tokens/{id}/agent", post(settings::tokens_agent)) |
|
| 141 | 142 | .route("/new", get(repo_settings::new_form).post(repo_settings::create)) |
|
| 143 | + | .route("/new/check", get(repo_settings::check_name)) |
|
| 142 | 144 | .route("/orgs/new", get(orgs::new_form).post(orgs::create)) |
|
| 143 | 145 | .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update)) |
|
| 144 | 146 | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| … | 832 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +132 −16
| … | 18 unchanged lines |
||
| 19 | 19 | shell: Shell, |
|
| 20 | 20 | owners: Vec<String>, |
|
| 21 | 21 | error: Option<String>, |
|
| 22 | + | /// The open boards of each owner the viewer may change, for "add it to |
|
| 23 | + | /// an existing board" (#360): (owner, number, title). |
|
| 24 | + | boards: Vec<(String, i64, String)>, |
|
| 25 | + | /// Owners that are organisations: a board is on by default for them. |
|
| 26 | + | orgs: Vec<String>, |
|
| 27 | + | licences: &'static [(&'static str, &'static str)], |
|
| 28 | + | ignores: &'static [&'static str], |
|
| 29 | + | base_url: String, |
|
| 22 | 30 | } |
|
| 23 | 31 | ||
| 32 | + | /// The New repository page's choices for `me` (#360). |
|
| 33 | + | async fn new_page(app: &Shared, shell: Shell, me: &accounts::Account, error: Option<String>) -> NewRepoPage { |
|
| 34 | + | let owners = owners_for(app, me).await; |
|
| 35 | + | let mut boards = Vec::new(); |
|
| 36 | + | let mut orgs = Vec::new(); |
|
| 37 | + | for o in &owners { |
|
| 38 | + | if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() { |
|
| 39 | + | orgs.push(o.clone()); |
|
| 40 | + | } |
|
| 41 | + | let may = crate::web::projects::rights_of(app, Some(me), o).await.ok().flatten().is_some_and(|r| r.edit); |
|
| 42 | + | if may { |
|
| 43 | + | for p in db::projects::of_owner(&app.db, o).await.unwrap_or_default().into_iter().filter(|p| !p.closed) { |
|
| 44 | + | boards.push((o.clone(), p.number, p.title)); |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | NewRepoPage { |
|
| 49 | + | shell, |
|
| 50 | + | owners, |
|
| 51 | + | error, |
|
| 52 | + | boards, |
|
| 53 | + | orgs, |
|
| 54 | + | licences: crate::repo_setup::LICENCES, |
|
| 55 | + | ignores: crate::repo_setup::IGNORES, |
|
| 56 | + | base_url: app.config.base_url().trim_end_matches('/').to_string(), |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | ||
| 24 | 60 | pub(crate) async fn owners_for(app: &Shared, me: &accounts::Account) -> Vec<String> { |
|
| 25 | 61 | let mut owners = vec![me.username.clone()]; |
|
| 26 | 62 | if let Ok(orgs) = accounts::orgs_of(&app.db, me.id).await { |
|
| … | 5 unchanged lines |
||
| 32 | 68 | pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<NewRepoPage>, Error> { |
|
| 33 | 69 | let shell = Shell::new(&app, "/new", &viewer); |
|
| 34 | 70 | let me = require(&viewer, &shell)?; |
|
| 35 | - | let owners = owners_for(&app, &me.account).await; |
|
| 36 | - | Ok(Page(NewRepoPage { shell, owners, error: None })) |
|
| 71 | + | Ok(Page(new_page(&app, shell, &me.account, None).await)) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | #[derive(Deserialize)] |
|
| 75 | + | pub struct NameCheck { |
|
| 76 | + | #[serde(default)] |
|
| 77 | + | owner: String, |
|
| 78 | + | #[serde(default)] |
|
| 79 | + | name: String, |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// `GET /new/check?owner=&name=` (#360): the page's live check that a name |
|
| 83 | + | /// is free and valid, answering `{"ok", "message", "address"}`. |
|
| 84 | + | pub async fn check_name(State(app): State<Shared>, viewer: Viewer, Query(q): Query<NameCheck>) -> Response { |
|
| 85 | + | let Some(me) = viewer.account() else { |
|
| 86 | + | return (axum::http::StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"ok": false, "message": "Sign in first."}))) |
|
| 87 | + | .into_response(); |
|
| 88 | + | }; |
|
| 89 | + | if q.name.trim().is_empty() { |
|
| 90 | + | return axum::Json(serde_json::json!({"ok": false, "message": ""})).into_response(); |
|
| 91 | + | } |
|
| 92 | + | match crate::repo_setup::check_name(&app, me, &q.owner, &q.name).await { |
|
| 93 | + | Ok(n) => axum::Json(serde_json::json!({ |
|
| 94 | + | "ok": true, |
|
| 95 | + | "message": format!("{n} is free."), |
|
| 96 | + | "address": format!("{}{}", app.config.base_url().trim_end_matches('/'), n.url()), |
|
| 97 | + | })) |
|
| 98 | + | .into_response(), |
|
| 99 | + | Err(m) => axum::Json(serde_json::json!({"ok": false, "message": m})).into_response(), |
|
| 100 | + | } |
|
| 37 | 101 | } |
|
| 38 | 102 | ||
| 39 | 103 | #[derive(Deserialize)] |
|
| … | 11 unchanged lines |
||
| 51 | 115 | storage: String, |
|
| 52 | 116 | #[serde(flatten)] |
|
| 53 | 117 | bucket: BucketForm, |
|
| 118 | + | /// What it starts with (#360): a README, a licence, an `.oxyignore`. |
|
| 119 | + | #[serde(default)] |
|
| 120 | + | readme: Option<String>, |
|
| 121 | + | #[serde(default)] |
|
| 122 | + | licence: String, |
|
| 123 | + | #[serde(default)] |
|
| 124 | + | ignore: String, |
|
| 125 | + | /// A board with it: `new` (titled `board_title`), a board number, or none. |
|
| 126 | + | #[serde(default)] |
|
| 127 | + | board: String, |
|
| 128 | + | #[serde(default)] |
|
| 129 | + | board_title: String, |
|
| 130 | + | /// The existing board's number, with `board=existing`. |
|
| 131 | + | #[serde(default)] |
|
| 132 | + | board_n: String, |
|
| 54 | 133 | } |
|
| 55 | 134 | ||
| 56 | 135 | /// A bucket and the credentials for it, as the New repository form and |
|
| … | 38 unchanged lines |
||
| 95 | 174 | let shell = Shell::new(&app, "/new", &viewer); |
|
| 96 | 175 | let me = require(&viewer, &shell)?; |
|
| 97 | 176 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 98 | - | let owners = owners_for(&app, &me.account).await; |
|
| 99 | - | let fail = |msg: &str| Page(NewRepoPage { shell: shell.clone(), owners: owners.clone(), error: Some(msg.into()) }).into_response(); |
|
| 100 | - | let Some(name) = RepoName::new(f.owner.trim(), f.name.trim()) else { |
|
| 101 | - | return Ok(fail("A repository name is letters, digits, dots, dashes and underscores.")); |
|
| 177 | + | let page = new_page(&app, shell.clone(), &me.account, None).await; |
|
| 178 | + | let fail = |msg: &str| Page(NewRepoPage { error: Some(msg.into()), ..clone_page(&page) }).into_response(); |
|
| 179 | + | let name = match crate::repo_setup::check_name(&app, &me.account, &f.owner, &f.name).await { |
|
| 180 | + | Ok(n) => n, |
|
| 181 | + | Err(m) => return Ok(fail(&m)), |
|
| 182 | + | }; |
|
| 183 | + | let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) }; |
|
| 184 | + | // Its board and its starting files are checked before anything is made (#360). |
|
| 185 | + | let choice = if f.board == "existing" { f.board_n.as_str() } else { f.board.as_str() }; |
|
| 186 | + | let board = match crate::repo_setup::Board::parse(choice, &f.board_title, &name.name) { |
|
| 187 | + | Ok(b) => b, |
|
| 188 | + | Err(m) => return Ok(fail(&m)), |
|
| 102 | 189 | }; |
|
| 103 | - | if name.name.ends_with(crate::wiki::SUFFIX) { |
|
| 104 | - | return Ok(fail("A name cannot end in .wiki: that is the address of a repository's wiki.")); |
|
| 190 | + | if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await { |
|
| 191 | + | return Ok(fail(&m)); |
|
| 105 | 192 | } |
|
| 106 | - | if !roles::may_create_under(&app.db, &me.account, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 107 | - | return Ok(fail("You cannot create repositories under that owner.")); |
|
| 193 | + | let start = |
|
| 194 | + | crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() }; |
|
| 195 | + | let year = time::OffsetDateTime::now_utc().year(); |
|
| 196 | + | let holder = if me.account.display_name.trim().is_empty() { me.account.username.clone() } else { me.account.display_name.clone() }; |
|
| 197 | + | let files = match start.files(&name, &f.description, &holder, year) { |
|
| 198 | + | Ok(files) => files, |
|
| 199 | + | Err(m) => return Ok(fail(&m)), |
|
| 200 | + | }; |
|
| 201 | + | if !files.is_empty() && f.storage == "bucket" { |
|
| 202 | + | return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it.")); |
|
| 108 | 203 | } |
|
| 109 | - | let Some(visibility) = Visibility::parse(&f.visibility) else { return Ok(fail("Visibility is public, internal or private.")) }; |
|
| 110 | - | if db::repos::get(&app.db, &name.owner, &name.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 111 | - | || app.repos.exists(&name) |
|
| 112 | - | { |
|
| 113 | - | return Ok(fail("That repository exists.")); |
|
| 204 | + | if !files.is_empty() { |
|
| 205 | + | if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await { |
|
| 206 | + | return Ok(fail(&format!("The hub cannot write the starting files for you yet. {m}"))); |
|
| 207 | + | } |
|
| 114 | 208 | } |
|
| 115 | 209 | // An organisation's storage policy (oxygenhub#96). |
|
| 116 | 210 | if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| … | 31 unchanged lines |
||
| 148 | 242 | ) |
|
| 149 | 243 | .await |
|
| 150 | 244 | .ok(); |
|
| 245 | + | crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public) |
|
| 246 | + | .await |
|
| 247 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 151 | 248 | return Ok(Redirect::to(&name.url()).into_response()); |
|
| 152 | 249 | } |
|
| 153 | 250 | app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 154 | - | db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) |
|
| 251 | + | let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) |
|
| 155 | 252 | .await |
|
| 156 | 253 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 157 | 254 | accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok(); |
|
| 255 | + | crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public) |
|
| 256 | + | .await |
|
| 257 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 258 | + | if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await { |
|
| 259 | + | tracing::warn!("starting files for {name}: {e}"); |
|
| 260 | + | } |
|
| 158 | 261 | Ok(Redirect::to(&name.url()).into_response()) |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | fn clone_page(p: &NewRepoPage) -> NewRepoPage { |
|
| 265 | + | NewRepoPage { |
|
| 266 | + | shell: p.shell.clone(), |
|
| 267 | + | owners: p.owners.clone(), |
|
| 268 | + | error: p.error.clone(), |
|
| 269 | + | boards: p.boards.clone(), |
|
| 270 | + | orgs: p.orgs.clone(), |
|
| 271 | + | licences: p.licences, |
|
| 272 | + | ignores: p.ignores, |
|
| 273 | + | base_url: p.base_url.clone(), |
|
| 274 | + | } |
|
| 159 | 275 | } |
|
| 160 | 276 | ||
| 161 | 277 | /// One page of a repository's settings (#278), at |
|
| … | 1502 unchanged lines |
modified crates/hub/tests/api.rs +76 −1
| … | 1131 unchanged lines |
||
| 1132 | 1132 | api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"closed":false}"#)).await; |
|
| 1133 | 1133 | assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NO_CONTENT); |
|
| 1134 | 1134 | assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NOT_FOUND); |
|
| 1135 | + | ||
| 1136 | + | // A repository made with a board (#360): a new one, or an existing one; |
|
| 1137 | + | // its issues join the board as they open. Checked before anything is made. |
|
| 1138 | + | let boards = |v: &serde_json::Value| v.as_array().unwrap().len(); |
|
| 1139 | + | let before = boards(&api(app, "GET", "/api/v1/projects/andy", Some(&a), None).await.1); |
|
| 1140 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"demo","project":{"new":"Never"}}"#)).await; |
|
| 1141 | + | assert_eq!(st, StatusCode::CONFLICT, "a name taken: {v}"); |
|
| 1142 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"fresh","readme":true}"#)).await; |
|
| 1143 | + | assert_eq!(st, StatusCode::CONFLICT, "starting files need the hub as the caller's delegate: {v}"); |
|
| 1144 | + | assert!(v["detail"].as_str().unwrap_or("").contains("delegated"), "{v}"); |
|
| 1145 | + | assert_eq!(api(app, "GET", "/api/v1/repos/andy/fresh", Some(&a), None).await.0, StatusCode::NOT_FOUND, "nothing made"); |
|
| 1146 | + | assert_eq!(boards(&api(app, "GET", "/api/v1/projects/andy", Some(&a), None).await.1), before, "no board made"); |
|
| 1147 | + | let (st, v) = |
|
| 1148 | + | api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"boarded","project":{"new":"Boarded work"}}"#)).await; |
|
| 1149 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 1150 | + | let all = api(app, "GET", "/api/v1/projects/andy", Some(&a), None).await.1; |
|
| 1151 | + | let made = all.as_array().unwrap().iter().find(|p| p["title"] == "Boarded work").expect("the new board").clone(); |
|
| 1152 | + | let n = made["number"].as_i64().unwrap(); |
|
| 1153 | + | api(app, "POST", "/api/v1/repos/andy/boarded/issues", Some(&a), Some(r#"{"title":"First"}"#)).await; |
|
| 1154 | + | let (_, b) = api(app, "GET", &format!("/api/v1/projects/andy/{n}"), Some(&a), None).await; |
|
| 1155 | + | assert_eq!(b["columns"][0]["cards"][0]["reference"], "andy/boarded#1", "{b}"); |
|
| 1156 | + | assert_eq!(b["columns"][2]["name"], "Done"); |
|
| 1157 | + | let (st, v) = |
|
| 1158 | + | api(app, "POST", "/api/v1/repos", Some(&a), Some(&format!(r#"{{"owner":"andy","name":"joined","project":{{"number":{n}}}}}"#))) |
|
| 1159 | + | .await; |
|
| 1160 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 1161 | + | api(app, "POST", "/api/v1/repos/andy/joined/issues", Some(&a), Some(r#"{"title":"Second"}"#)).await; |
|
| 1162 | + | let (_, b) = api(app, "GET", &format!("/api/v1/projects/andy/{n}"), Some(&a), None).await; |
|
| 1163 | + | let refs: Vec<&str> = b["columns"][0]["cards"].as_array().unwrap().iter().map(|c| c["reference"].as_str().unwrap()).collect(); |
|
| 1164 | + | assert_eq!(refs, ["andy/boarded#1", "andy/joined#1"]); |
|
| 1165 | + | let (st, _) = api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"nowhere","project":{"number":99}}"#)).await; |
|
| 1166 | + | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "no such board"); |
|
| 1167 | + | assert_eq!(api(app, "GET", "/api/v1/repos/andy/nowhere", Some(&a), None).await.0, StatusCode::NOT_FOUND); |
|
| 1135 | 1168 | } |
|
| 1136 | 1169 | ||
| 1137 | 1170 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 73 unchanged lines |
||
| 1211 | 1244 | .cookie |
|
| 1212 | 1245 | .unwrap(); |
|
| 1213 | 1246 | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1214 | - | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1247 | + | // The New repository page (#360): its choices, and the live name check. |
|
| 1248 | + | assert!(page.body.contains("data-new-repo") && page.body.contains(">MIT: ") && page.body.contains("A new board"), "{}", page.body); |
|
| 1249 | + | let check = |q: &'static str| { |
|
| 1250 | + | let (app, andy) = (app.clone(), andy.clone()); |
|
| 1251 | + | async move { serde_json::from_str::<serde_json::Value>(&call(&app, "GET", q, None, Some(&andy), None, None).await.body).unwrap() } |
|
| 1252 | + | }; |
|
| 1253 | + | let v = check("/new/check?owner=andy&name=demo").await; |
|
| 1254 | + | assert_eq!((v["ok"].as_bool(), v["message"].as_str()), (Some(true), Some("andy/demo is free.")), "{v}"); |
|
| 1255 | + | assert!(v["address"].as_str().unwrap().ends_with("/andy/demo")); |
|
| 1256 | + | assert_eq!(check("/new/check?owner=andy&name=bad%20name").await["ok"], false); |
|
| 1257 | + | let made = call( |
|
| 1258 | + | app, |
|
| 1259 | + | "POST", |
|
| 1260 | + | "/new", |
|
| 1261 | + | None, |
|
| 1262 | + | Some(&andy), |
|
| 1263 | + | None, |
|
| 1264 | + | Some(&format!("_csrf={}&owner=andy&name=demo&board=new&board_title=Demo+work", csrf_of(&page.body))), |
|
| 1265 | + | ) |
|
| 1266 | + | .await; |
|
| 1267 | + | assert_eq!(made.location.as_deref(), Some("/andy/demo"), "{}", made.body); |
|
| 1268 | + | assert_eq!(check("/new/check?owner=andy&name=demo").await["message"], "andy/demo exists."); |
|
| 1269 | + | let board = oxygenhub::db::projects::of_owner(&app.db, "andy").await.unwrap(); |
|
| 1270 | + | assert_eq!(board.iter().map(|p| p.title.as_str()).collect::<Vec<_>>(), ["Demo work"]); |
|
| 1271 | + | let again = |
|
| 1272 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=other&readme=1", csrf_of(&page.body)))) |
|
| 1273 | + | .await; |
|
| 1274 | + | assert!(again.body.contains("cannot write the starting files for you yet"), "no delegate: refused, nothing made"); |
|
| 1275 | + | assert!(oxygenhub::db::repos::get(&app.db, "andy", "other").await.unwrap().is_none()); |
|
| 1215 | 1276 | let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| 1216 | 1277 | assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos"); |
|
| 1217 | 1278 | let tab = call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await; |
|
| … | 653 unchanged lines |
||
| 1871 | 1932 | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 1872 | 1933 | let (st, _) = api(app, "DELETE", "/api/v1/repos/andy/demo/assistants/claude", Some(&admin), None).await; |
|
| 1873 | 1934 | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 1935 | + | } |
|
| 1936 | + | ||
| 1937 | + | /// oxygenhub#370: the hub vouches for the Android app's App Links, so its |
|
| 1938 | + | /// links open the app; the official app by default. |
|
| 1939 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1940 | + | async fn assetlinks_name_the_android_app() { |
|
| 1941 | + | let h = hub().await; |
|
| 1942 | + | let r = call(&h.app, "GET", "/.well-known/assetlinks.json", None, None, None, None).await; |
|
| 1943 | + | assert_eq!(r.status, StatusCode::OK); |
|
| 1944 | + | let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); |
|
| 1945 | + | assert_eq!(v[0]["relation"][0], "delegate_permission/common.handle_all_urls"); |
|
| 1946 | + | assert_eq!(v[0]["target"]["package_name"], "io.oxygenhub.android"); |
|
| 1947 | + | let fp = v[0]["target"]["sha256_cert_fingerprints"][0].as_str().unwrap(); |
|
| 1948 | + | assert!(fp.starts_with("B0:99:57:") && fp.ends_with(":2A:48") && fp.len() == 95, "{fp}"); |
|
| 1874 | 1949 | } |
modified crates/hub/tests/history_scan.rs +27 −3
| … | 34 unchanged lines |
||
| 35 | 35 | }); |
|
| 36 | 36 | } |
|
| 37 | 37 | ||
| 38 | + | /// A test that fails after two minutes instead of holding a runner for an |
|
| 39 | + | /// hour (oxygenhub#365), where it hangs waiting on the hub; a hang inside |
|
| 40 | + | /// a blocking call is stopped by nextest's limit in CI instead. The tests |
|
| 41 | + | /// run on four worker threads: the client's pushes block the thread they |
|
| 42 | + | /// run on, and with the hub served from the same runtime, a small runner's |
|
| 43 | + | /// few threads could leave none to answer them. |
|
| 44 | + | async fn bounded(test: impl std::future::Future<Output = ()>) { |
|
| 45 | + | if tokio::time::timeout(Duration::from_secs(120), test).await.is_err() { |
|
| 46 | + | panic!("the test did not finish in two minutes"); |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | ||
| 38 | 50 | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 39 | 51 | let p = root.join(rel); |
|
| 40 | 52 | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| … | 145 unchanged lines |
||
| 186 | 198 | /// The whole flow: found with change, file and line; allowed, resolved, an |
|
| 187 | 199 | /// allowed fingerprint removed; incremental after a push; scanned again |
|
| 188 | 200 | /// on request without duplicates. |
|
| 189 | - | #[tokio::test(flavor = "multi_thread")] |
|
| 201 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
|
| 190 | 202 | async fn a_secret_in_history_is_found_and_judged() { |
|
| 203 | + | bounded(a_secret_in_history_is_found_and_judged_body()).await |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | async fn a_secret_in_history_is_found_and_judged_body() { |
|
| 191 | 207 | let h = hub(false).await; |
|
| 192 | 208 | let app = &h.app; |
|
| 193 | 209 | let andy = register(app, "andy").await; |
|
| … | 144 unchanged lines |
||
| 338 | 354 | ||
| 339 | 355 | /// A pass stops at its budget and the next resumes where it stopped; a push |
|
| 340 | 356 | /// in the meantime waits for the scan under way, then is scanned alone. |
|
| 341 | - | #[tokio::test(flavor = "multi_thread")] |
|
| 357 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
|
| 342 | 358 | async fn the_scan_resumes_where_its_budget_stopped() { |
|
| 359 | + | bounded(the_scan_resumes_where_its_budget_stopped_body()).await |
|
| 360 | + | } |
|
| 361 | + | ||
| 362 | + | async fn the_scan_resumes_where_its_budget_stopped_body() { |
|
| 343 | 363 | let h = hub(false).await; |
|
| 344 | 364 | let app = &h.app; |
|
| 345 | 365 | register(app, "andy").await; |
|
| … | 45 unchanged lines |
||
| 391 | 411 | ||
| 392 | 412 | /// The worker scans every repository when it starts (as on the first |
|
| 393 | 413 | /// deploy) and a repository again after a push, by itself. |
|
| 394 | - | #[tokio::test(flavor = "multi_thread")] |
|
| 414 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
|
| 395 | 415 | async fn the_worker_scans_on_start_and_after_a_push() { |
|
| 416 | + | bounded(the_worker_scans_on_start_and_after_a_push_body()).await |
|
| 417 | + | } |
|
| 418 | + | ||
| 419 | + | async fn the_worker_scans_on_start_and_after_a_push_body() { |
|
| 396 | 420 | let h = hub(true).await; |
|
| 397 | 421 | let app = &h.app; |
|
| 398 | 422 | let andy = register(app, "andy").await; |
|
| … | 37 unchanged lines |
modified crates/oh/src/main.rs +54 −0
| … | 290 unchanged lines |
||
| 291 | 291 | enum RepoCmd { |
|
| 292 | 292 | /// Name, description, visibility and default bookmark |
|
| 293 | 293 | View, |
|
| 294 | + | /// Create a repository on the hub (#360): OWNER/NAME, with a board and |
|
| 295 | + | /// starting files if asked |
|
| 296 | + | Create { |
|
| 297 | + | /// OWNER/NAME |
|
| 298 | + | slug: String, |
|
| 299 | + | #[arg(long, short)] |
|
| 300 | + | description: Option<String>, |
|
| 301 | + | /// public (the default), internal or private |
|
| 302 | + | #[arg(long, default_value = "public")] |
|
| 303 | + | visibility: String, |
|
| 304 | + | /// A project board with it: `new`, `new:TITLE`, or an existing board's number |
|
| 305 | + | #[arg(long, short)] |
|
| 306 | + | project: Option<String>, |
|
| 307 | + | /// Start it with a README |
|
| 308 | + | #[arg(long)] |
|
| 309 | + | readme: bool, |
|
| 310 | + | /// Start it with a licence: MIT, BSD-2-Clause or ISC |
|
| 311 | + | #[arg(long)] |
|
| 312 | + | licence: Option<String>, |
|
| 313 | + | /// Start it with an .oxyignore for a language: Rust, Node, Python, Go, "Java and Kotlin" |
|
| 314 | + | #[arg(long)] |
|
| 315 | + | ignore: Option<String>, |
|
| 316 | + | }, |
|
| 294 | 317 | } |
|
| 295 | 318 | ||
| 296 | 319 | #[derive(Subcommand)] |
|
| … | 710 unchanged lines |
||
| 1007 | 1030 | None => c.get_text(&path)?, |
|
| 1008 | 1031 | }; |
|
| 1009 | 1032 | println!("{text}"); |
|
| 1033 | + | Ok(0) |
|
| 1034 | + | } |
|
| 1035 | + | Cmd::Repo { cmd: RepoCmd::Create { slug, description, visibility, project, readme, licence, ignore } } => { |
|
| 1036 | + | let (owner, name) = slug.split_once('/').context("give the repository as OWNER/NAME")?; |
|
| 1037 | + | let project = match project.as_deref() { |
|
| 1038 | + | None => None, |
|
| 1039 | + | Some("new") => Some(serde_json::json!({ "new": "" })), |
|
| 1040 | + | Some(p) if p.starts_with("new:") => Some(serde_json::json!({ "new": &p[4..] })), |
|
| 1041 | + | Some(n) => { |
|
| 1042 | + | Some(serde_json::json!({ "number": n.parse::<i64>().context("--project is new, new:TITLE or a board number")? })) |
|
| 1043 | + | } |
|
| 1044 | + | }; |
|
| 1045 | + | let mut body = serde_json::json!({ |
|
| 1046 | + | "owner": owner, "name": name, "description": description.unwrap_or_default(), "visibility": visibility, "readme": readme, |
|
| 1047 | + | }); |
|
| 1048 | + | if let Some(p) = project { |
|
| 1049 | + | body["project"] = p; |
|
| 1050 | + | } |
|
| 1051 | + | if let Some(l) = licence { |
|
| 1052 | + | body["licence"] = serde_json::json!(l); |
|
| 1053 | + | } |
|
| 1054 | + | if let Some(i) = ignore { |
|
| 1055 | + | body["ignore"] = serde_json::json!(i); |
|
| 1056 | + | } |
|
| 1057 | + | let r: serde_json::Value = c.send("POST", "/repos", &body)?; |
|
| 1058 | + | if json { |
|
| 1059 | + | println!("{r}"); |
|
| 1060 | + | } else { |
|
| 1061 | + | println!("{} created {owner}/{name} {}", ui::ok("✔"), ui::dim(r["url"].as_str().unwrap_or(""))); |
|
| 1062 | + | println!("{}", ui::kv("clone", &format!("oxy clone {}/{owner}/{name}", t.hub))); |
|
| 1063 | + | } |
|
| 1010 | 1064 | Ok(0) |
|
| 1011 | 1065 | } |
|
| 1012 | 1066 | Cmd::Repo { cmd: RepoCmd::View } => { |
|
| … | 1930 unchanged lines |
modified docs/spec.md +2 −2
| … | 233 unchanged lines |
||
| 234 | 234 | ||
| 235 | 235 | A repository with no policy accepts any reference update from any account with `write`. The settings page says so plainly and offers to create a policy. Policy editing in the browser produces a **policy proposal**: a signed policy object that roots sign with `oxy policy sign` from their own clients; the hub collects signatures until the threshold is met, then applies the update as an ordinary push. The hub never holds a root key. |
|
| 236 | 236 | ||
| 237 | - | The hub has one identity of its own, the **hub delegate key**, generated at `init` and published at `/.well-known/oxyhub-key`. It is used only to sign changes the hub itself creates on behalf of a signed-in user (web edits, §6.9; proposal landings, §7.6). Such a change carries the user's linked key as `author` and the hub key as signer, which the author-or-delegate rule (spec §14.4) accepts only when the user's identity object lists the hub key as a delegate with the `changes` and `refs` scopes (Oxygen spec §14.2; the hub signs the change and attests the reference), still in force: the user opts in once with `oxy identity delegate add <hub key> --scope changes,refs` and re-links the rotated identity (§5.3). Policy is then checked exactly as for the user's own signature, since a rule that names the author covers the author's delegates (spec §14.5). If the user has not delegated, or the policy does not authorise them for the reference, the hub refuses the action and shows the client recipe. Delegation is per user and revocable by the user alone (`oxy identity delegate remove`); the hub never needs a policy entry of its own. |
|
| 237 | + | The hub has one identity of its own, the **hub delegate key**, generated at `init` and published at `/.well-known/oxyhub-key`. `/.well-known/assetlinks.json` names the Android apps whose App Links the hub vouches for (`[push] android_apps`, `PACKAGE:SHA256`; the official app by default, #370), so the hub's links open them. It is used only to sign changes the hub itself creates on behalf of a signed-in user (web edits, §6.9; proposal landings, §7.6). Such a change carries the user's linked key as `author` and the hub key as signer, which the author-or-delegate rule (spec §14.4) accepts only when the user's identity object lists the hub key as a delegate with the `changes` and `refs` scopes (Oxygen spec §14.2; the hub signs the change and attests the reference), still in force: the user opts in once with `oxy identity delegate add <hub key> --scope changes,refs` and re-links the rotated identity (§5.3). Policy is then checked exactly as for the user's own signature, since a rule that names the author covers the author's delegates (spec §14.5). If the user has not delegated, or the policy does not authorise them for the reference, the hub refuses the action and shows the client recipe. Delegation is per user and revocable by the user alone (`oxy identity delegate remove`); the hub never needs a policy entry of its own. |
|
| 238 | 238 | ||
| 239 | 239 | ### 5.6 Personal access tokens |
|
| 240 | 240 | ||
| … | 253 unchanged lines |
||
| 494 | 494 | ||
| 495 | 495 | ### 12.1 API |
|
| 496 | 496 | ||
| 497 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `/charts` its cumulative flow, cycle time, weekly throughput, the open iteration's burn-down and Monte Carlo forecasts of when its open cards, each milestone's and `?q=`'s are done, at 50% and 85%, from its card moves, #357, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 497 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `POST /repos` (repo:admin: `{"owner", "name", "description", "visibility"}`, and with #360 `project` (`{"new": TITLE}` or `{"number": N}`: a board made or chosen with it, which then takes its issues and proposals as they open, `project_repos`) and `readme`, `licence` (`MIT`, `BSD-2-Clause`, `ISC`) and `ignore` (an `.oxyignore` for `Rust`, `Node`, `Python`, `Go`, `Java and Kotlin`), committed as the caller with the hub signing as their delegate; the board and the delegate are checked before anything is made; `oh repo create OWNER/NAME --project new:TITLE --readme`), `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `/charts` its cumulative flow, cycle time, weekly throughput, the open iteration's burn-down and Monte Carlo forecasts of when its open cards, each milestone's and `?q=`'s are done, at 50% and 85%, from its card moves, #357, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 498 | 498 | ||
| 499 | 499 | ### 12.2 Webhooks |
|
| 500 | 500 | ||
| … | 398 unchanged lines |
modified templates/repo_new.html +38 −13
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}New repository{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | - | <div class="head"><h1>New repository <small>empty until the first push, which it takes its identity from</small></h1></div> |
|
| 4 | + | <div class="head"><h1>New repository</h1></div> |
|
| 5 | 5 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | - | <form class="card" method="post" action="/new"> |
|
| 6 | + | <form class="card new-repo" method="post" action="/new" data-new-repo> |
|
| 7 | 7 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 8 | - | <div class="row"> |
|
| 9 | - | <div><label for="owner">Owner</label><select id="owner" name="owner">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select></div> |
|
| 10 | - | <div><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}"></div> |
|
| 8 | + | <div class="row name-row"> |
|
| 9 | + | <div><label for="owner">Owner</label><select id="owner" name="owner" data-orgs="{% for o in orgs %}{{ o }} {% endfor %}">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select></div> |
|
| 10 | + | <div class="slash" aria-hidden="true">/</div> |
|
| 11 | + | <div class="grow"><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" aria-describedby="name-check" autofocus></div> |
|
| 11 | 12 | </div> |
|
| 12 | - | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 13 | - | <label>Visibility</label> |
|
| 14 | - | <div class="choice"> |
|
| 15 | - | <label><input type="radio" name="visibility" value="public" checked> public</label> |
|
| 16 | - | <label><input type="radio" name="visibility" value="internal"> internal (anyone signed in)</label> |
|
| 17 | - | <label><input type="radio" name="visibility" value="private"> private</label> |
|
| 18 | - | </div> |
|
| 13 | + | <p id="name-check" class="small name-check" role="status" aria-live="polite" data-base="{{ base_url }}"></p> |
|
| 14 | + | <label for="description">Description <span class="small mute">(optional)</span></label><input type="text" id="description" name="description"> |
|
| 15 | + | ||
| 16 | + | <fieldset class="choices"> |
|
| 17 | + | <legend>Visibility</legend> |
|
| 18 | + | <label class="choice-card"><input type="radio" name="visibility" value="public" checked><span><strong>Public</strong><span class="small mute">Anyone can see and clone it. You choose who may push.</span></span></label> |
|
| 19 | + | <label class="choice-card"><input type="radio" name="visibility" value="internal"><span><strong>Internal</strong><span class="small mute">Anyone signed in to this hub can see it.</span></span></label> |
|
| 20 | + | <label class="choice-card"><input type="radio" name="visibility" value="private"><span><strong>Private</strong><span class="small mute">Only you{% if !orgs.is_empty() %}, the organisation's members{% endif %} and the people you add.</span></span></label> |
|
| 21 | + | </fieldset> |
|
| 22 | + | ||
| 23 | + | <fieldset class="choices"> |
|
| 24 | + | <legend>Start with <span class="small mute">(optional; left empty, it starts empty and takes its first push)</span></legend> |
|
| 25 | + | <label class="check"><input type="checkbox" name="readme" value="1"> a README with its name and description</label> |
|
| 26 | + | <div class="row"> |
|
| 27 | + | <div><label for="licence">Licence</label><select id="licence" name="licence"><option value="">none</option>{% for (id, what) in licences %}<option value="{{ id }}">{{ id }}: {{ what }}</option>{% endfor %}</select></div> |
|
| 28 | + | <div><label for="ignore">.oxyignore</label><select id="ignore" name="ignore"><option value="">none</option>{% for l in ignores %}<option>{{ l }}</option>{% endfor %}</select></div> |
|
| 29 | + | </div> |
|
| 30 | + | <p class="small mute">The hub commits these as you, signed with its key as your delegate, as it does for edits on the site.</p> |
|
| 31 | + | </fieldset> |
|
| 32 | + | ||
| 33 | + | <fieldset class="choices"> |
|
| 34 | + | <legend>Project board</legend> |
|
| 35 | + | <label class="choice-card"><input type="radio" name="board" value="" checked data-board-none><span><strong>No board</strong><span class="small mute">Add its issues to a board later.</span></span></label> |
|
| 36 | + | <label class="choice-card"><input type="radio" name="board" value="new" data-board-new><span><strong>A new board</strong><span class="small mute">Backlog, Doing and Done, with finished work moving to Done. Its issues and proposals join the board as they open.</span></span></label> |
|
| 37 | + | <div class="board-title"><label for="board_title">Board title</label><input type="text" id="board_title" name="board_title" maxlength="100" placeholder="the repository's name"></div> |
|
| 38 | + | {% if !boards.is_empty() %}<label class="choice-card"><input type="radio" name="board" value="existing" data-board-existing><span><strong>An existing board</strong><span class="small mute">Its issues and proposals join it as they open.</span></span></label> |
|
| 39 | + | <div class="board-existing"><label for="board_n">Board</label><select id="board_n" name="board_n" data-board-list>{% for (o, n, t) in boards %}<option value="{{ n }}" data-owner="{{ o }}">{{ o }} #{{ n }}: {{ t }}</option>{% endfor %}</select></div>{% endif %} |
|
| 40 | + | </fieldset> |
|
| 41 | + | ||
| 42 | + | <details class="storage"><summary>Storage <span class="small mute">(this server's disk, unless you keep it in your own bucket)</span></summary> |
|
| 19 | 43 | <label>Storage</label> |
|
| 20 | 44 | <div class="choice"> |
|
| 21 | 45 | <label><input type="radio" name="storage" value="hub" checked> this server's disk</label> |
|
| … | 17 unchanged lines |
||
| 39 | 63 | <div><label for="client_secret">Client secret</label><input type="password" id="client_secret" name="client_secret" autocomplete="new-password"></div> |
|
| 40 | 64 | </div> |
|
| 41 | 65 | </fieldset> |
|
| 42 | - | <button type="submit">CREATE</button> |
|
| 66 | + | </details> |
|
| 67 | + | <button type="submit">CREATE REPOSITORY</button> |
|
| 43 | 68 | </form> |
|
| 44 | 69 | {% endblock %} |
modified templates/style.css +14 −0
| … | 1551 unchanged lines |
||
| 1552 | 1552 | .tab-job .ok { color: var(--ok); } |
|
| 1553 | 1553 | .tab-job .bad { color: var(--bad); } |
|
| 1554 | 1554 | .tab-job pre { max-height: 24rem; overflow: auto; } |
|
| 1555 | + | /* The New repository page (#360). */ |
|
| 1556 | + | .new-repo .name-row { display: flex; align-items: flex-end; gap: 8px; } |
|
| 1557 | + | .new-repo .name-row .grow { flex: 1; min-width: 0; } |
|
| 1558 | + | .new-repo .name-row .slash { font-size: 20px; color: var(--mute); padding-bottom: 6px; } |
|
| 1559 | + | .new-repo .name-check { min-height: 1.4em; margin: 4px 0 8px; } |
|
| 1560 | + | .ok-text { color: var(--ok); } |
|
| 1561 | + | .err-text { color: var(--bad); } |
|
| 1562 | + | .new-repo fieldset.choices { border: 1px solid var(--line); padding: 10px 12px; margin: 12px 0; display: grid; gap: 8px; } |
|
| 1563 | + | .new-repo fieldset.choices legend { padding: 0 4px; font-weight: 600; } |
|
| 1564 | + | .choice-card { display: flex; gap: 10px; align-items: flex-start; padding: 8px 10px; border: 1px solid var(--line); background: var(--soft); cursor: pointer; } |
|
| 1565 | + | .choice-card > span { display: grid; gap: 2px; } |
|
| 1566 | + | .choice-card:has(input:checked) { border-color: var(--acc); } |
|
| 1567 | + | .new-repo .board-title, .new-repo .board-existing { margin-left: 28px; } |
|
| 1568 | + | .new-repo details.storage { margin: 12px 0; } |