github_actions_maintainer/
model.rs1use std::path::PathBuf;
2
3#[derive(Debug, Clone, Eq, PartialEq)]
4pub struct WorkflowAction {
5 pub file: PathBuf,
6 pub line_number: usize,
7 pub indentation: String,
8 pub list_prefix: String,
9 pub action_slug: String,
10 pub owner: String,
11 pub repository: String,
12 pub version: String,
13 pub inline_comment: Option<String>,
14 pub original_line: String,
15}
16
17impl WorkflowAction {
18 #[must_use]
19 pub fn repository_slug(&self) -> String {
20 format!("{}/{}", self.owner, self.repository)
21 }
22
23 #[must_use]
24 pub fn is_pinned(&self) -> bool {
25 is_full_length_sha(&self.version)
26 }
27
28 #[must_use]
29 pub fn logical_version(&self) -> String {
30 if self.is_pinned() {
31 self.version_hint().unwrap_or_else(|| self.version.clone())
32 } else {
33 self.version.clone()
34 }
35 }
36
37 #[must_use]
38 pub fn rendered_line(&self, commit_sha: &str, version_label: &str) -> String {
39 let mut comment = String::from(version_label);
40
41 if let Some(existing_comment) = self.extra_comment()
42 && !existing_comment.is_empty()
43 && existing_comment != version_label
44 {
45 comment.push_str(" | ");
46 comment.push_str(&existing_comment);
47 }
48
49 format!(
50 "{}{}uses: {}@{} # {}",
51 self.indentation, self.list_prefix, self.action_slug, commit_sha, comment
52 )
53 }
54
55 fn version_hint(&self) -> Option<String> {
56 if !self.is_pinned() {
57 return None;
58 }
59
60 let comment = self.inline_comment.as_deref()?.trim();
61 let (candidate, _) = comment.split_once('|').unwrap_or((comment, ""));
62 let candidate = candidate.trim();
63
64 if looks_like_version_hint(candidate) { Some(candidate.to_owned()) } else { None }
65 }
66
67 fn extra_comment(&self) -> Option<String> {
68 let comment = self.inline_comment.as_deref()?.trim();
69 if comment.is_empty() {
70 return None;
71 }
72
73 if self.is_pinned() {
74 let (candidate, remainder) = comment.split_once('|').unwrap_or((comment, ""));
75 if looks_like_version_hint(candidate.trim()) {
76 let remainder = remainder.trim();
77 if remainder.is_empty() { None } else { Some(remainder.to_owned()) }
78 } else {
79 Some(comment.to_owned())
80 }
81 } else {
82 Some(comment.to_owned())
83 }
84 }
85}
86
87#[derive(Debug, Clone, Eq, PartialEq)]
88pub struct PinChange {
89 pub file: PathBuf,
90 pub line_number: usize,
91 pub action_slug: String,
92 pub from_version: String,
93 pub to_sha: String,
94 pub original_line: String,
95 pub rewritten_line: String,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
99pub struct PinReport {
100 pub workflow_files: usize,
101 pub references_scanned: usize,
102 pub already_pinned: usize,
103 pub changes: Vec<PinChange>,
104}
105
106impl PinReport {
107 #[must_use]
108 pub fn changed_files(&self) -> usize {
109 let mut files = self.changes.iter().map(|change| change.file.as_path()).collect::<Vec<_>>();
110 files.sort();
111 files.dedup();
112 files.len()
113 }
114}
115
116#[derive(Debug, Clone, Copy, Eq, PartialEq)]
117pub enum UpdateChangeKind {
118 GitHubAction,
119 CargoDependency,
120}
121
122impl UpdateChangeKind {
123 #[must_use]
124 pub const fn label(self) -> &'static str {
125 match self {
126 Self::GitHubAction => "GitHub Action",
127 Self::CargoDependency => "cargo package",
128 }
129 }
130}
131
132#[derive(Debug, Clone, Eq, PartialEq)]
133pub struct UpdateChange {
134 pub kind: UpdateChangeKind,
135 pub file: PathBuf,
136 pub line_number: Option<usize>,
137 pub subject: String,
138 pub from_version: String,
139 pub to_version: String,
140}
141
142#[derive(Debug, Clone, Eq, PartialEq)]
143pub struct FileUpdate {
144 pub file: PathBuf,
145 pub updated_content: String,
146}
147
148#[must_use]
149pub fn is_full_length_sha(value: &str) -> bool {
150 value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
151}
152
153fn looks_like_version_hint(value: &str) -> bool {
154 let trimmed = value.trim();
155 !trimmed.is_empty()
156 && !trimmed.contains(char::is_whitespace)
157 && (trimmed.starts_with('v')
158 || trimmed.chars().next().is_some_and(|character| character.is_ascii_digit())
159 || matches!(trimmed, "main" | "master" | "stable" | "beta" | "nightly"))
160}
161
162#[derive(Debug, Clone, Eq, PartialEq)]
163pub struct ScriptUsage {
164 pub file: PathBuf,
165 pub line_number: usize,
166 pub script_type: ScriptType,
167 pub command: String,
168 pub context: String,
169}
170
171#[derive(Debug, Clone, Copy, Eq, PartialEq)]
172pub enum ScriptType {
173 Bash,
174 Python,
175}
176
177impl ScriptType {
178 #[must_use]
179 pub const fn label(self) -> &'static str {
180 match self {
181 Self::Bash => "bash",
182 Self::Python => "python",
183 }
184 }
185}
186
187impl ScriptUsage {
188 #[must_use]
189 pub const fn script_type_label(&self) -> &'static str {
190 self.script_type.label()
191 }
192}
193
194#[derive(Debug, Clone, Eq, PartialEq)]
195pub struct PolicyViolation {
196 pub file: PathBuf,
197 pub line_number: usize,
198 pub violation_type: PolicyViolationType,
199 pub severity: PolicySeverity,
200 pub description: String,
201 pub context: String,
202}
203
204#[derive(Debug, Clone, Copy, Eq, PartialEq)]
205pub enum PolicyViolationType {
206 UnpinnedAction,
207 MissingPermissions,
208 ExcessivePermissions,
209 MissingTimeoutMinutes,
210}
211
212impl PolicyViolationType {
213 #[must_use]
214 pub const fn label(self) -> &'static str {
215 match self {
216 Self::UnpinnedAction => "unpinned-action",
217 Self::MissingPermissions => "missing-permissions",
218 Self::ExcessivePermissions => "excessive-permissions",
219 Self::MissingTimeoutMinutes => "missing-timeout-minutes",
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, Eq, PartialEq)]
225pub enum PolicySeverity {
226 High,
227 Medium,
228 Low,
229}
230
231impl PolicySeverity {
232 #[must_use]
233 pub const fn label(self) -> &'static str {
234 match self {
235 Self::High => "HIGH",
236 Self::Medium => "MEDIUM",
237 Self::Low => "LOW",
238 }
239 }
240}
241
242impl PolicyViolation {
243 #[must_use]
244 pub const fn severity_label(&self) -> &'static str {
245 self.severity.label()
246 }
247
248 #[must_use]
249 pub const fn violation_type_label(&self) -> &'static str {
250 self.violation_type.label()
251 }
252}