1#![forbid(clippy::indexing_slicing)]
2
3use std::env;
91use std::fmt::Write as _;
92use std::path::Path;
93use std::path::PathBuf;
94use std::sync::OnceLock;
95
96use monochange_core::CommitMessage;
97use monochange_core::DiscoveryPathFilter;
98use monochange_core::HostedActorRef;
99use monochange_core::HostedActorSourceKind;
100use monochange_core::HostedIssueCommentOperation;
101use monochange_core::HostedIssueCommentOutcome;
102use monochange_core::HostedIssueCommentPlan;
103use monochange_core::HostedIssueRef;
104use monochange_core::HostedIssueRelationshipKind;
105use monochange_core::HostedReviewRequestKind;
106use monochange_core::HostedReviewRequestRef;
107use monochange_core::HostedSourceAdapter;
108use monochange_core::HostedSourceFeatures;
109use monochange_core::HostingCapabilities;
110use monochange_core::HostingProviderKind;
111use monochange_core::MonochangeError;
112use monochange_core::MonochangeResult;
113use monochange_core::PreparedChangeset;
114use monochange_core::ProviderReleaseNotesSource;
115use monochange_core::ReleaseManifest;
116use monochange_core::ReleaseManifestChangelog;
117use monochange_core::ReleaseManifestTarget;
118use monochange_core::ReleaseOwnerKind;
119use monochange_core::RetargetOperation;
120use monochange_core::RetargetProviderOperation;
121use monochange_core::RetargetProviderResult;
122use monochange_core::RetargetTagResult;
123use monochange_core::SourceCapabilities;
124use monochange_core::SourceChangeRequest;
125use monochange_core::SourceChangeRequestOperation;
126use monochange_core::SourceChangeRequestOutcome;
127use monochange_core::SourceConfiguration;
128use monochange_core::SourceProvider;
129use monochange_core::SourceReleaseOperation;
130use monochange_core::SourceReleaseOutcome;
131use monochange_core::SourceReleaseRequest;
132use monochange_core::git::git_checkout_branch_command;
133use monochange_core::git::git_command_output;
134use monochange_core::git::git_current_branch;
135use monochange_core::git::git_error_detail;
136use monochange_core::git::git_head_commit;
137use monochange_core::git::git_push_branch_command;
138use monochange_core::git::git_stage_all_command;
139use monochange_core::git::git_stage_paths_command;
140use monochange_core::git::run_command;
141use monochange_core::git::run_git_commit_message;
142use monochange_hosting::ensure_rustls_provider;
143use octocrab::Octocrab;
144use regex::Regex;
145use serde::Deserialize;
146use serde::Serialize;
147use serde::de::DeserializeOwned;
148use serde_json::json;
149use urlencoding::encode;
150
151pub type GitHubReleaseRequest = SourceReleaseRequest;
152pub type GitHubReleaseOperation = SourceReleaseOperation;
153pub type GitHubReleaseOutcome = SourceReleaseOutcome;
154pub type GitHubPullRequestRequest = SourceChangeRequest;
155pub type GitHubPullRequestOperation = SourceChangeRequestOperation;
156pub type GitHubPullRequestOutcome = SourceChangeRequestOutcome;
157
158type GitHubVerifiedCommitAttempt = Result<String, String>;
159
160#[must_use]
162pub const fn source_capabilities() -> SourceCapabilities {
163 SourceCapabilities {
164 draft_releases: true,
165 prereleases: true,
166 generated_release_notes: true,
167 auto_merge_change_requests: true,
168 released_issue_comments: true,
169 requires_host: false,
170 }
171}
172
173#[must_use = "the validation result must be checked"]
175pub fn validate_source_configuration(source: &SourceConfiguration) -> MonochangeResult<()> {
176 if source.releases.generate_notes
177 && matches!(
178 source.releases.source,
179 ProviderReleaseNotesSource::Monochange
180 ) {
181 return Err(MonochangeError::Config(
182 "[source.releases].generate_notes cannot be true when `source = \"monochange\"`; choose one release-note source"
183 .to_string(),
184 ));
185 }
186
187 Ok(())
188}
189
190pub type GitHubIssueCommentPlan = HostedIssueCommentPlan;
192pub type GitHubIssueCommentOperation = HostedIssueCommentOperation;
194pub type GitHubIssueCommentOutcome = HostedIssueCommentOutcome;
196
197pub static HOSTED_SOURCE_ADAPTER: GitHubHostedSourceAdapter = GitHubHostedSourceAdapter;
199
200pub struct GitHubHostedSourceAdapter;
202
203#[async_trait::async_trait]
204impl HostedSourceAdapter for GitHubHostedSourceAdapter {
205 fn provider(&self) -> SourceProvider {
206 SourceProvider::GitHub
207 }
208
209 fn tag_url(&self, source: &SourceConfiguration, tag_name: &str) -> String {
210 tag_url(source, tag_name)
211 }
212
213 fn compare_url(
214 &self,
215 source: &SourceConfiguration,
216 previous_tag: &str,
217 current_tag: &str,
218 ) -> String {
219 compare_url(source, previous_tag, current_tag)
220 }
221
222 fn build_release_requests(
223 &self,
224 source: &SourceConfiguration,
225 manifest: &ReleaseManifest,
226 ) -> Vec<SourceReleaseRequest> {
227 build_release_requests(source, manifest)
228 }
229
230 fn build_release_pull_request_request(
231 &self,
232 source: &SourceConfiguration,
233 manifest: &ReleaseManifest,
234 ) -> SourceChangeRequest {
235 build_release_pull_request_request(source, manifest)
236 }
237
238 fn features(&self) -> HostedSourceFeatures {
239 HostedSourceFeatures {
240 batched_changeset_context_lookup: true,
241 released_issue_comments: true,
242 release_retarget_sync: true,
243 }
244 }
245
246 fn annotate_changeset_context(
247 &self,
248 source: &SourceConfiguration,
249 changesets: &mut [PreparedChangeset],
250 ) {
251 annotate_changeset_context(source, changesets);
252 }
253
254 async fn enrich_changeset_context(
255 &self,
256 source: &SourceConfiguration,
257 changesets: &mut [PreparedChangeset],
258 ) {
259 enrich_changeset_context(source, changesets).await;
260 }
261
262 fn plan_released_issue_comments(
263 &self,
264 source: &SourceConfiguration,
265 manifest: &ReleaseManifest,
266 ) -> Vec<HostedIssueCommentPlan> {
267 plan_released_issue_comments(source, manifest)
268 }
269
270 async fn comment_released_issues(
271 &self,
272 source: &SourceConfiguration,
273 manifest: &ReleaseManifest,
274 ) -> MonochangeResult<Vec<HostedIssueCommentOutcome>> {
275 comment_released_issues(source, manifest).await
276 }
277
278 async fn sync_retargeted_releases(
279 &self,
280 source: &SourceConfiguration,
281 tag_results: &[RetargetTagResult],
282 dry_run: bool,
283 ) -> MonochangeResult<Vec<RetargetProviderResult>> {
284 sync_retargeted_releases(source, tag_results, dry_run).await
285 }
286}
287
288#[derive(Debug, Clone, Eq, PartialEq)]
289struct GitHubRelatedReviewRequest {
290 review_request: HostedReviewRequestRef,
291 issues: Vec<HostedIssueRef>,
292}
293
294#[derive(Debug, Serialize)]
295struct GitHubReleasePayload<'a> {
296 tag_name: &'a str,
297 name: &'a str,
298 body: Option<&'a str>,
299 draft: bool,
300 prerelease: bool,
301 generate_release_notes: bool,
302}
303
304#[derive(Debug, Serialize)]
305struct GitHubPullRequestPayload<'a> {
306 title: &'a str,
307 head: &'a str,
308 base: &'a str,
309 body: &'a str,
310 draft: bool,
311}
312
313#[derive(Debug, Serialize)]
314struct GitHubPullRequestUpdatePayload<'a> {
315 title: &'a str,
316 body: &'a str,
317 base: &'a str,
318}
319
320#[derive(Debug, Serialize)]
321struct GitHubLabelsPayload<'a> {
322 labels: &'a [String],
323}
324
325#[derive(Debug, Serialize)]
326struct GitHubCreateCommitPayload {
327 message: String,
328 tree: String,
329 parents: Vec<String>,
330}
331
332#[derive(Debug, Serialize)]
333struct GitHubUpdateRefPayload<'a> {
334 sha: &'a str,
335 force: bool,
336}
337
338#[derive(Debug, Deserialize)]
339struct GitHubGitCommitResponse {
340 sha: String,
341 message: String,
342 tree: GitHubGitCommitTree,
343 parents: Vec<GitHubGitCommitParent>,
344 verification: GitHubGitCommitVerification,
345}
346
347#[derive(Debug, Deserialize)]
348struct GitHubGitCommitTree {
349 sha: String,
350}
351
352#[derive(Debug, Deserialize)]
353struct GitHubGitCommitParent {
354 sha: String,
355}
356
357#[derive(Debug, Deserialize)]
358struct GitHubGitCommitVerification {
359 verified: bool,
360 reason: Option<String>,
361}
362
363#[derive(Debug, Deserialize)]
364struct GitHubGitRefResponse {
365 object: GitHubGitRefObject,
366}
367
368#[derive(Debug, Deserialize)]
369struct GitHubGitRefObject {
370 sha: String,
371}
372
373#[derive(Debug, Serialize)]
374struct GitHubCreateBlobPayload {
375 content: String,
376 encoding: &'static str,
377}
378
379#[derive(Debug, Deserialize)]
380struct GitHubCreateBlobResponse {
381 sha: String,
382}
383
384#[derive(Debug, Serialize)]
385struct GitHubCreateTreePayload<'a> {
386 #[serde(skip_serializing_if = "Option::is_none")]
387 base_tree: Option<&'a str>,
388 tree: Vec<GitHubCreateTreeEntry>,
389}
390
391#[derive(Debug, Serialize)]
392struct GitHubCreateTreeEntry {
393 path: String,
394 mode: &'static str,
395 #[serde(rename = "type")]
396 entry_type: &'static str,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 sha: Option<String>,
399}
400
401#[derive(Debug, Deserialize)]
402struct GitHubCreateTreeResponse {
403 sha: String,
404}
405
406#[derive(Debug, Deserialize)]
407struct GitHubExistingPullRequestLabel {
408 name: String,
409}
410
411#[derive(Debug, Deserialize)]
412struct GitHubExistingPullRequestBase {
413 #[serde(rename = "ref")]
414 ref_name: String,
415}
416
417#[derive(Debug, Deserialize)]
418struct GitHubExistingPullRequestHead {
419 sha: Option<String>,
420}
421
422#[derive(Debug, Deserialize)]
423struct GitHubExistingPullRequest {
424 number: u64,
425 html_url: Option<String>,
426 node_id: String,
427 title: String,
428 body: Option<String>,
429 base: GitHubExistingPullRequestBase,
430 head: GitHubExistingPullRequestHead,
431 #[serde(default)]
432 labels: Vec<GitHubExistingPullRequestLabel>,
433}
434
435#[derive(Debug, Deserialize)]
436struct GitHubExistingRelease {
437 id: u64,
438 html_url: Option<String>,
439 target_commitish: Option<String>,
440}
441
442#[derive(Debug, Deserialize)]
443struct GitHubReleaseResponse {
444 html_url: Option<String>,
445}
446
447#[derive(Debug, Serialize)]
448struct GitHubReleaseRetargetPayload<'a> {
449 target_commitish: &'a str,
450}
451
452#[derive(Debug, Deserialize)]
453struct GitHubPullRequestResponse {
454 number: u64,
455 html_url: Option<String>,
456 node_id: String,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "camelCase")]
461struct GraphqlEnableAutoMergeResponse {
462 enable_pull_request_auto_merge: Option<GraphqlPullRequestMutation>,
463}
464
465#[derive(Debug, Deserialize)]
466#[serde(rename_all = "camelCase")]
467struct GraphqlPullRequestMutation {
468 pull_request: Option<GraphqlPullRequestNode>,
469}
470
471#[derive(Debug, Deserialize)]
472struct GraphqlPullRequestNode {
473 #[serde(rename = "number")]
474 _number: u64,
475}
476
477#[derive(Debug, Deserialize)]
478struct GitHubIssueCommentResponse {
479 html_url: Option<String>,
480 body: Option<String>,
481}
482
483#[must_use]
485pub fn github_hosting_capabilities() -> HostingCapabilities {
486 HostingCapabilities {
487 commit_web_urls: true,
488 actor_profiles: true,
489 review_request_lookup: true,
490 related_issues: true,
491 issue_comments: true,
492 }
493}
494
495#[must_use]
497pub fn github_web_base_url() -> String {
498 env::var("GITHUB_SERVER_URL").unwrap_or_else(|_| "https://github.com".to_string())
499}
500
501#[must_use]
503pub fn github_host() -> Option<String> {
504 let base_url = github_web_base_url();
505 let without_scheme = base_url
506 .trim_start_matches("https://")
507 .trim_start_matches("http://");
508 let host = without_scheme.split('/').next().unwrap_or_default().trim();
509 if host.is_empty() {
510 None
511 } else {
512 Some(host.to_string())
513 }
514}
515
516#[must_use]
518pub fn github_commit_url(source: &SourceConfiguration, sha: &str) -> String {
519 format!(
520 "{}/{}/{}/commit/{}",
521 github_web_base_url().trim_end_matches('/'),
522 source.owner,
523 source.repo,
524 sha
525 )
526}
527
528#[must_use]
530pub fn github_pull_request_url(source: &SourceConfiguration, number: u64) -> String {
531 format!(
532 "{}/{}/{}/pull/{}",
533 github_web_base_url().trim_end_matches('/'),
534 source.owner,
535 source.repo,
536 number
537 )
538}
539
540#[must_use]
542pub fn github_issue_url(source: &SourceConfiguration, number: u64) -> String {
543 format!(
544 "{}/{}/{}/issues/{}",
545 github_web_base_url().trim_end_matches('/'),
546 source.owner,
547 source.repo,
548 number
549 )
550}
551
552#[must_use]
554pub fn tag_url(source: &SourceConfiguration, tag_name: &str) -> String {
555 let base = github_web_base_url();
556 let base = source.host.as_deref().unwrap_or(base.trim_end_matches('/'));
557 format!(
558 "{}/{}/{}/releases/tag/{tag_name}",
559 base.trim_end_matches('/'),
560 source.owner,
561 source.repo
562 )
563}
564
565#[must_use]
567pub fn compare_url(source: &SourceConfiguration, previous_tag: &str, current_tag: &str) -> String {
568 let base = github_web_base_url();
569 let base = source.host.as_deref().unwrap_or(base.trim_end_matches('/'));
570 format!(
571 "{}/{}/{}/compare/{previous_tag}...{current_tag}",
572 base.trim_end_matches('/'),
573 source.owner,
574 source.repo
575 )
576}
577
578fn apply_github_changeset_annotations(
579 source: &SourceConfiguration,
580 changesets: &mut [PreparedChangeset],
581) {
582 let host = github_host();
583 let capabilities = github_hosting_capabilities();
584 for changeset in changesets.iter_mut() {
585 let Some(context) = changeset.context.as_mut() else {
586 continue;
587 };
588 context.provider = HostingProviderKind::GitHub;
589 context.host.clone_from(&host);
590 context.capabilities = capabilities.clone();
591 for revision in [&mut context.introduced, &mut context.last_updated] {
592 let Some(revision) = revision.as_mut() else {
593 continue;
594 };
595 if let Some(commit) = revision.commit.as_mut() {
596 commit.provider = HostingProviderKind::GitHub;
597 commit.host.clone_from(&host);
598 commit.url = Some(github_commit_url(source, &commit.sha));
599 }
600 if let Some(actor) = revision.actor.as_mut() {
601 actor.provider = HostingProviderKind::GitHub;
602 actor.host.clone_from(&host);
603 }
604 }
605 }
606}
607
608pub fn annotate_changeset_context(
618 source: &SourceConfiguration,
619 changesets: &mut [PreparedChangeset],
620) {
621 apply_github_changeset_annotations(source, changesets);
622}
623
624#[tracing::instrument(skip_all)]
626pub async fn enrich_changeset_context(
627 source: &SourceConfiguration,
628 changesets: &mut [PreparedChangeset],
629) {
630 apply_github_changeset_annotations(source, changesets);
631
632 let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN")) else {
633 tracing::debug!("skipping GitHub enrichment: no GITHUB_TOKEN or GH_TOKEN found");
634 return;
635 };
636 let api_base_url = env::var("GITHUB_API_URL").ok();
637 let Ok(client) = build_github_client(&token, api_base_url.as_deref()) else {
638 return;
639 };
640 enrich_changeset_context_with_client(&client, source, changesets).await;
641}
642
643#[must_use]
645pub fn build_release_requests(
646 source: &SourceConfiguration,
647 manifest: &ReleaseManifest,
648) -> Vec<GitHubReleaseRequest> {
649 manifest
650 .release_targets
651 .iter()
652 .filter(|target| target.release)
653 .map(|target| {
654 GitHubReleaseRequest {
655 provider: SourceProvider::GitHub,
656 repository: format!("{}/{}", source.owner, source.repo),
657 owner: source.owner.clone(),
658 repo: source.repo.clone(),
659 target_id: target.id.clone(),
660 target_kind: target.kind,
661 tag_name: target.tag_name.clone(),
662 name: if target.rendered_title.is_empty() {
663 target.tag_name.clone()
664 } else {
665 target.rendered_title.clone()
666 },
667 body: release_body(source, manifest, target),
668 draft: source.releases.draft,
669 prerelease: source.releases.prerelease,
670 generate_release_notes: source.releases.generate_notes,
671 }
672 })
673 .collect()
674}
675
676#[must_use]
678pub fn build_release_pull_request_request(
679 source: &SourceConfiguration,
680 manifest: &ReleaseManifest,
681) -> GitHubPullRequestRequest {
682 let repository = format!("{}/{}", source.owner, source.repo);
683 let title = source.pull_requests.title.clone();
684 GitHubPullRequestRequest {
685 provider: SourceProvider::GitHub,
686 repository: repository.clone(),
687 owner: source.owner.clone(),
688 repo: source.repo.clone(),
689 base_branch: source.pull_requests.base.clone(),
690 head_branch: release_pull_request_branch(
691 &source.pull_requests.branch_prefix,
692 &manifest.command,
693 ),
694 title: title.clone(),
695 body: release_pull_request_body(manifest),
696 labels: source.pull_requests.labels.clone(),
697 auto_merge: source.pull_requests.auto_merge,
698 commit_message: CommitMessage {
699 subject: title,
700 body: None,
701 },
702 }
703}
704
705async fn enrich_changeset_context_with_client(
706 client: &Octocrab,
707 source: &SourceConfiguration,
708 changesets: &mut [PreparedChangeset],
709) {
710 let host = github_host();
711 let capabilities = github_hosting_capabilities();
712 for changeset in changesets.iter_mut() {
713 let Some(context) = changeset.context.as_mut() else {
714 continue;
715 };
716 context.provider = HostingProviderKind::GitHub;
717 context.host.clone_from(&host);
718 context.capabilities = capabilities.clone();
719 for revision in [&mut context.introduced, &mut context.last_updated] {
720 let Some(revision) = revision.as_mut() else {
721 continue;
722 };
723 if let Some(commit) = revision.commit.as_mut() {
724 commit.provider = HostingProviderKind::GitHub;
725 commit.host.clone_from(&host);
726 commit.url = Some(github_commit_url(source, &commit.sha));
727 }
728 if let Some(actor) = revision.actor.as_mut() {
729 actor.provider = HostingProviderKind::GitHub;
730 actor.host.clone_from(&host);
731 }
732 }
733 }
734 let review_request_lookup_shas = collect_review_request_lookup_shas(changesets);
735 let review_requests_by_sha =
736 load_review_requests_for_commits_with_client(client, source, &review_request_lookup_shas)
737 .await
738 .unwrap_or_else(|error| {
739 tracing::warn!(commits = review_request_lookup_shas.len(), %error, "failed to batch load GitHub review requests; continuing with commit annotations only");
740 BTreeMap::new()
741 });
742
743 for changeset in changesets.iter_mut() {
744 let Some(context) = changeset.context.as_mut() else {
745 continue;
746 };
747
748 let mut issues_by_id = BTreeMap::<String, HostedIssueRef>::new();
749
750 for revision in [&mut context.introduced, &mut context.last_updated] {
751 let Some(revision) = revision.as_mut() else {
752 continue;
753 };
754
755 let Some(commit) = revision.commit.as_ref() else {
756 continue;
757 };
758
759 if let Some(related_review_request) = review_requests_by_sha
760 .get(&commit.sha)
761 .and_then(Clone::clone)
762 {
763 for issue in related_review_request.issues {
764 issues_by_id.entry(issue.id.clone()).or_insert(issue);
765 }
766 revision.review_request = Some(related_review_request.review_request.clone());
767 if let Some(author) = related_review_request.review_request.author.clone() {
768 revision.actor = Some(author);
769 }
770 }
771
772 if let Some(actor) = revision.actor.as_mut() {
773 actor.provider = HostingProviderKind::GitHub;
774 actor.host.clone_from(&host);
775 }
776 }
777
778 context.related_issues = issues_by_id.into_values().collect();
779 }
780}
781
782fn collect_review_request_lookup_shas(changesets: &[PreparedChangeset]) -> Vec<String> {
783 let mut shas = changesets
784 .iter()
785 .filter_map(|changeset| changeset.context.as_ref())
786 .flat_map(|context| [&context.introduced, &context.last_updated])
787 .filter_map(|revision| revision.as_ref())
788 .filter_map(|revision| revision.commit.as_ref())
789 .map(|commit| commit.sha.clone())
790 .collect::<Vec<_>>();
791
792 shas.sort();
793 shas.dedup();
794
795 shas
796}
797
798async fn load_review_requests_for_commits_with_client(
800 client: &Octocrab,
801 source: &SourceConfiguration,
802 shas: &[String],
803) -> MonochangeResult<BTreeMap<String, Option<GitHubRelatedReviewRequest>>> {
804 if shas.is_empty() {
805 return Ok(BTreeMap::new());
806 }
807
808 tracing::info!(
809 commits = shas.len(),
810 requests = 1,
811 "loading GitHub review requests"
812 );
813
814 let review_requests_by_sha =
815 load_review_request_batch_with_client(client, source, shas).await?;
816
817 let review_requests_found = review_requests_by_sha
818 .values()
819 .filter(|review_request| review_request.is_some())
820 .count();
821
822 tracing::debug!(
823 commits = shas.len(),
824 review_requests = review_requests_found,
825 "resolved GitHub review requests"
826 );
827
828 Ok(review_requests_by_sha)
829}
830async fn load_review_request_batch_with_client(
833 client: &Octocrab,
834 source: &SourceConfiguration,
835 shas: &[String],
836) -> MonochangeResult<BTreeMap<String, Option<GitHubRelatedReviewRequest>>> {
837 let query = build_review_request_batch_query(&source.owner, &source.repo, shas);
838
839 let response = client
840 .graphql::<serde_json::Value>(&json!({ "query": query }))
841 .await
842 .map_err(|error| {
843 MonochangeError::Config(format!(
844 "failed to batch load GitHub review requests for {} commit(s): {error}",
845 shas.len()
846 ))
847 })?;
848
849 let repository = response
850 .get("repository")
851 .or_else(|| response.get("data").and_then(|data| data.get("repository")))
852 .and_then(serde_json::Value::as_object)
853 .ok_or_else(|| {
854 MonochangeError::Config(
855 "GitHub review-request lookup returned no repository payload".to_string(),
856 )
857 })?;
858
859 let mut review_requests_by_sha = BTreeMap::<String, Option<GitHubRelatedReviewRequest>>::new();
860
861 for (index, sha) in shas.iter().enumerate() {
862 let alias = format!("commit_{index}");
863 let review_request = repository
864 .get(&alias)
865 .and_then(|commit| {
866 commit
867 .get("associatedPullRequests")
868 .and_then(|pull_requests| pull_requests.get("nodes"))
869 .and_then(serde_json::Value::as_array)
870 .and_then(|pull_requests| pull_requests.first())
871 })
872 .and_then(|pull_request| parse_review_request_from_graphql(source, pull_request));
873 review_requests_by_sha.insert(sha.clone(), review_request);
874 }
875
876 Ok(review_requests_by_sha)
877}
878
879fn build_review_request_batch_query(owner: &str, repo: &str, shas: &[String]) -> String {
880 let mut query = format!("query {{ repository(owner: \"{owner}\", name: \"{repo}\") {{");
881 for (index, sha) in shas.iter().enumerate() {
882 let alias = format!("commit_{index}");
883 let _ = write!(
884 query,
885 " {alias}: object(expression: \"{sha}\") {{ ... on Commit {{ associatedPullRequests(first: 1) {{ nodes {{ number title url body author {{ login url }} }} }} }} }}"
886 );
887 }
888 query.push_str(" } }");
889 query
890}
891
892fn parse_review_request_from_graphql(
893 source: &SourceConfiguration,
894 pull_request: &serde_json::Value,
895) -> Option<GitHubRelatedReviewRequest> {
896 let number = pull_request.get("number")?.as_u64()?;
897 let title = pull_request
898 .get("title")
899 .and_then(serde_json::Value::as_str)?
900 .to_string();
901 let body = pull_request
902 .get("body")
903 .and_then(serde_json::Value::as_str)
904 .map(str::to_string);
905 let author = pull_request
906 .get("author")
907 .and_then(serde_json::Value::as_object)
908 .map(|author| {
909 HostedActorRef {
910 provider: HostingProviderKind::GitHub,
911 host: github_host(),
912 id: None,
913 login: author
914 .get("login")
915 .and_then(serde_json::Value::as_str)
916 .map(str::to_string),
917 display_name: author
918 .get("login")
919 .and_then(serde_json::Value::as_str)
920 .map(str::to_string),
921 url: author
922 .get("url")
923 .and_then(serde_json::Value::as_str)
924 .map(str::to_string),
925 source: HostedActorSourceKind::ReviewRequestAuthor,
926 }
927 });
928 let review_request = HostedReviewRequestRef {
929 provider: HostingProviderKind::GitHub,
930 host: github_host(),
931 kind: HostedReviewRequestKind::PullRequest,
932 id: format!("#{number}"),
933 title: Some(title),
934 url: pull_request
935 .get("url")
936 .and_then(serde_json::Value::as_str)
937 .map(str::to_string)
938 .or_else(|| Some(github_pull_request_url(source, number))),
939 author,
940 };
941 let mut issues_by_id = BTreeMap::<String, HostedIssueRef>::new();
942 for issue_number in body
943 .as_deref()
944 .map(extract_closing_issue_numbers)
945 .unwrap_or_default()
946 {
947 issues_by_id.insert(
948 format!("#{issue_number}"),
949 HostedIssueRef {
950 provider: HostingProviderKind::GitHub,
951 host: github_host(),
952 id: format!("#{issue_number}"),
953 title: None,
954 url: Some(github_issue_url(source, issue_number)),
955 relationship: HostedIssueRelationshipKind::ClosedByReviewRequest,
956 },
957 );
958 }
959 for issue_number in body
960 .as_deref()
961 .map(extract_issue_numbers)
962 .unwrap_or_default()
963 {
964 issues_by_id
965 .entry(format!("#{issue_number}"))
966 .or_insert_with(|| {
967 HostedIssueRef {
968 provider: HostingProviderKind::GitHub,
969 host: github_host(),
970 id: format!("#{issue_number}"),
971 title: None,
972 url: Some(github_issue_url(source, issue_number)),
973 relationship: HostedIssueRelationshipKind::ReferencedByReviewRequest,
974 }
975 });
976 }
977 Some(GitHubRelatedReviewRequest {
978 review_request,
979 issues: issues_by_id.into_values().collect(),
980 })
981}
982
983fn issue_reference_regex() -> &'static Regex {
984 static ISSUE_REFERENCE_RE: OnceLock<Regex> = OnceLock::new();
985 ISSUE_REFERENCE_RE.get_or_init(|| {
986 Regex::new(r"(?:[\w.-]+/[\w.-]+)?#(?P<number>\d+)")
987 .unwrap_or_else(|error| panic!("issue reference regex should compile: {error}"))
988 })
989}
990
991fn closing_issue_reference_regex() -> &'static Regex {
992 static CLOSING_ISSUE_REFERENCE_RE: OnceLock<Regex> = OnceLock::new();
993 CLOSING_ISSUE_REFERENCE_RE.get_or_init(|| {
994 Regex::new(r"(?i)\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b[:\s]*(?P<refs>(?:[\w.-]+/[\w.-]+)?#\d+(?:\s*(?:,|and)\s*(?:[\w.-]+/[\w.-]+)?#\d+)*)")
995 .unwrap_or_else(|error| panic!("closing issue regex should compile: {error}"))
996 })
997}
998
999fn extract_closing_issue_numbers(text: &str) -> std::collections::BTreeSet<u64> {
1000 let mut issue_numbers = std::collections::BTreeSet::new();
1001 for captures in closing_issue_reference_regex().captures_iter(text) {
1002 let Some(references) = captures.name("refs") else {
1003 continue;
1004 };
1005 issue_numbers.extend(extract_issue_numbers(references.as_str()));
1006 }
1007 issue_numbers
1008}
1009
1010fn extract_issue_numbers(text: &str) -> std::collections::BTreeSet<u64> {
1011 issue_reference_regex()
1012 .captures_iter(text)
1013 .filter_map(|captures| captures.name("number"))
1014 .filter_map(|number| number.as_str().parse::<u64>().ok())
1015 .collect()
1016}
1017
1018#[must_use]
1020pub fn plan_released_issue_comments(
1021 source: &SourceConfiguration,
1022 manifest: &ReleaseManifest,
1023) -> Vec<GitHubIssueCommentPlan> {
1024 let release_tags = manifest
1025 .release_targets
1026 .iter()
1027 .filter(|target| target.release)
1028 .map(|target| target.tag_name.clone())
1029 .collect::<Vec<_>>();
1030 if release_tags.is_empty() {
1031 return Vec::new();
1032 }
1033 let marker = release_comment_marker(&release_tags);
1034 let body = release_issue_comment_body(&release_tags, &marker);
1035 let mut plans_by_issue = BTreeMap::<String, GitHubIssueCommentPlan>::new();
1036 for issue in manifest
1037 .changesets
1038 .iter()
1039 .filter_map(|changeset| changeset.context.as_ref())
1040 .flat_map(|context| context.related_issues.iter())
1041 {
1042 plans_by_issue.entry(issue.id.clone()).or_insert_with(|| {
1043 GitHubIssueCommentPlan {
1044 repository: format!("{}/{}", source.owner, source.repo),
1045 issue_id: issue.id.clone(),
1046 issue_url: issue.url.clone(),
1047 body: body.clone(),
1048 close: issue.relationship != HostedIssueRelationshipKind::ClosedByReviewRequest,
1049 }
1050 });
1051 }
1052 plans_by_issue.into_values().collect()
1053}
1054
1055#[tracing::instrument(skip_all)]
1057#[must_use = "the comment result must be checked"]
1058#[allow(clippy::let_and_return, tail_expr_drop_order)]
1059pub async fn comment_released_issues(
1060 source: &SourceConfiguration,
1061 manifest: &ReleaseManifest,
1062) -> MonochangeResult<Vec<GitHubIssueCommentOutcome>> {
1063 let plans = plan_released_issue_comments(source, manifest);
1064 if plans.is_empty() {
1065 return Ok(Vec::new());
1066 }
1067 let client = github_client_from_env(source)?;
1068 comment_released_issues_with_client(&client, source, &plans).await
1069}
1070
1071async fn comment_released_issues_with_client(
1072 client: &Octocrab,
1073 source: &SourceConfiguration,
1074 plans: &[GitHubIssueCommentPlan],
1075) -> MonochangeResult<Vec<GitHubIssueCommentOutcome>> {
1076 let mut outcomes = Vec::with_capacity(plans.len());
1077 for plan in plans {
1078 let issue_number = plan
1079 .issue_id
1080 .trim_start_matches('#')
1081 .parse::<u64>()
1082 .map_err(|error| {
1083 MonochangeError::Config(format!(
1084 "invalid issue id `{}` for release comment: {error}",
1085 plan.issue_id
1086 ))
1087 })?;
1088 let path = format!(
1089 "/repos/{}/{}/issues/{}/comments",
1090 source.owner, source.repo, issue_number
1091 );
1092 let existing_comments = get_json::<Vec<GitHubIssueCommentResponse>>(client, &path).await?;
1093 if existing_comments.iter().any(|comment| {
1094 comment
1095 .body
1096 .as_deref()
1097 .is_some_and(|body| body.contains(&plan.body))
1098 }) {
1099 outcomes.push(GitHubIssueCommentOutcome {
1100 repository: plan.repository.clone(),
1101 issue_id: plan.issue_id.clone(),
1102 operation: GitHubIssueCommentOperation::SkippedExisting,
1103 url: plan.issue_url.clone(),
1104 });
1105 if plan.close {
1106 let issue_path = format!(
1107 "/repos/{}/{}/issues/{}",
1108 source.owner, source.repo, issue_number
1109 );
1110 let _: serde_json::Value =
1111 patch_json(client, &issue_path, &json!({ "state": "closed" })).await?;
1112 }
1113 continue;
1114 }
1115 let response = post_json::<_, GitHubIssueCommentResponse>(
1116 client,
1117 &path,
1118 &json!({ "body": plan.body }),
1119 )
1120 .await?;
1121 outcomes.push(GitHubIssueCommentOutcome {
1122 repository: plan.repository.clone(),
1123 issue_id: plan.issue_id.clone(),
1124 operation: if plan.close {
1125 GitHubIssueCommentOperation::Closed
1126 } else {
1127 GitHubIssueCommentOperation::Created
1128 },
1129 url: response.html_url.or_else(|| plan.issue_url.clone()),
1130 });
1131 }
1132 Ok(outcomes)
1133}
1134
1135fn release_comment_marker(release_tags: &[String]) -> String {
1136 format!("<!-- monochange:released-in:{} -->", release_tags.join("|"))
1137}
1138
1139fn release_issue_comment_body(release_tags: &[String], marker: &str) -> String {
1140 if let Some(release_tag) = release_tags.first().filter(|_| release_tags.len() == 1) {
1141 format!("Released in {release_tag}.\n\n{marker}")
1142 } else {
1143 format!("Released in {}.\n\n{marker}", release_tags.join(", "))
1144 }
1145}
1146
1147#[tracing::instrument(skip_all)]
1149#[must_use = "the publish result must be checked"]
1150#[allow(clippy::let_and_return)]
1151pub async fn publish_release_requests(
1152 source: &SourceConfiguration,
1153 requests: &[GitHubReleaseRequest],
1154) -> MonochangeResult<Vec<GitHubReleaseOutcome>> {
1155 let client = github_client_from_env(source)?;
1156 let result = publish_release_requests_with_client(&client, requests).await;
1157 result
1158}
1159
1160#[tracing::instrument(skip_all)]
1162#[must_use = "the pull request result must be checked"]
1163#[allow(clippy::let_and_return, tail_expr_drop_order)]
1164pub async fn publish_release_pull_request(
1165 source: &SourceConfiguration,
1166 root: &Path,
1167 request: &GitHubPullRequestRequest,
1168 tracked_paths: &[PathBuf],
1169 no_verify: bool,
1170 stage_all: bool,
1171) -> MonochangeResult<GitHubPullRequestOutcome> {
1172 let lookup_source = source.clone();
1173 let lookup_request = request.clone();
1174 let existing_pull_request =
1175 tokio::spawn(
1176 async move { lookup_existing_pull_request(&lookup_source, &lookup_request).await },
1177 );
1178 git_checkout_branch(root, &request.head_branch).await?;
1179 git_stage_paths(root, tracked_paths, stage_all).await?;
1180 git_commit_paths(root, &request.commit_message, no_verify).await?;
1181 let mut head_commit = git_head_commit(root).await?;
1182 let existing = join_existing_pull_request_lookup(existing_pull_request).await?;
1183 let head_matches_existing = existing
1184 .as_ref()
1185 .and_then(|pull_request| pull_request.head.sha.as_deref())
1186 == Some(head_commit.as_str());
1187 if !head_matches_existing {
1188 git_push_branch(root, &request.head_branch, no_verify).await?;
1189 head_commit = maybe_replace_release_pull_request_commit_with_verified_github_commit(
1193 source,
1194 request,
1195 &head_commit,
1196 root,
1197 tracked_paths,
1198 )
1199 .await
1200 .unwrap_or_else(|warning| {
1201 tracing::warn!(%warning, commit = %head_commit, "falling back to regular release pull request commit");
1202 head_commit.clone()
1203 });
1204 }
1205
1206 let client = github_client_from_env(source)?;
1207 publish_release_pull_request_with_existing_pull_request(
1208 &client,
1209 request,
1210 existing.as_ref(),
1211 &head_commit,
1212 )
1213 .await
1214}
1215
1216async fn maybe_replace_release_pull_request_commit_with_verified_github_commit(
1217 source: &SourceConfiguration,
1218 request: &GitHubPullRequestRequest,
1219 fallback_commit: &str,
1220 root: &Path,
1221 tracked_paths: &[PathBuf],
1222) -> GitHubVerifiedCommitAttempt {
1223 if !github_actions_release_commit_verification_enabled(source) {
1224 return Ok(fallback_commit.to_string());
1225 }
1226
1227 let commit_client = github_commit_client_from_env(source).map_err(|error| error.to_string())?;
1228 let verified_commit = create_verified_github_commit_for_release_pull_request(
1229 &commit_client,
1230 request,
1231 fallback_commit,
1232 root,
1233 tracked_paths,
1234 )
1235 .await?;
1236 update_github_branch_ref_to_verified_commit(
1237 &commit_client,
1238 request,
1239 fallback_commit,
1240 &verified_commit,
1241 )
1242 .await?;
1243 Ok(verified_commit)
1244}
1245
1246fn github_actions_release_commit_verification_enabled(source: &SourceConfiguration) -> bool {
1247 if !source.pull_requests.verified_commits {
1248 return false;
1249 }
1250 if env::var("GITHUB_ACTIONS").as_deref() != Ok("true") {
1251 return false;
1252 }
1253 let repository = format!("{}/{}", source.owner, source.repo);
1254 env::var("GITHUB_REPOSITORY").is_ok_and(|value| value.eq_ignore_ascii_case(&repository))
1255}
1256
1257async fn create_verified_github_commit_for_release_pull_request(
1258 client: &Octocrab,
1259 request: &GitHubPullRequestRequest,
1260 fallback_commit: &str,
1261 root: &Path,
1262 tracked_paths: &[PathBuf],
1263) -> GitHubVerifiedCommitAttempt {
1264 let commit_path = format!(
1265 "/repos/{}/{}/git/commits/{}",
1266 request.owner, request.repo, fallback_commit
1267 );
1268 let original: GitHubGitCommitResponse = get_json(client, &commit_path)
1269 .await
1270 .map_err(|error| error.to_string())?;
1271
1272 let base_tree = match original.parents.first() {
1274 Some(parent) => {
1275 let parent_path = format!(
1276 "/repos/{}/{}/git/commits/{}",
1277 request.owner, request.repo, parent.sha
1278 );
1279 let parent_commit: GitHubGitCommitResponse = get_json(client, &parent_path)
1280 .await
1281 .map_err(|error| error.to_string())?;
1282 Some(parent_commit.tree.sha)
1283 }
1284 None => None,
1285 };
1286
1287 let mut tree_entries = Vec::with_capacity(tracked_paths.len());
1288 for path in tracked_paths {
1289 let absolute_path = root.join(path);
1290 let relative_path = path.to_string_lossy().to_string();
1291
1292 if !absolute_path.exists() {
1293 tree_entries.push(GitHubCreateTreeEntry {
1295 path: relative_path,
1296 mode: "100644",
1297 entry_type: "blob",
1298 sha: None,
1299 });
1300 continue;
1301 }
1302
1303 let metadata = fs::symlink_metadata(&absolute_path)
1304 .map_err(|e| format!("failed to read metadata for {}: {}", path.display(), e))?;
1305
1306 if metadata.is_dir() {
1307 continue;
1309 }
1310
1311 let (content, mode) = if metadata.is_symlink() {
1312 let target = fs::read_link(&absolute_path)
1313 .map_err(|e| format!("failed to read symlink {}: {}", path.display(), e))?;
1314 let content = target.to_string_lossy().to_string();
1315 (content, "120000")
1316 } else {
1317 let content = fs::read_to_string(&absolute_path)
1318 .map_err(|e| format!("failed to read file {}: {}", path.display(), e))?;
1319 let mode = git_blob_mode(&metadata);
1320 (content, mode)
1321 };
1322
1323 let blob_payload = GitHubCreateBlobPayload {
1324 content,
1325 encoding: "utf-8",
1326 };
1327 let blob_path = format!("/repos/{}/{}/git/blobs", request.owner, request.repo);
1328 let blob: GitHubCreateBlobResponse = post_json(client, &blob_path, &blob_payload)
1329 .await
1330 .map_err(|error| error.to_string())?;
1331
1332 tree_entries.push(GitHubCreateTreeEntry {
1333 path: relative_path,
1334 mode,
1335 entry_type: "blob",
1336 sha: Some(blob.sha),
1337 });
1338 }
1339
1340 let tree_sha = if tree_entries.is_empty() {
1342 base_tree.ok_or_else(|| "no base tree available for empty commit".to_string())?
1344 } else {
1345 let tree_payload = GitHubCreateTreePayload {
1346 base_tree: base_tree.as_deref(),
1347 tree: tree_entries,
1348 };
1349 let tree_path = format!("/repos/{}/{}/git/trees", request.owner, request.repo);
1350 let tree: GitHubCreateTreeResponse = post_json(client, &tree_path, &tree_payload)
1351 .await
1352 .map_err(|error| error.to_string())?;
1353 tree.sha
1354 };
1355
1356 let commit_payload = GitHubCreateCommitPayload {
1357 message: original.message,
1358 tree: tree_sha,
1359 parents: original.parents.into_iter().map(|p| p.sha).collect(),
1360 };
1361 let create_path = format!("/repos/{}/{}/git/commits", request.owner, request.repo);
1362 let commit: GitHubGitCommitResponse = post_json(client, &create_path, &commit_payload)
1363 .await
1364 .map_err(|error| error.to_string())?;
1365
1366 if commit.verification.verified {
1367 tracing::info!(
1368 commit = %commit.sha,
1369 reason = ?commit.verification.reason,
1370 "created verified GitHub release pull request commit"
1371 );
1372 return Ok(commit.sha);
1373 }
1374
1375 Err(format!(
1376 "GitHub Git Database API created commit {} without verification ({})",
1377 commit.sha,
1378 commit
1379 .verification
1380 .reason
1381 .unwrap_or_else(|| "unknown reason".to_string())
1382 ))
1383}
1384
1385async fn update_github_branch_ref_to_verified_commit(
1386 client: &Octocrab,
1387 request: &GitHubPullRequestRequest,
1388 fallback_commit: &str,
1389 verified_commit: &str,
1390) -> GitHubVerifiedCommitAttempt {
1391 let get_ref_path = github_head_ref_get_path(request);
1392 let current: GitHubGitRefResponse = get_json(client, &get_ref_path)
1393 .await
1394 .map_err(|error| error.to_string())?;
1395 if current.object.sha != fallback_commit {
1396 return Err(format!(
1397 "release branch {} moved from {} to {}; refusing to replace it with verified commit {}",
1398 request.head_branch, fallback_commit, current.object.sha, verified_commit
1399 ));
1400 }
1401 let payload = GitHubUpdateRefPayload {
1402 sha: verified_commit,
1403 force: true,
1404 };
1405 let update_ref_path = github_head_ref_update_path(request);
1406 let updated: GitHubGitRefResponse = patch_json(client, &update_ref_path, &payload)
1407 .await
1408 .map_err(|error| error.to_string())?;
1409 if updated.object.sha != verified_commit {
1410 return Err(format!(
1411 "GitHub returned {} after updating {}, expected {}",
1412 updated.object.sha, request.head_branch, verified_commit
1413 ));
1414 }
1415 Ok(updated.object.sha)
1416}
1417
1418fn github_head_ref_get_path(request: &GitHubPullRequestRequest) -> String {
1419 format!(
1420 "/repos/{}/{}/git/ref/heads/{}",
1421 request.owner, request.repo, request.head_branch
1422 )
1423}
1424
1425fn github_head_ref_update_path(request: &GitHubPullRequestRequest) -> String {
1426 format!(
1427 "/repos/{}/{}/git/refs/heads/{}",
1428 request.owner, request.repo, request.head_branch
1429 )
1430}
1431
1432#[tracing::instrument(skip_all)]
1434#[must_use = "the sync result must be checked"]
1435#[allow(clippy::let_and_return, tail_expr_drop_order)]
1436pub async fn sync_retargeted_releases(
1437 source: &SourceConfiguration,
1438 tag_updates: &[RetargetTagResult],
1439 dry_run: bool,
1440) -> MonochangeResult<Vec<RetargetProviderResult>> {
1441 let client = github_client_from_env(source)?;
1442 sync_retargeted_releases_with_client(&client, source, tag_updates, dry_run).await
1443}
1444
1445async fn publish_release_requests_with_client(
1446 client: &Octocrab,
1447 requests: &[GitHubReleaseRequest],
1448) -> MonochangeResult<Vec<GitHubReleaseOutcome>> {
1449 let mut outcomes = Vec::with_capacity(requests.len());
1450 for request in requests {
1451 outcomes.push(publish_release_request_with_client(client, request).await?);
1452 }
1453 Ok(outcomes)
1454}
1455
1456async fn publish_release_request_with_client(
1457 client: &Octocrab,
1458 request: &GitHubReleaseRequest,
1459) -> MonochangeResult<GitHubReleaseOutcome> {
1460 tracing::info!(tag = %request.tag_name, repository = %request.repository, "publishing GitHub release");
1461 let payload = GitHubReleasePayload {
1462 tag_name: &request.tag_name,
1463 name: &request.name,
1464 body: request.body.as_deref(),
1465 draft: request.draft,
1466 prerelease: request.prerelease,
1467 generate_release_notes: request.generate_release_notes,
1468 };
1469 let existing = lookup_existing_release_with_client(client, request).await?;
1470 let (operation, response) = match existing {
1471 Some(existing) => {
1472 (
1473 GitHubReleaseOperation::Updated,
1474 patch_json::<_, GitHubReleaseResponse>(
1475 client,
1476 &format!(
1477 "/repos/{}/{}/releases/{}",
1478 request.owner, request.repo, existing.id
1479 ),
1480 &payload,
1481 )
1482 .await?,
1483 )
1484 }
1485 None => {
1486 (
1487 GitHubReleaseOperation::Created,
1488 post_json::<_, GitHubReleaseResponse>(
1489 client,
1490 &format!("/repos/{}/{}/releases", request.owner, request.repo),
1491 &payload,
1492 )
1493 .await?,
1494 )
1495 }
1496 };
1497 Ok(GitHubReleaseOutcome {
1498 provider: SourceProvider::GitHub,
1499 repository: request.repository.clone(),
1500 tag_name: request.tag_name.clone(),
1501 operation,
1502 url: response.html_url,
1503 })
1504}
1505
1506#[cfg_attr(not(test), allow(dead_code))]
1507async fn publish_release_pull_request_with_client(
1508 client: &Octocrab,
1509 request: &GitHubPullRequestRequest,
1510) -> MonochangeResult<GitHubPullRequestOutcome> {
1511 let existing = lookup_existing_pull_request_with_client(client, request).await?;
1512 publish_release_pull_request_with_existing_pull_request(client, request, existing.as_ref(), "")
1513 .await
1514}
1515
1516async fn publish_release_pull_request_with_existing_pull_request(
1517 client: &Octocrab,
1518 request: &GitHubPullRequestRequest,
1519 existing: Option<&GitHubExistingPullRequest>,
1520 head_commit: &str,
1521) -> MonochangeResult<GitHubPullRequestOutcome> {
1522 let labels_match = existing.is_some_and(|pull_request| {
1523 request.labels.iter().all(|label| {
1524 pull_request
1525 .labels
1526 .iter()
1527 .any(|existing_label| existing_label.name == *label)
1528 })
1529 });
1530 let content_matches = existing.is_some_and(|pull_request| {
1531 pull_request.title == request.title
1532 && pull_request.body.as_deref().unwrap_or_default() == request.body
1533 && pull_request.base.ref_name == request.base_branch
1534 });
1535 let head_matches_existing =
1536 existing.and_then(|pull_request| pull_request.head.sha.as_deref()) == Some(head_commit);
1537 let (operation, pull_request) = match existing {
1538 Some(existing_pull_request) if content_matches => {
1539 (
1540 if head_matches_existing && labels_match && !request.auto_merge {
1541 GitHubPullRequestOperation::Skipped
1542 } else {
1543 GitHubPullRequestOperation::Updated
1544 },
1545 GitHubPullRequestResponse {
1546 number: existing_pull_request.number,
1547 html_url: existing_pull_request.html_url.clone(),
1548 node_id: existing_pull_request.node_id.clone(),
1549 },
1550 )
1551 }
1552 Some(existing_pull_request) => {
1553 (
1554 GitHubPullRequestOperation::Updated,
1555 sync_existing_pull_request_metadata(client, request, existing_pull_request).await?,
1556 )
1557 }
1558 None => {
1559 (
1560 GitHubPullRequestOperation::Created,
1561 post_json::<_, GitHubPullRequestResponse>(
1562 client,
1563 &format!("/repos/{}/{}/pulls", request.owner, request.repo),
1564 &GitHubPullRequestPayload {
1565 title: &request.title,
1566 head: &request.head_branch,
1567 base: &request.base_branch,
1568 body: &request.body,
1569 draft: false,
1570 },
1571 )
1572 .await?,
1573 )
1574 }
1575 };
1576 if !request.labels.is_empty() && !labels_match {
1577 let _: serde_json::Value = post_json(
1578 client,
1579 &format!(
1580 "/repos/{}/{}/issues/{}/labels",
1581 request.owner, request.repo, pull_request.number
1582 ),
1583 &GitHubLabelsPayload {
1584 labels: &request.labels,
1585 },
1586 )
1587 .await?;
1588 }
1589 if request.auto_merge {
1590 enable_pull_request_auto_merge_with_client(client, &pull_request.node_id).await?;
1591 }
1592 Ok(GitHubPullRequestOutcome {
1593 provider: SourceProvider::GitHub,
1594 repository: request.repository.clone(),
1595 number: pull_request.number,
1596 head_branch: request.head_branch.clone(),
1597 operation,
1598 url: pull_request.html_url,
1599 })
1600}
1601
1602async fn sync_retargeted_releases_with_client(
1603 client: &Octocrab,
1604 source: &SourceConfiguration,
1605 tag_updates: &[RetargetTagResult],
1606 dry_run: bool,
1607) -> MonochangeResult<Vec<RetargetProviderResult>> {
1608 let mut results = Vec::with_capacity(tag_updates.len());
1609 for update in tag_updates {
1610 if dry_run {
1611 results.push(RetargetProviderResult {
1612 provider: SourceProvider::GitHub,
1613 tag_name: update.tag_name.clone(),
1614 target_commit: update.to_commit.clone(),
1615 operation: RetargetProviderOperation::Planned,
1616 url: None,
1617 message: None,
1618 });
1619 continue;
1620 }
1621 let path = format!(
1622 "/repos/{}/{}/releases/tags/{}",
1623 source.owner, source.repo, update.tag_name
1624 );
1625 let Some(existing) = get_optional_json::<GitHubExistingRelease>(client, &path).await?
1626 else {
1627 return Err(MonochangeError::Config(format!(
1628 "GitHub release for tag `{}` could not be found",
1629 update.tag_name
1630 )));
1631 };
1632 if existing.target_commitish.as_deref() == Some(update.to_commit.as_str())
1633 || update.operation == RetargetOperation::AlreadyUpToDate
1634 {
1635 results.push(RetargetProviderResult {
1636 provider: SourceProvider::GitHub,
1637 tag_name: update.tag_name.clone(),
1638 target_commit: update.to_commit.clone(),
1639 operation: RetargetProviderOperation::AlreadyAligned,
1640 url: existing.html_url,
1641 message: None,
1642 });
1643 continue;
1644 }
1645 let response = patch_json::<_, GitHubReleaseResponse>(
1646 client,
1647 &format!(
1648 "/repos/{}/{}/releases/{}",
1649 source.owner, source.repo, existing.id
1650 ),
1651 &GitHubReleaseRetargetPayload {
1652 target_commitish: &update.to_commit,
1653 },
1654 )
1655 .await?;
1656 results.push(RetargetProviderResult {
1657 provider: SourceProvider::GitHub,
1658 tag_name: update.tag_name.clone(),
1659 target_commit: update.to_commit.clone(),
1660 operation: RetargetProviderOperation::Synced,
1661 url: response.html_url,
1662 message: None,
1663 });
1664 }
1665 Ok(results)
1666}
1667
1668async fn sync_existing_pull_request_metadata(
1669 client: &Octocrab,
1670 request: &GitHubPullRequestRequest,
1671 existing: &GitHubExistingPullRequest,
1672) -> MonochangeResult<GitHubPullRequestResponse> {
1673 patch_json::<_, GitHubPullRequestResponse>(
1674 client,
1675 &format!(
1676 "/repos/{}/{}/pulls/{}",
1677 request.owner, request.repo, existing.number
1678 ),
1679 &GitHubPullRequestUpdatePayload {
1680 title: &request.title,
1681 body: &request.body,
1682 base: &request.base_branch,
1683 },
1684 )
1685 .await
1686}
1687
1688async fn lookup_existing_release_with_client(
1689 client: &Octocrab,
1690 request: &GitHubReleaseRequest,
1691) -> MonochangeResult<Option<GitHubExistingRelease>> {
1692 get_optional_json(
1693 client,
1694 &format!(
1695 "/repos/{}/{}/releases/tags/{}",
1696 request.owner,
1697 request.repo,
1698 encode(&request.tag_name)
1699 ),
1700 )
1701 .await
1702}
1703
1704async fn lookup_existing_pull_request_with_client(
1705 client: &Octocrab,
1706 request: &GitHubPullRequestRequest,
1707) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1708 let path = format!(
1709 "/repos/{}/{}/pulls?state=open&head={}:{}&base={}&per_page=1",
1710 request.owner,
1711 request.repo,
1712 encode(&request.owner),
1713 encode(&request.head_branch),
1714 encode(&request.base_branch)
1715 );
1716 let pull_requests = get_json::<Vec<GitHubExistingPullRequest>>(client, &path).await?;
1717 Ok(pull_requests.into_iter().next())
1718}
1719
1720#[allow(clippy::let_and_return)]
1721async fn lookup_existing_pull_request(
1722 source: &SourceConfiguration,
1723 request: &GitHubPullRequestRequest,
1724) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1725 let client = github_client_from_env(source)?;
1726 lookup_existing_pull_request_with_client(&client, request).await
1727}
1728
1729async fn enable_pull_request_auto_merge_with_client(
1730 client: &Octocrab,
1731 node_id: &str,
1732) -> MonochangeResult<()> {
1733 let response = client
1734 .graphql::<GraphqlEnableAutoMergeResponse>(&json!({
1735 "query": "mutation($pullRequestId: ID!) { enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) { pullRequest { number } } }",
1736 "variables": {
1737 "pullRequestId": node_id,
1738 },
1739 }))
1740 .await
1741 .map_err(|error| {
1742 MonochangeError::Config(format!(
1743 "failed to enable GitHub pull request auto merge: {error}"
1744 ))
1745 })?;
1746 if response
1747 .enable_pull_request_auto_merge
1748 .and_then(|payload| payload.pull_request)
1749 .is_none()
1750 {
1751 return Err(MonochangeError::Config(
1752 "GitHub pull request auto merge returned no pull request payload".to_string(),
1753 ));
1754 }
1755 Ok(())
1756}
1757
1758fn github_client_from_env(source: &SourceConfiguration) -> MonochangeResult<Octocrab> {
1759 let token = env::var("GITHUB_TOKEN")
1760 .or_else(|_| env::var("GH_TOKEN"))
1761 .map_err(|_| {
1762 MonochangeError::Config(
1763 "set `GITHUB_TOKEN` (or `GH_TOKEN`) before running GitHub automation".to_string(),
1764 )
1765 })?;
1766 let env_api_url = env::var("GITHUB_API_URL").ok();
1767 let api_url = source.api_url.as_deref().or(env_api_url.as_deref());
1768 build_github_client(&token, api_url)
1769}
1770
1771fn github_commit_client_from_env(source: &SourceConfiguration) -> MonochangeResult<Octocrab> {
1775 let token = env::var("GITHUB_COMMIT_TOKEN")
1776 .or_else(|_| env::var("GITHUB_TOKEN"))
1777 .map_err(|_| {
1778 MonochangeError::Config(
1779 "set `GITHUB_COMMIT_TOKEN` (or `GITHUB_TOKEN`) for GitHub commit verification"
1780 .to_string(),
1781 )
1782 })?;
1783 let env_api_url = env::var("GITHUB_API_URL").ok();
1784 let api_url = source.api_url.as_deref().or(env_api_url.as_deref());
1785 build_github_client(&token, api_url)
1786}
1787
1788fn build_github_client(token: &str, base_uri: Option<&str>) -> MonochangeResult<Octocrab> {
1789 ensure_rustls_provider();
1790 let builder = Octocrab::builder().personal_token(token.to_string());
1791 let builder = if let Some(base_uri) = base_uri {
1792 builder.base_uri(base_uri).map_err(|error| {
1793 MonochangeError::Config(format!(
1794 "failed to configure GitHub base URL `{base_uri}`: {error}"
1795 ))
1796 })?
1797 } else {
1798 builder
1799 };
1800 builder.build().map_err(|error| {
1801 MonochangeError::Config(format!("failed to build GitHub API client: {error}"))
1802 })
1803}
1804
1805fn format_github_api_error(method: &str, path: &str, error: &octocrab::Error) -> String {
1806 match error {
1807 octocrab::Error::GitHub { source, .. } => {
1808 let mut parts = vec![
1809 format!("status {}", source.status_code.as_u16()),
1810 source.message.clone(),
1811 ];
1812 if let Some(documentation_url) = &source.documentation_url {
1813 parts.push(format!("documentation: {documentation_url}"));
1814 }
1815 if let Some(errors) = &source.errors
1816 && !errors.is_empty()
1817 {
1818 for error in errors {
1819 parts.push(format!("details: {error}"));
1820 }
1821 }
1822 format!("GitHub API {method} `{path}` failed: {}", parts.join("; "))
1823 }
1824 _ => format!("GitHub API {method} `{path}` failed: {error}"),
1825 }
1826}
1827
1828async fn get_optional_json<T>(client: &Octocrab, path: &str) -> MonochangeResult<Option<T>>
1829where
1830 T: DeserializeOwned,
1831{
1832 match client.get::<T, _, _>(path, None::<&()>).await {
1833 Ok(value) => Ok(Some(value)),
1834 Err(octocrab::Error::GitHub { source, .. }) if source.status_code.as_u16() == 404 => {
1835 Ok(None)
1836 }
1837 Err(error) => {
1838 Err(MonochangeError::Config(format_github_api_error(
1839 "GET", path, &error,
1840 )))
1841 }
1842 }
1843}
1844
1845async fn get_json<T>(client: &Octocrab, path: &str) -> MonochangeResult<T>
1846where
1847 T: DeserializeOwned,
1848{
1849 match client.get::<T, _, _>(path, None::<&()>).await {
1850 Ok(value) => Ok(value),
1851 Err(error) => {
1852 Err(MonochangeError::Config(format_github_api_error(
1853 "GET", path, &error,
1854 )))
1855 }
1856 }
1857}
1858
1859async fn post_json<Body, Response>(
1860 client: &Octocrab,
1861 path: &str,
1862 body: &Body,
1863) -> MonochangeResult<Response>
1864where
1865 Body: Serialize + ?Sized,
1866 Response: DeserializeOwned,
1867{
1868 client
1869 .post(path, Some(body))
1870 .await
1871 .map_err(|error| MonochangeError::Config(format_github_api_error("POST", path, &error)))
1872}
1873
1874async fn patch_json<Body, Response>(
1875 client: &Octocrab,
1876 path: &str,
1877 body: &Body,
1878) -> MonochangeResult<Response>
1879where
1880 Body: Serialize + ?Sized,
1881 Response: DeserializeOwned,
1882{
1883 client
1884 .patch(path, Some(body))
1885 .await
1886 .map_err(|error| MonochangeError::Config(format_github_api_error("PATCH", path, &error)))
1887}
1888
1889async fn join_existing_pull_request_lookup(
1890 handle: tokio::task::JoinHandle<MonochangeResult<Option<GitHubExistingPullRequest>>>,
1891) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1892 handle.await.map_err(|_| {
1893 MonochangeError::Config("failed to join GitHub pull request lookup task".to_string())
1894 })?
1895}
1896
1897async fn git_checkout_branch(root: &Path, branch: &str) -> MonochangeResult<()> {
1898 if matches!(git_current_branch(root).await.as_deref(), Ok(current) if current == branch) {
1899 return Ok(());
1900 }
1901 run_command(
1902 git_checkout_branch_command(root, branch),
1903 "prepare release pull request branch",
1904 )
1905 .await
1906}
1907
1908async fn git_stage_paths(
1909 root: &Path,
1910 tracked_paths: &[PathBuf],
1911 stage_all: bool,
1912) -> MonochangeResult<()> {
1913 let stageable_paths = resolve_stageable_release_paths(root, tracked_paths).await?;
1914 if stageable_paths.is_empty() {
1915 return Ok(());
1916 }
1917 let command = if stage_all {
1918 git_stage_all_command(root)
1919 } else {
1920 git_stage_paths_command(root, &stageable_paths)
1921 };
1922 run_command(command, "stage release pull request files").await
1923}
1924
1925async fn resolve_stageable_release_paths(
1926 root: &Path,
1927 tracked_paths: &[PathBuf],
1928) -> MonochangeResult<Vec<PathBuf>> {
1929 let mut stageable_paths = Vec::with_capacity(tracked_paths.len());
1930 let path_filter = DiscoveryPathFilter::new(root);
1931 for path in tracked_paths {
1932 if release_path_requires_staging(root, path, &path_filter).await? {
1933 stageable_paths.push(path.clone());
1934 }
1935 }
1936 Ok(stageable_paths)
1937}
1938
1939async fn release_path_requires_staging(
1940 root: &Path,
1941 path: &Path,
1942 path_filter: &DiscoveryPathFilter,
1943) -> MonochangeResult<bool> {
1944 let relative = root_relative_path(root, path);
1945 if !relative.starts_with(".monochange/releases")
1946 && release_path_is_ignored_by_filter(root, relative, path_filter)
1947 {
1948 return Ok(false);
1949 }
1950
1951 let absolute_path = root.join(path);
1952 if absolute_path.exists() {
1953 if git_path_is_tracked(root, path).await? {
1954 return Ok(true);
1955 }
1956 return Ok(!git_path_is_ignored(root, path).await?);
1957 }
1958 git_path_is_tracked(root, path).await
1959}
1960
1961fn root_relative_path<'a>(root: &Path, path: &'a Path) -> &'a Path {
1962 if path.is_absolute() {
1963 path.strip_prefix(root).unwrap_or(path)
1964 } else {
1965 path
1966 }
1967}
1968
1969fn release_path_is_ignored_by_filter(
1970 root: &Path,
1971 relative: &Path,
1972 path_filter: &DiscoveryPathFilter,
1973) -> bool {
1974 !path_filter.allows(&root.join(relative))
1975}
1976
1977async fn git_path_is_tracked(root: &Path, path: &Path) -> MonochangeResult<bool> {
1978 let relative = path.to_string_lossy();
1979 let output = git_command_output(root, &["ls-files", "--error-unmatch", "--", &relative])
1980 .await
1981 .map_err(|error| {
1982 MonochangeError::Config(format!(
1983 "failed to inspect tracked git path {}: {error}",
1984 path.display()
1985 ))
1986 })?;
1987 match output.status.code() {
1988 Some(0) => Ok(true),
1989 Some(1) => Ok(false),
1990 _ => {
1991 Err(MonochangeError::Config(format!(
1992 "failed to inspect tracked git path {}: {}",
1993 path.display(),
1994 git_error_detail(&output)
1995 )))
1996 }
1997 }
1998}
1999
2000async fn git_path_is_ignored(root: &Path, path: &Path) -> MonochangeResult<bool> {
2001 let relative = path.to_string_lossy();
2002 let output = git_command_output(root, &["check-ignore", "-q", "--", &relative])
2003 .await
2004 .map_err(|error| {
2005 MonochangeError::Config(format!(
2006 "failed to inspect ignored git path {}: {error}",
2007 path.display()
2008 ))
2009 })?;
2010 match output.status.code() {
2011 Some(0) => Ok(true),
2012 Some(1) => Ok(false),
2013 _ => {
2014 Err(MonochangeError::Config(format!(
2015 "failed to inspect ignored git path {}: {}",
2016 path.display(),
2017 git_error_detail(&output)
2018 )))
2019 }
2020 }
2021}
2022
2023async fn git_commit_paths(
2024 root: &Path,
2025 message: &CommitMessage,
2026 no_verify: bool,
2027) -> MonochangeResult<()> {
2028 run_git_commit_message(
2029 root,
2030 message,
2031 "commit release pull request changes",
2032 no_verify,
2033 )
2034 .await
2035}
2036
2037async fn git_push_branch(root: &Path, branch: &str, no_verify: bool) -> MonochangeResult<()> {
2038 run_command(
2039 git_push_branch_command(root, branch, no_verify),
2040 "push release pull request branch",
2041 )
2042 .await
2043}
2044
2045fn release_body(
2046 github: &SourceConfiguration,
2047 manifest: &ReleaseManifest,
2048 target: &ReleaseManifestTarget,
2049) -> Option<String> {
2050 match github.releases.source {
2051 ProviderReleaseNotesSource::GitHubGenerated => None,
2052 ProviderReleaseNotesSource::Monochange => Some(monochange_release_body(manifest, target)),
2053 }
2054}
2055
2056fn monochange_release_body(manifest: &ReleaseManifest, target: &ReleaseManifestTarget) -> String {
2057 let target_changelog = manifest
2058 .changelogs
2059 .iter()
2060 .find(|changelog| changelog.owner_id == target.id && changelog.owner_kind == target.kind);
2061 let member_changelogs = uncovered_member_changelogs(manifest, target, target_changelog);
2062
2063 match (target_changelog, member_changelogs.is_empty()) {
2064 (Some(changelog), _) if changelog_has_release_notes(changelog) => {
2065 changelog.rendered.clone()
2066 }
2067 (_, false) => grouped_member_release_body(target, &member_changelogs),
2068 (_, true) => minimal_release_body(manifest, target),
2069 }
2070}
2071
2072fn uncovered_member_changelogs<'a>(
2073 manifest: &'a ReleaseManifest,
2074 target: &ReleaseManifestTarget,
2075 target_changelog: Option<&ReleaseManifestChangelog>,
2076) -> Vec<&'a ReleaseManifestChangelog> {
2077 if target.kind != ReleaseOwnerKind::Group {
2078 return Vec::new();
2079 }
2080
2081 manifest
2082 .changelogs
2083 .iter()
2084 .filter(|changelog| {
2085 changelog.owner_kind == ReleaseOwnerKind::Package
2086 && target.members.contains(&changelog.owner_id)
2087 && changelog_has_release_notes(changelog)
2088 && changelog_has_uncovered_notes(changelog, target_changelog)
2089 })
2090 .collect()
2091}
2092
2093fn grouped_member_release_body(
2094 target: &ReleaseManifestTarget,
2095 member_changelogs: &[&ReleaseManifestChangelog],
2096) -> String {
2097 let title = if target.rendered_changelog_title.is_empty() {
2098 target.rendered_title.as_str()
2099 } else {
2100 target.rendered_changelog_title.as_str()
2101 };
2102 let title = if title.is_empty() {
2103 target.tag_name.as_str()
2104 } else {
2105 title
2106 };
2107 let mut lines = vec![format!("## {title}"), String::new()];
2108 lines.push(format!("Grouped release for `{}`.", target.id));
2109 lines.push(String::new());
2110 push_member_changelogs(&mut lines, member_changelogs);
2111 lines.join("\n")
2112}
2113
2114fn push_member_changelogs(lines: &mut Vec<String>, changelogs: &[&ReleaseManifestChangelog]) {
2115 lines.push("## Member package changelogs".to_string());
2116
2117 for changelog in changelogs {
2118 lines.push(String::new());
2119 lines.push(format!("### `{}`", changelog.owner_id));
2120 push_changelog_notes(lines, changelog);
2121 }
2122}
2123
2124fn push_changelog_notes(lines: &mut Vec<String>, changelog: &ReleaseManifestChangelog) {
2125 for paragraph in &changelog.notes.summary {
2126 lines.push(String::new());
2127 lines.push(paragraph.clone());
2128 }
2129
2130 for section in &changelog.notes.sections {
2131 let entries = section
2132 .entries
2133 .iter()
2134 .filter(|entry| !is_empty_release_note(entry))
2135 .cloned()
2136 .collect::<Vec<_>>();
2137 if entries.is_empty() {
2138 continue;
2139 }
2140 lines.push(String::new());
2141 lines.push(format!("#### {}", section.title));
2142 lines.push(String::new());
2143 push_body_entries(lines, &entries);
2144 }
2145}
2146
2147fn changelog_has_release_notes(changelog: &ReleaseManifestChangelog) -> bool {
2148 changelog.notes.sections.iter().any(|section| {
2149 section
2150 .entries
2151 .iter()
2152 .any(|entry| !is_empty_release_note(entry))
2153 })
2154}
2155
2156fn is_empty_release_note(entry: &str) -> bool {
2157 entry.contains("No group-facing notes were recorded for this release")
2158 || entry.contains("No package-specific changes were recorded")
2159 || entry.contains("No significant changes")
2160}
2161
2162fn changelog_has_uncovered_notes(
2163 changelog: &ReleaseManifestChangelog,
2164 target_changelog: Option<&ReleaseManifestChangelog>,
2165) -> bool {
2166 let Some(target_changelog) = target_changelog else {
2167 return true;
2168 };
2169 let covered_entries = target_changelog
2170 .notes
2171 .sections
2172 .iter()
2173 .flat_map(|section| §ion.entries)
2174 .map(|entry| normalized_release_entry(entry))
2175 .collect::<Vec<_>>();
2176
2177 changelog
2178 .notes
2179 .sections
2180 .iter()
2181 .flat_map(|section| §ion.entries)
2182 .filter(|entry| !is_empty_release_note(entry))
2183 .any(|entry| !covered_entries.contains(&normalized_release_entry(entry)))
2184}
2185
2186fn normalized_release_entry(entry: &str) -> String {
2187 entry
2188 .lines()
2189 .filter(|line| !line.trim_start().starts_with("_Packages:"))
2190 .collect::<Vec<_>>()
2191 .join("\n")
2192 .trim()
2193 .to_string()
2194}
2195
2196fn release_pull_request_branch(branch_prefix: &str, command: &str) -> String {
2197 let command = command
2198 .chars()
2199 .map(|character| {
2200 if character.is_ascii_alphanumeric() {
2201 character.to_ascii_lowercase()
2202 } else {
2203 '-'
2204 }
2205 })
2206 .collect::<String>()
2207 .trim_matches('-')
2208 .to_string();
2209 let command = if command.is_empty() {
2210 "release".to_string()
2211 } else {
2212 command
2213 };
2214 format!("{}/{}", branch_prefix.trim_end_matches('/'), command)
2215}
2216
2217fn release_pull_request_body(manifest: &ReleaseManifest) -> String {
2218 let mut lines = vec!["## Prepared release".to_string(), String::new()];
2219 lines.push(format!("- command: `{}`", manifest.command));
2220 for target in manifest
2221 .release_targets
2222 .iter()
2223 .filter(|target| target.release)
2224 {
2225 lines.push(format!(
2226 "- {} `{}` -> `{}`",
2227 target.kind, target.id, target.tag_name
2228 ));
2229 }
2230 if !manifest.release_targets.iter().any(|target| target.release) {
2231 lines.push("- no outward release targets".to_string());
2232 }
2233 lines.push(String::new());
2234 lines.push("## Release notes".to_string());
2235 for target in manifest
2236 .release_targets
2237 .iter()
2238 .filter(|target| target.release)
2239 {
2240 lines.push(String::new());
2241 lines.push(format!("### {} {}", target.id, target.version));
2242 if let Some(changelog) = manifest.changelogs.iter().find(|changelog| {
2243 changelog.owner_id == target.id && changelog.owner_kind == target.kind
2244 }) {
2245 for paragraph in &changelog.notes.summary {
2246 lines.push(String::new());
2247 lines.push(paragraph.clone());
2248 }
2249 for section in &changelog.notes.sections {
2250 if section.entries.is_empty() {
2251 continue;
2252 }
2253 lines.push(String::new());
2254 lines.push(format!("### {}", section.title));
2255 lines.push(String::new());
2256 push_body_entries(&mut lines, §ion.entries);
2257 }
2258 } else {
2259 lines.push(String::new());
2260 lines.push(minimal_release_body(manifest, target));
2261 }
2262 }
2263 if !manifest.changed_files.is_empty() {
2264 lines.push(String::new());
2265 lines.push("## Changed files".to_string());
2266 lines.push(String::new());
2267 for path in &manifest.changed_files {
2268 lines.push(format!("- {}", path.display()));
2269 }
2270 }
2271 lines.join("\n")
2272}
2273
2274fn push_body_entries(lines: &mut Vec<String>, entries: &[String]) {
2275 for (index, entry) in entries.iter().enumerate() {
2276 let trimmed = entry.trim();
2277 if trimmed.contains('\n') {
2278 lines.extend(trimmed.lines().map(ToString::to_string));
2279 if index + 1 < entries.len() {
2280 lines.push(String::new());
2281 }
2282 continue;
2283 }
2284 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with('#') {
2285 lines.push(trimmed.to_string());
2286 } else {
2287 lines.push(format!("- {trimmed}"));
2288 }
2289 }
2290}
2291
2292#[cfg(unix)]
2293fn git_blob_mode(metadata: &fs::Metadata) -> &'static str {
2294 use std::os::unix::fs::PermissionsExt;
2295
2296 if metadata.permissions().mode() & 0o100 != 0 {
2297 "100755"
2298 } else {
2299 "100644"
2300 }
2301}
2302
2303#[cfg(not(unix))]
2304fn git_blob_mode(_metadata: &std::fs::Metadata) -> &'static str {
2305 "100644"
2306}
2307
2308fn minimal_release_body(manifest: &ReleaseManifest, target: &ReleaseManifestTarget) -> String {
2309 let mut lines = vec![format!("Release target `{}`", target.id), String::new()];
2310 if !target.members.is_empty() {
2311 lines.push(format!("Members: {}", target.members.join(", ")));
2312 lines.push(String::new());
2313 }
2314 let reasons = manifest
2315 .plan
2316 .decisions
2317 .iter()
2318 .filter(|decision| {
2319 target.kind == ReleaseOwnerKind::Package || target.members.contains(&decision.package)
2320 })
2321 .flat_map(|decision| decision.reasons.iter().cloned())
2322 .collect::<Vec<_>>();
2323 if reasons.is_empty() {
2324 lines.push("- prepare release".to_string());
2325 } else {
2326 for reason in reasons {
2327 lines.push(format!("- {reason}"));
2328 }
2329 }
2330 lines.join("\n")
2331}
2332
2333use std::collections::BTreeMap;
2334use std::fs;
2335
2336use monochange_core::TrustedPublishingSettings;
2337use monochange_publish::PublishRequest;
2338use serde_json::Value as JsonValue;
2339use serde_yaml_ng::Value as YamlValue;
2340
2341pub const GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
2342pub const GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
2343
2344#[derive(Debug, Clone, Eq, PartialEq)]
2345pub struct GitHubTrustContext {
2346 pub repository: String,
2347 pub workflow: String,
2348 pub environment: Option<String>,
2349}
2350
2351pub fn verify_github_trust_context(
2352 request: &PublishRequest,
2353 root: &Path,
2354 env_map: &BTreeMap<String, String>,
2355 expected: &GitHubTrustContext,
2356 actual_repository: Option<&str>,
2357 actual_workflow: Option<&str>,
2358 actual_environment: Option<&str>,
2359) -> MonochangeResult<()> {
2360 let actual_repository = actual_repository.ok_or_else(|| {
2361 trusted_publishing_identity_error(
2362 request,
2363 "GitHub Actions did not expose `GITHUB_REPOSITORY`".to_string(),
2364 )
2365 })?;
2366 if actual_repository != expected.repository {
2367 return Err(trusted_publishing_identity_error(
2368 request,
2369 format!(
2370 "expected GitHub repository `{}`, but detected `{actual_repository}`",
2371 expected.repository
2372 ),
2373 ));
2374 }
2375
2376 let actual_workflow = actual_workflow.ok_or_else(|| {
2377 trusted_publishing_identity_error(
2378 request,
2379 "GitHub Actions did not expose `GITHUB_WORKFLOW_REF` with a workflow filename"
2380 .to_string(),
2381 )
2382 })?;
2383 if actual_workflow != expected.workflow {
2384 return Err(trusted_publishing_identity_error(
2385 request,
2386 format!(
2387 "expected GitHub workflow `{}`, but detected `{actual_workflow}`",
2388 expected.workflow
2389 ),
2390 ));
2391 }
2392
2393 if let Some(expected_environment) = expected.environment.as_deref() {
2394 let resolved_environment = actual_environment
2395 .map(ToString::to_string)
2396 .or_else(|| resolve_github_job_environment(root, actual_workflow, env_map));
2397 if resolved_environment.as_deref() != Some(expected_environment) {
2398 return Err(trusted_publishing_identity_error(
2399 request,
2400 format!(
2401 "expected GitHub environment `{expected_environment}`, but detected `{}`",
2402 resolved_environment.as_deref().unwrap_or("none")
2403 ),
2404 ));
2405 }
2406 }
2407
2408 if !env_map.contains_key(GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL)
2409 || !env_map.contains_key(GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN)
2410 {
2411 return Err(trusted_publishing_identity_error(
2412 request,
2413 format!(
2414 "GitHub Actions did not expose `{GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL}` and `{GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN}`; grant `id-token: write` to the publish job"
2415 ),
2416 ));
2417 }
2418
2419 Ok(())
2420}
2421
2422pub fn trusted_publishing_identity_error(
2423 request: &PublishRequest,
2424 reason: impl std::fmt::Display,
2425) -> MonochangeError {
2426 MonochangeError::Config(format!(
2427 "`{}` requires trusted publishing from the configured GitHub Actions OIDC identity, but the current context does not match: {reason}. Run `monochange step publish-packages` from the configured CI workflow or set `publish.trusted_publishing = false` to opt out.",
2428 request.package_id,
2429 ))
2430}
2431
2432pub fn resolve_github_trust_context(
2433 root: &Path,
2434 source: Option<&SourceConfiguration>,
2435 trust: &TrustedPublishingSettings,
2436 env_map: &BTreeMap<String, String>,
2437) -> MonochangeResult<GitHubTrustContext> {
2438 let repository = trust
2439 .repository
2440 .clone()
2441 .or_else(|| source.map(|source| format!("{}/{}", source.owner, source.repo)))
2442 .or_else(|| env_map.get("GITHUB_REPOSITORY").cloned())
2443 .ok_or_else(|| {
2444 MonochangeError::Config(
2445 "trusted publishing could not determine the GitHub repository; set `publish.trusted_publishing.repository`".to_string(),
2446 )
2447 })?;
2448
2449 let workflow = trust
2450 .workflow
2451 .clone()
2452 .or_else(|| {
2453 env_map
2454 .get("GITHUB_WORKFLOW_REF")
2455 .and_then(|value| parse_github_workflow_ref(value))
2456 })
2457 .ok_or_else(|| {
2458 MonochangeError::Config(
2459 "trusted publishing could not determine the GitHub workflow; set `publish.trusted_publishing.workflow`".to_string(),
2460 )
2461 })?;
2462
2463 let environment = trust
2464 .environment
2465 .clone()
2466 .or_else(|| resolve_github_job_environment(root, &workflow, env_map));
2467
2468 Ok(GitHubTrustContext {
2469 repository,
2470 workflow,
2471 environment,
2472 })
2473}
2474
2475pub fn parse_github_workflow_ref(value: &str) -> Option<String> {
2476 let (_, path_and_ref) = value.split_once('/')?;
2477 let (_, path_and_ref) = path_and_ref.split_once('/')?;
2478 let (_, path_and_ref) = path_and_ref.split_once('/')?;
2479 let (workflow_path, _) = path_and_ref.split_once('@')?;
2480 Path::new(workflow_path)
2481 .file_name()
2482 .and_then(|name| name.to_str())
2483 .map(ToString::to_string)
2484}
2485
2486pub fn resolve_github_job_environment(
2487 root: &Path,
2488 workflow: &str,
2489 env_map: &BTreeMap<String, String>,
2490) -> Option<String> {
2491 let job_id = env_map.get("GITHUB_JOB")?;
2492 let workflow_path = root.join(".github/workflows").join(workflow);
2493 let contents = fs::read_to_string(workflow_path).ok()?;
2494 let parsed = serde_yaml_ng::from_str::<YamlValue>(&contents).ok()?;
2495 let jobs = parsed.get("jobs")?;
2496 let job = jobs.get(job_id.as_str())?;
2497 match job.get("environment") {
2498 Some(YamlValue::String(environment)) => Some(environment.clone()),
2499 Some(YamlValue::Mapping(mapping)) => {
2500 mapping
2501 .get(YamlValue::String("name".to_string()))
2502 .and_then(YamlValue::as_str)
2503 .map(ToString::to_string)
2504 }
2505 _ => None,
2506 }
2507}
2508
2509pub fn trust_list_contains_context(output: &str, context: &GitHubTrustContext) -> bool {
2510 if let Ok(json) = serde_json::from_str::<JsonValue>(output) {
2511 let mut required = vec![context.repository.as_str(), context.workflow.as_str()];
2512 if let Some(environment) = &context.environment {
2513 required.push(environment.as_str());
2514 }
2515 return required
2516 .into_iter()
2517 .all(|needle| json_value_contains(&json, needle));
2518 }
2519
2520 output.contains(&context.repository)
2521 && output.contains(&context.workflow)
2522 && context
2523 .environment
2524 .as_deref()
2525 .is_none_or(|environment| output.contains(environment))
2526}
2527
2528pub fn json_value_contains(value: &JsonValue, needle: &str) -> bool {
2529 match value {
2530 JsonValue::String(text) => text.contains(needle),
2531 JsonValue::Array(items) => items.iter().any(|item| json_value_contains(item, needle)),
2532 JsonValue::Object(map) => map.values().any(|value| json_value_contains(value, needle)),
2533 _ => false,
2534 }
2535}
2536
2537pub fn format_manual_trust_context(context: &GitHubTrustContext) -> String {
2538 let mut parts = vec![
2539 format!("repository `{}`", context.repository),
2540 format!("workflow `{}`", context.workflow),
2541 ];
2542 if let Some(environment) = &context.environment {
2543 parts.push(format!("environment `{environment}`"));
2544 }
2545 parts.join(", ")
2546}
2547
2548#[cfg(test)]
2549#[path = "__tests__/lib_tests.rs"]
2550mod tests;