Skip to content

Commit e96f368

Browse files
Timna BrownTimna Brown
authored andcommitted
feat: add repo fix command for interactive metadata updates
- new repo fix command prompts for missing description/topics/license - uses template topics/descriptions and README first line when available - supports GitHub and GitLab metadata updates; skips unsupported providers - adds update_description and readme_first_line hooks to provider trait - README: add repo fix and stats --scope-missing to command table - help: include repo fix row in devopster --help table
1 parent a06d519 commit e96f368

6 files changed

Lines changed: 452 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ After `make setup` the `devopster` binary is on your `$PATH` and these commands
8080
| `devopster repo list` | List all repositories in the configured organization |
8181
| `devopster repo list --topic rust` | Filter repositories by topic |
8282
| `devopster repo audit` | Audit repos for missing description, topics, license, and default branch |
83+
| `devopster repo fix` | Prompt for missing description, topics, and license in scoped repos |
8384
| `devopster repo scaffold --name <name> --template <template>` | Create a new repository from a template defined in config |
8485
| `devopster repo sync` | Push files from `.github/` to all repositories |
8586
| `devopster catalog generate` | Export a JSON catalog of all repositories |
8687
| `devopster topics align` | Add missing template topics to every matching repository |
8788
| `devopster stats` | Print org summary: config, coverage (description/topics/license/branch), compliance, and top topics |
89+
| `devopster stats --scope-missing` | Update scoped repos to the non-compliant list |
8890

8991
### Quick start inside the container
9092

src/cli/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Commands:
3535
{tab}| devopster repo list | List repositories in the configured organization |
3636
{tab}| devopster repo list --topic <topic> | Filter repositories by topic |
3737
{tab}| devopster repo audit | Audit repos against the configured policy |
38-
{tab}| devopster repo scaffold --name <n> --template <t> | Create a new repository from a template |
38+
{tab}| devopster repo fix | Prompt to fix missing metadata |
39+
{tab}| devopster repo scaffold | Create a new repository from a template |
3940
{tab}| devopster repo sync | Push files from .github/ to all repositories |
4041
{tab}+-----------------------------------------------+---------------------------------------------------+
4142
{tab}| devopster catalog generate | Export a catalog.json of all repositories |

src/cli/repo.rs

Lines changed: 290 additions & 0 deletions
Large diffs are not rendered by default.

src/provider/github.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,37 @@ impl Provider for GitHubProvider {
200200
})
201201
}
202202

203+
async fn update_description(
204+
&self,
205+
organization: &str,
206+
repository: &str,
207+
description: &str,
208+
) -> Result<()> {
209+
let endpoint = self
210+
.api_url
211+
.join(&format!("/repos/{organization}/{repository}"))
212+
.with_context(|| {
213+
format!("failed to build GitHub URL for '{organization}/{repository}'")
214+
})?;
215+
216+
self.client
217+
.patch(endpoint)
218+
.json(&UpdateGitHubRepositoryRequest {
219+
description: description.to_string(),
220+
})
221+
.send()
222+
.await
223+
.with_context(|| {
224+
format!("failed to update description for '{organization}/{repository}'")
225+
})?
226+
.error_for_status()
227+
.with_context(|| {
228+
format!("GitHub update repo API returned an error for '{repository}'")
229+
})?;
230+
231+
Ok(())
232+
}
233+
203234
async fn align_topics(
204235
&self,
205236
organization: &str,
@@ -229,6 +260,46 @@ impl Provider for GitHubProvider {
229260
Ok(())
230261
}
231262

263+
async fn readme_first_line(
264+
&self,
265+
organization: &str,
266+
repository: &str,
267+
) -> Result<Option<String>> {
268+
let endpoint = self
269+
.api_url
270+
.join(&format!("/repos/{organization}/{repository}/readme"))
271+
.with_context(|| {
272+
format!("failed to build GitHub README URL for '{organization}/{repository}'")
273+
})?;
274+
275+
let response = self.client.get(endpoint).send().await.with_context(|| {
276+
format!("failed to fetch README for '{organization}/{repository}'")
277+
})?;
278+
279+
if response.status() == StatusCode::NOT_FOUND {
280+
return Ok(None);
281+
}
282+
283+
let response = response.error_for_status().with_context(|| {
284+
format!("GitHub README API returned an error for '{repository}'")
285+
})?;
286+
287+
let readme: GitHubReadmeContent = response.json().await.with_context(|| {
288+
format!("failed to decode README response for '{repository}'")
289+
})?;
290+
291+
if readme.content.trim().is_empty() {
292+
return Ok(None);
293+
}
294+
295+
let cleaned = readme.content.replace('\n', "");
296+
let bytes = base64::engine::general_purpose::STANDARD
297+
.decode(cleaned)
298+
.with_context(|| format!("failed to decode README content for '{repository}'"))?;
299+
let text = String::from_utf8_lossy(&bytes);
300+
Ok(first_readme_line(&text))
301+
}
302+
232303
async fn push_file(
233304
&self,
234305
organization: &str,
@@ -451,3 +522,37 @@ struct PushGitHubFileRequest {
451522
#[serde(skip_serializing_if = "Option::is_none")]
452523
sha: Option<String>,
453524
}
525+
526+
#[derive(Debug, Serialize)]
527+
struct UpdateGitHubRepositoryRequest {
528+
description: String,
529+
}
530+
531+
#[derive(Debug, Deserialize)]
532+
struct GitHubReadmeContent {
533+
#[serde(default)]
534+
content: String,
535+
}
536+
537+
fn first_readme_line(markdown: &str) -> Option<String> {
538+
for line in markdown.lines() {
539+
let mut s = line.trim();
540+
if s.is_empty() {
541+
continue;
542+
}
543+
loop {
544+
let trimmed = s.trim_start_matches(|c: char| {
545+
c == '#' || c == '>' || c == '-' || c == '*' || c == ' '
546+
});
547+
if trimmed == s {
548+
break;
549+
}
550+
s = trimmed;
551+
}
552+
let cleaned = s.trim().trim_matches('`');
553+
if !cleaned.is_empty() {
554+
return Some(cleaned.to_string());
555+
}
556+
}
557+
None
558+
}

src/provider/gitlab.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,33 @@ impl Provider for GitLabProvider {
167167
})
168168
}
169169

170+
async fn update_description(
171+
&self,
172+
organization: &str,
173+
repository: &str,
174+
description: &str,
175+
) -> Result<()> {
176+
let project_path = url_encode_project_path(organization, repository);
177+
let endpoint = self.url(&format!("/projects/{project_path}"));
178+
179+
self.client
180+
.put(&endpoint)
181+
.json(&UpdateGitLabProjectRequest {
182+
description: description.to_string(),
183+
})
184+
.send()
185+
.await
186+
.with_context(|| {
187+
format!("failed to update description for '{organization}/{repository}'")
188+
})?
189+
.error_for_status()
190+
.with_context(|| {
191+
format!("GitLab update project API returned an error for '{repository}'")
192+
})?;
193+
194+
Ok(())
195+
}
196+
170197
async fn align_topics(
171198
&self,
172199
organization: &str,
@@ -429,6 +456,11 @@ struct UpdateGitLabTopics {
429456
topics: Vec<String>,
430457
}
431458

459+
#[derive(Debug, Serialize)]
460+
struct UpdateGitLabProjectRequest {
461+
description: String,
462+
}
463+
432464
#[derive(Debug, Serialize)]
433465
struct GitLabFileRequest {
434466
branch: String,

src/provider/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ pub trait Provider: Send + Sync {
9191
Ok(())
9292
}
9393

94+
/// Update a repository description.
95+
/// Providers that do not support metadata updates will return an error.
96+
async fn update_description(
97+
&self,
98+
_organization: &str,
99+
_repository: &str,
100+
_description: &str,
101+
) -> Result<()> {
102+
anyhow::bail!("update_description is not supported by this provider")
103+
}
104+
105+
/// Best-effort fetch of the first non-empty README line for suggestions.
106+
/// Providers that do not support README access may return Ok(None).
107+
async fn readme_first_line(
108+
&self,
109+
_organization: &str,
110+
_repository: &str,
111+
) -> Result<Option<String>> {
112+
Ok(None)
113+
}
114+
94115
/// Create or update a single file in a repository.
95116
/// Providers that do not support file push will return an error.
96117
async fn push_file(

0 commit comments

Comments
 (0)