Skip to content

Commit 6a8c0c7

Browse files
author
Cooper Maruyama
committed
feat(ci): add himitsu CI command + opencode workflow; guard key.txt in gitignore
1 parent 1809525 commit 6a8c0c7

7 files changed

Lines changed: 401 additions & 5 deletions

File tree

.beads/issues.jsonl

Lines changed: 9 additions & 2 deletions
Large diffs are not rendered by default.

.github/workflows/opencode.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: opencode
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
9+
jobs:
10+
opencode:
11+
if: |
12+
contains(github.event.comment.body, ' /oc') ||
13+
startsWith(github.event.comment.body, '/oc') ||
14+
contains(github.event.comment.body, ' /opencode') ||
15+
startsWith(github.event.comment.body, '/opencode')
16+
runs-on: ubuntu-latest
17+
permissions:
18+
id-token: write
19+
contents: read
20+
pull-requests: read
21+
issues: read
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v6
25+
with:
26+
persist-credentials: false
27+
28+
- name: Run opencode
29+
uses: anomalyco/opencode/github@latest
30+
env:
31+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
32+
with:
33+
model: openrouter/tencent/hy3-preview

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ node_modules
1616
.meta/
1717

1818
# Local demo and scratch files
19+
key.txt
1920
amp
2021
.cursor
2122
.mux

rust/src/cli/ci.rs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
use std::fs;
2+
use std::path::{Path, PathBuf};
3+
use std::process::Command;
4+
5+
use clap::{Args, Subcommand, ValueEnum};
6+
7+
use crate::config;
8+
use crate::error::{HimitsuError, Result};
9+
10+
/// Manage the GitHub Actions workflow for self-serve himitsu rekeys.
11+
#[derive(Debug, Args)]
12+
pub struct CiArgs {
13+
#[command(subcommand)]
14+
pub command: CiCommand,
15+
}
16+
17+
#[derive(Debug, Subcommand)]
18+
pub enum CiCommand {
19+
/// Show whether the himitsu workflow is installed in this repository.
20+
Status(WorkflowPathArgs),
21+
/// Install the himitsu self-serve rekey workflow into .github/workflows/.
22+
Install(InstallArgs),
23+
/// Trigger the installed workflow through the GitHub CLI.
24+
Run(RunArgs),
25+
}
26+
27+
#[derive(Debug, Args)]
28+
pub struct WorkflowPathArgs {
29+
/// Workflow file path, relative to the current directory by default.
30+
#[arg(long, default_value = ".github/workflows/himitsu.yml")]
31+
pub path: PathBuf,
32+
}
33+
34+
#[derive(Debug, Args)]
35+
pub struct InstallArgs {
36+
/// Default remote slug to prefill in the workflow_dispatch form.
37+
#[arg(long = "default-remote")]
38+
pub default_remote: Option<String>,
39+
40+
/// Git ref for the himitsu action used by the generated workflow.
41+
#[arg(long, default_value = "main")]
42+
pub action_ref: String,
43+
44+
/// Workflow file path, relative to the current directory by default.
45+
#[arg(long, default_value = ".github/workflows/himitsu.yml")]
46+
pub path: PathBuf,
47+
48+
/// Overwrite an existing workflow file.
49+
#[arg(long)]
50+
pub force: bool,
51+
}
52+
53+
#[derive(Clone, Copy, Debug, ValueEnum)]
54+
pub enum CiOperation {
55+
Sync,
56+
AddRecipient,
57+
RmRecipient,
58+
}
59+
60+
impl CiOperation {
61+
fn as_input(self) -> &'static str {
62+
match self {
63+
Self::Sync => "sync",
64+
Self::AddRecipient => "add-recipient",
65+
Self::RmRecipient => "rm-recipient",
66+
}
67+
}
68+
}
69+
70+
#[derive(Debug, Args)]
71+
pub struct RunArgs {
72+
/// Operation to trigger in the workflow.
73+
#[arg(long, value_enum, default_value_t = CiOperation::Sync)]
74+
pub operation: CiOperation,
75+
76+
/// Target remote slug (org/repo).
77+
#[arg(long = "target-remote")]
78+
pub target_remote: Option<String>,
79+
80+
/// Recipient name for add-recipient or rm-recipient.
81+
#[arg(long)]
82+
pub recipient_name: Option<String>,
83+
84+
/// Age public key for add-recipient.
85+
#[arg(long)]
86+
pub recipient_key: Option<String>,
87+
88+
/// Target recipient group.
89+
#[arg(long, default_value = "team")]
90+
pub group: String,
91+
92+
/// Workflow file name known to GitHub Actions.
93+
#[arg(long, default_value = "himitsu.yml")]
94+
pub workflow: String,
95+
96+
/// Print the gh command instead of executing it.
97+
#[arg(long)]
98+
pub dry_run: bool,
99+
}
100+
101+
pub fn run(args: CiArgs) -> Result<()> {
102+
match args.command {
103+
CiCommand::Status(args) => status(&args.path),
104+
CiCommand::Install(args) => install(args),
105+
CiCommand::Run(args) => trigger(args),
106+
}
107+
}
108+
109+
fn status(path: &Path) -> Result<()> {
110+
if path.exists() {
111+
println!("himitsu ci installed at {}", path.display());
112+
} else {
113+
println!(
114+
"himitsu ci not installed; run `himitsu ci install` to create {}",
115+
path.display()
116+
);
117+
}
118+
Ok(())
119+
}
120+
121+
fn install(args: InstallArgs) -> Result<()> {
122+
if let Some(remote) = &args.default_remote {
123+
config::validate_remote_slug(remote)?;
124+
}
125+
126+
if args.path.exists() && !args.force {
127+
return Err(HimitsuError::External(format!(
128+
"{} already exists; pass --force to overwrite",
129+
args.path.display()
130+
)));
131+
}
132+
133+
if let Some(parent) = args.path.parent() {
134+
fs::create_dir_all(parent)?;
135+
}
136+
137+
let workflow = workflow_template(args.default_remote.as_deref(), &args.action_ref);
138+
fs::write(&args.path, workflow)?;
139+
println!("Installed himitsu ci workflow at {}", args.path.display());
140+
Ok(())
141+
}
142+
143+
fn trigger(args: RunArgs) -> Result<()> {
144+
if let Some(remote) = &args.target_remote {
145+
config::validate_remote_slug(remote)?;
146+
}
147+
148+
let mut fields = vec![format!("operation={}", args.operation.as_input())];
149+
push_optional_field(&mut fields, "remote", args.target_remote.as_deref());
150+
push_optional_field(
151+
&mut fields,
152+
"recipient-name",
153+
args.recipient_name.as_deref(),
154+
);
155+
push_optional_field(&mut fields, "recipient-key", args.recipient_key.as_deref());
156+
fields.push(format!("group={}", args.group));
157+
158+
if args.dry_run {
159+
println!("{}", render_gh_command(&args.workflow, &fields));
160+
return Ok(());
161+
}
162+
163+
let mut cmd = Command::new("gh");
164+
cmd.args(["workflow", "run", &args.workflow]);
165+
for field in &fields {
166+
cmd.args(["--field", field]);
167+
}
168+
169+
let status = cmd.status().map_err(|e| {
170+
HimitsuError::External(format!(
171+
"failed to run `gh workflow run {}`: {e}",
172+
args.workflow
173+
))
174+
})?;
175+
176+
if !status.success() {
177+
return Err(HimitsuError::External(format!(
178+
"gh workflow run exited with status {status}"
179+
)));
180+
}
181+
182+
Ok(())
183+
}
184+
185+
fn push_optional_field(fields: &mut Vec<String>, name: &str, value: Option<&str>) {
186+
if let Some(value) = value {
187+
if !value.is_empty() {
188+
fields.push(format!("{name}={value}"));
189+
}
190+
}
191+
}
192+
193+
fn render_gh_command(workflow: &str, fields: &[String]) -> String {
194+
let mut parts = vec![
195+
"gh".to_string(),
196+
"workflow".to_string(),
197+
"run".to_string(),
198+
workflow.to_string(),
199+
];
200+
201+
for field in fields {
202+
parts.push("--field".to_string());
203+
parts.push(field.clone());
204+
}
205+
206+
parts.join(" ")
207+
}
208+
209+
fn workflow_template(default_remote: Option<&str>, action_ref: &str) -> String {
210+
let default_remote = default_remote.unwrap_or("");
211+
format!(
212+
r#"name: Himitsu Self-Serve Rekey
213+
214+
on:
215+
workflow_dispatch:
216+
inputs:
217+
operation:
218+
description: "Operation to run"
219+
required: true
220+
default: sync
221+
type: choice
222+
options:
223+
- sync
224+
- add-recipient
225+
- rm-recipient
226+
remote:
227+
description: "Target himitsu remote (org/repo)"
228+
required: false
229+
default: {default_remote}
230+
recipient-name:
231+
description: "Recipient name for add-recipient or rm-recipient"
232+
required: false
233+
recipient-key:
234+
description: "Age public key for add-recipient"
235+
required: false
236+
group:
237+
description: "Target recipient group"
238+
required: false
239+
default: team
240+
241+
permissions:
242+
contents: write
243+
pull-requests: write
244+
245+
jobs:
246+
himitsu:
247+
runs-on: ubuntu-latest
248+
steps:
249+
- uses: actions/checkout@v4
250+
- uses: darkmatter/himitsu@{action_ref}
251+
with:
252+
operation: ${{{{ inputs.operation }}}}
253+
remote: ${{{{ inputs.remote }}}}
254+
recipient-name: ${{{{ inputs.recipient-name }}}}
255+
recipient-key: ${{{{ inputs.recipient-key }}}}
256+
group: ${{{{ inputs.group }}}}
257+
"#
258+
)
259+
}
260+
261+
#[cfg(test)]
262+
mod tests {
263+
use super::*;
264+
265+
#[test]
266+
fn operation_inputs_match_action_operations() {
267+
assert_eq!(CiOperation::Sync.as_input(), "sync");
268+
assert_eq!(CiOperation::AddRecipient.as_input(), "add-recipient");
269+
assert_eq!(CiOperation::RmRecipient.as_input(), "rm-recipient");
270+
}
271+
272+
#[test]
273+
fn dry_run_command_includes_fields() {
274+
let fields = vec![
275+
"operation=sync".to_string(),
276+
"remote=acme/secrets".to_string(),
277+
];
278+
assert_eq!(
279+
render_gh_command("himitsu.yml", &fields),
280+
"gh workflow run himitsu.yml --field operation=sync --field remote=acme/secrets"
281+
);
282+
}
283+
}

rust/src/cli/completions.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,7 @@ pub fn run_complete_paths(args: CompletePathsArgs, ctx: &super::Context) -> Resu
7777

7878
// Fast path: serve from the SQLite cache when warm.
7979
if crate::completions_cache::is_warm(&ctx.state_dir, &stores) {
80-
if let Ok(paths) =
81-
crate::completions_cache::lookup(&ctx.state_dir, &stores, &args.prefix)
82-
{
80+
if let Ok(paths) = crate::completions_cache::lookup(&ctx.state_dir, &stores, &args.prefix) {
8381
let mut out = io::stdout().lock();
8482
for p in paths {
8583
let _ = writeln!(out, "{p}");

rust/src/cli/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod check;
2+
pub mod ci;
23
pub mod codegen;
34
pub mod completions;
45
pub mod context;
@@ -343,6 +344,9 @@ pub enum Command {
343344
/// Verify store checkouts are up to date with their remotes.
344345
Check(check::CheckArgs),
345346

347+
/// Manage GitHub Actions workflows for self-serve rekeys.
348+
Ci(ci::CiArgs),
349+
346350
/// Show the himitsu documentation (renders README).
347351
Docs,
348352

@@ -393,13 +397,15 @@ impl Cli {
393397
let is_docs = matches!(&command, Command::Docs);
394398
let is_completions = matches!(&command, Command::Completions(_));
395399
let is_complete_paths = matches!(&command, Command::CompletePaths(_));
400+
let is_ci = matches!(&command, Command::Ci(_));
396401

397402
if !is_init
398403
&& !is_git
399404
&& !is_version
400405
&& !is_docs
401406
&& !is_completions
402407
&& !is_complete_paths
408+
&& !is_ci
403409
&& !crate::crypto::keystore::is_initialized(&data_dir)
404410
{
405411
eprintln!("First run — initializing himitsu...");
@@ -548,6 +554,7 @@ impl Cli {
548554
Command::Exec(args) => exec::run(args, &ctx),
549555
Command::Git(args) => git::run(args, &ctx),
550556
Command::Check(args) => check::run(args, &ctx),
557+
Command::Ci(args) => ci::run(args),
551558
Command::Docs => docs::run(),
552559
Command::Version => {
553560
println!("{}", crate::build_info::VERSION_LINE);

0 commit comments

Comments
 (0)