pipedash-plugin-gitea 0.9.0

Gitea Actions plugin for Pipedash
Documentation
use pipedash_plugin_actions::ActionsLogDialect;
use pipedash_plugin_api::{
    LineMarker,
    LogDialect,
    ParsedLine,
};

pub struct GiteaLogDialect;

impl LogDialect for GiteaLogDialect {
    fn parse<'a>(&self, line: &'a [u8]) -> ParsedLine<'a> {
        let parsed = ActionsLogDialect.parse(line);
        if parsed.marker != LineMarker::Plain {
            return parsed;
        }
        match workflow_command(parsed.body) {
            Some((marker, body)) => ParsedLine {
                marker,
                body,
                ..parsed
            },
            None => parsed,
        }
    }
}

fn workflow_command(body: &[u8]) -> Option<(LineMarker, &[u8])> {
    let rest = body.strip_prefix(b"::")?;
    let end = rest.windows(2).position(|pair| pair == b"::")?;
    let name = rest[..end].split(|b| *b == b' ').next()?;
    let marker = match name {
        b"group" => LineMarker::GroupStart,
        b"endgroup" => LineMarker::GroupEnd,
        b"error" => LineMarker::Error,
        b"warning" => LineMarker::Warning,
        b"notice" => LineMarker::Notice,
        b"debug" => LineMarker::Debug,
        _ => return None,
    };
    Some((marker, &rest[end + 2..]))
}

#[cfg(test)]
mod tests {
    use chrono::{
        DateTime,
        Utc,
    };

    use super::*;

    fn parse(line: &[u8]) -> ParsedLine<'_> {
        GiteaLogDialect.parse(line)
    }

    #[test]
    fn recorded_gitea_line_splits_into_timestamp_and_text() {
        let parsed = parse(b"2026-10-28T10:15:30.1234567Z + go test ./...");
        assert_eq!(parsed.marker, LineMarker::Plain);
        assert_eq!(parsed.body, b"+ go test ./...");
        assert_eq!(
            parsed.timestamp,
            Some(
                "2026-10-28T10:15:30.1234567Z"
                    .parse::<DateTime<Utc>>()
                    .unwrap()
            )
        );
    }

    #[test]
    fn group_and_endgroup_commands_open_and_close_a_section() {
        let group = parse(b"2026-10-28T10:15:31.0000000Z ::group::Install dependencies");
        assert_eq!(group.marker, LineMarker::GroupStart);
        assert_eq!(group.body, b"Install dependencies");
        assert!(group.timestamp.is_some());

        let end = parse(b"2026-10-28T10:15:32.0000000Z ::endgroup::");
        assert_eq!(end.marker, LineMarker::GroupEnd);
        assert_eq!(end.body, b"");
    }

    #[test]
    fn annotation_commands_become_severity_markers() {
        let cases: [(&[u8], LineMarker, &[u8]); 4] = [
            (b"::error::Build failed", LineMarker::Error, b"Build failed"),
            (
                b"::warning::Cache missed",
                LineMarker::Warning,
                b"Cache missed",
            ),
            (b"::notice::Deployed", LineMarker::Notice, b"Deployed"),
            (
                b"::debug::cwd=/workspace",
                LineMarker::Debug,
                b"cwd=/workspace",
            ),
        ];
        for (text, marker, body) in cases {
            let line = [b"2026-10-28T10:15:33.0000000Z ".as_slice(), text].concat();
            let parsed = parse(&line);
            assert_eq!(parsed.marker, marker, "{}", String::from_utf8_lossy(text));
            assert!(parsed.timestamp.is_some());
            assert_eq!(parsed.body, body);
        }
    }

    #[test]
    fn annotation_parameters_are_dropped_from_the_body() {
        let parsed = parse(
            b"2026-10-28T10:15:34.0000000Z ::error file=app.js,line=3,col=7::Missing semicolon",
        );
        assert_eq!(parsed.marker, LineMarker::Error);
        assert_eq!(parsed.body, b"Missing semicolon");
    }

    #[test]
    fn github_style_markers_still_work() {
        let parsed = parse(b"2026-10-28T10:15:35.0000000Z ##[group]Run make");
        assert_eq!(parsed.marker, LineMarker::GroupStart);
        assert_eq!(parsed.body, b"Run make");

        let parsed = parse(b"2026-10-28T10:15:36.0000000Z ##[error]exit code 2");
        assert_eq!(parsed.marker, LineMarker::Error);
        assert_eq!(parsed.body, b"exit code 2");
    }

    #[test]
    fn other_workflow_commands_and_unterminated_commands_stay_plain() {
        for text in [
            &b"::set-output name=sha::abc123"[..],
            b"::add-mask::***",
            b"::group no closing colons",
            b"::groupx::name",
            b"echo ::group::inside a sentence",
        ] {
            let line = [b"2026-10-28T10:15:37.0000000Z ".as_slice(), text].concat();
            let parsed = parse(&line);
            assert_eq!(
                parsed.marker,
                LineMarker::Plain,
                "{}",
                String::from_utf8_lossy(text)
            );
            assert_eq!(parsed.body, text);
        }
    }

    #[test]
    fn line_without_timestamp_still_reads_its_command() {
        let parsed = parse(b"::group::Set up job");
        assert_eq!(parsed.timestamp, None);
        assert_eq!(parsed.marker, LineMarker::GroupStart);
        assert_eq!(parsed.body, b"Set up job");
    }

    #[test]
    fn no_line_starts_a_step() {
        for text in [
            &b"::group::Run actions/checkout@v4"[..],
            b"Post job cleanup.",
        ] {
            let parsed = parse(text);
            assert!(!GiteaLogDialect.is_step_start(&parsed));
        }
    }
}