From f30668777405d930360424ffaa624548022a0dd8 Mon Sep 17 00:00:00 2001 From: Andrew Ghostuhin Date: Tue, 21 Apr 2026 06:25:06 +0300 Subject: [PATCH] fix: make DSM generation idempotent in MSK --- .../src/application/queries/get_issues.rs | 4 +- .../src/application/use_cases/close_issues.rs | 19 ++-- .../scripts/dsm-launcher/src/domain/issue.rs | 15 +++ .../dsm-launcher/src/domain/repository.rs | 10 +- .../github_adapter/issue_adapter.rs | 80 ++++++---------- .github/scripts/dsm-launcher/src/main.rs | 93 +++++++++++-------- 6 files changed, 116 insertions(+), 105 deletions(-) diff --git a/.github/scripts/dsm-launcher/src/application/queries/get_issues.rs b/.github/scripts/dsm-launcher/src/application/queries/get_issues.rs index 7a81ab6..0f5b1f2 100644 --- a/.github/scripts/dsm-launcher/src/application/queries/get_issues.rs +++ b/.github/scripts/dsm-launcher/src/application/queries/get_issues.rs @@ -1,7 +1,7 @@ use anyhow::Result; use std::rc::Rc; -use crate::domain::{issue::IssueId, repo::RepoId, repository::IssueRepository}; +use crate::domain::{issue::OpenIssue, repo::RepoId, repository::IssueRepository}; #[derive(Clone)] pub struct GetIssuesQuery { @@ -9,7 +9,7 @@ pub struct GetIssuesQuery { } impl GetIssuesQuery { - pub async fn execute(&self, repo_id: &RepoId) -> Result> { + pub async fn execute(&self, repo_id: &RepoId) -> Result> { self.repo.get_issues(repo_id).await } } diff --git a/.github/scripts/dsm-launcher/src/application/use_cases/close_issues.rs b/.github/scripts/dsm-launcher/src/application/use_cases/close_issues.rs index dde3085..2c563f0 100644 --- a/.github/scripts/dsm-launcher/src/application/use_cases/close_issues.rs +++ b/.github/scripts/dsm-launcher/src/application/use_cases/close_issues.rs @@ -1,17 +1,11 @@ use anyhow::{Ok, Result}; +use std::ops::Deref; use crate::application::{ commands::close_issue::CloseIssueCommand, - queries::{ - get_issues::GetIssuesQuery, - get_repo::GetRepoQuery, - get_org::GetOrgQuery - }, -}; -use crate::domain::repository::{ - IssueRepository, - OrgRepository + queries::{get_issues::GetIssuesQuery, get_org::GetOrgQuery, get_repo::GetRepoQuery}, }; +use crate::domain::repository::{IssueRepository, OrgRepository}; pub async fn close_issues( get_org: GetOrgQuery, @@ -20,13 +14,18 @@ pub async fn close_issues( close_issue: CloseIssueCommand, owner: &str, repo_name: &str, + keep_issue_id: Option<&str>, ) -> Result<()> { let org_id = get_org.execute(owner).await?; let repo_id = get_repo.execute(&org_id, &repo_name).await?; let issues = get_issues.execute(&repo_id).await?; for issue in issues.iter() { - close_issue.execute(issue).await?; + if keep_issue_id == Some(issue.id.deref()) { + continue; + } + + close_issue.execute(&issue.id).await?; } Ok(()) diff --git a/.github/scripts/dsm-launcher/src/domain/issue.rs b/.github/scripts/dsm-launcher/src/domain/issue.rs index 2f4448f..144543e 100644 --- a/.github/scripts/dsm-launcher/src/domain/issue.rs +++ b/.github/scripts/dsm-launcher/src/domain/issue.rs @@ -11,6 +11,21 @@ pub struct Issue { pub assignees: Vec, } +pub struct OpenIssue { + pub id: IssueId, + pub title: String, +} + +impl OpenIssue { + pub fn new(id: String, title: String) -> Self { + OpenIssue { + id: IssueId::new(id), + title, + } + } +} + +#[derive(Clone)] pub struct IssueId(String); impl IssueId { diff --git a/.github/scripts/dsm-launcher/src/domain/repository.rs b/.github/scripts/dsm-launcher/src/domain/repository.rs index 7576eae..e61e44e 100644 --- a/.github/scripts/dsm-launcher/src/domain/repository.rs +++ b/.github/scripts/dsm-launcher/src/domain/repository.rs @@ -1,13 +1,17 @@ -use async_trait::async_trait; use anyhow::Result; +use async_trait::async_trait; use super::{ - issue::{Issue, IssueId, IssueType}, member::Member, org::OrgId, repo::RepoId, team::TeamId + issue::{Issue, IssueId, IssueType, OpenIssue}, + member::Member, + org::OrgId, + repo::RepoId, + team::TeamId, }; #[async_trait] pub trait IssueRepository { - async fn get_issues(&self, repo: &RepoId) -> Result>; + async fn get_issues(&self, repo: &RepoId) -> Result>; async fn get_issue_types(&self, repo: &RepoId) -> Result>; async fn create_issue(&self, issue: Issue) -> Result; async fn close_issue(&self, issue_id: &IssueId) -> Result<()>; diff --git a/.github/scripts/dsm-launcher/src/infrastructure/github_adapter/issue_adapter.rs b/.github/scripts/dsm-launcher/src/infrastructure/github_adapter/issue_adapter.rs index c302a63..cf27c5a 100644 --- a/.github/scripts/dsm-launcher/src/infrastructure/github_adapter/issue_adapter.rs +++ b/.github/scripts/dsm-launcher/src/infrastructure/github_adapter/issue_adapter.rs @@ -1,53 +1,33 @@ +use anyhow::{Ok, Result}; use async_trait::async_trait; -use anyhow::{Result, Ok}; use crate::domain::{ - issue::{ - Issue, - IssueId, IssueType - }, repo::RepoId, repository::IssueRepository + issue::{Issue, IssueId, IssueType, OpenIssue}, + repo::RepoId, + repository::IssueRepository, }; use crate::graphql_queries::{ - close_issue::{ - CloseIssue, - close_issue::Variables as CloseIssueVars - }, - create_issue::{ - CreateIssue, - create_issue::Variables as CreateIssueVars, + close_issue::{close_issue::Variables as CloseIssueVars, CloseIssue}, + create_issue::{create_issue::Variables as CreateIssueVars, CreateIssue}, + get_issue_types::{ + get_issue_types::{GetIssueTypesNode, Variables as GetIssueTypesVars}, + GetIssueTypes, }, get_open_issues::{ - GetOpenIssues, - get_open_issues::{ - Variables as GetOpenIssuesVars, - GetOpenIssuesNode - }, + get_open_issues::{GetOpenIssuesNode, Variables as GetOpenIssuesVars}, + GetOpenIssues, }, - get_issue_types::{ - GetIssueTypes, - get_issue_types::{ - Variables as GetIssueTypesVars, - GetIssueTypesNode, - } - } }; -use crate::domain::errors::{ - issue::IssueError, - issue_types::IssueTypesError, - repo::RepoError, -}; +use crate::domain::errors::{issue::IssueError, issue_types::IssueTypesError, repo::RepoError}; -use super::{ - errors::GitHubAdapterError, - GitHubAdapter -}; +use super::{errors::GitHubAdapterError, GitHubAdapter}; #[async_trait] impl IssueRepository for GitHubAdapter { - async fn get_issues(&self, repo_id: &RepoId) -> Result> { + async fn get_issues(&self, repo_id: &RepoId) -> Result> { let vars = GetOpenIssuesVars { - id: repo_id.to_string() + id: repo_id.to_string(), }; let response = self.client.execute::(vars).await?; @@ -63,18 +43,20 @@ impl IssueRepository for GitHubAdapter { _ => { return Err(GitHubAdapterError::UnexpectedNodeType.into()); } - }.issues.nodes; + } + .issues + .nodes; Ok(issues .ok_or(IssueError::IssuesWereNotFound)? .into_iter() - .filter_map(|x| x.map(|issue| IssueId::new(issue.id))) - .collect::>()) + .filter_map(|x| x.map(|issue| OpenIssue::new(issue.id, issue.title))) + .collect::>()) } async fn get_issue_types(&self, repo_id: &RepoId) -> Result> { let vars = GetIssueTypesVars { - id: repo_id.to_string() + id: repo_id.to_string(), }; let response = self.client.execute::(vars).await?; @@ -95,19 +77,14 @@ impl IssueRepository for GitHubAdapter { .nodes .ok_or(IssueTypesError::IssueTypesNotFound)? .into_iter() - .filter_map(|x| { - x.map(|y| IssueType::new(y.id, y.name)) - }) + .filter_map(|x| x.map(|y| IssueType::new(y.id, y.name))) .collect::>(); Ok(issue_types) } async fn create_issue(&self, issue: Issue) -> Result { - let logins: Vec = issue.assignees - .iter() - .map(|x| x.id.to_string()) - .collect(); + let logins: Vec = issue.assignees.iter().map(|x| x.id.to_string()).collect(); let vars = CreateIssueVars { repo_id: issue.repo_id, @@ -124,19 +101,18 @@ impl IssueRepository for GitHubAdapter { } let response_data = response.data.ok_or(IssueError::EmptyCreateIssueResponse)?; - let issue = response_data.create_issue.ok_or(IssueError::CreatedIssueNotFound)?; + let issue = response_data + .create_issue + .ok_or(IssueError::CreatedIssueNotFound)?; Ok(IssueId::new( - issue - .issue - .ok_or(IssueError::CreatedIssueBodyNotFound)? - .id + issue.issue.ok_or(IssueError::CreatedIssueBodyNotFound)?.id, )) } async fn close_issue(&self, issue_id: &IssueId) -> Result<()> { let vars = CloseIssueVars { - id: issue_id.to_string() + id: issue_id.to_string(), }; let response = self.client.execute::(vars).await?; diff --git a/.github/scripts/dsm-launcher/src/main.rs b/.github/scripts/dsm-launcher/src/main.rs index 706dda9..aebac04 100644 --- a/.github/scripts/dsm-launcher/src/main.rs +++ b/.github/scripts/dsm-launcher/src/main.rs @@ -7,9 +7,10 @@ use application::queries::get_org::GetOrgQuery; use application::queries::get_repo::GetRepoQuery; use application::queries::get_team::GetTeamQuery; use application::queries::get_team_members::GetTeamMembersQuery; +use chrono::{FixedOffset, Utc}; use infrastructure::github_adapter::GitHubAdapter; -use std::fs; use std::env; +use std::fs; use std::rc::Rc; mod graphql_queries { @@ -17,57 +18,67 @@ mod graphql_queries { } mod application; -mod infrastructure; mod domain; +mod infrastructure; +use application::use_cases::{close_issues::close_issues, create_issue::create_issue}; use infrastructure::github_graphql_client::GitHubGraphQLClient; -use application::use_cases::{ - close_issues::close_issues, - create_issue::create_issue, -}; + +const DSM_TEAM_SLUG: &str = "DSM"; +const DSM_TITLE_DATE_FORMAT: &str = "%a %b %d %Y"; +const MOSCOW_UTC_OFFSET_SECONDS: i32 = 3 * 60 * 60; #[tokio::main] async fn main() -> Result<()> { let repo_owner = env::var("GITHUB_REPO_OWNER")?; let repo_name = env::var("GITHUB_REPO_NAME")?; - let team_slug = "DSM"; let body = fs::read_to_string("./.github/ISSUE_TEMPLATE/dsm.md")?; + let moscow_offset = + FixedOffset::east_opt(MOSCOW_UTC_OFFSET_SECONDS).expect("Moscow UTC offset must be valid"); let title: String = format!( "[DSM] {}", - chrono::Utc::now().date_naive().format("%a %b %d %Y") + Utc::now() + .with_timezone(&moscow_offset) + .date_naive() + .format(DSM_TITLE_DATE_FORMAT) ); - let client = GitHubGraphQLClient::new( - "dsm-launcher".to_string(), - env::var("GITHUB_TOKEN")?, - )?; - + let client = GitHubGraphQLClient::new("dsm-launcher".to_string(), env::var("GITHUB_TOKEN")?)?; + let adapter = Rc::new(GitHubAdapter::new(client.clone())); let get_org = GetOrgQuery { - repo: adapter.clone() + repo: adapter.clone(), }; let get_repo = GetRepoQuery { - repo: adapter.clone() + repo: adapter.clone(), }; let get_issues = GetIssuesQuery { - repo: adapter.clone() + repo: adapter.clone(), }; let get_issue_types = GetIssueTypes { - repo: adapter.clone() + repo: adapter.clone(), }; let close_issue = CloseIssueCommand { - repo: adapter.clone() + repo: adapter.clone(), }; let create_issue_ = CreateIssueCommand { - repo: adapter.clone() + repo: adapter.clone(), }; let get_team = GetTeamQuery { - repo: adapter.clone() - }; - let get_team_members = GetTeamMembersQuery { - repo: adapter + repo: adapter.clone(), }; + let get_team_members = GetTeamMembersQuery { repo: adapter }; + + let org_id = get_org.execute(&repo_owner).await?; + let repo_id = get_repo.execute(&org_id, &repo_name).await?; + let current_issue_id = get_issues + .clone() + .execute(&repo_id) + .await? + .into_iter() + .find(|issue| issue.title == title) + .map(|issue| issue.id.to_string()); close_issues( get_org.clone(), @@ -75,22 +86,28 @@ async fn main() -> Result<()> { get_issues, close_issue, &repo_owner, - &repo_name - ).await?; - create_issue( - get_org, - get_repo, - get_team, - get_team_members, - get_issue_types, - create_issue_, - &repo_owner, &repo_name, - team_slug, - team_slug, - &title, - &body - ).await?; + current_issue_id.as_deref(), + ) + .await?; + + if current_issue_id.is_none() { + create_issue( + get_org, + get_repo, + get_team, + get_team_members, + get_issue_types, + create_issue_, + &repo_owner, + &repo_name, + DSM_TEAM_SLUG, + DSM_TEAM_SLUG, + &title, + &body, + ) + .await?; + } Ok(()) }