# Incident Action TCOCP Field Sync

## Background

Incident postmortem todo actions are now linked to TCOCP Jira issues through `incident_postmortem_todoaction.linked_jira_issue`. The import flow already detects whether the extracted Jira reference contains a TCOCP key. If a TCOCP issue exists, it reuses the issue and currently syncs only the TCOCP status back to the local action.

The new requirement is to treat an existing TCOCP issue as the source of truth for these action fields during postmortem import:

- assignee
- ETA
- priority
- status
- createdat

The manual sync API must also sync the same fields from TCOCP to the incident todo action.

## Current State

Relevant backend code:

- `pkg/service/incident/postmortem_action_import.go`
  - `CreatePostMortemToDoActionsFromFeishuDoc()` extracts actions from a Feishu postmortem document and creates local todo actions.
  - After local creation, it calls `ensureImportedToDoActionTCOCPLink()` for each imported action.
- `pkg/service/incident/incident_tcocp.go`
  - `ensureImportedToDoActionTCOCPLink()` normalizes `linked_jira_issue` to the TCOCP key.
  - If the TCOCP is newly created, it notifies owners.
  - If the TCOCP already exists, it calls `syncImportedExistingTCOCPStatus()`.
  - `syncOneToDoActionModelFromTCOCP()` loads the TCOCP issue and updates only `status`.
- `pkg/controller/incident/incident_controller.go`
  - `POST /incidents/{incident_id}/postmortems/{postmortem_id}/todoactions/sync` calls `SyncToDoActionStatusFromTCOCP()`.

Available local action columns already cover most target fields:

- `persons` maps from TCOCP assignee.
- `deadline` maps from TCOCP ETA.
- `priority` maps from TCOCP priority.
- `status` maps from TCOCP status.

The existing `created_at` field is the local incident action row creation time. It must not be reused for TCOCP creation time because that would corrupt local action lifecycle metadata. Add a separate nullable `tcocp_created_at` field to store the TCOCP issue creation time.

## Goals

1. When importing postmortem actions, if an extracted action already contains a TCOCP issue, populate local action fields from that TCOCP issue after the local row is created.
2. When calling the existing sync API, sync the same fields from each linked TCOCP issue to each local action.
3. Keep newly created TCOCP behavior unchanged unless Jira returns the created issue fields in a reliable shape later.
4. Add a dedicated `tcocp_created_at` column for TCOCP creation time.
5. Keep compatibility with existing action list/update/delete APIs.

## Non-Goals

- Do not introduce a new sync API route.
- Do not backfill all historical actions in this delivery.
- Do not overwrite action `title`, `description`, or `type` from Jira.
- Do not sync non-TCOCP Jira issues.
- Do not change frontend behavior in this delivery.

## Field Mapping

| TCOCP field | Source in `jiraent.IssueInJira` | Local action field | Notes |
| --- | --- | --- | --- |
| assignee | `issue.Assignee.Email` | `persons` | Convert the email to local users with existing `emailsToUsers()`/converter behavior. If email is empty, set no assignee only if product wants clearing; recommended initial behavior is skip empty assignee to avoid deleting existing owners by accident. |
| ETA | Jira custom field `customfield_13303` | `deadline` | `IssueInJira` does not expose custom fields today. Add ETA extraction to Jira issue loading or expose an unknown/custom-field map on `IssueInJira`. |
| priority | `issue.Priority` | `priority` | Store the Jira priority display name directly. |
| status | `issue.Status` | `status` | Reuse `mapTCOCPStatusToToDoActionStatus()`. Unknown status should skip status update but should not block other field updates. |
| createdat | `issue.CreatedAt` | `tcocp_created_at` | Convert Unix seconds to `time.Time`. Do not update local action `created_at`. |

## Schema and API Change

Add a nullable column to `incident_postmortem_todoaction`:

```sql
ALTER TABLE incident_postmortem_todoaction
  ADD COLUMN tcocp_created_at timestamp NULL DEFAULT NULL AFTER linked_jira_issue;
```

Add the field to the model:

```go
type IncidentPostMortemToDoAction struct {
    // existing fields...
    TCOCPCreatedAt *time.Time `gorm:"column:tcocp_created_at;type:timestamp;default null"`
}
```

Add the field to the entity returned by incident action APIs:

```go
type IncidentPostMortemToDoAction struct {
    // existing fields...
    TCOCPCreatedAt *time.Time `json:"tcocp_created_at"`
}
```

Update model/entity converters in both directions so list/get/import responses can expose the TCOCP creation time and so service updates can persist it.

No request field is required for manual create/update in this delivery. `tcocp_created_at` should be maintained by TCOCP sync only.

## Jira Entity Change

`jiraent.IssueInJira` currently exposes `CreatedAt`, `Priority`, `Assignee`, and `Status`, but not ETA.

Add one of these approaches:

1. Preferred: add a typed field to `IssueInJira`.

```go
type IssueInJira struct {
    // existing fields...
    ETA *int64 `json:"eta,omitempty"`
}
```

`pkg/service/jira/issueloader` should read `customfield_13303`, parse it as a Jira date, and store Unix seconds in `ETA`.

2. Alternative: expose a custom-field map.

```go
CustomFields map[string]any `json:"custom_fields,omitempty"`
```

Then incident code parses `customfield_13303`. This is more flexible but leaks Jira custom-field details into downstream services. The typed ETA field is cleaner for this narrow requirement.

Recommended parsing behavior:

- Accept Jira date values formatted as `YYYY-MM-DD`.
- Convert to local action deadline at start of day in UTC, or use the existing date handling convention used by TCOCP issue creation.
- If parsing fails, log and skip deadline update.

## Incident Service Design

Rename the status-only helper to field-sync semantics:

```go
func (s *incidentService) syncImportedExistingTCOCPFields(
    ctx context.Context,
    wdb *gorm.DB,
    action *entity.IncidentPostMortemToDoAction,
) error
```

or replace it directly with:

```go
func (s *incidentService) syncOneToDoActionModelFromTCOCP(
    ctx context.Context,
    wdb *gorm.DB,
    mod *models.IncidentPostMortemToDoAction,
) error
```

The existing method name can stay, but its behavior should no longer be status-only.

Add a small mapper:

```go
type tcocpActionFields struct {
    Persons   *string
    Deadline  *time.Time
    Priority  *string
    Status    *string
    TCOCPCreatedAt *time.Time
}

func (s *incidentService) mapTCOCPIssueToActionFields(ctx context.Context, issue *jiraent.IssueInJira) tcocpActionFields
```

Mapping rules:

- Assignee:
  - If `issue.Assignee != nil` and email is not empty, call `s.emailsToUsers(ctx, []string{issue.Assignee.Email})`.
  - Convert the returned users to the model `persons` format through the existing entity-to-model converter path, or use the same serialization helper used by `IncidentPostMortemToDoAction`.
  - If assignee is nil or local user resolution fails, skip updating `persons`.
- ETA:
  - If ETA is present and parsed, set `deadline`.
  - If ETA is absent, skip updating `deadline` in the first rollout.
- Priority:
  - If non-empty, set `priority`.
- Status:
  - Use `mapTCOCPStatusToToDoActionStatus()`.
  - Unknown TCOCP status should log and skip only status.
- CreatedAt:
  - If `issue.CreatedAt > 0`, set local `tcocp_created_at` to `time.Unix(issue.CreatedAt, 0)`.

Use an update map or a selected struct update containing only fields that should be synced:

```go
updates := map[string]any{}
updates["status"] = status
updates["persons"] = persons
updates["deadline"] = deadline
updates["priority"] = priority
updates["tcocp_created_at"] = tcocpCreatedAt

err := wdb.Model(mod).Updates(updates).Error
```

Only include fields that are actually present and valid. This prevents missing Jira fields from clearing user-entered local data.

After a successful update, update the in-memory model/entity fields so import response and tests observe the new values.

## Import Flow

Keep the import sequence:

```text
extract Feishu action
  -> create local todo action from extracted fields
  -> ensure TCOCP link
  -> if existing TCOCP, sync TCOCP fields to local action
```

When `ensureTCOCPIssueForToDoAction()` returns `Created: false`, call the expanded TCOCP field sync. This replaces the current status-only sync.

Expected behavior:

- Existing TCOCP wins over extracted Feishu values for assignee, ETA, priority, status, and `tcocp_created_at`.
- Extracted Feishu title/description/type remain local values.
- `linked_jira_issue` remains normalized to only the TCOCP key.
- Newly created TCOCP issue behavior stays as-is; the local action fields are already the source used to create the TCOCP.

## Sync API Flow

The current route should remain:

```text
POST /api/v1/devops/incidents/{incident_id}/postmortems/{postmortem_id}/todoactions/sync
```

`SyncToDoActionStatusFromTCOCP()` can be renamed later, but to minimize generated API churn it can keep its public interface and call the expanded sync implementation internally.

Recommended internal rename:

```go
func (s *incidentService) SyncToDoActionFieldsFromTCOCP(ctx context.Context, incidentId uint64, postMortemId *uint64) error
```

Then keep a compatibility wrapper:

```go
func (s *incidentService) SyncToDoActionStatusFromTCOCP(ctx context.Context, incidentId uint64, postMortemId *uint64) error {
    return s.SyncToDoActionFieldsFromTCOCP(ctx, incidentId, postMortemId)
}
```

The sync query should keep existing scoping:

- always filter by `incident_id`
- filter by real `postmortem_id` only when `resolvePostMortemID()` returns a real row
- skip actions without a TCOCP key

## Data Consistency

Because import creates the local action before reading TCOCP fields, the local row may briefly contain extracted values. That is acceptable because the sync happens in the same import request/goroutine immediately after creation.

Partial sync policy:

- Jira fetch failure: return error for manual sync; import should collect the error as it does today.
- Unknown status: do not fail the whole field sync.
- Invalid ETA: do not fail the whole field sync.
- Empty assignee/priority/ETA: skip the field update in the first rollout.

This policy favors preserving local data over clearing fields based on incomplete Jira payloads.

## Test Plan

Unit tests to add or update:

- Import with existing TCOCP syncs:
  - `linked_jira_issue` normalized to `TCOCP-*`
  - `persons` from TCOCP assignee email
  - `deadline` from TCOCP ETA
  - `priority` from TCOCP priority
  - `status` from TCOCP status
  - `tcocp_created_at` from TCOCP created time
- Manual sync API/service syncs the same fields for an existing action.
- Unknown TCOCP status updates other fields but leaves local status unchanged.
- Missing TCOCP assignee/ETA/priority does not clear local fields.
- Invalid ETA is logged and skipped.
- Actions with only non-TCOCP Jira references are skipped by sync.

The existing `TestEnsureImportedToDoActionTCOCPLinkSyncsExistingTCOCPStatus` should be expanded or replaced because it currently validates only status.

SQLite test table definitions must include any columns asserted by the field sync, especially `persons`, `deadline`, `priority`, and `tcocp_created_at`.

## Rollout Plan

1. Add ETA support to Jira issue loading.
2. Add the TCOCP issue to action field mapper.
3. Expand `syncOneToDoActionModelFromTCOCP()` to update all target fields.
4. Update import path to call the expanded sync for existing TCOCP issues.
5. Keep the sync API route unchanged and route it to the expanded sync.
6. Add focused unit tests for import and manual sync.
7. Run targeted backend tests:

```bash
go test ./pkg/service/incident ./pkg/controller/incident
```

## Risks

- A new nullable `tcocp_created_at` column requires a database migration before code that writes it is deployed.
- Confusing local action `created_at` with TCOCP `tcocp_created_at` would corrupt action lifecycle semantics. Implementation and tests should assert that local `created_at` is not changed by TCOCP sync.
- TCOCP ETA is a Jira custom field and is not exposed by the current `IssueInJira` entity. The Jira loader change is required before incident sync can be complete.
- Assignee email from Jira may not map to a local user. The initial implementation should skip owner updates when mapping fails rather than clearing existing owners.
- The existing method/API names mention status only. Keeping them avoids API churn but may be misleading; internal comments should clarify that they now sync TCOCP-backed action fields.

## Open Questions

- Should an empty TCOCP assignee clear local `persons`, or should it preserve existing owners?
- Should an empty TCOCP ETA clear local `deadline`, or preserve the extracted deadline?
- Should newly created TCOCP issues be fetched after creation and synced back, or should local action fields remain authoritative for newly created issues?
- Should frontend labels change from "Sync status" to "Sync from TCOCP" in a separate UI task?
