-
Notifications
You must be signed in to change notification settings - Fork 2
Projects: closeOnStatus enforcement + auto-add issues (projects-006) #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| use std::collections::HashSet; | ||
|
|
||
| use chrono::{DateTime, Duration, NaiveDate, Utc}; | ||
| use serde::{Deserialize, Serialize}; | ||
| use serde_json::{json, Value}; | ||
|
|
@@ -3194,6 +3196,111 @@ pub async fn run_project_item_automation( | |
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn run_project_repository_item_added_automation( | ||
| pool: &PgPool, | ||
| input: ProjectAutomationInput, | ||
| ) -> Result<(), ProjectsError> { | ||
| if input.event != ProjectAutomationEvent::ItemAdded { | ||
| return run_project_item_automation(pool, input).await; | ||
| } | ||
|
|
||
| let rows = sqlx::query( | ||
| r#" | ||
| SELECT project_workflows.project_id, project_workflows.configuration | ||
| FROM project_workflows | ||
| JOIN projects ON projects.id = project_workflows.project_id | ||
| WHERE project_workflows.enabled = true | ||
| AND project_workflows.trigger_event = 'item_added' | ||
| AND projects.deleted_at IS NULL | ||
| AND ( | ||
| projects.default_repository_id = $1 | ||
| OR EXISTS ( | ||
| SELECT 1 FROM project_repositories | ||
| WHERE project_repositories.project_id = projects.id | ||
| AND project_repositories.repository_id = $1 | ||
| ) | ||
| ) | ||
| AND ( | ||
| projects.owner_user_id = $2 | ||
| OR projects.created_by_user_id = $2 | ||
| OR EXISTS ( | ||
| SELECT 1 FROM project_permissions | ||
| WHERE project_permissions.project_id = projects.id | ||
| AND project_permissions.user_id = $2 | ||
| AND project_permissions.role IN ('write', 'admin', 'owner') | ||
| ) | ||
| ) | ||
| AND ( | ||
| NOT EXISTS ( | ||
| SELECT 1 FROM project_workflow_repository_targets targets | ||
| WHERE targets.project_workflow_id = project_workflows.id | ||
| ) | ||
| OR EXISTS ( | ||
| SELECT 1 FROM project_workflow_repository_targets targets | ||
| WHERE targets.project_workflow_id = project_workflows.id | ||
| AND targets.repository_id = $1 | ||
| ) | ||
| ) | ||
| ORDER BY project_workflows.created_at | ||
| "#, | ||
| ) | ||
| .bind(input.repository_id) | ||
| .bind(input.actor_user_id) | ||
| .fetch_all(pool) | ||
| .await?; | ||
|
|
||
| let synthetic_item = ProjectAutomationItem { | ||
| project_id: Uuid::nil(), | ||
| item_id: Uuid::nil(), | ||
| item_type: if input.pull_request_id.is_some() { | ||
| "pull_request".to_owned() | ||
| } else { | ||
| "issue".to_owned() | ||
| }, | ||
| issue_id: input.issue_id, | ||
| pull_request_id: input.pull_request_id, | ||
| repository_id: input.repository_id, | ||
| }; | ||
|
|
||
| let project_ids = rows | ||
| .into_iter() | ||
| .filter(|row| { | ||
| let configuration: Value = row.get("configuration"); | ||
| workflow_condition_matches(&configuration, input.event, &synthetic_item) | ||
| }) | ||
| .map(|row| row.get::<Uuid, _>("project_id")) | ||
| .collect::<HashSet<_>>(); | ||
|
|
||
| if project_ids.is_empty() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| for project_id in &project_ids { | ||
| sqlx::query( | ||
| r#" | ||
| INSERT INTO project_items (project_id, item_type, issue_id, pull_request_id, position) | ||
| SELECT $1, $2, $3, $4, COALESCE(MAX(position), 0) + 1 | ||
| FROM project_items | ||
| WHERE project_id = $1 | ||
| HAVING NOT EXISTS ( | ||
| SELECT 1 FROM project_items existing | ||
| WHERE existing.project_id = $1 | ||
| AND (($3::uuid IS NOT NULL AND existing.issue_id = $3) | ||
| OR ($4::uuid IS NOT NULL AND existing.pull_request_id = $4)) | ||
| ) | ||
| "#, | ||
| ) | ||
| .bind(project_id) | ||
| .bind(&synthetic_item.item_type) | ||
| .bind(input.issue_id) | ||
| .bind(input.pull_request_id) | ||
| .execute(pool) | ||
| .await?; | ||
| } | ||
|
|
||
| run_project_item_automation(pool, input).await | ||
| } | ||
|
|
||
| pub async fn invoke_project_automation_for_actor( | ||
| pool: &PgPool, | ||
| project_id: Uuid, | ||
|
|
@@ -7417,6 +7524,21 @@ async fn run_project_item_workflows( | |
| }, | ||
| ) | ||
| .await?; | ||
| if configuration | ||
| .get("closeOnStatus") | ||
| .and_then(Value::as_bool) | ||
| .unwrap_or(false) | ||
| && value.as_str().is_some_and(|value| value.eq_ignore_ascii_case("done")) | ||
| { | ||
| close_project_item_linked_issue_on_status( | ||
| pool, | ||
| item, | ||
| input.actor_user_id, | ||
| workflow_id, | ||
| &workflow_key, | ||
| ) | ||
| .await?; | ||
| } | ||
| sqlx::query( | ||
| r#" | ||
| UPDATE project_workflows | ||
|
|
@@ -7435,6 +7557,74 @@ async fn run_project_item_workflows( | |
| Ok(()) | ||
| } | ||
|
|
||
|
|
||
| async fn close_project_item_linked_issue_on_status( | ||
| pool: &PgPool, | ||
| item: &ProjectAutomationItem, | ||
| actor_user_id: Uuid, | ||
| workflow_id: Uuid, | ||
| workflow_key: &str, | ||
| ) -> Result<(), ProjectsError> { | ||
| let Some(issue_id) = item.issue_id else { | ||
| return Ok(()); | ||
| }; | ||
| let idempotency_key = format!("{}:{}:close_on_status", workflow_id, item.item_id); | ||
| if workflow_log_exists(pool, item.project_id, &idempotency_key).await? { | ||
| return Ok(()); | ||
| } | ||
| let updated = sqlx::query( | ||
| r#" | ||
| UPDATE issues | ||
| SET state = 'closed', | ||
| closed_by_user_id = $2, | ||
| closed_at = COALESCE(closed_at, now()), | ||
| updated_at = now() | ||
| WHERE id = $1 AND state <> 'closed' | ||
|
Comment on lines
+7578
to
+7582
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a project writer adds an issue they can only read (allowed by Useful? React with πΒ / π. |
||
| "#, | ||
| ) | ||
| .bind(issue_id) | ||
| .bind(actor_user_id) | ||
| .execute(pool) | ||
| .await? | ||
| .rows_affected(); | ||
| if updated == 0 { | ||
| return Ok(()); | ||
| } | ||
| record_project_item_event( | ||
| pool, | ||
| item.project_id, | ||
| item.item_id, | ||
| actor_user_id, | ||
| "project.workflow.close_on_status", | ||
| json!({ | ||
| "workflowId": workflow_id, | ||
| "workflowKey": workflow_key, | ||
| "issueId": issue_id, | ||
| "actor": "@opengithub-project-automation", | ||
| }), | ||
| ) | ||
| .await?; | ||
| record_workflow_execution( | ||
| pool, | ||
| &WorkflowExecutionRecord { | ||
| project_id: item.project_id, | ||
| workflow_id: Some(workflow_id), | ||
| item_id: Some(item.item_id), | ||
| actor_user_id: Some(actor_user_id), | ||
| source: "system", | ||
| event_type: "close_on_status", | ||
| status: "success", | ||
| message: "Project workflow closed the linked issue after status matched.", | ||
| metadata: json!({ | ||
| "workflowKey": workflow_key, | ||
| "idempotencyKey": idempotency_key, | ||
| "issueId": issue_id, | ||
| }), | ||
| }, | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| fn workflow_condition_matches( | ||
| configuration: &Value, | ||
| event: ProjectAutomationEvent, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an item-added workflow sets status to Done with
closeOnStatus, this call can close the issue row created above, butcreate_issuestill indexes and returns the pre-automationIssuevalue. In that scenario the POST/issuesresponse and search metadata reportstate: openeven though the database row is already closed.Useful? React with πΒ / π.