Skip to content

Commit 7dfd579

Browse files
Timna BrownTimna Brown
authored andcommitted
feat: sync from blueprint repo and document blueprint config
- add blueprint source config block (repo/branch/paths) - repo sync can pull files from blueprint repo via --from-blueprint - GitHub provider fetches files from a repo tree + contents API - README: add repo sync --from-blueprint and blueprint config snippet - remove completed Next Steps section - help: include sync --from-blueprint row
1 parent e9adc6e commit 7dfd579

7 files changed

Lines changed: 296 additions & 20 deletions

File tree

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ After `make setup` the `devopster` binary is on your `$PATH` and these commands
8383
| `devopster repo fix` | Prompt for missing description, topics, and license in scoped repos |
8484
| `devopster repo blueprint --name <name> --template <template>` | Create a new repository from a template defined in config |
8585
| `devopster repo sync` | Push files from `.github/` to all repositories |
86+
| `devopster repo sync --from-blueprint` | Sync pipeline and policy files from the blueprint repo |
8687
| `devopster catalog generate` | Export a JSON catalog of all repositories |
8788
| `devopster topics align` | Add missing template topics to every matching repository |
8889
| `devopster stats` | Print org summary: config, coverage (description/topics/license/branch), compliance, and top topics |
@@ -123,7 +124,12 @@ cp devopster-config.template.yaml devopster-config.yaml
123124

124125
Then set the provider and token environment variables you want to use.
125126

126-
## Next Steps
127+
Optional: configure a blueprint source repo for `devopster repo sync --from-blueprint`:
127128

128-
- add repository creation and file sync logic *(template structure TBD)*
129-
- render templates for new repository blueprints *(template structure TBD)*
129+
```yaml
130+
blueprint:
131+
repo: MicrosoftCloudEssentials-LearningHub/org-repo-template
132+
branch: main
133+
paths:
134+
- .github
135+
```

devopster-config.template.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ audit:
3737
require_license: true # fail if no license is detected
3838
require_default_branch: true # fail if the default branch does not match `default_branch`
3939

40+
# Blueprint source repository used by `devopster repo sync --from-blueprint`.
41+
blueprint:
42+
repo: MicrosoftCloudEssentials-LearningHub/org-repo-template
43+
branch: main
44+
paths:
45+
- .github
46+
4047
templates:
4148
- name: azure-overview
4249
description: Base structure for Azure overview and demo repositories.

src/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Commands:
3838
{tab}| devopster repo fix | Prompt to fix missing metadata |
3939
{tab}| devopster repo blueprint | Create a new repository from a blueprint |
4040
{tab}| devopster repo sync | Push files from .github/ to all repositories |
41+
{tab}| devopster repo sync --from-blueprint | Sync files from the blueprint repo |
4142
{tab}+-----------------------------------------------+---------------------------------------------------+
4243
{tab}| devopster catalog generate | Export a catalog.json of all repositories |
4344
{tab}+-----------------------------------------------+---------------------------------------------------+

src/cli/repo.rs

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub enum RepoAction {
2121
Audit(AuditReposCommand),
2222
/// Interactively fix missing description, topics, and license
2323
Fix(FixReposCommand),
24-
/// Push files from a local directory to all matching repositories
24+
/// Sync files from a local directory or the blueprint repository
2525
Sync(SyncReposCommand),
2626
/// Create a new repository from a named blueprint
2727
Blueprint(BlueprintRepoCommand),
@@ -44,6 +44,22 @@ pub struct SyncReposCommand {
4444
#[arg(long, default_value = ".github")]
4545
pub source: String,
4646

47+
/// Sync files from the blueprint source repository instead of local files.
48+
#[arg(long, default_value_t = false)]
49+
pub from_blueprint: bool,
50+
51+
/// Override the blueprint source repo (org/repo or GitHub URL).
52+
#[arg(long)]
53+
pub blueprint_repo: Option<String>,
54+
55+
/// Override the blueprint source branch (default: main or config default_branch).
56+
#[arg(long)]
57+
pub blueprint_branch: Option<String>,
58+
59+
/// Paths to sync from the blueprint repo (repeatable, defaults to .github).
60+
#[arg(long, value_name = "PATH")]
61+
pub blueprint_path: Vec<String>,
62+
4763
#[arg(long, help = "Only sync to repositories matching this template (by topic overlap)")]
4864
pub template: Option<String>,
4965
}
@@ -103,19 +119,42 @@ impl RepoCommand {
103119
fix_repos(provider.as_ref(), &config).await?;
104120
}
105121
RepoAction::Sync(command) => {
106-
let source_path = std::path::Path::new(&command.source);
107-
if !source_path.exists() {
108-
anyhow::bail!(
109-
"sync source directory '{}' does not exist",
110-
command.source
111-
);
112-
}
122+
let use_blueprint = command.from_blueprint
123+
|| command.blueprint_repo.is_some()
124+
|| !command.blueprint_path.is_empty();
125+
126+
let files = if use_blueprint {
127+
let blueprint = resolve_blueprint_source(config, command)?;
128+
let (owner, repo) = parse_repo_slug(&blueprint.repo)?;
129+
let files = provider
130+
.fetch_repository_files(
131+
&owner,
132+
&repo,
133+
&blueprint.branch,
134+
&blueprint.paths,
135+
)
136+
.await?;
137+
if files.is_empty() {
138+
println!("No files found in blueprint repo '{}'.", blueprint.repo);
139+
return Ok(());
140+
}
141+
files
142+
} else {
143+
let source_path = std::path::Path::new(&command.source);
144+
if !source_path.exists() {
145+
anyhow::bail!(
146+
"sync source directory '{}' does not exist",
147+
command.source
148+
);
149+
}
113150

114-
let files = collect_sync_files(source_path)?;
115-
if files.is_empty() {
116-
println!("No files found in '{}'.", command.source);
117-
return Ok(());
118-
}
151+
let files = collect_sync_files(source_path)?;
152+
if files.is_empty() {
153+
println!("No files found in '{}'.", command.source);
154+
return Ok(());
155+
}
156+
files
157+
};
119158

120159
let repos = provider.list_repositories(&config.organization).await?;
121160
let repos = scope_to_config(repos, &config.scoped_repos);
@@ -148,10 +187,21 @@ impl RepoCommand {
148187
repos_to_sync.len()
149188
);
150189

151-
let commit_msg = format!(
152-
"chore: sync files from devopster (source: {})",
153-
command.source
154-
);
190+
let commit_msg = if use_blueprint {
191+
let repo_name = command
192+
.blueprint_repo
193+
.as_deref()
194+
.or_else(|| config.blueprint.as_ref().map(|b| b.repo.as_str()))
195+
.unwrap_or("blueprint");
196+
format!(
197+
"chore: sync files from blueprint repo ({repo_name})"
198+
)
199+
} else {
200+
format!(
201+
"chore: sync files from devopster (source: {})",
202+
command.source
203+
)
204+
};
155205
let mut sync_count = 0usize;
156206
let mut error_count = 0usize;
157207

@@ -259,6 +309,65 @@ fn print_audit_findings(findings: Vec<AuditFinding>) {
259309
println!(" then use 'devopster topics align' or 'devopster repo sync' to fix.");
260310
}
261311

312+
struct ResolvedBlueprintSource {
313+
repo: String,
314+
branch: String,
315+
paths: Vec<String>,
316+
}
317+
318+
fn resolve_blueprint_source(
319+
config: &AppConfig,
320+
command: &SyncReposCommand,
321+
) -> Result<ResolvedBlueprintSource> {
322+
let repo = command
323+
.blueprint_repo
324+
.clone()
325+
.or_else(|| config.blueprint.as_ref().map(|b| b.repo.clone()))
326+
.context("blueprint.repo is not configured (set it in devopster-config.yaml)")?;
327+
328+
let branch = command
329+
.blueprint_branch
330+
.clone()
331+
.or_else(|| config.blueprint.as_ref().map(|b| b.branch.clone()))
332+
.unwrap_or_else(|| config.default_branch.clone());
333+
334+
let mut paths = if !command.blueprint_path.is_empty() {
335+
command.blueprint_path.clone()
336+
} else {
337+
config
338+
.blueprint
339+
.as_ref()
340+
.map(|b| b.paths.clone())
341+
.unwrap_or_default()
342+
};
343+
344+
if paths.is_empty() {
345+
paths.push(".github".to_string());
346+
}
347+
348+
Ok(ResolvedBlueprintSource { repo, branch, paths })
349+
}
350+
351+
fn parse_repo_slug(input: &str) -> Result<(String, String)> {
352+
let trimmed = input.trim().trim_end_matches('/');
353+
let slug = if let Some(pos) = trimmed.find("github.com/") {
354+
&trimmed[pos + "github.com/".len()..]
355+
} else {
356+
trimmed
357+
};
358+
359+
let mut parts = slug.split('/').filter(|p| !p.is_empty());
360+
let owner = parts.next().unwrap_or("");
361+
let repo = parts.next().unwrap_or("");
362+
363+
if owner.is_empty() || repo.is_empty() {
364+
anyhow::bail!("blueprint repo must be in 'org/repo' or GitHub URL format")
365+
}
366+
367+
let repo = repo.trim_end_matches(".git");
368+
Ok((owner.to_string(), repo.to_string()))
369+
}
370+
262371
async fn fix_repos(provider: &dyn crate::provider::Provider, config: &AppConfig) -> Result<()> {
263372
if config.scoped_repos.is_empty() {
264373
let proceed = prompt_confirm(

src/config/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub struct AppConfig {
1919
#[serde(default)]
2020
pub audit: AuditConfig,
2121
#[serde(default)]
22+
pub blueprint: Option<BlueprintSource>,
23+
#[serde(default)]
2224
pub templates: Vec<TemplateConfig>,
2325
/// When non-empty, devopster operations target only these repositories.
2426
/// An empty list means all repositories in the organization.
@@ -109,6 +111,16 @@ pub struct CatalogConfig {
109111
pub output_path: String,
110112
}
111113

114+
#[derive(Debug, Clone, Deserialize)]
115+
pub struct BlueprintSource {
116+
/// Org/repo or full GitHub URL for the blueprint source repository.
117+
pub repo: String,
118+
#[serde(default = "default_branch")]
119+
pub branch: String,
120+
#[serde(default)]
121+
pub paths: Vec<String>,
122+
}
123+
112124
#[derive(Debug, Clone, Deserialize)]
113125
pub struct TemplateConfig {
114126
pub name: String,

0 commit comments

Comments
 (0)