Skip to content
Open
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
40 changes: 33 additions & 7 deletions crates/server/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::collections::HashMap;

use thiserror::Error;

use tasks_github::model::{Issue, PullRequest};
use tasks_github::model::{ClosureReason, Issue, PullRequest};
use crate::model::task::{Task, TaskSource, TaskState};
use crate::workflow::LabelConfig;

Expand Down Expand Up @@ -88,10 +88,7 @@ pub fn issue_to_task(
project_id: &str,
label_config: &LabelConfig,
) -> Option<Task> {
// Skip closed issues.
if issue.state != tasks_github::model::IssueState::Open {
return None;
}
let is_closed = issue.state != tasks_github::model::IssueState::Open;

let issue_label_names: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();

Expand Down Expand Up @@ -121,6 +118,16 @@ pub fn issue_to_task(
task.source_number = Some(issue.number);
task.priority = parse_priority_from_labels(&issue_label_names);

// Import closed issues as terminal tasks so they are tracked as "seen"
// even if closed before the first poll or between poll intervals (#502).
if is_closed {
task.state = match issue.classify_closure() {
Some(ClosureReason::NotPlanned) => TaskState::Cancelled,
_ => TaskState::Completed,
};
return Some(task);
}
Comment on lines +123 to +129
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[IMPORTANT] — Correctness: Unknown closure reason is mapped inconsistently with reconcile_task.

The _ arm here catches ClosureReason::Unknown (issued closed with no state_reason) and maps it to Completed. But reconcile_task (line 297) maps UnknownCancelled:

// reconcile_task
ClosureReason::NotPlanned | ClosureReason::Unknown => TaskState::Cancelled,

This means the terminal state of a task depends purely on timing: the same issue closed with no state_reason becomes Completed if it was already closed at first poll, but Cancelled if it was observed transitioning during reconciliation.

To stay consistent with the reconcile path:

if is_closed {
    task.state = match issue.classify_closure() {
        Some(ClosureReason::PrMerged | ClosureReason::ManualCompletion) => TaskState::Completed,
        _ => TaskState::Cancelled, // NotPlanned, Unknown, and the unreachable None
    };
    return Some(task);
}

A test for the Unknown case (closed with no state_reason) would lock in the expected behavior.


// If any label matches a blocked label, set state to Blocked.
let is_blocked = label_config.blocked.iter().any(|b| issue_label_names.contains(&b.as_str()));
if is_blocked {
Expand Down Expand Up @@ -534,12 +541,31 @@ mod tests {
}

#[test]
fn closed_issue_not_imported() {
fn closed_issue_imported_as_completed() {
let issue = make_issue(55, vec![make_label("bug")], GhIssueState::Closed);
let cfg = default_label_config();

let task = issue_to_task(&issue, "proj-1", &cfg).expect("closed issue should be imported");
assert_eq!(task.state, TaskState::Completed);
}

#[test]
fn closed_issue_not_planned_imported_as_cancelled() {
let mut issue = make_issue(55, vec![make_label("bug")], GhIssueState::Closed);
issue.state_reason = Some(tasks_github::model::IssueStateReason::NotPlanned);
let cfg = default_label_config();

let task = issue_to_task(&issue, "proj-1", &cfg).expect("closed issue should be imported");
assert_eq!(task.state, TaskState::Cancelled);
}

#[test]
fn closed_issue_with_skip_label_not_imported() {
let issue = make_issue(55, vec![make_label(super::SKIP_LABEL)], GhIssueState::Closed);
let cfg = default_label_config();

let task = issue_to_task(&issue, "proj-1", &cfg);
assert!(task.is_none(), "closed issues should be skipped");
assert!(task.is_none(), "closed issue with skip label should still be skipped");
}

#[test]
Expand Down
Loading