blob: 9a88f527f886a03ef1823e8ffcea90873ec18790 [file]
//! The GitHub comments tap, the outbound half: a completed task that came from an issue
//! of one repository has its handoff posted back on that issue as a comment, once. The
//! host hands the tap only the handoffs it may read, under its ceiling, and records where
//! each was posted so a replay posts nothing twice. A post that fails costs only itself.
//! The tap holds no credential: the host puts the bearer token on the request.
//!
//! Config, JSON: `{"repo": "owner/name", "api": "https://api.github.com"}`; `api` is
//! optional and exists so a test can point the tap at a fake.
wit_bindgen::generate!({
path: "../../crates/taps/wit",
world: "tap",
});
use durable_memory::tap::memory::{completed, posted, Done};
use serde::Deserialize;
use wasi::http::outgoing_handler;
use wasi::http::types::{Fields, Method, OutgoingBody, OutgoingRequest, Scheme};
use wasi::io::streams::StreamError;
#[derive(Deserialize)]
struct Config {
repo: String,
#[serde(default = "github")]
api: String,
}
fn github() -> String {
"https://api.github.com".into()
}
#[derive(Deserialize)]
struct Comment {
html_url: String,
}
/// `scheme`, `authority` and `path?query` of a URL.
fn split(url: &str) -> Result<(Scheme, String, String), String> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| format!("{url:?} has no scheme"))?;
let scheme = match scheme {
"https" => Scheme::Https,
"http" => Scheme::Http,
other => Scheme::Other(other.to_string()),
};
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a.to_string(), format!("/{p}")),
None => (rest.to_string(), "/".to_string()),
};
if authority.is_empty() {
return Err(format!("{url:?} has no host"));
}
Ok((scheme, authority, path))
}
/// POST a JSON body to one URL; the status and the response text.
fn post(url: &str, json: &str) -> Result<(u16, String), String> {
let (scheme, authority, path) = split(url)?;
let headers = Fields::from_list(&[
(
"accept".to_string(),
b"application/vnd.github+json".to_vec(),
),
("content-type".to_string(), b"application/json".to_vec()),
("user-agent".to_string(), b"durable-memory-tap/0.1".to_vec()),
])
.map_err(|e| format!("headers: {e}"))?;
let req = OutgoingRequest::new(headers);
req.set_method(&Method::Post).map_err(|()| "method")?;
req.set_scheme(Some(&scheme)).map_err(|()| "scheme")?;
req.set_authority(Some(&authority))
.map_err(|()| "authority")?;
req.set_path_with_query(Some(&path)).map_err(|()| "path")?;
let body = req.body().map_err(|()| "body")?;
{
let out = body.write().map_err(|()| "body stream")?;
out.blocking_write_and_flush(json.as_bytes())
.map_err(|e| format!("write: {e}"))?;
}
OutgoingBody::finish(body, None).map_err(|e| format!("finish: {e}"))?;
let pending = outgoing_handler::handle(req, None).map_err(|e| format!("request: {e}"))?;
pending.subscribe().block();
let response = pending
.get()
.ok_or("no response")?
.map_err(|()| "response taken twice")?
.map_err(|e| format!("response: {e}"))?;
let status = response.status();
let body = response.consume().map_err(|()| "body")?;
let stream = body.stream().map_err(|()| "stream")?;
let mut bytes = Vec::new();
loop {
match stream.blocking_read(64 * 1024) {
Ok(chunk) => bytes.extend(chunk),
Err(StreamError::Closed) => break,
Err(StreamError::LastOperationFailed(e)) => {
return Err(format!("read: {}", e.to_debug_string()))
}
}
}
drop(stream);
Ok((status, String::from_utf8_lossy(&bytes).into_owned()))
}
/// The issue number of an origin in this repository, `github:<repo>#<n>`.
fn issue_number(origin: &str, repo: &str) -> Option<u64> {
origin
.strip_prefix("github:")?
.strip_prefix(repo)?
.strip_prefix('#')?
.parse()
.ok()
}
/// The comment: the handoff as written, and a line saying where it came from.
fn comment(done: &Done) -> String {
format!(
"{}\n\n---\nPosted by the memory tap `github-comments`: the handoff `{}` of task `{}`.\n",
done.text.trim_end(),
done.handoff,
done.id
)
}
struct Component;
impl Guest for Component {
fn run(config: String) -> Result<Report, String> {
let config: Config = serde_json::from_str(&config).map_err(|e| format!("config: {e}"))?;
if config.repo.split('/').filter(|s| !s.is_empty()).count() != 2
|| config.repo.contains("..")
{
return Err(format!("repo {:?} is not owner/name", config.repo));
}
let api = config.api.trim_end_matches('/');
let mut report = Report {
filed: 0,
deduplicated: 0,
proposed: 0,
posted: 0,
note: String::new(),
};
let (mut elsewhere, mut failed) = (0u32, Vec::new());
for done in completed()? {
let Some(number) = issue_number(&done.origin, &config.repo) else {
elsewhere += 1;
continue;
};
let url = format!("{api}/repos/{}/issues/{number}/comments", config.repo);
let body = serde_json::json!({ "body": comment(&done) }).to_string();
match post(&url, &body) {
Ok((201, text)) => {
let to = serde_json::from_str::<Comment>(&text)
.map(|c| c.html_url)
.unwrap_or(url);
// the comment is up; a refusal to record it costs this one, not the run
match posted(&done.id, &to) {
Ok(()) => report.posted += 1,
Err(e) => failed.push(format!("#{number}: posted, not recorded: {e}")),
}
}
Ok((status, text)) => failed.push(format!(
"#{number}: {status} {}",
text.chars().take(120).collect::<String>()
)),
Err(e) => failed.push(format!("#{number}: {e}")),
}
}
report.note = format!(
"{}: {} handoffs posted, {elsewhere} done elsewhere{}",
config.repo,
report.posted,
if failed.is_empty() {
String::new()
} else {
format!(", {} failed: {}", failed.len(), failed.join("; "))
}
);
Ok(report)
}
}
export!(Component);