thirdshift 0.16.1

Turn a GitHub issue into a ready-for-review pull request, unattended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! The Resend API key, and where it came from: `RESEND_API_KEY` when it is
//! set and not empty, else `resend.key` in the Credentials,
//! `~/.thirdshift/credentials.toml`. The email sender and Setup both find it
//! here, so they can't disagree about whether there is one, and Setup saves
//! the key it is given here too. Only they call it, so a broken Credentials
//! file can't block a Run that sends no email, nor `update`, `version` or
//! `help`; a first Run's Setup offer, once accepted, is Setup, and reads it.
//! The same Credentials also keep `claude.setup_token` and `opencode.api_key`,
//! reserved for Trials; ordinary Runs never receive them.

use std::fmt;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use toml::{Table, Value};
use toml_edit::{DocumentMut, Item};

use crate::{config, progress};

/// A Resend API key, and where it came from.
pub struct ResendKey {
    pub secret: String,
    pub source: Source,
}

/// Where a Resend API key came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
    /// The `RESEND_API_KEY` environment variable.
    Environment,
    /// The Credentials at this path.
    Credentials(PathBuf),
}

impl fmt::Display for Source {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Source::Environment => write!(f, "RESEND_API_KEY"),
            Source::Credentials(path) => write!(f, "the Credentials {}", path.display()),
        }
    }
}

impl ResendKey {
    /// The Resend API key, or `None` if there is none. The Credentials are
    /// read only when `RESEND_API_KEY` is unset or empty; a missing file
    /// holds no key, and one that others can read is used with a warning.
    /// Fails, naming the file and the key, on Credentials that aren't TOML
    /// or hold unknown keys or non-string secrets, so a typo can't quietly
    /// leave a Run without its notification.
    pub fn find() -> Result<Option<Self>> {
        match from_environment() {
            Some(key) => Ok(Some(key)),
            None => Ok(Credentials::read()?.key()),
        }
    }

    /// The Resend API key, or an error listing every way to give one.
    pub fn require() -> Result<Self> {
        match ResendKey::find()? {
            Some(key) => Ok(key),
            None => bail!("{}", missing(&path()?)),
        }
    }
}

/// The key in `RESEND_API_KEY`, if it is set and not empty.
fn from_environment() -> Option<ResendKey> {
    environment_key(std::env::var("RESEND_API_KEY").ok())
}

/// The key in `RESEND_API_KEY` when it holds `value`: none if it is unset or
/// empty, so the Credentials are read instead.
fn environment_key(value: Option<String>) -> Option<ResendKey> {
    value.filter(|key| !key.is_empty()).map(|secret| ResendKey {
        secret,
        source: Source::Environment,
    })
}

/// The Credentials, as read: text and secrets, if any. Setup reads them before
/// asking anything, so it can refuse a broken file and retain saved secrets.
pub struct Credentials {
    path: PathBuf,
    text: Option<String>,
    secrets: Secrets,
}

#[derive(Default)]
struct Secrets {
    key: Option<String>,
    claude_setup_token: Option<String>,
    opencode_api_key: Option<String>,
}

impl Credentials {
    /// Read the Credentials, as strictly as [`ResendKey::find`] does, with
    /// the same warning if others can read them. A missing file has no secrets.
    pub fn read() -> Result<Self> {
        let path = path()?;
        let text = match std::fs::read_to_string(&path) {
            Ok(text) => text,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Credentials {
                    path,
                    text: None,
                    secrets: Secrets::default(),
                });
            }
            Err(error) => {
                return Err(error).with_context(|| format!("can't read {}", path.display()));
            }
        };
        let source = Source::Credentials(path.clone());
        let secrets = parse(&text, &source)?;
        warn_if_others_can_read(&path, &source);
        Ok(Credentials {
            path,
            text: Some(text),
            secrets,
        })
    }

    /// The Resend API key the lookup finds with these Credentials:
    /// `RESEND_API_KEY` if it is set and not empty, else theirs, if any.
    pub fn lookup(&self) -> Option<ResendKey> {
        from_environment().or_else(|| self.key())
    }

    /// The key these Credentials hold, if any.
    fn key(&self) -> Option<ResendKey> {
        self.secrets.key.clone().map(|secret| ResendKey {
            secret,
            source: Source::Credentials(self.path.clone()),
        })
    }

    /// Whether Enter in Setup will keep a saved Claude setup token. Its
    /// value is never exposed to Setup's prompts or ordinary Runs.
    pub fn has_claude_setup_token(&self) -> bool {
        self.secrets.claude_setup_token.is_some()
    }

    /// The saved Claude setup token, for a Trial's `CLAUDE_CODE_OAUTH_TOKEN`,
    /// if there is one. Ordinary Runs never receive it.
    pub fn claude_setup_token(&self) -> Option<String> {
        self.secrets.claude_setup_token.clone()
    }

    /// The saved OpenCode provider key, for a Trial's environment, if there
    /// is one. Ordinary Runs never receive it. Setup asking for it is a later
    /// Ticket; a missing key fails an OpenCode Trial before its session.
    pub fn opencode_api_key(&self) -> Option<String> {
        self.secrets.opencode_api_key.clone()
    }

    /// Save the supplied secrets together, retaining omitted values and
    /// comments. New files and replacements have mode 0600.
    pub fn save(
        &self,
        key: Option<&str>,
        claude_setup_token: Option<&str>,
        opencode_api_key: Option<&str>,
    ) -> Result<()> {
        let mut text = self.text.clone().unwrap_or_default();
        if let Some(key) = key {
            text = with_secret(&text, "resend", "key", key)?;
        }
        if let Some(token) = claude_setup_token {
            text = with_secret(&text, "claude", "setup_token", token)?;
        }
        if let Some(api_key) = opencode_api_key {
            text = with_secret(&text, "opencode", "api_key", api_key)?;
        }
        let written = match &self.text {
            None => create(&self.path, &text),
            Some(_) => {
                // config::replace preserves permissions; restrict them before
                // replacing so neither the old nor new file remains exposed.
                std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))
                    .and_then(|()| config::replace(&self.path, &text))
            }
        };
        written.with_context(|| format!("can't write {}", self.path.display()))
    }
}

impl fmt::Display for Credentials {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "the Credentials {}", self.path.display())
    }
}

/// Whether `text` looks like a Resend API key, which starts with `re_`.
pub fn is_key(text: &str) -> bool {
    text.starts_with("re_")
}

/// Set one secret, keeping its decor or appending a missing section.
fn with_secret(text: &str, section_name: &str, key: &str, secret: &str) -> Result<String> {
    let mut document: DocumentMut = text
        .parse()
        .map_err(|_| anyhow!("can't parse the Credentials: invalid TOML (contents hidden)"))?;
    let Some(settings) = document.get_mut(section_name) else {
        // A new section must be a table header: assigning the key directly
        // renders an inline value, which would file it under the previous
        // section. Render the header separately and append it instead, so
        // the existing text stays byte-identical.
        let mut section = DocumentMut::new();
        let mut table = toml_edit::Table::new();
        table.insert(key, toml_edit::value(secret));
        section[section_name] = Item::Table(table);
        let mut text = text.to_string();
        if !text.trim().is_empty() {
            if !text.ends_with('\n') {
                text.push('\n');
            }
            text.push('\n');
        }
        text.push_str(&section.to_string());
        return Ok(text);
    };
    let settings = settings
        .as_table_like_mut()
        .with_context(|| format!("{section_name} must be a section in the Credentials"))?;
    match settings.get_mut(key).and_then(Item::as_value_mut) {
        Some(old) => {
            let decor = old.decor().clone();
            *old = secret.into();
            *old.decor_mut() = decor;
        }
        None => {
            settings.insert(key, toml_edit::value(secret));
        }
    }
    Ok(document.to_string())
}

/// Create the file at `path` holding `text`, readable only by the user,
/// and the folder it goes in if that is missing.
fn create(path: &Path, text: &str) -> std::io::Result<()> {
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir)?;
    }
    std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)?
        .write_all(text.as_bytes())
}

/// The Credentials' path, next to the User config.
fn path() -> Result<PathBuf> {
    Ok(config::home()?.join(".thirdshift/credentials.toml"))
}

/// `resend.key` in `text`, the contents of `file`, if it is there and not
/// empty. Also accepts `claude.setup_token` and `opencode.api_key`, reserved
/// for Trials. Any other section or key is an error, as in the User config.
fn parse(text: &str, file: &Source) -> Result<Secrets> {
    let table: Table = text
        .parse()
        .map_err(|_| anyhow!("can't parse {file}: invalid TOML (contents hidden)"))?;
    let mut found = Secrets::default();
    for (section, value) in &table {
        let settings = match (section.as_str(), value) {
            ("resend" | "claude" | "opencode", Value::Table(settings)) => settings,
            ("resend" | "claude" | "opencode", _) => {
                bail!("{section} must be the section [{section}] in {file}")
            }
            (_, Value::Table(_)) => bail!("unknown section [{section}] in {file}"),
            _ => bail!("unknown key {section} in {file}"),
        };
        for (key, value) in settings {
            match (section.as_str(), key.as_str(), value) {
                ("resend", "key", Value::String(key)) => found.key = nonempty(key),
                ("resend", "key", _) => {
                    bail!("resend.key must be a quoted Resend API key in {file}")
                }
                ("claude", "setup_token", Value::String(token)) => {
                    found.claude_setup_token = nonempty(token);
                }
                ("claude", "setup_token", _) => {
                    bail!("claude.setup_token must be a quoted Claude setup token in {file}")
                }
                ("opencode", "api_key", Value::String(api_key)) => {
                    found.opencode_api_key = nonempty(api_key);
                }
                ("opencode", "api_key", _) => {
                    bail!("opencode.api_key must be a quoted OpenCode provider key in {file}")
                }
                _ => bail!("unknown key {section}.{key} in {file}"),
            }
        }
    }
    Ok(found)
}

fn nonempty(secret: &str) -> Option<String> {
    (!secret.is_empty()).then(|| secret.to_owned())
}

/// Warn when `file`, at `path`, can be read by anyone but the user.
fn warn_if_others_can_read(path: &Path, file: &Source) {
    let mode = std::fs::metadata(path).map(|metadata| metadata.permissions().mode());
    if mode.is_ok_and(|mode| mode & 0o077 != 0) {
        progress::step(format_args!(
            "warning: others can read {file}; chmod 600 {}",
            path.display()
        ));
    }
}

/// What to say when there is no key, with the Credentials at `path`.
fn missing(path: &Path) -> String {
    format!(
        "no Resend API key. Either:\n  \
         - run `thirdshift setup`, or\n  \
         - add it to {} (mode 600):\n        \
         [resend]\n        \
         key = \"re_...\"\n  \
         - or set RESEND_API_KEY in the environment the Ticket run starts from\n    \
         (a crontab line, CI secret, or a shell profile the Ticket run's shell reads)",
        path.display()
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resend_api_key_unset_or_empty_holds_no_key() {
        for value in [None, Some(String::new())] {
            assert!(environment_key(value.clone()).is_none(), "{value:?}");
        }
        let key = environment_key(Some("re_test_123".into())).unwrap();
        assert_eq!(key.secret, "re_test_123");
        assert_eq!(key.source, Source::Environment);
    }

    #[test]
    fn the_credentials_key_is_read_and_an_empty_one_is_none() {
        let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));

        assert_eq!(
            parse("[resend]\nkey = \"re_file_456\"\n", &file)
                .unwrap()
                .key,
            Some("re_file_456".to_string())
        );
        for text in ["", "[resend]\n", "[resend]\nkey = \"\"\n"] {
            assert_eq!(parse(text, &file).unwrap().key, None, "{text:?}");
        }
    }

    #[test]
    fn a_saved_key_is_replaced_in_place_keeping_the_rest_of_the_credentials() {
        let saved = "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_saved_456\"   # from the dashboard\n";

        assert_eq!(
            with_secret(saved, "resend", "key", "re_secret_123").unwrap(),
            "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_secret_123\"   # from the dashboard\n"
        );
    }

    #[test]
    fn a_key_is_added_after_credentials_that_hold_none() {
        let text = with_secret("# Keys go here.\n", "resend", "key", "re_secret_123").unwrap();

        assert!(text.starts_with("# Keys go here.\n"), "{text}");
        let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));
        assert_eq!(
            parse(&text, &file).unwrap().key.as_deref(),
            Some("re_secret_123")
        );
    }

    #[test]
    fn broken_credentials_are_an_error_naming_the_file_and_what_is_wrong() {
        let path = "/home/me/.thirdshift/credentials.toml";
        let file = Source::Credentials(PathBuf::from(path));
        for (text, named) in [
            ("[resend\n", "can't parse"),
            ("[resend]\nkye = \"re_file_456\"\n", "resend.kye"),
            ("[resend]\nkey = true\n", "resend.key"),
            ("resend = 1\n", "resend must be the section [resend]"),
            ("[opencode]\napi_key = true\n", "opencode.api_key"),
            ("[opencode]\nunknown = \"x\"\n", "opencode.unknown"),
            ("[email]\n", "unknown section [email]"),
            ("key = \"re_file_456\"\n", "unknown key key"),
        ] {
            let error = format!("{:#}", parse(text, &file).err().unwrap());
            for part in [named, path] {
                assert!(error.contains(part), "{text:?}: no {part:?} in {error}");
            }
        }
    }

    #[test]
    fn trial_secrets_are_read_and_an_empty_one_is_none() {
        let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));

        let found = parse(
            "[claude]\nsetup_token = \"sk-ant-oat01-secret\"\n[opencode]\napi_key = \"opencode-provider-key\"\n",
            &file,
        )
        .unwrap();
        assert_eq!(
            found.claude_setup_token,
            Some("sk-ant-oat01-secret".to_string())
        );
        assert_eq!(
            found.opencode_api_key,
            Some("opencode-provider-key".to_string())
        );
        for text in [
            "",
            "[opencode]\n",
            "[opencode]\napi_key = \"\"\n",
            "[claude]\nsetup_token = \"\"\n",
        ] {
            let found = parse(text, &file).unwrap();
            assert_eq!(found.opencode_api_key, None, "{text:?}");
            assert_eq!(found.claude_setup_token, None, "{text:?}");
        }
    }

    #[test]
    fn an_opencode_key_is_saved_alongside_the_other_secrets() {
        let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));
        let saved = with_secret(
            "[resend]\nkey = \"re_saved_456\"\n",
            "opencode",
            "api_key",
            "opencode-provider-key",
        )
        .unwrap();

        assert_eq!(
            parse(&saved, &file).unwrap().opencode_api_key.as_deref(),
            Some("opencode-provider-key")
        );
        assert_eq!(
            parse(&saved, &file).unwrap().key.as_deref(),
            Some("re_saved_456")
        );
    }
}