Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/api/examples/dashboard_e2e_seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,20 @@ async fn seed_projects_workspace_fixture(
.bind(project_id)
.fetch_one(pool)
.await?;
sqlx::query(
r#"
INSERT INTO project_field_options (project_field_id, name, color, position, description)
VALUES ($1, 'Backlog', 'gray', 1, 'Not started'),
($1, 'In progress', 'blue', 2, 'Actively moving'),
($1, 'Done', 'green', 3, 'Completed work'),
($2, 'P1', 'red', 1, 'Highest priority'),
($2, 'P2', 'orange', 2, 'Normal priority')
"#,
)
.bind(status_field)
.bind(priority_field)
.execute(pool)
.await?;
let iteration_field: Uuid = sqlx::query_scalar(
r#"
INSERT INTO project_fields (project_id, name, field_type, position, settings)
Expand Down
20 changes: 19 additions & 1 deletion crates/api/src/domain/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ use super::{
NotificationDeliveryCheck,
},
permissions::RepositoryRole,
projects::{run_project_item_automation, ProjectAutomationEvent, ProjectAutomationInput},
projects::{
run_project_item_automation, run_project_repository_item_added_automation,
ProjectAutomationEvent, ProjectAutomationInput,
},
repositories::{
get_repository, get_repository_by_owner_name, repository_permission_for_user, Repository,
RepositoryVisibility, RepositoryWatchEvent,
Expand Down Expand Up @@ -848,6 +851,21 @@ pub async fn create_issue(pool: &PgPool, input: CreateIssue) -> Result<Issue, Co
)
.await?;
notify_issue_assignees(pool, &issue, actor_user_id, &assignee_user_ids).await?;
run_project_repository_item_added_automation(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh issues after automation closes them

When an item-added workflow sets status to Done with closeOnStatus, this call can close the issue row created above, but create_issue still indexes and returns the pre-automation Issue value. In that scenario the POST /issues response and search metadata report state: open even though the database row is already closed.

Useful? React with πŸ‘Β / πŸ‘Ž.

pool,
ProjectAutomationInput {
actor_user_id,
repository_id: issue.repository_id,
issue_id: Some(issue.id),
pull_request_id: None,
event: ProjectAutomationEvent::ItemAdded,
},
)
.await
.map_err(|error| match error {
super::projects::ProjectsError::Sqlx(error) => CollaborationError::Sqlx(error),
_ => CollaborationError::IssueNotFound,
})?;
index_issue_search_document(pool, &issue, actor_user_id).await?;
Ok(issue)
}
Expand Down
190 changes: 190 additions & 0 deletions crates/api/src/domain/projects.rs
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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require repo write before closing linked issues

When a project writer adds an issue they can only read (allowed by add_project_item_for_actor via ensure_repository_readable) to a project with closeOnStatus enabled, this direct update closes that repository issue without the RepositoryRole::Write check that update_issue_state enforces. That lets project automation mutate issues in public/read-only repos the actor could not close through the issues API.

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,
Expand Down
Loading