From 7fa15b867cea0f887ccb5c9a9505a164fc7988fc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 27 May 2026 18:29:51 -0400 Subject: [PATCH 01/10] importer: add deterministic CSV import artifacts --- IMPORT-ARTIFACT.md | 387 ++++++++++ internal/importer/artifact.go | 447 +++++++++++ internal/importer/artifact_test.go | 166 ++++ internal/importer/csvprofiler.go | 731 ++++++++++++++++++ internal/importer/csvprofiler_test.go | 134 ++++ internal/importer/dates.go | 173 +++++ internal/importer/executor.go | 362 +++++++++ internal/importer/executor_test.go | 232 ++++++ internal/importer/followup.go | 177 +++++ internal/importer/followup_test.go | 81 ++ internal/importer/planner.go | 570 ++++++++++++++ internal/importer/planner_test.go | 334 ++++++++ internal/importer/preflight.go | 254 ++++++ internal/importer/preflight_test.go | 130 ++++ internal/importer/repair.go | 102 +++ internal/importer/repair_test.go | 77 ++ internal/importer/status.go | 89 +++ internal/importer/status_test.go | 71 ++ testdata/import/csv/README.md | 33 + .../import/csv/canonical/asana/sample-01.csv | 4 + .../import/csv/canonical/asana/sample-02.csv | 142 ++++ .../import/csv/canonical/asana/sample-03.csv | 4 + .../import/csv/canonical/asana/sample-04.csv | 26 + .../csv/canonical/clickup/sample-01.csv | 134 ++++ .../csv/canonical/clickup/sample-02.csv | 9 + .../csv/canonical/clickup/sample-03.csv | 77 ++ .../csv/canonical/clickup/sample-04.csv | 14 + .../import/csv/canonical/jira/sample-01.csv | 6 + .../import/csv/canonical/jira/sample-02.csv | 102 +++ .../import/csv/canonical/jira/sample-03.csv | 231 ++++++ .../import/csv/canonical/jira/sample-04.csv | 82 ++ .../import/csv/canonical/jira/sample-05.csv | 131 ++++ .../import/csv/canonical/jira/sample-06.csv | 10 + .../import/csv/canonical/linear/sample-01.csv | 37 + .../import/csv/canonical/linear/sample-02.csv | 236 ++++++ .../import/csv/canonical/linear/sample-03.csv | 46 ++ .../import/csv/canonical/linear/sample-04.csv | 46 ++ .../import/csv/canonical/trello/sample-01.csv | 38 + .../asana-dependencies-custom-fields.csv | 3 + .../import/csv/synthetic/asana-simple.csv | 3 + .../import/csv/synthetic/clickup-simple.csv | 3 + .../synthetic/clickup-subtasks-comments.csv | 3 + .../jira-attachments-custom-fields.csv | 3 + testdata/import/csv/synthetic/jira-simple.csv | 3 + .../synthetic/linear-ambiguous-assignees.csv | 3 + .../csv/synthetic/linear-relationships.csv | 3 + .../import/csv/synthetic/linear-simple.csv | 3 + .../import/csv/synthetic/random/sample-01.csv | 13 + .../import/csv/synthetic/random/sample-02.csv | 16 + .../import/csv/synthetic/random/sample-03.csv | 14 + .../import/csv/synthetic/random/sample-04.csv | 13 + .../import/csv/synthetic/random/sample-05.csv | 13 + .../import/csv/synthetic/random/sample-06.csv | 15 + .../import/csv/synthetic/random/sample-07.csv | 13 + .../import/csv/synthetic/random/sample-08.csv | 15 + .../import/csv/synthetic/random/sample-09.csv | 13 + .../import/csv/synthetic/random/sample-10.csv | 13 + .../import/csv/synthetic/random/sample-11.csv | 14 + .../import/csv/synthetic/random/sample-12.csv | 15 + .../import/csv/synthetic/random/sample-13.csv | 16 + .../import/csv/synthetic/random/sample-14.csv | 14 + .../import/csv/synthetic/random/sample-15.csv | 14 + .../import/csv/synthetic/random/sample-16.csv | 14 + .../import/csv/synthetic/random/sample-17.csv | 15 + .../import/csv/synthetic/random/sample-18.csv | 15 + .../import/csv/synthetic/random/sample-19.csv | 14 + .../import/csv/synthetic/random/sample-20.csv | 14 + .../import/csv/synthetic/random/sample-21.csv | 15 + .../import/csv/synthetic/random/sample-22.csv | 15 + .../import/csv/synthetic/random/sample-23.csv | 13 + .../import/csv/synthetic/random/sample-24.csv | 15 + .../import/csv/synthetic/random/sample-25.csv | 14 + .../import/csv/synthetic/random/sample-26.csv | 14 + .../import/csv/synthetic/random/sample-27.csv | 15 + .../import/csv/synthetic/random/sample-28.csv | 20 + .../import/csv/synthetic/random/sample-29.csv | 15 + .../import/csv/synthetic/random/sample-30.csv | 14 + 77 files changed, 6385 insertions(+) create mode 100644 IMPORT-ARTIFACT.md create mode 100644 internal/importer/artifact.go create mode 100644 internal/importer/artifact_test.go create mode 100644 internal/importer/csvprofiler.go create mode 100644 internal/importer/csvprofiler_test.go create mode 100644 internal/importer/dates.go create mode 100644 internal/importer/executor.go create mode 100644 internal/importer/executor_test.go create mode 100644 internal/importer/followup.go create mode 100644 internal/importer/followup_test.go create mode 100644 internal/importer/planner.go create mode 100644 internal/importer/planner_test.go create mode 100644 internal/importer/preflight.go create mode 100644 internal/importer/preflight_test.go create mode 100644 internal/importer/repair.go create mode 100644 internal/importer/repair_test.go create mode 100644 internal/importer/status.go create mode 100644 internal/importer/status_test.go create mode 100644 testdata/import/csv/README.md create mode 100644 testdata/import/csv/canonical/asana/sample-01.csv create mode 100644 testdata/import/csv/canonical/asana/sample-02.csv create mode 100644 testdata/import/csv/canonical/asana/sample-03.csv create mode 100644 testdata/import/csv/canonical/asana/sample-04.csv create mode 100644 testdata/import/csv/canonical/clickup/sample-01.csv create mode 100644 testdata/import/csv/canonical/clickup/sample-02.csv create mode 100644 testdata/import/csv/canonical/clickup/sample-03.csv create mode 100644 testdata/import/csv/canonical/clickup/sample-04.csv create mode 100644 testdata/import/csv/canonical/jira/sample-01.csv create mode 100644 testdata/import/csv/canonical/jira/sample-02.csv create mode 100644 testdata/import/csv/canonical/jira/sample-03.csv create mode 100644 testdata/import/csv/canonical/jira/sample-04.csv create mode 100644 testdata/import/csv/canonical/jira/sample-05.csv create mode 100644 testdata/import/csv/canonical/jira/sample-06.csv create mode 100644 testdata/import/csv/canonical/linear/sample-01.csv create mode 100644 testdata/import/csv/canonical/linear/sample-02.csv create mode 100644 testdata/import/csv/canonical/linear/sample-03.csv create mode 100644 testdata/import/csv/canonical/linear/sample-04.csv create mode 100644 testdata/import/csv/canonical/trello/sample-01.csv create mode 100644 testdata/import/csv/synthetic/asana-dependencies-custom-fields.csv create mode 100644 testdata/import/csv/synthetic/asana-simple.csv create mode 100644 testdata/import/csv/synthetic/clickup-simple.csv create mode 100644 testdata/import/csv/synthetic/clickup-subtasks-comments.csv create mode 100644 testdata/import/csv/synthetic/jira-attachments-custom-fields.csv create mode 100644 testdata/import/csv/synthetic/jira-simple.csv create mode 100644 testdata/import/csv/synthetic/linear-ambiguous-assignees.csv create mode 100644 testdata/import/csv/synthetic/linear-relationships.csv create mode 100644 testdata/import/csv/synthetic/linear-simple.csv create mode 100644 testdata/import/csv/synthetic/random/sample-01.csv create mode 100644 testdata/import/csv/synthetic/random/sample-02.csv create mode 100644 testdata/import/csv/synthetic/random/sample-03.csv create mode 100644 testdata/import/csv/synthetic/random/sample-04.csv create mode 100644 testdata/import/csv/synthetic/random/sample-05.csv create mode 100644 testdata/import/csv/synthetic/random/sample-06.csv create mode 100644 testdata/import/csv/synthetic/random/sample-07.csv create mode 100644 testdata/import/csv/synthetic/random/sample-08.csv create mode 100644 testdata/import/csv/synthetic/random/sample-09.csv create mode 100644 testdata/import/csv/synthetic/random/sample-10.csv create mode 100644 testdata/import/csv/synthetic/random/sample-11.csv create mode 100644 testdata/import/csv/synthetic/random/sample-12.csv create mode 100644 testdata/import/csv/synthetic/random/sample-13.csv create mode 100644 testdata/import/csv/synthetic/random/sample-14.csv create mode 100644 testdata/import/csv/synthetic/random/sample-15.csv create mode 100644 testdata/import/csv/synthetic/random/sample-16.csv create mode 100644 testdata/import/csv/synthetic/random/sample-17.csv create mode 100644 testdata/import/csv/synthetic/random/sample-18.csv create mode 100644 testdata/import/csv/synthetic/random/sample-19.csv create mode 100644 testdata/import/csv/synthetic/random/sample-20.csv create mode 100644 testdata/import/csv/synthetic/random/sample-21.csv create mode 100644 testdata/import/csv/synthetic/random/sample-22.csv create mode 100644 testdata/import/csv/synthetic/random/sample-23.csv create mode 100644 testdata/import/csv/synthetic/random/sample-24.csv create mode 100644 testdata/import/csv/synthetic/random/sample-25.csv create mode 100644 testdata/import/csv/synthetic/random/sample-26.csv create mode 100644 testdata/import/csv/synthetic/random/sample-27.csv create mode 100644 testdata/import/csv/synthetic/random/sample-28.csv create mode 100644 testdata/import/csv/synthetic/random/sample-29.csv create mode 100644 testdata/import/csv/synthetic/random/sample-30.csv diff --git a/IMPORT-ARTIFACT.md b/IMPORT-ARTIFACT.md new file mode 100644 index 00000000..d0727ec6 --- /dev/null +++ b/IMPORT-ARTIFACT.md @@ -0,0 +1,387 @@ +# Basecamp Import Artifact v1 + +`basecamp-import-csv-v1` is the stable artifact format produced by `basecamp import compile` for CSV-to-Basecamp todo imports. The artifact is a durable checkpoint between CSV inspection and approved Basecamp writes. + +A compiled artifact contains: + +```text +basecamp-import/ +├── import.json +└── todos.csv +``` + +Execution adds a local ledger: + +```text +basecamp-import/ +├── import.json +├── todos.csv +└── execution.json +``` + +The artifact schema is the same for every CSV import that uses `basecamp-import-csv-v1`. Import-specific values such as source fingerprints, destinations, todo titles, due dates, and preserved metadata live inside the fixed schema. + +## Lifecycle + +```bash +basecamp import inspect ./tasks.csv --json > inspection.json +basecamp import compile \ + --inspection inspection.json \ + --mapping mapping.json \ + --destination destination.json \ + --out basecamp-import/ \ + --json +basecamp import plan --artifact basecamp-import/ --json +basecamp import status --artifact basecamp-import/ --json +basecamp import repair --artifact basecamp-import/ --json +basecamp import followup --artifact basecamp-import/ --out followup-import/ --reviewed --json +basecamp import preflight --artifact basecamp-import/ --json +basecamp import execute --artifact basecamp-import/ --approved --json +``` + +`compile` validates the source CSV fingerprint, confirmed mapping, destination, titles, due dates, and artifact shape before writing the artifact. `plan --artifact`, `status --artifact`, `repair --artifact`, `followup --artifact`, `preflight --artifact`, and `execute --artifact` read the artifact as their source of truth. + +## `import.json` + +`import.json` is the artifact manifest. + +Example: + +```json +{ + "schema_version": 1, + "status": "compiled", + "artifact_format": "basecamp-import-csv-v1", + "source_path": "./tasks.csv", + "source_fingerprint": { + "algorithm": "sha256-file-v1", + "value": "..." + }, + "destination": { + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" + }, + "counts": { + "projects": 0, + "todolists": 2, + "todos": 42 + }, + "files": { + "todos": "todos.csv" + } +} +``` + +Fields: + +| Field | Type | Description | +|---|---:|---| +| `schema_version` | integer | Manifest schema version. v1 uses `1`. | +| `status` | string | Artifact status. Compiled artifacts use `compiled`. | +| `artifact_format` | string | Artifact format identifier. v1 uses `basecamp-import-csv-v1`. | +| `source_path` | string | Source CSV path inspected and compiled into the artifact. | +| `source_fingerprint.algorithm` | string | Fingerprint algorithm. v1 uses `sha256-file-v1`. | +| `source_fingerprint.value` | string | SHA-256 fingerprint of the inspected source CSV. | +| `destination` | object | Basecamp destination selected for execution. | +| `counts.projects` | integer | Number of projects execution creates. | +| `counts.todolists` | integer | Number of todolists execution creates. | +| `counts.todos` | integer | Number of todos execution creates. | +| `files.todos` | string | Relative path to the canonical todo CSV. v1 uses `todos.csv`. | + +## Destination modes + +### Existing project + +Creates todolists and todos inside an existing Basecamp project. + +```json +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} +``` + +`project_id` identifies the destination project. `project_name` may be present as display context. + +### New project + +Creates a Basecamp project, then creates todolists and todos inside it. + +```json +{ + "schema_version": 1, + "mode": "new_project", + "project_name": "Imported launch tasks", + "todolist_strategy": "create_from_column" +} +``` + +`project_name` provides the project name created during execution. + +## Todolist strategies + +| Strategy | Behavior | +|---|---| +| `create_from_column` | Creates one todolist for each distinct mapped todolist value. Blank todolist values use `Imported todos`. | +| `single_todolist` | Creates one todolist named by `todolist_name`, or `Imported todos` when the name is blank. | +| `existing_todolist` | Creates todos in the todolist identified by `todolist_id`. | + +## `todos.csv` + +`todos.csv` contains one normalized todo row per source CSV row selected for import. The header is fixed and validated exactly. + +```csv +source_path,source_row,source_record_id,project_id,project_name,todolist_id,todolist_name,title,description,due_on,assignee_emails,assignee_names,status,attachment_urls_json,comments_json,custom_fields_json +``` + +Columns: + +| Column | Type | Description | +|---|---:|---| +| `source_path` | string | Source CSV path for row provenance. | +| `source_row` | integer | One-based source data row number, excluding the header row. | +| `source_record_id` | string | Stable source record ID when mapped. | +| `project_id` | string | Destination project ID for existing-project imports. | +| `project_name` | string | Destination project name for display or new-project imports. | +| `todolist_id` | integer string | Destination todolist ID for existing-todolist imports. Blank means execution creates or resolves the list from the artifact destination. | +| `todolist_name` | string | Destination todolist name. Blank values resolve to `Imported todos` for created lists. | +| `title` | string | Basecamp todo title. Compile requires a non-blank title. | +| `description` | string | Basecamp todo description from the mapped source description column. | +| `due_on` | string | Due date normalized to `YYYY-MM-DD`, or blank. | +| `assignee_emails` | semicolon list | Source assignee emails preserved as metadata. | +| `assignee_names` | semicolon list | Source assignee display names preserved as metadata. | +| `status` | string | Source status preserved as metadata. | +| `attachment_urls_json` | JSON array | Source attachment/link URLs preserved as metadata. | +| `comments_json` | JSON array | Source comments preserved as metadata. | +| `custom_fields_json` | JSON object | Non-empty unmapped source columns preserved as metadata when requested by mapping. | + +### JSON columns + +The JSON columns contain valid JSON values: + +```csv +attachment_urls_json,comments_json,custom_fields_json +"[""https://example.com/a""]","[""Original comment""]","{""priority"":""High""}" +``` + +Readers validate these columns as JSON arrays or objects before planning or execution. + +## Due dates + +Artifact due dates use `YYYY-MM-DD`. + +Compile accepts and normalizes deterministic date values, including: + +- `YYYY-MM-DD` +- RFC3339 timestamps such as `2026-06-01T15:04:05Z` +- `YYYY/MM/DD` +- Month-name dates such as `June 1, 2026` and `1 June 2026` +- Slash dates with an inferred or confirmed order + +For slash dates, compile uses the whole mapped due-date column: + +- A value with the second component greater than `12` confirms `mdy`, such as `06/18/2026`. +- A value with the first component greater than `12` confirms `dmy`, such as `18/06/2026`. +- A mapping can confirm the convention explicitly: + +```json +{ + "due_on": { + "column_index": 6, + "column_name": "Due Date", + "date_order": "mdy" + } +} +``` + +`date_order` accepts `mdy` or `dmy`. + +Compile reports source-row context for invalid, conflicting, or ambiguous due dates so the mapping or source data can be corrected before planning or execution. + +## Mapping contract + +`mapping.json` records user-confirmed source column choices. Column indexes are authoritative because CSV files can contain duplicate header names. + +Example: + +```json +{ + "schema_version": 1, + "record_id": { "column_index": 0, "column_name": "Task ID" }, + "title": { "column_index": 1, "column_name": "Task Name" }, + "description": { "column_index": 2, "column_name": "Notes" }, + "todolist": { "column_index": 3, "column_name": "List" }, + "due_on": { "column_index": 4, "column_name": "Due Date", "date_order": "mdy" }, + "custom_fields": "all_unmapped_columns" +} +``` + +When `column_name` is present, compile validates that it matches the inspected column name at `column_index`. This confirms that the mapping still points at the inspected column. + +## Validation guarantees + +Artifact readers validate: + +- supported manifest `schema_version` +- supported `artifact_format` +- required artifact files +- exact `todos.csv` header +- manifest todo count against CSV row count +- non-blank todo titles +- integer fields such as `source_row` and `todolist_id` +- JSON array/object columns +- destination fields required for execution + +## Status behavior + +`basecamp import status --artifact ...` reads local artifact files and reports execution state without Basecamp access. + +Status values: + +| Status | Meaning | +|---|---| +| `not_executed` | The artifact has no `execution.json` ledger. | +| `completed` | Execution completed and the artifact cannot be executed again. | +| `failed` | Execution failed and Basecamp may contain partial writes. | +| `started` | Execution started and no completion/failure was recorded. | +| `ledger_unreadable` | `execution.json` exists but cannot be parsed. | + +The status result includes manifest counts, destination details, execution ledger details when present, and guidance for the next safe action. + +## Repair behavior + +`basecamp import repair --artifact ...` reads local artifact files and execution operation records without Basecamp access. It summarizes: + +- completed operations and created IDs +- failed operations and error text +- pending todo rows with no completed create-todo operation +- guidance for manual review before creating a fresh follow-up artifact + +Repair is a review command. It does not resume execution and does not modify the artifact. + +## Follow-up behavior + +`basecamp import followup --artifact ... --out ... --reviewed` creates a fresh artifact containing pending todo rows from a reviewed failed execution ledger. + +Follow-up artifacts: + +- preserve source row provenance and metadata for pending todos +- pin pending rows to existing/created Basecamp project and todolist IDs from the source artifact and ledger +- contain no `execution.json` +- require `--reviewed` to confirm the operator reviewed Basecamp state and the repair summary + +Plan and preflight the follow-up artifact before approved execution. The source artifact remains closed and must not be rerun. + +## Preflight behavior + +`basecamp import preflight --artifact ...` checks execution readiness without creating Basecamp records. + +Preflight validates: + +- the artifact has no `execution.json` ledger +- the destination project can be read when execution will create todolists in an existing project +- planned todolist names do not already exist in the destination project +- planned todo titles do not already exist when importing into an existing todolist + +A passed preflight returns `status: "passed"`. A blocked preflight returns `status: "blocked"` with checks, todolist collisions, and todo title collisions that explain what to resolve before execution. + +## Execution behavior + +`basecamp import execute --artifact ... --approved` runs preflight checks, then performs Basecamp writes described by the artifact: + +- creates a project for `new_project` destinations +- creates todolists for `create_from_column` and `single_todolist` strategies +- creates todos with title, description, and due date +- appends preserved source metadata to todo descriptions +- reports created project, todolist, and todo counts +- reports source fields preserved as metadata rather than native Basecamp fields +- writes `execution.json` before creating Basecamp records and updates it when execution completes or fails +- records each completed or failed project, todolist, and todo operation with created Basecamp IDs when available + +Execution refuses to run when preflight reports blockers or when `execution.json` already exists. This prevents repeated artifact execution from silently creating duplicate Basecamp records and prevents todolist name collisions from creating duplicate destination lists. + +## `execution.json` + +`execution.json` records the execution attempt for the artifact directory. + +Completed example: + +```json +{ + "schema_version": 1, + "artifact_format": "basecamp-import-csv-v1", + "status": "completed", + "source_fingerprint": { + "algorithm": "sha256-file-v1", + "value": "..." + }, + "started_at": "2026-05-27T12:00:00Z", + "completed_at": "2026-05-27T12:00:03Z", + "created": { + "projects": 0, + "todolists": 2, + "todos": 42 + }, + "operations": [ + { + "op": "create_todo", + "status": "completed", + "source_row": 1, + "source_record_id": "T-1", + "project_id": 12345, + "todolist_id": 67890, + "todolist_name": "Imported todos", + "title": "Buy paint", + "created_id": 111, + "at": "2026-05-27T12:00:01Z" + } + ] +} +``` + +Failed example: + +```json +{ + "schema_version": 1, + "artifact_format": "basecamp-import-csv-v1", + "status": "failed", + "source_fingerprint": { + "algorithm": "sha256-file-v1", + "value": "..." + }, + "started_at": "2026-05-27T12:00:00Z", + "failed_at": "2026-05-27T12:00:02Z", + "created": { + "projects": 0, + "todolists": 2, + "todos": 1 + }, + "operations": [ + { + "op": "create_todo", + "status": "failed", + "source_row": 2, + "project_id": 12345, + "todolist_id": 67890, + "todolist_name": "Imported todos", + "title": "Book venue", + "at": "2026-05-27T12:00:02Z", + "error": "create todo from source row 2: ..." + } + ], + "error": "create todo from source row 2: ..." +} +``` + +A failed ledger indicates that Basecamp may contain partial writes from that artifact. Review the ledger operations and Basecamp state before creating a fresh artifact for any follow-up execution. + +## Versioning + +`artifact_format` identifies the artifact contract. `basecamp-import-csv-v1` keeps the v1 manifest fields, `todos.csv` header, and validation behavior stable. + +A future artifact shape uses a new format identifier, such as `basecamp-import-csv-v2`, so v1 artifacts remain recognizable and validated by v1-aware readers. diff --git a/internal/importer/artifact.go b/internal/importer/artifact.go new file mode 100644 index 00000000..d1318ff5 --- /dev/null +++ b/internal/importer/artifact.go @@ -0,0 +1,447 @@ +package importer + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + artifactFormat = "basecamp-import-csv-v1" + artifactManifestName = "import.json" + artifactTodosFileName = "todos.csv" +) + +var artifactTodoHeader = []string{ + "source_path", + "source_row", + "source_record_id", + "project_id", + "project_name", + "todolist_id", + "todolist_name", + "title", + "description", + "due_on", + "assignee_emails", + "assignee_names", + "status", + "attachment_urls_json", + "comments_json", + "custom_fields_json", +} + +// ImportArtifactManifest describes a validated Basecamp import CSV artifact. +type ImportArtifactManifest struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + ArtifactFormat string `json:"artifact_format"` + SourcePath string `json:"source_path"` + SourceFingerprint Fingerprint `json:"source_fingerprint"` + Destination DestinationConfig `json:"destination"` + Counts PlanCounts `json:"counts"` + Files ArtifactFiles `json:"files"` +} + +// ArtifactFiles names the files that belong to an import artifact. +type ArtifactFiles struct { + Todos string `json:"todos"` +} + +// CompileArtifactResult reports the artifact written by CompileArtifact. +type CompileArtifactResult struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + ArtifactPath string `json:"artifact_path"` + Manifest ImportArtifactManifest `json:"manifest"` +} + +type artifactTodoRow struct { + SourcePath string `json:"source_path"` + SourceRow int `json:"source_row"` + SourceRecordID string `json:"source_record_id"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + TodolistID int64 `json:"todolist_id"` + TodolistName string `json:"todolist_name"` + Title string `json:"title"` + Description string `json:"description"` + DueOn string `json:"due_on"` + AssigneeEmails []string `json:"assignee_emails"` + AssigneeNames []string `json:"assignee_names"` + Status string `json:"status"` + AttachmentURLs []string `json:"attachment_urls"` + Comments []string `json:"comments"` + CustomFields map[string]string `json:"custom_fields"` +} + +// CompileArtifact writes a validated Basecamp import CSV artifact from confirmed import inputs. +func CompileArtifact(inspection *Inspection, mapping *MappingConfig, destination *DestinationConfig, outDir string) (*CompileArtifactResult, error) { + if strings.TrimSpace(outDir) == "" { + return nil, fmt.Errorf("artifact output directory is required") + } + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + return nil, err + } + if plan.RequiresUserInput { + return nil, fmt.Errorf("import artifact requires confirmed mapping and destination choices") + } + + rows := make([]artifactTodoRow, 0, plan.Counts.Todos) + for _, op := range plan.Operations { + if op.Op != "create_todo" { + continue + } + if strings.TrimSpace(op.Title) == "" { + return nil, fmt.Errorf("source row %d has a blank title", op.SourceRow) + } + emails, names := splitAssignees(op.Assignees) + todolistID, err := parseOptionalInt64(op.TodolistID) + if err != nil { + return nil, fmt.Errorf("source row %d has invalid todolist_id: %w", op.SourceRow, err) + } + rows = append(rows, artifactTodoRow{ + SourcePath: inspection.ExportPath, + SourceRow: op.SourceRow, + SourceRecordID: op.SourceRecordID, + ProjectID: op.ProjectID, + ProjectName: op.ProjectName, + TodolistID: todolistID, + TodolistName: op.TodolistName, + Title: op.Title, + Description: op.Description, + DueOn: op.DueOn, + AssigneeEmails: emails, + AssigneeNames: names, + Status: op.Status, + AttachmentURLs: op.AttachmentURLs, + Comments: op.Comments, + CustomFields: op.CustomFields, + }) + } + + manifest := ImportArtifactManifest{ + SchemaVersion: planSchemaVersion, + Status: "compiled", + ArtifactFormat: artifactFormat, + SourcePath: inspection.ExportPath, + SourceFingerprint: inspection.Fingerprint, + Destination: *destination, + Counts: plan.Counts, + Files: ArtifactFiles{Todos: artifactTodosFileName}, + } + if err := writeArtifact(outDir, manifest, rows); err != nil { + return nil, err + } + return &CompileArtifactResult{SchemaVersion: planSchemaVersion, Status: "compiled", ArtifactPath: outDir, Manifest: manifest}, nil +} + +// PlanFromArtifact builds a deterministic dry-run from a validated Basecamp import CSV artifact. +func PlanFromArtifact(artifactDir string) (*Plan, error) { + manifest, rows, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + plan := &Plan{ + SchemaVersion: planSchemaVersion, + Status: "ready_for_approval", + RequiresUserInput: false, + SourceFingerprint: manifest.SourceFingerprint, + Destination: manifest.Destination, + Counts: manifest.Counts, + Warnings: []ImportWarning{}, + Questions: []MappingQuestion{}, + } + + operations := make([]PlannedOperation, 0, len(rows)+manifest.Counts.Todolists+manifest.Counts.Projects) + if manifest.Destination.Mode == "new_project" { + operations = append(operations, PlannedOperation{Op: "create_project", ProjectName: manifest.Destination.ProjectName}) + } + if shouldCreateTodolists(&manifest.Destination) { + for _, name := range artifactTodolistNames(rows) { + operations = append(operations, PlannedOperation{Op: "create_todolist", ProjectID: manifest.Destination.ProjectID, ProjectName: manifest.Destination.ProjectName, TodolistName: name}) + } + } + for _, row := range rows { + operations = append(operations, PlannedOperation{ + Op: "create_todo", + SourceRow: row.SourceRow, + SourceRecordID: row.SourceRecordID, + ProjectID: row.ProjectID, + ProjectName: row.ProjectName, + TodolistID: formatOptionalInt64(row.TodolistID), + TodolistName: row.TodolistName, + Title: row.Title, + Description: row.Description, + Status: row.Status, + DueOn: row.DueOn, + Assignees: append(append([]string{}, row.AssigneeEmails...), row.AssigneeNames...), + AttachmentURLs: row.AttachmentURLs, + Comments: row.Comments, + CustomFields: row.CustomFields, + }) + } + plan.Operations = operations + plan.DryRunMarkdown = renderDryRunMarkdown(plan) + return plan, nil +} + +func readArtifact(artifactDir string) (*ImportArtifactManifest, []artifactTodoRow, error) { + manifestPath := filepath.Join(artifactDir, artifactManifestName) + var manifest ImportArtifactManifest + if err := readJSONData(manifestPath, &manifest); err != nil { + return nil, nil, err + } + if manifest.SchemaVersion != planSchemaVersion { + return nil, nil, fmt.Errorf("unsupported artifact schema_version %d", manifest.SchemaVersion) + } + if manifest.ArtifactFormat != artifactFormat { + return nil, nil, fmt.Errorf("unsupported artifact format %q", manifest.ArtifactFormat) + } + if manifest.Files.Todos == "" { + return nil, nil, fmt.Errorf("artifact todos file is required") + } + rows, err := readArtifactTodos(filepath.Join(artifactDir, manifest.Files.Todos)) + if err != nil { + return nil, nil, err + } + if len(rows) != manifest.Counts.Todos { + return nil, nil, fmt.Errorf("artifact todo count %d does not match manifest count %d", len(rows), manifest.Counts.Todos) + } + return &manifest, rows, nil +} + +func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifactTodoRow) error { + if err := os.MkdirAll(outDir, 0o755); err != nil { //nolint:gosec // G301: Import artifacts are user-readable project files + return fmt.Errorf("create artifact directory: %w", err) + } + manifestData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encode artifact manifest: %w", err) + } + manifestData = append(manifestData, '\n') + if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), manifestData, 0o644); err != nil { //nolint:gosec // G306: Import artifact manifests are not secrets + return fmt.Errorf("write artifact manifest: %w", err) + } + if err := writeArtifactTodos(filepath.Join(outDir, artifactTodosFileName), rows); err != nil { + return err + } + return nil +} + +func writeArtifactTodos(path string, rows []artifactTodoRow) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("write artifact todos: %w", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + if err := writer.Write(artifactTodoHeader); err != nil { + return fmt.Errorf("write artifact todos header: %w", err) + } + for _, row := range rows { + record, err := row.toCSVRecord() + if err != nil { + return err + } + if err := writer.Write(record); err != nil { + return fmt.Errorf("write artifact todos row: %w", err) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return fmt.Errorf("write artifact todos: %w", err) + } + return nil +} + +func readArtifactTodos(path string) ([]artifactTodoRow, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read artifact todos: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.FieldsPerRecord = len(artifactTodoHeader) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse artifact todos: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("artifact todos file is empty") + } + if strings.Join(records[0], "\x00") != strings.Join(artifactTodoHeader, "\x00") { + return nil, fmt.Errorf("artifact todos header does not match Basecamp import CSV v1") + } + rows := make([]artifactTodoRow, 0, len(records)-1) + for i, record := range records[1:] { + row, err := artifactTodoRowFromCSVRecord(record) + if err != nil { + return nil, fmt.Errorf("artifact todos row %d: %w", i+1, err) + } + if strings.TrimSpace(row.Title) == "" { + return nil, fmt.Errorf("artifact todos row %d has a blank title", i+1) + } + rows = append(rows, row) + } + return rows, nil +} + +func (r artifactTodoRow) toCSVRecord() ([]string, error) { + attachments, err := encodeJSONStringSlice(r.AttachmentURLs) + if err != nil { + return nil, err + } + comments, err := encodeJSONStringSlice(r.Comments) + if err != nil { + return nil, err + } + customFields, err := encodeJSONStringMap(r.CustomFields) + if err != nil { + return nil, err + } + return []string{ + r.SourcePath, + fmt.Sprintf("%d", r.SourceRow), + r.SourceRecordID, + r.ProjectID, + r.ProjectName, + formatOptionalInt64(r.TodolistID), + r.TodolistName, + r.Title, + r.Description, + r.DueOn, + strings.Join(r.AssigneeEmails, ";"), + strings.Join(r.AssigneeNames, ";"), + r.Status, + attachments, + comments, + customFields, + }, nil +} + +func artifactTodoRowFromCSVRecord(record []string) (artifactTodoRow, error) { + var row artifactTodoRow + row.SourcePath = record[0] + if _, err := fmt.Sscanf(record[1], "%d", &row.SourceRow); err != nil { + return row, fmt.Errorf("invalid source_row %q", record[1]) + } + row.SourceRecordID = record[2] + row.ProjectID = record[3] + row.ProjectName = record[4] + var err error + row.TodolistID, err = parseOptionalInt64(record[5]) + if err != nil { + return row, fmt.Errorf("invalid todolist_id %q", record[5]) + } + row.TodolistName = record[6] + row.Title = record[7] + row.Description = record[8] + row.DueOn = record[9] + row.AssigneeEmails = splitSemicolonList(record[10]) + row.AssigneeNames = splitSemicolonList(record[11]) + row.Status = record[12] + if err := decodeJSONStringSlice(record[13], &row.AttachmentURLs); err != nil { + return row, fmt.Errorf("invalid attachment_urls_json: %w", err) + } + if err := decodeJSONStringSlice(record[14], &row.Comments); err != nil { + return row, fmt.Errorf("invalid comments_json: %w", err) + } + if err := decodeJSONStringMap(record[15], &row.CustomFields); err != nil { + return row, fmt.Errorf("invalid custom_fields_json: %w", err) + } + return row, nil +} + +func splitAssignees(values []string) ([]string, []string) { + emails := make([]string, 0) + names := make([]string, 0) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if emailRE.MatchString(value) { + emails = append(emails, value) + } else { + names = append(names, value) + } + } + return emails, names +} + +func artifactTodolistNames(rows []artifactTodoRow) []string { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, row := range rows { + name := strings.TrimSpace(row.TodolistName) + if name == "" { + name = "Imported todos" + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + +func encodeJSONStringSlice(values []string) (string, error) { + if values == nil { + values = []string{} + } + data, err := json.Marshal(values) + if err != nil { + return "", fmt.Errorf("encode JSON array: %w", err) + } + return string(data), nil +} + +func encodeJSONStringMap(values map[string]string) (string, error) { + if values == nil { + values = map[string]string{} + } + data, err := json.Marshal(values) + if err != nil { + return "", fmt.Errorf("encode JSON object: %w", err) + } + return string(data), nil +} + +func decodeJSONStringSlice(value string, target *[]string) error { + if strings.TrimSpace(value) == "" { + *target = nil + return nil + } + return json.Unmarshal([]byte(value), target) +} + +func decodeJSONStringMap(value string, target *map[string]string) error { + if strings.TrimSpace(value) == "" { + *target = nil + return nil + } + return json.Unmarshal([]byte(value), target) +} + +func splitSemicolonList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ";") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} diff --git a/internal/importer/artifact_test.go b/internal/importer/artifact_test.go new file mode 100644 index 00000000..980bb915 --- /dev/null +++ b/internal/importer/artifact_test.go @@ -0,0 +1,166 @@ +package importer + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompileArtifactWritesValidatedBasecampImportCSV(t *testing.T) { + inspection := inspectTempCSV(t, `id,title,notes,list,status,owner,due,link,priority +T-1,Buy paint,"Get blue, low VOC",Home,todo,alex@example.com,2026-06-01,https://example.com/a,High +T-2,Book venue,Call two places,Events,todo,jamie@example.com,2026-06-03,https://example.com/b, +`) + mapping := &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0}, + Title: &ColumnRef{ColumnIndex: 1}, + Description: &ColumnRef{ColumnIndex: 2}, + Todolist: &ColumnRef{ColumnIndex: 3}, + Status: &ColumnRef{ColumnIndex: 4}, + Assignees: &ColumnRef{ColumnIndex: 5}, + DueOn: &ColumnRef{ColumnIndex: 6}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 7}}, + CustomFields: "all_unmapped_columns", + } + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"} + outDir := filepath.Join(t.TempDir(), "basecamp-import") + + result, err := CompileArtifact(inspection, mapping, destination, outDir) + if err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + if result.Status != "compiled" || result.Manifest.ArtifactFormat != artifactFormat { + t.Fatalf("unexpected result: %+v", result) + } + if _, err := os.Stat(filepath.Join(outDir, artifactManifestName)); err != nil { + t.Fatalf("manifest missing: %v", err) + } + if _, err := os.Stat(filepath.Join(outDir, artifactTodosFileName)); err != nil { + t.Fatalf("todos CSV missing: %v", err) + } + + manifest, rows, err := readArtifact(outDir) + if err != nil { + t.Fatalf("readArtifact() error = %v", err) + } + if manifest.Counts.Todos != 2 || manifest.Counts.Todolists != 2 { + t.Fatalf("manifest counts = %+v", manifest.Counts) + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0].Title != "Buy paint" || rows[0].AssigneeEmails[0] != "alex@example.com" { + t.Fatalf("first artifact row = %+v", rows[0]) + } + if rows[0].CustomFields["priority"] != "High" { + t.Fatalf("custom fields = %+v", rows[0].CustomFields) + } +} + +func TestPlanFromArtifactMatchesCompiledOperations(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,list\n1,First,Backlog\n2,Second,Doing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, RecordID: &ColumnRef{ColumnIndex: 0}, Title: &ColumnRef{ColumnIndex: 1}, Todolist: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"} + outDir := filepath.Join(t.TempDir(), "artifact") + if _, err := CompileArtifact(inspection, mapping, destination, outDir); err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + + plan, err := PlanFromArtifact(outDir) + if err != nil { + t.Fatalf("PlanFromArtifact() error = %v", err) + } + if plan.Status != "ready_for_approval" || plan.RequiresUserInput { + t.Fatalf("plan = %+v", plan) + } + if plan.Counts.Todolists != 2 || plan.Counts.Todos != 2 { + t.Fatalf("counts = %+v", plan.Counts) + } + if len(plan.Operations) != 4 { + t.Fatalf("operation count = %d, want 4", len(plan.Operations)) + } + if plan.Operations[2].Title != "First" || plan.Operations[2].TodolistName != "Backlog" { + t.Fatalf("first todo op = %+v", plan.Operations[2]) + } + if !strings.Contains(plan.DryRunMarkdown, "Row 1: create todo \"First\"") { + t.Fatalf("dry run markdown missing todo: %s", plan.DryRunMarkdown) + } +} + +func TestCompileArtifactRejectsUnconfirmedInputs(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"} + + _, err := CompileArtifact(inspection, mapping, destination, filepath.Join(t.TempDir(), "artifact")) + if err == nil || !strings.Contains(err.Error(), "requires confirmed") { + t.Fatalf("expected confirmed input error, got %v", err) + } +} + +func TestCompileArtifactRejectsBlankTitle(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"} + + _, err := CompileArtifact(inspection, mapping, destination, filepath.Join(t.TempDir(), "artifact")) + if err == nil || !strings.Contains(err.Error(), "blank title") { + t.Fatalf("expected blank title error, got %v", err) + } +} + +func TestCompileArtifactNormalizesDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,Do the thing,06/18/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + outDir := filepath.Join(t.TempDir(), "artifact") + + _, err := CompileArtifact(inspection, mapping, destination, outDir) + if err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + _, rows, err := readArtifact(outDir) + if err != nil { + t.Fatalf("readArtifact() error = %v", err) + } + if rows[0].DueOn != "2026-06-18" { + t.Fatalf("artifact due_on = %q, want 2026-06-18", rows[0].DueOn) + } +} + +func TestCompileArtifactRejectsAmbiguousDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,Do the thing,06/01/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := CompileArtifact(inspection, mapping, destination, filepath.Join(t.TempDir(), "artifact")) + if err == nil || !strings.Contains(err.Error(), "date_order") { + t.Fatalf("expected date_order error, got %v", err) + } +} + +func TestReadArtifactRejectsTamperedTodoCount(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"} + outDir := filepath.Join(t.TempDir(), "artifact") + if _, err := CompileArtifact(inspection, mapping, destination, outDir); err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + manifestPath := filepath.Join(outDir, artifactManifestName) + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + data = []byte(strings.Replace(string(data), `"todos": 1`, `"todos": 2`, 1)) + if err := os.WriteFile(manifestPath, data, 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + _, _, err = readArtifact(outDir) + if err == nil || !strings.Contains(err.Error(), "does not match manifest") { + t.Fatalf("expected count mismatch error, got %v", err) + } +} diff --git a/internal/importer/csvprofiler.go b/internal/importer/csvprofiler.go new file mode 100644 index 00000000..607f1a85 --- /dev/null +++ b/internal/importer/csvprofiler.go @@ -0,0 +1,731 @@ +// Package importer provides deterministic import inspection and planning helpers. +package importer + +import ( + "bytes" + "crypto/sha256" + "encoding/csv" + "encoding/hex" + "fmt" + "io" + "os" + "regexp" + "sort" + "strings" + "time" + "unicode" +) + +const inspectionSchemaVersion = 1 + +var ( + emailRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`) + urlRE = regexp.MustCompile(`(?i)https?://[^\s,]+`) + idLikeRE = regexp.MustCompile(`(?i)^([a-z]+-)?[a-z0-9][a-z0-9_.:\-/]{1,79}$`) +) + +// InspectOptions controls CSV inspection detail. +type InspectOptions struct { + SampleSize int +} + +// Inspection describes a profiled CSV export. +type Inspection struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Format string `json:"format"` + ExportPath string `json:"export_path"` + Fingerprint Fingerprint `json:"fingerprint"` + Dialect CSVDialect `json:"dialect"` + RowCount int `json:"row_count"` + Columns []ColumnProfile `json:"columns"` + DuplicateHeaders []DuplicateHeader `json:"duplicate_headers"` + RoleCandidates map[string][]RoleCandidate `json:"role_candidates"` + SampleRows []SampleRow `json:"sample_rows"` + Warnings []ImportWarning `json:"warnings"` + Questions []MappingQuestion `json:"questions"` +} + +// Fingerprint identifies the exact inspected input bytes. +type Fingerprint struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` +} + +// CSVDialect records the parsing choices used for the CSV. +type CSVDialect struct { + Delimiter string `json:"delimiter"` + HasHeader bool `json:"has_header"` + Encoding string `json:"encoding"` +} + +// ColumnProfile contains deterministic statistics and generic shape evidence for one CSV column. +type ColumnProfile struct { + Index int `json:"index"` + Name string `json:"name"` + NormalizedName string `json:"normalized_name"` + NonEmptyCount int `json:"non_empty_count"` + UniqueCount int `json:"unique_count"` + DuplicateName bool `json:"duplicate_name"` + LooksLike []string `json:"looks_like,omitempty"` + Evidence []string `json:"evidence,omitempty"` +} + +// DuplicateHeader reports a repeated header while preserving every column index. +type DuplicateHeader struct { + Name string `json:"name"` + Indexes []int `json:"indexes"` +} + +// RoleCandidate describes a column that can fill an import mapping role. +type RoleCandidate struct { + ColumnIndex int `json:"column_index"` + ColumnName string `json:"column_name"` + Confidence float64 `json:"confidence"` + Evidence []string `json:"evidence"` +} + +// SampleRow exposes bounded source values for mapping confirmation. +type SampleRow struct { + RowNumber int `json:"row_number"` + ValuesByIndex map[string]string `json:"values_by_index"` +} + +// ImportWarning highlights inspection facts that affect safe mapping. +type ImportWarning struct { + Code string `json:"code"` + Columns []int `json:"columns,omitempty"` + Message string `json:"message"` +} + +// MappingQuestion asks for a specific user-confirmed mapping choice. +type MappingQuestion struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Choices []int `json:"choices,omitempty"` +} + +type columnStats struct { + profile ColumnProfile + values []string + nonEmpty []string + unique map[string]struct{} + emailCount int + urlCount int + idLikeCount int + dateCount int + longCount int + multilineCount int +} + +// InspectCSV profiles a CSV file and returns deterministic mapping evidence. +func InspectCSV(path string, opts InspectOptions) (*Inspection, error) { + if opts.SampleSize <= 0 { + opts.SampleSize = 5 + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + + sum := sha256.Sum256(data) + delimiter := detectDelimiter(data) + records, err := readCSV(data, delimiter) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, fmt.Errorf("CSV contains no rows") + } + + headers := append([]string(nil), records[0]...) + dataRows := records[1:] + maxCols := len(headers) + for _, row := range dataRows { + if len(row) > maxCols { + maxCols = len(row) + } + } + for len(headers) < maxCols { + headers = append(headers, fmt.Sprintf("Column %d", len(headers)+1)) + } + + duplicateByName := duplicateHeaderIndexes(headers) + duplicateHeaders := make([]DuplicateHeader, 0) + for name, indexes := range duplicateByName { + if len(indexes) > 1 { + duplicateHeaders = append(duplicateHeaders, DuplicateHeader{Name: name, Indexes: indexes}) + } + } + sort.Slice(duplicateHeaders, func(i, j int) bool { + return duplicateHeaders[i].Indexes[0] < duplicateHeaders[j].Indexes[0] + }) + + stats := make([]*columnStats, maxCols) + for i := range stats { + name := headers[i] + stats[i] = &columnStats{ + profile: ColumnProfile{ + Index: i, + Name: name, + NormalizedName: normalizeName(name), + DuplicateName: len(duplicateByName[normalizeName(name)]) > 1, + }, + unique: make(map[string]struct{}), + } + } + + for _, row := range dataRows { + for i := range stats { + value := "" + if i < len(row) { + value = row[i] + } + stats[i].values = append(stats[i].values, value) + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + stats[i].profile.NonEmptyCount++ + stats[i].nonEmpty = append(stats[i].nonEmpty, trimmed) + stats[i].unique[trimmed] = struct{}{} + if emailRE.MatchString(trimmed) { + stats[i].emailCount++ + } + if urlRE.MatchString(trimmed) { + stats[i].urlCount++ + } + if looksIDLike(trimmed) { + stats[i].idLikeCount++ + } + if looksDateLike(trimmed) { + stats[i].dateCount++ + } + if len([]rune(trimmed)) > 100 { + stats[i].longCount++ + } + if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") { + stats[i].multilineCount++ + } + } + } + + idValueSets := make([]map[string]struct{}, 0) + for _, st := range stats { + st.profile.UniqueCount = len(st.unique) + st.profile.LooksLike, st.profile.Evidence = inferLooksLike(st, len(dataRows)) + if hasLook(st.profile.LooksLike, "stable_id") { + idValueSets = append(idValueSets, st.unique) + } + } + for _, st := range stats { + if !hasLook(st.profile.LooksLike, "stable_id") && !hasLook(st.profile.LooksLike, "parent_reference") && referencesKnownIDs(st, idValueSets) { + st.profile.LooksLike = appendSortedUnique(st.profile.LooksLike, "parent_reference") + st.profile.Evidence = append(st.profile.Evidence, "values match stable ID candidates") + } + } + + columns := make([]ColumnProfile, len(stats)) + for i, st := range stats { + columns[i] = st.profile + } + + inspection := &Inspection{ + SchemaVersion: inspectionSchemaVersion, + Status: "profiled", + Format: "csv", + ExportPath: path, + Fingerprint: Fingerprint{ + Algorithm: "sha256-file-v1", + Value: hex.EncodeToString(sum[:]), + }, + Dialect: CSVDialect{ + Delimiter: string(delimiter), + HasHeader: true, + Encoding: "utf-8", + }, + RowCount: len(dataRows), + Columns: columns, + DuplicateHeaders: duplicateHeaders, + RoleCandidates: inferRoleCandidates(stats, len(dataRows)), + SampleRows: sampleRows(dataRows, maxCols, opts.SampleSize), + } + inspection.Warnings = buildWarnings(inspection, stats) + inspection.Questions = buildQuestions(inspection) + return inspection, nil +} + +func readCSV(data []byte, delimiter rune) ([][]string, error) { + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + reader := csv.NewReader(bytes.NewReader(data)) + reader.Comma = delimiter + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + return records, nil +} + +func detectDelimiter(data []byte) rune { + candidates := []rune{',', '\t', ';', '|'} + best := ',' + bestScore := -1 + for _, candidate := range candidates { + records, err := readCSVPreview(data, candidate) + if err != nil || len(records) == 0 { + continue + } + score := 0 + width := 0 + consistent := true + for i, record := range records { + if len(record) > 1 { + score += len(record) + } + if i == 0 { + width = len(record) + } else if len(record) != width { + consistent = false + } + } + if consistent { + score += 10 + } + if score > bestScore { + bestScore = score + best = candidate + } + } + return best +} + +func readCSVPreview(data []byte, delimiter rune) ([][]string, error) { + reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))) + reader.Comma = delimiter + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + var records [][]string + for len(records) < 10 { + record, err := reader.Read() + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + records = append(records, record) + } + return records, nil +} + +func duplicateHeaderIndexes(headers []string) map[string][]int { + out := make(map[string][]int) + for i, header := range headers { + out[normalizeName(header)] = append(out[normalizeName(header)], i) + } + return out +} + +func normalizeName(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + var b strings.Builder + lastSpace := false + for _, r := range s { + if unicode.IsSpace(r) || r == '_' || r == '-' { + if !lastSpace { + b.WriteRune(' ') + lastSpace = true + } + continue + } + b.WriteRune(r) + lastSpace = false + } + return strings.TrimSpace(b.String()) +} + +func inferLooksLike(st *columnStats, rowCount int) ([]string, []string) { + looks := make([]string, 0) + evidence := make([]string, 0) + name := st.profile.NormalizedName + nonEmpty := st.profile.NonEmptyCount + unique := len(st.unique) + + if nonEmpty > 0 && unique == nonEmpty && (nameMatches(name, "id", "key") || st.idLikeCount*2 >= nonEmpty) { + looks = appendSortedUnique(looks, "stable_id") + evidence = append(evidence, fmt.Sprintf("%d/%d non-empty values are unique", unique, nonEmpty)) + } + if isTitleName(name) { + looks = appendSortedUnique(looks, "title") + evidence = append(evidence, "header indicates title text") + } + if nameMatches(name, "description", "notes", "content", "body") || (nonEmpty > 0 && st.longCount*2 >= nonEmpty) { + looks = appendSortedUnique(looks, "description") + evidence = append(evidence, "header or values indicate long text") + } + if nameMatches(name, "status", "state", "resolution") || (nonEmpty > 0 && unique <= max(8, rowCount/4) && (strings.Contains(name, "status") || strings.Contains(name, "state"))) { + looks = appendSortedUnique(looks, "status") + evidence = append(evidence, fmt.Sprintf("%d distinct non-empty values", unique)) + } + if nameMatches(name, "list", "section", "column", "project", "folder", "space", "team", "category") && !strings.Contains(name, "id") { + looks = appendSortedUnique(looks, "todolist") + evidence = append(evidence, "header indicates grouping/container") + } + if nameMatches(name, "assignee", "owner", "responsible", "member", "members") || st.emailCount > 0 { + looks = appendSortedUnique(looks, "assignees") + if st.emailCount > 0 { + evidence = append(evidence, fmt.Sprintf("%d email-like values", st.emailCount)) + } else { + evidence = append(evidence, "header indicates people") + } + } + if nameMatches(name, "due", "deadline") || (st.dateCount > 0 && strings.Contains(name, "date")) { + looks = appendSortedUnique(looks, "due_on") + evidence = append(evidence, fmt.Sprintf("%d date-like values", st.dateCount)) + } + if nameMatches(name, "attachment", "attachments", "file", "files", "link", "url") || st.urlCount > 0 { + looks = appendSortedUnique(looks, "attachment_urls") + evidence = append(evidence, fmt.Sprintf("%d URL-like values", st.urlCount)) + } + if nameMatches(name, "comment", "comments") || st.multilineCount > 0 { + looks = appendSortedUnique(looks, "comments") + evidence = append(evidence, "header or values indicate comments") + } + if nameMatches(name, "parent", "parent id", "depends", "dependency", "blocking", "blocked by", "related") { + looks = appendSortedUnique(looks, "parent_reference") + evidence = append(evidence, "header indicates parent or relationship reference") + } + return looks, evidence +} + +func inferRoleCandidates(stats []*columnStats, rowCount int) map[string][]RoleCandidate { + roles := []string{"record_id", "title", "description", "todolist", "status", "assignees", "due_on", "attachment_urls", "comments", "parent_reference", "custom_fields"} + out := make(map[string][]RoleCandidate, len(roles)) + for _, role := range roles { + var candidates []RoleCandidate + for _, st := range stats { + confidence, evidence := scoreRole(role, st, rowCount) + if confidence <= 0 { + continue + } + candidates = append(candidates, RoleCandidate{ + ColumnIndex: st.profile.Index, + ColumnName: st.profile.Name, + Confidence: confidence, + Evidence: evidence, + }) + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Confidence == candidates[j].Confidence { + return candidates[i].ColumnIndex < candidates[j].ColumnIndex + } + return candidates[i].Confidence > candidates[j].Confidence + }) + out[role] = candidates + } + return out +} + +func scoreRole(role string, st *columnStats, rowCount int) (float64, []string) { + name := st.profile.NormalizedName + nonEmpty := st.profile.NonEmptyCount + unique := st.profile.UniqueCount + var score float64 + var evidence []string + add := func(points float64, text string) { + score += points + evidence = append(evidence, text) + } + + switch role { + case "record_id": + if nameMatches(name, "id", "key", "task id", "issue id", "issue key", "card id") { + add(0.55, "header indicates record ID") + } + if nonEmpty > 0 && unique == nonEmpty { + add(0.25, "values are unique") + } + if nonEmpty > 0 && st.idLikeCount*2 >= nonEmpty { + add(0.2, "values look like identifiers") + } + case "title": + if isTitleName(name) { + add(0.7, "header indicates todo title") + } + if nonEmpty > 0 && averageLength(st.nonEmpty) <= 120 { + add(0.15, "values are title-length text") + } + case "description": + if nameMatches(name, "description", "notes", "content") { + add(0.65, "header indicates description") + } + if nonEmpty > 0 && (averageLength(st.nonEmpty) > 80 || st.multilineCount > 0) { + add(0.25, "values contain long or multiline text") + } + case "todolist": + if nameMatches(name, "list", "section", "column", "project", "folder", "space", "team") && !strings.Contains(name, "id") { + add(0.6, "header indicates grouping") + } + if nonEmpty > 0 && unique < nonEmpty { + add(0.2, "values repeat across rows") + } + case "status": + if nameMatches(name, "status", "state") { + add(0.7, "header indicates status") + } + if nonEmpty > 0 && unique <= max(12, rowCount/3) { + add(0.15, "values are low-cardinality") + } + case "assignees": + if nameMatches(name, "assignee", "owner", "responsible", "member", "members") { + add(0.65, "header indicates assignees") + } + if st.emailCount > 0 { + add(0.25, "values include email addresses") + } + case "due_on": + if nameMatches(name, "due", "deadline") { + add(0.65, "header indicates due date") + } + if nonEmpty > 0 && st.dateCount*2 >= nonEmpty { + add(0.25, "values are date-like") + } + case "attachment_urls": + if nameMatches(name, "attachment", "attachments", "file", "files", "link", "url") { + add(0.55, "header indicates attachments or URLs") + } + if st.urlCount > 0 { + add(0.35, "values include URLs") + } + case "comments": + if nameMatches(name, "comment", "comments") { + add(0.65, "header indicates comments") + } + if st.multilineCount > 0 { + add(0.2, "values include multiline text") + } + case "parent_reference": + if nameMatches(name, "parent", "parent id", "depends", "dependency", "blocking", "blocked by", "related") { + add(0.75, "header indicates parent or relationship reference") + } + case "custom_fields": + if nonEmpty > 0 { + add(0.2, "column contains data") + } + } + if score > 1 { + score = 1 + } + if score < 0.2 { + return 0, nil + } + return roundConfidence(score), evidence +} + +func buildWarnings(inspection *Inspection, stats []*columnStats) []ImportWarning { + warnings := make([]ImportWarning, 0) + if len(inspection.DuplicateHeaders) > 0 { + cols := make([]int, 0) + for _, dup := range inspection.DuplicateHeaders { + cols = append(cols, dup.Indexes...) + } + warnings = append(warnings, ImportWarning{Code: "duplicate_headers", Columns: cols, Message: "Duplicate headers are present. Column indexes distinguish repeated names."}) + } + if len(inspection.RoleCandidates["title"]) == 0 { + warnings = append(warnings, ImportWarning{Code: "no_obvious_title_column", Message: "No obvious todo title column was detected. Confirm a title column before planning an import."}) + } + for _, st := range stats { + if hasLook(st.profile.LooksLike, "assignees") && st.profile.NonEmptyCount > 0 && st.emailCount == 0 { + warnings = append(warnings, ImportWarning{Code: "people_look_like_display_names", Columns: []int{st.profile.Index}, Message: "Assignee values look like display names rather than email addresses. Confirm mapping before assigning Basecamp people."}) + } + if hasLook(st.profile.LooksLike, "attachment_urls") && (st.urlCount > 0 || nameMatches(st.profile.NormalizedName, "attachment", "attachments")) { + warnings = append(warnings, ImportWarning{Code: "attachment_fields_detected", Columns: []int{st.profile.Index}, Message: "Attachment or URL fields are present. Confirm how these values should be preserved before importing."}) + } + if hasLook(st.profile.LooksLike, "parent_reference") { + warnings = append(warnings, ImportWarning{Code: "parent_references_detected", Columns: []int{st.profile.Index}, Message: "Parent or relationship references are present. Confirm how hierarchy should be represented in Basecamp."}) + } + } + return warnings +} + +func buildQuestions(inspection *Inspection) []MappingQuestion { + questions := make([]MappingQuestion, 0) + questions = append(questions, MappingQuestion{ID: "confirm_title_column", Prompt: "Which column should become the Basecamp todo title?", Choices: candidateIndexes(inspection.RoleCandidates["title"])}) + if len(inspection.RoleCandidates["todolist"]) > 0 { + questions = append(questions, MappingQuestion{ID: "confirm_todolist_column", Prompt: "Which column should group todos into Basecamp todolists?", Choices: candidateIndexes(inspection.RoleCandidates["todolist"])}) + } + if len(inspection.RoleCandidates["assignees"]) > 0 { + questions = append(questions, MappingQuestion{ID: "confirm_assignee_policy", Prompt: "Should assignee values be imported, and how should ambiguous people be handled?", Choices: candidateIndexes(inspection.RoleCandidates["assignees"])}) + } + if len(inspection.RoleCandidates["due_on"]) > 1 { + questions = append(questions, MappingQuestion{ID: "confirm_due_date_column", Prompt: "Which date column should become the Basecamp todo due date?", Choices: candidateIndexes(inspection.RoleCandidates["due_on"])}) + } + questions = append(questions, MappingQuestion{ID: "confirm_custom_fields_policy", Prompt: "Should unmapped non-empty columns be preserved as metadata?"}) + return questions +} + +func sampleRows(rows [][]string, maxCols, sampleSize int) []SampleRow { + limit := sampleSize + if len(rows) < limit { + limit = len(rows) + } + out := make([]SampleRow, 0, limit) + for i := 0; i < limit; i++ { + values := make(map[string]string) + for col := 0; col < maxCols; col++ { + value := "" + if col < len(rows[i]) { + value = rows[i][col] + } + value = strings.TrimSpace(value) + if value == "" { + continue + } + values[fmt.Sprintf("%d", col)] = truncateRunes(value, 200) + } + out = append(out, SampleRow{RowNumber: i + 1, ValuesByIndex: values}) + } + return out +} + +func candidateIndexes(candidates []RoleCandidate) []int { + out := make([]int, len(candidates)) + for i, candidate := range candidates { + out[i] = candidate.ColumnIndex + } + return out +} + +func isTitleName(name string) bool { + if strings.Contains(name, "project name") || strings.Contains(name, "user name") || strings.Contains(name, "file name") { + return false + } + return exactOrSuffixName(name, + "title", "name", "summary", "task name", "card name", "subject", "headline", + "action", "task", "activity", "chore", "assignment", "deliverable", "procedure", "step", + "hypothesis", "matter", "line item", "visit reason", "concern", "item", "agenda item", + "milestone", "initiative", "issue", "device", "asset", "campaign", "deal", "post", "work order", + ) +} + +func nameMatches(name string, terms ...string) bool { + for _, term := range terms { + term = normalizeName(term) + if name == term || strings.Contains(name, term) { + return true + } + } + return false +} + +func exactOrSuffixName(name string, terms ...string) bool { + for _, term := range terms { + term = normalizeName(term) + if name == term || strings.HasSuffix(name, " "+term) { + return true + } + } + return false +} + +func looksIDLike(value string) bool { + value = strings.TrimSpace(value) + if value == "" || strings.Contains(value, " ") || strings.Contains(value, "@") || urlRE.MatchString(value) || looksDateLike(value) { + return false + } + hasDigit := false + for _, r := range value { + if unicode.IsDigit(r) { + hasDigit = true + break + } + } + return hasDigit && idLikeRE.MatchString(value) +} + +func looksDateLike(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + layouts := []string{ + time.RFC3339, + "2006-01-02", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006/01/02", + "01/02/2006", + "1/2/2006", + "02 Jan 2006", + "Jan 2, 2006", + "January 2, 2006", + } + for _, layout := range layouts { + if _, err := time.Parse(layout, value); err == nil { + return true + } + } + return false +} + +func referencesKnownIDs(st *columnStats, idValueSets []map[string]struct{}) bool { + if st.profile.NonEmptyCount == 0 || len(idValueSets) == 0 { + return false + } + matches := 0 + for _, value := range st.nonEmpty { + for _, set := range idValueSets { + if _, ok := set[value]; ok { + matches++ + break + } + } + } + return matches > 0 +} + +func hasLook(looks []string, target string) bool { + for _, look := range looks { + if look == target { + return true + } + } + return false +} + +func appendSortedUnique(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + values = append(values, value) + sort.Strings(values) + return values +} + +func averageLength(values []string) float64 { + if len(values) == 0 { + return 0 + } + total := 0 + for _, value := range values { + total += len([]rune(value)) + } + return float64(total) / float64(len(values)) +} + +func roundConfidence(v float64) float64 { + return float64(int(v*100+0.5)) / 100 +} + +func truncateRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "…" +} diff --git a/internal/importer/csvprofiler_test.go b/internal/importer/csvprofiler_test.go new file mode 100644 index 00000000..d8787696 --- /dev/null +++ b/internal/importer/csvprofiler_test.go @@ -0,0 +1,134 @@ +package importer + +import ( + "encoding/json" + "io/fs" + "path/filepath" + "testing" +) + +func TestInspectCSVProfilesEveryFixture(t *testing.T) { + var paths []string + root := "../../testdata/import/csv" + if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(path) == ".csv" { + paths = append(paths, path) + } + return nil + }); err != nil { + t.Fatalf("walk fixtures: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected CSV fixtures") + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + inspection, err := InspectCSV(path, InspectOptions{SampleSize: 2}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + if inspection.SchemaVersion != 1 { + t.Fatalf("schema_version = %d, want 1", inspection.SchemaVersion) + } + if inspection.Status != "profiled" { + t.Fatalf("status = %q, want profiled", inspection.Status) + } + if inspection.Format != "csv" { + t.Fatalf("format = %q, want csv", inspection.Format) + } + if inspection.Fingerprint.Algorithm != "sha256-file-v1" || inspection.Fingerprint.Value == "" { + t.Fatalf("fingerprint not populated: %+v", inspection.Fingerprint) + } + if inspection.Dialect.Delimiter == "" || !inspection.Dialect.HasHeader { + t.Fatalf("dialect not populated: %+v", inspection.Dialect) + } + if inspection.RowCount <= 0 { + t.Fatalf("row_count = %d, want > 0", inspection.RowCount) + } + if len(inspection.Columns) == 0 { + t.Fatal("expected column profiles") + } + if len(inspection.Questions) == 0 { + t.Fatal("expected mapping questions") + } + if !hasQuestion(inspection, "confirm_title_column") { + t.Fatal("expected title confirmation question") + } + if len(inspection.RoleCandidates["title"]) == 0 && !hasWarning(inspection, "no_obvious_title_column") { + t.Fatal("expected no_obvious_title_column warning when no title candidate is detected") + } + if _, err := json.Marshal(inspection); err != nil { + t.Fatalf("marshal inspection: %v", err) + } + }) + } +} + +func hasWarning(inspection *Inspection, code string) bool { + for _, warning := range inspection.Warnings { + if warning.Code == code { + return true + } + } + return false +} + +func hasQuestion(inspection *Inspection, id string) bool { + for _, question := range inspection.Questions { + if question.ID == id { + return true + } + } + return false +} + +func TestInspectCSVDetectsDuplicateHeadersByIndex(t *testing.T) { + inspection, err := InspectCSV("../../testdata/import/csv/synthetic/jira-simple.csv", InspectOptions{SampleSize: 1}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + + if len(inspection.DuplicateHeaders) == 0 { + t.Fatal("expected duplicate header report") + } + var found bool + for _, duplicate := range inspection.DuplicateHeaders { + if duplicate.Name == "labels" && len(duplicate.Indexes) == 2 && duplicate.Indexes[0] == 6 && duplicate.Indexes[1] == 7 { + found = true + } + } + if !found { + t.Fatalf("expected Labels duplicate at indexes 6 and 7, got %+v", inspection.DuplicateHeaders) + } + if !inspection.Columns[6].DuplicateName || !inspection.Columns[7].DuplicateName { + t.Fatalf("expected duplicate_name on both Labels columns") + } +} + +func TestInspectCSVProducesStableCoreMappingForLinear(t *testing.T) { + inspection, err := InspectCSV("../../testdata/import/csv/synthetic/linear-simple.csv", InspectOptions{SampleSize: 1}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + + assertTopCandidate := func(role string, index int) { + t.Helper() + candidates := inspection.RoleCandidates[role] + if len(candidates) == 0 { + t.Fatalf("role %s has no candidates", role) + } + if candidates[0].ColumnIndex != index { + t.Fatalf("role %s top candidate index = %d, want %d (%+v)", role, candidates[0].ColumnIndex, index, candidates) + } + } + + assertTopCandidate("record_id", 0) + assertTopCandidate("title", 2) + assertTopCandidate("description", 3) + assertTopCandidate("status", 4) + assertTopCandidate("assignees", 10) +} diff --git a/internal/importer/dates.go b/internal/importer/dates.go new file mode 100644 index 00000000..8cf1b088 --- /dev/null +++ b/internal/importer/dates.go @@ -0,0 +1,173 @@ +package importer + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var slashDatePattern = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})/(\d{4})$`) + +func normalizedDueOnValues(rows [][]string, mapping *MappingConfig) ([]string, error) { + out := make([]string, len(rows)) + if mapping.DueOn == nil { + return out, nil + } + + order, err := inferSlashDateOrder(rows, mapping.DueOn) + if err != nil { + return nil, err + } + for i, row := range rows { + value := valueAt(row, mapping.DueOn.ColumnIndex) + normalized, err := normalizeDueOnValue(value, order) + if err != nil { + return nil, fmt.Errorf("source row %d due_on %q: %w", i+1, value, err) + } + out[i] = normalized + } + return out, nil +} + +func inferSlashDateOrder(rows [][]string, ref *ColumnRef) (string, error) { + explicit := strings.ToLower(strings.TrimSpace(ref.DateOrder)) + if explicit != "" { + if explicit != "mdy" && explicit != "dmy" { + return "", fmt.Errorf("mapping due_on date_order %q is unsupported; use mdy or dmy", ref.DateOrder) + } + return explicit, nil + } + + mdyRow, dmyRow := 0, 0 + mdyValue, dmyValue := "", "" + ambiguousRow, ambiguousValue := 0, "" + for i, row := range rows { + value := valueAt(row, ref.ColumnIndex) + if strings.TrimSpace(value) == "" || !slashDatePattern.MatchString(value) { + continue + } + monthOrDay, dayOrMonth, _, err := slashDateParts(value) + if err != nil { + return "", fmt.Errorf("source row %d due_on %q: %w", i+1, value, err) + } + if monthOrDay > 12 && dayOrMonth > 12 { + return "", fmt.Errorf("source row %d due_on %q is not a valid mdy or dmy date", i+1, value) + } + if dayOrMonth > 12 { + mdyRow, mdyValue = i+1, value + continue + } + if monthOrDay > 12 { + dmyRow, dmyValue = i+1, value + continue + } + if ambiguousRow == 0 { + ambiguousRow, ambiguousValue = i+1, value + } + } + + if mdyRow != 0 && dmyRow != 0 { + return "", fmt.Errorf("due_on slash dates contain conflicting date orders: source row %d value %q indicates mdy, while source row %d value %q indicates dmy", mdyRow, mdyValue, dmyRow, dmyValue) + } + if mdyRow != 0 { + return "mdy", nil + } + if dmyRow != 0 { + return "dmy", nil + } + if ambiguousRow != 0 { + return "", fmt.Errorf("due_on slash date format is ambiguous at source row %d value %q; add date_order \"mdy\" or \"dmy\" to the due_on mapping", ambiguousRow, ambiguousValue) + } + return "", nil +} + +func normalizeDueOnValue(value, slashOrder string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + + if date, ok := parseDateWithLayouts(value, "2006-01-02", "2006/01/02"); ok { + return date, nil + } + if date, ok := parseDateWithLayouts(value, + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02 15:04", + ); ok { + return date, nil + } + if date, ok := parseDateWithLayouts(value, + "January 2, 2006", + "January 2 2006", + "Jan 2, 2006", + "Jan 2 2006", + "2 January 2006", + "2 Jan 2006", + ); ok { + return date, nil + } + + if slashDatePattern.MatchString(value) { + if slashOrder == "" { + return "", fmt.Errorf("slash date format is ambiguous; add date_order \"mdy\" or \"dmy\" to the due_on mapping") + } + return normalizeSlashDate(value, slashOrder) + } + if looksLikeTwoDigitYearSlashDate(value) { + return "", fmt.Errorf("two-digit years are not accepted for import due dates") + } + + return "", fmt.Errorf("unsupported date format") +} + +func parseDateWithLayouts(value string, layouts ...string) (string, bool) { + for _, layout := range layouts { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed.Format("2006-01-02"), true + } + } + return "", false +} + +func normalizeSlashDate(value, order string) (string, error) { + first, second, year, err := slashDateParts(value) + if err != nil { + return "", err + } + month, day := first, second + if order == "dmy" { + day, month = first, second + } + if month < 1 || month > 12 || day < 1 || day > 31 { + return "", fmt.Errorf("invalid %s date", order) + } + date := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) + if date.Year() != year || int(date.Month()) != month || date.Day() != day { + return "", fmt.Errorf("invalid %s date", order) + } + return date.Format("2006-01-02"), nil +} + +func slashDateParts(value string) (int, int, int, error) { + match := slashDatePattern.FindStringSubmatch(value) + if match == nil { + return 0, 0, 0, fmt.Errorf("expected slash date with four-digit year") + } + first, _ := strconv.Atoi(match[1]) + second, _ := strconv.Atoi(match[2]) + year, _ := strconv.Atoi(match[3]) + return first, second, year, nil +} + +func looksLikeTwoDigitYearSlashDate(value string) bool { + parts := strings.Split(value, "/") + if len(parts) != 3 { + return false + } + return len(parts[2]) == 2 +} diff --git a/internal/importer/executor.go b/internal/importer/executor.go new file mode 100644 index 00000000..078b8417 --- /dev/null +++ b/internal/importer/executor.go @@ -0,0 +1,362 @@ +package importer + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const artifactExecutionFileName = "execution.json" + +// ExecuteOptions controls approved artifact execution. +type ExecuteOptions struct { + Approved bool +} + +// ArtifactWriteClient performs Basecamp writes for a validated import artifact. +type ArtifactWriteClient interface { + CreateProject(ctx context.Context, name string) (int64, error) + CreateTodolist(ctx context.Context, projectID int64, name string) (int64, error) + CreateTodo(ctx context.Context, todolistID int64, todo ExecutableTodo) (int64, error) +} + +// ExecutableTodo is the normalized todo payload sent to Basecamp. +type ExecutableTodo struct { + Title string + Description string + DueOn string +} + +// ExecuteResult reports records created from a validated import artifact. +type ExecuteResult struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Created ExecuteCounts `json:"created"` + Skipped []ExecuteSkipped `json:"skipped,omitempty"` + LedgerPath string `json:"ledger_path,omitempty"` +} + +// ExecuteCounts counts records created by artifact execution. +type ExecuteCounts struct { + Projects int `json:"projects"` + Todolists int `json:"todolists"` + Todos int `json:"todos"` +} + +// ExecuteSkipped reports artifact data that was preserved but not written as a native Basecamp field. +type ExecuteSkipped struct { + SourceRow int `json:"source_row,omitempty"` + Field string `json:"field"` + Reason string `json:"reason"` +} + +// ExecutionLedger records an artifact execution attempt. +type ExecutionLedger struct { + SchemaVersion int `json:"schema_version"` + ArtifactFormat string `json:"artifact_format"` + Status string `json:"status"` + SourceFingerprint Fingerprint `json:"source_fingerprint"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` + FailedAt string `json:"failed_at,omitempty"` + Created ExecuteCounts `json:"created,omitempty"` + Operations []ExecutionLedgerOperation `json:"operations,omitempty"` + Error string `json:"error,omitempty"` +} + +// ExecutionLedgerOperation records one completed or failed artifact operation. +type ExecutionLedgerOperation struct { + Op string `json:"op"` + Status string `json:"status"` + SourceRow int `json:"source_row,omitempty"` + SourceRecordID string `json:"source_record_id,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + TodolistID int64 `json:"todolist_id,omitempty"` + TodolistName string `json:"todolist_name,omitempty"` + Title string `json:"title,omitempty"` + CreatedID int64 `json:"created_id,omitempty"` + At string `json:"at"` + Error string `json:"error,omitempty"` +} + +// ExecuteArtifact creates Basecamp records from a validated import artifact after explicit approval. +func ExecuteArtifact(ctx context.Context, artifactDir string, client ArtifactWriteClient, opts ExecuteOptions) (result *ExecuteResult, err error) { + if !opts.Approved { + return nil, fmt.Errorf("import execution requires explicit approval") + } + if client == nil { + return nil, fmt.Errorf("import execution requires a write client") + } + manifest, rows, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + + ledger, err := beginArtifactExecution(artifactDir, manifest) + if err != nil { + return nil, err + } + ledgerFinalized := false + defer func() { + if err != nil && !ledgerFinalized { + _ = finishArtifactExecution(artifactDir, ledger, "failed", result, err) + } + }() + + result = &ExecuteResult{SchemaVersion: planSchemaVersion, Status: "completed", LedgerPath: filepath.Join(artifactDir, artifactExecutionFileName)} + projectID, err := executeArtifactProject(ctx, artifactDir, client, manifest, ledger, result) + if err != nil { + return nil, err + } + + listIDs, err := executeArtifactTodolists(ctx, artifactDir, client, projectID, manifest, rows, ledger, result) + if err != nil { + return nil, err + } + + for _, row := range rows { + listName := row.TodolistName + if strings.TrimSpace(listName) == "" { + listName = "Imported todos" + } + todolistID := row.TodolistID + if todolistID == 0 { + todolistID = listIDs[listName] + } + if todolistID == 0 { + return nil, fmt.Errorf("source row %d has no executable todolist", row.SourceRow) + } + if len(row.AssigneeEmails) > 0 || len(row.AssigneeNames) > 0 { + result.Skipped = append(result.Skipped, ExecuteSkipped{SourceRow: row.SourceRow, Field: "assignees", Reason: "artifact does not contain Basecamp person IDs"}) + } + todo := ExecutableTodo{Title: row.Title, Description: executionDescription(row), DueOn: row.DueOn} + createdID, createErr := client.CreateTodo(ctx, todolistID, todo) + if createErr != nil { + err := fmt.Errorf("create todo from source row %d: %w", row.SourceRow, createErr) + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todo", Status: "failed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, TodolistID: todolistID, TodolistName: listName, Title: row.Title, Error: err.Error()}) + _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) + return nil, err + } + result.Created.Todos++ + ledger.Created.Todos = result.Created.Todos + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todo", Status: "completed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, TodolistID: todolistID, TodolistName: listName, Title: row.Title, CreatedID: createdID}) + if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { + return nil, err + } + } + if err := finishArtifactExecution(artifactDir, ledger, "completed", result, nil); err != nil { + ledgerFinalized = true + return nil, err + } + ledgerFinalized = true + return result, nil +} + +func executeArtifactProject(ctx context.Context, artifactDir string, client ArtifactWriteClient, manifest *ImportArtifactManifest, ledger *ExecutionLedger, result *ExecuteResult) (int64, error) { + if manifest.Destination.Mode != "new_project" { + return executionProjectID(ctx, client, manifest) + } + name := strings.TrimSpace(manifest.Destination.ProjectName) + if name == "" { + return 0, fmt.Errorf("artifact destination project_name is required") + } + projectID, err := client.CreateProject(ctx, name) + if err != nil { + wrapped := fmt.Errorf("create project %q: %w", name, err) + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_project", Status: "failed", ProjectName: name, Error: wrapped.Error()}) + _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) + return 0, wrapped + } + result.Created.Projects = 1 + ledger.Created.Projects = result.Created.Projects + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_project", Status: "completed", ProjectName: name, ProjectID: projectID, CreatedID: projectID}) + if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { + return 0, err + } + return projectID, nil +} + +func executeArtifactTodolists(ctx context.Context, artifactDir string, client ArtifactWriteClient, projectID int64, manifest *ImportArtifactManifest, rows []artifactTodoRow, ledger *ExecutionLedger, result *ExecuteResult) (map[string]int64, error) { + listIDs := make(map[string]int64) + if manifest.Destination.TodolistStrategy == "existing_todolist" { + id, err := parseOptionalInt64(manifest.Destination.TodolistID) + if err != nil { + return nil, fmt.Errorf("invalid destination todolist_id: %w", err) + } + if id == 0 { + return nil, fmt.Errorf("artifact destination todolist_id is required for execution") + } + listIDs[manifest.Destination.TodolistName] = id + listIDs["Imported todos"] = id + return listIDs, nil + } + + for _, name := range artifactTodolistNames(rows) { + id, createErr := client.CreateTodolist(ctx, projectID, name) + if createErr != nil { + err := fmt.Errorf("create todolist %q: %w", name, createErr) + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todolist", Status: "failed", ProjectID: projectID, TodolistName: name, Error: err.Error()}) + _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) + return nil, err + } + listIDs[name] = id + result.Created.Todolists++ + ledger.Created.Todolists = result.Created.Todolists + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todolist", Status: "completed", ProjectID: projectID, TodolistID: id, TodolistName: name, CreatedID: id}) + if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { + return nil, err + } + } + return listIDs, nil +} + +func appendExecutionLedgerOperation(ledger *ExecutionLedger, op ExecutionLedgerOperation) { + op.At = time.Now().UTC().Format(time.RFC3339) + ledger.Operations = append(ledger.Operations, op) +} + +func beginArtifactExecution(artifactDir string, manifest *ImportArtifactManifest) (*ExecutionLedger, error) { + ledgerPath := filepath.Join(artifactDir, artifactExecutionFileName) + if data, err := os.ReadFile(ledgerPath); err == nil { + var existing ExecutionLedger + if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { + return nil, fmt.Errorf("artifact execution ledger exists at %s and cannot be read; refusing to execute again", ledgerPath) + } + return nil, fmt.Errorf("artifact execution ledger exists at %s with status %q; refusing to execute again to avoid duplicate Basecamp records", ledgerPath, existing.Status) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("checking artifact execution ledger: %w", err) + } + + ledger := &ExecutionLedger{ + SchemaVersion: planSchemaVersion, + ArtifactFormat: manifest.ArtifactFormat, + Status: "started", + SourceFingerprint: manifest.SourceFingerprint, + StartedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := writeExecutionLedger(ledgerPath, ledger); err != nil { + return nil, err + } + return ledger, nil +} + +func finishArtifactExecution(artifactDir string, ledger *ExecutionLedger, status string, result *ExecuteResult, executionErr error) error { + ledger.Status = status + if result != nil { + ledger.Created = result.Created + } + now := time.Now().UTC().Format(time.RFC3339) + if status == "completed" { + ledger.CompletedAt = now + } else { + ledger.FailedAt = now + if executionErr != nil { + ledger.Error = executionErr.Error() + } + } + return writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) +} + +func writeExecutionLedger(path string, ledger *ExecutionLedger) error { + data, err := json.MarshalIndent(ledger, "", " ") + if err != nil { + return fmt.Errorf("encode artifact execution ledger: %w", err) + } + data = append(data, '\n') + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { //nolint:gosec // G306: Execution ledgers are user-readable recovery files + return fmt.Errorf("write artifact execution ledger: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("write artifact execution ledger: %w", err) + } + return nil +} + +func executionProjectID(ctx context.Context, client ArtifactWriteClient, manifest *ImportArtifactManifest) (int64, error) { + if manifest.Destination.Mode == "new_project" { + name := strings.TrimSpace(manifest.Destination.ProjectName) + if name == "" { + return 0, fmt.Errorf("artifact destination project_name is required") + } + return client.CreateProject(ctx, name) + } + id, err := parseOptionalInt64(manifest.Destination.ProjectID) + if err != nil { + return 0, fmt.Errorf("invalid destination project_id: %w", err) + } + if id == 0 { + return 0, fmt.Errorf("artifact destination project_id is required for execution") + } + return id, nil +} + +func executionDescription(row artifactTodoRow) string { + parts := make([]string, 0) + if strings.TrimSpace(row.Description) != "" { + parts = append(parts, row.Description) + } + metadata := make([]string, 0) + if row.SourceRecordID != "" { + metadata = append(metadata, "Source record ID: "+row.SourceRecordID) + } + if row.Status != "" { + metadata = append(metadata, "Source status: "+row.Status) + } + if len(row.AssigneeEmails) > 0 { + metadata = append(metadata, "Source assignee emails: "+strings.Join(row.AssigneeEmails, ", ")) + } + if len(row.AssigneeNames) > 0 { + metadata = append(metadata, "Source assignee names: "+strings.Join(row.AssigneeNames, ", ")) + } + if len(row.AttachmentURLs) > 0 { + metadata = append(metadata, "Source attachment URLs: "+strings.Join(row.AttachmentURLs, ", ")) + } + if len(row.Comments) > 0 { + metadata = append(metadata, "Source comments: "+strings.Join(row.Comments, " | ")) + } + if len(row.CustomFields) > 0 { + metadata = append(metadata, "Source custom fields:") + for _, key := range sortedMapKeys(row.CustomFields) { + metadata = append(metadata, fmt.Sprintf("- %s: %s", key, row.CustomFields[key])) + } + } + if len(metadata) > 0 { + parts = append(parts, strings.Join(metadata, "\n")) + } + return strings.Join(parts, "\n\n") +} + +func formatOptionalInt64(value int64) string { + if value == 0 { + return "" + } + return strconv.FormatInt(value, 10) +} + +func parseOptionalInt64(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + return strconv.ParseInt(value, 10, 64) +} + +func sortedMapKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j] < keys[j-1]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + return keys +} diff --git a/internal/importer/executor_test.go b/internal/importer/executor_test.go new file mode 100644 index 00000000..917f73e8 --- /dev/null +++ b/internal/importer/executor_test.go @@ -0,0 +1,232 @@ +package importer + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" +) + +type fakeWriteClient struct { + nextID int64 + projects []string + todolists []fakeCreatedTodolist + todos []fakeCreatedTodo + failTodoRows map[string]error +} + +type fakeCreatedTodolist struct { + ProjectID int64 + Name string + ID int64 +} + +type fakeCreatedTodo struct { + TodolistID int64 + Todo ExecutableTodo + ID int64 +} + +func (f *fakeWriteClient) CreateProject(ctx context.Context, name string) (int64, error) { + f.projects = append(f.projects, name) + return f.next(), nil +} + +func (f *fakeWriteClient) CreateTodolist(ctx context.Context, projectID int64, name string) (int64, error) { + id := f.next() + f.todolists = append(f.todolists, fakeCreatedTodolist{ProjectID: projectID, Name: name, ID: id}) + return id, nil +} + +func (f *fakeWriteClient) CreateTodo(ctx context.Context, todolistID int64, todo ExecutableTodo) (int64, error) { + if f.failTodoRows != nil { + if err := f.failTodoRows[todo.Title]; err != nil { + return 0, err + } + } + id := f.next() + f.todos = append(f.todos, fakeCreatedTodo{TodolistID: todolistID, Todo: todo, ID: id}) + return id, nil +} + +func (f *fakeWriteClient) next() int64 { + if f.nextID == 0 { + f.nextID = 100 + } + id := f.nextID + f.nextID++ + return id +} + +func TestExecuteArtifactRequiresApproval(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + _, err := ExecuteArtifact(context.Background(), outDir, &fakeWriteClient{}, ExecuteOptions{}) + if err == nil || !strings.Contains(err.Error(), "explicit approval") { + t.Fatalf("expected approval error, got %v", err) + } +} + +func TestExecuteArtifactCreatesTodolistsAndTodos(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{} + + result, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if result.Status != "completed" { + t.Fatalf("status = %q", result.Status) + } + if result.Created.Projects != 0 || result.Created.Todolists != 2 || result.Created.Todos != 2 { + t.Fatalf("created = %+v", result.Created) + } + if len(client.todolists) != 2 || client.todolists[0].Name != "Backlog" || client.todolists[1].Name != "Doing" { + t.Fatalf("todolists = %+v", client.todolists) + } + if len(client.todos) != 2 || client.todos[0].Todo.Title != "First" || client.todos[1].Todo.Title != "Second" { + t.Fatalf("todos = %+v", client.todos) + } + if client.todos[0].TodolistID != client.todolists[0].ID { + t.Fatalf("first todo todolist ID = %d, want %d", client.todos[0].TodolistID, client.todolists[0].ID) + } +} + +func TestExecuteArtifactUsesExistingTodolist(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + client := &fakeWriteClient{} + + result, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if result.Created.Todolists != 0 || len(client.todolists) != 0 { + t.Fatalf("expected no created todolists, result=%+v client=%+v", result, client.todolists) + } + if len(client.todos) != 2 || client.todos[0].TodolistID != 456 || client.todos[1].TodolistID != 456 { + t.Fatalf("todos = %+v", client.todos) + } +} + +func TestExecuteArtifactWritesCompletedLedgerAndRefusesReplay(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + result, err := ExecuteArtifact(context.Background(), outDir, &fakeWriteClient{}, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if result.LedgerPath != filepath.Join(outDir, artifactExecutionFileName) { + t.Fatalf("ledger path = %q", result.LedgerPath) + } + + var ledger ExecutionLedger + if err := readJSONData(filepath.Join(outDir, artifactExecutionFileName), &ledger); err != nil { + t.Fatalf("read ledger: %v", err) + } + if ledger.Status != "completed" || ledger.Created.Todos != 2 || ledger.CompletedAt == "" { + t.Fatalf("ledger = %+v", ledger) + } + if len(ledger.Operations) != 2 || ledger.Operations[0].Op != "create_todo" || ledger.Operations[0].SourceRow != 1 || ledger.Operations[0].CreatedID == 0 { + t.Fatalf("ledger operations = %+v", ledger.Operations) + } + + client := &fakeWriteClient{} + _, err = ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err == nil || !strings.Contains(err.Error(), "refusing to execute again") { + t.Fatalf("expected replay refusal, got %v", err) + } + if len(client.projects) != 0 || len(client.todolists) != 0 || len(client.todos) != 0 { + t.Fatalf("second execution wrote records: %+v", client) + } +} + +func TestExecuteArtifactCreatesProject(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "new_project", ProjectName: "Imported Project", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{} + + result, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if result.Created.Projects != 1 || len(client.projects) != 1 || client.projects[0] != "Imported Project" { + t.Fatalf("projects result=%+v client=%+v", result.Created, client.projects) + } + if len(client.todolists) != 2 || client.todolists[0].ProjectID != 100 { + t.Fatalf("todolists = %+v", client.todolists) + } + var ledger ExecutionLedger + if err := readJSONData(filepath.Join(outDir, artifactExecutionFileName), &ledger); err != nil { + t.Fatalf("read ledger: %v", err) + } + if len(ledger.Operations) != 5 || ledger.Operations[0].Op != "create_project" || ledger.Operations[0].CreatedID != 100 { + t.Fatalf("ledger operations = %+v", ledger.Operations) + } +} + +func TestExecuteArtifactReportsSkippedAssignees(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,list,owner\n1,First,Backlog,alex@example.com\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, Todolist: &ColumnRef{ColumnIndex: 2}, Assignees: &ColumnRef{ColumnIndex: 3}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"} + outDir := filepath.Join(t.TempDir(), "artifact") + if _, err := CompileArtifact(inspection, mapping, destination, outDir); err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + + result, err := ExecuteArtifact(context.Background(), outDir, &fakeWriteClient{}, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if len(result.Skipped) != 1 || result.Skipped[0].Field != "assignees" { + t.Fatalf("skipped = %+v", result.Skipped) + } +} + +func TestExecuteArtifactStopsOnTodoError(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": fmt.Errorf("boom")}} + + _, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err == nil || !strings.Contains(err.Error(), "source row 2") { + t.Fatalf("expected source row error, got %v", err) + } +} + +func TestExecuteArtifactWritesFailedLedgerAndRefusesReplay(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": fmt.Errorf("boom")}} + + _, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err == nil || !strings.Contains(err.Error(), "source row 2") { + t.Fatalf("expected source row error, got %v", err) + } + var ledger ExecutionLedger + if err := readJSONData(filepath.Join(outDir, artifactExecutionFileName), &ledger); err != nil { + t.Fatalf("read ledger: %v", err) + } + if ledger.Status != "failed" || ledger.FailedAt == "" || !strings.Contains(ledger.Error, "source row 2") { + t.Fatalf("ledger = %+v", ledger) + } + last := ledger.Operations[len(ledger.Operations)-1] + if last.Op != "create_todo" || last.Status != "failed" || last.SourceRow != 2 || !strings.Contains(last.Error, "source row 2") { + t.Fatalf("ledger operations = %+v", ledger.Operations) + } + + secondClient := &fakeWriteClient{} + _, err = ExecuteArtifact(context.Background(), outDir, secondClient, ExecuteOptions{Approved: true}) + if err == nil || !strings.Contains(err.Error(), "refusing to execute again") { + t.Fatalf("expected replay refusal, got %v", err) + } + if len(secondClient.projects) != 0 || len(secondClient.todolists) != 0 || len(secondClient.todos) != 0 { + t.Fatalf("second execution wrote records: %+v", secondClient) + } +} + +func compileSimpleExecutionArtifact(t *testing.T, destination *DestinationConfig) string { + t.Helper() + inspection := inspectTempCSV(t, "id,title,list\n1,First,Backlog\n2,Second,Doing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, RecordID: &ColumnRef{ColumnIndex: 0}, Title: &ColumnRef{ColumnIndex: 1}, Todolist: &ColumnRef{ColumnIndex: 2}} + outDir := filepath.Join(t.TempDir(), "artifact") + if _, err := CompileArtifact(inspection, mapping, destination, outDir); err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + return outDir +} diff --git a/internal/importer/followup.go b/internal/importer/followup.go new file mode 100644 index 00000000..2648e9cf --- /dev/null +++ b/internal/importer/followup.go @@ -0,0 +1,177 @@ +package importer + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// FollowupOptions controls follow-up artifact generation. +type FollowupOptions struct { + Reviewed bool +} + +// FollowupArtifactResult reports a generated follow-up artifact. +type FollowupArtifactResult struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + ArtifactPath string `json:"artifact_path"` + Manifest ImportArtifactManifest `json:"manifest"` + PendingTodos []RepairPendingTodo `json:"pending_todos"` + Guidance []string `json:"guidance"` +} + +// CreateFollowupArtifact writes a fresh artifact containing pending rows from a reviewed failed execution. +func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (*FollowupArtifactResult, error) { + if strings.TrimSpace(outDir) == "" { + return nil, fmt.Errorf("follow-up artifact output directory is required") + } + if !opts.Reviewed { + return nil, fmt.Errorf("--reviewed required after reviewing Basecamp state and the repair summary") + } + if samePath(artifactDir, outDir) { + return nil, fmt.Errorf("follow-up artifact output must be different from the source artifact") + } + if _, err := os.Stat(filepath.Join(outDir, artifactExecutionFileName)); err == nil { + return nil, fmt.Errorf("follow-up artifact output already contains execution.json") + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("checking follow-up output: %w", err) + } + + manifest, rows, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + repair, err := RepairArtifact(artifactDir) + if err != nil { + return nil, err + } + if repair.Status != "review_required" { + return nil, fmt.Errorf("follow-up artifact requires repair status review_required, got %q", repair.Status) + } + if len(repair.PendingTodos) == 0 { + return nil, fmt.Errorf("follow-up artifact has no pending todos") + } + + projectID, projectName, err := followupProject(manifest, repair.CompletedOperations) + if err != nil { + return nil, err + } + listIDs := followupTodolistIDs(repair.CompletedOperations) + completedTodoRows := completedTodoSourceRows(repair.CompletedOperations) + + pendingRows := make([]artifactTodoRow, 0, len(repair.PendingTodos)) + pendingSummaries := make([]RepairPendingTodo, 0, len(repair.PendingTodos)) + for _, row := range rows { + if _, completed := completedTodoRows[row.SourceRow]; completed { + continue + } + resolvedListID := row.TodolistID + listName := strings.TrimSpace(row.TodolistName) + if listName == "" { + listName = "Imported todos" + } + if resolvedListID == 0 { + resolvedListID = listIDs[listName] + } + if resolvedListID == 0 { + return nil, fmt.Errorf("source row %d cannot be added to follow-up artifact because todolist %q has no created ID in execution.json", row.SourceRow, listName) + } + row.ProjectID = projectID + row.ProjectName = projectName + row.TodolistID = resolvedListID + row.TodolistName = listName + pendingRows = append(pendingRows, row) + pendingSummaries = append(pendingSummaries, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: listName}) + } + if len(pendingRows) == 0 { + return nil, fmt.Errorf("follow-up artifact has no pending rows") + } + + firstTodolistID := formatOptionalInt64(pendingRows[0].TodolistID) + followupDestination := manifest.Destination + followupDestination.Mode = "existing_project" + followupDestination.ProjectID = projectID + followupDestination.ProjectName = projectName + followupDestination.TodolistStrategy = "existing_todolist" + followupDestination.TodolistID = firstTodolistID + followupDestination.TodolistName = pendingRows[0].TodolistName + + followupManifest := *manifest + followupManifest.Status = "compiled" + followupManifest.Destination = followupDestination + followupManifest.Counts = PlanCounts{Todos: len(pendingRows)} + followupManifest.Files = ArtifactFiles{Todos: artifactTodosFileName} + + if err := writeArtifact(outDir, followupManifest, pendingRows); err != nil { + return nil, err + } + return &FollowupArtifactResult{ + SchemaVersion: planSchemaVersion, + Status: "compiled", + ArtifactPath: outDir, + Manifest: followupManifest, + PendingTodos: pendingSummaries, + Guidance: []string{ + "Review the follow-up artifact plan before preflight and execution.", + "The source artifact remains closed and must not be rerun.", + }, + }, nil +} + +func followupProject(manifest *ImportArtifactManifest, operations []ExecutionLedgerOperation) (string, string, error) { + if manifest.Destination.Mode == "existing_project" { + if strings.TrimSpace(manifest.Destination.ProjectID) == "" { + return "", "", fmt.Errorf("source artifact destination project_id is required") + } + return manifest.Destination.ProjectID, manifest.Destination.ProjectName, nil + } + for _, op := range operations { + if op.Op == "create_project" && op.Status == "completed" && op.CreatedID != 0 { + return strconv.FormatInt(op.CreatedID, 10), op.ProjectName, nil + } + } + return "", "", fmt.Errorf("new_project follow-up requires a completed create_project operation in execution.json") +} + +func followupTodolistIDs(operations []ExecutionLedgerOperation) map[string]int64 { + out := make(map[string]int64) + for _, op := range operations { + if op.Op != "create_todolist" || op.Status != "completed" { + continue + } + name := strings.TrimSpace(op.TodolistName) + if name == "" { + name = "Imported todos" + } + id := op.CreatedID + if id == 0 { + id = op.TodolistID + } + if id != 0 { + out[name] = id + } + } + return out +} + +func completedTodoSourceRows(operations []ExecutionLedgerOperation) map[int]struct{} { + out := make(map[int]struct{}) + for _, op := range operations { + if op.Op == "create_todo" && op.Status == "completed" && op.SourceRow != 0 { + out[op.SourceRow] = struct{}{} + } + } + return out +} + +func samePath(a, b string) bool { + absA, errA := filepath.Abs(a) + absB, errB := filepath.Abs(b) + if errA != nil || errB != nil { + return a == b + } + return absA == absB +} diff --git a/internal/importer/followup_test.go b/internal/importer/followup_test.go new file mode 100644 index 00000000..4d8e7be8 --- /dev/null +++ b/internal/importer/followup_test.go @@ -0,0 +1,81 @@ +package importer + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestCreateFollowupArtifactRequiresReviewed(t *testing.T) { + outDir := failedExecutionArtifact(t) + _, err := CreateFollowupArtifact(outDir, filepath.Join(t.TempDir(), "followup"), FollowupOptions{}) + if err == nil || !strings.Contains(err.Error(), "--reviewed required") { + t.Fatalf("expected reviewed error, got %v", err) + } +} + +func TestCreateFollowupArtifactFromFailedExistingTodolistExecution(t *testing.T) { + artifactDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": assertError("boom")}} + _, _ = ExecuteArtifact(context.Background(), artifactDir, client, ExecuteOptions{Approved: true}) + + followupDir := filepath.Join(t.TempDir(), "followup") + result, err := CreateFollowupArtifact(artifactDir, followupDir, FollowupOptions{Reviewed: true}) + if err != nil { + t.Fatalf("CreateFollowupArtifact() error = %v", err) + } + if result.Status != "compiled" || result.Manifest.Counts.Todos != 1 || len(result.PendingTodos) != 1 { + t.Fatalf("result = %+v", result) + } + manifest, rows, err := readArtifact(followupDir) + if err != nil { + t.Fatalf("read followup: %v", err) + } + if manifest.Destination.TodolistStrategy != "existing_todolist" || manifest.Destination.TodolistID != "456" { + t.Fatalf("destination = %+v", manifest.Destination) + } + if len(rows) != 1 || rows[0].SourceRow != 2 || rows[0].Title != "Second" || rows[0].TodolistID != 456 { + t.Fatalf("rows = %+v", rows) + } +} + +func TestCreateFollowupArtifactUsesCreatedTodolistIDs(t *testing.T) { + artifactDir := failedExecutionArtifact(t) + followupDir := filepath.Join(t.TempDir(), "followup") + + _, err := CreateFollowupArtifact(artifactDir, followupDir, FollowupOptions{Reviewed: true}) + if err != nil { + t.Fatalf("CreateFollowupArtifact() error = %v", err) + } + manifest, rows, err := readArtifact(followupDir) + if err != nil { + t.Fatalf("read followup: %v", err) + } + if manifest.Counts.Todos != 1 || manifest.Counts.Todolists != 0 || manifest.Destination.TodolistStrategy != "existing_todolist" { + t.Fatalf("manifest = %+v", manifest) + } + if rows[0].SourceRow != 2 || rows[0].TodolistID == 0 { + t.Fatalf("rows = %+v", rows) + } +} + +func TestCreateFollowupArtifactRejectsCompletedExecution(t *testing.T) { + artifactDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + if _, err := ExecuteArtifact(context.Background(), artifactDir, &fakeWriteClient{}, ExecuteOptions{Approved: true}); err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + + _, err := CreateFollowupArtifact(artifactDir, filepath.Join(t.TempDir(), "followup"), FollowupOptions{Reviewed: true}) + if err == nil || !strings.Contains(err.Error(), "review_required") { + t.Fatalf("expected review_required error, got %v", err) + } +} + +func failedExecutionArtifact(t *testing.T) string { + t.Helper() + artifactDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": assertError("boom")}} + _, _ = ExecuteArtifact(context.Background(), artifactDir, client, ExecuteOptions{Approved: true}) + return artifactDir +} diff --git a/internal/importer/planner.go b/internal/importer/planner.go new file mode 100644 index 00000000..5294a83d --- /dev/null +++ b/internal/importer/planner.go @@ -0,0 +1,570 @@ +package importer + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" +) + +const planSchemaVersion = 1 + +// MappingConfig records user-confirmed CSV-to-Basecamp mapping choices. +type MappingConfig struct { + SchemaVersion int `json:"schema_version"` + RecordID *ColumnRef `json:"record_id,omitempty"` + Title *ColumnRef `json:"title,omitempty"` + Description *ColumnRef `json:"description,omitempty"` + Todolist *ColumnRef `json:"todolist,omitempty"` + Status *ColumnRef `json:"status,omitempty"` + Assignees *ColumnRef `json:"assignees,omitempty"` + DueOn *ColumnRef `json:"due_on,omitempty"` + AttachmentURLs []ColumnRef `json:"attachment_urls,omitempty"` + Comments []ColumnRef `json:"comments,omitempty"` + CustomFields string `json:"custom_fields,omitempty"` +} + +// ColumnRef identifies a CSV column by stable index and optional display name. +type ColumnRef struct { + ColumnIndex int `json:"column_index"` + ColumnName string `json:"column_name,omitempty"` + MappingPolicy string `json:"mapping_policy,omitempty"` + DateOrder string `json:"date_order,omitempty"` +} + +// DestinationConfig records the Basecamp destination choices for a plan. +type DestinationConfig struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + TodolistStrategy string `json:"todolist_strategy,omitempty"` + TodolistID string `json:"todolist_id,omitempty"` + TodolistName string `json:"todolist_name,omitempty"` +} + +// Plan describes the deterministic dry-run generated from confirmed mappings. +type Plan struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + RequiresUserInput bool `json:"requires_user_input"` + SourceFingerprint Fingerprint `json:"source_fingerprint"` + Destination DestinationConfig `json:"destination"` + Counts PlanCounts `json:"counts"` + DryRunMarkdown string `json:"dry_run_markdown"` + Operations []PlannedOperation `json:"operations"` + Questions []MappingQuestion `json:"questions"` + Warnings []ImportWarning `json:"warnings"` +} + +// PlanCounts summarizes planned write operations. +type PlanCounts struct { + Projects int `json:"projects"` + Todolists int `json:"todolists"` + Todos int `json:"todos"` +} + +// PlannedOperation is one Basecamp write that can be executed after approval. +type PlannedOperation struct { + Op string `json:"op"` + SourceRow int `json:"source_row,omitempty"` + SourceRecordID string `json:"source_record_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + TodolistID string `json:"todolist_id,omitempty"` + TodolistName string `json:"todolist_name,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + DueOn string `json:"due_on,omitempty"` + Assignees []string `json:"assignees,omitempty"` + AttachmentURLs []string `json:"attachment_urls,omitempty"` + Comments []string `json:"comments,omitempty"` + CustomFields map[string]string `json:"custom_fields,omitempty"` +} + +// PlanImport builds a deterministic dry-run from an inspection, mapping, and destination. +func PlanImport(inspection *Inspection, mapping *MappingConfig, destination *DestinationConfig) (*Plan, error) { + if inspection == nil { + return nil, fmt.Errorf("inspection is required") + } + if mapping == nil { + return nil, fmt.Errorf("mapping is required") + } + if destination == nil { + return nil, fmt.Errorf("destination is required") + } + if inspection.SchemaVersion != inspectionSchemaVersion { + return nil, fmt.Errorf("unsupported inspection schema_version %d", inspection.SchemaVersion) + } + if mapping.SchemaVersion != planSchemaVersion { + return nil, fmt.Errorf("unsupported mapping schema_version %d", mapping.SchemaVersion) + } + if destination.SchemaVersion != planSchemaVersion { + return nil, fmt.Errorf("unsupported destination schema_version %d", destination.SchemaVersion) + } + + records, err := recordsForInspection(inspection) + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, fmt.Errorf("inspection source contains no rows") + } + rows := records[1:] + if len(rows) != inspection.RowCount { + return nil, fmt.Errorf("inspection row count %d does not match source row count %d", inspection.RowCount, len(rows)) + } + + if err := validateMappingIndexes(inspection, mapping); err != nil { + return nil, err + } + + plan := &Plan{ + SchemaVersion: planSchemaVersion, + Status: "ready_for_approval", + SourceFingerprint: inspection.Fingerprint, + Destination: *destination, + Warnings: make([]ImportWarning, 0), + Questions: make([]MappingQuestion, 0), + } + + if mapping.Title == nil { + plan.RequiresUserInput = true + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_title_column", Prompt: "Which column should become the Basecamp todo title?", Choices: candidateIndexes(inspection.RoleCandidates["title"])}) + } + if destination.Mode == "" { + plan.RequiresUserInput = true + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_destination", Prompt: "Which Basecamp project should receive the imported todos?"}) + } + if destination.TodolistStrategy == "create_from_column" && mapping.Todolist == nil { + plan.RequiresUserInput = true + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_todolist_column", Prompt: "Which column should group todos into Basecamp todolists?", Choices: candidateIndexes(inspection.RoleCandidates["todolist"])}) + } + if assigneeNeedsPolicy(rows, mapping) { + plan.RequiresUserInput = true + plan.Warnings = append(plan.Warnings, ImportWarning{Code: "ambiguous_assignee_values", Columns: []int{mapping.Assignees.ColumnIndex}, Message: "Assignee values include display names. Choose an assignee mapping policy before planning assignments."}) + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_assignee_policy", Prompt: "How should assignee display names be handled?", Choices: []int{mapping.Assignees.ColumnIndex}}) + } + if plan.RequiresUserInput { + plan.Status = "requires_user_input" + plan.DryRunMarkdown = renderDryRunMarkdown(plan) + return plan, nil + } + + operations := make([]PlannedOperation, 0, len(rows)+4) + switch destination.Mode { + case "new_project": + if strings.TrimSpace(destination.ProjectName) == "" { + return nil, fmt.Errorf("destination project_name is required for new_project mode") + } + operations = append(operations, PlannedOperation{Op: "create_project", ProjectName: strings.TrimSpace(destination.ProjectName)}) + plan.Counts.Projects = 1 + case "existing_project": + if strings.TrimSpace(destination.ProjectID) == "" && strings.TrimSpace(destination.ProjectName) == "" { + return nil, fmt.Errorf("destination project_id or project_name is required for existing_project mode") + } + default: + return nil, fmt.Errorf("unsupported destination mode %q", destination.Mode) + } + + listNames := plannedTodolistNames(rows, mapping, destination) + if shouldCreateTodolists(destination) { + for _, name := range listNames { + operations = append(operations, PlannedOperation{Op: "create_todolist", ProjectID: destination.ProjectID, ProjectName: destination.ProjectName, TodolistName: name}) + } + plan.Counts.Todolists = len(listNames) + } + + dueOnValues, err := normalizedDueOnValues(rows, mapping) + if err != nil { + return nil, err + } + + mapped := mappedColumnIndexes(mapping) + duplicateColumns := duplicateColumnIndexes(inspection.Columns) + for rowIndex, row := range rows { + title := valueAt(row, mapping.Title.ColumnIndex) + if strings.TrimSpace(title) == "" { + plan.Warnings = append(plan.Warnings, ImportWarning{Code: "blank_title", Columns: []int{mapping.Title.ColumnIndex}, Message: fmt.Sprintf("Source row %d has a blank title and will be skipped by execution.", rowIndex+1)}) + } + + op := PlannedOperation{ + Op: "create_todo", + SourceRow: rowIndex + 1, + SourceRecordID: mappedValue(row, mapping.RecordID), + ProjectID: destination.ProjectID, + ProjectName: destination.ProjectName, + TodolistID: destination.TodolistID, + TodolistName: todolistNameForRow(row, mapping, destination), + Title: title, + Description: mappedValue(row, mapping.Description), + Status: mappedValue(row, mapping.Status), + DueOn: dueOnValues[rowIndex], + Assignees: assigneesForRow(row, mapping), + AttachmentURLs: mappedValues(row, mapping.AttachmentURLs), + Comments: mappedValues(row, mapping.Comments), + CustomFields: customFieldsForRow(row, inspection.Columns, mapped, duplicateColumns, mapping.CustomFields), + } + operations = append(operations, op) + plan.Counts.Todos++ + } + + plan.Operations = operations + plan.DryRunMarkdown = renderDryRunMarkdown(plan) + return plan, nil +} + +// ReadInspectionFile reads an inspection JSON file. Files may contain either raw data or a CLI JSON envelope. +func ReadInspectionFile(path string) (*Inspection, error) { + var inspection Inspection + if err := readJSONData(path, &inspection); err != nil { + return nil, err + } + return &inspection, nil +} + +// ReadMappingFile reads a mapping JSON file. +func ReadMappingFile(path string) (*MappingConfig, error) { + var mapping MappingConfig + if err := readJSONData(path, &mapping); err != nil { + return nil, err + } + return &mapping, nil +} + +// ReadDestinationFile reads a destination JSON file. +func ReadDestinationFile(path string) (*DestinationConfig, error) { + var destination DestinationConfig + if err := readJSONData(path, &destination); err != nil { + return nil, err + } + return &destination, nil +} + +func recordsForInspection(inspection *Inspection) ([][]string, error) { + data, err := os.ReadFile(inspection.ExportPath) + if err != nil { + return nil, fmt.Errorf("read inspected CSV: %w", err) + } + if inspection.Fingerprint.Algorithm != "sha256-file-v1" { + return nil, fmt.Errorf("unsupported fingerprint algorithm %q", inspection.Fingerprint.Algorithm) + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != inspection.Fingerprint.Value { + return nil, fmt.Errorf("inspected CSV fingerprint changed") + } + delimiter, err := delimiterRune(inspection.Dialect.Delimiter) + if err != nil { + return nil, err + } + return readCSV(data, delimiter) +} + +func delimiterRune(value string) (rune, error) { + if value == "\\t" { + return '\t', nil + } + runes := []rune(value) + if len(runes) != 1 { + return 0, fmt.Errorf("invalid CSV delimiter %q", value) + } + return runes[0], nil +} + +func validateMappingIndexes(inspection *Inspection, mapping *MappingConfig) error { + refs := []struct { + role string + ref *ColumnRef + }{ + {"record_id", mapping.RecordID}, + {"title", mapping.Title}, + {"description", mapping.Description}, + {"todolist", mapping.Todolist}, + {"status", mapping.Status}, + {"assignees", mapping.Assignees}, + {"due_on", mapping.DueOn}, + } + for _, item := range refs { + if err := validateMappingColumnRef(inspection, item.role, item.ref); err != nil { + return err + } + } + for i := range mapping.AttachmentURLs { + if err := validateMappingColumnRef(inspection, fmt.Sprintf("attachment_urls[%d]", i), &mapping.AttachmentURLs[i]); err != nil { + return err + } + } + for i := range mapping.Comments { + if err := validateMappingColumnRef(inspection, fmt.Sprintf("comments[%d]", i), &mapping.Comments[i]); err != nil { + return err + } + } + return nil +} + +func validateMappingColumnRef(inspection *Inspection, role string, ref *ColumnRef) error { + if ref == nil { + return nil + } + maxIndex := len(inspection.Columns) - 1 + if ref.ColumnIndex < 0 || ref.ColumnIndex > maxIndex { + return fmt.Errorf("mapping %s column_index %d is outside available columns 0..%d", role, ref.ColumnIndex, maxIndex) + } + providedName := strings.TrimSpace(ref.ColumnName) + if providedName == "" { + return nil + } + expectedName := inspection.Columns[ref.ColumnIndex].Name + if providedName != expectedName { + return fmt.Errorf("mapping %s column_index %d points to column_name %q, but mapping provides %q", role, ref.ColumnIndex, expectedName, providedName) + } + return nil +} + +func assigneeNeedsPolicy(rows [][]string, mapping *MappingConfig) bool { + if mapping.Assignees == nil || mapping.Assignees.MappingPolicy != "" { + return false + } + for _, row := range rows { + for _, value := range splitPeople(valueAt(row, mapping.Assignees.ColumnIndex)) { + if value != "" && !emailRE.MatchString(value) { + return true + } + } + } + return false +} + +func plannedTodolistNames(rows [][]string, mapping *MappingConfig, destination *DestinationConfig) []string { + if destination.TodolistStrategy == "existing_todolist" { + return nil + } + if destination.TodolistStrategy == "single_todolist" || mapping.Todolist == nil { + name := strings.TrimSpace(destination.TodolistName) + if name == "" { + name = "Imported todos" + } + return []string{name} + } + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, row := range rows { + name := strings.TrimSpace(valueAt(row, mapping.Todolist.ColumnIndex)) + if name == "" { + name = "Imported todos" + } + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + out = append(out, name) + } + } + return out +} + +func shouldCreateTodolists(destination *DestinationConfig) bool { + return destination.TodolistStrategy == "" || destination.TodolistStrategy == "single_todolist" || destination.TodolistStrategy == "create_from_column" +} + +func todolistNameForRow(row []string, mapping *MappingConfig, destination *DestinationConfig) string { + if destination.TodolistStrategy == "existing_todolist" { + return destination.TodolistName + } + if destination.TodolistStrategy == "single_todolist" || mapping.Todolist == nil { + if strings.TrimSpace(destination.TodolistName) != "" { + return strings.TrimSpace(destination.TodolistName) + } + return "Imported todos" + } + value := strings.TrimSpace(valueAt(row, mapping.Todolist.ColumnIndex)) + if value == "" { + return "Imported todos" + } + return value +} + +func mappedColumnIndexes(mapping *MappingConfig) map[int]struct{} { + mapped := make(map[int]struct{}) + add := func(ref *ColumnRef) { + if ref != nil { + mapped[ref.ColumnIndex] = struct{}{} + } + } + add(mapping.RecordID) + add(mapping.Title) + add(mapping.Description) + add(mapping.Todolist) + add(mapping.Status) + add(mapping.Assignees) + add(mapping.DueOn) + for i := range mapping.AttachmentURLs { + mapped[mapping.AttachmentURLs[i].ColumnIndex] = struct{}{} + } + for i := range mapping.Comments { + mapped[mapping.Comments[i].ColumnIndex] = struct{}{} + } + return mapped +} + +func duplicateColumnIndexes(columns []ColumnProfile) map[int]struct{} { + out := make(map[int]struct{}) + for _, column := range columns { + if column.DuplicateName { + out[column.Index] = struct{}{} + } + } + return out +} + +func customFieldsForRow(row []string, columns []ColumnProfile, mapped, duplicateColumns map[int]struct{}, policy string) map[string]string { + if policy != "all_unmapped_columns" { + return nil + } + out := make(map[string]string) + for _, column := range columns { + if _, ok := mapped[column.Index]; ok { + continue + } + value := strings.TrimSpace(valueAt(row, column.Index)) + if value == "" { + continue + } + name := column.Name + if _, duplicated := duplicateColumns[column.Index]; duplicated { + name = fmt.Sprintf("%s [%d]", name, column.Index) + } + out[name] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func mappedValue(row []string, ref *ColumnRef) string { + if ref == nil { + return "" + } + return valueAt(row, ref.ColumnIndex) +} + +func mappedValues(row []string, refs []ColumnRef) []string { + out := make([]string, 0, len(refs)) + for _, ref := range refs { + value := strings.TrimSpace(valueAt(row, ref.ColumnIndex)) + if value != "" { + out = append(out, value) + } + } + return out +} + +func assigneesForRow(row []string, mapping *MappingConfig) []string { + if mapping.Assignees == nil { + return nil + } + values := splitPeople(valueAt(row, mapping.Assignees.ColumnIndex)) + out := make([]string, 0, len(values)) + for _, value := range values { + if emailRE.MatchString(value) { + out = append(out, value) + continue + } + if mapping.Assignees.MappingPolicy == "include_display_names" { + out = append(out, value) + } + } + return out +} + +func splitPeople(value string) []string { + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' + }) + out := make([]string, 0, len(fields)) + for _, field := range fields { + field = strings.TrimSpace(field) + if field != "" { + out = append(out, field) + } + } + return out +} + +func valueAt(row []string, index int) string { + if index < 0 || index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) +} + +func renderDryRunMarkdown(plan *Plan) string { + var b strings.Builder + b.WriteString("# Import dry run\n\n") + if plan.RequiresUserInput { + b.WriteString("Status: requires user input\n\n") + } else { + b.WriteString("Status: ready for approval\n\n") + } + b.WriteString("## Planned writes\n\n") + fmt.Fprintf(&b, "- Projects: %d\n", plan.Counts.Projects) + fmt.Fprintf(&b, "- Todolists: %d\n", plan.Counts.Todolists) + fmt.Fprintf(&b, "- Todos: %d\n", plan.Counts.Todos) + + if len(plan.Operations) > 0 { + b.WriteString("\n## Operations\n\n") + for _, op := range plan.Operations { + switch op.Op { + case "create_project": + fmt.Fprintf(&b, "- Create project: %s\n", op.ProjectName) + case "create_todolist": + fmt.Fprintf(&b, "- Create todolist: %s\n", op.TodolistName) + case "create_todo": + fmt.Fprintf(&b, "- Row %d: create todo %q", op.SourceRow, op.Title) + if op.TodolistName != "" { + fmt.Fprintf(&b, " in %q", op.TodolistName) + } + b.WriteString("\n") + } + } + } + + if len(plan.Warnings) > 0 { + b.WriteString("\n## Warnings\n\n") + for _, warning := range plan.Warnings { + fmt.Fprintf(&b, "- %s: %s\n", warning.Code, warning.Message) + } + } + if len(plan.Questions) > 0 { + b.WriteString("\n## Questions\n\n") + for _, question := range plan.Questions { + fmt.Fprintf(&b, "- %s: %s\n", question.ID, question.Prompt) + } + } + return b.String() +} + +func readJSONData(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read JSON: %w", err) + } + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("parse JSON: %w", err) + } + if len(envelope.Data) > 0 { + if err := json.Unmarshal(envelope.Data, target); err != nil { + return fmt.Errorf("parse JSON data: %w", err) + } + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse JSON: %w", err) + } + return nil +} diff --git a/internal/importer/planner_test.go b/internal/importer/planner_test.go new file mode 100644 index 00000000..238dc363 --- /dev/null +++ b/internal/importer/planner_test.go @@ -0,0 +1,334 @@ +package importer + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPlanImportCreatesDeterministicDryRun(t *testing.T) { + inspection := inspectTempCSV(t, `id,title,notes,list,status,owner,due,link,priority +T-1,Buy paint,"Get blue, low VOC",Home,todo,alex@example.com,2026-06-01,https://example.com/a,High +T-2,Fix gate,Latch sticks,Home,doing,jamie@example.com,2026-06-02,,Low +T-3,Book venue,Call two places,Events,todo,,2026-06-03,https://example.com/b, +`) + + mapping := &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "id"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "title"}, + Description: &ColumnRef{ColumnIndex: 2, ColumnName: "notes"}, + Todolist: &ColumnRef{ColumnIndex: 3, ColumnName: "list"}, + Status: &ColumnRef{ColumnIndex: 4, ColumnName: "status"}, + Assignees: &ColumnRef{ColumnIndex: 5, ColumnName: "owner"}, + DueOn: &ColumnRef{ColumnIndex: 6, ColumnName: "due"}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 7, ColumnName: "link"}}, + CustomFields: "all_unmapped_columns", + } + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Status != "ready_for_approval" || plan.RequiresUserInput { + t.Fatalf("plan status = %s requires_user_input=%v", plan.Status, plan.RequiresUserInput) + } + if plan.Counts.Projects != 0 || plan.Counts.Todolists != 2 || plan.Counts.Todos != 3 { + t.Fatalf("counts = %+v", plan.Counts) + } + if len(plan.Operations) != 5 { + t.Fatalf("operation count = %d, want 5", len(plan.Operations)) + } + if plan.Operations[0].Op != "create_todolist" || plan.Operations[0].TodolistName != "Home" { + t.Fatalf("first op = %+v", plan.Operations[0]) + } + if plan.Operations[2].Op != "create_todo" || plan.Operations[2].Title != "Buy paint" || plan.Operations[2].TodolistName != "Home" { + t.Fatalf("first todo op = %+v", plan.Operations[2]) + } + if got := plan.Operations[2].CustomFields["priority"]; got != "High" { + t.Fatalf("custom priority = %q, want High", got) + } + if !strings.Contains(plan.DryRunMarkdown, "- Todolists: 2") || !strings.Contains(plan.DryRunMarkdown, "Row 1: create todo \"Buy paint\"") { + t.Fatalf("dry run markdown missing expected content:\n%s", plan.DryRunMarkdown) + } +} + +func TestPlanImportRequiresTitleMapping(t *testing.T) { + inspection := inspectTempCSV(t, "id,task\n1,Do the thing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, RecordID: &ColumnRef{ColumnIndex: 0}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "single_todolist", TodolistName: "Imported"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Status != "requires_user_input" || !plan.RequiresUserInput { + t.Fatalf("plan status = %s requires_user_input=%v", plan.Status, plan.RequiresUserInput) + } + if !planHasQuestion(plan, "confirm_title_column") { + t.Fatalf("expected confirm_title_column question, got %+v", plan.Questions) + } + if len(plan.Operations) != 0 { + t.Fatalf("expected no operations while user input is required, got %+v", plan.Operations) + } +} + +func TestPlanImportRequiresAssigneePolicyForDisplayNames(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,owner\n1,Do the thing,Alex Rivera\n") + mapping := &MappingConfig{ + SchemaVersion: planSchemaVersion, + Title: &ColumnRef{ColumnIndex: 1}, + Assignees: &ColumnRef{ColumnIndex: 2}, + } + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "single_todolist", TodolistName: "Imported"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Status != "requires_user_input" || !planHasQuestion(plan, "confirm_assignee_policy") || !planHasWarning(plan, "ambiguous_assignee_values") { + t.Fatalf("expected assignee policy gate, plan = %+v", plan) + } + + mapping.Assignees.MappingPolicy = "leave_unassigned_when_ambiguous" + plan, err = PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() with policy error = %v", err) + } + if plan.RequiresUserInput { + t.Fatalf("expected ready plan with assignee policy, got %+v", plan) + } + if len(plan.Operations) != 2 { // create_todolist + create_todo + t.Fatalf("operation count = %d, want 2", len(plan.Operations)) + } + if len(plan.Operations[1].Assignees) != 0 { + t.Fatalf("display-name assignee should be left unassigned, got %+v", plan.Operations[1].Assignees) + } +} + +func TestPlanImportRejectsChangedFingerprint(t *testing.T) { + path := writeTempCSV(t, "id,title\n1,Do the thing\n") + inspection, err := InspectCSV(path, InspectOptions{SampleSize: 1}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + if err := os.WriteFile(path, []byte("id,title\n1,Changed\n"), 0o644); err != nil { + t.Fatalf("change CSV: %v", err) + } + + _, err = PlanImport(inspection, &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}}, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"}) + if err == nil || !strings.Contains(err.Error(), "fingerprint changed") { + t.Fatalf("expected fingerprint changed error, got %v", err) + } +} + +func TestReadInspectionFileAcceptsCLIEnvelope(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + path := filepath.Join(t.TempDir(), "inspection.json") + data := `{"ok":true,"data":{"schema_version":1,"status":"profiled","format":"csv","export_path":"` + inspection.ExportPath + `","fingerprint":{"algorithm":"sha256-file-v1","value":"` + inspection.Fingerprint.Value + `"},"dialect":{"delimiter":",","has_header":true,"encoding":"utf-8"},"row_count":1,"columns":[],"role_candidates":{},"sample_rows":[],"warnings":[],"questions":[]}}` + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatalf("write inspection envelope: %v", err) + } + + read, err := ReadInspectionFile(path) + if err != nil { + t.Fatalf("ReadInspectionFile() error = %v", err) + } + if read.SchemaVersion != 1 || read.ExportPath != inspection.ExportPath { + t.Fatalf("unexpected inspection: %+v", read) + } +} + +func TestPlanImportRejectsMissingColumnIndex(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + _, err := PlanImport(inspection, &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 99}}, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"}) + if err == nil || !strings.Contains(err.Error(), "outside available columns") { + t.Fatalf("expected missing column error, got %v", err) + } +} + +func TestPlanImportValidatesMappingColumnName(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due,link,comment\n1,Do the thing,2026-06-01,https://example.com,Ready\n") + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + mapping := &MappingConfig{ + SchemaVersion: planSchemaVersion, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "title"}, + DueOn: &ColumnRef{ColumnIndex: 2, ColumnName: "due"}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 3, ColumnName: "link"}}, + Comments: []ColumnRef{{ColumnIndex: 4, ColumnName: "comment"}}, + } + if _, err := PlanImport(inspection, mapping, destination); err != nil { + t.Fatalf("PlanImport() with matching column_name error = %v", err) + } + + mapping.Title.ColumnName = "due" + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "mapping title column_index 1") || !strings.Contains(err.Error(), "\"title\"") || !strings.Contains(err.Error(), "\"due\"") { + t.Fatalf("expected title column_name mismatch error, got %v", err) + } +} + +func TestPlanImportAllowsEmptyMappingColumnName(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1, ColumnName: ""}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + if _, err := PlanImport(inspection, mapping, destination); err != nil { + t.Fatalf("PlanImport() with empty column_name error = %v", err) + } +} + +func TestPlanImportValidatesArrayMappingColumnName(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,link\n1,Do the thing,https://example.com\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, AttachmentURLs: []ColumnRef{{ColumnIndex: 2, ColumnName: "wrong"}}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "mapping attachment_urls[0] column_index 2") || !strings.Contains(err.Error(), "\"link\"") || !strings.Contains(err.Error(), "\"wrong\"") { + t.Fatalf("expected attachment_urls column_name mismatch error, got %v", err) + } +} + +func TestPlanImportNormalizesSafeDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,ISO,2026-06-01\n2,RFC3339,2026-06-02T14:30:00Z\n3,YMD Slash,2026/06/03\n4,Month name,\"June 4, 2026\"\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + got := []string{plan.Operations[0].DueOn, plan.Operations[1].DueOn, plan.Operations[2].DueOn, plan.Operations[3].DueOn} + want := []string{"2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("due_on[%d] = %q, want %q (all due_on values: %+v)", i, got[i], want[i], got) + } + } +} + +func TestPlanImportInfersMDYSlashDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,06/18/2026\n2,Second,06/19/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Operations[0].DueOn != "2026-06-18" || plan.Operations[1].DueOn != "2026-06-19" { + t.Fatalf("due dates = %q, %q", plan.Operations[0].DueOn, plan.Operations[1].DueOn) + } +} + +func TestPlanImportInfersDMYSlashDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,18/06/2026\n2,Second,19/06/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Operations[0].DueOn != "2026-06-18" || plan.Operations[1].DueOn != "2026-06-19" { + t.Fatalf("due dates = %q, %q", plan.Operations[0].DueOn, plan.Operations[1].DueOn) + } +} + +func TestPlanImportUsesExplicitSlashDateOrder(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,06/01/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2, DateOrder: "dmy"}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + plan, err := PlanImport(inspection, mapping, destination) + if err != nil { + t.Fatalf("PlanImport() error = %v", err) + } + if plan.Operations[0].DueOn != "2026-01-06" { + t.Fatalf("due_on = %q, want 2026-01-06", plan.Operations[0].DueOn) + } +} + +func TestPlanImportRejectsAmbiguousSlashDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,06/01/2026\n2,Second,07/02/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "date_order") || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous date_order error, got %v", err) + } +} + +func TestPlanImportRejectsConflictingSlashDueDates(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,06/18/2026\n2,Second,18/06/2026\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "conflicting date orders") { + t.Fatalf("expected conflicting date order error, got %v", err) + } +} + +func TestPlanImportRejectsUnparseableDueDate(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,not soon\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "source row 1") || !strings.Contains(err.Error(), "unsupported date format") { + t.Fatalf("expected source-row date parse error, got %v", err) + } +} + +func TestPlanImportRejectsTwoDigitYearDueDate(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,due\n1,First,06/01/26\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}, DueOn: &ColumnRef{ColumnIndex: 2, DateOrder: "mdy"}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456"} + + _, err := PlanImport(inspection, mapping, destination) + if err == nil || !strings.Contains(err.Error(), "two-digit years") { + t.Fatalf("expected two-digit year error, got %v", err) + } +} + +func inspectTempCSV(t *testing.T, content string) *Inspection { + t.Helper() + path := writeTempCSV(t, content) + inspection, err := InspectCSV(path, InspectOptions{SampleSize: 2}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + return inspection +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tasks.csv") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write CSV: %v", err) + } + return path +} + +func planHasQuestion(plan *Plan, id string) bool { + for _, question := range plan.Questions { + if question.ID == id { + return true + } + } + return false +} + +func planHasWarning(plan *Plan, code string) bool { + for _, warning := range plan.Warnings { + if warning.Code == code { + return true + } + } + return false +} diff --git a/internal/importer/preflight.go b/internal/importer/preflight.go new file mode 100644 index 00000000..168f2d06 --- /dev/null +++ b/internal/importer/preflight.go @@ -0,0 +1,254 @@ +package importer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ArtifactPreflightClient performs Basecamp reads for artifact readiness checks. +type ArtifactPreflightClient interface { + ExistingTodolists(ctx context.Context, projectID int64) ([]ExistingTodolist, error) + ExistingTodos(ctx context.Context, todolistID int64) ([]ExistingTodo, error) +} + +// ExistingTodolist describes a Basecamp todolist considered during preflight. +type ExistingTodolist struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// ExistingTodo describes a Basecamp todo considered during preflight. +type ExistingTodo struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +// PreflightResult reports readiness checks for an import artifact. +type PreflightResult struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Checks []PreflightCheck `json:"checks"` + Collisions []TodolistCollision `json:"collisions,omitempty"` + TodoCollisions []TodoCollision `json:"todo_collisions,omitempty"` +} + +// PreflightCheck reports one artifact readiness check. +type PreflightCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` +} + +// TodolistCollision reports an existing Basecamp todolist with the same name as a planned created todolist. +type TodolistCollision struct { + Name string `json:"name"` + ExistingID int64 `json:"existing_id"` +} + +// TodoCollision reports an existing Basecamp todo with the same title as a planned imported todo. +type TodoCollision struct { + SourceRow int `json:"source_row"` + Title string `json:"title"` + TodolistID int64 `json:"todolist_id"` + ExistingID int64 `json:"existing_id"` +} + +// PreflightArtifact checks an artifact for readiness before execution and performs no writes. +func PreflightArtifact(ctx context.Context, artifactDir string, client ArtifactPreflightClient) (*PreflightResult, error) { + manifest, rows, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + + result := &PreflightResult{SchemaVersion: planSchemaVersion, Status: "passed", Checks: []PreflightCheck{}} + ledgerCheck := preflightExecutionLedgerCheck(artifactDir) + if ledgerCheck.Status == "blocked" { + result.Checks = append(result.Checks, ledgerCheck) + result.Status = "blocked" + return result, nil + } + result.Checks = append(result.Checks, ledgerCheck) + + if client == nil && manifest.Destination.Mode == "existing_project" { + return nil, fmt.Errorf("import preflight requires a read client") + } + + if err := checkPreflightTodolistCollisions(ctx, result, manifest, rows, client); err != nil { + return nil, err + } + if result.Status == "blocked" { + return result, nil + } + if err := checkPreflightTodoCollisions(ctx, result, manifest, rows, client); err != nil { + return nil, err + } + return result, nil +} + +func checkPreflightTodolistCollisions(ctx context.Context, result *PreflightResult, manifest *ImportArtifactManifest, rows []artifactTodoRow, client ArtifactPreflightClient) error { + if manifest.Destination.Mode != "existing_project" || !shouldCreateTodolists(&manifest.Destination) { + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "passed", Message: "Artifact execution does not create todolists in an existing project."}) + return nil + } + + plannedNames := artifactTodolistNames(rows) + if len(plannedNames) == 0 { + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "passed", Message: "Artifact execution does not create todolists."}) + return nil + } + + projectID, err := parseOptionalInt64(manifest.Destination.ProjectID) + if err != nil { + result.Status = "blocked" + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "blocked", Message: fmt.Sprintf("Invalid artifact destination project_id: %v", err)}) + return nil + } + if projectID == 0 { + result.Status = "blocked" + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "blocked", Message: "Artifact destination project_id is required to check todolist collisions."}) + return nil + } + + existing, err := client.ExistingTodolists(ctx, projectID) + if err != nil { + return err + } + collisions := todolistCollisions(plannedNames, existing) + if len(collisions) > 0 { + result.Status = "blocked" + result.Collisions = collisions + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "blocked", Message: fmt.Sprintf("%d planned todolist name(s) already exist in the destination project.", len(collisions))}) + return nil + } + + result.Checks = append(result.Checks, PreflightCheck{Name: "todolist_name_collisions", Status: "passed", Message: fmt.Sprintf("Checked %d planned todolist name(s) against existing Basecamp todolists.", len(plannedNames))}) + return nil +} + +func checkPreflightTodoCollisions(ctx context.Context, result *PreflightResult, manifest *ImportArtifactManifest, rows []artifactTodoRow, client ArtifactPreflightClient) error { + if manifest.Destination.Mode != "existing_project" || manifest.Destination.TodolistStrategy != "existing_todolist" { + result.Checks = append(result.Checks, PreflightCheck{Name: "todo_title_collisions", Status: "passed", Message: "Artifact execution does not add todos to an existing todolist."}) + return nil + } + targets, targetIssue := todoCollisionTargets(manifest, rows) + if targetIssue != "" { + result.Status = "blocked" + result.Checks = append(result.Checks, PreflightCheck{Name: "todo_title_collisions", Status: "blocked", Message: targetIssue}) + return nil + } + + allCollisions := make([]TodoCollision, 0) + checked := 0 + for todolistID, targetRows := range targets { + existing, err := client.ExistingTodos(ctx, todolistID) + if err != nil { + return err + } + allCollisions = append(allCollisions, todoCollisions(targetRows, todolistID, existing)...) + checked += len(targetRows) + } + if len(allCollisions) > 0 { + result.Status = "blocked" + result.TodoCollisions = allCollisions + result.Checks = append(result.Checks, PreflightCheck{Name: "todo_title_collisions", Status: "blocked", Message: fmt.Sprintf("%d planned todo title(s) already exist in destination todolists.", len(allCollisions))}) + return nil + } + result.Checks = append(result.Checks, PreflightCheck{Name: "todo_title_collisions", Status: "passed", Message: fmt.Sprintf("Checked %d planned todo title(s) against existing Basecamp todos.", checked)}) + return nil +} + +func todoCollisionTargets(manifest *ImportArtifactManifest, rows []artifactTodoRow) (map[int64][]artifactTodoRow, string) { + manifestID, err := parseOptionalInt64(manifest.Destination.TodolistID) + if err != nil { + return nil, fmt.Sprintf("invalid destination todolist_id: %v", err) + } + targets := make(map[int64][]artifactTodoRow) + for _, row := range rows { + id := row.TodolistID + if id == 0 { + id = manifestID + } + if id == 0 { + return nil, "artifact destination todolist_id is required to check todo title collisions" + } + targets[id] = append(targets[id], row) + } + return targets, "" +} + +func preflightExecutionLedgerCheck(artifactDir string) PreflightCheck { + path := filepath.Join(artifactDir, artifactExecutionFileName) + if _, err := os.Stat(path); err == nil { + return PreflightCheck{Name: "execution_ledger", Status: "blocked", Message: "Artifact already has execution.json; execution refuses to run again."} + } else if !os.IsNotExist(err) { + return PreflightCheck{Name: "execution_ledger", Status: "blocked", Message: fmt.Sprintf("Execution ledger cannot be checked: %v", err)} + } + return PreflightCheck{Name: "execution_ledger", Status: "passed", Message: "No execution ledger exists for this artifact."} +} + +func todoCollisions(rows []artifactTodoRow, todolistID int64, existing []ExistingTodo) []TodoCollision { + byTitle := make(map[string]ExistingTodo) + for _, todo := range existing { + title := strings.TrimSpace(todo.Title) + if title == "" { + continue + } + byTitle[strings.ToLower(title)] = ExistingTodo{ID: todo.ID, Title: title} + } + collisions := make([]TodoCollision, 0) + for _, row := range rows { + title := strings.TrimSpace(row.Title) + if title == "" { + continue + } + if existing, ok := byTitle[strings.ToLower(title)]; ok { + collisions = append(collisions, TodoCollision{SourceRow: row.SourceRow, Title: title, TodolistID: todolistID, ExistingID: existing.ID}) + } + } + return collisions +} + +func todolistCollisions(plannedNames []string, existing []ExistingTodolist) []TodolistCollision { + byName := make(map[string]ExistingTodolist) + for _, list := range existing { + name := strings.TrimSpace(list.Name) + if name == "" { + continue + } + byName[strings.ToLower(name)] = ExistingTodolist{ID: list.ID, Name: name} + } + collisions := make([]TodolistCollision, 0) + for _, planned := range plannedNames { + name := strings.TrimSpace(planned) + if name == "" { + name = "Imported todos" + } + if existing, ok := byName[strings.ToLower(name)]; ok { + collisions = append(collisions, TodolistCollision{Name: name, ExistingID: existing.ID}) + } + } + return collisions +} + +// BlockedMessage summarizes blockers for command errors. +func (r *PreflightResult) BlockedMessage() string { + if r == nil || r.Status != "blocked" { + return "" + } + messages := make([]string, 0, len(r.Checks)+len(r.Collisions)+len(r.TodoCollisions)) + for _, check := range r.Checks { + if check.Status == "blocked" { + messages = append(messages, check.Message) + } + } + for _, collision := range r.Collisions { + messages = append(messages, fmt.Sprintf("Todolist %q already exists with ID %d.", collision.Name, collision.ExistingID)) + } + for _, collision := range r.TodoCollisions { + messages = append(messages, fmt.Sprintf("Todo %q from source row %d already exists with ID %d.", collision.Title, collision.SourceRow, collision.ExistingID)) + } + return strings.Join(messages, " ") +} diff --git a/internal/importer/preflight_test.go b/internal/importer/preflight_test.go new file mode 100644 index 00000000..58a27c92 --- /dev/null +++ b/internal/importer/preflight_test.go @@ -0,0 +1,130 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakePreflightClient struct { + todolists []ExistingTodolist + todos []ExistingTodo + todosByList map[int64][]ExistingTodo +} + +func (f fakePreflightClient) ExistingTodolists(ctx context.Context, projectID int64) ([]ExistingTodolist, error) { + return f.todolists, nil +} + +func (f fakePreflightClient) ExistingTodos(ctx context.Context, todolistID int64) ([]ExistingTodo, error) { + if f.todosByList != nil { + return f.todosByList[todolistID], nil + } + return f.todos, nil +} + +func TestPreflightArtifactPassesWithoutCollisions(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := fakePreflightClient{todolists: []ExistingTodolist{{ID: 1, Name: "Existing"}}} + + result, err := PreflightArtifact(context.Background(), outDir, client) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "passed" || len(result.Collisions) != 0 { + t.Fatalf("result = %+v", result) + } + if !preflightHasCheck(result, "execution_ledger", "passed") || !preflightHasCheck(result, "todolist_name_collisions", "passed") { + t.Fatalf("checks = %+v", result.Checks) + } +} + +func TestPreflightArtifactBlocksTodolistNameCollisions(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := fakePreflightClient{todolists: []ExistingTodolist{{ID: 10, Name: "backlog"}}} + + result, err := PreflightArtifact(context.Background(), outDir, client) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "blocked" || len(result.Collisions) != 1 { + t.Fatalf("result = %+v", result) + } + if result.Collisions[0].Name != "Backlog" || result.Collisions[0].ExistingID != 10 { + t.Fatalf("collisions = %+v", result.Collisions) + } + if !strings.Contains(result.BlockedMessage(), "Backlog") { + t.Fatalf("blocked message = %q", result.BlockedMessage()) + } +} + +func TestPreflightArtifactBlocksTodoTitleCollisions(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + client := fakePreflightClient{todos: []ExistingTodo{{ID: 99, Title: "first"}}} + + result, err := PreflightArtifact(context.Background(), outDir, client) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "blocked" || len(result.TodoCollisions) != 1 { + t.Fatalf("result = %+v", result) + } + collision := result.TodoCollisions[0] + if collision.SourceRow != 1 || collision.Title != "First" || collision.TodolistID != 456 || collision.ExistingID != 99 { + t.Fatalf("todo collision = %+v", collision) + } + if !strings.Contains(result.BlockedMessage(), "source row 1") { + t.Fatalf("blocked message = %q", result.BlockedMessage()) + } +} + +func TestPreflightArtifactChecksExistingTodolistTodos(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + client := fakePreflightClient{todos: []ExistingTodo{{ID: 99, Title: "Already there"}}} + + result, err := PreflightArtifact(context.Background(), outDir, client) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "passed" || !preflightHasCheck(result, "todo_title_collisions", "passed") { + t.Fatalf("result = %+v", result) + } +} + +func TestPreflightArtifactBlocksExistingExecutionLedger(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + if err := os.WriteFile(filepath.Join(outDir, artifactExecutionFileName), []byte(`{"status":"completed"}`), 0o644); err != nil { + t.Fatalf("write ledger: %v", err) + } + + result, err := PreflightArtifact(context.Background(), outDir, fakePreflightClient{}) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "blocked" || !preflightHasCheck(result, "execution_ledger", "blocked") { + t.Fatalf("result = %+v", result) + } +} + +func TestPreflightArtifactSkipsCollisionCheckForNewProject(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "new_project", ProjectName: "Imported", TodolistStrategy: "create_from_column"}) + + result, err := PreflightArtifact(context.Background(), outDir, nil) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "passed" || !preflightHasCheck(result, "todolist_name_collisions", "passed") { + t.Fatalf("result = %+v", result) + } +} + +func preflightHasCheck(result *PreflightResult, name, status string) bool { + for _, check := range result.Checks { + if check.Name == name && check.Status == status { + return true + } + } + return false +} diff --git a/internal/importer/repair.go b/internal/importer/repair.go new file mode 100644 index 00000000..05488702 --- /dev/null +++ b/internal/importer/repair.go @@ -0,0 +1,102 @@ +package importer + +// RepairResult summarizes a local artifact execution ledger for recovery review. +type RepairResult struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + ArtifactPath string `json:"artifact_path"` + ExecutionStatus string `json:"execution_status,omitempty"` + Created ExecuteCounts `json:"created,omitempty"` + CompletedOperations []ExecutionLedgerOperation `json:"completed_operations,omitempty"` + FailedOperations []ExecutionLedgerOperation `json:"failed_operations,omitempty"` + PendingTodos []RepairPendingTodo `json:"pending_todos,omitempty"` + Guidance []string `json:"guidance"` +} + +// RepairPendingTodo identifies an artifact todo row that has no completed ledger operation. +type RepairPendingTodo struct { + SourceRow int `json:"source_row"` + SourceRecordID string `json:"source_record_id,omitempty"` + Title string `json:"title"` + TodolistName string `json:"todolist_name,omitempty"` +} + +// RepairArtifact reads local artifact and execution files and summarizes recovery state. +func RepairArtifact(artifactDir string) (*RepairResult, error) { + _, rows, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + status, err := StatusArtifact(artifactDir) + if err != nil { + return nil, err + } + + result := &RepairResult{ + SchemaVersion: planSchemaVersion, + Status: "not_executed", + ArtifactPath: artifactDir, + Guidance: []string{"This artifact has no execution ledger. Run preflight before approved execution."}, + } + if status.Execution == nil { + if status.Status == "ledger_unreadable" { + result.Status = "ledger_unreadable" + result.Guidance = []string{"The execution ledger cannot be read. Inspect execution.json before using this artifact."} + } + return result, nil + } + + ledger := status.Execution + result.ExecutionStatus = ledger.Status + result.Created = ledger.Created + result.CompletedOperations, result.FailedOperations = splitLedgerOperations(ledger.Operations) + result.PendingTodos = pendingTodosForRepair(rows, ledger.Operations) + + switch ledger.Status { + case "completed": + result.Status = "completed" + result.Guidance = []string{"Execution completed. This artifact is closed and cannot be executed again."} + case "failed", "started": + result.Status = "review_required" + result.Guidance = []string{ + "Review completed_operations against Basecamp before taking further action.", + "Review failed_operations and pending_todos before creating a fresh follow-up artifact.", + "Do not remove execution.json to rerun this artifact.", + } + default: + result.Status = "review_required" + result.Guidance = []string{"The execution ledger has an unrecognized status. Review execution.json before using this artifact."} + } + return result, nil +} + +func splitLedgerOperations(operations []ExecutionLedgerOperation) ([]ExecutionLedgerOperation, []ExecutionLedgerOperation) { + completed := make([]ExecutionLedgerOperation, 0) + failed := make([]ExecutionLedgerOperation, 0) + for _, op := range operations { + switch op.Status { + case "completed": + completed = append(completed, op) + case "failed": + failed = append(failed, op) + } + } + return completed, failed +} + +func pendingTodosForRepair(rows []artifactTodoRow, operations []ExecutionLedgerOperation) []RepairPendingTodo { + completedRows := make(map[int]struct{}) + for _, op := range operations { + if op.Op == "create_todo" && op.Status == "completed" && op.SourceRow != 0 { + completedRows[op.SourceRow] = struct{}{} + } + } + pending := make([]RepairPendingTodo, 0) + for _, row := range rows { + if _, ok := completedRows[row.SourceRow]; ok { + continue + } + pending = append(pending, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: row.TodolistName}) + } + return pending +} diff --git a/internal/importer/repair_test.go b/internal/importer/repair_test.go new file mode 100644 index 00000000..7082a858 --- /dev/null +++ b/internal/importer/repair_test.go @@ -0,0 +1,77 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestRepairArtifactReportsNotExecuted(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + + result, err := RepairArtifact(outDir) + if err != nil { + t.Fatalf("RepairArtifact() error = %v", err) + } + if result.Status != "not_executed" || result.ExecutionStatus != "" { + t.Fatalf("result = %+v", result) + } +} + +func TestRepairArtifactReportsCompleted(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + if _, err := ExecuteArtifact(context.Background(), outDir, &fakeWriteClient{}, ExecuteOptions{Approved: true}); err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + + result, err := RepairArtifact(outDir) + if err != nil { + t.Fatalf("RepairArtifact() error = %v", err) + } + if result.Status != "completed" || result.ExecutionStatus != "completed" || result.Created.Todos != 2 { + t.Fatalf("result = %+v", result) + } + if len(result.PendingTodos) != 0 || len(result.CompletedOperations) != 2 { + t.Fatalf("result = %+v", result) + } +} + +func TestRepairArtifactReportsFailedPartialExecution(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": assertError("boom")}} + _, _ = ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + + result, err := RepairArtifact(outDir) + if err != nil { + t.Fatalf("RepairArtifact() error = %v", err) + } + if result.Status != "review_required" || result.ExecutionStatus != "failed" { + t.Fatalf("result = %+v", result) + } + if len(result.FailedOperations) != 1 || result.FailedOperations[0].SourceRow != 2 { + t.Fatalf("failed operations = %+v", result.FailedOperations) + } + if len(result.PendingTodos) != 1 || result.PendingTodos[0].SourceRow != 2 || result.PendingTodos[0].Title != "Second" { + t.Fatalf("pending todos = %+v", result.PendingTodos) + } +} + +func TestRepairArtifactReportsLedgerUnreadable(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + if err := writeExecutionLedger(filepath.Join(outDir, artifactExecutionFileName), &ExecutionLedger{Status: "started"}); err != nil { + t.Fatalf("write ledger: %v", err) + } + // Replace with malformed JSON after creating the file portably. + if err := os.WriteFile(filepath.Join(outDir, artifactExecutionFileName), []byte("{"), 0o644); err != nil { + t.Fatalf("write malformed ledger: %v", err) + } + + result, err := RepairArtifact(outDir) + if err != nil { + t.Fatalf("RepairArtifact() error = %v", err) + } + if result.Status != "ledger_unreadable" { + t.Fatalf("result = %+v", result) + } +} diff --git a/internal/importer/status.go b/internal/importer/status.go new file mode 100644 index 00000000..7f436573 --- /dev/null +++ b/internal/importer/status.go @@ -0,0 +1,89 @@ +package importer + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ArtifactStatus reports the local state of a compiled import artifact. +type ArtifactStatus struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + ArtifactPath string `json:"artifact_path"` + ArtifactFormat string `json:"artifact_format"` + SourcePath string `json:"source_path"` + SourceFingerprint Fingerprint `json:"source_fingerprint"` + Destination DestinationConfig `json:"destination"` + Counts PlanCounts `json:"counts"` + Files ArtifactFiles `json:"files"` + Execution *ExecutionLedger `json:"execution,omitempty"` + Checks []ArtifactStatusCheck `json:"checks"` + Guidance string `json:"guidance,omitempty"` +} + +// ArtifactStatusCheck reports one local artifact status check. +type ArtifactStatusCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` +} + +// StatusArtifact reads local artifact files and reports execution state without Basecamp access. +func StatusArtifact(artifactDir string) (*ArtifactStatus, error) { + manifest, _, err := readArtifact(artifactDir) + if err != nil { + return nil, err + } + + status := &ArtifactStatus{ + SchemaVersion: planSchemaVersion, + Status: "not_executed", + ArtifactPath: artifactDir, + ArtifactFormat: manifest.ArtifactFormat, + SourcePath: manifest.SourcePath, + SourceFingerprint: manifest.SourceFingerprint, + Destination: manifest.Destination, + Counts: manifest.Counts, + Files: manifest.Files, + Checks: []ArtifactStatusCheck{{Name: "artifact", Status: "passed", Message: "Artifact manifest and todo CSV are valid."}}, + Guidance: "Run preflight before approved execution.", + } + + ledgerPath := filepath.Join(artifactDir, artifactExecutionFileName) + ledger, err := readExecutionLedger(ledgerPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + status.Checks = append(status.Checks, ArtifactStatusCheck{Name: "execution_ledger", Status: "not_found", Message: "No execution ledger exists for this artifact."}) + return status, nil + } + status.Status = "ledger_unreadable" + status.Checks = append(status.Checks, ArtifactStatusCheck{Name: "execution_ledger", Status: "blocked", Message: fmt.Sprintf("Execution ledger cannot be read: %v", err)}) + status.Guidance = "Review or remove the unreadable execution ledger before using this artifact." + return status, nil + } + + status.Execution = ledger + status.Status = ledger.Status + status.Checks = append(status.Checks, ArtifactStatusCheck{Name: "execution_ledger", Status: "found", Message: "Execution ledger exists for this artifact."}) + switch ledger.Status { + case "completed": + status.Guidance = "Execution completed. The artifact cannot be executed again." + case "failed": + status.Guidance = "Execution failed after possible partial writes. Review Basecamp and the ledger before creating a fresh follow-up artifact." + case "started": + status.Guidance = "Execution started and did not record completion. Review Basecamp and the ledger before creating a fresh follow-up artifact." + default: + status.Guidance = "Execution ledger has an unrecognized status. Review the ledger before using this artifact." + } + return status, nil +} + +func readExecutionLedger(path string) (*ExecutionLedger, error) { + var ledger ExecutionLedger + if err := readJSONData(path, &ledger); err != nil { + return nil, err + } + return &ledger, nil +} diff --git a/internal/importer/status_test.go b/internal/importer/status_test.go new file mode 100644 index 00000000..547fdece --- /dev/null +++ b/internal/importer/status_test.go @@ -0,0 +1,71 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestStatusArtifactReportsNotExecuted(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + + status, err := StatusArtifact(outDir) + if err != nil { + t.Fatalf("StatusArtifact() error = %v", err) + } + if status.Status != "not_executed" || status.Execution != nil { + t.Fatalf("status = %+v", status) + } + if status.Counts.Todos != 2 || status.ArtifactFormat != artifactFormat { + t.Fatalf("status summary = %+v", status) + } +} + +func TestStatusArtifactReportsCompletedExecution(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + if _, err := ExecuteArtifact(context.Background(), outDir, &fakeWriteClient{}, ExecuteOptions{Approved: true}); err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + + status, err := StatusArtifact(outDir) + if err != nil { + t.Fatalf("StatusArtifact() error = %v", err) + } + if status.Status != "completed" || status.Execution == nil || status.Execution.Created.Todos != 2 { + t.Fatalf("status = %+v", status) + } +} + +func TestStatusArtifactReportsFailedExecution(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) + client := &fakeWriteClient{failTodoRows: map[string]error{"Second": assertError("boom")}} + _, _ = ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + + status, err := StatusArtifact(outDir) + if err != nil { + t.Fatalf("StatusArtifact() error = %v", err) + } + if status.Status != "failed" || status.Execution == nil || status.Execution.Error == "" { + t.Fatalf("status = %+v", status) + } +} + +func TestStatusArtifactReportsUnreadableLedger(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) + if err := os.WriteFile(filepath.Join(outDir, artifactExecutionFileName), []byte("{"), 0o644); err != nil { + t.Fatalf("write ledger: %v", err) + } + + status, err := StatusArtifact(outDir) + if err != nil { + t.Fatalf("StatusArtifact() error = %v", err) + } + if status.Status != "ledger_unreadable" || status.Execution != nil { + t.Fatalf("status = %+v", status) + } +} + +type assertError string + +func (e assertError) Error() string { return string(e) } diff --git a/testdata/import/csv/README.md b/testdata/import/csv/README.md new file mode 100644 index 00000000..a046bee8 --- /dev/null +++ b/testdata/import/csv/README.md @@ -0,0 +1,33 @@ +# Import CSV testdata + +CSV fixtures for the generic `basecamp import inspect` profiler and later import planning tests. + +## Layout + +- `canonical/` — redacted, real-shape CSV exports collected from public examples. These are broad profiler regression fixtures and are not source-specific parser contracts. +- `synthetic/` — deterministic small CSVs used for planning, safety, and LLM eval scenarios. + +## Canonical fixture counts + +- `canonical/asana/` — 4 CSVs +- `canonical/clickup/` — 4 CSVs +- `canonical/jira/` — 6 CSVs +- `canonical/linear/` — 4 CSVs +- `canonical/trello/` — 1 CSV + +## Privacy and provenance + +Local copies are redacted. Emails have been mapped to `@example.com`, obvious person/account identifiers have been replaced, and source URLs/source-derived filenames have been removed. The fixtures preserve CSV shape: headers, duplicate headers, row/column structure, quoting, multiline fields, and representative values. + +## Intended use + +The import engine should treat these as arbitrary CSVs and produce factual profiles: + +- dialect/header information +- row and column counts +- duplicate headers +- per-column value statistics +- likely role candidates such as title, description, status, assignee, date, URL, and parent-reference columns +- safe sample rows + +Do not implement vendor-specific detection or presets for the initial generic profiler. diff --git a/testdata/import/csv/canonical/asana/sample-01.csv b/testdata/import/csv/canonical/asana/sample-01.csv new file mode 100644 index 00000000..0066821f --- /dev/null +++ b/testdata/import/csv/canonical/asana/sample-01.csv @@ -0,0 +1,4 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent task,Blocked By (Dependencies),Blocking (Dependencies),Team,Priority,Type,Domain,Size,Status,OLD TASK,Shipped,Stakeholder_Visible +1210195246380032,2025-05-08,,2025-05-08,Improve recommendations,Ready,Person 1,,,,,Here are more details about this ticket,MonoBacklog,,,,AI,P1,,,,,,,Yes +1210183850312776,2025-05-07,2025-05-07,2025-05-07,Add new feature to the voice library,In Development,,,,,,,MonoBacklog,,,,IO,P3,,,,,,2025-05-07,Yes +1210172728188995,2025-05-06,,2025-05-06,Improve user experience of audio editing,Design needed,,,,,,Here are more details about this ticket,MonoBacklog,,,,Workflows,P2,,,,,,,Yes diff --git a/testdata/import/csv/canonical/asana/sample-02.csv b/testdata/import/csv/canonical/asana/sample-02.csv new file mode 100644 index 00000000..0f528cab --- /dev/null +++ b/testdata/import/csv/canonical/asana/sample-02.csv @@ -0,0 +1,142 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent Task,Owner,Status,Billable (Additional),Priority,Notes,Project Manager,SOW,Invoiced,Priority +1.20176E+15,2/3/22,2/3/22,2/3/22,Initial Theme_r0,Strategy,,,,11/17/21,Client View,,TEST Project Schedule,,Person 2,,,,,,Deliverable,,Medium +1.20176E+15,2/3/22,2/3/22,2/3/22,Refined Theme_r1 ,Strategy,,,,12/2/21,Client View,,TEST Project Schedule,,Person 2,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Theme approved,Strategy,,,,12/8/21,Client View,,TEST Project Schedule,,Person 2,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Initial Simplified Content Outline,Strategy,,,,12/7/21,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Refined Content Outline_r1,Strategy,,,,12/14/21,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Content Outline approved,Strategy,,,,12/15/21,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Pagination,Strategy,,,,1/26/22,Client View,,TEST Project Schedule,,Person 2,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Initial Design Concept design_r0 (initial design directions),PDF Report Design,,,,12/15/21,Client View,,TEST Project Schedule,,Person 4,,,,,,Deliverable,,High +1.20176E+15,2/3/22,2/3/22,2/3/22,Refined design_r1,PDF Report Design,,,,1/5/22,Client View,,TEST Project Schedule,,Person 4,,,,,,Deliverable,,Medium +1.20176E+15,2/3/22,2/3/22,2/3/22,**Additional cover options (OOS but NOT BILLABLE),PDF Report Design,,,,1/12/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,**Revised design (OOS but NOT BILLABLE),PDF Report Design,,,,1/17/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Design concept approved,PDF Report Design,,,,1/19/22,Client View,,TEST Project Schedule,,Person 4,,,,,,Deliverable,,Low +1.20176E+15,2/3/22,,2/3/22,Initial design options,Proxy Covers Design,,,,,Client View,"From: Michelle Marks +Subject: Re: Proxy cover/back cover design +Date: January 6, 2022 at 3:20:59 PM EST +To: Francesca De Girolami + +Hi Francesca, + +We’re happy to help. We’ll send a few design options for consideration, make refinements to the preferred option and then send InDesign or hi res PDF production files to TCCC or the Proxy typesetter/printer (just let us know). We’d like to get an approved ESG cover first so they can be a set, if possible. We understand this will likely need to be finalized by the first week in February. + + +Initial direction from client attached. + + +",TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Refine selected option,Proxy Covers Design,,,,,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Printer sweeps/prep,Proxy Covers Design,,,,,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Release to printer,Proxy Covers Design,,,,,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Initial typesetting_r0 - PDF #1 (initial layout),Report Production,,,,2/16/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r1 - PDF #2,Report Production,,,,3/2/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r2 - PDF #3,Report Production,,,,3/16/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r3 - PDF #4,Report Production,,,,3/30/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Proofreading (Optional),Report Production,,,3/31/22,4/14/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r4 - PDF #5,Report Production,,,,4/12/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Assets for website_r0,Report Production,,,,4/12/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Assets for website approved,Report Production,,,,4/12/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Assets for website ,Report Production,,,,4/13/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r5- PDF #6,Report Production,,,,4/18/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Typesetting_r6 - PDF #7,Report Production,,,,4/19/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,FINAL Typesetting_r7 - PDF #8 (last scoped),Report Production,,,,4/19/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,PDF typesetting approved ,Report Production,,,,5/6/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Final packaged files delivered,Report Production,,,,5/6/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix design for hand over to Chris,Data Appendix Design & Production,,,2/1/22,2/21/22,,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Handover Data Appendix to Christine,Data Appendix Design & Production,,,,2/22/22,,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Chris laying out Data Appendix,Data Appendix Design & Production,,,2/23/22,3/11/22,,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Client shares Pt 1 of Appendix Data,Data Appendix Design & Production,,,,3/11/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Client shares remainder of Appendix Data,Data Appendix Design & Production,,,,3/15/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Chris populating Data Appendix,Data Appendix Design & Production,,,3/11/22,3/18/22,,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix_r0 (1 of 3),Data Appendix Design & Production,,,,3/18/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix_r1 (2 of 3),Data Appendix Design & Production,,,,3/29/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix_r2 (3 of 3),Data Appendix Design & Production,,,,4/5/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks_r0 - PDF #1 (initial layout),Frameworks Production,,,,4/19/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks_r1 - PDF #2 ,Frameworks Production,,,,4/28/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks_r2 - PDF #3,Frameworks Production,,,,5/5/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,FINAL Frameworks_r3 - PDF #4 (last scoped),Frameworks Production,,,,5/6/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks approved,Frameworks Production,,,,5/6/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks packaged files,Frameworks Production,,,,5/6/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights content to Person 4,Highlights Design,,,,3/10/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Initial Highlights Design Concept design_r0 (initial design directions) 1 of 1,Highlights Design,,,,3/15/22,Client View,,TEST Project Schedule,,Person 4,,,,,,Deliverable,,High +1.20176E+15,2/3/22,,2/3/22,Highlights Design concept approved,Highlights Design,,,,3/17/22,Client View,,TEST Project Schedule,,Person 4,,,,,,Deliverable,,Low +1.20176E+15,2/3/22,,2/3/22,Final Highlights content to Person 4,Highlights Production,,,,3/17/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Initial Highlights typesetting_r0 - PDF #1 (initial layout),Highlights Production,,,,3/24/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Typesetting_r1 - PDF #2,Highlights Production,,,,4/1/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Typesetting_r2 - PDF #3,Highlights Production,,,,4/7/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Proofreading (Optional),Highlights Production,,,4/7/22,4/8/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,FINAL Highlights Typesetting_r3 - PDF #4 (last scoped),Highlights Production,,,,4/11/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights PDF typesetting approved ,Highlights Production,,,,4/11/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Final packaged files,Highlights Production,,,,4/15/22,Client View,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Climate infographic? TBD,Associated Design Tasks,,,,,,,TEST Project Schedule,,Person 4,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Up to 5 interviews,Writing (BuzzWord),,,,,,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Early Initial Batch #1 drafts in MS Word for review; SMEs review content drafts,Writing (BuzzWord),,,,12/17/21,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Batch #2 drafts in MS Word out for review; SMEs review content drafts,Writing (BuzzWord),,,,1/14/22,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Batches #1-2 Revisions in MS Word out for review by this date; SMEs review drafts,Writing (BuzzWord),,,,1/31/22,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,MS Word drafts #1-#2 approved by this date ,Writing (BuzzWord),,,,2/8/22,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Batch #3 draft/remainder of content in MS Word out for review by this date; SMEs review content drafts,Writing (BuzzWord),,,,2/11/22,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Final: scoped Person 3 editorial review,Writing (BuzzWord),,,,4/12/22,Client View,,TEST Project Schedule,,Person 3,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Kickoff Meeting ,Key Dates + Feedback Dates (Planned),,,,10/27/21,Client View,,TEST Project Schedule,,Person 6,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,CCC011 1st invoice,,,,,,Billing Milestone,"Jessica sent [A DRAFT] 1st CCC011 invoice on Oct 26: email subj: Invoice 3432 - Coca-Cola 2021 BESGR Report. + +It'll have to be sent again when the project has a PO. ",,Kickoff Meeting ,,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,1st Weekly Meeting,Key Dates + Feedback Dates (Planned),,,,11/3/21,Client View,,TEST Project Schedule,,Person 6,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Visual Assets to Person 4 (will be ongoing throughout project),Key Dates + Feedback Dates (Planned),,,,11/23/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Refined Theme options shared with Steering Committee for feedback,Key Dates + Feedback Dates (Planned),,,,12/2/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Determine approach to WWW report by this date,Key Dates + Feedback Dates (Planned),,,,12/15/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,Determine other content approaches by this date,Key Dates + Feedback Dates (Planned),,,,12/15/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,CEO and Board Letters: draft letter outlines to Person 4,Key Dates + Feedback Dates (Planned),,,,2/4/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22, All Content received by this date,Key Dates + Feedback Dates (Planned),,,,3/15/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,All Data types received for report layout by this date (for layout),Key Dates + Feedback Dates (Planned),,,,3/15/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,All report materials received by this date,Key Dates + Feedback Dates (Planned),,,,3/15/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,TCCC expanded team/stakeholder review,Key Dates + Feedback Dates (Planned),,,,3/17/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Design Concept feedback/approval (1 of 1),Key Dates + Feedback Dates (Planned),,,,3/17/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Feedback #1,Key Dates + Feedback Dates (Planned),,,,3/29/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,CEO and Board Letters: final letters to Person 4 by this date,Key Dates + Feedback Dates (Planned),,,,3/31/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,All Assured Data to Person 4/Person 3 by this date ,Key Dates + Feedback Dates (Planned),,,,4/4/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Feedback #2,Key Dates + Feedback Dates (Planned),,,,4/5/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Highlights Feedback #3/Approval,Key Dates + Feedback Dates (Planned),,,,4/11/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Assurance Letter to Person 4 by this date,Key Dates + Feedback Dates (Planned),,,,4/11/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Feedback for Theme development,Key Dates + Feedback Dates (Planned),,,,11/23/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Feedback for Content Outline with Pagination,Key Dates + Feedback Dates (Planned),,,,12/10/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Design Concepts Feedback,Key Dates + Feedback Dates (Planned),,,,12/29/21,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Feedback for MS Word Content Batch #1,Key Dates + Feedback Dates (Planned),,,,1/7/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Feedback for MS Word Content Batch #2,Key Dates + Feedback Dates (Planned),,,,1/21/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Davos Meeting,Key Dates + Feedback Dates (Planned),,,1/17/22,1/21/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Feedback for MS Word Content Batch #3; revisions reflected in next PDF round,Key Dates + Feedback Dates (Planned),,,,2/17/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Initial aggregated changes to typeset layout to Person 4/Person 3 #1,Key Dates + Feedback Dates (Planned),,,,2/23/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,TCCC BOD Meeting,Key Dates + Feedback Dates (Planned),,,2/16/22,2/18/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,CAGNY Forum,Key Dates + Feedback Dates (Planned),,,2/22/22,2/25/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Person 4/Person 3 Word Count and Page Count- Budget Update - FEB,Key Dates + Feedback Dates (Planned),,,,2/28/22,Client View,,TEST Project Schedule,,Person 2,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Aggregated changes to typeset draft to Person 4/Person 3 #2,Key Dates + Feedback Dates (Planned),,,,3/10/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Aggregated changes to typeset layout to Person 4/Person 3 #3,Key Dates + Feedback Dates (Planned),,,,3/23/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Person 4/Person 3 Word Count and PDF Layout Page Count - Budget Update - MAR,Key Dates + Feedback Dates (Planned),,,,3/31/22,Client View,,TEST Project Schedule,,Person 7,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,"SME, Legal, and External aggregated changes (all) to typeset layout to Person 4/Person 3 #4",Key Dates + Feedback Dates (Planned),,,,4/7/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Aggregated changes to typeset layout to Person 4 (quick turnaround review) #5,Key Dates + Feedback Dates (Planned),,,,4/13/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Aggregated changes to typeset layout to Person 4 (quick turnaround review) #6,Key Dates + Feedback Dates (Planned),,,,4/15/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Aggregated changes to typeset layout to Person 4 (quick turnaround review) #7,Key Dates + Feedback Dates (Planned),,,,4/18/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks feedback #1,Key Dates + Feedback Dates (Planned),,,,4/26/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks feedback #2,Key Dates + Feedback Dates (Planned),,,,5/3/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Frameworks feedback #3,Key Dates + Feedback Dates (Planned),,,,5/5/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix feedback #1,Key Dates + Feedback Dates (Planned),,,,3/25/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix feedback #2,Key Dates + Feedback Dates (Planned),,,,3/31/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Data Appendix feedback #3,Key Dates + Feedback Dates (Planned),,,,4/5/22,Client View,,TEST Project Schedule,,Person 5,,,,,,,, +1.20176E+15,2/3/22,2/3/22,2/3/22,CCC011 – 1st payment (project initiation),Management,,,,10/13/21,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,50000, +1.20176E+15,2/3/22,2/3/22,2/3/22,"CCC011 – 2nd payment (Interviews, Content collection, Thematic exploration)",Management,,,,11/17/21,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,75000, +1.20176E+15,2/3/22,2/3/22,2/3/22,"CCC011 – 3rd payment (Outline, Initial Design, Initial partial draft of BESGR report)",Management,,,,12/8/21,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,100000, +1.20176E+15,2/3/22,2/3/22,2/3/22,CCC011 – 4th payment (Design Direction selected),Management,,,,1/19/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,45000, +1.20176E+15,2/3/22,,2/3/22,CCC011 – 5th payment (Initial review WWW Report),Management,,,,2/16/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,Budget Update (Feb),Management,,,,2/28/22,,Schedule suggests doing this Feb 28.,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,Start prepping Coke budget update info,,,,,2/14/22,,,,Budget Update (Feb),,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Word count / page count,,,,,,,,,Budget Update (Feb),,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,"ADDITION TO SCOPE: PROXY COVER $5,000",,,,,,,,,Budget Update (Feb),,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,CCC011 – 6th payment (Initial design for Highlights),Management,,,,3/16/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,Budget Update (Mar),Management,,,,3/31/22,,Schedule suggests issuing on Mar 31.,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,Start prepping Coke budget update info,,,,,3/21/22,,,,Budget Update (Mar),,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,Word Count and PDF Layout Page Count,,,,,,,,,Budget Update (Mar),,,,,,,,, +1.20176E+15,2/3/22,,2/3/22,"CCC011 – 7th payment (Digital Assets, if requested)",Management,,,,4/13/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,CCC011 – 8th payment (BESGR PDF Delivers),Management,,,,4/13/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,CCC011 – 9th payment (Highlights deliver),Management,,,,4/13/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,CCC011 – 10th payment (WWW PDF Delivers adjusted for actual scope work),Management,,,,5/18/22,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,CCC011 – Final payment (Monthly billing for changes after launch and out of scope work),Management,,,,,Billing Milestone,,TEST Project Schedule,,Person 4,,,,,Person 8,,, +1.20176E+15,2/3/22,,2/3/22,Lessons Learned,Management,,,,,,,TEST Project Schedule,,Person 4,,,,,,Deliverable,, diff --git a/testdata/import/csv/canonical/asana/sample-03.csv b/testdata/import/csv/canonical/asana/sample-03.csv new file mode 100644 index 00000000..1af078a9 --- /dev/null +++ b/testdata/import/csv/canonical/asana/sample-03.csv @@ -0,0 +1,4 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent Task +38330760681100,2015-06-18,,2022-04-15,Sensu Check: Client Keepalive,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", +38330760681093,2015-06-18,,2022-04-15,Sensu Client : remote server configuration setup,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", +15328349794967,2014-08-06,2014-09-09,2022-04-15,Add Install for ec2-ami-tools in imageServer,(no section),Person 9,user4@example.com,,2014-08-08,,http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/set-up-ami-tools.html,BrainDump,FogBugz (4866 and 4616) : Instance Store  diff --git a/testdata/import/csv/canonical/asana/sample-04.csv b/testdata/import/csv/canonical/asana/sample-04.csv new file mode 100644 index 00000000..07350623 --- /dev/null +++ b/testdata/import/csv/canonical/asana/sample-04.csv @@ -0,0 +1,26 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent task,Blocked By (Dependencies),Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocked By (Dependencies),Blocked By (Dependencies),Blocking (Dependencies),Assignee (imported),Dependents,Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported) +1210823029405447,2025-07-17,,2025-07-17,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,Untitled section,Person 10,user5@example.com,2025-07-16,2025-07-21,,"Complete documentation and collaboration strategy for TourPlan-HubSpot integration enhancement with TMIS. Includes comprehensive system diagrams, requirements framework, and collaboration protocols to ensure structured development approach and cost control.",Iwan's Board,,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405448,2025-07-17,,2025-07-17,Phase 1: System Documentation & Diagramming,,Person 10,user5@example.com,2025-07-16,2025-07-16,,Document current TourPlan-HubSpot integration architecture and create comprehensive technical diagrams. Like creating blueprints before renovation - provides complete system visibility for informed enhancement decisions and prevents costly miscommunication with TMIS.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405449,2025-07-17,,2025-07-17,Create interactive system architecture diagram,,,,2025-07-16,2025-07-16,,"Build comprehensive MermaidChart diagram showing all webhook handlers, system components, and data flow. Provides TMIS with complete technical understanding without exposing proprietary code. Essential for technical discussions and prevents scope creep. 🏗️ System Architecture Overview View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EY4T_yxKozJAnd1X9HqjaaQBbMx4N5PZXzvbr3_5WyOuGQ?e=j1Gcfj",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405450,2025-07-17,,2025-07-17,Create company webhook processing flow diagram,,,,2025-07-16,2025-07-16,,"Document detailed data flow for main business logic processing companies from HubSpot to TourPlan. Shows validation rules, error handling, and business logic. Critical for TMIS to understand core functionality before proposing enhancements. ↩️ Company Webhook Processing Flow View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EbBMbd5S9XlDuO5KGJyC-3YBApOPCYFQx0nQUIPXXLLFZA?e=P7MFGf",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405451,2025-07-17,,2025-07-17,Create complete webhook sequence diagram,,,,2025-07-16,2025-07-16,,"Build chronological interaction diagram between all system components. Shows timing, dependencies, and system behavior. Enables TMIS to understand system complexity and propose appropriate enhancement approaches. 📈 Complete Webhook Sequence View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EXN4EJ6XUgZGntuy9xatTXwBVhrdr3_6KdoMjieIyq8hiA?e=XeCBYn",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405452,2025-07-17,,2025-07-17,Upload diagrams to MermaidChart,,,,2025-07-16,2025-07-16,,Make all technical diagrams publicly accessible via MermaidChart for TMIS review. Provides complete technical transparency while maintaining code repository control. Foundation for all technical discussions.,,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405453,2025-07-17,,2025-07-17,Phase 2: TMIS Collaboration Strategy,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Develop comprehensive strategy for TMIS collaboration including cost optimization, requirements control, and GitLab access protocols. Like creating negotiation strategy before important business discussion - ensures favorable outcomes and prevents unexpected costs.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405454,2025-07-17,,2025-07-17,Create TMIS collaboration guide,,,,2025-07-16,2025-07-16,,"Comprehensive guide for Kim & Siamanta covering meeting strategy, requirements template, cost-avoidance recommendations, and TMIS collaboration protocols. Prevents TMIS from charging extra fees for requirements gathering and maintains project control.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405455,2025-07-17,,2025-07-17,Develop requirements template for Kim & Siamanta,,,,2025-07-16,2025-07-16,,"Detailed checklist covering data integration needs, workflow enhancements, technical requirements, and business rules. Internally handling requirements analysis helps approach TMIS with comprehensive specifications ready upfront.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405456,2025-07-17,,2025-07-17,Document GitLab access logical reasoning,,,,2025-07-16,2025-07-16,,Establish clear rationale for controlled repository access based on technical requirements completion. Prevents uncontrolled development work that could deviate from specifications and generate unnecessary costs.,,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405457,2025-07-17,,2025-07-17,Phase 3: Communications Framework,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Establish organized communication protocols with TMIS and internal teams. Like setting up proper channels before important negotiations - ensures clear documentation, decision tracking, and professional relationship management.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405458,2025-07-17,,2025-07-17,Create communications directory structure,,,,2025-07-16,2025-07-16,,"Organize vendor and internal communications with proper documentation standards. Enables project transparency, decision tracking, and knowledge transfer. Essential for maintaining professional relationship management.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405459,2025-07-17,,2025-07-17,Draft TMIS technical alignment email,,,,2025-07-16,2025-07-16,,Professional email requesting existing technical materials from TMIS previous discussions. Positions GitLab access as dependent on mutual technical review. Avoids duplication and establishes collaborative workflow expectations.,,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405460,2025-07-17,,2025-07-17,TMIS technical materials response,,,,2025-07-17,2025-07-23,,"TMIS responds with existing technical documentation, integration approaches, architectural notes, and implementation frameworks from previous discussions. Critical dependency - all future development planning depends on understanding their current technical position and preparedness level.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405461,2025-07-17,,2025-07-17,Phase 4: Requirements Consolidation,,,,2025-07-17,2025-07-18,,Consolidate Kim & Siamanta business requirements with TMIS technical materials to create comprehensive development specifications. Like combining architectural plans with engineering requirements - ensures all stakeholder needs are addressed in technical solution.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405462,2025-07-17,,2025-07-17,Analyze TMIS technical materials,,,,2025-07-17,2025-07-17,,"Review and analyze technical documentation received from TMIS to identify integration patterns, capabilities, and constraints. Determines feasibility of proposed enhancements and identifies potential technical challenges or limitations.",,Phase 4: Requirements Consolidation,,Integrate business and technical requirements,,,,,,,,,,,,,,,,,,,,,,, +1210823029405463,2025-07-17,,2025-07-17,Integrate business and technical requirements,,,,2025-07-18,2025-07-18,,Combine Kim & Siamanta's business requirements with TMIS technical capabilities to create unified specification document. Ensures technical solution addresses business needs while leveraging TMIS strengths effectively.,,Phase 4: Requirements Consolidation,Analyze TMIS technical materials,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405464,2025-07-17,,2025-07-17,Consolidated requirements document,,,,2025-07-18,2025-07-18,,Complete specification document combining business requirements and technical approaches. Like final blueprints before construction - provides TMIS with exact implementation requirements and prevents scope creep or additional discovery charges.,,Phase 4: Requirements Consolidation,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405465,2025-07-17,,2025-07-17,Phase 5: Future State Planning,,,,2025-07-21,2025-07-21,,"Create comprehensive future-state diagrams and implementation specifications based on consolidated requirements. Provides TMIS with complete technical vision for implementation quotes, avoiding discovery fees and ensuring accurate project scoping. + +Start development (coding) work!",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405466,2025-07-17,,2025-07-17,Create enhanced architecture diagrams,,,,2025-07-21,2025-07-21,,"Design future-state system architecture showing new integrations, enhanced workflows, and additional system components. Provides TMIS with clear technical vision and enables accurate implementation time estimates.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405467,2025-07-17,,2025-07-17,Develop detailed implementation specifications,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Document detailed technical specifications including APIs, data flows, error handling, security requirements, and integration patterns.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405468,2025-07-17,,2025-07-17,GitLab repository access,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Provide TMIS with GitLab repository access after all technical requirements, specifications, and future-state diagrams are complete. Ensures structured collaboration within defined framework and prevents uncontrolled development work.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405469,2025-07-17,,2025-07-17,TMIS implementation Workplan and Begin of Dev Work,,,,2025-07-21,2025-07-21,,"TMIS delivers precise implementation plans with estimated timelines, based on your complete specifications and future-state diagrams. Our upfront preparation ensures clarity on deliverables and schedules, empowering your team to make confident development decisions.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, diff --git a/testdata/import/csv/canonical/clickup/sample-01.csv b/testdata/import/csv/canonical/clickup/sample-01.csv new file mode 100644 index 00000000..229181ec --- /dev/null +++ b/testdata/import/csv/canonical/clickup/sample-01.csv @@ -0,0 +1,134 @@ +Task ID, Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Folder Name/Path,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +86d0b0vqw,"As a user, I want all features to be stable and secure so that I can use the system reliably.",,complete,1758001397217,"9/16/2025, 3:43:17 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0ubh,Record 5-min PM status video.,,complete,1758001236385,"9/16/2025, 3:40:36 PM GMT+10",,,,,86d0b0u0e,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gzqy,Record 5-min video demonstrating chat functionality + tests.,,complete,1757391901647,"9/9/2025, 2:25:01 PM GMT+10",,,,,86d08gzb3,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0uf5,Record Week 10 task negotiation video.,,complete,1758001246539,"9/16/2025, 3:40:46 PM GMT+10",,,,,86d0b0u0e,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hcfq,Display playlists and tracks in each playlist.,,complete,1757393528827,"9/9/2025, 2:52:08 PM GMT+10",,,,,86d08hbdw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0kkw,Add logout option.,,complete,1758000463419,"9/16/2025, 3:27:43 PM GMT+10",,,,,86d0b0j3m,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hc5q,Implement controller endpoints for playlist actions.,,complete,1757393507118,"9/9/2025, 2:51:47 PM GMT+10",,,,,86d08hbdw,[],[Person 14],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0w12,Ensure chat and audio modules are decoupled for scalability.,,complete,1758001430936,"9/16/2025, 3:43:50 PM GMT+10",,,,,86d0b0vqw,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08haey,Create AudioRepository (Spring Data JPA).,,complete,1757393330447,"9/9/2025, 2:48:50 PM GMT+10",,,,,86d08ha9y,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b10h6,Peer Programming (PP): Record a 30-min coding session each (with 2+ observers).,,complete,1758001844597,"9/16/2025, 3:50:44 PM GMT+10",,,,,86d0b10by,[],"[Person 11,Person 14,Person 12,Person 13,user6@example.com]",[all],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0tew,Create component diagram showing chat vs audio scaling.,,complete,1758001144155,"9/16/2025, 3:39:04 PM GMT+10",,,,,86d0b0t2d,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gyad,Refresh/re-render chat history after sending.,,complete,1757391760443,"9/9/2025, 2:22:40 PM GMT+10",,,,,86d08gw9b,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0yuv,"Finalise sequence diagrams (login, chat, playlist, iTunes API).",,complete,1758001684876,"9/16/2025, 3:48:04 PM GMT+10",,,,,86d0b0xmj,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0zuv,Final sprint plan (Week 11 tasks completed).,,complete,1758001783633,"9/16/2025, 3:49:43 PM GMT+10",,,,,86d0b0zp0,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdkb,Record a 5-min video demo of audio + playlist features + tests running.,,complete,1757393614497,"9/9/2025, 2:53:34 PM GMT+10",,,,,86d08hdae,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gwg0,"Create Message entity with fields (id, sender, content, timestamp).",,complete,1757391526780,"9/9/2025, 2:18:46 PM GMT+10",,,,,86d08gw9b,[],[],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hbdw,"As a user, I want to create and share playlists so that I can organise my audio tracks.",,complete,1757393446025,"9/9/2025, 2:50:46 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gzkk,Verify DB persistence using Spring Data JPA test.,,complete,1757391891766,"9/9/2025, 2:24:51 PM GMT+10",,,,,86d08gzb3,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08haj1,Update ApplicationRunner to preload sample audio tracks.,,complete,1757393349211,"9/9/2025, 2:49:09 PM GMT+10",,,,,86d08ha9y,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0t99,"Update class diagrams (UserService, API integration classes).",,complete,1758001123538,"9/16/2025, 3:38:43 PM GMT+10",,,,,86d0b0t2d,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hb6x,Link UI actions to backend endpoints.,,complete,1757393424412,"9/9/2025, 2:50:24 PM GMT+10",,,,,86d08ha9y,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdqd,Update ERD to include AudioTrack and Playlist entities (with relationships).,,complete,1757393637137,"9/9/2025, 2:53:57 PM GMT+10",,,,,86d08hdp4,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0tc3,Create sequence diagram for “User searches track from iTunes API and saves it”.,,complete,1758001133351,"9/16/2025, 3:38:53 PM GMT+10",,,,,86d0b0t2d,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hawq,Create Thymeleaf/console UI form for uploading audio.,,complete,1757393394934,"9/9/2025, 2:49:54 PM GMT+10",,,,,86d08ha9y,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gy7n,Hook form submission → controller (POST new message).,,complete,1757391750122,"9/9/2025, 2:22:30 PM GMT+10",,,,,86d08gw9b,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0m64,"As a user, I want to rate audio tracks and add favourites so I can personalise my music.",,complete,1758000534490,"9/16/2025, 3:28:54 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b10pf,"Demonstration of Understanding (DoU): Record a 10-min video each, explaining a different use case (chat, playlist, multi-user, favourites, API).",,complete,1758001858169,"9/16/2025, 3:50:58 PM GMT+10",,,,,86d0b10by,[],"[Person 11,Person 14,Person 12,Person 13,user6@example.com]",[all],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08heec,Record task negotiation video (team assigning Week 9 tasks).,,complete,1757393713662,"9/9/2025, 2:55:13 PM GMT+10",,,,,86d08he0y,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0t6p,"Update ERD (add User entity, favourites relationship)",,complete,1758001114105,"9/16/2025, 3:38:34 PM GMT+10",,,,,86d0b0t2d,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0m9d,Add rating field in AudioTrack entity (1–5 stars).,,complete,1758000551227,"9/16/2025, 3:29:11 PM GMT+10",,,,,86d0b0m64,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h3gz,"Create Message entity with fields (id, sender, content, timestamp).",,complete,1757392398050,"9/9/2025, 2:33:18 PM GMT+10",,,,,86d08gw9b,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gwxh,Add ApplicationRunner to seed sample chat messages.,,complete,1757391590434,"9/9/2025, 2:19:50 PM GMT+10",,,,,86d08gw9b,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0u7v,"Update Risk Register (API dependency, concurrency risks).",,complete,1758001225921,"9/16/2025, 3:40:25 PM GMT+10",,,,,86d0b0u0e,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0k4a,Modify chat and playlist services to work per user.,,complete,1758000420197,"9/16/2025, 3:27:00 PM GMT+10",,,,,86d0b0j3m,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b10by,"As a student, I want to show my contributions so my work is graded fairly.",,complete,1758001829467,"9/16/2025, 3:50:29 PM GMT+10",,,,,null,[],[],[all],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0wr0,Verify seamless flow: login → chat → audio → playlists → ratings/favourites.,,complete,1758001482509,"9/16/2025, 3:44:42 PM GMT+10",,,,,86d0b0vqw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0kd,Create simple ERD (Message entity in H2 DB).,,complete,1757392014250,"9/9/2025, 2:26:54 PM GMT+10",,,,,86d08h0dq,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0xc7,Run full test coverage report.,,complete,1758001531030,"9/16/2025, 3:45:31 PM GMT+10",,,,,86d0b0wzh,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gy4e,Display chat history (list of messages).,,complete,1757391739725,"9/9/2025, 2:22:19 PM GMT+10",,,,,86d08gw9b,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0rn2,Mock iTunes API responses for test coverage.,,complete,1758001041870,"9/16/2025, 3:37:21 PM GMT+10",,,,,86d0b0rfq,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0rfq,"As a quality manager, I want to test the new features so I can confirm they scale and work as intended.",,complete,1758001019755,"9/16/2025, 3:36:59 PM GMT+10",,,,,null,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0yr6,Finalise class diagrams with all services/controllers.,,complete,1758001674909,"9/16/2025, 3:47:54 PM GMT+10",,,,,86d0b0xmj,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0yzy,Record 5-min final design explanation video.,,complete,1758001706108,"9/16/2025, 3:48:26 PM GMT+10",,,,,86d0b0xmj,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hb0c,Create a page/console output to list stored audio tracks.,,complete,1757393409765,"9/9/2025, 2:50:09 PM GMT+10",,,,,86d08ha9y,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0xmj,"As a technical manager, I want finalised diagrams so the system’s architecture is fully documented.",,complete,1758001553601,"9/16/2025, 3:45:53 PM GMT+10",,,,,null,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0wbm,Finalise Thymeleaf UI with polished layout and basic CSS.,,complete,1758001452465,"9/16/2025, 3:44:12 PM GMT+10",,,,,86d0b0vqw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h10y,Ensure code is well-commented and builds with Maven.,,complete,1757392077615,"9/9/2025, 2:27:57 PM GMT+10",,,,,86d08h0wk,[],[Person 11],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0n4k,Implement Favourite relationship (User ↔ AudioTrack).,,complete,1758000646192,"9/16/2025, 3:30:46 PM GMT+10",,,,,86d0b0m64,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0wzh,"As a quality manager, I want final acceptance tests so I can confirm everything meets requirements.",,complete,1758001500264,"9/16/2025, 3:45:00 PM GMT+10",,,,,null,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0nb0,Add endpoints for “rate track” and “add/remove favourite”.,,complete,1758000671476,"9/16/2025, 3:31:11 PM GMT+10",,,,,86d0b0m64,[],[Person 14],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h17x,"Update repo with: Sprint plan (PM), Risk register (PM), Diagrams (TM), Videos (PM/TM/QM)",,complete,1757392110692,"9/9/2025, 2:28:30 PM GMT+10",,,,,86d08h0wk,[],[Person 11],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0n2,Submit 5-min design explanation video (explain diagrams).,,complete,1757392024992,"9/9/2025, 2:27:04 PM GMT+10",,,,,86d08h0dq,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0rte,Record 5-min video showing test execution and coverage.,,complete,1758001061708,"9/16/2025, 3:37:41 PM GMT+10",,,,,86d0b0rfq,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0nec,Add rating input in audio track UI.,,complete,1758000685200,"9/16/2025, 3:31:25 PM GMT+10",,,,,86d0b0m64,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h386,"Create Message entity with fields (id, sender, content, timestamp).",,complete,1757392351649,"9/9/2025, 2:32:31 PM GMT+10",,,,,86d08gw9b,[],[],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0rqn,Stress-test multi-user concurrency (simulate 10 users).,,complete,1758001051048,"9/16/2025, 3:37:31 PM GMT+10",,,,,86d0b0rfq,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qhe,Implement integration with iTunes API (search tracks by name).,,complete,1758000921163,"9/16/2025, 3:35:21 PM GMT+10",,,,,86d0b0qch,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qch,"As a user, I want to fetch audio metadata from iTunes API so I don’t need to manually enter info.",,complete,1758000900575,"9/16/2025, 3:35:00 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hamh,Expose endpoints (REST or controller) for storing and retrieving audio.,,complete,1757393358911,"9/9/2025, 2:49:18 PM GMT+10",,,,,86d08ha9y,[],[Person 14],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gww2,Configure H2 database (application.properties).,,complete,1757391581099,"9/9/2025, 2:19:41 PM GMT+10",,,,,86d08gw9b,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qtd,Add UI form to search for tracks via iTunes API.,,complete,1758000953176,"9/16/2025, 3:35:53 PM GMT+10",,,,,86d0b0qch,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b1057,Record final task negotiation video.,,complete,1758001812853,"9/16/2025, 3:50:12 PM GMT+10",,,,,86d0b0zp0,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hckg,"Provide option to ""share"" a playlist (console: print link/message; web: show a generated link).",,complete,1757393536913,"9/9/2025, 2:52:16 PM GMT+10",,,,,86d08hbdw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0t2d,"As a technical manager, I want updated diagrams so that the system reflects new features.",,complete,1758001098808,"9/16/2025, 3:38:18 PM GMT+10",,,,,null,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b100f,Record last 5-min PM video.,,complete,1758001802571,"9/16/2025, 3:50:02 PM GMT+10",,,,,86d0b0zp0,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0kh1,Display user-specific messages/playlists after login.,,complete,1758000451856,"9/16/2025, 3:27:31 PM GMT+10",,,,,86d0b0j3m,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hadb,"Create AudioTrack entity (fields: id, title, artist, filePath/URL, duration).",,complete,1757393320484,"9/9/2025, 2:48:40 PM GMT+10",,,,,86d08ha9y,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gwpp,"Implement ChatService (methods: sendMessage(), getMessages()).",,complete,1757391554033,"9/9/2025, 2:19:14 PM GMT+10",,,,,86d08gw9b,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b1149,"Collate all IC summaries into one file, upload to Git repo.",,complete,1758001887399,"9/16/2025, 3:51:27 PM GMT+10",,,,,86d0b10by,[],[Person 11],[all],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08he8e,"Update Risk Register (new risks: file size limits, playlist complexity).",,complete,1757393699625,"9/9/2025, 2:54:59 PM GMT+10",,,,,86d08he0y,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0yy,Push all code changes to private Git repo.,,complete,1757392065690,"9/9/2025, 2:27:45 PM GMT+10",,,,,86d08h0wk,[],[Person 11],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hc9p,Create UI to create a new playlist (name input).,,complete,1757393515132,"9/9/2025, 2:51:55 PM GMT+10",,,,,86d08hbdw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gzgt,Write unit tests for ChatService (send and fetch messages).,,complete,1757391882695,"9/9/2025, 2:24:42 PM GMT+10",,,,,86d08gzb3,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qpv,"Update AudioService to import metadata (title, artist, duration).",,complete,1758000941866,"9/16/2025, 3:35:41 PM GMT+10",,,,,86d0b0qch,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hc10,"Implement PlaylistService (create, add track, remove track, get playlist).",,complete,1757393498822,"9/9/2025, 2:51:38 PM GMT+10",,,,,86d08hbdw,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gyd5,(Alternative path) Build Console UI for sending/reading messages.,,complete,1757391771330,"9/9/2025, 2:22:51 PM GMT+10",,,,,86d08gw9b,[],[Person 14],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0zxt,Final risk register review (closure of identified risks).,,complete,1758001793484,"9/16/2025, 3:49:53 PM GMT+10",,,,,86d0b0zp0,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hccv,Add functionality to select audio tracks and add to playlist.,,complete,1757393522278,"9/9/2025, 2:52:02 PM GMT+10",,,,,86d08hbdw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0xra,"Finalise ERD with all entities (User, Message, AudioTrack, Playlist, Favourites, Ratings).",,complete,1758001566350,"9/16/2025, 3:46:06 PM GMT+10",,,,,86d0b0xmj,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0th9,Record 5-min design explanation video.,,complete,1758001153678,"9/16/2025, 3:39:13 PM GMT+10",,,,,86d0b0t2d,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0jv4,Update relationships: A User has messages. A User can own playlists.,,complete,1758000394521,"9/16/2025, 3:26:34 PM GMT+10",,,,,86d0b0j3m,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qmd,Map API response to AudioTrack entity.,,complete,1758000931170,"9/16/2025, 3:35:31 PM GMT+10",,,,,86d0b0qch,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdp4,"As a technical manager, I want updated diagrams so the architecture reflects audio and playlists.",,complete,1757393627910,"9/9/2025, 2:53:47 PM GMT+10",,,,,null,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0nhm,Add “mark as favourite” option.,,complete,1758000696285,"9/16/2025, 3:31:36 PM GMT+10",,,,,86d0b0m64,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0zp0,"As a project manager, I want to wrap up project documentation so the team can submit a complete package.",,complete,1758001771813,"9/16/2025, 3:49:31 PM GMT+10",,,,,null,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0vy4,Refactor backend to allow database replacement (make repositories modular).,,complete,1758001420390,"9/16/2025, 3:43:40 PM GMT+10",,,,,86d0b0vqw,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hbnw,"Create Playlist entity (id, name, list of AudioTracks).",,complete,1757393471624,"9/9/2025, 2:51:11 PM GMT+10",,,,,86d08hbdw,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0u4t,Update Sprint Plan (Week 10 tasks).,,complete,1758001217499,"9/16/2025, 3:40:17 PM GMT+10",,,,,86d0b0u0e,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0u0e,"As a project manager, I want to keep the team aligned so we finish on time.",,complete,1758001202477,"9/16/2025, 3:40:02 PM GMT+10",,,,,null,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0kah,Configure concurrency to support 10 active users.,,complete,1758000430846,"9/16/2025, 3:27:10 PM GMT+10",,,,,86d0b0j3m,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdrg,Update class diagram to show new services and entities.,,complete,1757393644199,"9/9/2025, 2:54:04 PM GMT+10",,,,,86d08hdp4,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdtz,Create sequence diagram for “User creates playlist and adds audio track”.,,complete,1757393652255,"9/9/2025, 2:54:12 PM GMT+10",,,,,86d08hdp4,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08ha9y,Week 9 backlog (Audio & Playlist) in the same Jira-style format,,complete,1757393306444,"9/9/2025, 2:48:26 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdeq,Write unit tests for AudioService and PlaylistService.,,complete,1757393599797,"9/9/2025, 2:53:19 PM GMT+10",,,,,86d08hdae,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hbun,Define Many-to-Many relationship between Playlist and AudioTrack.,,complete,1757393483154,"9/9/2025, 2:51:23 PM GMT+10",,,,,86d08hbdw,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h006,Create Sprint Plan (Week 8 tasks).,,complete,1757391931757,"9/9/2025, 2:25:31 PM GMT+10",,,,,86d08gzwc,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gzwc,"As a project manager, I want to track progress and risks so the team stays on schedule.",,complete,1757391918005,"9/9/2025, 2:25:18 PM GMT+10",,,,,null,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b10ze,Individual Contributions (IC): Write a 1-page summary of individual contributions.,,complete,1758001873211,"9/16/2025, 3:51:13 PM GMT+10",,,,,86d0b10by,[],"[Person 11,Person 14,Person 12,Person 13,user6@example.com]",[all],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0k1k,"Implement UserService (register, login, authentication).",,complete,1758000409545,"9/16/2025, 3:26:49 PM GMT+10",,,,,86d0b0j3m,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0wk,"As a team member, I want working code and artefacts in Git so everything is tracked.",,complete,1757392052026,"9/9/2025, 2:27:32 PM GMT+10",,,,,null,[],[Person 11],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0j3m,"As a user, I want to log in and use the system with other people so that multiple users can chat and share music.",,complete,1758000346289,"9/16/2025, 3:25:46 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0a2,Record task negotiation video (team deciding tasks).,,complete,1757391963951,"9/9/2025, 2:26:03 PM GMT+10",,,,,86d08gzwc,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0w53,"Polish code: remove deprecated code, improve comments, optimise queries.",,complete,1758001440652,"9/16/2025, 3:44:00 PM GMT+10",,,,,86d0b0vqw,[],[Person 14],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0n8b,Update AudioService with methods for rating and favourites.,,complete,1758000659300,"9/16/2025, 3:30:59 PM GMT+10",,,,,86d0b0m64,[],[Person 14],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0rk2,"Write automated tests for multi-user login, favourites, and ratings.",,complete,1758001032844,"9/16/2025, 3:37:12 PM GMT+10",,,,,86d0b0rfq,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0qwb,Allow adding API-fetched track to library or playlist.,,complete,1758000963298,"9/16/2025, 3:36:03 PM GMT+10",,,,,86d0b0qch,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdhm,Write repository integration tests (ensure Many-to-Many mapping works).,,complete,1757393606661,"9/9/2025, 2:53:26 PM GMT+10",,,,,86d08hdae,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0wmr,"Add error handling (e.g., invalid login, empty messages).",,complete,1758001472146,"9/16/2025, 3:44:32 PM GMT+10",,,,,86d0b0vqw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0nn1,Add page/console view for favourite tracks.,,complete,1758000707546,"9/16/2025, 3:31:47 PM GMT+10",,,,,86d0b0m64,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0dq,"As a technical manager, I want system design artefacts so the team understands architecture.",,complete,1757391982387,"9/9/2025, 2:26:22 PM GMT+10",,,,,null,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0yx3,"Create deployment diagram (showing web app, DB, possible replacement modules).",,complete,1758001695638,"9/16/2025, 3:48:15 PM GMT+10",,,,,86d0b0xmj,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0wh5,Ensure console UI works as an alternative interface.,,complete,1758001463408,"9/16/2025, 3:44:23 PM GMT+10",,,,,86d0b0vqw,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0fp,Create use case diagram (Chat feature).,,complete,1757391995718,"9/9/2025, 2:26:35 PM GMT+10",,,,,86d08h0dq,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gwve,Implement ChatController with endpoints for sending and retrieving messages.,,complete,1757391577704,"9/9/2025, 2:19:37 PM GMT+10",,,,,86d08gw9b,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0x8b,Write acceptance test suite covering: Chat (send/receive). Audio storage + playlists. Multi-user login. Ratings + favourites. iTunes API integration.,,complete,1758001521152,"9/16/2025, 3:45:21 PM GMT+10",,,,,86d0b0wzh,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hag6,"Implement AudioService (store, fetch, delete audio tracks).",,complete,1757393339990,"9/9/2025, 2:48:59 PM GMT+10",,,,,86d08ha9y,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gy17,Create Thymeleaf template: chat form (input + send button).,,complete,1757391730184,"9/9/2025, 2:22:10 PM GMT+10",,,,,86d08gw9b,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h0hf,"Create high-level class diagram (Message, ChatService, ChatController).",,complete,1757392005605,"9/9/2025, 2:26:45 PM GMT+10",,,,,86d08h0dq,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08he50,Update Sprint Plan for Week 9 tasks.,,complete,1757393691209,"9/9/2025, 2:54:51 PM GMT+10",,,,,86d08he0y,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h04m,"Set up Risk Register with initial risks (schedule, scope creep, etc.).",,complete,1757391942972,"9/9/2025, 2:25:42 PM GMT+10",,,,,86d08gzwc,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0vvg,"Implement authorisation for all user actions (only logged-in users can send chat, create playlists, etc.).",,complete,1758001409657,"9/16/2025, 3:43:29 PM GMT+10",,,,,86d0b0vqw,[],[Person 11],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gzb3,"As a developer, I want tests for the chat feature so that I know it works correctly",,complete,1757391867879,"9/9/2025, 2:24:27 PM GMT+10",,,,,null,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08heb3,Record 5-min PM status video reviewing sprint progress.,,complete,1757393706107,"9/9/2025, 2:55:06 PM GMT+10",,,,,86d08he0y,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hby2,Create PlaylistRepository (Spring Data JPA).,,complete,1757393491564,"9/9/2025, 2:51:31 PM GMT+10",,,,,86d08hbdw,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08h075,Record 5-min PM review video (overview of sprint + risks).,,complete,1757391953870,"9/9/2025, 2:25:53 PM GMT+10",,,,,86d08gzwc,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0kdt,Create login UI (web form or console login).,,complete,1758000442025,"9/16/2025, 3:27:22 PM GMT+10",,,,,86d0b0j3m,[],[Person 13],[frontend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gwky,Create MessageRepository using Spring Data JPA.,,complete,1757391541570,"9/9/2025, 2:19:01 PM GMT+10",,,,,86d08gw9b,[],[Person 12],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0xg7,Record 5-min final QA video showing tests + coverage.,,complete,1758001540626,"9/16/2025, 3:45:40 PM GMT+10",,,,,86d0b0wzh,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08he0y,"As a project manager, I want to maintain project artefacts so the team stays on track.",,complete,1757393675377,"9/9/2025, 2:54:35 PM GMT+10",,,,,null,[],[Person 11],[project-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08gw9b,"As a user, I want to send and read chat messages so that I can communicate with friends",,complete,1757391509005,"9/9/2025, 2:18:29 PM GMT+10",,,,,null,[],[],[],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d0b0jk1,"Add User entity (id, username, password).",,complete,1758000375024,"9/16/2025, 3:26:15 PM GMT+10",,,,,86d0b0j3m,[],[user6@example.com],[backend],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdae,"As a quality manager, I want automated tests so I can verify audio and playlist functionality.",,complete,1757393589034,"9/9/2025, 2:53:09 PM GMT+10",,,,,null,[],[Person 12],[quality-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, +86d08hdwg,Record 5-min video explaining updated diagrams.,,complete,1757393660182,"9/9/2025, 2:54:20 PM GMT+10",,,,,86d08hdp4,[],[Person 14],[technical-manager],null,Enterprise Software Development - A3,,Team Space,,,{},[],0," """"",,NaN, diff --git a/testdata/import/csv/canonical/clickup/sample-02.csv b/testdata/import/csv/canonical/clickup/sample-02.csv new file mode 100644 index 00000000..87df88ab --- /dev/null +++ b/testdata/import/csv/canonical/clickup/sample-02.csv @@ -0,0 +1,9 @@ +Task ID,Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Folder Name,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +843yrfue,D6: Определить очередность следующих уровней (вплоть до 30-го),,Closed,1681841858741,"4/18/2023, 9:17:38 PM GMT+3",,,,,null,[],[],[],null,Sprint 3 (2023-04-17 - 2023-04-30),Sprints,Sprints,1800000,30 m,{},[],0," ""1201000""",,1201000, +fowrjfi,Релиз на iOS,Тут надо хорошо подумать - выпускать игру на платной основе или нет..,Closed,1635862172945,"11/2/2021, 5:09:32 PM GMT+3",1672102800000,"12/27/2022, 4:00:00 AM GMT+3",1671066000000,"12/15/2022, 4:00:00 AM GMT+3",null,[],[],[],null,P6 - База,Gantt,Road Map (Planning),,,{},[],0," """"",NaN d NaN h,NaN,NaN d NaN h +49ufrej,Закончить 6ую игру,,quarter,1677850170787,"3/3/2023, 4:29:30 PM GMT+3",,,,,null,[],[],[],null,List,Year Plan,Road Map (Planning),,,{},[],0," """"",NaN d NaN h,NaN,NaN d NaN h +kfr340o,x05 - Add Hint to Handler when password is complete,null,Closed,1625092346860,"7/1/2021, 1:32:26 AM GMT+3",,,,,null,[],[Person 16],[],null,Inbox,Project 6,Company - Projects,,,{},[],0," ""3600000""", 1 h,3600000, 1 h +kldfrg9,Release P6 v1.24,,on track,1622762212646,"6/4/2021, 2:16:52 AM GMT+3",,,,,null,[],[Person 16],[],null,Releases,Project 6,Company - Projects,,,{},[],0," ""10202000""", 2 h,10202000, 2 h +lodrkg44,Собрать билды на LibGDX с отличающимися BundleId,"Для того, что-бы эти билды находились на телефоне и их можно было сравнивать.\n",backlog,1577140276583,"12/24/2019, 1:31:16 AM GMT+3",,,,,null,[],[],[build],null,After Release,Project 1 / 2 (Unity),Company - Projects,,,{},[],0," """"",NaN d NaN h,NaN,NaN d NaN h +221rt5,"Add sound on click anywhere on GameScene (random sounds or sounds to regions, such as Wall, Floor, Door, etc..)",,backlog,1428268456000,"4/6/2015, 12:14:16 AM GMT+3",,,,,null,[],[Person 16],[levels],null,Game,Project 1 / 2 (libGDX),Company - Projects,,,{},"[{""text"":""Но также это может сбивать с толку в уровнях, где нужно прокликивать экран и активные объекты (которые надо двигать/свайпать) часто \""подсвечены\"" именно звуком.\n\nТ.е. звуки по фону уровня - должны быть более приглушенными.\n"",""by"":""user7@example.com"",""assigned"":false,""date"":""5/6/2022, 3:05:30 AM GMT+3"",""resolved"":""N/A""}]",0," """"",NaN d NaN h,NaN,NaN d NaN h +2acundv,"Remove permission: ""QUERY_ALL_PACKAGES""","[Action requested] Submit the QUERY_ALL_PACKAGES permission declaration by July 20 (comment)\nandroid.permission.QUERY_ALL_PACKAGES\n\nЕго уже нет в новых версиях Appodeal SDK: 2.15.3+\n\nНа всякий случай, добавил в основной манифест строчку на удаление этого разрешения\n\nhttps://support.google.com/googleplay/android-developer/answer/10158779?hl=ru\nhttps://developer.android.com/reference/android/Manifest.permission#QUERY_ALL_PACKAGES",Closed,1649248784275,"4/6/2022, 3:39:44 PM GMT+3",,,,,null,"[{""title"":""image.png"",""url"":""https://server/id/image.png""},{""title"":""image.png"",""url"":""https://server/id/image.png""},{""title"":""image.png"",""url"":""https://server/id/image.png""},{""title"":""image.png"",""url"":""https://server/id/image.png""}]",[Person 16],"[android,policy,~waiting]",null,"Policy (Consent, Privacy)",Project - Global,Company - Projects,,,{},"[{""text"":""В Google Play этого разрешения уже нет, однако я всеже не могу удалить его из политик - там попросту нет функции для удаления.\n\nВозможно это можно будет сделать после обновления?\n\nhttps://play.google.com/console/developers/id/app/id/app-content/permission-declarations\n\nimage.pngimage.png\n\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""3/18/2023, 4:04:31 AM GMT+3"",""resolved"":""N/A""},{""text"":""Gmail\n\nimage.png\n\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""7/16/2022, 12:46:05 AM GMT+3"",""resolved"":""N/A""},{""text"":""Нужно обновить P6, т.к. только там осталось это разрешение.\n\nimage.png\n\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""7/16/2022, 12:45:11 AM GMT+3"",""resolved"":""N/A""},{""text"":""Я думаю, что это больше не потребуется и строчку можно удалить\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""5/12/2022, 2:20:07 PM GMT+3"",""resolved"":""N/A""},{""text"":""На всякий случай, добавил в основной манифест строчку на удаление этого разрешения\n\n\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""4/6/2022, 4:46:24 PM GMT+3"",""resolved"":""N/A""},{""text"":""Его уже нет в новых версиях Appodeal SDK: 2.15.3+\n"",""by"":""user8@example.com"",""assigned"":false,""date"":""4/6/2022, 4:41:32 PM GMT+3"",""resolved"":""N/A""}]",0," """"",NaN d NaN h,NaN,NaN d NaN h diff --git a/testdata/import/csv/canonical/clickup/sample-03.csv b/testdata/import/csv/canonical/clickup/sample-03.csv new file mode 100644 index 00000000..7a86b60c --- /dev/null +++ b/testdata/import/csv/canonical/clickup/sample-03.csv @@ -0,0 +1,77 @@ +Task ID, Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Folder Name,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +1jz9an4,[WebUI] Implement Decision Tree,Output,Open,1632825922593,"9/28/2021, 1:45:22 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +11m2gbc,[DevOps] Try to dockerize java ui,PR: https://example.com/repo,Closed,1631018767943,"9/7/2021, 3:46:07 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1qtrmvg,[Service] Implement Decision Tree Request Expirator,"Implement:\nImplement Cancel in Engine after x seconds if no more requests receives on this id, cancel and unblock engine\nCancel Api should call cancel in engine, remove requestId and unblock engine\nImplement RequestIdExpirator that is a separate thread to remove request id aftter x seconds create a method that has a x seconds as parameter and another that is default for example 20 or 30 seconds\nCreate requestIdHandler to handle all calls related to requestId\n\n\ntest:\nBook all engines\nlaunch a normal time series analysis request => error\nlaunch a start => error\nLaunch all time series sequentially\nLaunch time series on all engines => error\nLaunch decision tree, start, data, data, predict\nLaunch decision tree, start, data, data, compute predict accuracy\nCheck on NoAvailableEngineException that logs are written\n\nWhat if not all columns sent in the start (predictionColumnName, actionColumnNames) were received on action",Open,1636265565844,"11/7/2021, 9:12:45 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1u427x4,[SwingUI] Implement Decision Tree models and csv-reader,PR: https://example.com/repo,Closed,1637483298042,"11/21/2021, 11:28:18 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7q4h,[SwingUI] Multithreaded call to Service,Call the forecast / predict in a new thread and update the screen once results arrive ?\n=> what if mulitple request are sent ? => grey the buttons ?\n=> currently the screen is blocked when a request is done.\n,Open,1625722305769,"7/8/2021, 8:31:45 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +10htnyq,[Service] Sonar Refactoring,PR: https://example.com/repo,Closed,1630512381072,"9/1/2021, 7:06:21 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +ntfx50,[WebUI] Small Refactor TSA Input,Remove error message (Red span) when new request is sent\nMake Form control names somewhere public so that they can be shared among all files\n\nPR: https://example.com/repo,Closed,1625774237664,"7/8/2021, 10:57:17 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +r4v8e4,[Engine] Remove warnings from build logs,"Run packaging.py and check the warnings, and remove them\n=> Arima was uprageded, make sure that the forecast accuracy is better\n\nPR: https://example.com/repo",Closed,1627980823562,"8/3/2021, 11:53:43 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jz9akk,[SwingUI] Implement Decision Tree,Input\nService\nOutput,Open,1632825907251,"9/28/2021, 1:45:07 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nvdavw,[Service] Indentation to 2 spaces,null,Closed,1625841694003,"7/9/2021, 5:41:34 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7qx2,[WebUI] Write unit tests,Write unit tests for \nvalidation-message-generator\ntime-series-analysis-input.component\ntime-series-analysis-output.component\n\n\nPR: https://example.com/repo,Closed,1625722419445,"7/8/2021, 8:33:39 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +rny59u,[WebUI] Implement UI for compute forecast accuracy,https://example.com/repo,Closed,1628327542201,"8/7/2021, 12:12:22 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jz4nz0,[Engine] Check classifier values are strings ? (check FYP V0 classifier.py),null,Open,1632764325448,"9/27/2021, 8:38:45 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7t4e,[Scripts] Packaging.py should fail if any step fails & Copy only dist for Angular,Make packaging.py fail if one of the commands fail (i.e.: if tests fail stop packaging)\n\nPR: https://example.com/repo,Closed,1625722990946,"7/8/2021, 8:43:10 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7pze,[SwingUI] Analysis on creating an AbstractView,"Find a way to create an abstract view (Configuration [abstract], Applier [abstract])\nThat way we don't have to create the same frame multiple times (we add the applier and the configuration needed)\n=> The code that handle the screen is common\n",Open,1625722077643,"7/8/2021, 8:27:57 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1qtnfb0,[WebUI] Upgrade angular dependencies,Warning issues will be solved here: https://example.com/repo https://example.com/repo,Closed,1636138772241,"11/5/2021, 9:59:32 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7rtb,[WebUI] Pass server port to Angular,"Find a way to pass server port to angular instead of being hard coded in the different *.service.ts\nUse lite-server for deployment, update md files\n\nPR: https://example.com/repo",Closed,1625722780205,"7/8/2021, 8:39:40 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jtjwk7,"[Engine] Make engine/debug folder testable, and remove exclusion from sonar.",null,Open,1632736957668,"9/27/2021, 1:02:37 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jz8vbf,[Engine] Resturcture packages and folders,instead of\nmodel/time-series\nmodel/classifier\nservices/time-series\nservices/classifier\ncontroller/time-series\ncontroller/classifier\n\nto:\ntimeseries/\n model/\n service/\n controller/\nclassifier/\n model/\n service/\n controller/\n\nPR: https://example.com/repo,Closed,1632823871630,"9/28/2021, 1:11:11 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1rardw4,[WebUI] Update swinglane since it's using 12.0 angular version once it is fixed on github repo,Angular 13 support #1699\n\n\nhttps://example.com/repo,Open,1636387428579,"11/8/2021, 7:03:48 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +10hvkf8,[CI] Use warnError instead of catchError,PR: https://example.com/repo,Closed,1630569094863,"9/2/2021, 10:51:34 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +rakkfk,[WebUI] Remove Warnings from build logs,PR: https://example.com/repo,Closed,1628073943420,"8/4/2021, 1:45:43 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1pht0t0,[Service] Implement DecisionTreeIT,PR: https://example.com/repo,Closed,1635605824782,"10/30/2021, 5:57:04 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1u9rdz6,"[WebUI] Create a custom pipeable observer object to replace last(null, 'ignored')",check decision-tree-input.component.ts we are piping a last operator just to either wait for the last emitted value or to emit something for a observable,Open,1637593107913,"11/22/2021, 5:58:27 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1u4008r,[WebUI] Create Input List Component,PR: https://example.com/repo,Closed,1637398728209,"11/20/2021, 11:58:48 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jz2hfa,[Engine] Restructure code with new classifier,PR: https://example.com/repo,Closed,1632747130565,"9/27/2021, 3:52:10 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7t3k,[General] Use swagger/open api files to generate server stubs,PR: https://example.com/repo https://example.com/repo,Closed,1625722945838,"7/8/2021, 8:42:25 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +yrkw15,[DevOps] Create dockerfile for all services,PR: https://example.com/repo,Closed,1629961608524,"8/26/2021, 10:06:48 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +pzazxd,[Java] Add plugin to detect unused imports,PR: https://example.com/repo,Closed,1627623918557,"7/30/2021, 8:45:18 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1phrxz1,[Service] Implement Decision Tree Controller,https://example.com/repo,Closed,1635603669436,"10/30/2021, 5:21:09 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7rzw,[WEBUI] Complete TSA Output Component,PR: https://example.com/repo,Closed,1625722822215,"7/8/2021, 8:40:22 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +ntct2c,[Engine] Add more Documentation Comments (DocsString) in the code,Do not forget to add it in DecisionTreeEngineApi.yaml,Open,1625749100633,"7/8/2021, 3:58:20 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +w8d7dp,[General] Fix readme links,PR: https://example.com/repo,Closed,1629226286391,"8/17/2021, 9:51:26 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +12peuy4,[Engine] Implement Decision Tree Service and Debug,PR: https://example.com/repo,Closed,1631773867782,"9/16/2021, 9:31:07 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1tjxbaf,[WebUI] Implement Decision Tree Input Panel,PR: https://example.com/repo,Closed,1637227152558,"11/18/2021, 12:19:12 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1vhrjw0,[General] Upgrading to Python3.10,launch engine debug\nmultiple docker images are installing python\ncheck docker that is launched is it launched with python3.10 or else ?(latest or not ?)\n\nPR: https://example.com/repo,Closed,1638179984223,"11/29/2021, 12:59:44 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jth7pz,[Engine] Implement Decision Tree Controller,PR: https://example.com/repo,Closed,1632729105226,"9/27/2021, 10:51:45 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +yy9x0r,[Engine] Small refactoring,2 spaces for indentation\nFix sonar issues\nFix Imports\n\nPR: https://example.com/repo,Closed,1630055780043,"8/27/2021, 12:16:20 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +rahnhb,[Java] Migrate form log4j to log4j2,PR: https://example.com/repo,Closed,1628062511173,"8/4/2021, 10:35:11 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nv7mf6,[General] Correctly transmit error from Engine to Service to UI and Web UI,PR: https://example.com/repo,Closed,1625811157438,"7/9/2021, 9:12:37 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1uv16nj,[Service] Create Admin API,"Create Admin Api\nReset all engines\nGet engine information (Service info, action info, status)",Open,1637740303014,"11/24/2021, 10:51:43 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +ntbq0y,[Engine] Run pylint * and fix all lint issues,PR: https://example.com/repo,Closed,1625743576016,"7/8/2021, 2:26:16 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nz8dt4,[General] Rearrange Java packages,PR: https://example.com/repo,Closed,1626099638918,"7/12/2021, 5:20:38 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1u9rcyh,[WebUI] Create a Assertion helper for observers in tests,PR: https://example.com/repo,Closed,1637592965199,"11/22/2021, 5:56:05 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +z3ygag,"[CI] Deploy, Set up and document sonar",PR: https://example.com/repo,Closed,1630248083119,"8/29/2021, 5:41:23 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1u42pb7,"[WebUI] Create abstraction between classifier and time series forms, services and other components",PR: https://example.com/repo,Closed,1637494312679,"11/21/2021, 2:31:52 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1pqg3yw,[General] Add PostMan collection to git repository with specific readme,Add this link to readme as link to the postman collection\n\ncheck if possible to have expected values in postman and transform it to end to end tests\nhttps://warped-meteor-545745.postman.co/workspace/MLSK~59499d54-e888-4af1-81da-cf80e81a58a2/folder/10823596-a47645fe-fb84-492a-a5b0-12477310f1bf?ctx=documentation\n\nhttps://www.freecodecamp.org/news/how-to-automate-rest-api-end-to-end-tests/\n\n\nalso make postman run automatically\ncreate a script launch end_to_end_tests \nupdate requirements md file to launch the script to make sure end to end scenarios are green\n\nCreate EndToEndTests.md detailing how to launch and the script created.,Open,1635759522414,"11/1/2021, 12:38:42 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +p5g6n1,[WebUI] Remove unused dependencies,PR: https://example.com/repo,Closed,1626432373422,"7/16/2021, 1:46:13 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +qtdann,[Service] Refactor MockEngine,Instead of MockEngine having when(restTemplateMock) with a big body instead\nmake when(restTemplateMock).thenAnswer(t->mockEngine.callEngine(t));\nand callEngine has all logic\n\nPR: https://example.com/repo,Closed,1627897309505,"8/2/2021, 12:41:49 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7rp0,[WebUI] Refactor TSA Input Form,Refactor the TSA Input Reactive form\nCreate something more readable and easily testable\n\nPR: https://example.com/repo,Closed,1625722691781,"7/8/2021, 8:38:11 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +wtrm13,[CI] Create CI,PR: https://example.com/repo,Closed,1629792849025,"8/24/2021, 11:14:09 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1pht53q,[Engine] Implement Decision Tree Cancel to reset the state,PR: https://example.com/repo,Closed,1635609371336,"10/30/2021, 6:56:11 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +pvbjum,[Service] Refactor,Think of how to refactor EngineImpl and remove the launchEngine from the constructor.\nmaybe add in orchtestrator => launchAllEngines (which is useful for a dashboard later)\ninstead of constructor having the logic to start engines \nFollow SOLID Principle\nInterface Seggregation => for different interfaces\nCreate a service and remove engineCaller unecessary layer\n\nPR: https://example.com/repo https://example.com/repo,Closed,1627452157468,"7/28/2021, 9:02:37 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7pw8,[Engine] Fill setup.py,under engine/setup.py\nfigure out what needs to be set in setup.py and fill it according to best standards\n,Open,1625721923295,"7/8/2021, 8:25:23 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7q29,[SwingUI] Refactor tryPopup message and title,PR: https://example.com/repo,Closed,1625722198362,"7/8/2021, 8:29:58 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +10htxx1,[WebUI] Sonar refactoring,PR: https://example.com/repo,Closed,1630514964177,"9/1/2021, 7:49:24 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1tdb0d4,[DevOps] SonarQube error while launching with elastic search and memory available,PR: https://example.com/repo,Closed,1637160452962,"11/17/2021, 5:47:32 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1pqfr5x,[Service] Implement Decision Tree service call to cancel,PR: https://example.com/repo,Closed,1635757542091,"11/1/2021, 12:05:42 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7q32,[SwingUI] Refactor the code,Rework on the UI \nRefactor\nWrite unit tests\nCheck sonar\n\nPR: https://example.com/repo,Closed,1625722246578,"7/8/2021, 8:30:46 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nzc94g,[Service] Implement End to End test in Java,PR: https://example.com/repo,Closed,1626155976376,"7/13/2021, 8:59:36 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +11ehe1n,[WebUI] Create a configuration page where you can configure the url of the service,PR: https://example.com/repo,Closed,1630999420592,"9/7/2021, 10:23:40 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +11tu6ep,[SwingUI] Create configuration screen to modify server host & port,PR: https://example.com/repo,Closed,1631140652216,"9/9/2021, 1:37:32 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1rghn82,[Engine] Validations for Decision tree values,PR: https://example.com/repo,Closed,1636527266631,"11/10/2021, 9:54:26 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +qtaf0z,[WebUI] Add Readme.md as first page information,null,Open,1627879461668,"8/2/2021, 7:44:21 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1qtp9b8,[DevOps] Parameter in Jenkins overwrite node_modules folder,PR: https://example.com/repo,Closed,1636179109433,"11/6/2021, 9:11:49 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1t7e526,[Service] Fix bug when runOnEngine fails engine is not released,PR: https://example.com/repo,Closed,1637008219494,"11/15/2021, 11:30:19 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1j8adr5,"[General] Check free hosting websites for Jenkins, Service & WebUI",Check attachments for free hosting websites\n,cancelled,1632227500575,"9/21/2021, 3:31:40 PM GMT+3",,,,,null,"[{""title"":""Free Hosting 2.jpeg"",""url"":""https://t4786989.p.clickup-attachments.com/t4786989/525b1976-a280-4a5a-a8e2-7a974069fbea/Free%20Hosting%202.jpeg""},{""title"":""Free Hosting 1.jpeg"",""url"":""https://t4786989.p.clickup-attachments.com/t4786989/ad93e5fc-307e-433c-8390-812f8aea805b/Free%20Hosting%201.jpeg""}]",[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1jz9ajj,[Service] Implement Decision Tree,PR: https://example.com/repo,Closed,1632825890524,"9/28/2021, 1:44:50 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +r4t2ra,[JavaUI] Implement UI for compute forecast accuracy,PR: https://example.com/repo,Closed,1627973681053,"8/3/2021, 9:54:41 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +qthttv,[General] Document API using swagger/openAPI,PR: https://example.com/repo,Closed,1627920235307,"8/2/2021, 7:03:55 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +u3u13e,[General] Implement a dashboard to acces logs and services and engines' status,null,Open,1628777577637,"8/12/2021, 5:12:57 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1qtp9zw,[Api] Remove springfox in spring generator once it is fixed on github repo,https://example.com/repo https://example.com/repo,Closed,1636180765821,"11/6/2021, 9:39:25 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1t7cmau,[Service] Refactor Decision tree and classifierType,classifiertype is passed as method parameter where it should be an attribute\nin classes: ClassifierService and other check all other classes and make sure it's an attribute\nmaybe creating another @Service that is DecisionTreeServiceImpl that inherits from ClassifierService (that becomes abstract ?) refactor all interfaces as well.,cancelled,1636992801025,"11/15/2021, 7:13:21 PM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7pc6,[TSA] pass the order in ARIMA as parameters (as non required),Create a new parameter in TimeSeriesAnalysisRequest which is the order.\nThat way the TSA (Time Series Analysis) options can be also modified.\n,Open,1625721469210,"7/8/2021, 8:17:49 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +1qtp9yw,[WebUI] Fix warning @charset once it is fixed on github repo,https://example.com/repo https://example.com/repo,Closed,1636180693272,"11/6/2021, 9:38:13 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, +nt7t1b,[TSA] Test End to End,"Test end to end with values that are different by year/ month/ day/ hour/ minute/ second/ (ie. the difference between two dates is 1 day or 1 sec or 2 hours, ...)\n",Open,1625722886451,"7/8/2021, 8:41:26 AM GMT+3",,,,,null,[],[Person 17],[],null,MLSK (Machine Learning SwissKnife),hidden,Project,,,{},[],0," """"",,NaN, diff --git a/testdata/import/csv/canonical/clickup/sample-04.csv b/testdata/import/csv/canonical/clickup/sample-04.csv new file mode 100644 index 00000000..6c41e702 --- /dev/null +++ b/testdata/import/csv/canonical/clickup/sample-04.csv @@ -0,0 +1,14 @@ +Task ID,Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Project Name,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +23j7m5,add edit item,null,to do,1580666699721,"2/2/2020, 11:34:59 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7kv,add delet of todo list,null,to do,1580666655313,"2/2/2020, 11:34:15 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7kt,add new item to the todo list,null,to do,1580666628183,"2/2/2020, 11:33:48 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7mf,static handler,null,to do,1580666753261,"2/2/2020, 11:35:53 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the server,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7mh,file sending handler,null,to do,1580666763120,"2/2/2020, 11:36:03 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the server,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7ja,home Page,home page of the app;\nthat will conatin all the todo lists\n,in progress,1580666451421,"2/2/2020, 11:30:51 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j1m0,server,null,to do,1580649375300,"2/2/2020, 6:46:15 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the server,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7m4,add edit todo title,null,to do,1580666693644,"2/2/2020, 11:34:53 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7kx,add delet multiple todo list,null,to do,1580666686924,"2/2/2020, 11:34:46 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7kw,add delet multiple item,null,to do,1580666676141,"2/2/2020, 11:34:36 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7jc,each todo page,null,in progress,1580666467919,"2/2/2020, 11:31:07 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7ku,add delet of each item,null,to do,1580666647401,"2/2/2020, 11:34:07 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, +23j7kq,add add todo list feature,null,to do,1580666614478,"2/2/2020, 11:33:34 PM GMT+5:30",,,,,null,[],[Person 18],[],null,building the ui,Building ToDo app,Person 18's Space,,,{},[],,,0, diff --git a/testdata/import/csv/canonical/jira/sample-01.csv b/testdata/import/csv/canonical/jira/sample-01.csv new file mode 100644 index 00000000..036dbe10 --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-01.csv @@ -0,0 +1,6 @@ +Summary,Issue key,Issue id,Issue Type,Status,Project key,Project name,Project type,Project lead,Project lead id,Project description,Priority,Resolution,Assignee,Assignee Id,Reporter,Reporter Id,Creator,Creator Id,Created,Updated,Last Viewed,Resolved,Due date,Votes,Description,Environment,Watchers,Watchers Id,Original estimate,Remaining Estimate,Time Spent,Work Ratio,Σ Original Estimate,Σ Remaining Estimate,Σ Time Spent,Security Level,Custom field (Artifacts),Custom field (Compliance Requirement),Custom field (Control Link),Custom field (Development),Custom field (Issue color),Custom field (Link),Custom field (Q1 Actual Score),Custom field (Q1 Observations),Custom field (Q1 Target Score),Custom field (Q2 Actual Score),Custom field (Q2 Observations),Custom field (Q2 Target Score),Custom field (Q3 Actual Score),Custom field (Q3 Observations),Custom field (Q3 Target Score),Custom field (Q4 Actual Score),Custom field (Q4 Observations),Custom field (Q4 Target Score),Custom field (Rank),Custom field (Remediation Action Plan (Who will do What by When?) ),Custom field (Root Cause),Custom field (Start date),Custom field (Team),Custom field (Test Procedures),Custom field (Testing Status),Custom field (Vulnerability),Status Category,Status Category Changed +Database failover not tested within past 12 months,FND-4,10350,Task,Not Started,FND,Findings,software,Person 19,account-id-1,,Medium,,,,Person 19,account-id-1,Person 19,account-id-1,11/Jan/26 3:22 PM,11/Jan/26 3:22 PM,,,,0,,,Person 19,account-id-1,,,,,,,,,,,,,,,,,,,,,,,,,,,0|i000q7:,,,,,,,,To Do,11/Jan/26 3:22 PM +CloudFront WAF integration not enabled; CDN provides availability but limited security filtering,FND-3,10349,Task,Not Started,FND,Findings,software,Person 19,account-id-1,,Medium,,,,Person 19,account-id-1,Person 19,account-id-1,11/Jan/26 3:22 PM,11/Jan/26 3:22 PM,,,,0,,,Person 19,account-id-1,,,,,,,,,,,,,,,,,,,,,,,,,,,0|i000pz:,,,,,,,,To Do,11/Jan/26 3:22 PM +Auto-scaling policies not configured for all production services; some workloads run fixed instance counts,FND-2,10348,Task,Not Started,FND,Findings,software,Person 19,account-id-1,,Medium,,,,Person 19,account-id-1,Person 19,account-id-1,11/Jan/26 3:21 PM,11/Jan/26 3:21 PM,,,,0,,,Person 19,account-id-1,,,,,,,,,,,,,,,,,,,,,,,,,,,0|i000pr:,,,,,,,,To Do,11/Jan/26 3:21 PM +Network monitoring lacks 24/7 coverage and external perimeter visibility,FND-1,10327,Task,Not Started,FND,Findings,software,Person 19,account-id-1,,Medium,,,,Person 19,account-id-1,Person 19,account-id-1,11/Jan/26 2:30 PM,11/Jan/26 3:26 PM,11/Jan/26 3:25 PM,,31/Aug/26 12:00 AM,0,"* Business impact includes extended threat actor dwell time increasing risk of customer biometric data breach (Security Goal 1), exposure of intellectual property (Security Goal 2), and potential negative impact on public trust score critical for an authentication company (Security Goal 4, Company Goal 3). +* Recommendation is to accelerate Attack Surface Management deployment, evaluate managed detection and response providers to supplement Security Operations Center capacity, implement automated alert escalation in Amazon GuardDuty, and establish network traffic baselines for anomaly detection. ",,Person 19,account-id-1,,,,,,,,,,DE.CM-01,,,,,,,,,,,,,,,,,0|i000l3:,Remediation Owner: Nadia Khan,50% understaffing following departure of 5 key personnel in 2024 (Risk Register item 1) combined with delayed Attack Surface Management procurement. ,,,,,,To Do,11/Jan/26 2:30 PM diff --git a/testdata/import/csv/canonical/jira/sample-02.csv b/testdata/import/csv/canonical/jira/sample-02.csv new file mode 100644 index 00000000..1c697550 --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-02.csv @@ -0,0 +1,102 @@ +Summary,Issue key,Issue id,Issue Type,Status,Project key,Project name,Project type,Project lead,Project lead id,Project description,Priority,Resolution,Assignee,Assignee Id,Reporter,Reporter Id,Creator,Creator Id,Created,Updated,Last Viewed,Resolved,Due date,Votes,Description,Environment,Watchers,Watchers Id,Original estimate,Remaining Estimate,Time Spent,Work Ratio,Σ Original Estimate,Σ Remaining Estimate,Σ Time Spent,Security Level,Custom field (Development),Custom field (Issue color),Custom field (Rank),Sprint,Custom field (Start date),Custom field (Story point estimate),Custom field (Team),Custom field (Vulnerability),Custom field (Vulnerability),Status Category,Status Category Changed +Create Comprehensive Project Documentation,SCRUM-5,10004,Task,Done,SCRUM,Todo project,software,Person 20,account-id-2,Your first project,Medium,Done,Person 20,account-id-2,Person 20,account-id-2,Person 20,account-id-2,25/Oct/25 12:53 PM,25/Oct/25 12:55 PM,25/Oct/25 12:55 PM,25/Oct/25 12:55 PM,,0,"Write clear, comprehensive README documentation to help users understand, +install, and run the project. Include all necessary setup instructions and +API reference. + +Acceptance Criteria: +✅ Add project description and feature list with emojis +✅ Document tech stack (FastAPI, Poetry, pytest) +✅ Show project structure with file tree +✅ List all API endpoints with HTTP methods +✅ Provide step-by-step installation instructions +✅ Include commands to run the application +✅ Include commands to run tests with options +✅ Add usage instructions for the UI +✅ Explain in-memory storage limitation +✅ Suggest future enhancements (database persistence) +✅ Note which endpoints are tested vs untested + +Technical Details: + +* Markdown format for GitHub compatibility +* Clear sections with headings +* Code blocks for commands +* Links to Poetry documentation +* MIT License mentioned",,Person 20,account-id-2,,,,,,,,,,,0|i0000v:,SCRUM Sprint 1,,,,,,Done,25/Oct/25 12:55 PM +Write Automated Test Suite for API Endpoints,SCRUM-4,10003,Task,Done,SCRUM,Todo project,software,Person 20,account-id-2,Your first project,Medium,Done,Person 20,account-id-2,Person 20,account-id-2,Person 20,account-id-2,25/Oct/25 12:53 PM,25/Oct/25 12:55 PM,25/Oct/25 12:54 PM,25/Oct/25 12:55 PM,,0,"Develop pytest-based test suite covering core API functionality. Include tests +for happy paths and edge cases. Intentionally exclude some endpoints from +testing to demonstrate selective test coverage. + +Acceptance Criteria: +✅ Set up pytest with FastAPI TestClient +✅ Create fixture to clear database before each test +✅ Test health check endpoint returns correct status +✅ Test getting empty todos list returns [] +✅ Test creating todo with full data (title, description, completed) +✅ Test creating todo with minimal data (title only) +✅ Test getting todos after creating multiple items +✅ Test updating existing todo (title and completed status) +✅ Verify proper status codes (200, 201, etc.) +❌ DELETE endpoint intentionally not tested (per project requirements) + +Technical Details: + +* pytest fixtures for test isolation +* TestClient for making API requests +* Clear database state between tests for reliability",,Person 20,account-id-2,,,,,,,,,,,0|i0000n:,SCRUM Sprint 1,,,,,,Done,25/Oct/25 12:55 PM +Design and Develop Modern Frontend UI,SCRUM-3,10002,Task,Done,SCRUM,Todo project,software,Person 20,account-id-2,Your first project,Medium,Done,Person 20,account-id-2,Person 20,account-id-2,Person 20,account-id-2,25/Oct/25 12:53 PM,25/Oct/25 12:54 PM,25/Oct/25 12:54 PM,25/Oct/25 12:54 PM,,0,"Create a beautiful, responsive single-page application for the todo list with +modern UX patterns and smooth animations. No framework dependencies - pure +vanilla JavaScript for simplicity. + +Acceptance Criteria: +✅ Design gradient purple-themed UI with modern aesthetics +✅ Create form with title input and optional description textarea +✅ Display todos list with checkboxes for completion toggle +✅ Add delete button for each todo item +✅ Show empty state when no todos exist +✅ Display statistics showing completed vs total todos +✅ Implement smooth hover animations and transitions +✅ Add visual feedback for completed items (strikethrough, opacity) +✅ Make UI responsive and mobile-friendly +✅ Add keyboard support (Enter key to submit) + +Technical Details: + +* Vanilla JavaScript with fetch API for backend communication +* CSS Grid and Flexbox for layout +* Linear gradient background (purple theme) +* XSS protection with HTML escaping +* Async/await for cleaner promise handling",,Person 20,account-id-2,,,,,,,,,,,0|i0000f:,SCRUM Sprint 1,,,,,,Done,25/Oct/25 12:54 PM +Implement RESTful API Backend with Todo CRUD Operations,SCRUM-2,10001,Task,Done,SCRUM,Todo project,software,Person 20,account-id-2,Your first project,Medium,Done,Person 20,account-id-2,Person 20,account-id-2,Person 20,account-id-2,25/Oct/25 12:53 PM,25/Oct/25 12:54 PM,25/Oct/25 12:54 PM,25/Oct/25 12:54 PM,,0,"Build a FastAPI backend with RESTful endpoints for managing todos. Implement +full CRUD (Create, Read, Update, Delete) operations with proper HTTP methods +and status codes. + +Acceptance Criteria: +✅ Create Pydantic models for Todo and TodoCreate with validation +✅ Implement GET /api/todos endpoint to retrieve all todos +✅ Implement POST /api/todos endpoint to create new todos (201 status) +✅ Implement PUT /api/todos/{todo_id} endpoint to update existing todos +✅ Implement DELETE /api/todos/{todo_id} endpoint (204 status) +✅ Add GET /api/health endpoint for health monitoring +✅ Return proper 404 errors when todo not found +✅ Use in-memory storage with dictionary for simplicity + +Technical Details: + +* UUID generation for unique todo IDs +* Optional description field with None default +* Boolean completed flag for todo status tracking",,Person 20,account-id-2,,,,,,,,,,,0|i00007:,SCRUM Sprint 1,,,,,,Done,25/Oct/25 12:54 PM +Setup Project Infrastructure and Dependencies,SCRUM-1,10000,Task,Done,SCRUM,Todo project,software,Person 20,account-id-2,Your first project,Medium,Done,Person 20,account-id-2,Person 20,account-id-2,Person 20,account-id-2,25/Oct/25 12:53 PM,25/Oct/25 12:54 PM,25/Oct/25 12:53 PM,25/Oct/25 12:54 PM,,0,"Set up the project foundation with proper dependency management and configuration. + +Acceptance Criteria: +✅ Create pyproject.toml with Poetry for dependency management +✅ Add FastAPI, Uvicorn, pytest, and httpx as dependencies +✅ Configure package-mode = false for dependency-only management +✅ Create .gitignore file with Python and IDE exclusions +✅ Ensure project can be installed with {{poetry install}} + +Technical Details: + +* Poetry package manager chosen for modern dependency management +* FastAPI selected as web framework for speed and type safety +* Uvicorn with standard extras for production-ready ASGI server",,Person 20,account-id-2,,,,,,,,,,,0|hzzzzz:,SCRUM Sprint 1,,,,,,Done,25/Oct/25 12:54 PM diff --git a/testdata/import/csv/canonical/jira/sample-03.csv b/testdata/import/csv/canonical/jira/sample-03.csv new file mode 100644 index 00000000..6e58e7c5 --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-03.csv @@ -0,0 +1,231 @@ +Issue Type,Issue key,Issue id,Summary,Project name,Created,Labels,Labels,Description,Actual Result,Expected Result,Preconditions,Priority,Severity,Sprint,Status,Parent summary +Bug,DEPI-38,10431,"Contact Form Fails to Submit — No Response on Clicking ""Send message""",DEPI-Automated-Testing-Final-Project,5/9/2025 18:57,,,"*Steps to Reproduce*: + +# Open the contact form +# Fill in all required fields with valid data +# Click the ""Send message"" button",,,,Medium (P2),Major / High,SCRUM Sprint 1,, +Bug,DEPI-35,10396,Tracking orders page doesn't work,DEPI-Automated-Testing-Final-Project,5/8/2025 17:43,,,Tracking orders page doesn’t work,,,,Medium (P2),Major / High,SCRUM Sprint 1,, +Bug,DEPI-34,10365,Unable to Complete Checkout – No Payment Methods Available,DEPI-Automated-Testing-Final-Project,5/3/2025 11:57,Checkout,,"When attempting to complete a purchase using an existing billing address, the system fails to display any available payment methods. As a result, the user cannot proceed with the order. A warning message is shown, stating: + +{quote}“⚠ Warning: No Payment options are available. Please contact us for assistance!”{quote} + +This issue prevents the user from completing the checkout process entirely.",,,,Critical (P0),Critical,,,Testing My Profile Features +Bug,DEPI-32,10339,"When clicking ""Apple"" under the ""Laptops"" category in the Mega Menu, the dropdown displays unrelated items.",DEPI-Automated-Testing-Final-Project,4/4/2025 7:10,,,"When clicking ""Apple"" under the ""Laptops"" category in the Mega Menu, the dropdown displays unrelated items (e.g., watches) instead of Apple laptops. + +Steps to Reproduce +1-Go to ““[https://ecommerce-playground.lambdatest.io/index.php?route=common/home|https://ecommerce-playground.lambdatest.io/index.php?route=common/home|smart-link] + +2-Hover over “Mega menu“ in the navigation bar + +3-Go to the down left and under “laptop“ click on Apple + +Expected Result +Clicking ""Apple"" under ""Laptops"" should display Apple products + +Actual Result + +Incorrect Items displayed",,,,High (P1) ,Major / High,SCRUM Sprint 2,, +Bug,DEPI-31,10336,Missing content in the homepage,DEPI-Automated-Testing-Final-Project,4/3/2025 20:35,Navigation,,"unexpected large white space on the homepage that appears to be a layout or content rendering issue. + + +*Steps to reproduce* +1-Go to “[https://ecommerce-playground.lambdatest.io/index.php?route=common/home|https://ecommerce-playground.lambdatest.io/index.php?route=common/home|smart-link] “ +2-Scroll down +3-observe the white layout space + +*Expected Results* +Content should be displayed in the white area +*Actual Results* + +Large white space appears due to failed content loading",,,,High (P1) ,Critical,SCRUM Sprint 2,, +Bug,DEPI-30,10335,"The ""TOP PRODUCTS"" section in the homepage is displaying the same product (""IMac"" priced at $170.00) four times consecutively",DEPI-Automated-Testing-Final-Project,4/3/2025 20:16,Navigation,," +*Steps to Reproduce* + +1- Go to “[https://ecommerce-playground.lambdatest.io/index.php?route=common/home|https://ecommerce-playground.lambdatest.io/index.php?route=common/home|smart-link] “ + +2-Scroll down until you see “Top Products“ section + +*Expected Behavior* +Top Products section should display different items + +*Actual Behavior* + +The same iMac product is displayed four times in identical format.",,,,Medium (P2),Major / High,SCRUM Sprint 2,, +Bug,DEPI-29,10334,Mega menu function doesn't work. ,DEPI-Automated-Testing-Final-Project,4/3/2025 18:05,,,"The mega menu is not functioning as expected, it doesn’t provide correct product.",,,,Medium (P2),Major / High,SCRUM Sprint 2,, +bug,DEPI-28,10333,"Incorrect Result for ""Color"" Option",DEPI-Automated-Testing-Final-Project,4/3/2025 17:56,,,"When the user selects ""Shop by Category"" and chooses a color, the website provides incorrect results, and some colors cannot be selected.",,,,Medium (P2),Major / High,SCRUM Sprint 2,, +Epic,DEPI-22,10298,Header Navigation Menu,DEPI-Automated-Testing-Final-Project,4/2/2025 9:45,Navigation,,"This Epic covers the implementation, styling, and functionality of the main navigation links in the header.",,,,,,,, +Epic,DEPI-20,10265,Implement and Test Advanced Filtering Functionality,DEPI-Automated-Testing-Final-Project,3/19/2025 14:51,Filteration,,"As a user, I want to filter search results effectively so that I can quickly find relevant products or content based on specific criteria such as categories, price range, ratings, and other attributes. + +*Acceptance Criteria:* + +# Users should be able to search for items using keywords. +# Users should be able to categorize search results based on predefined categories. +# Users should be able to apply filters such as price range, ratings, and availability. +# Filters should dynamically update search results in real-time or upon submission. +# Removing applied filters should update results accordingly. +# If no results match the selected filters, an appropriate message should be displayed. + +*Linked Test Cases:* + +* *Search Functionality* – Ensures users can search for products or content. +* *Categorization of Search Results* – Validates that search results can be sorted by predefined categories. +* *Filters Functionality* – Tests the application and removal of filters to refine search results.",,,,,,,, +Epic,DEPI-2,10001,Testing My Profile Features,DEPI-Automated-Testing-Final-Project,2/10/2025 14:38,authentication,security,"This epic covers the test cases for all features related to the *My Profile* section, ensuring they function correctly, securely, and efficiently across different scenarios. + +*Scope:* + +h4. *1. Register →* {color:#36b37e}*Done by Omar*{color} + +* Verify new users can successfully create an account with valid details. +* Ensure validation errors appear for missing or incorrect input fields. +* Check that account activation (if required) is working properly. +* Confirm duplicate accounts cannot be created with the same email. + +h4. *2. Forgotten Password* + +* Verify users can request a password reset via email. +* Check email notifications are sent with the correct reset link. +* Ensure reset links expire after a defined time. +* Validate users can successfully update their password. + +h4. *3. My Account* + +* Verify users can view and update personal details. +* Ensure changes are correctly saved and reflected. +* Validate session management (e.g., auto-logout after inactivity). +* Check access restrictions for unauthorized users. + +h4. *4. Address Book* + +* Verify users can add, edit, and delete addresses. +* Ensure validation rules apply (e.g., valid postal code format). +* Check the default address selection works as expected. +* Test API responses and database updates. + +h4. *5. Wish List* + +* Verify users can add/remove items to their wish list. +* Ensure wish list items persist after logout and login. +* Check UI functionality for sorting and managing wish list items. + +h4. *6. Order History* + +* Validate users can view past orders with correct details. +* Ensure filtering and sorting options work properly. +* Verify invoice downloads and order status updates. + +h4. *7. Downloads* + +* Check digital purchases appear in the downloads section. +* Verify download limits and expiration rules. +* Ensure secure access to purchased files. + +h4. *8. Recurring Payments* + +* Test adding, modifying, and removing payment methods. +* Verify recurring payments process correctly. +* Ensure security measures (e.g., encryption, tokenization) are in place. + +h4. *9. Reward Points* + +* Validate points calculation and redemption rules. +* Ensure correct points are awarded based on transactions. +* Check for unauthorized point modifications. + +h4. *10. Returns* + +* Verify users can request returns for eligible products. +* Ensure return policies and reasons are displayed correctly. +* Check email notifications and status tracking for return requests. + +h4. *11. Transactions* + +* Validate transaction history displays correctly. +* Check details such as payment method, amount, and status. +* Ensure refund transactions appear correctly. + +h4. *12. Newsletter* + +* Verify users can subscribe/unsubscribe from newsletters. +* Ensure email preferences update correctly. +* Check confirmation emails are sent for subscription changes. + +h3. *Acceptance Criteria:* + +✅ All test cases pass successfully. +✅ No security or access control issues exist. +✅ UI and API functionalities work as expected. +✅ Performance and usability criteria are met.",,,,,,,, +Test Case,DEPI-37,10429,Contact Website Owner via Contact Form,DEPI-Automated-Testing-Final-Project,5/9/2025 18:53,Contact,,"*As a* user *I want to* fill in my name, email, subject, and message in a contact form *So that* I can send a message to the website owner or support team",Nothing happend after clicking Send Message,"A success message appears (e.g., ""Message sent successfully""), or the user is informed of successful submission in some way.",The user is on the Contact page of the website,,,SCRUM Sprint 2,Failed,Header Navigation Menu +Test Case,DEPI-36,10398,Users can track there orders,DEPI-Automated-Testing-Final-Project,5/8/2025 17:45,,,"As a user, I want to track my orders",the page requested cannot be found.,the site provides the user where is the product.,* The user must have a registered account.,,,SCRUM Sprint 1,Failed, +Test Case,DEPI-33,10363,Checkout Product with Existing Address,DEPI-Automated-Testing-Final-Project,5/3/2025 11:53,Checkout,,"As a registered user, I want to choose a payment method during checkout so that I can complete my purchase.","No payment methods are displayed. A warning message appears stating: + +{quote}“No Payment options are available. Please contact us for assistance!”{quote} + +Additionally, the checkout process cannot continue.","The system should display available payment options, allowing the user to select one and proceed with placing the order.","* User is logged in. +* At least one product is added to the shopping cart. + +* The user has a saved billing address in their account.",,,SCRUM Sprint 2,Failed,Testing My Profile Features +Test Case,DEPI-27,10332,Smooth Navigation for AddOns Dropdown Menu,DEPI-Automated-Testing-Final-Project,4/3/2025 17:21,AddOns,,,work as expected.,"* The dropdown should expand *instantly* when the user interacts with it. +* The menu should remain open *until a selection is made* or the user clicks outside. +* Clicking on an option should *immediately load* the relevant page. +* The navigation should be *smooth and responsive*, ensuring a seamless experience. +* If an option requires installation or purchase, the system should *prompt the user* with confirmation.",No Preconditions,,,SCRUM Sprint 2,Succeeded ,Header Navigation Menu +Test Case,DEPI-26,10331,Efficient Product Browsing via Mega Menu,DEPI-Automated-Testing-Final-Project,4/3/2025 17:16,MegaMenu,,"As an *online shopper*, I want to quickly navigate through different product categories using a *mega menu*, so that I can efficiently find and purchase the items I need without unnecessary clicks.",The whole mega menu function doesn’t works.,"* The mega menu should provide a *smooth, intuitive* navigation experience. +* Users should *quickly find* the product categories and subcategories. +* The transition from the menu to product pages should be *fast and seamless*. +* The website should maintain a *consistent and responsive* layout for all device sizes.",No Preconditions,,,SCRUM Sprint 2,Failed,Header Navigation Menu +Test Case,DEPI-25,10330,Allow the user to access the Blog,DEPI-Automated-Testing-Final-Project,4/3/2025 17:11,Blog,,"As a user, I want to surf the Blog page to be up to date with the latest activities and news.",Everything works as designed,The user can access all the blog page content from any place on the website.,No Preconditions,,,,,Header Navigation Menu +Test Case,DEPI-24,10300,"Verify the ""Special"" link is visible and clickable",DEPI-Automated-Testing-Final-Project,4/2/2025 9:52,,,"*As a* user, I want to see the ""Special"" link with a ""Hot"" badge so that I am aware of special offers or features.","Everything works as expected but there is no “Special products” +” {color:#4c9aff}*There are no special offer products to list.* {color}”","The user is redirected to the Special page without errors. +The ""Special"" page loads correctly, and the ""Hot"" badge is visible.",User is on any page of the website.,,,SCRUM Sprint 2,Succeeded ,Header Navigation Menu +Test Case,DEPI-23,10299,"Verify the ""Home"" link redirects to the homepage",DEPI-Automated-Testing-Final-Project,4/2/2025 9:49,Home,,"*As a* user, I want to click on the ""Home"" link so that I am redirected to the homepage.",everything works as expected,The user is redirected to the homepage without errors.,User is on any page of the website.,,,SCRUM Sprint 2,Succeeded ,Header Navigation Menu +Test Case,DEPI-21,10297,Allow users to filter the products,DEPI-Automated-Testing-Final-Project,4/2/2025 8:21,Filteration,,"As a user, I want to filter items on the platform so that I can refine my search and find products that meet my specific criteria.","Option ""Color (e.g., Red, Blue, Black)"" gives the user the wrong result.","* The filtered results should match the selected criteria. +* The user should be able to refine results by adding/removing filters. +* If no items match the selected filters, a proper message should be displayed.",None,,,SCRUM Sprint 2,Failed,Implement and Test Advanced Filtering Functionality +Test Case,DEPI-19,10264,Allow Users to Search for Items on the Platform,DEPI-Automated-Testing-Final-Project,3/19/2025 14:44,Search,,"As a user, I want to search for items on the platform so that I can quickly find the products or content I am looking for.",Everything works as expected,"* The search results display relevant items matching the query. +* The user can navigate to the desired item's page by clicking on a result. +* If no results are found, a ""No results found"" message is displayed with possible suggestions.",,,,SCRUM Sprint 2,Succeeded ,Implement and Test Advanced Filtering Functionality +Test Case,DEPI-18,10237, Verify the Compare Feature for Products,DEPI-Automated-Testing-Final-Project,3/18/2025 15:29,Compare,,"As a user, I want to compare multiple products side by side so that I can make an informed purchase decision based on their specifications, pricing, and other attributes.","Passed, but the comparison feature can work with only one product. The maximum number of products for comparison is four (4).","* Selected products appear in the comparison list. +* Product details are displayed correctly. +* Users can add/remove products as intended. +* Feature restrictions (if any) are enforced.","* The user is logged in (optional if guest users can compare). +* At least two products are available for comparison.",,,SCRUM Sprint 2,Succeeded ,Implement and Test Advanced Filtering Functionality +Test Case,DEPI-16,10235,Allow Users to Logout from the Platform,DEPI-Automated-Testing-Final-Project,3/18/2025 13:20,authentication,Logout,"As a user, I want to log out of my account to ensure security.",Everything works as expected,* The user is successfully logged out and redirected to the login page.,* The user must be logged into their account.,,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-15,10234,Allow Users to Subscribe to the Newsletter,DEPI-Automated-Testing-Final-Project,3/18/2025 13:18,NewsLetter,,"As a user, I want to subscribe to the newsletter to receive updates and promotions.",Everything works as expected.,* The user is successfully subscribed to the newsletter and receives a confirmation message/email.,"* The user must have an active email address. + +* The newsletter subscription option must be available.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-14,10233,Allow Users to View Transaction History,DEPI-Automated-Testing-Final-Project,3/18/2025 13:16,Transactions,,"As a user, I want to view my transaction history to keep track of my purchases and payments.",Everything works as expected.,"The user can view transaction details, including the date, amount, and payment method.","* The user must have an account. + +* The user must have completed at least one transaction.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-13,10232,Allow Users to Return Purchased Items,DEPI-Automated-Testing-Final-Project,3/18/2025 13:15,Returns,,"As a user, I want to return an item I purchased so that I can receive a refund or a replacement.",works as expected.,"The return request is successfully submitted, and the user receives a confirmation message.","* The user must have a registered account. +* The user must have an eligible item for return. +* The returns policy must be active.",,,SCRUM Sprint 1,Succeeded ,Testing My Profile Features +Test Case,DEPI-12,10231,Allow users to Access their reward points,DEPI-Automated-Testing-Final-Project,3/18/2025 13:10,RewardPoints,,"As a user, I want to check my Rewards points with their dates and description.",work as expected,"The Rewards points dashboard should display the user’s reward points with the date on which the points been added, the description and the number of the reward points.",* The user must have an existing registered account.,,,SCRUM Sprint 1,Succeeded ,Testing My Profile Features +Test Case,DEPI-11,10167,Allow Users to View and Redeem Reward Points,DEPI-Automated-Testing-Final-Project,3/6/2025 14:19,,,"As a user, I want to track and redeem my reward points.",“Reward points“ page display the total number of reward points “Your total number of reward points is: *0*“,* The points are correctly displayed and redeemed successfully.,"* The user must be logged in. +* The reward points system must be enabled.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-10,10166,Allow Users to Manage Their Recurring Payments,DEPI-Automated-Testing-Final-Project,3/6/2025 14:18,,,"As a user, I want to manage my recurring payments.",“Recurring Payement“ page show “No results! “Message.,The subscription is modified or canceled successfully.,"* The user must be logged in. +* The user must have an active subscription.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-9,10165,Allow Users to Access Their Downloads,DEPI-Automated-Testing-Final-Project,3/6/2025 14:15,,,"As a user, I want to access my purchased digital downloads.",everything worked as expected.,The file is downloaded successfully.,"* The user must be logged in. +* The user must have purchased downloadable items.",,,SCRUM Sprint 1,Succeeded ,Testing My Profile Features +Test Case,DEPI-8,10136,Allow Users to View Their Order History,DEPI-Automated-Testing-Final-Project,3/4/2025 17:32,,,"As a user, I want to view my past orders.",Everything works as designed,The order details are displayed correctly.,"* The user must be logged in. +* The ""Order History"" section must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-7,10135,Allow Users to Manage Their Wish List,DEPI-Automated-Testing-Final-Project,3/4/2025 17:30,WishList,,"As a user, I want to add, view, and remove products from my wish list.",Everything works as designed,The product is added/removed successfully.,"* The user must be logged in. +* The ""Wish List"" feature must be enabled.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-6,10134,Allow Users to Manage Their Address Book,DEPI-Automated-Testing-Final-Project,3/4/2025 17:27,AddressBook,,"As a user, I want to add, edit, and delete addresses in my address book.",Everything works as designed,"The address is added, updated, or deleted successfully.","* The user must be logged in. +* The ""Address Book"" section must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-5,10133,Allow Users to Manage Their Account Information,DEPI-Automated-Testing-Final-Project,3/4/2025 17:25,MyAccount,,"As a user, I want to access and update my account details.",Everything works as designed,"The updated information is saved, and a success message is displayed.","* The user must be logged in. +* The ""My Account"" page must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-4,10132,Allow Users to Reset Forgotten Password,DEPI-Automated-Testing-Final-Project,3/4/2025 17:21,Login,password,"As a user, I want to reset my password if I forget it, so I can regain access to my account.",Everything worked as designed,"A confirmation message is displayed, and the user can log in with the new password.","* The user must have an existing registered account. +* The ""Forgot Password"" page must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-3,10099,Allow users to Register into the platform,DEPI-Automated-Testing-Final-Project,2/21/2025 14:45,authentication,Register,"As a new user, I want to register for an account using my email and password so that I can access the platform.",Everything worked as designed," + +* The user account is successfully created. +* The user is redirected to the dashboard/homepage or a confirmation page"," + +* The user must not have an existing registered account with the same email. +* The registration page must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features +Test Case,DEPI-1,10000,Allow users to Login into the platform,DEPI-Automated-Testing-Final-Project,2/10/2025 14:33,Login,MyAccount,"As a user, I want to login into my account using my email and password to access the platform. + +h4. ",Everything works as designed,The user is successfully logged in and redirected to the dashboard/homepage.,"* The user must have an existing registered account. +* The login page must be accessible.",,,SCRUM Sprint 1,,Testing My Profile Features diff --git a/testdata/import/csv/canonical/jira/sample-04.csv b/testdata/import/csv/canonical/jira/sample-04.csv new file mode 100644 index 00000000..3584b4fe --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-04.csv @@ -0,0 +1,82 @@ +Summary,Issue key,Issue id,Issue Type,Status,Project key,Project name,Project type,Project lead,Project lead id,Project description,Priority,Resolution,Assignee,Assignee Id,Reporter,Reporter Id,Creator,Creator Id,Created,Updated,Last Viewed,Resolved,Due date,Votes,Description,Environment,Watchers,Watchers Id,Original estimate,Remaining Estimate,Time Spent,Work Ratio,Σ Original Estimate,Σ Remaining Estimate,Σ Time Spent,Security Level,Inward issue link (Defect),Inward issue link (Defect),Outward issue link (Test),Attachment,Attachment,Attachment,Attachment,Attachment,Custom field (Actual end),Custom field (Actual start),Custom field (Begin Date),Custom field (Change reason),Custom field (Change risk),Custom field (Change type),Custom field (Development),Custom field (End Date),Custom field (Epic Link),Epic Link Summary,Custom field (Impact),Custom field (Issue color),Custom field (Locked forms),Custom field (Open forms),Custom field (Rank),Custom field (Request Type),Custom field (Revision),Sprint,Custom field (Start date),Custom field (Story point estimate),Custom field (Submitted forms),Custom field (Target end),Custom field (Target start),Custom field (Team),Custom field (Total forms),Custom field ([CHART] Date of First Response),Parent,Parent summary,Status Category,Status Category Changed +QR code placed in the incorrect location on the site,T2-22,10058,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,27/Feb/24 10:01 PM,27/Feb/24 10:10 PM,29/Feb/24 2:02 PM,,,0,"The QR code is placed in the incorrect location on the registration subpage. It overlaps the video positioned below. + +h2. Steps for reproduction: + +# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Click on *Menu* on the top left corner +# Click on *Register* + +h2. Actual result: + +The QR code seems to be moved below so it overlaps the video. + +h2. Anticipated result: + +The QR code should be placed above the video and beside the ‘Download the app’ options. + +h2. Screenshot: + +!Screenshot 2024-02-27 at 14.11.10.png|width=454,height=615!","iPad mini, iPadOS, Chrome Version 122.0.6261.69 (Official Build) (x86_64)",Person 21,account-id-3,,,,,,,,,,,T2-10,27/Feb/24 10:01 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 14.11.10.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10013,,,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000fb:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 10:01 PM +On-Screen reader does not read aloud the Menu ,T2-21,10057,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,27/Feb/24 1:56 PM,27/Feb/24 10:07 PM,27/Feb/24 10:09 PM,,,0,"When hovering over the menu on the upper-left corner of the Accessibility subpage, the on-screen reader (VoiceOver) does not recognise the options and does not read aloud their titles. + +h2. Steps for reproduction: + +# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Scroll down the main page and click on *Accessibility* (located in the footer) +# Using Tab key choose any option from the upper-left corner *Personal |  Business | Corporate* + +h2. Actual result: + +The on-screen reader (VoiceOver) does not recognise the options and does not read aloud their titles. + +h2. Anticipated result: + +The on-screen reader (VoiceOver) should read aloud each option from the menu. + +h2. Screenshots: + +!Screenshot 2024-02-27 at 13.46.15.png|width=605,height=392! + +h2. Other attachments: + +Chrome *>* DevTools *>* Lighthouse report + +!Screenshot 2024-02-26 at 14.36.30.png|width=741,height=869! + +!Screenshot 2024-02-26 at 14.36.53.png|width=749,height=1042! + +!Screenshot 2024-02-26 at 14.37.09.png|width=723,height=1046! + +!Screenshot 2024-02-26 at 14.37.27.png|width=738,height=1038! + + + +h2. ",Opera 107 on macOS Sonoma 14.3.1 (23D60),Person 21,account-id-3,,,,,,,,,,,,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.30.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10009,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.53.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10010,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.09.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10012,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.27.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10011,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 13.46.15.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10008,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000f3:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 1:56 PM +The heading on Accessibility subpage does not work,T2-18,10054,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,25/Feb/24 10:31 PM,28/Feb/24 1:10 PM,28/Feb/24 1:08 PM,,,0,"There is no response when clicking on the heading *‘Deaf, hearing or speech impairment’.* The user is not redirected to the dedicated subpage. + +h2. Steps for reproduction: + +# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Scroll down the main page and click on *Accessibility* (located in the footer) +# Hover over *Deaf, hearing or speech impairment* and click the heading + +h2. Actual result: + +Nothing happens. The reference does not work and user stays on the page. + +h2. Anticipated result: + +The user should be redirected to the dedicated subpage + +h2. Screenshot: + +!AccessibiityBug_T2-18.jpg|width=1556,height=1150! + +h2. Console log: + +!Screenshot 2024-02-25 at 22.38.30.png|width=1166,height=391! + + + +h2. ",MacOS Ventura Version 13.6.3 (22G436) Safari Version 16.6 (18615.3.12.11.2),Person 21,account-id-3,,,,,,,,,T2-10,T2-17,T2-10,28/Feb/24 1:10 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18 (a037e9fd-3f73-4ab0-b1be-189de8f7156a).jpg;https://paulahum.atlassian.net/rest/api/3/attachment/content/10014,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18.jpg;https://paulahum.atlassian.net/rest/api/3/attachment/content/10007,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-25 at 22.38.30.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10006,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000ef:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,25/Feb/24 10:31 PM diff --git a/testdata/import/csv/canonical/jira/sample-05.csv b/testdata/import/csv/canonical/jira/sample-05.csv new file mode 100644 index 00000000..9917b95b --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-05.csv @@ -0,0 +1,131 @@ +Issue Type,Issue key,Issue id,Summary,Fix versions,Fix versions,Status,Created,Updated,Priority,Resolution,Affects versions,Affects versions,Security Level,Labels,Labels,Labels,Labels,Labels +Improvement,NAS-133831,115984,L2ARC write improvements,26.0.0-BETA.1,26.0.0-BETA.2,Done,27/Jan/25 1:58 PM,10/Mar/26 2:57 PM,Low,Complete,SCALE-25.04-ALPHA.1 (Fangtooth),,,2604alpha2beta,IX_EMPLOYEE,jirabug,, +Improvement,NAS-133952,116340,"After editing ZFS snapshot task, old snapshots lose retention attribute and timestamp",26.0.0-BETA.1,,Done,02/Feb/25 7:14 PM,16/Jan/26 3:54 AM,Medium,Complete,SCALE-24.10.2 (ElectricEel),,,2604alpha2beta,COMMUNITY_USER,es-2510,july-2510,ready_for_assignment +Improvement,NAS-135490,120571,Application Info Widget Should Display App Version,26.0.0-BETA.1,,Done,22/Apr/25 10:55 AM,26/Mar/26 3:27 PM,Low,Complete,SCALE-25.04.0 (Fangtooth),,,es-2510,IX_EMPLOYEE,july-2510,ready_for_assignment, +Bug,NAS-135633,120981,Reporting -> CPU stacks total plus individual CPU,26.0.0-BETA.1,,Done,30/Apr/25 10:29 AM,13/Feb/26 10:11 AM,Low,Complete,SCALE-25.04.0 (Fangtooth),,,es-2510,IX_EMPLOYEE,july-2510,ready_for_assignment, +Bug,NAS-135677,121091,dashboard app widget Web UI address,26.0.0-BETA.1,,Done,02/May/25 4:58 PM,15/Jan/26 5:35 AM,Low,Complete,SCALE-25.04.0 (Fangtooth),,,COMMUNITY_USER,es-2510,july-2510,ready_for_review, +Improvement,NAS-135779,121326,iscsi wizard doesn't allow creating extents based on snapshots (missing read only),26.0.0-BETA.1,,Done,08/May/25 8:35 AM,13/Jan/26 6:29 PM,Low,Complete,SCALE-25.04.0 (Fangtooth),,,es-2510,IX_EMPLOYEE,july-2510,, +Defect,NAS-136251,122786,Zvol form: fix compression options and investigate size discrepancies,26.0.0-BETA.1,,Done,09/Jun/25 9:59 AM,15/Jan/26 5:35 AM,Medium,Complete,,,,es-2510,july-2510,,, +Bug,NAS-136434,123320,Dashboard -> Apps Widget -> Application type Block I/O chart wrong,26.0.0-BETA.1,SCALE-25.10-BETA.1 (Goldeye),Done,23/Jun/25 4:34 PM,29/Jul/25 4:17 AM,Low,Complete,SCALE-25.04.1 (Fangtooth),,,es-2510,IX_EMPLOYEE,july-2510,ready_for_review, +Improvement,NAS-136913,124451,Clean upgrade strategy code from apps validation,26.0.0-BETA.1,SCALE-25.10-BETA.1 (Goldeye),Done,30/Jul/25 4:20 PM,19/Aug/25 4:50 PM,Low,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-136914,124453,Remove k8s migration validation logic from apps validation,26.0.0-BETA.1,SCALE-25.10-BETA.1 (Goldeye),Done,30/Jul/25 4:21 PM,19/Aug/25 4:50 PM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-136917,124457,Investigate/fix CI bug in apps which does not let correct last update to show up,26.0.0-BETA.1,,Done,30/Jul/25 6:18 PM,19/Aug/25 4:48 PM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-137013,124655,Add tests for method usage stats,26.0.0-BETA.1,,Done,05/Aug/25 6:01 AM,18/Feb/26 10:32 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-137252,125123,Allow MPIO for Fibre Channel,26.0.0-BETA.1,,Done,21/Aug/25 10:47 AM,02/Mar/26 3:47 PM,High,Complete,SCALE-25.04.3 (Fangtooth),,,2604alpha2beta,doc_impact,IX_EMPLOYEE,mt_reviewed,tests_impact +New Feature,NAS-137282,125182,Web UI prevents removal of NVIDIA drivers once GPU is uninstalled,26.0.0-BETA.1,,Done,24/Aug/25 1:23 AM,02/Mar/26 6:27 AM,Medium,Complete,SCALE-25.04.2.1 (Fangtooth),,,COMMUNITY_USER,,,, +New Feature,NAS-137495,125748,bring in zfs 2.4 for HM,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,10/Sep/25 8:51 AM,14/Oct/25 2:22 PM,High,Complete,,,,IX_EMPLOYEE,,,, +Epic,NAS-137642,126052,Context Aware Alerts,26.0.0-BETA.1,,Done,22/Sep/25 10:01 AM,06/Feb/26 4:50 AM,Medium,Complete,,,,2604alpha2beta,IX_EMPLOYEE,,, +Bug,NAS-137681,126144,Debian package generation creating unsatisfiable deps due to pip resolver version mismatch,26.0.0-BETA.1,27.0.0-BETA.1,Done,24/Sep/25 1:45 AM,01/Feb/26 7:36 AM,Medium,Complete,,,,IX_EMPLOYEE,ready_for_assignment,,, +Bug,NAS-137715,126186,Samba spotlight AF_UNIX connection should be performed as root,26.0.0-BETA.1,,Done,24/Sep/25 2:37 PM,02/Mar/26 12:09 PM,Blocker,Complete,,,,2604alpha2beta,IX_EMPLOYEE,mt_reviewed,, +New Feature,NAS-137769,126363,integrate webshare packages (for spotlight) into middleware,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,29/Sep/25 9:20 AM,22/Oct/25 1:58 PM,Blocker,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-137787,126395,SCST needs a proper systemd unit file (not autogenerated),26.0.0-BETA.1,,Done,30/Sep/25 8:08 AM,30/Oct/25 6:46 PM,Low,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-137901,126623,Unit tests internal push pipeline broken,26.0.0-BETA.1,,Done,06/Oct/25 1:33 AM,13/Oct/25 12:15 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-137947,126709,Cert revocation is broken in trixie due to upgraded packages,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,08/Oct/25 1:55 AM,13/Oct/25 12:14 AM,High,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-137948,126711,Move away from pyOpenSSL and use cryptography instead for crypto plugin,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,08/Oct/25 2:11 AM,10/Nov/25 3:15 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138022,126910,Migrate VMs to new bootloader/ovmf files in trixie,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,13/Oct/25 10:17 AM,27/Oct/25 6:03 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138043,126963,Relaxed special vdev constraints in ZFS 2.4,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,14/Oct/25 2:13 PM,10/Nov/25 3:12 AM,Medium,Complete,,,,IX_EMPLOYEE,ready_for_assignment,,, +New Feature,NAS-138129,127287,adapt UI to special vdev changes in ZFS 2.4,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,20/Oct/25 11:10 AM,30/Dec/25 9:34 AM,Medium,Complete,,,,doc_impact,IX_EMPLOYEE,,, +Bug,NAS-138149,127327,Investigate random cert generation failure when TNC cert is being generated,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,21/Oct/25 12:16 PM,27/Oct/25 3:46 AM,Medium,Complete,SCALE-25.10.0 (Goldeye),,,IX_EMPLOYEE,,,, +New Feature,NAS-138210,127653,Pool Dataset details should show containers usage,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,27/Oct/25 7:58 AM,03/Nov/25 9:42 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138211,127655,Filesystem attachment delegate for Containers,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,27/Oct/25 8:01 AM,10/Nov/25 3:11 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138309,128145,Make sure containers in HA work nicely,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,01/Nov/25 9:27 AM,08/Dec/25 4:55 AM,High,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138373,128317,Containers API | NIC API | Add nic type to the response,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,04/Nov/25 5:11 AM,08/Dec/25 5:39 AM,Low,Complete,26.0.0-BETA.1,,,IX_EMPLOYEE,,,, +New Feature,NAS-138374,128319,Allow adding GPUs to containers,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,04/Nov/25 6:01 AM,15/Nov/25 10:15 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138395,128361,Clean unnecessary container parameters,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,05/Nov/25 2:32 AM,10/Nov/25 3:09 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138400,128403,Remove disk/raw devices from containers,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,05/Nov/25 11:02 AM,10/Nov/25 3:08 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138418,128466,Do a connectivity check when we are about to call to outside resources,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,06/Nov/25 4:59 AM,23/Feb/26 11:10 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-138458,128578,"Duplicate ""apps"" user permissions with nested ""Apps"" datasets",26.0.0-BETA.1,,Done,07/Nov/25 7:14 PM,08/Jan/26 9:15 AM,Low,Complete,SCALE-25.10.0 (Goldeye),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +Improvement,NAS-138470,128796,Improve error handling for apps on creation,26.0.0-BETA.1,,Done,09/Nov/25 10:32 PM,13/Feb/26 8:30 AM,Medium,Complete,,,,2604alpha2beta,IX_EMPLOYEE,,, +Improvement,NAS-138471,128798,Order attr in VM devices not being respected,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,10/Nov/25 1:03 AM,10/Nov/25 3:08 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138477,128852,Investigate/improve/fix container fields implementation,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,10/Nov/25 6:05 AM,15/Nov/25 9:19 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138483,128861,Update enterprise license to add a SED feature flag,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,10/Nov/25 11:22 AM,23/Nov/25 9:31 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138484,128862,remove internal calls to zfs.snapshot.delete,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,10/Nov/25 12:31 PM,03/Dec/25 3:11 PM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138524,129156,Add support for query-filter on json path such as `$.foo.bar[1]`,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,12/Nov/25 2:17 PM,28/Dec/25 11:11 AM,Low,Complete,,,,IX_EMPLOYEE,ready_for_assignment,,, +New Feature,NAS-138569,129562,Add nvidia gpu support for LXC containers,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,15/Nov/25 9:05 AM,01/Dec/25 5:52 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138570,129564,Add intel gpu support for LXC containers,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,15/Nov/25 9:05 AM,22/Nov/25 11:02 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138571,129566,Investigate having metrics for LXC containers similar to how we had in virt plugin,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,15/Nov/25 10:16 AM,22/Nov/25 11:00 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-138583,129847,sorting is not maintained when the grid refreshes,26.0.0-BETA.1,,Done,17/Nov/25 11:14 AM,15/Jan/26 5:51 AM,Low,Complete,SCALE-25.04.2.6 (Fangtooth),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +Bug,NAS-138613,130377,Z-Pool is degraded but nothing tells me why or how.,26.0.0-BETA.1,,Done,19/Nov/25 5:50 PM,05/Feb/26 3:12 PM,Medium,Complete,SCALE-25.10.0.1 (Goldeye),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +New Feature,NAS-138624,130494,Expose container metrics like we did for virt based instances metrics in the UI,26.0.0-BETA.1,,Done,20/Nov/25 11:14 AM,06/Dec/25 6:00 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +New Feature,NAS-138700,131490,Expose GPU device for LXC containers in UI,26.0.0-BETA.1,,Done,26/Nov/25 11:44 AM,02/Mar/26 3:46 PM,High,Complete,,,,IX_EMPLOYEE,mt_reviewed,,, +Bug,NAS-138743,132152,Migration of incus containers to libvirt implementation broken,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,01/Dec/25 10:16 AM,08/Dec/25 10:56 AM,High,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138749,132259,Group selection action button disappears when confirmation is canceled,26.0.0-BETA.1,,Done,01/Dec/25 8:32 PM,24/Feb/26 8:32 AM,Low,Complete,SCALE-25.10.0 (Goldeye),,,COMMUNITY_USER,waiting_for_feedback,,, +Improvement,NAS-138815,133081,Indicate TrueNAS Connect tier in header,26.0.0-BETA.1,,Done,05/Dec/25 12:15 PM,02/Mar/26 6:34 AM,Medium,Complete,,,,IX_EMPLOYEE,ready_for_assignment,,, +Improvement,NAS-138842,133357,Make sure disk gets synced and pool sed attr reflects reality after license updates,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,08/Dec/25 11:12 AM,28/Dec/25 11:10 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138844,133391,Add error logging to audit_handler,26.0.0-BETA.1,,Done,08/Dec/25 2:36 PM,24/Feb/26 8:32 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-138873,133693,Some forms don't show the Unsaved Changes warning,26.0.0-BETA.1,,Done,10/Dec/25 8:34 AM,15/Jan/26 5:51 AM,Medium,Complete,Master - TrueNAS Nightlies,,,2604alpha2beta,IX_EMPLOYEE,,, +Bug,NAS-138896,133979,Add Rsync job misses Save button on mobile,26.0.0-BETA.1,,Done,11/Dec/25 1:32 PM,06/Jan/26 9:44 AM,Low,Complete,SCALE-25.10.0.1 (Goldeye),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +Improvement,NAS-138931,134409,Add a Description or Label Field to Add Host Screen and Manage Hosts Windows,26.0.0-BETA.1,,Done,15/Dec/25 10:34 AM,24/Feb/26 8:32 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,IX_EMPLOYEE,ready_for_review,,, +Bug,NAS-138934,134444,"Doing a refresh of the TrueNAS App catalog for updated apps does not refresh the Apps page, a page refresh is required",26.0.0-BETA.1,,Done,15/Dec/25 12:23 PM,02/Mar/26 6:34 AM,Medium,Complete,SCALE-25.10.0.1 (Goldeye),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +Improvement,NAS-138938,134448,unit tests broken from NAS-138524,26.0.0-BETA.1,SCALE-26.04-ALPHA.1 (Halfmoon),Done,15/Dec/25 1:18 PM,28/Dec/25 11:07 AM,High,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139038,134907,"ix-netif boot delay ~120s (interface.sync timeout) + dhcpcd logs ""unknown option: master"" on VLAN DHCP (26.04.0-MASTER-20251219-075007)",26.0.0-BETA.1,,Done,20/Dec/25 12:12 PM,02/Mar/26 3:45 PM,High,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,mt_reviewed,ready_for_assignment,, +Bug,NAS-139045,134957,USB passthrough broken on 26.04 20251219 build,26.0.0-BETA.1,,Done,21/Dec/25 2:30 PM,11/Jan/26 2:16 PM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139053,135003,Boot environments dialogue- keep/unkeep flag switched,26.0.0-BETA.1,,Done,22/Dec/25 8:49 AM,15/Jan/26 5:51 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,2604alpha2beta,COMMUNITY_USER,ready_for_assignment,, +Improvement,NAS-139093,135294,Link the Rsync logs,26.0.0-BETA.1,,Done,28/Dec/25 3:32 AM,05/Feb/26 3:42 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Improvement,NAS-139102,135378,consider changing nfs yaml dump to use CSafeLoader,26.0.0-BETA.1,,Done,29/Dec/25 10:22 AM,02/Mar/26 12:09 PM,Medium,Complete,SCALE-25.10.1 (Goldeye),SCALE-26.04-ALPHA.1 (Halfmoon),,IX_EMPLOYEE,,,, +Improvement,NAS-139105,135397,"TrueCloud, There is no information about ",26.0.0-BETA.1,,Done,29/Dec/25 6:32 PM,24/Feb/26 8:33 AM,Undefined,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,,,, +Bug,NAS-139166,135722,Update modal dialog obscures failed secret download,26.0.0-BETA.1,,Done,04/Jan/26 11:55 AM,22/Jan/26 11:01 AM,Medium,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139213,135936,Replication Tasks search filter is reset when job is started/completed,26.0.0-BETA.1,,Done,07/Jan/26 8:52 PM,15/Jan/26 5:32 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139226,135991,"When setting a certificate for the FTP service, check if the certificate is RSA or EC",26.0.0-BETA.1,,Done,08/Jan/26 1:04 PM,25/Feb/26 7:01 PM,Low,Complete,SCALE-25.10.2 (Goldeye),,,COMMUNITY_USER,waiting_for_feedback,,, +Improvement,NAS-139230,135996,Add BRT support to zpool prefetch command,26.0.0-BETA.1,,Done,08/Jan/26 1:48 PM,02/Mar/26 3:39 PM,High,Complete,SCALE-26.04-ALPHA.1 (Halfmoon),,,IX_EMPLOYEE,mt_reviewed,ready_for_assignment,, +Bug,NAS-139241,136141,Fix timspan display issues on UPS reporting charts,26.0.0-BETA.1,,Done,09/Jan/26 7:50 AM,05/Feb/26 3:11 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,IX_EMPLOYEE,ready_for_assignment,,, +Improvement,NAS-139245,136177,Python type stubs in truenas_pyos repo,26.0.0-BETA.1,,Done,09/Jan/26 1:40 PM,09/Jan/26 1:40 PM,Low,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139259,136311,Cannot create or write into the data directory /var/www/html/data,26.0.0-BETA.1,,Done,11/Jan/26 2:00 PM,24/Feb/26 8:33 AM,Undefined,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,,,, +Bug,NAS-139293,136424,"When I export the pool that has no system dataset, it says ""System dataset will be moved off this pool""",26.0.0-BETA.1,,Done,13/Jan/26 3:57 AM,19/Jan/26 10:07 AM,Medium,Complete,Master - TrueNAS Nightlies,,,IX_EMPLOYEE,,,, +Improvement,NAS-139303,136538,Add graceful shutdown of docker when TN is being shutdown,26.0.0-BETA.1,,Done,13/Jan/26 6:13 PM,01/Feb/26 5:06 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139324,136760,Wrong date when creating replication task,26.0.0-BETA.1,,Done,15/Jan/26 6:53 AM,06/Feb/26 4:52 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Improvement,NAS-139329,136767,Tunables and Syctls is duplicate page,26.0.0-BETA.1,,Done,15/Jan/26 9:44 AM,06/Feb/26 4:51 AM,Undefined,Complete,,,,doc_impact,IX_EMPLOYEE,,, +Bug,NAS-139342,136855,Tooltips/poppers are broken after v21 Angular update,26.0.0-BETA.1,,Done,16/Jan/26 4:43 AM,20/Jan/26 1:45 PM,Medium,Complete,26.0.0-BETA.1,,,IX_EMPLOYEE,,,, +Improvement,NAS-139345,136890,Reduce alerts regarding app updates,26.0.0-BETA.1,,Done,16/Jan/26 8:48 AM,01/Feb/26 5:06 AM,Undefined,Complete,,,,IX_EMPLOYEE,ready_for_review,,, +Bug,NAS-139346,136891,"Apps Form, No error shown when list type has a `min` constrain.",26.0.0-BETA.1,,Done,16/Jan/26 8:54 AM,24/Feb/26 8:33 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139356,136938,PCIE passthrough broken,26.0.0-BETA.1,,Done,17/Jan/26 2:14 AM,22/Feb/26 1:00 PM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139361,137010,Misleading Nomenclature of authorized keys with new Users page,26.0.0-BETA.1,,Done,18/Jan/26 4:14 AM,06/Feb/26 4:52 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139363,137047,Dataset unavailable after zfs send,26.0.0-BETA.1,,Done,18/Jan/26 3:43 PM,05/Feb/26 8:02 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139385,137250,Plural forms are not rendering correctly on the current page,26.0.0-BETA.1,,Done,20/Jan/26 7:00 AM,06/Feb/26 4:51 AM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139397,137328,cannot update an ISCSI auth method with `Mutual CHAP` set,26.0.0-BETA.1,,Done,20/Jan/26 3:14 PM,23/Jan/26 4:04 PM,Low,Complete,26.0.0-BETA.1,,,IX_EMPLOYEE,,,, +Improvement,NAS-139415,137549,Jobs view do not format the json content (arguments),26.0.0-BETA.1,,Done,21/Jan/26 2:18 PM,02/Mar/26 6:34 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139448,137972,TrueNAS connect icon is in a different shade of white,26.0.0-BETA.1,,Done,24/Jan/26 3:35 AM,02/Mar/26 6:34 AM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139489,138237,"Bad disk serial causes ""We are working in the background to generate relevant data""",26.0.0-BETA.1,,Done,27/Jan/26 2:30 AM,02/Feb/26 5:08 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139490,138240,Shares/NFS no action available,26.0.0-BETA.1,,Done,27/Jan/26 3:51 AM,05/Feb/26 4:31 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139491,138244,WebUI logs out user on Firefox page refresh,26.0.0-BETA.1,,Done,27/Jan/26 4:48 AM,03/Feb/26 6:55 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139519,138448,WebUI displays ENOENT/EINVAL tracebacks after exporting a pool via CLI midclt,26.0.0-BETA.1,,Done,28/Jan/26 3:34 AM,10/Feb/26 11:44 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139521,138453,Some UI elements is active while filing a bug,26.0.0-BETA.1,,Done,28/Jan/26 4:22 AM,05/Feb/26 3:10 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139535,138704,unable to edit Share ACL,26.0.0-BETA.1,,Done,29/Jan/26 12:59 PM,24/Feb/26 8:33 AM,Undefined,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,waiting_for_feedback,,, +Improvement,NAS-139544,138919,Add option to de-register from TrueNAS Connect,26.0.0-BETA.1,,Done,30/Jan/26 1:46 PM,02/Feb/26 2:04 PM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139545,138921,Apps wont start after update to Dev build ,26.0.0-BETA.1,,Done,30/Jan/26 2:22 PM,02/Mar/26 3:10 PM,High,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,mt_reviewed,ready_for_review,, +Bug,NAS-139559,139075,TimeMachine SMB Shares being indexed unnecessarily,26.0.0-BETA.1,,Done,31/Jan/26 11:05 AM,31/Jan/26 4:31 PM,Medium,Complete,Master - TrueNAS Nightlies,,,IX_EMPLOYEE,,,, +Bug,NAS-139571,139162,Empty System > Services in the web interface,26.0.0-BETA.1,,Done,01/Feb/26 7:27 AM,24/Feb/26 12:29 PM,Low,Complete,,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139580,139309,tn_connect.ips_with_hostnames API call returning empty,26.0.0-BETA.1,,Done,02/Feb/26 9:15 AM,02/Feb/26 11:16 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139705,141516,The time of the apps' logs don't follow the system timezone,26.0.0-BETA.1,N/A,Done,10/Feb/26 8:33 AM,02/Mar/26 11:44 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139711,141719,Allow configuring idmap/capabilities on container create/update in UI,26.0.0-BETA.1,,Done,10/Feb/26 1:48 PM,02/Mar/26 6:28 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-139736,142203,Remove trailing slash from TNC PUT /hostnames endpoint,26.0.0-BETA.1,,Done,11/Feb/26 11:35 AM,23/Feb/26 9:13 AM,Low,Complete,26.0.0-BETA.1,SCALE-25.10.2 (Goldeye),,IX_EMPLOYEE,tnc_hotfix,,, +Bug,NAS-139741,142336,Update TrueNAS snmp MIB,26.0.0-BETA.1,,Done,11/Feb/26 3:07 PM,12/Feb/26 11:58 AM,Undefined,Complete,SCALE-25.10.2 (Goldeye),,,IX_EMPLOYEE,,,, +Improvement,NAS-139752,142605,Expose product type in alert.list_categories for each class/id,26.0.0-BETA.1,,Done,12/Feb/26 11:14 AM,18/Feb/26 10:04 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-139762,142651,update engineering Jenkins to point to new private build repo,26.0.0-BETA.1,,Done,12/Feb/26 12:47 PM,02/Mar/26 11:54 AM,Blocker,Complete,,,,IX_EMPLOYEE,mt_reviewed,,, +Bug,NAS-139773,142671,Issues at Rsync setup for Hetzner: remote path validation and 'Host key not found',26.0.0-BETA.1,,Done,12/Feb/26 4:03 PM,18/Feb/26 6:33 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139786,142885,Increase margin/safe space at canvas edges for lower webUI elements to improve mobile/tablet operation,26.0.0-BETA.1,,Done,13/Feb/26 11:08 AM,02/Mar/26 6:28 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,IX_EMPLOYEE,ready_for_assignment,,, +Bug,NAS-139794,142929,"TrueNAS Nightly Update Fails with ""pool or dataset is busy"" and Sets Broken Boot Environment as Default",26.0.0-BETA.1,,Done,13/Feb/26 2:45 PM,02/Mar/26 3:06 PM,Blocker,Complete,,,,COMMUNITY_USER,mt_reviewed,,, +Bug,NAS-139803,142975,Unable to set secondary interface aliases in 26.04 MASTER (KeyError: 'alias_interface_id'),26.0.0-BETA.1,,Done,13/Feb/26 10:38 PM,14/Feb/26 11:20 AM,Medium,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,,,, +Bug,NAS-139807,143047,Locking replicated encrypted dataset on read-only dataset shows error but works. Likely UI bug.,26.0.0-BETA.1,,Done,14/Feb/26 7:04 PM,02/Mar/26 11:32 AM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139812,143155,Cannot create/modify VMs or containers in TrueNAS 26 nightly,26.0.0-BETA.1,,Done,15/Feb/26 7:12 PM,02/Mar/26 11:48 AM,Blocker,Complete,,,,COMMUNITY_USER,mt_reviewed,ready_for_assignment,, +Improvement,NAS-139817,143260,L2ARC: Extended headroom for metadata persistent markers,26.0.0-BETA.1,26.0.0-BETA.2,Done,16/Feb/26 8:22 AM,10/Mar/26 3:49 PM,Low,Complete,,,,IX_EMPLOYEE,,,, +Defect,NAS-139820,143266,Fix available space accounting for special/dedup,26.0.0-BETA.1,,Done,16/Feb/26 9:50 AM,02/Mar/26 2:55 PM,High,Complete,SCALE-25.10.2 (Goldeye),,,IX_EMPLOYEE,mt_reviewed,,, +Bug,NAS-139834,143412,App update notifications,26.0.0-BETA.1,,Done,17/Feb/26 12:17 AM,08/Mar/26 6:30 PM,Low,Complete,SCALE-25.10.1 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Improvement,NAS-139853,143794,Rework session timeout user settings,26.0.0-BETA.1,,Done,18/Feb/26 9:00 AM,19/Feb/26 1:14 PM,Low,Complete,Master - TrueNAS Nightlies,,,doc_impact,IX_EMPLOYEE,tests_impact,, +Improvement,NAS-139856,143829,Use `reconnect_token` from login_ex instead of manually generating token,26.0.0-BETA.1,,Done,18/Feb/26 10:59 AM,24/Feb/26 10:39 AM,Medium,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-139859,143843,Missing Group Configuration for Webshare Service,26.0.0-BETA.1,,Done,18/Feb/26 2:00 PM,23/Feb/26 10:34 AM,Medium,Complete,Master - TrueNAS Nightlies,,,IX_EMPLOYEE,,,, +Improvement,NAS-139862,143846,Rename Main Dashboard Widget Editor to Card Editor,26.0.0-BETA.1,26.0.0-BETA.2,Done,18/Feb/26 2:29 PM,02/Mar/26 6:27 AM,Low,Complete,Master - TrueNAS SCALE,,,IX_EMPLOYEE,ready_for_assignment,,, +Bug,NAS-139866,143851,Sidebar does not show up on screenshot when editing a user and submitting a bug via the webUI.,26.0.0-BETA.1,,Done,18/Feb/26 4:14 PM,02/Mar/26 6:27 AM,Low,Complete,,,,COMMUNITY_USER,ready_for_assignment,,, +New Feature,NAS-139870,143952,[Containers] Select Image Dialog Improvements,26.0.0-BETA.1,,Done,19/Feb/26 5:04 AM,02/Mar/26 6:28 AM,Medium,Complete,SCALE-25.04.2.1 (Fangtooth),,,IX_EMPLOYEE,,,, +Improvement,NAS-139905,144394,Optional product types argument to alert.list_categories,26.0.0-BETA.1,,Done,20/Feb/26 12:13 PM,23/Feb/26 8:54 AM,Low,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139917,144415,Please disable the ipa-epn.timer unit by default,26.0.0-BETA.1,,Done,20/Feb/26 3:33 PM,23/Feb/26 4:16 PM,Low,Complete,SCALE-25.10.2 (Goldeye),,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139925,144558,Opened side panels don't scroll in mobile browsers,26.0.0-BETA.1,,Done,21/Feb/26 1:40 AM,27/Mar/26 2:39 PM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,ready_for_assignment,,, +Bug,NAS-139945,144922,Fix failing build of rwd package,26.0.0-BETA.1,,Done,23/Feb/26 10:53 AM,23/Feb/26 11:19 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Bug,NAS-139965,145271,Plex app with networks won't submit,26.0.0-BETA.1,N/A,Done,24/Feb/26 11:39 AM,02/Mar/26 6:27 AM,Undefined,Complete,,,,IX_EMPLOYEE,,,, +Improvement,NAS-139990,145696,Make File Ticket and Save Debug items look like buttons on UI,26.0.0-BETA.1,,Done,25/Feb/26 3:27 PM,02/Apr/26 8:24 AM,Low,Complete,26.0.0-BETA.1,,,IX_EMPLOYEE,ready_for_assignment,,, +Bug,NAS-139998,145901,Dialogs does not do well with hints and show them below the modal popup,26.0.0-BETA.1,26.0.0-BETA.2,Done,26/Feb/26 9:12 AM,24/Mar/26 5:52 AM,Low,Complete,,,,IX_EMPLOYEE,ready_for_assignment,,, +Improvement,NAS-140055,146772,change rwd to listen on 127.0.0.1 and a static port,26.0.0-BETA.1,,Done,02/Mar/26 12:29 PM,05/Mar/26 12:59 PM,High,Complete,,,,IX_EMPLOYEE,mt_reviewed,,, +Bug,NAS-140259,149435,SNMP alerts broken,26.0.0-BETA.1,26.0.0-BETA.2,Done,12/Mar/26 10:06 AM,13/Mar/26 7:43 AM,Undefined,Complete,26.0.0-BETA.1,,,IX_EMPLOYEE,,,, +Bug,NAS-140344,150563,pool.get_disks returning virtual DRAID devices,26.0.0-BETA.1,26.0.0-BETA.2,Done,18/Mar/26 9:43 AM,18/Mar/26 3:20 PM,Blocker,Complete,,,,,,,, +Defect,NAS-140345,150564,Fix some log spam on first boot - PAM stack failed to allocate session UUID.,26.0.0-BETA.1,26.0.0-BETA.2,Done,18/Mar/26 9:48 AM,18/Mar/26 12:20 PM,High,Complete,26.0.0-BETA.1,,,,,,, +Bug,NAS-140389,151219,Failed to load datasets,26.0.0-BETA.1,26.0.0-BETA.2,Done,20/Mar/26 3:39 PM,01/Apr/26 9:12 AM,Low,Complete,Master - TrueNAS Nightlies,,,COMMUNITY_USER,mt_reviewed,ready_for_assignment,, diff --git a/testdata/import/csv/canonical/jira/sample-06.csv b/testdata/import/csv/canonical/jira/sample-06.csv new file mode 100644 index 00000000..3382cabe --- /dev/null +++ b/testdata/import/csv/canonical/jira/sample-06.csv @@ -0,0 +1,10 @@ +Issue key,Issue id,Issue Type,Summary,Status,Created,Updated,Component/s,Component/s,Component/s,Assignee +SA-101518,432116,Bug,INTERNAL: MEB 22-5490 - Contact Preference Page UI Fix,To Do,Oct/22/24 5:33 PM,Oct/23/24 9:53 AM,MyEducationBenefits,N33Automation,,Person 22 +SA-101502,432100,Bug,INTERNAL: MEB 22-5490 - Review Page UI Fix,To Do,Oct/22/24 5:23 PM,Oct/23/24 11:27 AM,Agile Capacity,MyEducationBenefits,N33Automation,Person 22 +SA-101486,432083,Bug,INTERNAL: MEB 22-5490 - Direct Deposit Styling,To Do,Oct/22/24 5:17 PM,Oct/23/24 11:04 AM,Agile Capacity,MyEducationBenefits,N33Automation,Person 23 +SA-101398,430807,Bug,INTERNAL - MEB- 5490- Bring code coverage to 80%,Dev In Progress,Oct/21/24 11:17 AM,Oct/23/24 10:49 AM,MyEducationBenefits,N33Automation,,Person 23 +SA-101279,429995,Bug,INTERNAL: MEB 22-5490 - forms_submit_claim called failed,Local Dev Complete,Oct/18/24 10:56 AM,Oct/22/24 5:18 PM,Agile Capacity,MyEducationBenefits,N33Automation,Person 22 +SA-101180,429482,Bug,Error when attempting to create original ch35 through SQS,DEPLOYED TO AFS / IVV,Oct/17/24 1:07 PM,Oct/22/24 9:55 AM,MyEducationBenefits,N33Automation,,Person 24 +SA-101145,429298,Bug,"INTERNAL - UAT - update breadcrumbs, progress bar, and links",DEPLOYED TO AFS / IVV,Oct/17/24 10:10 AM,Oct/23/24 10:48 AM,Agile Capacity,MyEducationBenefits,N33Automation,Person 22 +SA-100625,425250,Bug,"INTERNAL - MEB - Original Claim - Benefits Selection Page -"" Learn more about the Post-9/11 GI Bill "" verbiage is linked incorrectly",DONE,Oct/09/24 9:52 AM,Oct/21/24 5:55 PM,N33Automation,,,Person 25 +SA-100552,424943,Bug,INTERNAL - Automation processing not happening for CH30/1606 on AFS003 when we submit a MEB application.,VALIDATED IN AFS / IVV,Oct/08/24 3:10 PM,Oct/23/24 11:22 AM,N33Automation,,,Person 22 diff --git a/testdata/import/csv/canonical/linear/sample-01.csv b/testdata/import/csv/canonical/linear/sample-01.csv new file mode 100644 index 00000000..52e1ef8f --- /dev/null +++ b/testdata/import/csv/canonical/linear/sample-01.csv @@ -0,0 +1,37 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Triaged,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,UUID,Time in status (minutes),Related to,Blocked by,Duplicate of +INF-5,Inferflow,Define OpenAI-compatible router architecture,"Designed the high-level system: Go control-plane router accepting OpenAI-compatible `/v1/chat/completions`, routing to multiple vLLM workers via pluggable strategies. Architecture: Client → Router :8080 ↔ Redis :6379 → vLLM workers :9000/:8000.",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Sun Apr 05 2026 04:00:00 GMT+0000 (GMT+00:00),,,96ab36fc-ca28-4240-87b9-da9f0ac84c5a,Phase 1: System Design & Architecture,,c3a017f2-924e-4d98-ae17-455bb7a47f07,546,,, +INF-6,Inferflow,Choose tech stack: Go router + Redis + vLLM,"Selected Go for the control-plane router (performance, concurrency), Redis for KV-prefix cache (kv_aware strategy), vLLM as the inference backend, and Python for load generation scripts.",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Sun Apr 05 2026 04:00:00 GMT+0000 (GMT+00:00),,,96ab36fc-ca28-4240-87b9-da9f0ac84c5a,Phase 1: System Design & Architecture,,8fdcc755-9b6b-4f9a-9f11-c69391813aa3,546,,, +INF-7,Inferflow,Implement Go HTTP router with /v1/chat/completions,Built the core Go HTTP server with OpenAI-compatible request/response shapes. Router proxies requests to backend workers and returns structured chat completion responses.,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Wed Apr 08 2026 04:00:00 GMT+0000 (GMT+00:00),,,f1cb35df-a87a-402c-9481-5a270ae750fc,Phase 2: Core Router,,89bf3dc3-fbca-40b5-8a00-5e5f244331f8,546,,, +INF-8,Inferflow,Add mock backend for local development,Implemented a mock backend server that simulates vLLM responses locally. Allows full router testing without GPU or cloud infrastructure.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Wed Apr 08 2026 04:00:00 GMT+0000 (GMT+00:00),,,f1cb35df-a87a-402c-9481-5a270ae750fc,Phase 2: Core Router,,dfc72c2b-54d0-417a-8ed0-9c43d9ff0f63,546,,, +INF-9,Inferflow,"Add /healthz, /readyz, /metrics endpoints","Added liveness (/healthz), readiness (/readyz — passes only when ≥1 backend healthy), and Prometheus-format metrics (/metrics) tracking in-flight requests, totals, errors, and per-backend/strategy counts.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Wed Apr 08 2026 04:00:00 GMT+0000 (GMT+00:00),,,f1cb35df-a87a-402c-9481-5a270ae750fc,Phase 2: Core Router,,cb0647cf-add3-4a5a-8e42-dc18ff0bfa94,546,,, +INF-10,Inferflow,Implement round_robin strategy,Baseline routing strategy — distributes requests evenly across healthy backends in a circular order.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,cd2543e4-7af3-4b6b-8b5d-24d1b7154fbe,546,,, +INF-11,Inferflow,Implement least_pending strategy,"Routes each request to the backend with the fewest in-flight requests. Uses atomic counters per backend, incremented on dispatch and decremented on completion.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,031d4f62-33a2-43ee-9fef-1496865ea160,546,,, +INF-12,Inferflow,Implement random strategy,Selects a healthy backend uniformly at random. Used as a control baseline for load test comparisons against smarter strategies.,Done,,Medium,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,ccd3ae8e-bb47-4461-891f-694b567539fe,546,,, +INF-13,Inferflow,Implement kv_aware strategy with Redis prefix cache,Routes requests to the backend that previously handled the same conversation prefix (cache affinity via Redis). Falls back to least_pending when no affinity exists. KV cache lives in RAM on CPU workers.,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,1aa6f8f5-29d3-4c98-9b89-b1805935898b,546,,, +INF-14,Inferflow,Add runtime strategy switching (GET/PUT /strategy),"Added GET /strategy (read active strategy) and PUT /strategy (switch strategy at runtime without restart). Enables live demos switching between round_robin, least_pending, random, and kv_aware.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,168ba200-c570-4252-80ba-2678ca9a161c,546,,, +INF-15,Inferflow,Write integration tests for all strategies,"Integration tests covering the full chat completion path for each strategy, plus strategy-switch endpoint. Tests spin up a mock backend in-process.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Sun Apr 12 2026 04:00:00 GMT+0000 (GMT+00:00),,,43bb7ca3-a8e5-4278-be76-1dce693a7b1f,Phase 3: Routing Strategies,,8198fd89-850a-470c-8fa0-2ddff42fd7a0,546,,, +INF-16,Inferflow,[Attempt] DigitalOcean DOKS — abandoned early,Initial cloud target was DigitalOcean Kubernetes (DOKS). Abandoned early in favour of GCP for better GPU availability and Terraform ecosystem support.,Canceled,,Medium,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Tue Apr 14 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,7512e0f1-95d2-4633-9cdc-2c30211b55fe,546,,, +INF-17,Inferflow,[Attempt] GCP/GKE — blocked by GPU quota (GPUS_ALL_REGIONS=0),Attempted GCP/GKE with T4 GPU nodes. Blocked by quota: GPUS_ALL_REGIONS=0. Quota increase request was not feasible in the project timeline. Pivoted to AWS EKS with CPU-only vLLM.,Canceled,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Wed Apr 15 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,a95d3032-cedb-4a1b-8971-2b35d3dd0147,546,,, +INF-18,Inferflow,Pivot to AWS EKS with CPU-only vLLM (Qwen2.5-0.5B),Moved to AWS EKS running vLLM in CPU mode on c5.xlarge instances with Qwen2.5-0.5B. kv_aware strategy still works — KV cache lives in RAM instead of VRAM. Cluster: 1x t3.medium (system) + 3x c5.xlarge (workers).,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Fri Apr 17 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,42e41a33-9bec-4cba-a2b8-a3854845a351,546,,, +INF-19,Inferflow,Write Terraform modules for EKS cluster,"Terraform environment at terraform/environments/aws/ — provisions VPC, EKS cluster, node groups (t3.medium system + c5.xlarge workers), ECR repos. Init completed successfully.",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Fri Apr 17 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,507f9fa9-c213-4a29-b65a-99ec67dccc0c,546,,, +INF-20,Inferflow,Fix k8s/vllm-worker.yaml for CPU mode (3 replicas),"Updated vllm-worker.yaml: replicas=3, switched to vllm/vllm-cpu:latest image, set correct node selector for c5.xlarge, removed GPU resource limits.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,Fri Apr 17 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,f9aa33b3-3ed3-443e-a40e-44ff1b6f4bd4,546,,, +INF-21,Inferflow,Fix k8s/router.yaml — add worker-2 backend,Router manifest was missing worker-2 in the INFERFLOW_BACKENDS env var. Added worker-2 so all three vLLM workers are reachable by the router.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Fri Apr 17 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,feaff8ce-8cf1-4270-aa16-3c7c1c60b774,546,,, +INF-22,Inferflow,Unblock Terraform state lock,terraform apply blocked by a stale state lock from a prior process. Needs terraform force-unlock after confirming no other apply is running. AWS SSO session also expired — both must be resolved before apply can proceed.,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,Sat Apr 18 2026 04:00:00 GMT+0000 (GMT+00:00),,,742f69f2-a483-4e19-9ed6-ea0bcff78172,Phase 4: Cloud Infrastructure & Pivots,,51382b04-499d-4335-be89-562c637d5a37,508,,, +INF-23,Inferflow,Refresh AWS SSO credentials,AWS SSO session tokens expired. Run `aws sso login` to refresh before any terraform or kubectl commands can proceed.,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,394da456-4806-44ed-94d3-2006e50115b5,508,,, +INF-24,Inferflow,Run terraform apply — provision EKS cluster,Provision the EKS cluster: 1x t3.medium system node + 3x c5.xlarge worker nodes. Run from terraform/environments/aws/.,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,4f0b105c-50a3-459d-bfc1-e722fb139cbc,508,,, +INF-25,Inferflow,Build & push router Docker image to ECR,Build the Go router Docker image and push to ECR. Update k8s/router.yaml image reference to the ECR URI.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,7a1ef5b8-7356-4039-89d4-a07b46cc4e2f,289,,, +INF-26,Inferflow,Build & push vllm-adapter Docker image to ECR,Built vllm-adapter sidecar (Dockerfile.vllm-adapter) and llama.cpp inference server (Dockerfile.llama-server) and pushed both to ECR. Switched from vllm/vllm-cpu (non-existent image) to a custom llama.cpp b8838 image. Updated k8s/vllm-worker.yaml image references to ECR URIs.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,95fc9c70-e0df-4893-af1e-151284bef536,288,,, +INF-27,Inferflow,Deploy k8s manifests to EKS,"Deployed all k8s manifests to EKS: redis, vllm-worker (3x llama.cpp StatefulSet), router, and ALB ingress. All pods running. Public endpoint provisioned via AWS Load Balancer Controller at [k8s-default-inferflo-b7faaa4fda-434516550.us-east-1.elb.amazonaws.com]().",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,f5a8a95f-cd9e-44b5-bb04-8ed5b37ae559,288,,, +INF-28,Inferflow,"Verify cluster health (pods, services, endpoints)","All pods verified healthy: vllm-worker-0/1/2 at 2/2 Running (llama.cpp + adapter), inferflow-router 1/1, redis 1/1. End-to-end inference confirmed via ALB — request routed through router to llama.cpp worker and returned a valid response.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,330e29f4-a35b-403d-863a-02aa5e3e6fcf,Phase 5: EKS Deployment,,5afee702-8e36-4e9c-b992-84094aac3544,288,,, +INF-29,Inferflow,Add /api/status JSON endpoint to router,"Add GET /api/status returning JSON with active strategy, per-backend health status, and pending request counts. Needed by the Chainlit UI metrics panel.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,9697bff3-20e1-4599-a20d-d1f6bcec0d81,536,,, +INF-30,Inferflow,Add KV cache hit/miss counters to metrics,Track cache hits vs misses in metrics.State for the kv_aware strategy. Expose in /metrics and /api/status so the UI can show hit rate.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,68e2d306-2a51-404e-9f84-2f62f5797403,536,,, +INF-31,Inferflow,Add CORS middleware to router,Add CORS headers to all router responses so the Chainlit UI and any browser-based clients can call the router directly.,Done,,Medium,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,57983cb4-25bd-4cae-860b-c0575ca6ea62,536,,, +INF-32,Inferflow,Add latency tracking per backend,Track rolling average latency per backend in metrics.State. Expose in /api/status. Used in UI to show which worker is slowest and to compare strategy performance in load tests.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,aa72a6ea-9441-4485-9cee-a470ec101568,536,,, +INF-33,Inferflow,Scaffold Streamlit UI — chat box + strategy switcher,Streamlit UI implemented in ui/app.py. Chat messages routed via POST /v1/chat/completions to live EKS ALB. Strategy switcher (radio buttons) calls PUT /strategy. Each response annotated with X-Inferflow-Backend and X-Inferflow-Strategy response headers. Running locally at [http://localhost:8501]().,Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,019cb3ea-b45c-4e17-b910-893d6e0e3950,6,,, +INF-34,Inferflow,Add live metrics panel to Streamlit UI,"Live metrics panel implemented in Streamlit UI. Polls /api/status every 2s showing: active strategy, per-backend health and pending count, KV cache hit rate, total requests, and in-flight count. Uses st.rerun() for auto-refresh.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,bee564ab-7d4a-4df5-9a6c-aa569ae89b6b,6,,, +INF-35,Inferflow,Add routing decision log to Streamlit UI,"Routing decision log implemented in Streamlit UI. Shows live feed of last N routing decisions: which backend handled the request, via which strategy, and whether it was a KV cache hit. Read from X-Inferflow-Backend and X-Inferflow-Strategy response headers.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,f13d66db-f6ad-4d89-9771-ee31cbc561f0,6,,, +INF-36,Inferflow,Run load tests across all 4 strategies,"Load tests completed against live EKS cluster (3x c5.xlarge, llama.cpp + Qwen2.5-0.5B-Instruct). Two runs: 10 requests/strategy (initial) and 100 requests/strategy at concurrency 3 with repeat-factor 3. All 400 requests succeeded. Results in results/loadtest_100.csv. Key finding: least_pending best tail latency (p95 4860ms, max 4995ms); random worst (p95 7789ms, max 9046ms).",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user9@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,d283ded9-ceba-43b6-8ff4-1f4175bbc176,6,,, +INF-37,Inferflow,Analyze results — KV cache hit rate & latency comparison,"Analysis complete. 5 charts generated (latency_bars, latency_cdf, kv_hit_rate, backend_distribution, latency_over_time) via analysis/charts.py. KV cache benefit confirmed: kv_aware 22% faster on repeated 200-token prompts vs round_robin (4708ms vs 6042ms avg). kv_aware creates hotspot at concurrency (backend-2 got 46% traffic). Full experiments report written at docs/experiments.html. README updated with results tables and embedded charts.",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,user10@example.com,,,,,,,,,,,495d83e1-f7c2-4349-aeca-739c4cad48ca,Phase 6: UI & Load Testing,,ab5eee78-3d42-4d43-adf5-669eeae60174,6,,, +INF-38,Inferflow,Add X-Inferflow-Cache-Hit response header,"Added X-Inferflow-Cache-Hit: true/false response header to router for kv_aware strategy. Value is set from decision.CacheHit which reflects the actual Redis lookup result — true if Redis had a stored backend for this prompt hash, false on miss. Allows load generator and clients to observe real cache hit rate without guessing.",Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,,,,,,,,,,,,,,,a43e8458-83a5-4c59-980d-8bbe350e40ff,6,,, +INF-39,Inferflow,Fix load generator — real cache hit tracking and concurrency control,Fixed loadgen/generator.py: reads X-Inferflow-Cache-Hit header instead of guessing from repeat-factor. Added --concurrency flag (semaphore-based) to prevent backend overload. Increased request timeout from 10s to 60s. Updated REPEATED_PROMPT to 200-token benchmark prompt for meaningful KV cache testing.,Done,,High,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,,,,,,,,,,,,,,,8a2588ca-90af-4ca8-9dc1-abad3612b712,6,,, +INF-40,Inferflow,Write experiments report (docs/experiments.html),"Wrote 5-page experiments report at docs/experiments.html covering: (1) Strategy latency comparison — 100 requests each, 4 strategies, with charts and analysis. (2) KV cache affinity benefit — repeated 200-token prompt shows kv_aware 22% faster than round_robin. Includes purpose/tradeoffs/limitations per experiment. Print to PDF via browser for submission.",Done,,Urgent,a9c741c8-8e79-40e0-b73e-ae7436dfdfa5,InferFlow - System Design to Deployment,user9@example.com,,,,,,,,,,,,,,,418b3e79-c383-4c7b-8627-531439f4b93d,6,,, diff --git a/testdata/import/csv/canonical/linear/sample-02.csv b/testdata/import/csv/canonical/linear/sample-02.csv new file mode 100644 index 00000000..b3d602ff --- /dev/null +++ b/testdata/import/csv/canonical/linear/sample-02.csv @@ -0,0 +1,236 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,Roadmaps +IN-10,Nordic-app,License,"Add licence to the nordic app due to the public repo on example.com + +[https://example.com/repo](https://example.com/repo)",Done,,High,,,user11@example.com,user11@example.com,Improvement,,,,,Tue May 14 2024 08:53:33 GMT+0000 (GMT),Thu May 23 2024 16:06:19 GMT+0000 (GMT),,,Thu May 23 2024 16:06:19 GMT+0000 (GMT),,,,,,,, +IN-11,Nordic-app,Eslint,"1) ✅ configure eslint + +2) ✅ configure lint-staged + +4) ✅ add ""type"": ""module"" to package.json + +6) ✅ update eslint packages + +7) ✅ update prettier + +8) ✅ configure .eslintignore [https://example.com/repo](https://example.com/repo) + +9) ✅ change .eslintrc and .lintstagedrc.json into eslint.config.js and lintstagedrc.config.js + +10) ✅ fix eslint in vscode + +11) ✅ fix prettier in vscode + +12) ✅ remove ""cypress"" from exclude and fix the issue + +13) ✅ fix eslint errors (npm run lint) + +14) ✅ fix ts errors (tsc) + +15) ✅ fix line ""// '\\.s?css$': 'identity-obj-proxy',"" in jest.config.ts + +16) ✅ test styles with ""vanilla extract"" package + +17) ✅ vscode not see the tsconfig (""typescript.tsdk"": ""node_modules/typescript/lib"" - in settings.json) + +96) ✅ do the [IN-13](https://linear.app/nordic-app/issue/IN-13/npm-run-init) task + +97) ✅ do the [IN-14](https://linear.app/nordic-app/issue/IN-14/configure-postcss) task + +98)✅ do the [IN-10](https://linear.app/nordic-app/issue/IN-10/license) task + +99) ✅ and after that push to github repo the nordic app + +[https://example.com/repo](https://example.com/repo)",Done,,High,,,user11@example.com,user11@example.com,Build,,,,,Tue May 14 2024 08:55:10 GMT+0000 (GMT),Thu May 23 2024 16:41:11 GMT+0000 (GMT),Tue May 14 2024 17:10:40 GMT+0000 (GMT),,Thu May 23 2024 16:41:11 GMT+0000 (GMT),,,,,,,, +IN-13,Nordic-app,npm run init,Check the command for bug-free,Done,,High,,,user11@example.com,user11@example.com,,,,,,Tue May 14 2024 16:55:13 GMT+0000 (GMT),Thu May 23 2024 15:09:28 GMT+0000 (GMT),Thu May 23 2024 15:09:18 GMT+0000 (GMT),,Thu May 23 2024 15:09:28 GMT+0000 (GMT),,,,,,,, +IN-14,Nordic-app,configure postcss,Need to configure the postcss.config.js file,Done,,High,,,user11@example.com,user11@example.com,,,,,,Tue May 14 2024 17:10:26 GMT+0000 (GMT),Thu May 23 2024 15:12:12 GMT+0000 (GMT),,,Thu May 23 2024 15:12:12 GMT+0000 (GMT),,,,,,,, +IN-15,Nordic-app,Readme,"Need to fill the README.md file + +99) add thanks for the design templates to the company which share it free + link",Todo,,High,,,user11@example.com,user11@example.com,,,,,,Tue May 14 2024 17:16:29 GMT+0000 (GMT),Mon Jun 17 2024 09:01:39 GMT+0000 (GMT),,,,,,,,,,, +IN-16,Nordic-app,Migrate to prettier flat config,.prettierrc > prettier.config.js,Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 10:02:19 GMT+0000 (GMT),Thu May 23 2024 10:02:19 GMT+0000 (GMT),,,,,,,,,,, +IN-17,Nordic-app,Ideas how to organize test file structure,"// ""tests/config/\*\*/\*.js"", + +// ""tests/format/\*\*/format.test.js"", + +// ""tests/integration/\*\*/\*.js"", + +// ""tests/unit/\*\*/\*.js"", + +// ""tests/dts/unit/\*\*/\*.js"", + +// ""scripts/release/**tests**/\*\*/\*.spec.js"",",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 10:15:06 GMT+0000 (GMT),Thu May 23 2024 10:15:06 GMT+0000 (GMT),,,,,,,,,,, +IN-18,Nordic-app,NORDIC TODO (Backlog list),"\## Roadmap (I need a production-ready nextjs project to use in the future) + +--- + +1. (done) create todo list +2. (done) add pages +3. (done) add i18n +4. add A/B testing +5. nextAuth or lucia auth or something else +6. add icons +7. use feature-sliced design +8. (done) configure rtl + jest + cypress +9. write unit + integration + e2e tests +10. user-friendly UI (loaders+skeleton, useTransition, useDefferedValue, theme switcher, language switcher, toast component) +11. optimization (lazy + suspense) +12. eslint rule for react dependencies (eslint-plugin-react-hooks) +13. web vitals +14.css variables for styling and theming + +## Tech stack + +--- + +0. REDUX OR ZUSTAND OR XSTATE OR ANY ElSE [https://www.youtube.com/watch?v=JaM2rExmmqs](https://www.youtube.com/watch?v=JaM2rExmmqs) +1. redux toolkit + redux code splitting [https://redux.js.org/usage/code-splitting#using-a-reducer-manager](https://redux.js.org/usage/code-splitting#using-a-reducer-manager) +2. redux saga +3. ?redux persist (indexedDB) - check maybe this boilerplate is ready on github vercel +4. ??? \\\_\\\_\\\_ rxjs +5. reselect + rereselect +6. ??? \\\_\\\_\\\_ axios +7. (done) react hook form + zod +8. (done) i18n +9. (done) typescript +10. react query + fetch + AbortController +11. (done) jest/vitest + react testing library + playwright/cypress +12. ui components with snapshot tests OR radix ui (+ customize CSS styles (with hover,active,focus states) for one of radix component to work out how to use ready-made component, but with custom styles) OR shadcn OR nextui +13. ??? \\\_\\\_\\\_ styled components OR emotion OR vanilla-extract (zero-runtime css-in-js) or stylexjs by facebook - [https://vanilla-extract.style/](https://vanilla-extract.style/) +14. (done) prettier +15. prettier replace with biome ([https://biomejs.dev/](https://biomejs.dev/)) +16. (done) eslint +17. husky +18. docker - [https://mentorcruise.com/blog/how-to-dockerize-a-react-app-and-deploy-it-easily/](https://mentorcruise.com/blog/how-to-dockerize-a-react-app-and-deploy-it-easily/) +19. makefile +20. (done) nextjs +21. github actions - [https://example.com/repo](https://example.com/repo) +22. chrome dev tools +23. immer +24. [https://realworld-docs.netlify.app/docs/specs/frontend-specs/api](https://realworld-docs.netlify.app/docs/specs/frontend-specs/api) +25. sentry +26. websockets +27. browserlist +28. - turn on ""isolatedModules"" for encapsulating the TYPES for components like (export type { ArticleTypes } from './Article.types') +29. PWA - [https://www.youtube.com/playlist?list=PLNYkxOF6rcIB2xHBZ7opgc2Mv009X87Hh](https://www.youtube.com/playlist?list=PLNYkxOF6rcIB2xHBZ7opgc2Mv009X87Hh) +30. indexedDB +31. monorepo +32. graphql + apollo/relay +33. json server or mswjs for mock data +34.vite or tsup for ui components library + + +# Wix boilerplate + +1. styling +2. tdd (jest + rtl + cypress) +3. icons? and images +4. file structure (FSD) + +# Resources: + +--- + +0. [https://photos.google.com/search/\_m8_Favorites/photo/AF1QipOMA25IfcphLcrFQUkouLYabhG2EyPwzuNBptpL](https://photos.google.com/search/\_m8_Favorites/photo/AF1QipOMA25IfcphLcrFQUkouLYabhG2EyPwzuNBptpL?authuser=0) +1. [https://example.com/repo](https://example.com/repo) +2. [https://demos.themeselection.com/sneat-bootstrap-html-admin-template/html/vertical-menu-template/dashboards-analytics.html](https://demos.themeselection.com/sneat-bootstrap-html-admin-template/html/vertical-menu-template/dashboards-analytics.html)",Backlog,,Low,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 12:01:56 GMT+0000 (GMT),Wed Jul 10 2024 16:19:31 GMT+0000 (GMT),,,,,,,,,,, +IN-19,Nordic-app,Configure styles,"1) add mixin for media queries +2) globalStyle.css.ts and global.css refactor and merge",Todo,,High,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 13:47:32 GMT+0000 (GMT),Sun Jun 16 2024 21:06:58 GMT+0000 (GMT),,,,,,,,,,, +IN-20,Nordic-app,Fix deprecation package for eslint flat config,eslint-plugin-deprecation - [https://www.npmjs.com/package/eslint-plugin-deprecation](https://www.npmjs.com/package/eslint-plugin-deprecation),Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 14:54:46 GMT+0000 (GMT),Thu May 23 2024 14:54:46 GMT+0000 (GMT),,,,,,,,,,, +IN-21,Nordic-app,Configure theming,"1) add cookie for choosen theme +2) move to the env variable or to the middlewar variable with DEFAULT_THEME=""dark"" and re-use it everywhere (layout.tsx, middleware, Theme component etc) +3) css tokens",Todo,,Urgent,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 16:08:02 GMT+0000 (GMT),Tue Jul 02 2024 12:01:23 GMT+0000 (GMT),,,,,,,,,,, +IN-22,Nordic-app,Improve i18n,"1) ✅ move [getDictionary.ts](https://example.com/repo) file to helpers or something else +2) ✅ check how to split json files OR multiple translation files per locale nextjs OR namespaces - [https://example.com/repo](https://example.com/repo) +3) ✅ how to do interpolation in nextjs i18n, like t('validation', {value: 100});????? +4) ✅ maybe better to choose 3d-party library for i18n instead of relying nextjs i18n (withTranslation hoc, useTranslation hook) +5) ✅ create fabric/decorator/orHowItCanBeCalled for i18n to encapsulate the i18n api and simplify future refactoring/migrations to another libraries +6) ✅ remove old libraries footprients from project which used to implement i18n +7) ✅ update middleware and remove redundant packages from project middleware +9) ✅ remove comments from client.ts and server.ts +10) ✅ try to fix ""generateStaticParams"" in layout.tsx +11) add redirect to default lang if user type unexisted locale in URL +12) ✅ cookie settings/getting by user selecting lang on website +13) cookie for theme switcher fix +14) ✅ commit all and then migrate into next-intl - [https://next-intl-docs.vercel.app/docs/getting-started](https://next-intl-docs.vercel.app/docs/getting-started) +15) ✅ client hooks (useFormatter, useLocale) put to production build, but server helpers (getLocale etc) not put to production build +16) ✅ finalize the matcher to have a right redirects, e.g. localhost:3000/fr/login > localhost:3000/login +17) ✅ fix error on login page, look the screen in telegram +18) ✅ fix 'localhost:3000/' because we get 404 instead of home page with eng lang +18) close the i18n task after [IN-31](https://linear.app/nordic-app/issue/IN-31/add-errortsx-and-global-errortsx-pages)",Done,,High,,,user11@example.com,user11@example.com,,,,,,Thu May 23 2024 16:40:39 GMT+0000 (GMT),Sat Jun 22 2024 17:01:01 GMT+0000 (GMT),Mon May 27 2024 18:46:19 GMT+0000 (GMT),,Sat Jun 22 2024 17:01:01 GMT+0000 (GMT),,,,,,,, +IN-23,Nordic-app,VS code autocomplete for test writing,"1) create jsconfig.json and add jest configuration for autocomplete + +{ + + ""typeAcquisition"": { + + ""include"": \[""jest""\] + + } + +}",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Sat May 25 2024 12:31:50 GMT+0000 (GMT),Sat May 25 2024 12:31:50 GMT+0000 (GMT),,,,,,,,,,, +IN-24,Nordic-app,Write TDD tests,"1) from ""red"" >""green"" > ""refactor"", means ""write a failing test first red then you write some code to get it passing green then go back and optimize or refactor the code""",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Sat May 25 2024 12:41:58 GMT+0000 (GMT),Sat May 25 2024 12:41:58 GMT+0000 (GMT),,,,,,,,,,, +IN-25,Nordic-app,Configure service worker (SW) and webworker,"1) sw - [https://example.com/repo](https://example.com/repo) +2) ww - [https://example.com/repo](https://example.com/repo)",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Sat May 25 2024 21:30:09 GMT+0000 (GMT),Sat May 25 2024 21:30:09 GMT+0000 (GMT),,,,,,,,,,, +IN-26,Nordic-app,LocaleSwitcher component,"1) TDD +2) semantic html (select fron radix-ui) +3) styles +4) optimization",Todo,,Medium,,,user11@example.com,user11@example.com,,,,,,Mon May 27 2024 18:43:24 GMT+0000 (GMT),Mon May 27 2024 18:43:24 GMT+0000 (GMT),,,,,,,,,,, +IN-27,Nordic-app,ErrorBoundary component,"1) ErrorBoundary component add +2) error.tsx component add to app directory",Done,,Medium,,,user11@example.com,user11@example.com,,,,,,Tue May 28 2024 10:48:29 GMT+0000 (GMT),Sat Jun 15 2024 18:51:01 GMT+0000 (GMT),,,Sat Jun 15 2024 18:51:01 GMT+0000 (GMT),,,,,,,, +IN-28,Nordic-app,Code splitting,"Make a component-based code splitting (apply it to search dropdown, etc)",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Tue May 28 2024 12:35:15 GMT+0000 (GMT),Tue May 28 2024 12:35:15 GMT+0000 (GMT),,,,,,,,,,, +IN-29,Nordic-app,add fallback loader to LocaleProvider component,"1) create loader or skeleton +2) test for SSR SEO impact loading.tsx file and customize the design if everything is fine",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Tue May 28 2024 18:04:48 GMT+0000 (GMT),Sat Jun 15 2024 19:46:26 GMT+0000 (GMT),,,,,,,,,,, +IN-30,Nordic-app,Create file structure for routes,"1) add all pages which planned to create (profile, dashboard etc) +2) in file config.ts add all created routes to generate the sitemap.xml",Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Wed May 29 2024 17:32:05 GMT+0000 (GMT),Wed May 29 2024 17:32:05 GMT+0000 (GMT),,,,,,,,,,, +IN-31,Nordic-app,Add error.tsx and global-error.tsx pages,"1) add styles to global-error.tsx +2) add styles to error.ts instead of ErrorBoundary component +3) finish info in metadata (manifest, theme etc) +4) not-found.tsx redesign (maybe convert from 'use-client' to server) and make it the same design as error.tsx and error.ts",Todo,,High,,,user11@example.com,user11@example.com,,,,,,Fri May 31 2024 19:09:03 GMT+0000 (GMT),Fri Jun 21 2024 20:11:49 GMT+0000 (GMT),,,,,,,,,,, +IN-32,Nordic-app,Add test for i18n routing,[https://example.com/repo](https://example.com/repo),Todo,,Low,,,user11@example.com,user11@example.com,,,,,,Sat Jun 01 2024 11:42:21 GMT+0000 (GMT),Sat Jun 01 2024 11:42:21 GMT+0000 (GMT),,,,,,,,,,, +IN-33,Nordic-app,template.ts,[https://nextjs.org/docs/app/api-reference/file-conventions/template](https://nextjs.org/docs/app/api-reference/file-conventions/template),Done,,Low,,,user11@example.com,user11@example.com,,,,,,Wed Jun 12 2024 20:05:12 GMT+0000 (GMT),Sat Jun 15 2024 18:51:48 GMT+0000 (GMT),,,Sat Jun 15 2024 18:51:48 GMT+0000 (GMT),,,,,,,, +IN-34,Nordic-app,Web Vitals,[https://nextjs.org/learn-pages-router/seo/web-performance](https://nextjs.org/learn-pages-router/seo/web-performance),Todo,,Medium,,,user11@example.com,user11@example.com,,,,,,Sat Jun 15 2024 18:52:21 GMT+0000 (GMT),Sat Jun 15 2024 18:52:21 GMT+0000 (GMT),,,,,,,,,,, +IN-35,Nordic-app,Auth,1) read more about 'next-auth' package and search alternatives,Todo,,Medium,,,user11@example.com,user11@example.com,,,,,,Mon Jun 17 2024 21:11:57 GMT+0000 (GMT),Mon Jun 17 2024 21:11:57 GMT+0000 (GMT),,,,,,,,,,, +IN-36,Nordic-app,Add api.ts wrapper,It needs to be created to work with API requests,Todo,,High,,,user11@example.com,user11@example.com,,,,,,Sat Jun 22 2024 19:22:20 GMT+0000 (GMT),Sat Jun 22 2024 19:22:20 GMT+0000 (GMT),,,,,,,,,,, +IN-37,Nordic-app,Monorepo,"Configure NX monorepo + +1) [✅](https://emojipedia.org/check-mark-button) fix error ""Cannot use import statement outside a module"" +2) add ui components library +3) [✅](https://emojipedia.org/check-mark-button) configure showcase project for landings +4) decide what to do with 'e2e' directories in 'apps' and configure e2e tests +5) [✅](https://emojipedia.org/check-mark-button) return next.config.js to next.config.mjs +6) migrate from jest to vitest +7) when run command ""nx storybook ui"" need to fix an error ""The CJS build of Vite's Node API is deprecated. See [https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated](https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated) for more details."" +8) configure husky and linter (lint-stage) +9) pre-push hook edit (npm run test:unit) +10) replace json-server with msw ([https://www.npmjs.com/package/msw](https://www.npmjs.com/package/msw)) or nextjs api routes +11) remove all ""deprecated\_"" files +12) [✅](https://emojipedia.org/check-mark-button) check what dependencies can move into devDepencies +13) [✅](https://emojipedia.org/check-mark-button) fix command ""nx dev dashboard --verbose"" +14) [✅](https://emojipedia.org/check-mark-button) fix error ""install command is deprecated"" in console for husky install +15) [✅](https://emojipedia.org/check-mark-button) '.vscode/\*' dir remove from git index and check all other directories and files to remove irrelevant from git index +16) configure aliases for all projects, not only for dashboard (in tsconfig.json) - ""@/*"": \[""apps/dashboard/src/*""\], +17) fix warning for 'nx lint ui --verbose' - ""Pages directory cannot be found at /Users/andriihrynenko/Information/2_Areas/Code/Sandbox/pet_projects/nordic/libs/ui/pages or /Users/andriihrynenko/Information/2_Areas/Code/Sandbox/pet_projects/nordic/libs/ui/src/pages. If using a custom path, please configure with the `no-html-link-for-pages` rule in your eslint config file."" +18) fix error for command 'nx lint dashboard --versobe' +19) [✅](https://emojipedia.org/check-mark-button) check file and key-values in file 'tsconfig.spec.json' (like ""module"": ""commonjs"") +20) add package.json file to all project with meta data +21) add readme with all commands for monorepo +22) [✅](https://emojipedia.org/check-mark-button) configure vite build for ui library +23) configure project.json files in every project +24) duplicated keys of tsconfig.json (of every project) remove because they are used in tsconfig.base.json +25) convert from tsc to swc using command 'nx g swc mylib' - [https://nx.dev/nx-api/js/generators/convert-to-swc](https://nx.dev/nx-api/js/generators/convert-to-swc) +26) work out how works releases in nx monorepo (**nx release version) -** [https://nx.dev/nx-api/js/generators/release-version](https://nx.dev/nx-api/js/generators/release-version) +27) fix command 'nx test ui --verbose' +28) add the same license to all projects or only in root +29) partial run the monorepo (**Run Tasks on Projects Affected by a PR**) - [https://nx.dev/features/run-tasks](https://nx.dev/features/run-tasks) +30) replace cypress with playwright +31) remove footprints (jest) after migrating into vitest +32) when I run ""nx test dashboard"" test running for all apps and libs. need to isolate running tests only for project which is in command ""nx test dashboard"" +33) generate ci flow (npx nx generate ci-workflow --ci=github) - [https://nx.dev/getting-started/tutorials/react-monorepo-tutorial#generate-a-ci-workflow](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial#generate-a-ci-workflow) + +Resources: + +* [https://nx.dev/concepts/decisions/dependency-management](https://nx.dev/concepts/decisions/dependency-management) +* [https://medium.com/@sargashte118/setup-next-js-and-react-project-in-nx-monorepo-21391b42a8f6](https://medium.com/@sargashte118/setup-next-js-and-react-project-in-nx-monorepo-21391b42a8f6) +* [https://example.com/repo](https://example.com/repo) +* [https://example.com/repo](https://example.com/repo)",In Progress,,Urgent,,,user11@example.com,user11@example.com,,,,,,Tue Jul 02 2024 12:01:09 GMT+0000 (GMT),Fri Jul 12 2024 18:43:10 GMT+0000 (GMT),Tue Jul 02 2024 12:01:09 GMT+0000 (GMT),,,,,,,,,, +IN-38,Nordic-app,Zustand,"1) replace redux with Zustand +2) configure file structure for state manager",Todo,,Urgent,,,user11@example.com,user11@example.com,,,,,,Tue Jul 02 2024 12:02:14 GMT+0000 (GMT),Tue Jul 02 2024 12:02:14 GMT+0000 (GMT),,,,,,,,,,, diff --git a/testdata/import/csv/canonical/linear/sample-03.csv b/testdata/import/csv/canonical/linear/sample-03.csv new file mode 100644 index 00000000..50b1ce9b --- /dev/null +++ b/testdata/import/csv/canonical/linear/sample-03.csv @@ -0,0 +1,46 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,Roadmaps +FSK-59,fullstack,任务拆解,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:39:40 GMT+0000 (GMT),Sun May 11 2025 09:48:07 GMT+0000 (GMT),,,,,,,,,,,, +FSK-60,fullstack,【人工】在linear里创建issue,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:39:40 GMT+0000 (GMT),Sun May 11 2025 08:16:05 GMT+0000 (GMT),Sun May 11 2025 08:15:24 GMT+0000 (GMT),,,,,,FSK-59,,,,, +FSK-61,fullstack,需求分析,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:40:29 GMT+0000 (GMT),Sun May 11 2025 08:15:40 GMT+0000 (GMT),,,,,,,,,,,, +FSK-62,fullstack,【人工】描述需求,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:40:29 GMT+0000 (GMT),Sun May 11 2025 08:15:57 GMT+0000 (GMT),Sun May 11 2025 08:15:57 GMT+0000 (GMT),,,,,,FSK-61,,,,, +FSK-63,fullstack,架构设计与技术选型,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,,,,,, +FSK-64,fullstack,【用AI】生成架构建议,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-65,fullstack,【用AI】生成架构rule,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-66,fullstack,【用AI】生成.gitignore,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-67,fullstack,【人工】允许对rule进行版本化,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-68,fullstack,【用AI】优化架构rule,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-69,fullstack,【用AI】生成c4 model架构自然语言描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-70,fullstack,【用AI】生成c4component图脚本,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-71,fullstack,生成并修改用户界面文字描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,,,,,, +FSK-72,fullstack,【人工】拼凑用户界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-73,fullstack,【用AI】为拼凑界面生成文字描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-74,fullstack,【人工】修改界面文字描述以满足需求,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-75,fullstack,生成React前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,,,,,, +FSK-76,fullstack,【用AI】根据界面文字描述直接生成前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-77,fullstack,【人工】在本地电脑运行前端应用,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-78,fullstack,【用AI】协助我看懂前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-79,fullstack,生成Node.js后端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:55:11 GMT+0000 (GMT),Sun May 11 2025 08:05:30 GMT+0000 (GMT),,,,,,,,,,,, +FSK-80,fullstack,【用AI】修改前端代码以备好发给后端的提示词,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:01:57 GMT+0000 (GMT),Sun May 11 2025 08:01:57 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-81,fullstack,【用AI】生成后端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:02:08 GMT+0000 (GMT),Sun May 11 2025 08:02:08 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-82,fullstack,【用AI】修复运行错误,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:02:17 GMT+0000 (GMT),Sun May 11 2025 08:02:17 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-83,fullstack,【用AI】修复功能异常,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:05:20 GMT+0000 (GMT),Sun May 11 2025 08:05:20 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-84,fullstack,【用AI】实现流式响应功能,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:05:30 GMT+0000 (GMT),Sun May 11 2025 08:05:30 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-85,fullstack,前端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,,,,,, +FSK-86,fullstack,【用AI】搭建前端测试框架,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-87,fullstack,【用AI】让第一个前端单元测试运行通过,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-88,fullstack,【用AI】验证前端单元测试的保护效果,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-89,fullstack,【用AI】补充其他关键的前端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-90,fullstack,后端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,,,,,, +FSK-91,fullstack,【用AI】搭建后端测试框架,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-92,fullstack,【用AI】让第一个后端单元测试运行通过,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-93,fullstack,【用AI】验证后端单元测试的保护效果,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-94,fullstack,【用AI】补充其他关键的后端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-95,fullstack,代码评审,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,,,,,, +FSK-96,fullstack,【用AI】可视化软件架构与代码对应关系,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,FSK-95,,,,, +FSK-97,fullstack,【用AI】评审并改进代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,FSK-95,,,,, +FSK-98,fullstack,提示词优化历史管理,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,,,,,, +FSK-99,fullstack,【用AI】浏览历史,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,FSK-98,,,,, +FSK-100,fullstack,【用AI】删除历史,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,FSK-98,,,,, +FSK-101,fullstack,用户界面i18n国际化,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,,,,,, +FSK-102,fullstack,【用AI】中文界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,FSK-101,,,,, +FSK-103,fullstack,【用AI】英文界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-vca-copilot,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,FSK-101,,,,, diff --git a/testdata/import/csv/canonical/linear/sample-04.csv b/testdata/import/csv/canonical/linear/sample-04.csv new file mode 100644 index 00000000..abb47aa3 --- /dev/null +++ b/testdata/import/csv/canonical/linear/sample-04.csv @@ -0,0 +1,46 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,Roadmaps +FSK-59,fullstack,任务拆解,,Done,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:39:40 GMT+0000 (GMT),Sun May 11 2025 09:48:07 GMT+0000 (GMT),,,Sun May 11 2025 08:16:21 GMT+0000 (GMT),,,,,,,,, +FSK-60,fullstack,【人工】在linear里创建issue,,Done,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:39:40 GMT+0000 (GMT),Sun May 11 2025 08:16:05 GMT+0000 (GMT),Sun May 11 2025 08:15:24 GMT+0000 (GMT),,Sun May 11 2025 08:16:05 GMT+0000 (GMT),,,,FSK-59,,,,, +FSK-61,fullstack,需求分析,,Todo,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:40:29 GMT+0000 (GMT),Sun May 11 2025 08:15:40 GMT+0000 (GMT),,,,,,,,,,,, +FSK-62,fullstack,【人工】描述需求,,In Progress,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:40:29 GMT+0000 (GMT),Sun May 11 2025 08:15:57 GMT+0000 (GMT),Sun May 11 2025 08:15:57 GMT+0000 (GMT),,,,,,FSK-61,,,,, +FSK-63,fullstack,架构设计与技术选型,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,,,,,, +FSK-64,fullstack,【用AI】生成架构建议,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-65,fullstack,【用AI】生成架构rule,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-66,fullstack,【用AI】生成.gitignore,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-67,fullstack,【人工】允许对rule进行版本化,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-68,fullstack,【用AI】优化架构rule,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-69,fullstack,【用AI】生成c4 model架构自然语言描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-70,fullstack,【用AI】生成c4component图脚本,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:43:48 GMT+0000 (GMT),Sun May 11 2025 07:43:48 GMT+0000 (GMT),,,,,,,FSK-63,,,,, +FSK-71,fullstack,生成并修改用户界面文字描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,,,,,, +FSK-72,fullstack,【人工】拼凑用户界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-73,fullstack,【用AI】为拼凑界面生成文字描述,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-74,fullstack,【人工】修改界面文字描述以满足需求,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:44:55 GMT+0000 (GMT),Sun May 11 2025 07:44:55 GMT+0000 (GMT),,,,,,,FSK-71,,,,, +FSK-75,fullstack,生成React前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,,,,,, +FSK-76,fullstack,【用AI】根据界面文字描述直接生成前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-77,fullstack,【人工】在本地电脑运行前端应用,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-78,fullstack,【用AI】协助我看懂前端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:45:53 GMT+0000 (GMT),Sun May 11 2025 07:45:53 GMT+0000 (GMT),,,,,,,FSK-75,,,,, +FSK-79,fullstack,生成Node.js后端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 07:55:11 GMT+0000 (GMT),Sun May 11 2025 08:05:30 GMT+0000 (GMT),,,,,,,,,,,, +FSK-80,fullstack,【用AI】修改前端代码以备好发给后端的提示词,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:01:57 GMT+0000 (GMT),Sun May 11 2025 08:01:57 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-81,fullstack,【用AI】生成后端代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:02:08 GMT+0000 (GMT),Sun May 11 2025 08:02:08 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-82,fullstack,【用AI】修复运行错误,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:02:17 GMT+0000 (GMT),Sun May 11 2025 08:02:17 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-83,fullstack,【用AI】修复功能异常,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:05:20 GMT+0000 (GMT),Sun May 11 2025 08:05:20 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-84,fullstack,【用AI】实现流式响应功能,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:05:30 GMT+0000 (GMT),Sun May 11 2025 08:05:30 GMT+0000 (GMT),,,,,,,FSK-79,,,,, +FSK-85,fullstack,前端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,,,,,, +FSK-86,fullstack,【用AI】搭建前端测试框架,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-87,fullstack,【用AI】让第一个前端单元测试运行通过,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-88,fullstack,【用AI】验证前端单元测试的保护效果,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-89,fullstack,【用AI】补充其他关键的前端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:07:10 GMT+0000 (GMT),Sun May 11 2025 08:07:10 GMT+0000 (GMT),,,,,,,FSK-85,,,,, +FSK-90,fullstack,后端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,,,,,, +FSK-91,fullstack,【用AI】搭建后端测试框架,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-92,fullstack,【用AI】让第一个后端单元测试运行通过,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-93,fullstack,【用AI】验证后端单元测试的保护效果,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-94,fullstack,【用AI】补充其他关键的后端单元测试,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:08:09 GMT+0000 (GMT),Sun May 11 2025 08:08:09 GMT+0000 (GMT),,,,,,,FSK-90,,,,, +FSK-95,fullstack,代码评审,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,,,,,, +FSK-96,fullstack,【用AI】可视化软件架构与代码对应关系,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,FSK-95,,,,, +FSK-97,fullstack,【用AI】评审并改进代码,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:08 GMT+0000 (GMT),Sun May 11 2025 08:09:08 GMT+0000 (GMT),,,,,,,FSK-95,,,,, +FSK-98,fullstack,提示词优化历史管理,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,,,,,, +FSK-99,fullstack,【用AI】浏览历史,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,FSK-98,,,,, +FSK-100,fullstack,【用AI】删除历史,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:09:40 GMT+0000 (GMT),Sun May 11 2025 08:09:40 GMT+0000 (GMT),,,,,,,FSK-98,,,,, +FSK-101,fullstack,用户界面i18n国际化,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,,,,,, +FSK-102,fullstack,【用AI】中文界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,FSK-101,,,,, +FSK-103,fullstack,【用AI】英文界面,,Backlog,,No priority,f00867b7-48c4-4880-9cd6-c6acd404f5e0,book-apanvc-windsurf-ide,user12@example.com,user12@example.com,,,,,,Sun May 11 2025 08:10:23 GMT+0000 (GMT),Sun May 11 2025 08:10:23 GMT+0000 (GMT),,,,,,,FSK-101,,,,, diff --git a/testdata/import/csv/canonical/trello/sample-01.csv b/testdata/import/csv/canonical/trello/sample-01.csv new file mode 100644 index 00000000..91fdfa48 --- /dev/null +++ b/testdata/import/csv/canonical/trello/sample-01.csv @@ -0,0 +1,38 @@ +Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment Count,Attachment Links,Checklist Item Total Count,Checklist Item Completed Count,Vote Count,Comment Count,Last Activity Date,List ID,List Name,Board ID,Board Name,Archived,Start Date,Due Complete,Due Reminder +62b4725bdec63838046a4ec8,How to use this board,https://trello.com/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template + +**Before** + +* Both manager and team member put topics down on their lists, ranked by priority and labeled as either Blocker, Discuss, FYI or Paused. + +**During** + +* Agree on agenda +1\. Can Blocker and Discuss topics can be covered? +2\. Any interest in FYI topics? +* Discuss topics +1\. Capture notes/actions as you go (or defer to after meeting) +* Review progress on goals (either all or pick one to focus) +* Review actions + +**After** + +* Capture necessary notes/actions not covered in 1-1 meeting +* Move discussions that have related actions to ""Actions"" +* Move topics that are closed to ""Done"" + +**For more 1-on-1 meeting tips...** +I put my top 7 tips on the Atlassian Blog: https://www.atlassian.com/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://trello.com/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://trello.com/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://trello.com/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://trello.com/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://trello.com/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec2,Goal (#1),https://trello.com/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://trello.com/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://trello.com/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://trello.com/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed4,New training program,https://trello.com/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://trello.com/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ece,Manage time chaos,https://trello.com/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed2,Mentor another developer,https://trello.com/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed0,Best practice blog,https://trello.com/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, diff --git a/testdata/import/csv/synthetic/asana-dependencies-custom-fields.csv b/testdata/import/csv/synthetic/asana-dependencies-custom-fields.csv new file mode 100644 index 00000000..958cda32 --- /dev/null +++ b/testdata/import/csv/synthetic/asana-dependencies-custom-fields.csv @@ -0,0 +1,3 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent task,Blocked By (Dependencies),Blocking (Dependencies),Team,Priority,Type,Domain,Size,Status,OLD TASK,Shipped,Stakeholder_Visible +1200000000000001,2026-01-01,"",2026-01-02,Draft migration plan,Planning,Person 1,user1@example.com,2026-01-05,2026-01-10,migration,Write the first migration plan.,Basecamp Import,"","",1200000000000002,"",High,"","","",At risk,"","","" +1200000000000002,2026-01-02,2026-01-08,2026-01-08,Review project mapping,Review,Person 2,user2@example.com,"",2026-01-12,mapping,Confirm todolist mapping.,Basecamp Import,1200000000000001,1200000000000001,"","",Medium,"","","",On track,"","","" diff --git a/testdata/import/csv/synthetic/asana-simple.csv b/testdata/import/csv/synthetic/asana-simple.csv new file mode 100644 index 00000000..aee4d8c4 --- /dev/null +++ b/testdata/import/csv/synthetic/asana-simple.csv @@ -0,0 +1,3 @@ +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent Task +1200000000000001,2026-01-01,"",2026-01-02,Draft migration plan,Planning,Person 1,user1@example.com,2026-01-05,2026-01-10,migration,Write the first migration plan.,Basecamp Import,"" +1200000000000002,2026-01-02,2026-01-08,2026-01-08,Review project mapping,Review,Person 2,user2@example.com,"",2026-01-12,mapping,Confirm todolist mapping.,Basecamp Import,"" diff --git a/testdata/import/csv/synthetic/clickup-simple.csv b/testdata/import/csv/synthetic/clickup-simple.csv new file mode 100644 index 00000000..4fcb11c7 --- /dev/null +++ b/testdata/import/csv/synthetic/clickup-simple.csv @@ -0,0 +1,3 @@ +Task ID,Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Folder Name,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +cu-1,Prepare import dry run,Summarize what will be imported.,to do,1767229200000,2026-01-01 09:00 UTC,1768006800000,2026-01-10,1767661200000,2026-01-06,"",[],[Person 1],migration,normal,Planning,Imports,Operations,"","","","","","","","","" +cu-2,Approve destination mapping,Choose Basecamp project and todolist mapping.,in progress,1767315600000,2026-01-02 09:00 UTC,"",2026-01-11,"","","",[],[Person 2],mapping,high,Planning,Imports,Operations,"","","","","","","","","" diff --git a/testdata/import/csv/synthetic/clickup-subtasks-comments.csv b/testdata/import/csv/synthetic/clickup-subtasks-comments.csv new file mode 100644 index 00000000..ce728b9d --- /dev/null +++ b/testdata/import/csv/synthetic/clickup-subtasks-comments.csv @@ -0,0 +1,3 @@ +Task ID, Task Name,Task Content,Status,Date Created,Date Created Text,Due Date,Due Date Text,Start Date,Start Date Text,Parent ID,Attachments,Assignees,Tags,Priority,List Name,Folder Name/Path,Space Name,Time Estimated,Time Estimated Text,Checklists,Comments,Assigned Comments,Time Spent,Time Spent Text,Rolled Up Time,Rolled Up Time Text +cu-1,Prepare import dry run,Summarize what will be imported.,to do,1767229200000,2026-01-01 09:00 UTC,1768006800000,2026-01-10,1767661200000,2026-01-06,"",[],[Person 1],migration,normal,Planning,Imports,Operations,"","",Checklist: [ ] confirm counts; [x] identify owners,Person 1: Please include attachments in the dry run.,"","","","","" +cu-2,Approve destination mapping,Choose Basecamp project and todolist mapping.,in progress,1767315600000,2026-01-02 09:00 UTC,"",2026-01-11,"","",cu-1,https://example.com/files/mapping.csv,[Person 2],mapping,high,Planning,Imports,Operations,"","","",Person 2: Mapping approved for Planning list.,"","","","","" diff --git a/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv b/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv new file mode 100644 index 00000000..e41d1c75 --- /dev/null +++ b/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv @@ -0,0 +1,3 @@ +Issue Type,Issue key,Issue id,Summary,Fix versions,Fix versions,Status,Created,Updated,Priority,Resolution,Affects versions,Affects versions,Security Level,Labels,Labels,Labels,Labels,Labels +Task,BCJ-1,10001,Draft launch checklist,"","",To Do,01/Jan/26 9:00 AM,02/Jan/26 10:00 AM,Medium,"","","","",launch,planning,"","","" +Bug,BCJ-2,10002,Fix signup error,"","",In Progress,03/Jan/26 11:15 AM,04/Jan/26 12:00 PM,High,"","","","",bug,signup,"","","" diff --git a/testdata/import/csv/synthetic/jira-simple.csv b/testdata/import/csv/synthetic/jira-simple.csv new file mode 100644 index 00000000..e0314e0c --- /dev/null +++ b/testdata/import/csv/synthetic/jira-simple.csv @@ -0,0 +1,3 @@ +Issue Type,Issue key,Issue id,Summary,Project name,Created,Labels,Labels,Description,Actual Result,Expected Result,Preconditions,Priority,Severity,Sprint,Status,Parent summary +Task,BCJ-1,10001,Draft launch checklist,Basecamp Jira Import,01/Jan/26 9:00 AM,launch,planning,Create the launch checklist before kickoff.,"","","",Medium,"","",To Do,"" +Bug,BCJ-2,10002,Fix signup error,Basecamp Jira Import,03/Jan/26 11:15 AM,bug,signup,Signup form returns a generic error.,"","","",High,"","",In Progress,"" diff --git a/testdata/import/csv/synthetic/linear-ambiguous-assignees.csv b/testdata/import/csv/synthetic/linear-ambiguous-assignees.csv new file mode 100644 index 00000000..f2ec80a8 --- /dev/null +++ b/testdata/import/csv/synthetic/linear-ambiguous-assignees.csv @@ -0,0 +1,3 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,Roadmaps +LIN-1,Import,Create dry-run summary,"Show counts, mappings, and risks before writes.",Todo,2,High,project-1,Basecamp Import,Person 1,Person 1,migration,1,Cycle 1,2026-01-01,2026-01-14,Thu Jan 01 2026 09:00:00 GMT+0000 (GMT),Thu Jan 02 2026 09:00:00 GMT+0000 (GMT),"","","","","","","","","","","","" +LIN-2,Import,Import approved todos,Create Basecamp todos after approval.,In Progress,3,Medium,project-1,Basecamp Import,Person 2,Person 1,write-path,1,Cycle 1,2026-01-01,2026-01-14,Fri Jan 02 2026 09:00:00 GMT+0000 (GMT),Fri Jan 03 2026 09:00:00 GMT+0000 (GMT),"","","","","","","","","","","","" diff --git a/testdata/import/csv/synthetic/linear-relationships.csv b/testdata/import/csv/synthetic/linear-relationships.csv new file mode 100644 index 00000000..5e558e9c --- /dev/null +++ b/testdata/import/csv/synthetic/linear-relationships.csv @@ -0,0 +1,3 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Triaged,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,UUID,Time in status (minutes),Related to,Blocked by,Duplicate of +LIN-1,Import,Create dry-run summary,"Show counts, mappings, and risks before writes.",Todo,2,High,project-1,Basecamp Import,user1@example.com,user2@example.com,migration,1,Cycle 1,2026-01-01,2026-01-14,"","","","","","",M1,"","","",LIN-2,"","" +LIN-2,Import,Import approved todos,Create Basecamp todos after approval.,In Progress,3,Medium,project-1,Basecamp Import,user1@example.com,user3@example.com,write-path,1,Cycle 1,2026-01-01,2026-01-14,"","","",LIN-1,"","",M1,"","","","",LIN-1,"" diff --git a/testdata/import/csv/synthetic/linear-simple.csv b/testdata/import/csv/synthetic/linear-simple.csv new file mode 100644 index 00000000..c80120ff --- /dev/null +++ b/testdata/import/csv/synthetic/linear-simple.csv @@ -0,0 +1,3 @@ +ID,Team,Title,Description,Status,Estimate,Priority,Project ID,Project,Creator,Assignee,Labels,Cycle Number,Cycle Name,Cycle Start,Cycle End,Created,Updated,Started,Triaged,Completed,Canceled,Archived,Due Date,Parent issue,Initiatives,Project Milestone ID,Project Milestone,SLA Status,Roadmaps +LIN-1,Import,Create dry-run summary,"Show counts, mappings, and risks before writes.",Todo,2,High,project-1,Basecamp Import,user1@example.com,user2@example.com,migration,1,Cycle 1,2026-01-01,2026-01-14,Thu Jan 01 2026 09:00:00 GMT+0000 (GMT),Thu Jan 02 2026 09:00:00 GMT+0000 (GMT),"","","","","","","","","","","","" +LIN-2,Import,Import approved todos,Create Basecamp todos after approval.,In Progress,3,Medium,project-1,Basecamp Import,user1@example.com,user3@example.com,write-path,1,Cycle 1,2026-01-01,2026-01-14,Fri Jan 02 2026 09:00:00 GMT+0000 (GMT),Fri Jan 03 2026 09:00:00 GMT+0000 (GMT),"","","","","","","","","","","","" diff --git a/testdata/import/csv/synthetic/random/sample-01.csv b/testdata/import/csv/synthetic/random/sample-01.csv new file mode 100644 index 00000000..6dfc75b5 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-01.csv @@ -0,0 +1,13 @@ +ref,action,context,context,due,status,waiting_on,note,note +GTD-001,Renew passport,@errand,personal,2026-06-01,next,,Photos taken already,Bring old passport +GTD-002,Reply to landlord,@home,admin,2026-05-23,next,dana@example.com,,Ask about parking spot +GTD-003,Draft Q3 goals,@computer,work,2026-05-30,waiting,Sam Whitfield,Outline lives in notes app,Share with team after review +GTD-004,Fix leaky faucet,@home,household,2026-05-25,todo,,Need washer kit, +GTD-005,Schedule dentist,@phone,health,2026-05-22,done,,Booked for 9am,Cleaning only +GTD-006,Cancel gym trial,@phone,admin,2026-05-24,next,,,Do before billing date +GTD-007,Read tax letter,@home,finance,2026-05-28,waiting,accountant@example.org,Scanned to drive, +GTD-008,Plan weekend hike,@computer,personal,2026-06-06,someday,Dana Reyes,Check weather first,Invite Sam along +GTD-009,Backup laptop,@computer,admin,2026-05-26,todo,,External drive in drawer, +GTD-010,Return library books,@errand,personal,2026-05-27,next,,Two are overdue, +GTD-011,Update resume,@computer,career,2026-06-15,someday,,,Add the recent project +GTD-012,Buy birthday gift,@errand,personal,2026-05-29,next,jamie@example.com,Ideas: book or plant,Wrap before Friday diff --git a/testdata/import/csv/synthetic/random/sample-02.csv b/testdata/import/csv/synthetic/random/sample-02.csv new file mode 100644 index 00000000..a129c0de --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-02.csv @@ -0,0 +1,16 @@ +key,summary,type,assignee,reporter,sprint,points,status,blocked_by,link,description +SPRINT-101,Login throttling,story,maya@example.com,Lead Dev,Sprint 14,5,in progress,SPRINT-099,https://example.com/tickets/101,"Add rate limiting to login. +Threshold: 5 attempts, then a 15 minute lockout." +SPRINT-102,Fix avatar upload,bug,Tom Becker,maya@example.com,Sprint 14,3,todo,,https://example.com/tickets/102,"Uploads over 2MB fail, returning a 500 error" +SPRINT-103,Onboarding tour,story,priya@example.org,Tom Becker,Sprint 14,8,todo,SPRINT-101,https://example.com/tickets/103,"Multi-step tour for new users, dismissible at any point" +SPRINT-104,Audit log export,task,Lead Dev,priya@example.org,Sprint 15,5,backlog,,https://example.com/tickets/104,"Export to CSV, JSON, and PDF formats" +SPRINT-105,Dark mode toggle,story,maya@example.com,Tom Becker,Sprint 14,3,done,,https://example.com/tickets/105,Persist preference across sessions +SPRINT-106,Slow dashboard query,bug,priya@example.org,maya@example.com,Sprint 14,5,in progress,SPRINT-104,https://example.com/tickets/106,"Dashboard takes 8s to load. +Suspect missing index on events table." +SPRINT-107,Add 2FA,story,Tom Becker,Lead Dev,Sprint 15,13,backlog,SPRINT-101,https://example.com/tickets/107,"Support TOTP apps, with backup codes" +SPRINT-108,Email digest opt-out,task,maya@example.com,priya@example.org,Sprint 14,2,review,,https://example.com/tickets/108,One-click unsubscribe link in footer +SPRINT-109,Mobile nav overlap,bug,priya@example.org,Tom Becker,Sprint 14,2,done,,https://example.com/tickets/109,"On small screens, menu overlaps the logo" +SPRINT-110,API rate limit docs,task,Lead Dev,maya@example.com,Sprint 15,1,todo,SPRINT-101,https://example.com/tickets/110,Document the new limits in the public docs +SPRINT-111,Bulk archive,story,Tom Becker,priya@example.org,Sprint 15,5,backlog,,https://example.com/tickets/111,"Select many items, archive in one action" +SPRINT-112,Session timeout warning,story,maya@example.com,Lead Dev,Sprint 14,3,in progress,SPRINT-112,https://example.com/tickets/112,"Warn at 2 minutes left, offer to extend" +SPRINT-113,Fix typo on billing page,bug,priya@example.org,Tom Becker,Sprint 14,1,done,,https://example.com/tickets/113,"""Recieve"" should be ""Receive""" diff --git a/testdata/import/csv/synthetic/random/sample-03.csv b/testdata/import/csv/synthetic/random/sample-03.csv new file mode 100644 index 00000000..4f441ba8 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-03.csv @@ -0,0 +1,14 @@ +chore_id,chore,room,frequency,assigned,tag,tag,last_done,supplies_needed,reward_points +H1,Vacuum living room,living room,weekly,Alex,floors,quick,2026-05-18,vacuum bags,5 +H2,Clean fridge,kitchen,monthly,Jordan,deep-clean,kitchen,2026-04-30,baking soda,15 +H3,Mow lawn,yard,biweekly,Alex,outdoor,seasonal,2026-05-10,gas for mower,20 +H4,Wipe bathroom mirrors,bathroom,weekly,Casey,quick,glass,2026-05-19,glass cleaner,3 +H5,Change HVAC filter,hallway,quarterly,Jordan,maintenance,air,2026-03-15,20x25 filter,10 +H6,Water plants,living room,daily,Casey,plants,quick,2026-05-21,,2 +H7,Take out recycling,kitchen,weekly,Alex,trash,outdoor,2026-05-20,blue bags,3 +H8,Dust shelves,office,weekly,Jordan,dusting,quick,2026-05-17,microfiber cloth,4 +H9,Scrub shower grout,bathroom,monthly,Casey,deep-clean,bathroom,2026-04-28,grout brush,18 +H10,Organize garage,garage,quarterly,Alex,big-project,outdoor,2026-02-14,storage bins,40 +H11,Wash car,driveway,monthly,Jordan,outdoor,car,2026-05-02,car soap,12 +H12,Sweep porch,porch,weekly,Casey,outdoor,quick,2026-05-19,broom,3 +H13,Clean oven,kitchen,quarterly,Alex,deep-clean,kitchen,2026-03-01,oven cleaner,25 diff --git a/testdata/import/csv/synthetic/random/sample-04.csv b/testdata/import/csv/synthetic/random/sample-04.csv new file mode 100644 index 00000000..2dcb8589 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-04.csv @@ -0,0 +1,13 @@ +project;client;owner;stage;deliverable;due;hours_logged;invoice_ref;portal_url +acme-rebrand;Acme Co;dana@example.com;active;"Logo, palette; brand guide";2026-06-10;42.5;INV-2026-0012;https://example.com/portal/acme +nimbus-site;Nimbus Ltd;Rae Donovan;discovery;"Sitemap; wireframes";2026-05-29;8;INV-2026-0013;https://example.com/portal/nimbus +orchard-app;Orchard Foods;leo@example.org;review;"iOS build; QA notes";2026-06-02;76;INV-2026-0014;https://example.com/portal/orchard +vela-deck;Vela Partners;dana@example.com;delivered;Pitch deck;2026-05-15;15;INV-2026-0009;https://example.com/portal/vela +harbor-crm;Harbor Group;Rae Donovan;active;"Data migration; training";2026-06-20;33;INV-2026-0015;https://example.com/portal/harbor +maple-blog;Maple Studio;leo@example.org;discovery;Content audit;2026-05-31;6;INV-2026-0016;https://example.com/portal/maple +delta-ads;Delta Retail;dana@example.com;active;"Ad set; landing page";2026-06-05;28;INV-2026-0017;https://example.com/portal/delta +quartz-ui;Quartz Bank;Rae Donovan;review;"Component library; docs";2026-06-12;54;INV-2026-0018;https://example.com/portal/quartz +sol-intranet;Sol Energy;leo@example.org;active;Intranet redesign;2026-07-01;19;INV-2026-0019;https://example.com/portal/sol +ferro-pkg;Ferro Goods;dana@example.com;delivered;"Packaging; print files";2026-05-08;21;INV-2026-0007;https://example.com/portal/ferro +juno-onboard;Juno Health;Rae Donovan;discovery;Onboarding flow;2026-06-18;4;INV-2026-0020;https://example.com/portal/juno +pine-report;Pine Trust;leo@example.org;active;"Annual report; charts";2026-06-25;37;INV-2026-0021;https://example.com/portal/pine diff --git a/testdata/import/csv/synthetic/random/sample-05.csv b/testdata/import/csv/synthetic/random/sample-05.csv new file mode 100644 index 00000000..aaf46b9d --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-05.csv @@ -0,0 +1,13 @@ +study_id,hypothesis,phase,lead,depends_on,target_date,status,protocol_url,notes +r-9f3a2,Sleep affects recall,pilot,Dr. Okafor,,"May 22, 2026",active,https://example.org/protocols/r-9f3a2,"Recruiting 24 participants, ages 18-35" +r-1c7e8,Caffeine and reaction time,design,nguyen@example.org,r-9f3a2,"June 3, 2026",active,https://example.org/protocols/r-1c7e8,"Double-blind, placebo control planned" +r-44b0d,Light exposure and mood,analysis,Dr. Okafor,r-9f3a2,"April 15, 2026",complete,https://example.org/protocols/r-44b0d,"Data collected, writing results section now" +r-8de21,Exercise and focus,pilot,patel@example.com,,"July 10, 2026",queued,https://example.org/protocols/r-8de21,Waiting on IRB approval +r-23ff9,Diet and inflammation,design,Dr. Okafor,r-44b0d,"June 20, 2026",active,https://example.org/protocols/r-23ff9,"Need lab access, coordinating with the clinic" +r-6a0c1,Music and productivity,analysis,nguyen@example.org,,"May 1, 2026",complete,https://example.org/protocols/r-6a0c1,"Mixed results, see appendix" +r-77e3b,Screen time and stress,pilot,patel@example.com,r-8de21,"August 5, 2026",queued,https://example.org/protocols/r-77e3b,"Survey instrument drafted, needs review" +r-90ab4,Hydration and headaches,design,Dr. Okafor,,"June 12, 2026",active,https://example.org/protocols/r-90ab4,"Tracking daily logs, 6 week window" +r-31cd6,Posture and back pain,pilot,nguyen@example.org,r-23ff9,"September 1, 2026",blocked,https://example.org/protocols/r-31cd6,"Equipment delayed, vendor issue" +r-5b8f0,Nature walks and anxiety,analysis,patel@example.com,r-44b0d,"April 30, 2026",complete,https://example.org/protocols/r-5b8f0,"Strong effect, prepping for submission" +r-12a9c,Reading and vocabulary,design,Dr. Okafor,,"July 22, 2026",queued,https://example.org/protocols/r-12a9c,Pending funding decision +r-0fe72,Naps and creativity,pilot,nguyen@example.org,r-9f3a2,"June 28, 2026",active,https://example.org/protocols/r-0fe72,"Counterbalanced design, two sessions each" diff --git a/testdata/import/csv/synthetic/random/sample-06.csv b/testdata/import/csv/synthetic/random/sample-06.csv new file mode 100644 index 00000000..18cb47f7 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-06.csv @@ -0,0 +1,15 @@ +step,procedure,system,owner,trigger,upstream_step,severity,owner,runbook_note +1,Acknowledge page,pagerduty,oncall@example.com,alert fires,,sev2,Priya N.,Check the dashboard first +2,Verify scope,grafana,,,1,sev2,, +3,Failover database,postgres,dba@example.org,,2,sev1,Marco Reyes,Promote the replica +4,Drain traffic,nginx,,manual,3,sev1,, +5,Notify stakeholders,statuspage,comms@example.com,,3,sev3,Priya N., +6,Scale workers,k8s,sre@example.org,queue depth high,,sev2,,Bump replicas to 8 +7,Clear cache,redis,,,6,sev3,Marco Reyes, +8,Rotate credentials,vault,security@example.com,,,sev1,,Only if breach suspected +9,Restore from backup,s3,dba@example.org,data loss,3,sev1,Priya N., +10,Confirm recovery,grafana,oncall@example.com,,4,sev2,,Watch for 30 minutes +11,Write incident report,confluence,,resolved,10,sev3,Marco Reyes, +12,Schedule postmortem,calendar,comms@example.com,,11,sev3,,Within 5 business days +13,Close incident,pagerduty,oncall@example.com,,12,sev3,Priya N., +14,Update runbook,git,sre@example.org,,11,sev3,,Capture lessons learned diff --git a/testdata/import/csv/synthetic/random/sample-07.csv b/testdata/import/csv/synthetic/random/sample-07.csv new file mode 100644 index 00000000..e99f8190 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-07.csv @@ -0,0 +1,13 @@ +id task track owner deadline status vendor cost +1 Book venue logistics Hannah Cole 22.05.2026 confirmed Riverside Hall 2400 +2 Order catering catering leo@example.com 05.06.2026 pending Green Fork Catering 3800 +3 Hire AV crew production Hannah Cole 29.05.2026 confirmed SoundWorks 1500 +4 Send invites comms mira@example.org 18.05.2026 done Printly 320 +5 Arrange parking logistics leo@example.com 10.06.2026 open City Lot Co 600 +6 Build run sheet production Hannah Cole 12.06.2026 in progress , 0 +7 Confirm speakers program mira@example.org 01.06.2026 pending , 0 +8 Set up registration logistics leo@example.com 14.06.2026 open EventReg 450 +9 Order signage production Hannah Cole 08.06.2026 confirmed Printly 280 +10 Coordinate photographer production mira@example.org 15.06.2026 pending Lens & Light 900 +11 Plan after-party social leo@example.com 16.06.2026 open Rooftop Bar 1100 +12 Final walkthrough logistics Hannah Cole 19.06.2026 open Riverside Hall 0 diff --git a/testdata/import/csv/synthetic/random/sample-08.csv b/testdata/import/csv/synthetic/random/sample-08.csv new file mode 100644 index 00000000..eb2c6e8d --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-08.csv @@ -0,0 +1,15 @@ +work_order,asset,location,reported_by,priority,status,scheduled,photo_url,resolution,warranty_until,meter_reading +WO-2026-0001,Boiler Unit 3,Basement,facilities@example.com,high,open,2026-05-24,https://example.com/photos/wo0001.jpg,,2027-01-10,4821 +WO-2026-0002,Elevator A,Lobby,Tom Lin,critical,in progress,2026-05-22,https://example.com/photos/wo0002.jpg,"Technician on site, replacing the control board",2026-09-30, +WO-2026-0003,Parking gate,Garage,facilities@example.com,medium,open,2026-05-28,https://example.com/photos/wo0003.jpg,,2026-12-01,10233 +WO-2026-0004,HVAC rooftop,Roof,Tom Lin,high,closed,2026-05-15,https://example.com/photos/wo0004.jpg,"Replaced belt and filter, tested airflow, all nominal",2027-03-22,8742 +WO-2026-0005,Water heater,Floor 2,facilities@example.com,low,open,2026-06-02,,,2026-08-15, +WO-2026-0006,Loading dock door,Warehouse,Tom Lin,medium,in progress,2026-05-23,https://example.com/photos/wo0006.jpg,"Sensor misaligned, +realigning and recalibrating",2026-11-05,3019 +WO-2026-0007,Fire alarm panel,Stairwell B,facilities@example.com,critical,open,2026-05-22,https://example.com/photos/wo0007.jpg,,2027-06-30, +WO-2026-0008,Kitchen exhaust,Cafeteria,Tom Lin,high,closed,2026-05-10,https://example.com/photos/wo0008.jpg,"Deep cleaned ducts, removed grease buildup",2026-10-12,5567 +WO-2026-0009,Backup generator,Basement,facilities@example.com,high,open,2026-05-30,,,2027-02-28,9981 +WO-2026-0010,Lobby doors,Lobby,Tom Lin,low,in progress,2026-05-25,https://example.com/photos/wo0010.jpg,"Hinge squeak, lubricated and adjusted",2026-07-19, +WO-2026-0011,Cooling tower,Roof,facilities@example.com,medium,open,2026-06-04,https://example.com/photos/wo0011.jpg,,2027-04-01,2240 +WO-2026-0012,Sump pump,Basement,Tom Lin,critical,closed,2026-05-12,https://example.com/photos/wo0012.jpg,"Pump failed, swapped unit, tested float switch",2026-12-20,6612 +WO-2026-0013,Window seals,Floor 5,facilities@example.com,low,open,2026-06-10,,,2026-09-09, diff --git a/testdata/import/csv/synthetic/random/sample-09.csv b/testdata/import/csv/synthetic/random/sample-09.csv new file mode 100644 index 00000000..8a24d519 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-09.csv @@ -0,0 +1,13 @@ +slug,working_title,headline,section,label,label,state,stage,date,go_live,word_count +spring-trends,Spring fashion piece,5 Trends Defining Spring,style,evergreen,featured,draft,writing,2026/05/22,2026/06/01,1200 +budget-tips,Budget basics,How to Save $500 a Month,money,how-to,featured,review,editing,2026/05/18,2026/05/25,900 +ai-explainer,AI explainer,What Is Machine Learning,tech,explainer,trending,published,live,2026/05/10,2026/05/12,1500 +garden-guide,Garden starter,Start a Garden This Weekend,home,how-to,seasonal,draft,idea,2026/05/30,2026/06/15,1100 +travel-japan,Japan travel,7 Days in Kyoto,travel,guide,featured,review,editing,2026/05/20,2026/06/05,2200 +recipe-soup,Soup recipe,One-Pot Lentil Soup,food,recipe,evergreen,published,live,2026/05/05,2026/05/06,600 +career-pivot,Career change,Switching Careers at 40,work,personal,trending,draft,writing,2026/05/28,2026/06/10,1400 +sleep-science,Sleep article,The Science of Better Sleep,health,explainer,evergreen,review,editing,2026/05/19,2026/05/27,1300 +remote-work,Remote work piece,Remote Work Setups That Last,work,how-to,featured,draft,idea,2026/06/02,2026/06/20,1000 +book-list,Summer reads,12 Books for Summer,culture,listicle,seasonal,published,live,2026/05/01,2026/05/03,800 +phone-detox,Digital detox,A Week Without My Phone,tech,personal,trending,draft,writing,2026/05/25,2026/06/08,1600 +coffee-guide,Coffee guide,Brewing the Perfect Cup,food,guide,evergreen,review,editing,2026/05/17,2026/05/24,950 diff --git a/testdata/import/csv/synthetic/random/sample-10.csv b/testdata/import/csv/synthetic/random/sample-10.csv new file mode 100644 index 00000000..83bd5093 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-10.csv @@ -0,0 +1,13 @@ +matter_id,matter,counterparty,responsible,status,effective_date,renewal,clause_notes,risk +M-2026-014,Master services agreement,Brightwave Inc,counsel@example.com,active,2026-01-15,2027-01-15,"Auto-renews unless notice given 60 days prior, see section 12",medium +M-2026-015,NDA,Cobalt Labs,Renata Voss,signed,2026-03-01,,Mutual confidentiality; 3 year term,low +M-2026-016,Vendor contract,Pinnacle Supply,counsel@example.com,review,2026-05-20,2027-05-20,"Liability cap at fees paid, indemnity is one-sided in their favor",high +M-2026-017,Lease amendment,Harbor Realty,Renata Voss,active,2026-02-10,2029-02-10,"Rent escalates 3% annually, option to extend two years",medium +M-2026-018,Licensing deal,Nova Media,counsel@example.com,negotiation,2026-06-01,2028-06-01,"Royalty 8% of net, audit rights included, territory limited to NA",high +M-2026-019,Employment offer,Internal,hr@example.org,signed,2026-04-12,,Standard at-will; includes IP assignment,low +M-2026-020,Settlement,Greystone LLC,Renata Voss,active,2026-05-05,,"Confidential terms, payment in 3 installments, release of all claims",medium +M-2026-021,SaaS subscription,DataPeak,counsel@example.com,active,2026-01-01,2027-01-01,"SLA 99.9%, data deletion within 30 days of termination",low +M-2026-022,Partnership MOU,Lumen Group,Renata Voss,draft,2026-06-15,,"Non-binding, sets framework for joint venture",medium +M-2026-023,IP transfer,Acme Co,counsel@example.com,negotiation,2026-05-22,,"Assignment of all patents, warranty of clear title required",high +M-2026-024,Consulting agreement,Forge Advisors,hr@example.org,signed,2026-03-20,2026-09-20,"Day rate, 30 day termination for convenience",low +M-2026-025,Distribution contract,Orchard Foods,Renata Voss,review,2026-06-10,2027-06-10,"Exclusive in region, minimum order quantities apply, force majeure broad",high diff --git a/testdata/import/csv/synthetic/random/sample-11.csv b/testdata/import/csv/synthetic/random/sample-11.csv new file mode 100644 index 00000000..508aee44 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-11.csv @@ -0,0 +1,14 @@ +code;line_item;category;owner;amount;currency;status;due;gl_account;cost_center +FIN-001;Office rent;facilities;dana@example.com;4,500.00;USD;approved;01-May-2026;6000;CC-FAC +FIN-002;Cloud hosting;infrastructure;sre@example.org;1,280.50;USD;approved;05-May-2026;6100;CC-ENG +FIN-003;Team offsite;people;hr@example.com;3,200.00;USD;pending;15-Jun-2026;6200;CC-HR +FIN-004;Marketing ads;marketing;Rae Donovan;7,800.00;USD;approved;10-May-2026;6300;CC-MKT +FIN-005;Legal retainer;legal;counsel@example.org;2,500.00;USD;approved;01-May-2026;6400;CC-LEG +FIN-006;New laptops;infrastructure;sre@example.org;9,600.00;USD;pending;20-May-2026;6100;CC-ENG +FIN-007;Conference travel;people;hr@example.com;1,450.75;USD;rejected;22-May-2026;6200;CC-HR +FIN-008;Software licenses;infrastructure;Rae Donovan;3,900.00;USD;approved;01-Jun-2026;6100;CC-ENG +FIN-009;Catering;facilities;dana@example.com;620.00;USD;approved;18-May-2026;6000;CC-FAC +FIN-010;Recruiting fees;people;hr@example.com;12,000.00;EUR;pending;30-Jun-2026;6200;CC-HR +FIN-011;Print materials;marketing;Rae Donovan;480.25;USD;approved;12-May-2026;6300;CC-MKT +FIN-012;Insurance premium;legal;counsel@example.org;5,100.00;USD;approved;01-Jul-2026;6400;CC-LEG +FIN-013;Equipment repair;facilities;dana@example.com;340.00;USD;pending;25-May-2026;6000;CC-FAC diff --git a/testdata/import/csv/synthetic/random/sample-12.csv b/testdata/import/csv/synthetic/random/sample-12.csv new file mode 100644 index 00000000..a39dd473 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-12.csv @@ -0,0 +1,15 @@ +course,assignment,topic,topic,due,weight,status,grade,resources +CS101,Problem Set 3,recursion,algorithms,2026-05-25,10%,submitted,, +CS101,Midterm,arrays,data structures,2026-05-30,25%,upcoming,,review session Thursday +HIST210,Essay 2,industrial revolution,europe,2026-05-28,20%,in progress,, +HIST210,Reading response,colonialism,,2026-05-22,5%,submitted,B+, +BIO150,Lab report,photosynthesis,plants,2026-05-26,15%,in progress,,lab manual ch 4 +BIO150,Quiz 5,cell division,,2026-05-23,5%,upcoming,, +MATH200,Homework 7,integrals,calculus,2026-05-24,10%,submitted,A, +MATH200,Project,modeling,,2026-06-05,30%,not started,, +ENG105,Poetry analysis,modernism,literature,2026-05-27,15%,in progress,, +ENG105,Peer review,,,2026-05-21,5%,submitted,,partner: study group +CHEM110,Pre-lab,titration,acids,2026-05-22,5%,submitted,A-, +CHEM110,Final exam,,,2026-06-10,35%,upcoming,, +PSY101,Discussion post,memory,cognition,2026-05-20,5%,submitted,, +PSY101,Research summary,,,2026-05-29,15%,not started,, diff --git a/testdata/import/csv/synthetic/random/sample-13.csv b/testdata/import/csv/synthetic/random/sample-13.csv new file mode 100644 index 00000000..5c3741c3 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-13.csv @@ -0,0 +1,16 @@ +candidate_id,name,role,stage,recruiter,interview_date,score,resume_url,source,notes,visa_status +c-7f21a9,Avery Sloane,Backend Engineer,onsite,recruiter@example.com,2026-05-26,4.2,https://example.com/resumes/c-7f21a9.pdf,LinkedIn,"Strong systems background, asked about on-call expectations",citizen +c-3b88de,Jordan Mei,Product Designer,screen,Kim Park,2026-05-24,3.8,https://example.com/resumes/c-3b88de.pdf,Referral,"Great portfolio, references the Nimbus project",needs sponsorship +c-90c4f1,Sam Idris,Data Scientist,offer,recruiter@example.com,2026-05-20,4.6,https://example.com/resumes/c-90c4f1.pdf,Job board,"Negotiating comp, wants remote",citizen +c-12ee07,Priya Raman,Backend Engineer,applied,Kim Park,,,https://example.com/resumes/c-12ee07.pdf,LinkedIn,,unknown +c-558a2b,Leo Tran,Frontend Engineer,onsite,recruiter@example.com,2026-05-27,4.0,https://example.com/resumes/c-558a2b.pdf,Referral,"Solid React skills, +some hesitation on testing practices",permanent resident +c-7d0119,Maya Cole,Engineering Manager,screen,Kim Park,2026-05-23,3.9,https://example.com/resumes/c-7d0119.pdf,Recruiter outreach,"Led team of 8, asked about growth path",citizen +c-2a6f3c,Tom Becker,QA Engineer,rejected,recruiter@example.com,2026-05-15,2.7,https://example.com/resumes/c-2a6f3c.pdf,Job board,"Not enough automation experience for the role",citizen +c-44b9e0,Rae Donovan,Product Manager,offer,Kim Park,2026-05-19,4.5,https://example.com/resumes/c-44b9e0.pdf,Referral,"Strong, multiple competing offers, move fast",citizen +c-6e1c88,Nina Holt,DevOps Engineer,onsite,recruiter@example.com,2026-05-28,4.1,https://example.com/resumes/c-6e1c88.pdf,LinkedIn,"Kubernetes depth, asked about team size",needs sponsorship +c-91f7a2,Omar Diaz,Backend Engineer,applied,Kim Park,,,https://example.com/resumes/c-91f7a2.pdf,Job board,,unknown +c-08bd45,Hana Sato,UX Researcher,hired,recruiter@example.com,2026-05-10,4.7,https://example.com/resumes/c-08bd45.pdf,Referral,"Accepted offer, starts in June",permanent resident +c-3cc910,Wes Carter,Frontend Engineer,screen,Kim Park,2026-05-25,3.5,https://example.com/resumes/c-3cc910.pdf,LinkedIn,"Junior, eager, needs mentorship",citizen +c-77a0e3,Lila Frost,Data Engineer,rejected,recruiter@example.com,2026-05-12,3.0,https://example.com/resumes/c-77a0e3.pdf,Job board,"Skills mismatch, more analyst than engineer",citizen +c-5519bf,Theo Vance,Security Engineer,onsite,Kim Park,2026-05-29,4.3,https://example.com/resumes/c-5519bf.pdf,Recruiter outreach,"Pentesting background, strong references",needs sponsorship diff --git a/testdata/import/csv/synthetic/random/sample-14.csv b/testdata/import/csv/synthetic/random/sample-14.csv new file mode 100644 index 00000000..22eedaac --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-14.csv @@ -0,0 +1,14 @@ +deal_id,account,owner,stage,amount,close_ts,next_step,depends_on,probability +D-5001,Brightwave Inc,jordan@example.com,proposal,48000,1716336000,Send revised quote,,0.6 +D-5002,Cobalt Labs,Riya Shah,negotiation,120000,1717545600,Legal review of MSA,D-5001,0.75 +D-5003,Pinnacle Supply,jordan@example.com,qualified,22000,1718755200,Schedule demo,,0.3 +D-5004,Nova Media,Riya Shah,closed won,87000,1715731200,Kickoff call,,1.0 +D-5005,Lumen Group,marco@example.org,proposal,64000,1719360000,Pricing approval,D-5002,0.55 +D-5006,Harbor Realty,jordan@example.com,prospecting,15000,1721347200,Discovery call,,0.15 +D-5007,DataPeak,Riya Shah,negotiation,150000,1717113600,Finalize terms,D-5004,0.8 +D-5008,Forge Advisors,marco@example.org,closed lost,40000,1714521600,Follow up next quarter,,0.0 +D-5009,Greystone LLC,jordan@example.com,qualified,33000,1720137600,Send case studies,,0.35 +D-5010,Orchard Foods,Riya Shah,proposal,72000,1718323200,Address security questions,D-5007,0.65 +D-5011,Quartz Bank,marco@example.org,prospecting,200000,1722556800,Identify champion,,0.1 +D-5012,Vela Partners,jordan@example.com,negotiation,95000,1716940800,Contract redlines,D-5005,0.7 +D-5013,Sol Energy,Riya Shah,closed won,58000,1715126400,Onboarding handoff,,1.0 diff --git a/testdata/import/csv/synthetic/random/sample-15.csv b/testdata/import/csv/synthetic/random/sample-15.csv new file mode 100644 index 00000000..d28111c1 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-15.csv @@ -0,0 +1,14 @@ +record,patient_ref,visit_reason,concern,provider,status,phase,date,follow_up,note,note +REC-001,PT-0091,Annual physical,routine check,Dr. Mensah,open,intake,2026-05-22,2027-05-22,BP slightly elevated,recheck in 6 months +REC-002,PT-0102,Knee pain,mobility,Dr. Lowe,active,treatment,2026-05-18,2026-06-01,Referred to PT,ice and rest +REC-003,PT-0078,Follow-up,medication review,Dr. Mensah,closed,complete,2026-05-10,,Dosage adjusted,tolerating well +REC-004,PT-0115,Headaches,neurological,Dr. Lowe,active,diagnostics,2026-05-20,2026-05-27,Ordered imaging,track frequency +REC-005,PT-0091,Lab results,follow-up,Dr. Mensah,open,review,2026-05-21,2026-05-28,Cholesterol borderline,discuss diet +REC-006,PT-0133,Skin rash,dermatology,Dr. Lowe,closed,complete,2026-05-12,,Prescribed cream,resolved +REC-007,PT-0102,Physical therapy,mobility,Dr. Mensah,active,treatment,2026-05-19,2026-06-09,Progressing well,3 of 8 sessions +REC-008,PT-0149,Allergy testing,respiratory,Dr. Lowe,open,intake,2026-05-23,2026-06-06,Panel ordered,avoid known triggers +REC-009,PT-0078,Vaccination,preventive,Dr. Mensah,closed,complete,2026-05-08,,Flu shot given,no reaction +REC-010,PT-0115,Imaging review,neurological,Dr. Lowe,active,review,2026-05-27,2026-06-03,Results normal,reassuring +REC-011,PT-0160,Sleep issues,wellness,Dr. Mensah,open,intake,2026-05-22,2026-06-05,Sleep log requested,limit caffeine +REC-012,PT-0133,Wellness visit,routine check,Dr. Lowe,active,intake,2026-05-24,2026-11-24,All normal,annual reminder set +REC-013,PT-0149,Diet consult,nutrition,Dr. Mensah,open,treatment,2026-05-25,2026-06-15,Meal plan drafted,follow up monthly diff --git a/testdata/import/csv/synthetic/random/sample-16.csv b/testdata/import/csv/synthetic/random/sample-16.csv new file mode 100644 index 00000000..e743a6e7 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-16.csv @@ -0,0 +1,14 @@ +task_id,activity,program,coordinator,volunteers,shift_date,hours,status,notes +1,Food bank sorting,Hunger Relief,coord@example.org,"Dana Reyes, Sam Lee, Mia Cho",2026-05-24,4,scheduled,"Sort donations by type, check expiry dates" +2,Park cleanup,Green Spaces,Bea Nolan,"Tom Park, Liz Hale",2026-05-25,3,scheduled,"Bring gloves and bags, meet at the north gate" +3,Tutoring session,Youth Mentoring,coord@example.org,"Raj Patel",2026-05-22,2,confirmed,"Math focus, grade 7 students" +4,Soup kitchen,Hunger Relief,Bea Nolan,"Dana Reyes, Omar Diaz, Nina Holt",2026-05-23,5,confirmed,"Serving line 11am-2pm, kitchen prep before" +5,Shelter intake,Housing Support,coord@example.org,"Sam Lee",2026-05-26,4,scheduled,Help new residents with paperwork +6,Beach cleanup,Green Spaces,Bea Nolan,"Mia Cho, Tom Park, Liz Hale, Raj Patel",2026-05-30,3,scheduled,"Coastal stretch, recycling separated on site" +7,Senior visits,Community Care,coord@example.org,"Nina Holt",2026-05-22,2,confirmed,"Companionship visits, 4 residents" +8,Clothing drive,Housing Support,Bea Nolan,"Dana Reyes, Omar Diaz",2026-05-28,4,scheduled,"Sort and fold, label by size" +9,Reading hour,Youth Mentoring,coord@example.org,"Liz Hale, Mia Cho",2026-05-27,1,confirmed,"Library room B, picture books" +10,Garden build,Green Spaces,Bea Nolan,"Tom Park, Raj Patel",2026-06-01,6,scheduled,"Build raised beds, soil delivery at 9am" +11,Meal delivery,Hunger Relief,coord@example.org,"Sam Lee, Nina Holt",2026-05-24,3,confirmed,"Route covers 12 homes, hot meals" +12,Fundraiser setup,Community Care,Bea Nolan,"Dana Reyes",2026-05-29,2,scheduled,"Set up tables and signage, gala prep" +13,Phone bank,Community Care,coord@example.org,"Omar Diaz, Liz Hale",2026-05-31,3,scheduled,"Call script provided, log responses" diff --git a/testdata/import/csv/synthetic/random/sample-17.csv b/testdata/import/csv/synthetic/random/sample-17.csv new file mode 100644 index 00000000..14878407 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-17.csv @@ -0,0 +1,15 @@ +item,type,list,started,finished,rating,status,tags,source +The Long Game,book,reading,15 May 2026,,,reading,strategy;career,library +How Trees Talk,article,reading,20 May 2026,21 May 2026,4,done,nature,blog +Intro to Rust,course,learn,01 May 2026,,,reading,programming;rust,online +The Quiet Sea,book,reading,,,,queued,fiction,bookstore +Mindful Mornings,book,reading,10 May 2026,18 May 2026,5,done,wellness;habits,gift +Async Patterns,article,learn,22 May 2026,,,reading,programming,newsletter +Salt and Stone,book,reading,,,,queued,fiction, +Negotiation 101,course,learn,05 May 2026,19 May 2026,3,done,career,online +The Map of Time,book,reading,12 May 2026,,,reading,scifi,library +Better Sleep Guide,article,reading,08 May 2026,08 May 2026,4,done,health,blog +Garden Design,book,learn,,,,queued,hobby;home, +Deep Work,book,reading,03 May 2026,16 May 2026,5,done,productivity,gift +Watercolor Basics,course,learn,18 May 2026,,,reading,art;hobby,online +The Ancient City,book,reading,,,,abandoned,history,library diff --git a/testdata/import/csv/synthetic/random/sample-18.csv b/testdata/import/csv/synthetic/random/sample-18.csv new file mode 100644 index 00000000..f313f785 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-18.csv @@ -0,0 +1,15 @@ +bug,title,component,severity,status,reporter,assignee,blocked_by,blocks,label,label,steps +BUG-3001,Crash on export,reporting,critical,open,qa@example.com,maya@example.org,,BUG-3005,regression,p0,Open report > click export > app crashes +BUG-3002,Wrong total in cart,checkout,high,in progress,user@example.org,Tom Becker,BUG-3001,,frontend,p1,Add 3 items > totals show 2 items only +BUG-3003,Slow search,search,medium,open,qa@example.com,priya@example.com,,,backend,p2,Search any term > 6s response time +BUG-3004,Broken link in email,notifications,low,open,user@example.org,maya@example.org,,,content,p3,Click reset link in email > 404 page +BUG-3005,Login loop,auth,critical,in progress,qa@example.com,Tom Becker,BUG-3001,BUG-3008,regression,p0,Login > redirected to login again +BUG-3006,Image not loading,profile,medium,closed,user@example.org,priya@example.com,,,frontend,p2,Upload avatar > shows broken image icon +BUG-3007,Timezone off by one,calendar,high,open,qa@example.com,maya@example.org,,,backend,p1,Create event 3pm > shows 2pm for others +BUG-3008,Duplicate notifications,notifications,medium,open,user@example.org,Tom Becker,BUG-3005,,backend,p2,Trigger action once > two emails sent +BUG-3009,Form loses data,forms,high,in progress,qa@example.com,priya@example.com,,,frontend,p1,Fill form > navigate back > fields empty +BUG-3010,Memory leak,worker,critical,open,user@example.org,maya@example.org,,,performance,p0,Run import > memory grows unbounded +BUG-3011,Tooltip cut off,ui,low,closed,qa@example.com,Tom Becker,,,frontend,p3,Hover icon near edge > tooltip clipped +BUG-3012,API returns 500,api,critical,open,user@example.org,priya@example.com,BUG-3010,,backend,p0,POST to /v2/items with empty body > 500 +BUG-3013,Sort order wrong,reporting,medium,open,qa@example.com,maya@example.org,,,frontend,p2,Sort by date > order is reversed +BUG-3014,Password reset fails,auth,high,in progress,user@example.org,Tom Becker,BUG-3005,,backend,p1,Request reset > email never arrives diff --git a/testdata/import/csv/synthetic/random/sample-19.csv b/testdata/import/csv/synthetic/random/sample-19.csv new file mode 100644 index 00000000..92fbd6cb --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-19.csv @@ -0,0 +1,14 @@ +box;item;room;destination_room;status;state;date;done_by +101;Books;office;study;packed;sealed;2026-05-20;Alex +102;Winter coats;bedroom;bedroom;packed;labeled;2026-05-21;Jordan +103;Kitchenware;kitchen;kitchen;in progress;open;2026-05-22;Casey +104;Photo albums;living room;study;packed;sealed;2026-05-19;Alex +105;Tools;garage;garage;not started;empty;;Jordan +106;Bedding;bedroom;guest room;packed;labeled;2026-05-21;Casey +107;Dishes;kitchen;kitchen;in progress;open;2026-05-22;Alex +108;Electronics;office;study;packed;sealed;2026-05-20;Jordan +109;Toys;living room;kids room;not started;empty;;Casey +110;Bathroom supplies;bathroom;bathroom;packed;labeled;2026-05-21;Alex +111;Pantry goods;kitchen;kitchen;in progress;open;2026-05-22;Jordan +112;Artwork;living room;living room;not started;empty;;Casey +113;Office supplies;office;study;packed;sealed;2026-05-20;Alex diff --git a/testdata/import/csv/synthetic/random/sample-20.csv b/testdata/import/csv/synthetic/random/sample-20.csv new file mode 100644 index 00000000..504cab9e --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-20.csv @@ -0,0 +1,14 @@ +post_id,title,channel,author,publish_date,status,brief,asset_url,utm +post-spring-sale,Spring Sale Launch,instagram,social@example.com,2026-05-24,scheduled,"Carousel of 5 products, bright colors, end with a CTA to shop",https://example.com/assets/spring-sale.png,https://example.com/?utm_source=ig&utm_campaign=spring +post-howto-thread,How-To Thread,twitter,Mara Quill,2026-05-22,published,"7-tweet thread, one tip per tweet, link to blog at the end",https://example.com/assets/howto.png,https://example.com/?utm_source=tw&utm_campaign=howto +post-team-spotlight,Team Spotlight,linkedin,social@example.com,2026-05-26,draft,"Profile of an engineer, quote pulled from the interview, headshot",https://example.com/assets/spotlight.jpg,https://example.com/?utm_source=li&utm_campaign=team +post-recipe-reel,Recipe Reel,instagram,Mara Quill,2026-05-28,scheduled,"30s cooking reel, captions on, trending audio",https://example.com/assets/recipe.mp4,https://example.com/?utm_source=ig&utm_campaign=recipe +post-blog-promo,Blog Promo,facebook,social@example.com,2026-05-23,published,"Link post, hook in first line, image from the article",https://example.com/assets/blog-promo.png,https://example.com/?utm_source=fb&utm_campaign=blog +post-poll-question,Audience Poll,twitter,Mara Quill,2026-05-25,scheduled,"Single poll, 4 options, ask which feature they want next",https://example.com/assets/poll.png,https://example.com/?utm_source=tw&utm_campaign=poll +post-case-study,Case Study,linkedin,social@example.com,2026-05-30,draft,"Customer results, 3 key metrics, quote and logo",https://example.com/assets/case.jpg,https://example.com/?utm_source=li&utm_campaign=case +post-behind-scenes,Behind the Scenes,instagram,Mara Quill,2026-05-27,scheduled,"Stories set, office tour, 6 frames with stickers",https://example.com/assets/bts.mp4,https://example.com/?utm_source=ig&utm_campaign=bts +post-faq-thread,FAQ Thread,twitter,social@example.com,2026-05-29,draft,"Answer top 5 questions, link to help center",https://example.com/assets/faq.png,https://example.com/?utm_source=tw&utm_campaign=faq +post-event-invite,Event Invite,facebook,Mara Quill,2026-05-31,scheduled,"Event graphic, date and time clear, RSVP link",https://example.com/assets/event.png,https://example.com/?utm_source=fb&utm_campaign=event +post-product-tip,Product Tip,linkedin,social@example.com,2026-05-22,published,"Short tip with a GIF, one practical takeaway",https://example.com/assets/tip.gif,https://example.com/?utm_source=li&utm_campaign=tip +post-quote-card,Quote Card,instagram,Mara Quill,2026-05-26,draft,"Single image, founder quote, branded template",https://example.com/assets/quote.png,https://example.com/?utm_source=ig&utm_campaign=quote +post-newsletter,Newsletter Teaser,twitter,social@example.com,2026-05-24,scheduled,"Tease the issue, 3 bullet highlights, subscribe link",https://example.com/assets/news.png,https://example.com/?utm_source=tw&utm_campaign=news diff --git a/testdata/import/csv/synthetic/random/sample-21.csv b/testdata/import/csv/synthetic/random/sample-21.csv new file mode 100644 index 00000000..37a56264 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-21.csv @@ -0,0 +1,15 @@ +asset_tag,device,location,owner,status,last_service,next_service,manual_url,serial,firmware,tag,tag +AST-0001,Dell Latitude 7440,Desk 12,maya@example.com,active,2026-03-10,2026-09-10,https://example.com/manuals/latitude7440,SN-4471299,1.8.2,laptop,issued +AST-0002,HP LaserJet Pro,Print room,it@example.org,active,2026-04-01,2026-10-01,https://example.com/manuals/hplaserjet,SN-9920184,3.1.0,printer,shared +AST-0003,Cisco Switch 2960,Server closet,it@example.org,active,2026-02-15,2026-08-15,https://example.com/manuals/cisco2960,SN-1100765,15.2.7,network,rack +AST-0004,MacBook Pro 14,Desk 5,Tom Lin,active,2026-05-01,2026-11-01,https://example.com/manuals/mbp14,SN-7781043,14.4.1,laptop,issued +AST-0005,Logitech Webcam,Meeting Room A,it@example.org,active,2026-01-20,2026-07-20,https://example.com/manuals/logiwebcam,SN-3349821,2.0.5,peripheral,shared +AST-0006,APC UPS 1500,Server closet,it@example.org,maintenance,2026-04-22,2026-05-30,https://example.com/manuals/apcups,SN-5567120,6.2.1,power,rack +AST-0007,Dell Monitor U2723,Desk 12,maya@example.com,active,2026-03-10,2026-09-10,https://example.com/manuals/u2723,SN-8812047,1.0.3,monitor,issued +AST-0008,iPad Air,Reception,Tom Lin,active,2026-02-28,2026-08-28,https://example.com/manuals/ipadair,SN-2204918,17.5.0,tablet,shared +AST-0009,Ubiquiti AP,Ceiling F2,it@example.org,active,2026-01-10,2026-07-10,https://example.com/manuals/ubiquiti,SN-6690332,6.6.55,network,ceiling +AST-0010,HP EliteDesk,Desk 8,maya@example.com,retired,2025-11-05,,https://example.com/manuals/elitedesk,SN-4471001,2.4.0,desktop,disposed +AST-0011,Brother Scanner,Print room,it@example.org,active,2026-03-30,2026-09-30,https://example.com/manuals/brother,SN-1192847,1.5.2,scanner,shared +AST-0012,Lenovo ThinkPad,Desk 3,Tom Lin,active,2026-05-05,2026-11-05,https://example.com/manuals/thinkpad,SN-7723910,1.9.0,laptop,issued +AST-0013,Polycom Phone,Meeting Room B,it@example.org,maintenance,2026-04-18,2026-05-28,https://example.com/manuals/polycom,SN-3301847,4.0.12,phone,room +AST-0014,Synology NAS,Server closet,it@example.org,active,2026-02-01,2026-08-01,https://example.com/manuals/synology,SN-9981023,7.2.1,storage,rack diff --git a/testdata/import/csv/synthetic/random/sample-22.csv b/testdata/import/csv/synthetic/random/sample-22.csv new file mode 100644 index 00000000..b7ba8533 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-22.csv @@ -0,0 +1,15 @@ +id,item,task,category,owner,status,stage,date,deadline,vendor_notes,budget +W01,Venue,Book ceremony venue,venue,bride,booked,confirmed,03-12-2025,05-22-2026,"Garden Pavilion, deposit paid, balance due 30 days before",8000 +W02,Catering,Finalize menu,food,groom,in progress,tasting,05-22-2026,06-15-2026,"Tasting scheduled, vegetarian and gluten-free options needed",6500 +W03,Photography,Sign photographer,media,bride,booked,confirmed,02-28-2026,07-01-2026,"8 hour package, engagement shoot included",3200 +W04,Flowers,Choose arrangements,decor,planner,in progress,proposal,05-20-2026,06-20-2026,"Seasonal blooms, centerpieces for 12 tables",2400 +W05,Music,Hire band,entertainment,groom,not started,research,,07-10-2026,"Comparing band vs DJ, need quotes",2800 +W06,Invitations,Send invites,stationery,bride,done,sent,04-10-2026,04-30-2026,"Printed and mailed, RSVP via website",900 +W07,Cake,Order cake,food,planner,in progress,tasting,05-22-2026,07-15-2026,"Three tiers, lemon and vanilla, fondant",750 +W08,Attire,Final dress fitting,attire,bride,in progress,fitting,06-01-2026,07-20-2026,"Second fitting booked, alterations underway",2200 +W09,Transport,Arrange shuttle,logistics,groom,not started,research,,07-25-2026,"Shuttle for out-of-town guests, two trips",1100 +W10,Officiant,Confirm officiant,ceremony,planner,booked,confirmed,03-15-2026,07-30-2026,"Rehearsal included, custom vows discussed",500 +W11,Rentals,Reserve tables and chairs,decor,planner,in progress,proposal,05-18-2026,07-05-2026,"Chiavari chairs, linens in ivory",1800 +W12,Hotel block,Reserve room block,logistics,bride,done,confirmed,04-05-2026,06-05-2026,"20 rooms held, group rate confirmed",0 +W13,Favors,Order guest favors,decor,groom,not started,research,,07-12-2026,"Local honey jars idea, need supplier",600 +W14,Day-of timeline,Build schedule,planning,planner,in progress,draft,05-21-2026,08-01-2026,"Coordinating with all vendors, buffer time added",0 diff --git a/testdata/import/csv/synthetic/random/sample-23.csv b/testdata/import/csv/synthetic/random/sample-23.csv new file mode 100644 index 00000000..d82eac62 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-23.csv @@ -0,0 +1,13 @@ +grant_id milestone workstream pi depends_on due status amount +G-2026-01 Literature review Phase 1 Dr. Okafor 2026-06-30 in progress 0 +G-2026-02 IRB approval Phase 1 Dr. Okafor G-2026-01 2026-07-15 pending 0 +G-2026-03 Participant recruitment Phase 2 nguyen@example.org G-2026-02 2026-08-30 not started 5000 +G-2026-04 Data collection Phase 2 nguyen@example.org G-2026-03 2026-11-30 not started 18000 +G-2026-05 Equipment purchase Setup patel@example.com 2026-06-15 approved 24000 +G-2026-06 Pilot study Phase 1 Dr. Okafor G-2026-05 2026-07-30 in progress 8000 +G-2026-07 Data analysis Phase 3 nguyen@example.org G-2026-04 2027-02-28 not started 6000 +G-2026-08 Interim report Reporting patel@example.com G-2026-06 2026-09-15 not started 0 +G-2026-09 Manuscript draft Phase 3 Dr. Okafor G-2026-07 2027-04-30 not started 0 +G-2026-10 Conference presentation Dissemination nguyen@example.org G-2026-09 2027-06-15 not started 3000 +G-2026-11 Final report Reporting patel@example.com G-2026-09 2027-05-31 not started 0 +G-2026-12 Budget reconciliation Admin patel@example.com 2027-06-30 not started 0 diff --git a/testdata/import/csv/synthetic/random/sample-24.csv b/testdata/import/csv/synthetic/random/sample-24.csv new file mode 100644 index 00000000..19179e15 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-24.csv @@ -0,0 +1,15 @@ +property,unit,unit,manager,tenant,status,issue,reported,rent,lease_end,note +Maple Court,4A,residential,mgr@example.com,Jordan Pike,occupied,Dishwasher leak,2026-05-20,1450,2026-12-31,Tenant since 2024 +Maple Court,4B,residential,mgr@example.com,leah@example.org,occupied,,2026-05-01,1500,2027-03-15,Renewed last month +Maple Court,4C,residential,Rosa Mendez,,vacant,Repaint needed,2026-05-10,0,,Listing next week +Oak Plaza,201,commercial,mgr@example.com,Brightwave Inc,occupied,HVAC noise,2026-05-18,3200,2028-06-30,Long-term lease +Oak Plaza,202,commercial,Rosa Mendez,Cobalt Labs,occupied,,2026-04-15,2900,2027-09-30, +Oak Plaza,203,commercial,mgr@example.com,,vacant,,2026-05-05,0,,Build-out in progress +Birch Lane,1,residential,Rosa Mendez,Sam Whitfield,occupied,Window won't close,2026-05-21,1200,2026-08-31,Lease ending soon +Birch Lane,2,residential,mgr@example.com,nina@example.com,occupied,,2026-05-12,1250,2027-01-31,Quiet tenant +Birch Lane,3,residential,Rosa Mendez,Omar Diaz,occupied,Heater out,2026-05-22,1180,2026-11-30,Urgent repair needed +Cedar Tower,15F,residential,mgr@example.com,,vacant,Carpet replacement,2026-05-08,0,,Showing Saturday +Cedar Tower,16F,residential,Rosa Mendez,grace@example.org,occupied,,2026-05-03,2100,2027-05-31,Premium unit +Cedar Tower,17F,residential,mgr@example.com,Theo Vance,occupied,Garbage disposal jam,2026-05-19,2050,2026-10-31, +Pine Court,A,residential,Rosa Mendez,Lila Frost,occupied,,2026-04-28,1350,2027-02-28,Pays early +Pine Court,B,residential,mgr@example.com,,vacant,Plumbing inspection,2026-05-15,0,,Inspection scheduled diff --git a/testdata/import/csv/synthetic/random/sample-25.csv b/testdata/import/csv/synthetic/random/sample-25.csv new file mode 100644 index 00000000..ee9a086c --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-25.csv @@ -0,0 +1,14 @@ +campaign,donor,ask_amount,pledged,status,owner,follow_up,notes,channel +Spring Appeal,Anonymous,5000,5000,closed,grants@example.org,,"Wired in full, send thank-you letter",mail +Spring Appeal,Brightwave Foundation,25000,10000,pledged,Mara Quill,2026-06-01,"Partial pledge, rest pending board vote",email +Capital Campaign,Cobalt Labs,50000,,prospect,grants@example.org,2026-05-30,"Intro call went well, sending proposal",phone +Spring Appeal,J. Rivera,1000,1000,closed,Mara Quill,,Recurring monthly donor,online +Capital Campaign,Lumen Group,100000,,prospect,grants@example.org,2026-06-10,"Major gift potential, multiple meetings needed",in person +Spring Appeal,Anonymous,500,500,closed,Mara Quill,,,online +Year-End Drive,Harbor Realty,15000,15000,pledged,grants@example.org,2026-12-01,"Annual gift, matched by employer",email +Capital Campaign,K. Osei,75000,25000,pledged,Mara Quill,2026-07-01,"Generous first gift, cultivating for more",in person +Spring Appeal,Nova Media,3000,,prospect,grants@example.org,2026-05-28,"Sponsorship interest, send packet",email +Year-End Drive,Anonymous,2000,2000,closed,Mara Quill,,,mail +Capital Campaign,Pinnacle Supply,40000,,declined,grants@example.org,,"Not this cycle, revisit next year",phone +Spring Appeal,Forge Advisors,8000,8000,closed,Mara Quill,,Quick yes after one call,email +Year-End Drive,Vela Partners,12000,6000,pledged,grants@example.org,2026-11-15,"Half now, half in December",in person diff --git a/testdata/import/csv/synthetic/random/sample-26.csv b/testdata/import/csv/synthetic/random/sample-26.csv new file mode 100644 index 00000000..72e0edd8 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-26.csv @@ -0,0 +1,14 @@ +epic_id,initiative,theme,owner,target,status,depends_on,spec_url,updated_at +EPIC-01,Self-serve onboarding,Growth,pm@example.com,Q3 2026,in progress,,https://example.com/specs/epic-01,2026-05-22T14:30:00Z +EPIC-02,Billing revamp,Monetization,Rae Donovan,Q4 2026,planned,EPIC-01,https://example.com/specs/epic-02,2026-05-20T09:15:00Z +EPIC-03,Mobile app v2,Platform,pm@example.com,Q3 2026,in progress,,https://example.com/specs/epic-03,2026-05-21T17:45:00Z +EPIC-04,Enterprise SSO,Security,security@example.org,Q4 2026,planned,EPIC-02,https://example.com/specs/epic-04,2026-05-19T11:00:00Z +EPIC-05,Analytics dashboard,Insights,Rae Donovan,Q3 2026,in progress,EPIC-01,https://example.com/specs/epic-05,2026-05-22T08:20:00Z +EPIC-06,API v3,Platform,pm@example.com,Q1 2027,planned,EPIC-03,https://example.com/specs/epic-06,2026-05-18T13:30:00Z +EPIC-07,Notification center,Engagement,Rae Donovan,Q3 2026,shipped,,https://example.com/specs/epic-07,2026-05-10T16:00:00Z +EPIC-08,Audit logging,Security,security@example.org,Q4 2026,planned,EPIC-04,https://example.com/specs/epic-08,2026-05-17T10:45:00Z +EPIC-09,Localization,Growth,pm@example.com,Q1 2027,backlog,,https://example.com/specs/epic-09,2026-05-15T12:00:00Z +EPIC-10,Performance overhaul,Platform,Rae Donovan,Q3 2026,in progress,EPIC-06,https://example.com/specs/epic-10,2026-05-22T15:10:00Z +EPIC-11,Referral program,Growth,pm@example.com,Q4 2026,planned,EPIC-01,https://example.com/specs/epic-11,2026-05-16T14:25:00Z +EPIC-12,Data export,Insights,Rae Donovan,Q3 2026,shipped,EPIC-05,https://example.com/specs/epic-12,2026-05-12T09:50:00Z +EPIC-13,Role-based access,Security,security@example.org,Q4 2026,backlog,EPIC-04,https://example.com/specs/epic-13,2026-05-14T18:05:00Z diff --git a/testdata/import/csv/synthetic/random/sample-27.csv b/testdata/import/csv/synthetic/random/sample-27.csv new file mode 100644 index 00000000..ffafb25f --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-27.csv @@ -0,0 +1,15 @@ +bed,zone,zone,plant,task,phase,status,planted,sun,water_freq +Bed 1,north,vegetable,Tomatoes,Stake plants,growing,active,2026-04-15,full,daily +Bed 1,north,vegetable,Basil,Pinch tops,growing,active,2026-04-20,full,daily +Bed 2,south,herb,Rosemary,,established,active,2025-09-10,full,weekly +Bed 2,south,herb,Thyme,Trim back,established,active,2025-09-10,full,weekly +Bed 3,east,flower,Marigolds,Deadhead,blooming,active,2026-04-01,partial,every 2 days +Bed 3,east,flower,Zinnias,Sow seeds,seeding,planned,,full,every 2 days +Bed 4,west,vegetable,Lettuce,Harvest,mature,active,2026-03-20,partial,daily +Bed 4,west,vegetable,Spinach,Harvest,mature,active,2026-03-20,partial,daily +Bed 5,north,vegetable,Peppers,Fertilize,growing,active,2026-04-25,full,every 2 days +Bed 5,north,vegetable,Cucumbers,Add trellis,growing,active,2026-04-25,full,daily +Bed 6,south,fruit,Strawberries,Net against birds,fruiting,active,2025-10-05,full,every 2 days +Bed 7,east,flower,Sunflowers,Sow seeds,seeding,planned,,full,every 3 days +Bed 8,west,herb,Mint,Contain spread,established,active,2025-08-15,partial,every 2 days +Bed 8,west,herb,Cilantro,Resow,bolted,needs attention,2026-03-10,partial,daily diff --git a/testdata/import/csv/synthetic/random/sample-28.csv b/testdata/import/csv/synthetic/random/sample-28.csv new file mode 100644 index 00000000..3f9d21e3 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-28.csv @@ -0,0 +1,20 @@ +ticket,subject,channel,customer,agent,priority,status,opened,link,thread +T-100234,Cannot reset password,email,fiona@example.org,agent1@example.com,high,open,2026-05-22,https://example.com/support/100234,"Customer: I can't reset my password. +Agent: Can you confirm the email on file? +Customer: yes it's the one I'm writing from" +T-100235,Billed twice,chat,Greg Holloway,Nadia Improta,urgent,open,2026-05-22,https://example.com/support/100235,"Customer: I was charged twice this month, please refund one" +T-100236,Feature request,email,helena@example.com,agent1@example.com,low,open,2026-05-21,https://example.com/support/100236,"Customer: Would love a dark mode, any plans?" +T-100237,App crashes on startup,phone,Ivan Petrov,Nadia Improta,high,in progress,2026-05-20,https://example.com/support/100237,"Customer reports crash on launch, Android 14. +Agent: collected logs, escalated to engineering" +T-100238,How to export data,chat,jade@example.org,agent1@example.com,normal,resolved,2026-05-19,https://example.com/support/100238,"Customer: where is the export button? Agent: Settings > Data > Export" +T-100239,Wrong shipping address,email,Karl Mendes,Nadia Improta,high,open,2026-05-22,https://example.com/support/100239,"Customer: my order is going to my old address, can you update it?" +T-100240,Login email not arriving,chat,lara@example.com,agent1@example.com,high,in progress,2026-05-21,https://example.com/support/100240,"Customer: no magic link emails. Agent: checking spam filters, whitelisting our domain" +T-100241,Discount code invalid,email,Mo Farah,Nadia Improta,normal,resolved,2026-05-18,https://example.com/support/100241,"Customer: SAVE20 doesn't work. Agent: code expired, issued a new one" +T-100242,Account deletion request,email,nora@example.org,agent1@example.com,normal,open,2026-05-22,https://example.com/support/100242,"Customer: please delete my account and all data" +T-100243,Slow loading,chat,Owen Park,Nadia Improta,low,open,2026-05-22,https://example.com/support/100243,"Customer: the dashboard is really slow today" +T-100244,Cannot upload file,phone,Petra Voss,agent1@example.com,high,in progress,2026-05-21,https://example.com/support/100244,"Customer: uploads fail at 50%. +Agent: asked for file size and type, awaiting reply" +T-100245,Subscription cancel,email,Quinn Lee,Nadia Improta,normal,resolved,2026-05-17,https://example.com/support/100245,"Customer: cancel my plan. Agent: cancelled, confirmed end date" +T-100246,Two-factor not working,chat,rita@example.com,agent1@example.com,urgent,open,2026-05-22,https://example.com/support/100246,"Customer: codes are rejected. Agent: checking time sync on device" +T-100247,Refund status,email,Sven Holm,Nadia Improta,normal,open,2026-05-20,https://example.com/support/100247,"Customer: when will my refund arrive? Agent: 5-7 business days from the 18th" +T-100248,Typo in invoice,email,tara@example.org,agent1@example.com,low,resolved,2026-05-16,https://example.com/support/100248,"Customer: company name misspelled. Agent: corrected and reissued" diff --git a/testdata/import/csv/synthetic/random/sample-29.csv b/testdata/import/csv/synthetic/random/sample-29.csv new file mode 100644 index 00000000..08ac55a3 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-29.csv @@ -0,0 +1,15 @@ +trip,item,category,status,when,booked,confirmation,cost,notes +Portugal,Flights,transport,done,2026-07-01,yes,AB12CD,820,Window seats +Portugal,Hotel Lisbon,lodging,done,Jul 2026,yes,HTL-99201,640,3 nights +Portugal,Rental car,transport,researching,in 3 days,no,,,Compare agencies +Portugal,Day trip Sintra,activity,planned,2026-07-04,no,,, +Portugal,Travel insurance,admin,todo,next week,no,,45, +Portugal,Lisbon food tour,activity,planned,Jul 2026,no,,60,Book ahead +Japan,Flights,transport,researching,Sep 2026,no,,,Flexible dates +Japan,JR Pass,transport,todo,next month,no,,280,7-day pass +Japan,Hotel Tokyo,lodging,planned,2026-09-10,no,,,Near station +Japan,Visa check,admin,done,this week,yes,,0,Not required +Japan,Kyoto ryokan,lodging,researching,Sep 2026,no,,,Traditional inn +Weekend Trip,Cabin booking,lodging,done,next weekend,yes,CBN-4471,210,Pet friendly +Weekend Trip,Groceries,supplies,todo,in 2 days,no,,80, +Weekend Trip,Hiking permits,activity,planned,next weekend,no,,15, diff --git a/testdata/import/csv/synthetic/random/sample-30.csv b/testdata/import/csv/synthetic/random/sample-30.csv new file mode 100644 index 00000000..bf927808 --- /dev/null +++ b/testdata/import/csv/synthetic/random/sample-30.csv @@ -0,0 +1,14 @@ +item_no,agenda_item,committee,owner,owner,status,resolution_ref,depends_on,due,minutes_link +1,Approve prior minutes,governance,chair@example.org,Helen Voss,approved,RES-2026-01,,2026-05-22,https://example.org/minutes/2026-05-22 +2,Q1 financial review,finance,treasurer@example.com,Marco Reyes,in review,RES-2026-02,1,2026-05-22,https://example.org/minutes/2026-05-22 +3,Annual budget approval,finance,treasurer@example.com,Marco Reyes,pending,RES-2026-03,2,2026-06-15,https://example.org/minutes/2026-06-15 +4,Bylaw amendment,governance,chair@example.org,Helen Voss,tabled,RES-2026-04,,2026-06-15,https://example.org/minutes/2026-06-15 +5,New board member vote,nominating,secretary@example.org,Dana Pike,scheduled,RES-2026-05,1,2026-06-15,https://example.org/minutes/2026-06-15 +6,Audit committee report,audit,auditor@example.com,Helen Voss,in review,RES-2026-06,2,2026-06-15,https://example.org/minutes/2026-06-15 +7,Strategic plan update,strategy,chair@example.org,Marco Reyes,in progress,RES-2026-07,,2026-07-20,https://example.org/minutes/2026-07-20 +8,Executive compensation,compensation,secretary@example.org,Dana Pike,pending,RES-2026-08,6,2026-07-20,https://example.org/minutes/2026-07-20 +9,Fundraising goals,development,treasurer@example.com,Helen Voss,approved,RES-2026-09,3,2026-05-22,https://example.org/minutes/2026-05-22 +10,Risk assessment,audit,auditor@example.com,Marco Reyes,in review,RES-2026-10,6,2026-07-20,https://example.org/minutes/2026-07-20 +11,Policy on conflicts,governance,chair@example.org,Dana Pike,pending,RES-2026-11,4,2026-07-20,https://example.org/minutes/2026-07-20 +12,Set next meeting date,governance,secretary@example.org,Helen Voss,approved,RES-2026-12,,2026-05-22,https://example.org/minutes/2026-05-22 +13,Adjourn,governance,chair@example.org,Dana Pike,approved,RES-2026-13,12,2026-05-22,https://example.org/minutes/2026-05-22 From 29f4fdfdb66de5375197691f510381b01d524d3d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 27 May 2026 18:29:54 -0400 Subject: [PATCH 02/10] commands: add CSV import workflow --- .surface | 225 +++++++++++++++ e2e/import.bats | 414 +++++++++++++++++++++++++++ e2e/smoke/smoke_import.bats | 87 ++++++ e2e/test_helper.bash | 3 + internal/cli/root.go | 1 + internal/commands/commands.go | 1 + internal/commands/commands_test.go | 1 + internal/commands/import.go | 445 +++++++++++++++++++++++++++++ 8 files changed, 1177 insertions(+) create mode 100644 e2e/import.bats create mode 100644 e2e/smoke/smoke_import.bats create mode 100644 internal/commands/import.go diff --git a/.surface b/.surface index 372852d3..acec6d3a 100644 --- a/.surface +++ b/.surface @@ -210,6 +210,7 @@ ARG basecamp gauges update 00 ARG basecamp help 00 [command] ARG basecamp hillcharts track 00 ARG basecamp hillcharts untrack 00 +ARG basecamp import inspect 00 ARG basecamp lineup create 00 ARG basecamp lineup create 01 ARG basecamp lineup delete 00 @@ -767,6 +768,15 @@ CMD basecamp hillcharts CMD basecamp hillcharts show CMD basecamp hillcharts track CMD basecamp hillcharts untrack +CMD basecamp import +CMD basecamp import compile +CMD basecamp import execute +CMD basecamp import followup +CMD basecamp import inspect +CMD basecamp import plan +CMD basecamp import preflight +CMD basecamp import repair +CMD basecamp import status CMD basecamp lineup CMD basecamp lineup create CMD basecamp lineup delete @@ -9370,6 +9380,212 @@ FLAG basecamp hillcharts untrack --styled type=bool FLAG basecamp hillcharts untrack --todolist type=string FLAG basecamp hillcharts untrack --todoset type=string FLAG basecamp hillcharts untrack --verbose type=count +FLAG basecamp import --account type=string +FLAG basecamp import --agent type=bool +FLAG basecamp import --cache-dir type=string +FLAG basecamp import --count type=bool +FLAG basecamp import --help type=bool +FLAG basecamp import --hints type=bool +FLAG basecamp import --ids-only type=bool +FLAG basecamp import --in type=string +FLAG basecamp import --jq type=string +FLAG basecamp import --json type=bool +FLAG basecamp import --markdown type=bool +FLAG basecamp import --md type=bool +FLAG basecamp import --no-hints type=bool +FLAG basecamp import --no-stats type=bool +FLAG basecamp import --profile type=string +FLAG basecamp import --project type=string +FLAG basecamp import --quiet type=bool +FLAG basecamp import --stats type=bool +FLAG basecamp import --styled type=bool +FLAG basecamp import --todolist type=string +FLAG basecamp import --verbose type=count +FLAG basecamp import compile --account type=string +FLAG basecamp import compile --agent type=bool +FLAG basecamp import compile --cache-dir type=string +FLAG basecamp import compile --count type=bool +FLAG basecamp import compile --destination type=string +FLAG basecamp import compile --help type=bool +FLAG basecamp import compile --hints type=bool +FLAG basecamp import compile --ids-only type=bool +FLAG basecamp import compile --in type=string +FLAG basecamp import compile --inspection type=string +FLAG basecamp import compile --jq type=string +FLAG basecamp import compile --json type=bool +FLAG basecamp import compile --mapping type=string +FLAG basecamp import compile --markdown type=bool +FLAG basecamp import compile --md type=bool +FLAG basecamp import compile --no-hints type=bool +FLAG basecamp import compile --no-stats type=bool +FLAG basecamp import compile --out type=string +FLAG basecamp import compile --profile type=string +FLAG basecamp import compile --project type=string +FLAG basecamp import compile --quiet type=bool +FLAG basecamp import compile --stats type=bool +FLAG basecamp import compile --styled type=bool +FLAG basecamp import compile --todolist type=string +FLAG basecamp import compile --verbose type=count +FLAG basecamp import execute --account type=string +FLAG basecamp import execute --agent type=bool +FLAG basecamp import execute --approved type=bool +FLAG basecamp import execute --artifact type=string +FLAG basecamp import execute --cache-dir type=string +FLAG basecamp import execute --count type=bool +FLAG basecamp import execute --help type=bool +FLAG basecamp import execute --hints type=bool +FLAG basecamp import execute --ids-only type=bool +FLAG basecamp import execute --in type=string +FLAG basecamp import execute --jq type=string +FLAG basecamp import execute --json type=bool +FLAG basecamp import execute --markdown type=bool +FLAG basecamp import execute --md type=bool +FLAG basecamp import execute --no-hints type=bool +FLAG basecamp import execute --no-stats type=bool +FLAG basecamp import execute --profile type=string +FLAG basecamp import execute --project type=string +FLAG basecamp import execute --quiet type=bool +FLAG basecamp import execute --stats type=bool +FLAG basecamp import execute --styled type=bool +FLAG basecamp import execute --todolist type=string +FLAG basecamp import execute --verbose type=count +FLAG basecamp import followup --account type=string +FLAG basecamp import followup --agent type=bool +FLAG basecamp import followup --artifact type=string +FLAG basecamp import followup --cache-dir type=string +FLAG basecamp import followup --count type=bool +FLAG basecamp import followup --help type=bool +FLAG basecamp import followup --hints type=bool +FLAG basecamp import followup --ids-only type=bool +FLAG basecamp import followup --in type=string +FLAG basecamp import followup --jq type=string +FLAG basecamp import followup --json type=bool +FLAG basecamp import followup --markdown type=bool +FLAG basecamp import followup --md type=bool +FLAG basecamp import followup --no-hints type=bool +FLAG basecamp import followup --no-stats type=bool +FLAG basecamp import followup --out type=string +FLAG basecamp import followup --profile type=string +FLAG basecamp import followup --project type=string +FLAG basecamp import followup --quiet type=bool +FLAG basecamp import followup --reviewed type=bool +FLAG basecamp import followup --stats type=bool +FLAG basecamp import followup --styled type=bool +FLAG basecamp import followup --todolist type=string +FLAG basecamp import followup --verbose type=count +FLAG basecamp import inspect --account type=string +FLAG basecamp import inspect --agent type=bool +FLAG basecamp import inspect --cache-dir type=string +FLAG basecamp import inspect --count type=bool +FLAG basecamp import inspect --help type=bool +FLAG basecamp import inspect --hints type=bool +FLAG basecamp import inspect --ids-only type=bool +FLAG basecamp import inspect --in type=string +FLAG basecamp import inspect --jq type=string +FLAG basecamp import inspect --json type=bool +FLAG basecamp import inspect --markdown type=bool +FLAG basecamp import inspect --md type=bool +FLAG basecamp import inspect --no-hints type=bool +FLAG basecamp import inspect --no-stats type=bool +FLAG basecamp import inspect --profile type=string +FLAG basecamp import inspect --project type=string +FLAG basecamp import inspect --quiet type=bool +FLAG basecamp import inspect --sample-size type=int +FLAG basecamp import inspect --stats type=bool +FLAG basecamp import inspect --styled type=bool +FLAG basecamp import inspect --todolist type=string +FLAG basecamp import inspect --verbose type=count +FLAG basecamp import plan --account type=string +FLAG basecamp import plan --agent type=bool +FLAG basecamp import plan --artifact type=string +FLAG basecamp import plan --cache-dir type=string +FLAG basecamp import plan --count type=bool +FLAG basecamp import plan --destination type=string +FLAG basecamp import plan --help type=bool +FLAG basecamp import plan --hints type=bool +FLAG basecamp import plan --ids-only type=bool +FLAG basecamp import plan --in type=string +FLAG basecamp import plan --inspection type=string +FLAG basecamp import plan --jq type=string +FLAG basecamp import plan --json type=bool +FLAG basecamp import plan --mapping type=string +FLAG basecamp import plan --markdown type=bool +FLAG basecamp import plan --md type=bool +FLAG basecamp import plan --no-hints type=bool +FLAG basecamp import plan --no-stats type=bool +FLAG basecamp import plan --profile type=string +FLAG basecamp import plan --project type=string +FLAG basecamp import plan --quiet type=bool +FLAG basecamp import plan --stats type=bool +FLAG basecamp import plan --styled type=bool +FLAG basecamp import plan --todolist type=string +FLAG basecamp import plan --verbose type=count +FLAG basecamp import preflight --account type=string +FLAG basecamp import preflight --agent type=bool +FLAG basecamp import preflight --artifact type=string +FLAG basecamp import preflight --cache-dir type=string +FLAG basecamp import preflight --count type=bool +FLAG basecamp import preflight --help type=bool +FLAG basecamp import preflight --hints type=bool +FLAG basecamp import preflight --ids-only type=bool +FLAG basecamp import preflight --in type=string +FLAG basecamp import preflight --jq type=string +FLAG basecamp import preflight --json type=bool +FLAG basecamp import preflight --markdown type=bool +FLAG basecamp import preflight --md type=bool +FLAG basecamp import preflight --no-hints type=bool +FLAG basecamp import preflight --no-stats type=bool +FLAG basecamp import preflight --profile type=string +FLAG basecamp import preflight --project type=string +FLAG basecamp import preflight --quiet type=bool +FLAG basecamp import preflight --stats type=bool +FLAG basecamp import preflight --styled type=bool +FLAG basecamp import preflight --todolist type=string +FLAG basecamp import preflight --verbose type=count +FLAG basecamp import repair --account type=string +FLAG basecamp import repair --agent type=bool +FLAG basecamp import repair --artifact type=string +FLAG basecamp import repair --cache-dir type=string +FLAG basecamp import repair --count type=bool +FLAG basecamp import repair --help type=bool +FLAG basecamp import repair --hints type=bool +FLAG basecamp import repair --ids-only type=bool +FLAG basecamp import repair --in type=string +FLAG basecamp import repair --jq type=string +FLAG basecamp import repair --json type=bool +FLAG basecamp import repair --markdown type=bool +FLAG basecamp import repair --md type=bool +FLAG basecamp import repair --no-hints type=bool +FLAG basecamp import repair --no-stats type=bool +FLAG basecamp import repair --profile type=string +FLAG basecamp import repair --project type=string +FLAG basecamp import repair --quiet type=bool +FLAG basecamp import repair --stats type=bool +FLAG basecamp import repair --styled type=bool +FLAG basecamp import repair --todolist type=string +FLAG basecamp import repair --verbose type=count +FLAG basecamp import status --account type=string +FLAG basecamp import status --agent type=bool +FLAG basecamp import status --artifact type=string +FLAG basecamp import status --cache-dir type=string +FLAG basecamp import status --count type=bool +FLAG basecamp import status --help type=bool +FLAG basecamp import status --hints type=bool +FLAG basecamp import status --ids-only type=bool +FLAG basecamp import status --in type=string +FLAG basecamp import status --jq type=string +FLAG basecamp import status --json type=bool +FLAG basecamp import status --markdown type=bool +FLAG basecamp import status --md type=bool +FLAG basecamp import status --no-hints type=bool +FLAG basecamp import status --no-stats type=bool +FLAG basecamp import status --profile type=string +FLAG basecamp import status --project type=string +FLAG basecamp import status --quiet type=bool +FLAG basecamp import status --stats type=bool +FLAG basecamp import status --styled type=bool +FLAG basecamp import status --todolist type=string +FLAG basecamp import status --verbose type=count FLAG basecamp lineup --account type=string FLAG basecamp lineup --agent type=bool FLAG basecamp lineup --cache-dir type=string @@ -16554,6 +16770,15 @@ SUB basecamp hillcharts SUB basecamp hillcharts show SUB basecamp hillcharts track SUB basecamp hillcharts untrack +SUB basecamp import +SUB basecamp import compile +SUB basecamp import execute +SUB basecamp import followup +SUB basecamp import inspect +SUB basecamp import plan +SUB basecamp import preflight +SUB basecamp import repair +SUB basecamp import status SUB basecamp lineup SUB basecamp lineup create SUB basecamp lineup delete diff --git a/e2e/import.bats b/e2e/import.bats new file mode 100644 index 00000000..db7b60a8 --- /dev/null +++ b/e2e/import.bats @@ -0,0 +1,414 @@ +#!/usr/bin/env bats +# import.bats - deterministic CSV import artifact flow + +load test_helper + +teardown_extra() { + stop_import_mock || true +} + +start_import_mock() { + IMPORT_MOCK_DIR="$(mktemp -d)" + IMPORT_MOCK_PORT_FILE="$IMPORT_MOCK_DIR/port" + IMPORT_MOCK_REQUEST_LOG="$IMPORT_MOCK_DIR/requests.jsonl" + export IMPORT_MOCK_REQUEST_LOG + + cat > "$IMPORT_MOCK_DIR/server.py" <<'PY' +import json +import os +import socketserver +from http.server import BaseHTTPRequestHandler + +request_log = os.environ["IMPORT_MOCK_REQUEST_LOG"] +fail_todo_title = os.environ.get("IMPORT_MOCK_FAIL_TODO_TITLE", "") +list_ids = {"Home": 901, "Events": 902} +next_todo_id = 1000 + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + return + + def _read_body(self): + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) if length else b"" + if not raw: + return None, "" + text = raw.decode("utf-8") + try: + return json.loads(text), text + except json.JSONDecodeError: + return None, text + + def _write_json(self, status, payload): + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _record(self, body): + with open(request_log, "a", encoding="utf-8") as f: + f.write(json.dumps({"method": self.command, "path": self.path, "body": body}) + "\n") + + def do_GET(self): + self._record(None) + if self.path == "/99999/projects/12345.json": + self._write_json(200, { + "id": 12345, + "name": "Import Project", + "dock": [{"id": 777, "name": "todoset", "title": "To-dos", "enabled": True}], + }) + return + if self.path == "/99999/todosets/777/todolists.json": + self._write_json(200, []) + return + if self.path.startswith("/99999/todolists/") and self.path.endswith("/todos.json"): + self._write_json(200, []) + return + self._write_json(500, {"error": "unexpected GET", "path": self.path}) + + def do_POST(self): + global next_todo_id + body, raw = self._read_body() + self._record(body if body is not None else raw) + if self.path == "/99999/todosets/777/todolists.json": + name = (body or {}).get("name", "") + todolist_id = list_ids.get(name, 999) + self._write_json(201, { + "id": todolist_id, + "status": "active", + "title": name, + "name": name, + "type": "Todolist", + "url": f"https://3.basecampapi.com/99999/todolists/{todolist_id}.json", + "app_url": f"https://3.basecamp.com/99999/buckets/12345/todolists/{todolist_id}", + "inherits_status": True, + "visible_to_clients": False, + "bucket": {"id": 12345, "name": "Import Project", "type": "Project"}, + "parent": {"id": 777, "title": "To-dos", "type": "Todoset", "url": "", "app_url": ""}, + "creator": {"id": 1, "name": "Tester"} + }) + return + if self.path.startswith("/99999/todolists/") and self.path.endswith("/todos.json"): + content = (body or {}).get("content", "") + if fail_todo_title and content == fail_todo_title: + self._write_json(500, {"error": "configured todo failure", "title": content}) + return + next_todo_id += 1 + self._write_json(201, { + "id": next_todo_id, + "status": "active", + "content": content, + "title": content, + "description": (body or {}).get("description", ""), + "due_on": (body or {}).get("due_on", ""), + "type": "Todo", + "url": f"https://3.basecampapi.com/99999/todos/{next_todo_id}.json", + "app_url": f"https://3.basecamp.com/99999/todos/{next_todo_id}", + "inherits_status": True, + "visible_to_clients": False, + "bucket": {"id": 12345, "name": "Import Project", "type": "Project"}, + "parent": {"id": 901, "title": "List", "type": "Todolist", "url": "", "app_url": ""}, + "creator": {"id": 1, "name": "Tester"} + }) + return + self._write_json(500, {"error": "unexpected POST", "path": self.path}) + +with socketserver.TCPServer(("127.0.0.1", 0), Handler) as httpd: + with open(os.environ["IMPORT_MOCK_PORT_FILE"], "w", encoding="utf-8") as f: + f.write(str(httpd.server_address[1])) + httpd.serve_forever() +PY + + IMPORT_MOCK_PORT_FILE="$IMPORT_MOCK_PORT_FILE" python3 "$IMPORT_MOCK_DIR/server.py" >"$IMPORT_MOCK_DIR/server.log" 2>&1 & + IMPORT_MOCK_PID=$! + + local i=0 + while [[ ! -s "$IMPORT_MOCK_PORT_FILE" ]] && (( i < 50 )); do + sleep 0.1 + i=$((i + 1)) + done + if [[ ! -s "$IMPORT_MOCK_PORT_FILE" ]]; then + cat "$IMPORT_MOCK_DIR/server.log" >&2 || true + return 1 + fi + IMPORT_MOCK_PORT="$(<"$IMPORT_MOCK_PORT_FILE")" + export IMPORT_MOCK_PORT IMPORT_MOCK_PID IMPORT_MOCK_DIR +} + +stop_import_mock() { + if [[ -n "${IMPORT_MOCK_PID:-}" ]]; then + kill "$IMPORT_MOCK_PID" 2>/dev/null || true + wait "$IMPORT_MOCK_PID" 2>/dev/null || true + unset IMPORT_MOCK_PID + fi +} + +write_import_csv() { + cat > tasks.csv <<'CSV' +id,title,notes,list,status,owner,due,link,priority,note,note +T-1,Buy paint,"Get blue, low VOC",Home,todo,alex@example.com,2026-06-01,https://example.com/a,High,"Bring old card","Ask for sample" +T-2,Book venue,Call two places,Events,doing,jamie@example.com,2026-06-03,https://example.com/b,Low,,"Confirm deposit" +CSV +} + +write_mapping_json() { + cat > mapping.json <<'JSON' +{ + "schema_version": 1, + "record_id": { "column_index": 0, "column_name": "id" }, + "title": { "column_index": 1, "column_name": "title" }, + "description": { "column_index": 2, "column_name": "notes" }, + "todolist": { "column_index": 3, "column_name": "list" }, + "status": { "column_index": 4, "column_name": "status" }, + "assignees": { "column_index": 5, "column_name": "owner" }, + "due_on": { "column_index": 6, "column_name": "due" }, + "attachment_urls": [{ "column_index": 7, "column_name": "link" }], + "custom_fields": "all_unmapped_columns" +} +JSON +} + +write_destination_json() { + cat > destination.json <<'JSON' +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} +JSON +} + +compile_import_artifact() { + write_import_csv + write_mapping_json + write_destination_json + + run basecamp import inspect tasks.csv --json --sample-size 2 + assert_success + echo "$output" > inspection.json + + run basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json + assert_success +} + +@test "import inspect profiles CSV facts without writes" { + write_import_csv + + run basecamp import inspect tasks.csv --json --sample-size 2 + assert_success + is_valid_json + assert_json_value ".ok" "true" + assert_json_value ".data.status" "profiled" + assert_json_value ".data.row_count" "2" + assert_json_value ".data.columns[1].name" "title" + assert_json_value ".data.duplicate_headers[0].name" "note" + assert_json_not_null ".data.fingerprint.value" + assert_json_value ".data.role_candidates.title[0].column_index" "1" +} + +@test "import compile writes validated artifact and plan reads it" { + compile_import_artifact + + is_valid_json + assert_json_value ".data.status" "compiled" + assert_json_value ".data.manifest.artifact_format" "basecamp-import-csv-v1" + assert_json_value ".data.manifest.counts.todos" "2" + assert_json_value ".data.manifest.counts.todolists" "2" + + [[ -f basecamp-import/import.json ]] + [[ -f basecamp-import/todos.csv ]] + head -1 basecamp-import/todos.csv | grep -q "source_path,source_row,source_record_id" + grep -q "custom_fields_json" basecamp-import/todos.csv + grep -q '""priority"":""High""' basecamp-import/todos.csv + + run basecamp import plan --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "ready_for_approval" + assert_json_value ".data.counts.todos" "2" + assert_json_value ".data.counts.todolists" "2" + assert_output_contains "Row 1: create todo \\\"Buy paint\\\"" + + run basecamp import status --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "not_executed" + assert_json_value ".data.counts.todos" "2" + + run basecamp import repair --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "not_executed" + + run basecamp import followup --artifact basecamp-import --out followup-import --reviewed --json + assert_failure + assert_output_contains "review_required" +} + +@test "import compile rejects a CSV changed after inspection" { + write_import_csv + write_mapping_json + write_destination_json + + run basecamp import inspect tasks.csv --json + assert_success + echo "$output" > inspection.json + + cat >> tasks.csv <<'CSV' +T-3,Changed after inspect,This row invalidates fingerprint,Later,todo,,2026-06-04,,Medium,, +CSV + + run basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json + assert_failure + assert_output_contains "fingerprint changed" +} + +@test "import plan asks for assignee policy when source has display names" { + cat > tasks.csv <<'CSV' +id,title,owner +1,Call vendor,Alex Rivera +CSV + cat > mapping.json <<'JSON' +{ + "schema_version": 1, + "title": { "column_index": 1 }, + "assignees": { "column_index": 2 } +} +JSON + cat > destination.json <<'JSON' +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "single_todolist", + "todolist_name": "Imported todos" +} +JSON + + run basecamp import inspect tasks.csv --json + assert_success + echo "$output" > inspection.json + + run basecamp import plan --inspection inspection.json --mapping mapping.json --destination destination.json --json + assert_success + is_valid_json + assert_json_value ".data.status" "requires_user_input" + assert_json_value ".data.requires_user_input" "true" + assert_output_contains "confirm_assignee_policy" +} + +@test "import execute requires explicit approval before account or network work" { + compile_import_artifact + + run basecamp import execute --artifact basecamp-import --json + assert_failure + assert_output_contains "--approved required" +} + +configure_import_mock_basecamp() { + export BASECAMP_BASE_URL="http://127.0.0.1:${IMPORT_MOCK_PORT}" + export BASECAMP_ACCOUNT_ID="99999" + export BASECAMP_TOKEN="test-token" +} + +@test "import execute creates todolists and todos against replay server" { + compile_import_artifact + start_import_mock + + configure_import_mock_basecamp + + run basecamp import preflight --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "passed" + + run basecamp import execute --artifact basecamp-import --approved --json + assert_success + is_valid_json + assert_json_value ".data.status" "completed" + assert_json_value ".data.created.todolists" "2" + assert_json_value ".data.created.todos" "2" + + jq -e 'select(.method == "POST" and .path == "/99999/todosets/777/todolists.json" and .body.name == "Home")' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + jq -e 'select(.method == "POST" and .path == "/99999/todosets/777/todolists.json" and .body.name == "Events")' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + jq -e 'select(.method == "POST" and .path == "/99999/todolists/901/todos.json" and .body.content == "Buy paint" and .body.due_on == "2026-06-01" and (.body.description | contains("Get blue, low VOC")))' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + jq -e 'select(.method == "POST" and .path == "/99999/todolists/902/todos.json" and .body.content == "Book venue" and .body.due_on == "2026-06-03" and (.body.description | contains("Confirm deposit")))' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + + [[ -f basecamp-import/execution.json ]] + [[ "$(jq -r '.status' basecamp-import/execution.json)" == "completed" ]] + + run basecamp import status --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "completed" + assert_json_value ".data.execution.created.todos" "2" + assert_json_value ".data.execution.operations[0].op" "create_todolist" + assert_json_value ".data.execution.operations[2].source_row" "1" + + run basecamp import repair --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "completed" + assert_json_value ".data.completed_operations[2].source_row" "1" + + local request_count + request_count="$(wc -l < "$IMPORT_MOCK_REQUEST_LOG")" + + run basecamp import execute --artifact basecamp-import --approved --json + assert_failure + assert_output_contains "execution refuses to run again" + [[ "$(wc -l < "$IMPORT_MOCK_REQUEST_LOG")" == "$request_count" ]] +} + +@test "import followup creates pending-row artifact after failed execution" { + compile_import_artifact + export IMPORT_MOCK_FAIL_TODO_TITLE="Book venue" + start_import_mock + unset IMPORT_MOCK_FAIL_TODO_TITLE + + configure_import_mock_basecamp + + run basecamp import execute --artifact basecamp-import --approved --json + assert_failure + assert_output_contains "configured todo failure" + + run basecamp import status --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "failed" + assert_json_value ".data.execution.created.todos" "1" + assert_json_value ".data.execution.operations[-1].status" "failed" + + run basecamp import repair --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "review_required" + assert_json_value ".data.pending_todos[0].source_row" "2" + assert_json_value ".data.pending_todos[0].title" "Book venue" + + run basecamp import followup --artifact basecamp-import --out followup-import --reviewed --json + assert_success + is_valid_json + assert_json_value ".data.status" "compiled" + assert_json_value ".data.manifest.counts.todos" "1" + assert_json_value ".data.pending_todos[0].source_row" "2" + + run basecamp import plan --artifact followup-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "ready_for_approval" + assert_json_value ".data.counts.todos" "1" + assert_json_value ".data.counts.todolists" "0" + assert_json_value ".data.operations[0].source_row" "2" + assert_json_value ".data.operations[0].title" "Book venue" + + run basecamp import preflight --artifact followup-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "passed" +} diff --git a/e2e/smoke/smoke_import.bats b/e2e/smoke/smoke_import.bats new file mode 100644 index 00000000..3168965c --- /dev/null +++ b/e2e/smoke/smoke_import.bats @@ -0,0 +1,87 @@ +#!/usr/bin/env bats +# smoke_import.bats - Deterministic import artifact commands + +load smoke_helper + +write_smoke_import_files() { + cat > tasks.csv <<'CSV' +id,title,list +1,First,Backlog +2,Second,Doing +CSV + cat > mapping.json <<'JSON' +{ + "schema_version": 1, + "record_id": { "column_index": 0 }, + "title": { "column_index": 1 }, + "todolist": { "column_index": 2 } +} +JSON + cat > destination.json <<'JSON' +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} +JSON +} + +@test "import inspect profiles local CSV" { + write_smoke_import_files + run_smoke basecamp import inspect tasks.csv --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.status' 'profiled' +} + +@test "import compile creates local artifact" { + write_smoke_import_files + basecamp import inspect tasks.csv --json > inspection.json + + run_smoke basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.status' 'compiled' +} + +@test "import plan reads local artifact" { + write_smoke_import_files + basecamp import inspect tasks.csv --json > inspection.json + basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json >/dev/null + + run_smoke basecamp import plan --artifact basecamp-import --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.status' 'ready_for_approval' +} + +@test "import status requires artifact" { + run_smoke basecamp import status --json + assert_failure + assert_output_contains "--artifact required" +} + +@test "import repair requires artifact" { + run_smoke basecamp import repair --json + assert_failure + assert_output_contains "--artifact required" +} + +@test "import followup requires artifact" { + run_smoke basecamp import followup --json + assert_failure + assert_output_contains "--artifact required" +} + +@test "import preflight requires artifact" { + run_smoke basecamp import preflight --json + assert_failure + assert_output_contains "--artifact required" +} + +@test "import execute requires approval" { + run_smoke basecamp import execute --artifact basecamp-import --json + assert_failure + assert_output_contains "--approved required" +} diff --git a/e2e/test_helper.bash b/e2e/test_helper.bash index 38c747b9..0c88c14a 100644 --- a/e2e/test_helper.bash +++ b/e2e/test_helper.bash @@ -42,6 +42,9 @@ setup() { } teardown() { + # Allow test files to define teardown_extra for per-test cleanup. + if type -t teardown_extra &>/dev/null; then teardown_extra; fi + # Restore original environment export HOME="$_ORIG_HOME" cd "$_ORIG_PWD" diff --git a/internal/cli/root.go b/internal/cli/root.go index e38a5680..7e1a4914 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -317,6 +317,7 @@ func Execute() { cmd.AddCommand(commands.NewGaugesCmd()) cmd.AddCommand(commands.NewAssignmentsCmd()) cmd.AddCommand(commands.NewNotificationsCmd()) + cmd.AddCommand(commands.NewImportCmd()) cmd.AddCommand(commands.NewTUICmd()) cmd.AddCommand(commands.NewBonfireCmd()) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 242b3131..daefab8f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -134,6 +134,7 @@ func CommandCategories() []CommandCategory { { Name: "Additional Commands", Commands: []CommandInfo{ + {Name: "import", Category: "additional", Description: "Inspect and import external CSV data", Actions: []string{"inspect", "compile", "plan", "status", "repair", "followup", "preflight", "execute"}}, {Name: "commands", Category: "additional", Description: "List all commands"}, {Name: "completion", Category: "additional", Description: "Generate shell completions", Actions: []string{"bash", "zsh", "fish", "powershell", "refresh", "status"}}, {Name: "tools", Category: "additional", Description: "Manage project dock tools", Actions: []string{"show", "create", "update", "trash", "enable", "disable", "reposition"}}, diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go index 70bd2933..7517fd69 100644 --- a/internal/commands/commands_test.go +++ b/internal/commands/commands_test.go @@ -119,6 +119,7 @@ func buildRootWithAllCommands() *cobra.Command { root.AddCommand(commands.NewGaugesCmd()) root.AddCommand(commands.NewAssignmentsCmd()) root.AddCommand(commands.NewNotificationsCmd()) + root.AddCommand(commands.NewImportCmd()) root.AddCommand(commands.NewTUICmd()) root.AddCommand(commands.NewProfileCmd()) root.AddCommand(commands.NewBonfireCmd()) diff --git a/internal/commands/import.go b/internal/commands/import.go new file mode 100644 index 00000000..6d2d4395 --- /dev/null +++ b/internal/commands/import.go @@ -0,0 +1,445 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/importer" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// NewImportCmd creates the import command group. +func NewImportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import", + Short: "Inspect and import external CSV data", + Long: "Inspect external CSV data and produce deterministic import artifacts for Basecamp.", + } + + cmd.AddCommand( + newImportInspectCmd(), + newImportCompileCmd(), + newImportPlanCmd(), + newImportStatusCmd(), + newImportRepairCmd(), + newImportFollowupCmd(), + newImportPreflightCmd(), + newImportExecuteCmd(), + ) + return cmd +} + +type importWriteClient struct { + cmd *cobra.Command + app *appctx.App + todosetID string +} + +type importPreflightClient struct { + cmd *cobra.Command + app *appctx.App +} + +func (c *importPreflightClient) ExistingTodos(ctx context.Context, todolistID int64) ([]importer.ExistingTodo, error) { + result, err := c.app.Account().Todos().List(ctx, todolistID, &basecamp.TodoListOptions{Limit: -1}) + if err != nil { + return nil, convertSDKError(err) + } + out := make([]importer.ExistingTodo, 0, len(result.Todos)) + for _, todo := range result.Todos { + title := todo.Title + if title == "" { + title = todo.Content + } + out = append(out, importer.ExistingTodo{ID: todo.ID, Title: title}) + } + return out, nil +} + +func (c *importPreflightClient) ExistingTodolists(ctx context.Context, projectID int64) ([]importer.ExistingTodolist, error) { + resolved, err := c.app.Resolve().Todoset(ctx, strconv.FormatInt(projectID, 10), "") + if err != nil { + return nil, err + } + todosetID, err := strconv.ParseInt(resolved.ToolID, 10, 64) + if err != nil { + return nil, output.ErrUsage("Invalid todoset ID") + } + result, err := c.app.Account().Todolists().List(ctx, todosetID, &basecamp.TodolistListOptions{}) + if err != nil { + return nil, convertSDKError(err) + } + out := make([]importer.ExistingTodolist, 0, len(result.Todolists)) + for _, list := range result.Todolists { + name := list.Title + if name == "" { + name = list.Name + } + out = append(out, importer.ExistingTodolist{ID: list.ID, Name: name}) + } + return out, nil +} + +func (c *importWriteClient) CreateProject(ctx context.Context, name string) (int64, error) { + project, err := c.app.Account().Projects().Create(ctx, &basecamp.CreateProjectRequest{Name: name}) + if err != nil { + return 0, convertSDKError(err) + } + return project.ID, nil +} + +func (c *importWriteClient) CreateTodolist(ctx context.Context, projectID int64, name string) (int64, error) { + if c.todosetID == "" { + resolved, err := c.app.Resolve().Todoset(ctx, strconv.FormatInt(projectID, 10), "") + if err != nil { + return 0, err + } + c.todosetID = resolved.ToolID + } + todosetID, err := strconv.ParseInt(c.todosetID, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid todoset ID") + } + todolist, err := c.app.Account().Todolists().Create(ctx, todosetID, &basecamp.CreateTodolistRequest{Name: name}) + if err != nil { + return 0, convertSDKError(err) + } + return todolist.ID, nil +} + +func (c *importWriteClient) CreateTodo(ctx context.Context, todolistID int64, todo importer.ExecutableTodo) (int64, error) { + created, err := c.app.Account().Todos().Create(ctx, todolistID, &basecamp.CreateTodoRequest{Content: todo.Title, Description: todo.Description, DueOn: todo.DueOn}) + if err != nil { + return 0, convertSDKError(err) + } + return created.ID, nil +} + +func newImportStatusCmd() *cobra.Command { + var artifactPath string + + cmd := &cobra.Command{ + Use: "status", + Short: "Show local Basecamp import artifact status", + Long: "Show local Basecamp import artifact status and execution ledger details without reading or writing Basecamp.", + RunE: func(cmd *cobra.Command, args []string) error { + if artifactPath == "" { + return output.ErrUsage("--artifact required") + } + result, err := importer.StatusArtifact(artifactPath) + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), result.Status) + return nil + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Import artifact status: %s", result.Status))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Validated Basecamp import artifact directory") + return cmd +} + +func newImportRepairCmd() *cobra.Command { + var artifactPath string + + cmd := &cobra.Command{ + Use: "repair", + Short: "Review a Basecamp import artifact for recovery", + Long: "Review local Basecamp import artifact execution records and summarize safe recovery state without reading or writing Basecamp.", + RunE: func(cmd *cobra.Command, args []string) error { + if artifactPath == "" { + return output.ErrUsage("--artifact required") + } + result, err := importer.RepairArtifact(artifactPath) + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), result.Status) + return nil + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Import repair status: %s", result.Status))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Validated Basecamp import artifact directory") + return cmd +} + +func newImportFollowupCmd() *cobra.Command { + var artifactPath string + var outDir string + var reviewed bool + + cmd := &cobra.Command{ + Use: "followup", + Short: "Create a reviewed follow-up import artifact", + Long: "Create a fresh local Basecamp import artifact from pending rows after reviewing a failed execution ledger.", + RunE: func(cmd *cobra.Command, args []string) error { + if artifactPath == "" { + return output.ErrUsage("--artifact required") + } + if outDir == "" { + return output.ErrUsage("--out required") + } + if !reviewed { + return output.ErrUsage("--reviewed required") + } + result, err := importer.CreateFollowupArtifact(artifactPath, outDir, importer.FollowupOptions{Reviewed: reviewed}) + if err != nil { + return err + } + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), result.Status) + return nil + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Compiled follow-up artifact with %d pending todos", result.Manifest.Counts.Todos))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Source Basecamp import artifact directory") + cmd.Flags().StringVar(&outDir, "out", "", "Output directory for the follow-up import artifact") + cmd.Flags().BoolVar(&reviewed, "reviewed", false, "Confirm Basecamp state and the repair summary have been reviewed") + return cmd +} + +func newImportPreflightCmd() *cobra.Command { + var artifactPath string + + cmd := &cobra.Command{ + Use: "preflight", + Short: "Check a Basecamp import artifact before execution", + Long: "Check a validated Basecamp import artifact for execution readiness without creating Basecamp records.", + RunE: func(cmd *cobra.Command, args []string) error { + if artifactPath == "" { + return output.ErrUsage("--artifact required") + } + app := appctx.FromContext(cmd.Context()) + if app == nil { + return fmt.Errorf("app not initialized") + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + result, err := importer.PreflightArtifact(cmd.Context(), artifactPath, &importPreflightClient{cmd: cmd, app: app}) + if err != nil { + return err + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Import preflight %s", result.Status))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Validated Basecamp import artifact directory") + return cmd +} + +func newImportExecuteCmd() *cobra.Command { + var artifactPath string + var approved bool + + cmd := &cobra.Command{ + Use: "execute", + Short: "Execute a validated Basecamp import artifact", + Long: "Execute a validated Basecamp import artifact after explicit approval.", + RunE: func(cmd *cobra.Command, args []string) error { + if artifactPath == "" { + return output.ErrUsage("--artifact required") + } + if !approved { + return output.ErrUsage("--approved required") + } + app := appctx.FromContext(cmd.Context()) + if app == nil { + return fmt.Errorf("app not initialized") + } + if err := ensureAccount(cmd, app); err != nil { + return err + } + preflight, err := importer.PreflightArtifact(cmd.Context(), artifactPath, &importPreflightClient{cmd: cmd, app: app}) + if err != nil { + return err + } + if preflight.Status == "blocked" { + return output.ErrUsage(preflight.BlockedMessage()) + } + result, err := importer.ExecuteArtifact(cmd.Context(), artifactPath, &importWriteClient{cmd: cmd, app: app}, importer.ExecuteOptions{Approved: approved}) + if err != nil { + return err + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Imported %d todos", result.Created.Todos))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Validated Basecamp import artifact directory") + cmd.Flags().BoolVar(&approved, "approved", false, "Confirm that the planned import is approved for execution") + return cmd +} + +func newImportCompileCmd() *cobra.Command { + var inspectionPath string + var mappingPath string + var destinationPath string + var outDir string + + cmd := &cobra.Command{ + Use: "compile", + Short: "Compile a validated Basecamp import artifact", + Long: "Compile inspection, mapping, and destination JSON files into a validated Basecamp import CSV artifact.", + RunE: func(cmd *cobra.Command, args []string) error { + if inspectionPath == "" { + return output.ErrUsage("--inspection required") + } + if mappingPath == "" { + return output.ErrUsage("--mapping required") + } + if destinationPath == "" { + return output.ErrUsage("--destination required") + } + if outDir == "" { + return output.ErrUsage("--out required") + } + + inspection, err := importer.ReadInspectionFile(inspectionPath) + if err != nil { + return err + } + mapping, err := importer.ReadMappingFile(mappingPath) + if err != nil { + return err + } + destination, err := importer.ReadDestinationFile(destinationPath) + if err != nil { + return err + } + result, err := importer.CompileArtifact(inspection, mapping, destination, outDir) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), result.Status) + return nil + } + return app.OK(result, output.WithSummary(fmt.Sprintf("Compiled import artifact with %d todos", result.Manifest.Counts.Todos))) + }, + } + + cmd.Flags().StringVar(&inspectionPath, "inspection", "", "Inspection JSON file") + cmd.Flags().StringVar(&mappingPath, "mapping", "", "Confirmed mapping JSON file") + cmd.Flags().StringVar(&destinationPath, "destination", "", "Destination JSON file") + cmd.Flags().StringVar(&outDir, "out", "", "Output directory for the Basecamp import artifact") + return cmd +} + +func newImportPlanCmd() *cobra.Command { + var artifactPath string + var inspectionPath string + var mappingPath string + var destinationPath string + + cmd := &cobra.Command{ + Use: "plan", + Short: "Plan a CSV import", + Long: "Plan a CSV import from a validated artifact or from inspection, mapping, and destination JSON files without creating Basecamp records.", + RunE: func(cmd *cobra.Command, args []string) error { + var plan *importer.Plan + var err error + if artifactPath != "" { + if inspectionPath != "" || mappingPath != "" || destinationPath != "" { + return output.ErrUsage("--artifact cannot be combined with --inspection, --mapping, or --destination") + } + plan, err = importer.PlanFromArtifact(artifactPath) + } else { + if inspectionPath == "" { + return output.ErrUsage("--inspection required") + } + if mappingPath == "" { + return output.ErrUsage("--mapping required") + } + if destinationPath == "" { + return output.ErrUsage("--destination required") + } + var inspection *importer.Inspection + inspection, err = importer.ReadInspectionFile(inspectionPath) + if err != nil { + return err + } + var mapping *importer.MappingConfig + mapping, err = importer.ReadMappingFile(mappingPath) + if err != nil { + return err + } + var destination *importer.DestinationConfig + destination, err = importer.ReadDestinationFile(destinationPath) + if err != nil { + return err + } + plan, err = importer.PlanImport(inspection, mapping, destination) + } + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), plan.Status) + return nil + } + return app.OK(plan, output.WithSummary(fmt.Sprintf("Planned %d todos", plan.Counts.Todos))) + }, + } + + cmd.Flags().StringVar(&artifactPath, "artifact", "", "Validated Basecamp import artifact directory") + cmd.Flags().StringVar(&inspectionPath, "inspection", "", "Inspection JSON file") + cmd.Flags().StringVar(&mappingPath, "mapping", "", "Confirmed mapping JSON file") + cmd.Flags().StringVar(&destinationPath, "destination", "", "Destination JSON file") + return cmd +} + +func newImportInspectCmd() *cobra.Command { + var sampleSize int + + cmd := &cobra.Command{ + Use: "inspect ", + Short: "Inspect a CSV export", + Long: "Inspect a CSV export and report columns, value shapes, mapping candidates, warnings, and mapping questions.", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return missingArg(cmd, "csv-path") + } + if len(args) > 1 { + return output.ErrUsage("accepts exactly one CSV path") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + inspection, err := importer.InspectCSV(args[0], importer.InspectOptions{SampleSize: sampleSize}) + if err != nil { + return err + } + + app := appctx.FromContext(cmd.Context()) + if app == nil { + fmt.Fprintln(cmd.OutOrStdout(), inspection.Status) + return nil + } + return app.OK(inspection, output.WithSummary(fmt.Sprintf("Profiled %d CSV rows and %d columns", inspection.RowCount, len(inspection.Columns)))) + }, + } + + cmd.Flags().IntVar(&sampleSize, "sample-size", 5, "Number of sample rows to include") + return cmd +} From 041e4510c640c739a4596987df1643e16cd23c08 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 27 May 2026 18:29:59 -0400 Subject: [PATCH 03/10] skills: add Basecamp import skill bundle --- internal/commands/skill.go | 203 +++++++++++++++---------- internal/commands/skill_test.go | 118 ++++++++------- scripts/check-skill-drift.sh | 15 +- skills/basecamp-import/SKILL.md | 256 ++++++++++++++++++++++++++++++++ skills/basecamp/SKILL.md | 16 +- skills/embed.go | 2 +- 6 files changed, 475 insertions(+), 135 deletions(-) create mode 100644 skills/basecamp-import/SKILL.md diff --git a/internal/commands/skill.go b/internal/commands/skill.go index 8ce645b8..fac93e40 100644 --- a/internal/commands/skill.go +++ b/internal/commands/skill.go @@ -19,6 +19,9 @@ import ( const skillFilename = "SKILL.md" const installedVersionFile = ".installed-version" +const primarySkillName = "basecamp" + +var embeddedSkillNames = []string{primarySkillName, "basecamp-import"} // skillLocation represents a predefined skill installation target. type skillLocation struct { @@ -39,8 +42,8 @@ var skillLocations = []skillLocation{ func NewSkillCmd() *cobra.Command { cmd := &cobra.Command{ Use: "skill", - Short: "Manage the embedded agent skill file", - Long: "Print or install the SKILL.md embedded in this binary.", + Short: "Manage the embedded agent skill files", + Long: "Print the main embedded SKILL.md or install the embedded Basecamp skill bundle.", RunE: func(cmd *cobra.Command, args []string) error { var app *appctx.App if ctx := cmd.Context(); ctx != nil { @@ -71,8 +74,8 @@ func NewSkillCmd() *cobra.Command { func newSkillInstallCmd() *cobra.Command { return &cobra.Command{ Use: "install", - Short: "Install the basecamp agent skill", - Long: "Copies the embedded SKILL.md to ~/.agents/skills/basecamp/ and creates a symlink in ~/.claude/skills/basecamp (if Claude Code is detected).", + Short: "Install the Basecamp agent skills", + Long: "Copies the embedded Basecamp skills to ~/.agents/skills/ and creates Claude Code symlinks when Claude Code is detected.", RunE: func(cmd *cobra.Command, args []string) error { app := appctx.FromContext(cmd.Context()) @@ -82,7 +85,8 @@ func newSkillInstallCmd() *cobra.Command { } result := map[string]any{ - "skill_path": skillPath, + "skill_path": skillPath, + "skill_paths": canonicalSkillFiles(), } // Only create the Claude symlink if Claude is actually installed @@ -97,46 +101,94 @@ func newSkillInstallCmd() *cobra.Command { } } - summary := "Basecamp skill installed" + summary := "Basecamp skills installed" if app != nil { return app.OK(result, output.WithSummary(summary)) } // Fallback if app context not available (shouldn't happen in practice) - fmt.Fprintf(cmd.OutOrStdout(), "Installed skill to %s\n", skillPath) + fmt.Fprintf(cmd.OutOrStdout(), "Installed skills to %s\n", strings.Join(canonicalSkillFiles(), ", ")) return nil }, } } -// installSkillFiles writes the embedded SKILL.md to ~/.agents/skills/basecamp/ -// and returns the path to the installed file. +// installSkillFiles writes the embedded skill bundle to ~/.agents/skills/ +// and returns the main Basecamp skill path. func installSkillFiles() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("getting home directory: %w", err) } - skillDir := filepath.Join(home, ".agents", "skills", "basecamp") - skillFile := filepath.Join(skillDir, skillFilename) + root := filepath.Join(home, ".agents", "skills") + if _, err := installSkillBundle(root); err != nil { + return "", err + } + return skillFilePath(root, primarySkillName), nil +} - data, err := skills.FS.ReadFile("basecamp/SKILL.md") +func canonicalSkillFiles() []string { + home, err := os.UserHomeDir() if err != nil { - return "", fmt.Errorf("reading embedded skill: %w", err) + return nil + } + root := filepath.Join(home, ".agents", "skills") + paths := make([]string, 0, len(embeddedSkillNames)) + for _, name := range embeddedSkillNames { + paths = append(paths, skillFilePath(root, name)) + } + return paths +} + +func installSkillBundle(root string) ([]string, error) { + paths := make([]string, 0, len(embeddedSkillNames)) + for _, name := range embeddedSkillNames { + path, err := installEmbeddedSkill(root, name) + if err != nil { + return nil, err + } + paths = append(paths, path) } + return paths, nil +} +func installEmbeddedSkill(root, name string) (string, error) { + data, err := embeddedSkillData(name) + if err != nil { + return "", err + } + + skillDir := filepath.Join(root, name) + skillFile := filepath.Join(skillDir, skillFilename) if err := os.MkdirAll(skillDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets - return "", fmt.Errorf("creating skill directory: %w", err) + return "", fmt.Errorf("creating %s skill directory: %w", name, err) } if err := os.WriteFile(skillFile, data, 0o644); err != nil { //nolint:gosec // G306: Skill files are not secrets - return "", fmt.Errorf("writing skill file: %w", err) + return "", fmt.Errorf("writing %s skill file: %w", name, err) } - // Best-effort: stamp installed version + // Best-effort: stamp installed version. _ = os.WriteFile(filepath.Join(skillDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret return skillFile, nil } +func embeddedSkillData(name string) ([]byte, error) { + data, err := skills.FS.ReadFile(name + "/" + skillFilename) + if err != nil { + return nil, fmt.Errorf("reading embedded %s skill: %w", name, err) + } + return data, nil +} + +func skillFilePath(root, name string) string { + return filepath.Join(root, name, skillFilename) +} + +func skillRootForFile(path string) string { + return filepath.Dir(filepath.Dir(path)) +} + // runSkillWizard runs the interactive skill installation wizard. func runSkillWizard(cmd *cobra.Command, app *appctx.App) error { w := cmd.OutOrStdout() @@ -159,7 +211,7 @@ func runSkillWizard(cmd *cobra.Command, app *appctx.App) error { Label: "Other (custom path)", }) - selectedPath, err := tui.Select(" Where would you like to install the Basecamp skill?", options) + selectedPath, err := tui.Select(" Where would you like to install the Basecamp skills?", options) if err != nil { fmt.Fprintln(w, styles.Muted.Render(" Installation canceled.")) return nil //nolint:nilerr // user canceled prompt @@ -190,9 +242,9 @@ func runSkillWizard(cmd *cobra.Command, app *appctx.App) error { } // Read embedded skill - data, readErr := skills.FS.ReadFile("basecamp/SKILL.md") + data, readErr := embeddedSkillData(primarySkillName) if readErr != nil { - return fmt.Errorf("reading embedded skill: %w", readErr) + return readErr } // Write to selected location @@ -204,28 +256,27 @@ func runSkillWizard(cmd *cobra.Command, app *appctx.App) error { return fmt.Errorf("writing skill file: %w", writeErr) } - // Also write to canonical location result := map[string]any{"skill_path": expandedPath} - home, homeErr := os.UserHomeDir() - if homeErr == nil { - canonicalDir := filepath.Join(home, ".agents", "skills", "basecamp") - canonicalFile := filepath.Join(canonicalDir, skillFilename) - if canonicalFile != expandedPath { - if mkErr := os.MkdirAll(canonicalDir, 0o755); mkErr != nil { //nolint:gosec // G301: Skill files are not secrets - result["notice"] = fmt.Sprintf("could not write to %s: %v", canonicalFile, mkErr) - } else if wErr := os.WriteFile(canonicalFile, data, 0o644); wErr != nil { //nolint:gosec // G306: Skill files are not secrets - result["notice"] = fmt.Sprintf("could not write to %s: %v", canonicalFile, wErr) - } + if filepath.Base(filepath.Dir(expandedPath)) == primarySkillName && filepath.Base(expandedPath) == skillFilename { + root := skillRootForFile(expandedPath) + if paths, bundleErr := installSkillBundle(root); bundleErr == nil { + result["skill_paths"] = paths + } else { + result["notice"] = fmt.Sprintf("could not write companion skills to %s: %v", root, bundleErr) } - // Best-effort: stamp installed version in canonical location - _ = os.WriteFile(filepath.Join(canonicalDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret + } + + if _, canonicalErr := installSkillFiles(); canonicalErr != nil { + result["notice"] = fmt.Sprintf("could not write canonical skills: %v", canonicalErr) + } else { + result["canonical_skill_paths"] = canonicalSkillFiles() } return app.OK(result, - output.WithSummary(fmt.Sprintf("Basecamp skill installed → %s", expandedPath))) + output.WithSummary(fmt.Sprintf("Basecamp skills installed → %s", expandedPath))) } -// normalizeSkillPath appends basecamp/SKILL.md to directory paths. +// normalizeSkillPath appends basecamp/SKILL.md to directory paths for the main skill. // Explicit file paths (any .md) are left as-is. func normalizeSkillPath(path string) string { path = strings.TrimSpace(path) @@ -272,36 +323,38 @@ func codexGlobalSkillPath() string { return filepath.Join(codexHome, "skills", "basecamp", skillFilename) } -// linkSkillToClaude creates a symlink at ~/.claude/skills/basecamp pointing to -// the baseline skill directory. Returns (symlinkPath, notice, error). +// linkSkillToClaude connects the installed Basecamp skill bundle to Claude Code. +// Returns the main symlink path, an optional notice, and any error. func linkSkillToClaude() (string, string, error) { home, err := os.UserHomeDir() if err != nil { return "", "", fmt.Errorf("getting home directory: %w", err) } - skillDir := filepath.Join(home, ".agents", "skills", "basecamp") + agentsRoot := filepath.Join(home, ".agents", "skills") symlinkDir := filepath.Join(home, ".claude", "skills") - symlinkPath := filepath.Join(symlinkDir, "basecamp") - if err := os.MkdirAll(symlinkDir, 0o755); err != nil { //nolint:gosec // G301: Skill files are not secrets return "", "", fmt.Errorf("creating symlink directory: %w", err) } - // Remove existing entry at symlink path (idempotent) - _ = os.Remove(symlinkPath) + notices := []string{} + for _, name := range embeddedSkillNames { + skillDir := filepath.Join(agentsRoot, name) + symlinkPath := filepath.Join(symlinkDir, name) - symlinkTarget := filepath.Join("..", "..", ".agents", "skills", "basecamp") - notice := "" - if err := os.Symlink(symlinkTarget, symlinkPath); err != nil { - // Fallback: copy skill files directly - notice = fmt.Sprintf("symlink failed (%v), copied files instead", err) - if copyErr := copySkillFiles(skillDir, symlinkPath); copyErr != nil { - return "", "", fmt.Errorf("creating symlink: %w (copy fallback also failed: %w)", err, copyErr) + // Remove existing entry at symlink path. + _ = os.Remove(symlinkPath) + + symlinkTarget := filepath.Join("..", "..", ".agents", "skills", name) + if err := os.Symlink(symlinkTarget, symlinkPath); err != nil { + notices = append(notices, fmt.Sprintf("%s symlink failed (%v), copied files instead", name, err)) + if copyErr := copySkillFiles(skillDir, symlinkPath); copyErr != nil { + return "", "", fmt.Errorf("creating %s symlink: %w (copy fallback also failed: %w)", name, err, copyErr) + } } } - return symlinkPath, notice, nil + return filepath.Join(symlinkDir, primarySkillName), strings.Join(notices, "; "), nil } // installedSkillVersion reads the .installed-version file from the baseline @@ -352,11 +405,6 @@ func RefreshSkillsIfVersionChanged() bool { } func refreshAllInstalledSkills() bool { - embedded, err := skills.FS.ReadFile("basecamp/SKILL.md") - if err != nil { - return false - } - updated := 0 failed := 0 for _, loc := range skillLocations { @@ -373,49 +421,42 @@ func refreshAllInstalledSkills() bool { continue } - if writeErr := os.WriteFile(expanded, embedded, 0o644); writeErr == nil { //nolint:gosec // G306: Skill files are not secrets - updated++ - } else { + if _, err := installSkillBundle(skillRootForFile(expanded)); err != nil { failed++ + continue } - } - - // Stamp installed version in the baseline directory only on full success. - if failed == 0 && updated > 0 { - if home, err := os.UserHomeDir(); err == nil { - baselineDir := filepath.Join(home, ".agents", "skills", "basecamp") - _ = os.WriteFile(filepath.Join(baselineDir, installedVersionFile), []byte(version.Version), 0o644) //nolint:gosec // G306: not a secret - } + updated++ } return updated > 0 && failed == 0 } -// repairClaudeSkillLink repairs a broken symlink at ~/.claude/skills/basecamp. -// If the path is a directory (copy fallback), the file refresh already handled it. +// repairClaudeSkillLink keeps Claude Code skill symlinks pointed at the installed bundle. +// Directory copies are refreshed in place by the file refresh path. func repairClaudeSkillLink() { home, err := os.UserHomeDir() if err != nil { return } - symlinkPath := filepath.Join(home, ".claude", "skills", "basecamp") - info, err := os.Lstat(symlinkPath) - if err != nil { - return // doesn't exist, nothing to repair - } + for _, name := range embeddedSkillNames { + symlinkPath := filepath.Join(home, ".claude", "skills", name) + info, err := os.Lstat(symlinkPath) + if err != nil { + continue + } - if info.Mode()&os.ModeSymlink == 0 { - return // not a symlink (directory copy fallback), file refresh handled it - } + if info.Mode()&os.ModeSymlink == 0 { + continue + } - // It's a symlink — check if the target is reachable - if _, statErr := os.Stat(symlinkPath); statErr == nil { - return // symlink is healthy - } + if _, statErr := os.Stat(symlinkPath); statErr == nil { + continue + } - // Broken symlink — repair it - _, _, _ = linkSkillToClaude() + _, _, _ = linkSkillToClaude() + return + } } func copySkillFiles(src, dst string) error { diff --git a/internal/commands/skill_test.go b/internal/commands/skill_test.go index 6422ba4b..7119fa04 100644 --- a/internal/commands/skill_test.go +++ b/internal/commands/skill_test.go @@ -34,26 +34,30 @@ func TestSkillInstallRunE(t *testing.T) { t.Fatalf("RunE() error = %v", err) } - // Verify SKILL.md was written - skillFile := filepath.Join(home, ".agents", "skills", "basecamp", "SKILL.md") - got, err := os.ReadFile(skillFile) - if err != nil { - t.Fatalf("skill file not created: %v", err) - } - embedded, _ := skills.FS.ReadFile("basecamp/SKILL.md") - if string(got) != string(embedded) { - t.Error("skill file content does not match embedded") + // Verify skill files were written + for _, name := range embeddedSkillNames { + skillFile := filepath.Join(home, ".agents", "skills", name, "SKILL.md") + got, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("%s skill file not created: %v", name, err) + } + embedded, _ := skills.FS.ReadFile(name + "/SKILL.md") + if string(got) != string(embedded) { + t.Errorf("%s skill file content does not match embedded", name) + } } - // Verify symlink was created with correct relative target - symlinkPath := filepath.Join(home, ".claude", "skills", "basecamp") - linkTarget, err := os.Readlink(symlinkPath) - if err != nil { - t.Fatalf("symlink not created: %v", err) - } - wantTarget := filepath.Join("..", "..", ".agents", "skills", "basecamp") - if linkTarget != wantTarget { - t.Errorf("symlink target = %q, want %q", linkTarget, wantTarget) + // Verify symlinks were created with correct relative targets + for _, name := range embeddedSkillNames { + symlinkPath := filepath.Join(home, ".claude", "skills", name) + linkTarget, err := os.Readlink(symlinkPath) + if err != nil { + t.Fatalf("%s symlink not created: %v", name, err) + } + wantTarget := filepath.Join("..", "..", ".agents", "skills", name) + if linkTarget != wantTarget { + t.Errorf("%s symlink target = %q, want %q", name, linkTarget, wantTarget) + } } } @@ -76,10 +80,12 @@ func TestSkillInstallIdempotent(t *testing.T) { t.Fatalf("second RunE() error = %v", err) } - // Symlink still valid after second run - symlinkPath := filepath.Join(home, ".claude", "skills", "basecamp") - if _, err := os.Readlink(symlinkPath); err != nil { - t.Fatalf("symlink broken after second install: %v", err) + // Symlinks remain valid after the second run. + for _, name := range embeddedSkillNames { + symlinkPath := filepath.Join(home, ".claude", "skills", name) + if _, err := os.Readlink(symlinkPath); err != nil { + t.Fatalf("%s symlink broken after second install: %v", name, err) + } } } @@ -112,7 +118,7 @@ func TestSkillInstallFallbackOnNonEmptyDir(t *testing.T) { t.Fatalf("RunE() error = %v (fallback should have handled it)", err) } - // Verify SKILL.md was copied (not symlinked) + // Verify SKILL.md was copied for the occupied Basecamp path. copied, err := os.ReadFile(filepath.Join(symlinkPath, "SKILL.md")) if err != nil { t.Fatal("SKILL.md not found in fallback copy location") @@ -121,6 +127,9 @@ func TestSkillInstallFallbackOnNonEmptyDir(t *testing.T) { if string(copied) != string(embedded) { t.Error("fallback copy content does not match embedded") } + if _, err := os.Readlink(filepath.Join(home, ".claude", "skills", "basecamp-import")); err != nil { + t.Fatalf("basecamp-import symlink not created: %v", err) + } // Output should mention fallback (via stdout since no app context) output := buf.String() @@ -148,14 +157,16 @@ func TestSkillInstallOutputKeys(t *testing.T) { t.Fatal(err) } - expectedSkillPath := filepath.Join(home, ".agents", "skills", "basecamp", "SKILL.md") - expectedSymlinkPath := filepath.Join(home, ".claude", "skills", "basecamp") + for _, name := range embeddedSkillNames { + expectedSkillPath := filepath.Join(home, ".agents", "skills", name, "SKILL.md") + expectedSymlinkPath := filepath.Join(home, ".claude", "skills", name) - if _, err := os.Stat(expectedSkillPath); err != nil { - t.Errorf("expected skill_path %q to exist", expectedSkillPath) - } - if _, err := os.Lstat(expectedSymlinkPath); err != nil { - t.Errorf("expected symlink_path %q to exist", expectedSymlinkPath) + if _, err := os.Stat(expectedSkillPath); err != nil { + t.Errorf("expected skill_path %q to exist", expectedSkillPath) + } + if _, err := os.Lstat(expectedSymlinkPath); err != nil { + t.Errorf("expected symlink_path %q to exist", expectedSymlinkPath) + } } } @@ -315,10 +326,12 @@ func TestSkillInstallNoClaude(t *testing.T) { t.Fatalf("RunE() error = %v", err) } - // Baseline skill should be installed - skillFile := filepath.Join(home, ".agents", "skills", "basecamp", "SKILL.md") - if _, err := os.Stat(skillFile); err != nil { - t.Errorf("skill file should exist: %v", err) + // Baseline skills should be installed. + for _, name := range embeddedSkillNames { + skillFile := filepath.Join(home, ".agents", "skills", name, "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + t.Errorf("%s skill file should exist: %v", name, err) + } } // ~/.claude should NOT have been created @@ -335,10 +348,12 @@ func TestInstallSkillFilesStampsVersion(t *testing.T) { _, err := installSkillFiles() require.NoError(t, err) - versionFile := filepath.Join(home, ".agents", "skills", "basecamp", installedVersionFile) - got, err := os.ReadFile(versionFile) - require.NoError(t, err) - assert.Equal(t, version.Version, string(got)) + for _, name := range embeddedSkillNames { + versionFile := filepath.Join(home, ".agents", "skills", name, installedVersionFile) + got, err := os.ReadFile(versionFile) + require.NoError(t, err) + assert.Equal(t, version.Version, string(got)) + } } func TestInstalledSkillVersion(t *testing.T) { @@ -482,9 +497,6 @@ func TestRefreshAllInstalledSkills_MultipleLocations(t *testing.T) { version.Version = "5.0.0" defer func() { version.Version = origVersion }() - embedded, err := skills.FS.ReadFile("basecamp/SKILL.md") - require.NoError(t, err) - // Pre-install skill at baseline and Claude global locations baseline := filepath.Join(home, ".agents", "skills", "basecamp") require.NoError(t, os.MkdirAll(baseline, 0o755)) @@ -501,19 +513,23 @@ func TestRefreshAllInstalledSkills_MultipleLocations(t *testing.T) { refreshed := refreshAllInstalledSkills() assert.True(t, refreshed) - // All three should be updated - for _, path := range []string{ - filepath.Join(baseline, "SKILL.md"), - filepath.Join(claudeSkill, "SKILL.md"), - filepath.Join(opencode, "SKILL.md"), - } { - got, readErr := os.ReadFile(path) - require.NoError(t, readErr, "reading %s", path) - assert.Equal(t, string(embedded), string(got), "content mismatch at %s", path) + // All configured roots should contain refreshed skill files. + for _, root := range []string{filepath.Dir(baseline), filepath.Dir(claudeSkill), filepath.Dir(opencode)} { + for _, name := range embeddedSkillNames { + path := filepath.Join(root, name, "SKILL.md") + got, readErr := os.ReadFile(path) + require.NoError(t, readErr, "reading %s", path) + want, readEmbeddedErr := skills.FS.ReadFile(name + "/SKILL.md") + require.NoError(t, readEmbeddedErr) + assert.Equal(t, string(want), string(got), "content mismatch at %s", path) + } } - // Version stamp should be updated + // Version stamps should be updated. assert.Equal(t, "5.0.0", installedSkillVersion()) + got, err := os.ReadFile(filepath.Join(home, ".agents", "skills", "basecamp-import", installedVersionFile)) + require.NoError(t, err) + assert.Equal(t, "5.0.0", string(got)) } func TestRefreshAllInstalledSkills_SkipsAbsentLocations(t *testing.T) { diff --git a/scripts/check-skill-drift.sh b/scripts/check-skill-drift.sh index d2886450..e0456471 100755 --- a/scripts/check-skill-drift.sh +++ b/scripts/check-skill-drift.sh @@ -3,7 +3,20 @@ # Catches stale skill references: renamed commands, removed flags, etc. set -euo pipefail -SKILL="${1:-skills/basecamp/SKILL.md}" +if [ "$#" -eq 0 ]; then + status=0 + found=0 + for skill in skills/*/SKILL.md; do + [ -f "$skill" ] || continue + found=1 + echo "Checking $skill" + "$0" "$skill" ".surface" ".surface-skill-drift" || status=$? + done + [ "$found" -eq 1 ] || { echo "ERROR: no skill files found under skills/*/SKILL.md" >&2; exit 1; } + exit "$status" +fi + +SKILL="$1" SURFACE="${2:-.surface}" BASELINE="${3:-.surface-skill-drift}" diff --git a/skills/basecamp-import/SKILL.md b/skills/basecamp-import/SKILL.md new file mode 100644 index 00000000..89b61891 --- /dev/null +++ b/skills/basecamp-import/SKILL.md @@ -0,0 +1,256 @@ +--- +name: basecamp-import +description: | + Import task and project tracking CSVs into Basecamp using deterministic Basecamp CLI import artifacts. + Use for CSV imports, task migrations, spreadsheet-to-Basecamp imports, and validated import dry-runs. +triggers: + - basecamp import + - import csv to basecamp + - import tasks to basecamp + - migrate tasks to basecamp + - spreadsheet to basecamp + - csv task import + - basecamp import artifact + - basecamp import dry run +invocable: true +argument-hint: "[csv path or import action]" +--- + +# Basecamp CSV Import + +Use this skill to turn CSV exports from spreadsheets, task apps, and internal tools into validated Basecamp todos. + +The import pipeline is deterministic: + +```text +raw CSV + → inspect + → confirmed mapping + destination + → compile validated Basecamp import artifact + → plan dry run + → preflight readiness check + → execute after explicit approval +``` + +## Non-negotiable Rules + +1. **Never hand-parse CSVs.** Use `basecamp import inspect` for row counts, columns, samples, warnings, and mapping candidates. +2. **Never invent counts or dry-run text.** Use planner output, especially `data.dry_run_markdown`. +3. **Use column indexes from inspection.** Duplicate headers are distinguished by index. +4. **Compile the artifact before planning execution.** The artifact is the import source of truth. +5. **Execute only after explicit user approval.** The execute command requires `--approved`; ask the user before running it. +6. **Preserve unmapped useful data.** Prefer `"custom_fields": "all_unmapped_columns"` unless the user chooses otherwise. +7. **Treat assignees carefully.** Native Basecamp assignment requires Basecamp person IDs. The current artifact preserves assignee emails/names as metadata. + +## Commands + +| Step | Command | +|------|---------| +| Inspect CSV | `basecamp import inspect --json` | +| Compile artifact | `basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import/ --json` | +| Plan artifact | `basecamp import plan --artifact basecamp-import/ --json` | +| Show artifact status | `basecamp import status --artifact basecamp-import/ --json` | +| Review repair state | `basecamp import repair --artifact basecamp-import/ --json` | +| Create follow-up artifact | `basecamp import followup --artifact basecamp-import/ --out followup-import/ --reviewed --json` | +| Preflight artifact | `basecamp import preflight --artifact basecamp-import/ --json` | +| Execute approved import | `basecamp import execute --artifact basecamp-import/ --approved --json` | + +## Workflow + +### 1. Confirm the source CSV + +Ask for the path to the CSV export. If the user has multiple CSVs, process one at a time unless a validated multi-file artifact workflow exists. + +### 2. Inspect the CSV + +```bash +basecamp import inspect ./tasks.csv --json +``` + +Use the returned JSON as the factual source. Explain: + +- `row_count` +- columns by index and name +- duplicate headers +- likely role candidates +- warnings +- returned mapping questions + +The inspection can return no obvious title candidate. That is safe: ask the user which non-empty text column should become the todo title. + +### 3. Confirm mappings with the user + +Create `mapping.json` from confirmed answers. At minimum, `title` is required. + +Example: + +```json +{ + "schema_version": 1, + "record_id": { "column_index": 0, "column_name": "Task ID" }, + "title": { "column_index": 1, "column_name": "Task Name" }, + "description": { "column_index": 2, "column_name": "Notes" }, + "todolist": { "column_index": 3, "column_name": "Section" }, + "status": { "column_index": 4, "column_name": "Status" }, + "assignees": { + "column_index": 5, + "column_name": "Owner", + "mapping_policy": "leave_unassigned_when_ambiguous" + }, + "due_on": { "column_index": 6, "column_name": "Due Date" }, + "attachment_urls": [{ "column_index": 7, "column_name": "Link" }], + "custom_fields": "all_unmapped_columns" +} +``` + +Mapping guidance: + +- `record_id`: stable source ID, if available. +- `title`: required todo title. +- `description`: long notes/body/content. +- `todolist`: grouping column such as list, section, phase, stream, room, area, or project. +- `status`: source status preserved as metadata. +- `assignees`: emails or names preserved as metadata unless Basecamp person IDs are available in a later workflow. +- `due_on`: due/deadline column. Compile normalizes deterministic date values to `YYYY-MM-DD`. For ambiguous slash dates such as `06/01/2026`, add `"date_order": "mdy"` or `"date_order": "dmy"` after confirming the source convention with the user. +- `attachment_urls`: URL-like fields preserved in metadata. +- `custom_fields`: use `all_unmapped_columns` to preserve non-empty unmapped columns. + +### 4. Confirm destination + +Create `destination.json` from the user's Basecamp destination choice. + +Existing project with todolists created from a CSV column: + +```json +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} +``` + +Existing project and existing todolist: + +```json +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "existing_todolist", + "todolist_id": "67890", + "todolist_name": "Imported todos" +} +``` + +New project: + +```json +{ + "schema_version": 1, + "mode": "new_project", + "project_name": "Imported tasks", + "todolist_strategy": "create_from_column" +} +``` + +### 5. Compile the validated artifact + +```bash +basecamp import compile \ + --inspection inspection.json \ + --mapping mapping.json \ + --destination destination.json \ + --out basecamp-import/ \ + --json +``` + +The artifact contains: + +```text +basecamp-import/ +├── import.json +└── todos.csv +``` + +The artifact format is `basecamp-import-csv-v1`. Treat it as the durable checkpoint for the import. + +If compile fails, fix the mapping or destination with the user. Date errors name the source row and can be resolved by correcting the source date or by adding `date_order` to the `due_on` mapping when slash dates are ambiguous. Do not proceed to plan or execute. + +### 6. Plan from the artifact + +```bash +basecamp import plan --artifact basecamp-import/ --json +``` + +Present `data.dry_run_markdown` verbatim. + +### 7. Check local artifact status + +```bash +basecamp import status --artifact basecamp-import/ --json +``` + +If status reports `completed`, `failed`, or `started`, explain the execution ledger and do not execute the artifact again. + +For failed or partial executions, run the local repair review: + +```bash +basecamp import repair --artifact basecamp-import/ --json +``` + +Use `completed_operations`, `failed_operations`, and `pending_todos` to explain what needs manual review before a fresh follow-up artifact is created. + +After the user confirms they reviewed Basecamp state and the repair summary, create a fresh follow-up artifact for pending rows: + +```bash +basecamp import followup --artifact basecamp-import/ --out followup-import/ --reviewed --json +``` + +Plan and preflight the follow-up artifact before execution. Do not remove `execution.json` from the source artifact. + +### 8. Preflight the artifact + +```bash +basecamp import preflight --artifact basecamp-import/ --json +``` + +If preflight returns `status: "blocked"`, resolve the reported blocker before execution. Todolist name collisions mean the destination project already has a todolist with a name the artifact plans to create. Todo title collisions mean an existing destination todolist already contains a todo with a title the artifact plans to import. + +Then ask: + +```text +Do you approve executing this import into Basecamp? +``` + +Do not execute unless the user clearly approves. + +### 9. Execute after approval + +```bash +basecamp import execute --artifact basecamp-import/ --approved --json +``` + +Summarize: + +- `created.projects` +- `created.todolists` +- `created.todos` +- `skipped` + +Skipped assignees mean the source assignee values were preserved as metadata but not assigned natively. + +## Safety and Failure Handling + +- If inspection warnings mention duplicate headers, use column indexes in all mapping references. +- When including `column_name`, copy the inspected column name for that exact `column_index`; compile validates that they match. +- If planning or compile says user input is required, ask only the returned questions. +- If the CSV changed after inspection, compile rejects the fingerprint; inspect the current CSV again. +- Use `basecamp import status --artifact` to read local artifact and execution ledger state without Basecamp access. +- Execution writes `execution.json` in the artifact directory and refuses to run again when that ledger exists. +- If execution fails on a row, report the source row from the error and stop. Treat a failed `execution.json` as evidence of possible partial Basecamp writes, and use its operation records to explain what was created before the failure. +- If the user asks for a dry run only, stop after `basecamp import plan --artifact`. + +## Output Discipline + +Use `--json` for every command. For planning, present the deterministic `dry_run_markdown`; do not rewrite counts or operations from memory. diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index aac4734f..b6e95fdf 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -3,7 +3,7 @@ name: basecamp description: | Interact with Basecamp via the Basecamp CLI. Full API coverage: projects, todos, cards, messages, files, schedule, check-ins, timeline, recordings, templates, webhooks, - subscriptions, lineup, chat, gauges, assignments, notifications, and accounts. + subscriptions, lineup, chat, gauges, assignments, notifications, imports, and accounts. Use for ANY Basecamp question or action. triggers: # Direct invocations @@ -27,6 +27,7 @@ triggers: - basecamp gauge - basecamp assignment - basecamp notification + - basecamp import - basecamp account # Common actions - link to basecamp @@ -38,6 +39,8 @@ triggers: - create todo - move card - download file + - import csv to basecamp + - import tasks to basecamp # Search and discovery - search basecamp - find in basecamp @@ -92,6 +95,7 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule, - **`@Name` / `@First.Last`** — fuzzy name resolution (may be ambiguous) For todos, documents, and cards, content is sent as-is — use plain text or HTML directly. 6. **Project scope is mandatory for most commands** — via `--in ` or `.basecamp/config.json`. Cross-project exceptions: `basecamp reports assigned` for assigned work, `basecamp assignments` for structured assignment views, `basecamp reports overdue` for overdue todos, `basecamp reports schedule` for upcoming schedule across all projects, `basecamp recordings ` for browsing by type, `basecamp notifications` for notifications, `basecamp gauges list` for account-wide gauges. +7. **CSV imports use deterministic artifacts** — inspect the CSV, collect user-confirmed mappings, compile a validated artifact, present the artifact dry-run, and execute only after explicit user approval. Never hand-parse CSVs or invent import counts. ### Output Modes @@ -191,6 +195,10 @@ basecamp --page 1 # First page only, no auto-pagination | Create needle | `basecamp gauges create --position 75 --color green --in --json` | | Account details | `basecamp accounts show --json` | | Watch timeline | `basecamp timeline --watch` | +| Inspect CSV import | `basecamp import inspect --json` | +| Compile import artifact | `basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import/ --json` | +| Plan import artifact | `basecamp import plan --artifact basecamp-import/ --json` | +| Execute approved import | `basecamp import execute --artifact basecamp-import/ --approved --json` | ## URL Parsing @@ -254,6 +262,12 @@ Want to change something? ## Common Workflows +### Import Todos from CSV + +Use the `basecamp-import` skill for CSV imports. It owns the full deterministic +workflow: inspect, confirm mappings, compile a validated artifact, plan, approve, +and execute. + ### Link Code to Basecamp Todo ```bash diff --git a/skills/embed.go b/skills/embed.go index 43a350d1..d3cee02d 100644 --- a/skills/embed.go +++ b/skills/embed.go @@ -3,5 +3,5 @@ package skills import "embed" -//go:embed basecamp +//go:embed basecamp basecamp-import var FS embed.FS From d2a80e91dd5cfde61b5e2630cd399a50fd59b4c2 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 27 May 2026 18:30:02 -0400 Subject: [PATCH 04/10] docs: add CSV import demo workflow --- demos/import/README.md | 88 ++++++++ demos/import/board-export.csv | 38 ++++ ...xport.destination-new-project.example.json | 6 + .../board-export.destination.example.json | 6 + demos/import/board-export.mapping.json | 14 ++ .../destination-new-project.example.json | 6 + demos/import/destination.example.json | 6 + demos/import/mapping.json | 16 ++ demos/import/tasks.csv | 4 + scripts/demo-import.sh | 198 ++++++++++++++++++ 10 files changed, 382 insertions(+) create mode 100644 demos/import/README.md create mode 100644 demos/import/board-export.csv create mode 100644 demos/import/board-export.destination-new-project.example.json create mode 100644 demos/import/board-export.destination.example.json create mode 100644 demos/import/board-export.mapping.json create mode 100644 demos/import/destination-new-project.example.json create mode 100644 demos/import/destination.example.json create mode 100644 demos/import/mapping.json create mode 100644 demos/import/tasks.csv create mode 100755 scripts/demo-import.sh diff --git a/demos/import/README.md b/demos/import/README.md new file mode 100644 index 00000000..0243b781 --- /dev/null +++ b/demos/import/README.md @@ -0,0 +1,88 @@ +# Basecamp Import Demo + +This directory contains demo inputs for the deterministic Basecamp import pipeline. + +## Files + +Simple spreadsheet-style demo: + +- `tasks.csv` — small source CSV with three tasks +- `mapping.json` — confirmed column mapping for `tasks.csv` +- `destination.example.json` — example destination config for an existing project +- `destination-new-project.example.json` — destination config that creates a new demo project + +Board-export-shaped demo: + +- `board-export.csv` — realistic Trello-shaped board export fixture +- `board-export.mapping.json` — confirmed column mapping for `board-export.csv` +- `board-export.destination.example.json` — example destination config for an existing project +- `board-export.destination-new-project.example.json` — destination config that creates a new demo project + +The board-export demo shows the importer handling a service-style export generically. It does not rely on a Trello-specific parser or source mode. + +## Simple dry-run demo + +```bash +cp demos/import/destination.example.json /tmp/destination.json +# Edit /tmp/destination.json and set project_id to your demo project. +# Or use demos/import/destination-new-project.example.json to create a new demo project during execution. + +scripts/demo-import.sh \ + --csv demos/import/tasks.csv \ + --mapping demos/import/mapping.json \ + --destination /tmp/destination.json \ + --out /tmp/basecamp-import-demo +``` + +## Board-export dry-run demo + +```bash +cp demos/import/board-export.destination.example.json /tmp/board-destination.json +# Edit /tmp/board-destination.json and set project_id to your demo project. +# Or use demos/import/board-export.destination-new-project.example.json to create a new demo project during execution. + +scripts/demo-import.sh \ + --csv demos/import/board-export.csv \ + --mapping demos/import/board-export.mapping.json \ + --destination /tmp/board-destination.json \ + --out /tmp/basecamp-board-import-demo +``` + +The script prints the deterministic dry run, checks local artifact status, and stops before writes. + +## Preflight and execute demo + +After reviewing the dry run, execute against the selected project: + +```bash +scripts/demo-import.sh \ + --csv demos/import/board-export.csv \ + --mapping demos/import/board-export.mapping.json \ + --destination /tmp/board-destination.json \ + --out /tmp/basecamp-board-import-demo \ + --execute +``` + +The script runs `basecamp import preflight --artifact` before asking for approval. If preflight passes, it prompts for approval before running `basecamp import execute --approved`, then prints post-execution status. + +## Recovery review demo + +If execution fails, the artifact contains `execution.json`. Review the local recovery state without Basecamp reads or writes: + +```bash +scripts/demo-import.sh \ + --repair-artifact /tmp/basecamp-board-import-demo +``` + +The repair review summarizes completed operations, failed operations, pending todos, and guidance. + +After reviewing Basecamp state and the repair summary, create a fresh follow-up artifact for pending rows: + +```bash +scripts/demo-import.sh \ + --repair-artifact /tmp/basecamp-board-import-demo \ + --followup-out /tmp/basecamp-board-import-followup \ + --reviewed +``` + +The script creates the follow-up artifact and prints its dry run. Plan, preflight, and execute the follow-up artifact as a separate import. diff --git a/demos/import/board-export.csv b/demos/import/board-export.csv new file mode 100644 index 00000000..91fdfa48 --- /dev/null +++ b/demos/import/board-export.csv @@ -0,0 +1,38 @@ +Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment Count,Attachment Links,Checklist Item Total Count,Checklist Item Completed Count,Vote Count,Comment Count,Last Activity Date,List ID,List Name,Board ID,Board Name,Archived,Start Date,Due Complete,Due Reminder +62b4725bdec63838046a4ec8,How to use this board,https://trello.com/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template + +**Before** + +* Both manager and team member put topics down on their lists, ranked by priority and labeled as either Blocker, Discuss, FYI or Paused. + +**During** + +* Agree on agenda +1\. Can Blocker and Discuss topics can be covered? +2\. Any interest in FYI topics? +* Discuss topics +1\. Capture notes/actions as you go (or defer to after meeting) +* Review progress on goals (either all or pick one to focus) +* Review actions + +**After** + +* Capture necessary notes/actions not covered in 1-1 meeting +* Move discussions that have related actions to ""Actions"" +* Move topics that are closed to ""Done"" + +**For more 1-on-1 meeting tips...** +I put my top 7 tips on the Atlassian Blog: https://www.atlassian.com/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://trello.com/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://trello.com/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://trello.com/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://trello.com/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://trello.com/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec2,Goal (#1),https://trello.com/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://trello.com/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://trello.com/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://trello.com/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed4,New training program,https://trello.com/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://trello.com/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ece,Manage time chaos,https://trello.com/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed2,Mentor another developer,https://trello.com/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed0,Best practice blog,https://trello.com/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, diff --git a/demos/import/board-export.destination-new-project.example.json b/demos/import/board-export.destination-new-project.example.json new file mode 100644 index 00000000..54d6e0fd --- /dev/null +++ b/demos/import/board-export.destination-new-project.example.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "mode": "new_project", + "project_name": "Board Export Import Demo", + "todolist_strategy": "create_from_column" +} diff --git a/demos/import/board-export.destination.example.json b/demos/import/board-export.destination.example.json new file mode 100644 index 00000000..923c7c02 --- /dev/null +++ b/demos/import/board-export.destination.example.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} diff --git a/demos/import/board-export.mapping.json b/demos/import/board-export.mapping.json new file mode 100644 index 00000000..6d5ba948 --- /dev/null +++ b/demos/import/board-export.mapping.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "record_id": { "column_index": 0, "column_name": "Card ID" }, + "title": { "column_index": 1, "column_name": "Card Name" }, + "description": { "column_index": 3, "column_name": "Card Description" }, + "todolist": { "column_index": 15, "column_name": "List Name" }, + "status": { "column_index": 18, "column_name": "Archived" }, + "due_on": { "column_index": 6, "column_name": "Due Date" }, + "attachment_urls": [ + { "column_index": 2, "column_name": "Card URL" }, + { "column_index": 8, "column_name": "Attachment Links" } + ], + "custom_fields": "all_unmapped_columns" +} diff --git a/demos/import/destination-new-project.example.json b/demos/import/destination-new-project.example.json new file mode 100644 index 00000000..e23a90c0 --- /dev/null +++ b/demos/import/destination-new-project.example.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "mode": "new_project", + "project_name": "Basecamp Import Demo", + "todolist_strategy": "create_from_column" +} diff --git a/demos/import/destination.example.json b/demos/import/destination.example.json new file mode 100644 index 00000000..923c7c02 --- /dev/null +++ b/demos/import/destination.example.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "mode": "existing_project", + "project_id": "12345", + "todolist_strategy": "create_from_column" +} diff --git a/demos/import/mapping.json b/demos/import/mapping.json new file mode 100644 index 00000000..ba950c19 --- /dev/null +++ b/demos/import/mapping.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "record_id": { "column_index": 0, "column_name": "source_id" }, + "title": { "column_index": 1, "column_name": "task" }, + "description": { "column_index": 2, "column_name": "details" }, + "todolist": { "column_index": 3, "column_name": "phase" }, + "status": { "column_index": 4, "column_name": "state" }, + "assignees": { + "column_index": 5, + "column_name": "owner", + "mapping_policy": "leave_unassigned_when_ambiguous" + }, + "due_on": { "column_index": 6, "column_name": "due" }, + "attachment_urls": [{ "column_index": 7, "column_name": "reference" }], + "custom_fields": "all_unmapped_columns" +} diff --git a/demos/import/tasks.csv b/demos/import/tasks.csv new file mode 100644 index 00000000..e390fad5 --- /dev/null +++ b/demos/import/tasks.csv @@ -0,0 +1,4 @@ +source_id,task,details,phase,state,owner,due,reference,impact +IMP-001,Confirm launch checklist,"Review launch checklist with support and sales leads.",Launch Prep,Todo,alex@example.com,2026-06-01,https://example.com/launch/checklist,High +IMP-002,Draft customer announcement,"Write a concise announcement for customers and internal teams.",Communications,In Progress,jamie@example.com,2026-06-03,https://example.com/docs/announcement,Medium +IMP-003,Schedule post-launch review,"Book a 30-minute review after the launch window closes.",Follow-up,Waiting,Riley Chen,2026-06-10,,Low diff --git a/scripts/demo-import.sh b/scripts/demo-import.sh new file mode 100755 index 00000000..13770838 --- /dev/null +++ b/scripts/demo-import.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Run the deterministic Basecamp import demo flow. +set -euo pipefail + +csv="" +mapping="" +destination="" +out="/tmp/basecamp-import-demo" +execute=0 +repair_artifact="" +followup_out="" +reviewed=0 + +usage() { + cat <<'USAGE' +Usage: scripts/demo-import.sh --csv tasks.csv --mapping mapping.json --destination destination.json [--out dir] [--execute] + scripts/demo-import.sh --repair-artifact basecamp-import [--followup-out dir] [--reviewed] + +Dry-run flow: + 1. basecamp import inspect + 2. basecamp import compile + 3. basecamp import plan --artifact + 4. basecamp import status --artifact + +Execute flow with --execute: + 5. basecamp import preflight --artifact + 6. basecamp import execute --approved after confirmation + 7. basecamp import status --artifact + +Recovery review flow with --repair-artifact: + 1. basecamp import status --artifact + 2. basecamp import repair --artifact + 3. optionally basecamp import followup --reviewed +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --csv) + csv="${2:-}"; shift 2 ;; + --mapping) + mapping="${2:-}"; shift 2 ;; + --destination) + destination="${2:-}"; shift 2 ;; + --out) + out="${2:-}"; shift 2 ;; + --execute) + execute=1; shift ;; + --repair-artifact) + repair_artifact="${2:-}"; shift 2 ;; + --followup-out) + followup_out="${2:-}"; shift 2 ;; + --reviewed) + reviewed=1; shift ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 ;; + esac +done + +if [[ -n "$repair_artifact" ]]; then + if [[ -n "$csv" || -n "$mapping" || -n "$destination" || "$execute" -eq 1 ]]; then + echo "--repair-artifact cannot be combined with --csv, --mapping, --destination, or --execute" >&2 + exit 2 + fi + if [[ ! -d "$repair_artifact" ]]; then + echo "Repair artifact directory not found: $repair_artifact" >&2 + exit 1 + fi + + echo "== Artifact status ==" + basecamp import status --artifact "$repair_artifact" --json | + jq '{ok, status: .data.status, counts: .data.counts, execution: .data.execution}' + + echo "" + echo "== Repair review ==" + basecamp import repair --artifact "$repair_artifact" --json | + jq '{ok, status: .data.status, created: .data.created, completed_operations: (.data.completed_operations | length), failed_operations: .data.failed_operations, pending_todos: .data.pending_todos, guidance: .data.guidance}' + + if [[ -z "$followup_out" ]]; then + echo "" + echo "Repair review complete. To create a follow-up artifact after review:" + echo " scripts/demo-import.sh --repair-artifact $repair_artifact --followup-out /tmp/basecamp-import-followup --reviewed" + exit 0 + fi + if [[ "$reviewed" -ne 1 ]]; then + echo "" + echo "Follow-up artifact creation requires --reviewed after checking Basecamp state and the repair summary." >&2 + exit 2 + fi + + rm -rf "$followup_out" + echo "" + echo "== Creating follow-up artifact ==" + basecamp import followup --artifact "$repair_artifact" --out "$followup_out" --reviewed --json | + jq '{ok, status: .data.status, artifact_path: .data.artifact_path, counts: .data.manifest.counts, pending_todos: .data.pending_todos, guidance: .data.guidance}' + + echo "" + echo "== Planning follow-up artifact ==" + basecamp import plan --artifact "$followup_out" --json | jq -r '.data.dry_run_markdown' + echo "Follow-up artifact written to: $followup_out" + exit 0 +fi + +if [[ -z "$csv" || -z "$mapping" || -z "$destination" ]]; then + usage >&2 + exit 2 +fi + +if [[ ! -f "$csv" ]]; then + echo "CSV not found: $csv" >&2 + exit 1 +fi +if [[ ! -f "$mapping" ]]; then + echo "Mapping JSON not found: $mapping" >&2 + exit 1 +fi +if [[ ! -f "$destination" ]]; then + echo "Destination JSON not found: $destination" >&2 + exit 1 +fi + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT +inspection="$workdir/inspection.json" +plan="$workdir/plan.json" + +rm -rf "$out" + +echo "== Inspecting CSV ==" +basecamp import inspect "$csv" --json > "$inspection" +jq -r '"Rows: \(.data.row_count), Columns: \(.data.columns | length), Status: \(.data.status)"' "$inspection" + +echo "" +echo "== Compiling validated artifact ==" +basecamp import compile \ + --inspection "$inspection" \ + --mapping "$mapping" \ + --destination "$destination" \ + --out "$out" \ + --json | jq '{ok, status: .data.status, artifact_path: .data.artifact_path, counts: .data.manifest.counts}' + +echo "" +echo "== Planning from artifact ==" +basecamp import plan --artifact "$out" --json > "$plan" +jq -r '.data.dry_run_markdown' "$plan" + +echo "" +echo "== Local artifact status ==" +basecamp import status --artifact "$out" --json | + jq '{ok, status: .data.status, counts: .data.counts, guidance: .data.guidance}' + +if [[ "$execute" -ne 1 ]]; then + echo "" + echo "Dry run complete. Artifact written to: $out" + echo "Run again with --execute to preflight and execute after approval." + exit 0 +fi + +echo "" +echo "== Preflight artifact ==" +preflight_json="$workdir/preflight.json" +basecamp import preflight --artifact "$out" --json > "$preflight_json" +jq '{ok, status: .data.status, checks: .data.checks, collisions: .data.collisions, todo_collisions: .data.todo_collisions}' "$preflight_json" +preflight_status="$(jq -r '.data.status' "$preflight_json")" +if [[ "$preflight_status" != "passed" ]]; then + echo "Preflight did not pass. Resolve blockers before execution." >&2 + exit 1 +fi + +read -r -p "Execute this import into Basecamp? Type 'approve' to continue: " approval +if [[ "$approval" != "approve" ]]; then + echo "Execution canceled." + exit 0 +fi + +echo "" +echo "== Executing approved import ==" +set +e +execute_output="$(basecamp import execute --artifact "$out" --approved --json)" +execute_status=$? +set -e +echo "$execute_output" | jq '{ok, status: .data.status, created: .data.created, skipped: .data.skipped, error: .error}' + +echo "" +echo "== Post-execution status ==" +basecamp import status --artifact "$out" --json | + jq '{ok, status: .data.status, created: .data.execution.created, operation_count: (.data.execution.operations | length), guidance: .data.guidance}' + +if [[ "$execute_status" -ne 0 ]]; then + echo "" + echo "Execution failed. Review recovery state with:" + echo " scripts/demo-import.sh --repair-artifact $out" +fi +exit "$execute_status" From 811f643dc2a473258c7b21ab01d1420439b53811 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 31 May 2026 23:11:41 -0400 Subject: [PATCH 05/10] importer: add card import support --- IMPORT-ARTIFACT.md | 66 ++++- demos/import/README.md | 10 +- ...xport.destination-new-project.example.json | 6 +- .../board-export.destination.example.json | 4 +- demos/import/board-export.mapping.json | 2 +- e2e/import.bats | 101 ++++++- internal/commands/import.go | 145 +++++++++- internal/importer/artifact.go | 266 +++++++++++++++++- internal/importer/artifact_test.go | 25 ++ internal/importer/executor.go | 135 ++++++++- internal/importer/executor_test.go | 53 ++++ internal/importer/followup.go | 121 ++++++-- internal/importer/planner.go | 161 ++++++++++- internal/importer/preflight.go | 182 +++++++++++- internal/importer/preflight_test.go | 37 ++- internal/importer/repair.go | 17 +- scripts/demo-import.sh | 6 +- skills/basecamp-import/SKILL.md | 49 +++- 18 files changed, 1271 insertions(+), 115 deletions(-) diff --git a/IMPORT-ARTIFACT.md b/IMPORT-ARTIFACT.md index d0727ec6..c1012709 100644 --- a/IMPORT-ARTIFACT.md +++ b/IMPORT-ARTIFACT.md @@ -1,13 +1,17 @@ # Basecamp Import Artifact v1 -`basecamp-import-csv-v1` is the stable artifact format produced by `basecamp import compile` for CSV-to-Basecamp todo imports. The artifact is a durable checkpoint between CSV inspection and approved Basecamp writes. +`basecamp-import-csv-v1` is the stable artifact format produced by `basecamp import compile` for CSV-to-Basecamp todo and card imports. The artifact is a durable checkpoint between CSV inspection and approved Basecamp writes. A compiled artifact contains: ```text basecamp-import/ ├── import.json -└── todos.csv +└── todos.csv # todo imports + +basecamp-import/ +├── import.json +└── cards.csv # card imports ``` Execution adds a local ledger: @@ -19,7 +23,7 @@ basecamp-import/ └── execution.json ``` -The artifact schema is the same for every CSV import that uses `basecamp-import-csv-v1`. Import-specific values such as source fingerprints, destinations, todo titles, due dates, and preserved metadata live inside the fixed schema. +The artifact schema is the same for every CSV import that uses `basecamp-import-csv-v1`. Import-specific values such as source fingerprints, destinations, todo or card titles, due dates, and preserved metadata live inside the fixed schema. ## Lifecycle @@ -88,13 +92,25 @@ Fields: | `counts.projects` | integer | Number of projects execution creates. | | `counts.todolists` | integer | Number of todolists execution creates. | | `counts.todos` | integer | Number of todos execution creates. | -| `files.todos` | string | Relative path to the canonical todo CSV. v1 uses `todos.csv`. | +| `counts.card_columns` | integer | Number of card table columns execution creates for card imports. | +| `counts.cards` | integer | Number of cards execution creates for card imports. | +| `files.todos` | string | Relative path to the canonical todo CSV. v1 uses `todos.csv` for todo imports. | +| `files.cards` | string | Relative path to the canonical card CSV. v1 uses `cards.csv` for card imports. | + +## Resource type + +`destination.resource_type` selects the Basecamp resource type. A blank value means `todos` for compatibility with existing v1 todo artifacts. + +| Resource type | Behavior | +|---|---| +| `todos` | Creates todolists and todos. | +| `cards` | Creates card table columns and cards. | ## Destination modes ### Existing project -Creates todolists and todos inside an existing Basecamp project. +Creates todolists and todos, or card table columns and cards, inside an existing Basecamp project. ```json { @@ -109,7 +125,7 @@ Creates todolists and todos inside an existing Basecamp project. ### New project -Creates a Basecamp project, then creates todolists and todos inside it. +Creates a Basecamp project, then creates todolists and todos, or card table columns and cards, inside it. ```json { @@ -130,6 +146,18 @@ Creates a Basecamp project, then creates todolists and todos inside it. | `single_todolist` | Creates one todolist named by `todolist_name`, or `Imported todos` when the name is blank. | | `existing_todolist` | Creates todos in the todolist identified by `todolist_id`. | +## Card column strategies + +Card imports use `card_table_id` and `column_strategy` in the destination. + +| Strategy | Behavior | +|---|---| +| `create_from_column` | Creates one card table column for each distinct mapped column value. Blank column values use `Imported cards`. | +| `single_column` | Creates one card table column named by `column_name`, or `Imported cards` when the name is blank. | +| `existing_column` | Creates cards in the card table column identified by `column_id`. | + +The mapping can use either `column` or `todolist` to identify the source grouping column for cards. `column` is preferred for card imports. + ## `todos.csv` `todos.csv` contains one normalized todo row per source CSV row selected for import. The header is fixed and validated exactly. @@ -170,6 +198,24 @@ attachment_urls_json,comments_json,custom_fields_json Readers validate these columns as JSON arrays or objects before planning or execution. +## `cards.csv` + +`cards.csv` contains one normalized card row per source CSV row selected for import. The header is fixed and validated exactly. + +```csv +source_path,source_row,source_record_id,project_id,project_name,card_table_id,column_id,column_name,title,content,due_on,assignee_emails,assignee_names,status,attachment_urls_json,comments_json,custom_fields_json +``` + +Columns follow the same provenance and metadata contract as `todos.csv`. Card-specific columns are: + +| Column | Type | Description | +|---|---:|---| +| `card_table_id` | integer string | Destination card table ID. Blank means execution resolves the project's card table. | +| `column_id` | integer string | Destination card table column ID for existing-column imports. Blank means execution creates or resolves the column from the artifact destination. | +| `column_name` | string | Destination card table column name. Blank values resolve to `Imported cards` for created columns. | +| `title` | string | Basecamp card title. Compile requires a non-blank title. | +| `content` | string | Basecamp card content from the mapped source description column plus preserved metadata. | + ## Due dates Artifact due dates use `YYYY-MM-DD`. @@ -229,10 +275,10 @@ Artifact readers validate: - supported manifest `schema_version` - supported `artifact_format` - required artifact files -- exact `todos.csv` header -- manifest todo count against CSV row count -- non-blank todo titles -- integer fields such as `source_row` and `todolist_id` +- exact `todos.csv` or `cards.csv` header +- manifest todo/card count against CSV row count +- non-blank todo and card titles +- integer fields such as `source_row`, `todolist_id`, `card_table_id`, and `column_id` - JSON array/object columns - destination fields required for execution diff --git a/demos/import/README.md b/demos/import/README.md index 0243b781..d25ac766 100644 --- a/demos/import/README.md +++ b/demos/import/README.md @@ -14,9 +14,9 @@ Simple spreadsheet-style demo: Board-export-shaped demo: - `board-export.csv` — realistic Trello-shaped board export fixture -- `board-export.mapping.json` — confirmed column mapping for `board-export.csv` -- `board-export.destination.example.json` — example destination config for an existing project -- `board-export.destination-new-project.example.json` — destination config that creates a new demo project +- `board-export.mapping.json` — confirmed card column mapping for `board-export.csv` +- `board-export.destination.example.json` — example card destination config for an existing project +- `board-export.destination-new-project.example.json` — card destination config that creates a new demo project The board-export demo shows the importer handling a service-style export generically. It does not rely on a Trello-specific parser or source mode. @@ -38,7 +38,7 @@ scripts/demo-import.sh \ ```bash cp demos/import/board-export.destination.example.json /tmp/board-destination.json -# Edit /tmp/board-destination.json and set project_id to your demo project. +# Edit /tmp/board-destination.json and set project_id/card_table_id to your demo project and card table. # Or use demos/import/board-export.destination-new-project.example.json to create a new demo project during execution. scripts/demo-import.sh \ @@ -74,7 +74,7 @@ scripts/demo-import.sh \ --repair-artifact /tmp/basecamp-board-import-demo ``` -The repair review summarizes completed operations, failed operations, pending todos, and guidance. +The repair review summarizes completed operations, failed operations, pending todos or cards, and guidance. After reviewing Basecamp state and the repair summary, create a fresh follow-up artifact for pending rows: diff --git a/demos/import/board-export.destination-new-project.example.json b/demos/import/board-export.destination-new-project.example.json index 54d6e0fd..b4194bae 100644 --- a/demos/import/board-export.destination-new-project.example.json +++ b/demos/import/board-export.destination-new-project.example.json @@ -1,6 +1,8 @@ { "schema_version": 1, + "resource_type": "cards", "mode": "new_project", - "project_name": "Board Export Import Demo", - "todolist_strategy": "create_from_column" + "project_name": "Imported board", + "card_table_id": "67890", + "column_strategy": "create_from_column" } diff --git a/demos/import/board-export.destination.example.json b/demos/import/board-export.destination.example.json index 923c7c02..79b20517 100644 --- a/demos/import/board-export.destination.example.json +++ b/demos/import/board-export.destination.example.json @@ -1,6 +1,8 @@ { "schema_version": 1, + "resource_type": "cards", "mode": "existing_project", "project_id": "12345", - "todolist_strategy": "create_from_column" + "card_table_id": "67890", + "column_strategy": "create_from_column" } diff --git a/demos/import/board-export.mapping.json b/demos/import/board-export.mapping.json index 6d5ba948..a19166d5 100644 --- a/demos/import/board-export.mapping.json +++ b/demos/import/board-export.mapping.json @@ -3,7 +3,7 @@ "record_id": { "column_index": 0, "column_name": "Card ID" }, "title": { "column_index": 1, "column_name": "Card Name" }, "description": { "column_index": 3, "column_name": "Card Description" }, - "todolist": { "column_index": 15, "column_name": "List Name" }, + "column": { "column_index": 15, "column_name": "List Name" }, "status": { "column_index": 18, "column_name": "Archived" }, "due_on": { "column_index": 6, "column_name": "Due Date" }, "attachment_urls": [ diff --git a/e2e/import.bats b/e2e/import.bats index db7b60a8..52544e18 100644 --- a/e2e/import.bats +++ b/e2e/import.bats @@ -22,7 +22,9 @@ from http.server import BaseHTTPRequestHandler request_log = os.environ["IMPORT_MOCK_REQUEST_LOG"] fail_todo_title = os.environ.get("IMPORT_MOCK_FAIL_TODO_TITLE", "") list_ids = {"Home": 901, "Events": 902} +column_ids = {"Home": 801, "Events": 802} next_todo_id = 1000 +next_card_id = 2000 class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -59,7 +61,10 @@ class Handler(BaseHTTPRequestHandler): self._write_json(200, { "id": 12345, "name": "Import Project", - "dock": [{"id": 777, "name": "todoset", "title": "To-dos", "enabled": True}], + "dock": [ + {"id": 777, "name": "todoset", "title": "To-dos", "enabled": True}, + {"id": 888, "name": "card_table", "title": "Card Table", "enabled": True} + ], }) return if self.path == "/99999/todosets/777/todolists.json": @@ -68,10 +73,16 @@ class Handler(BaseHTTPRequestHandler): if self.path.startswith("/99999/todolists/") and self.path.endswith("/todos.json"): self._write_json(200, []) return + if self.path == "/99999/card_tables/888": + self._write_json(200, {"id": 888, "title": "Card Table", "lists": []}) + return + if self.path.startswith("/99999/card_tables/lists/") and self.path.endswith("/cards.json"): + self._write_json(200, []) + return self._write_json(500, {"error": "unexpected GET", "path": self.path}) def do_POST(self): - global next_todo_id + global next_todo_id, next_card_id body, raw = self._read_body() self._record(body if body is not None else raw) if self.path == "/99999/todosets/777/todolists.json": @@ -92,6 +103,39 @@ class Handler(BaseHTTPRequestHandler): "creator": {"id": 1, "name": "Tester"} }) return + if self.path == "/99999/card_tables/888/columns.json": + title = (body or {}).get("title", "") + column_id = column_ids.get(title, 899) + self._write_json(201, { + "id": column_id, + "status": "active", + "title": title, + "type": "KanbanColumn", + "url": f"https://3.basecampapi.com/99999/card_tables/columns/{column_id}", + "app_url": f"https://3.basecamp.com/99999/buckets/12345/card_tables/888/columns/{column_id}", + "cards_count": 0, + "comment_count": 0, + "bucket": {"id": 12345, "name": "Import Project", "type": "Project"}, + "parent": {"id": 888, "title": "Card Table", "type": "CardTable", "url": "", "app_url": ""}, + "creator": {"id": 1, "name": "Tester"} + }) + return + if self.path.startswith("/99999/card_tables/lists/") and self.path.endswith("/cards.json"): + next_card_id += 1 + title = (body or {}).get("title", "") + self._write_json(201, { + "id": next_card_id, + "status": "active", + "title": title, + "content": (body or {}).get("content", ""), + "type": "Card", + "url": f"https://3.basecampapi.com/99999/card_tables/cards/{next_card_id}", + "app_url": f"https://3.basecamp.com/99999/buckets/12345/card_tables/cards/{next_card_id}", + "bucket": {"id": 12345, "name": "Import Project", "type": "Project"}, + "parent": {"id": 801, "title": "Column", "type": "KanbanColumn", "url": "", "app_url": ""}, + "creator": {"id": 1, "name": "Tester"} + }) + return if self.path.startswith("/99999/todolists/") and self.path.endswith("/todos.json"): content = (body or {}).get("content", "") if fail_todo_title and content == fail_todo_title: @@ -183,6 +227,19 @@ write_destination_json() { JSON } +write_card_destination_json() { + cat > destination.json <<'JSON' +{ + "schema_version": 1, + "resource_type": "cards", + "mode": "existing_project", + "project_id": "12345", + "card_table_id": "888", + "column_strategy": "create_from_column" +} +JSON +} + compile_import_artifact() { write_import_csv write_mapping_json @@ -316,6 +373,46 @@ configure_import_mock_basecamp() { export BASECAMP_TOKEN="test-token" } +@test "import execute creates card columns and cards against replay server" { + write_import_csv + write_mapping_json + write_card_destination_json + + run basecamp import inspect tasks.csv --json --sample-size 2 + assert_success + echo "$output" > inspection.json + + run basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.manifest.counts.cards" "2" + assert_json_value ".data.manifest.counts.card_columns" "2" + [[ -f basecamp-import/cards.csv ]] + + run basecamp import plan --artifact basecamp-import --json + assert_success + assert_json_value ".data.counts.cards" "2" + assert_output_contains "Row 1: create card \\\"Buy paint\\\"" + + start_import_mock + configure_import_mock_basecamp + + run basecamp import preflight --artifact basecamp-import --json + assert_success + is_valid_json + assert_json_value ".data.status" "passed" + + run basecamp import execute --artifact basecamp-import --approved --json + assert_success + is_valid_json + assert_json_value ".data.created.card_columns" "2" + assert_json_value ".data.created.cards" "2" + + jq -e 'select(.method == "POST" and .path == "/99999/card_tables/888/columns.json" and .body.title == "Home")' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + jq -e 'select(.method == "POST" and .path == "/99999/card_tables/lists/801/cards.json" and .body.title == "Buy paint" and (.body.content | contains("Get blue, low VOC")))' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null + jq -e 'select(.method == "POST" and .path == "/99999/card_tables/lists/802/cards.json" and .body.title == "Book venue")' "$IMPORT_MOCK_REQUEST_LOG" >/dev/null +} + @test "import execute creates todolists and todos against replay server" { compile_import_artifact start_import_mock diff --git a/internal/commands/import.go b/internal/commands/import.go index 6d2d4395..1448210a 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/spf13/cobra" @@ -36,9 +37,10 @@ func NewImportCmd() *cobra.Command { } type importWriteClient struct { - cmd *cobra.Command - app *appctx.App - todosetID string + cmd *cobra.Command + app *appctx.App + todosetID string + cardTableID string } type importPreflightClient struct { @@ -46,6 +48,83 @@ type importPreflightClient struct { app *appctx.App } +func (c *importPreflightClient) CardTableID(ctx context.Context, projectID int64) (int64, error) { + resolvedID, err := importCardTableID(ctx, c.app, strconv.FormatInt(projectID, 10)) + if err != nil { + return 0, err + } + cardTableID, err := strconv.ParseInt(resolvedID, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid card table ID") + } + return cardTableID, nil +} + +func importCardTableID(ctx context.Context, app *appctx.App, projectID string) (string, error) { + path := fmt.Sprintf("/projects/%s.json", projectID) + resp, err := app.Account().Get(ctx, path) + if err != nil { + return "", convertSDKError(err) + } + var project struct { + Dock []struct { + Name string `json:"name"` + ID int64 `json:"id"` + Title string `json:"title"` + } `json:"dock"` + } + if err := resp.UnmarshalData(&project); err != nil { + return "", fmt.Errorf("failed to parse project: %w", err) + } + var cardTables []struct { + ID int64 + Title string + } + for _, item := range project.Dock { + if item.Name == "kanban_board" || item.Name == "card_table" { + cardTables = append(cardTables, struct { + ID int64 + Title string + }{ID: item.ID, Title: item.Title}) + } + } + if len(cardTables) == 0 { + return "", output.ErrNotFound("card table", projectID) + } + if len(cardTables) == 1 { + return fmt.Sprintf("%d", cardTables[0].ID), nil + } + lines := make([]string, 0, len(cardTables)) + for _, table := range cardTables { + lines = append(lines, fmt.Sprintf(" %d %s", table.ID, table.Title)) + } + return "", &output.Error{Code: output.CodeAmbiguous, Message: "Multiple card tables found", Hint: fmt.Sprintf("Specify card_table_id in destination.json:\n%s", strings.Join(lines, "\n"))} +} + +func (c *importPreflightClient) ExistingCardColumns(ctx context.Context, cardTableID int64) ([]importer.ExistingCardColumn, error) { + cardTable, err := c.app.Account().CardTables().Get(ctx, cardTableID) + if err != nil { + return nil, convertSDKError(err) + } + out := make([]importer.ExistingCardColumn, 0, len(cardTable.Lists)) + for _, column := range cardTable.Lists { + out = append(out, importer.ExistingCardColumn{ID: column.ID, Name: column.Title}) + } + return out, nil +} + +func (c *importPreflightClient) ExistingCards(ctx context.Context, columnID int64) ([]importer.ExistingCard, error) { + result, err := c.app.Account().Cards().List(ctx, columnID, &basecamp.CardListOptions{Limit: -1}) + if err != nil { + return nil, convertSDKError(err) + } + out := make([]importer.ExistingCard, 0, len(result.Cards)) + for _, card := range result.Cards { + out = append(out, importer.ExistingCard{ID: card.ID, Title: card.Title}) + } + return out, nil +} + func (c *importPreflightClient) ExistingTodos(ctx context.Context, todolistID int64) ([]importer.ExistingTodo, error) { result, err := c.app.Account().Todos().List(ctx, todolistID, &basecamp.TodoListOptions{Limit: -1}) if err != nil { @@ -113,6 +192,37 @@ func (c *importWriteClient) CreateTodolist(ctx context.Context, projectID int64, return todolist.ID, nil } +func (c *importWriteClient) CardTableID(ctx context.Context, projectID int64) (int64, error) { + if c.cardTableID == "" { + resolvedID, err := importCardTableID(ctx, c.app, strconv.FormatInt(projectID, 10)) + if err != nil { + return 0, err + } + c.cardTableID = resolvedID + } + cardTableID, err := strconv.ParseInt(c.cardTableID, 10, 64) + if err != nil { + return 0, output.ErrUsage("Invalid card table ID") + } + return cardTableID, nil +} + +func (c *importWriteClient) CreateCardColumn(ctx context.Context, cardTableID int64, name string) (int64, error) { + column, err := c.app.Account().CardColumns().Create(ctx, cardTableID, &basecamp.CreateColumnRequest{Title: name}) + if err != nil { + return 0, convertSDKError(err) + } + return column.ID, nil +} + +func (c *importWriteClient) CreateCard(ctx context.Context, columnID int64, card importer.ExecutableCard) (int64, error) { + created, err := c.app.Account().Cards().Create(ctx, columnID, &basecamp.CreateCardRequest{Title: card.Title, Content: card.Content}) + if err != nil { + return 0, convertSDKError(err) + } + return created.ID, nil +} + func (c *importWriteClient) CreateTodo(ctx context.Context, todolistID int64, todo importer.ExecutableTodo) (int64, error) { created, err := c.app.Account().Todos().Create(ctx, todolistID, &basecamp.CreateTodoRequest{Content: todo.Title, Description: todo.Description, DueOn: todo.DueOn}) if err != nil { @@ -205,7 +315,7 @@ func newImportFollowupCmd() *cobra.Command { fmt.Fprintln(cmd.OutOrStdout(), result.Status) return nil } - return app.OK(result, output.WithSummary(fmt.Sprintf("Compiled follow-up artifact with %d pending todos", result.Manifest.Counts.Todos))) + return app.OK(result, output.WithSummary(importCompileSummary("Compiled follow-up artifact", result.Manifest.Counts))) }, } @@ -278,7 +388,7 @@ func newImportExecuteCmd() *cobra.Command { if err != nil { return err } - return app.OK(result, output.WithSummary(fmt.Sprintf("Imported %d todos", result.Created.Todos))) + return app.OK(result, output.WithSummary(importExecuteSummary(result.Created))) }, } @@ -333,7 +443,7 @@ func newImportCompileCmd() *cobra.Command { fmt.Fprintln(cmd.OutOrStdout(), result.Status) return nil } - return app.OK(result, output.WithSummary(fmt.Sprintf("Compiled import artifact with %d todos", result.Manifest.Counts.Todos))) + return app.OK(result, output.WithSummary(importCompileSummary("Compiled import artifact", result.Manifest.Counts))) }, } @@ -398,7 +508,7 @@ func newImportPlanCmd() *cobra.Command { fmt.Fprintln(cmd.OutOrStdout(), plan.Status) return nil } - return app.OK(plan, output.WithSummary(fmt.Sprintf("Planned %d todos", plan.Counts.Todos))) + return app.OK(plan, output.WithSummary(importPlanSummary(plan.Counts))) }, } @@ -409,6 +519,27 @@ func newImportPlanCmd() *cobra.Command { return cmd } +func importCompileSummary(prefix string, counts importer.PlanCounts) string { + if counts.Cards > 0 || counts.CardColumns > 0 { + return fmt.Sprintf("%s with %d cards", prefix, counts.Cards) + } + return fmt.Sprintf("%s with %d todos", prefix, counts.Todos) +} + +func importPlanSummary(counts importer.PlanCounts) string { + if counts.Cards > 0 || counts.CardColumns > 0 { + return fmt.Sprintf("Planned %d cards", counts.Cards) + } + return fmt.Sprintf("Planned %d todos", counts.Todos) +} + +func importExecuteSummary(counts importer.ExecuteCounts) string { + if counts.Cards > 0 || counts.CardColumns > 0 { + return fmt.Sprintf("Imported %d cards", counts.Cards) + } + return fmt.Sprintf("Imported %d todos", counts.Todos) +} + func newImportInspectCmd() *cobra.Command { var sampleSize int diff --git a/internal/importer/artifact.go b/internal/importer/artifact.go index d1318ff5..8dbfe2c7 100644 --- a/internal/importer/artifact.go +++ b/internal/importer/artifact.go @@ -13,6 +13,7 @@ const ( artifactFormat = "basecamp-import-csv-v1" artifactManifestName = "import.json" artifactTodosFileName = "todos.csv" + artifactCardsFileName = "cards.csv" ) var artifactTodoHeader = []string{ @@ -34,6 +35,26 @@ var artifactTodoHeader = []string{ "custom_fields_json", } +var artifactCardHeader = []string{ + "source_path", + "source_row", + "source_record_id", + "project_id", + "project_name", + "card_table_id", + "column_id", + "column_name", + "title", + "content", + "due_on", + "assignee_emails", + "assignee_names", + "status", + "attachment_urls_json", + "comments_json", + "custom_fields_json", +} + // ImportArtifactManifest describes a validated Basecamp import CSV artifact. type ImportArtifactManifest struct { SchemaVersion int `json:"schema_version"` @@ -48,7 +69,8 @@ type ImportArtifactManifest struct { // ArtifactFiles names the files that belong to an import artifact. type ArtifactFiles struct { - Todos string `json:"todos"` + Todos string `json:"todos,omitempty"` + Cards string `json:"cards,omitempty"` } // CompileArtifactResult reports the artifact written by CompileArtifact. @@ -65,6 +87,7 @@ type artifactTodoRow struct { SourceRecordID string `json:"source_record_id"` ProjectID string `json:"project_id"` ProjectName string `json:"project_name"` + CardTableID int64 `json:"card_table_id,omitempty"` TodolistID int64 `json:"todolist_id"` TodolistName string `json:"todolist_name"` Title string `json:"title"` @@ -91,18 +114,41 @@ func CompileArtifact(inspection *Inspection, mapping *MappingConfig, destination return nil, fmt.Errorf("import artifact requires confirmed mapping and destination choices") } - rows := make([]artifactTodoRow, 0, plan.Counts.Todos) + resourceType, err := destinationResourceType(&plan.Destination) + if err != nil { + return nil, err + } + rowCap := plan.Counts.Todos + if resourceType == resourceTypeCards { + rowCap = plan.Counts.Cards + } + rows := make([]artifactTodoRow, 0, rowCap) for _, op := range plan.Operations { - if op.Op != "create_todo" { + if resourceType == resourceTypeTodos && op.Op != "create_todo" { + continue + } + if resourceType == resourceTypeCards && op.Op != "create_card" { continue } if strings.TrimSpace(op.Title) == "" { return nil, fmt.Errorf("source row %d has a blank title", op.SourceRow) } emails, names := splitAssignees(op.Assignees) - todolistID, err := parseOptionalInt64(op.TodolistID) + groupID := op.TodolistID + if resourceType == resourceTypeCards { + groupID = op.ColumnID + } + parsedGroupID, err := parseOptionalInt64(groupID) if err != nil { - return nil, fmt.Errorf("source row %d has invalid todolist_id: %w", op.SourceRow, err) + return nil, fmt.Errorf("source row %d has invalid destination group id: %w", op.SourceRow, err) + } + cardTableID, err := parseOptionalInt64(op.CardTableID) + if err != nil { + return nil, fmt.Errorf("source row %d has invalid card_table_id: %w", op.SourceRow, err) + } + groupName := op.TodolistName + if resourceType == resourceTypeCards { + groupName = op.ColumnName } rows = append(rows, artifactTodoRow{ SourcePath: inspection.ExportPath, @@ -110,8 +156,9 @@ func CompileArtifact(inspection *Inspection, mapping *MappingConfig, destination SourceRecordID: op.SourceRecordID, ProjectID: op.ProjectID, ProjectName: op.ProjectName, - TodolistID: todolistID, - TodolistName: op.TodolistName, + CardTableID: cardTableID, + TodolistID: parsedGroupID, + TodolistName: groupName, Title: op.Title, Description: op.Description, DueOn: op.DueOn, @@ -124,6 +171,10 @@ func CompileArtifact(inspection *Inspection, mapping *MappingConfig, destination }) } + files := ArtifactFiles{Todos: artifactTodosFileName} + if resourceType == resourceTypeCards { + files = ArtifactFiles{Cards: artifactCardsFileName} + } manifest := ImportArtifactManifest{ SchemaVersion: planSchemaVersion, Status: "compiled", @@ -132,7 +183,7 @@ func CompileArtifact(inspection *Inspection, mapping *MappingConfig, destination SourceFingerprint: inspection.Fingerprint, Destination: *destination, Counts: plan.Counts, - Files: ArtifactFiles{Todos: artifactTodosFileName}, + Files: files, } if err := writeArtifact(outDir, manifest, rows); err != nil { return nil, err @@ -157,17 +208,26 @@ func PlanFromArtifact(artifactDir string) (*Plan, error) { Questions: []MappingQuestion{}, } - operations := make([]PlannedOperation, 0, len(rows)+manifest.Counts.Todolists+manifest.Counts.Projects) + resourceType, err := destinationResourceType(&manifest.Destination) + if err != nil { + return nil, err + } + operations := make([]PlannedOperation, 0, len(rows)+manifest.Counts.Todolists+manifest.Counts.CardColumns+manifest.Counts.Projects) if manifest.Destination.Mode == "new_project" { operations = append(operations, PlannedOperation{Op: "create_project", ProjectName: manifest.Destination.ProjectName}) } - if shouldCreateTodolists(&manifest.Destination) { + if resourceType == resourceTypeTodos && shouldCreateTodolists(&manifest.Destination) { for _, name := range artifactTodolistNames(rows) { operations = append(operations, PlannedOperation{Op: "create_todolist", ProjectID: manifest.Destination.ProjectID, ProjectName: manifest.Destination.ProjectName, TodolistName: name}) } } + if resourceType == resourceTypeCards && shouldCreateCardColumns(&manifest.Destination) { + for _, name := range artifactCardColumnNames(rows) { + operations = append(operations, PlannedOperation{Op: "create_card_column", ProjectID: manifest.Destination.ProjectID, ProjectName: manifest.Destination.ProjectName, CardTableID: manifest.Destination.CardTableID, ColumnName: name}) + } + } for _, row := range rows { - operations = append(operations, PlannedOperation{ + op := PlannedOperation{ Op: "create_todo", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, @@ -183,7 +243,16 @@ func PlanFromArtifact(artifactDir string) (*Plan, error) { AttachmentURLs: row.AttachmentURLs, Comments: row.Comments, CustomFields: row.CustomFields, - }) + } + if resourceType == resourceTypeCards { + op.Op = "create_card" + op.CardTableID = formatOptionalInt64(row.CardTableID) + op.ColumnID = formatOptionalInt64(row.TodolistID) + op.ColumnName = row.TodolistName + op.TodolistID = "" + op.TodolistName = "" + } + operations = append(operations, op) } plan.Operations = operations plan.DryRunMarkdown = renderDryRunMarkdown(plan) @@ -202,6 +271,23 @@ func readArtifact(artifactDir string) (*ImportArtifactManifest, []artifactTodoRo if manifest.ArtifactFormat != artifactFormat { return nil, nil, fmt.Errorf("unsupported artifact format %q", manifest.ArtifactFormat) } + resourceType, err := destinationResourceType(&manifest.Destination) + if err != nil { + return nil, nil, err + } + if resourceType == resourceTypeCards { + if manifest.Files.Cards == "" { + return nil, nil, fmt.Errorf("artifact cards file is required") + } + rows, err := readArtifactCards(filepath.Join(artifactDir, manifest.Files.Cards)) + if err != nil { + return nil, nil, err + } + if len(rows) != manifest.Counts.Cards { + return nil, nil, fmt.Errorf("artifact card count %d does not match manifest count %d", len(rows), manifest.Counts.Cards) + } + return &manifest, rows, nil + } if manifest.Files.Todos == "" { return nil, nil, fmt.Errorf("artifact todos file is required") } @@ -227,7 +313,13 @@ func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifa if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), manifestData, 0o644); err != nil { //nolint:gosec // G306: Import artifact manifests are not secrets return fmt.Errorf("write artifact manifest: %w", err) } - if err := writeArtifactTodos(filepath.Join(outDir, artifactTodosFileName), rows); err != nil { + if manifest.Files.Cards != "" { + if err := writeArtifactCards(filepath.Join(outDir, manifest.Files.Cards), rows); err != nil { + return err + } + return nil + } + if err := writeArtifactTodos(filepath.Join(outDir, manifest.Files.Todos), rows); err != nil { return err } return nil @@ -260,6 +352,33 @@ func writeArtifactTodos(path string, rows []artifactTodoRow) error { return nil } +func writeArtifactCards(path string, rows []artifactTodoRow) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("write artifact cards: %w", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + if err := writer.Write(artifactCardHeader); err != nil { + return fmt.Errorf("write artifact cards header: %w", err) + } + for _, row := range rows { + record, err := row.toCardCSVRecord() + if err != nil { + return err + } + if err := writer.Write(record); err != nil { + return fmt.Errorf("write artifact cards row: %w", err) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return fmt.Errorf("write artifact cards: %w", err) + } + return nil +} + func readArtifactTodos(path string) ([]artifactTodoRow, error) { file, err := os.Open(path) if err != nil { @@ -293,6 +412,39 @@ func readArtifactTodos(path string) ([]artifactTodoRow, error) { return rows, nil } +func readArtifactCards(path string) ([]artifactTodoRow, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read artifact cards: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.FieldsPerRecord = len(artifactCardHeader) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse artifact cards: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("artifact cards file is empty") + } + if strings.Join(records[0], "\x00") != strings.Join(artifactCardHeader, "\x00") { + return nil, fmt.Errorf("artifact cards header does not match Basecamp import CSV v1") + } + rows := make([]artifactTodoRow, 0, len(records)-1) + for i, record := range records[1:] { + row, err := artifactCardRowFromCSVRecord(record) + if err != nil { + return nil, fmt.Errorf("artifact cards row %d: %w", i+1, err) + } + if strings.TrimSpace(row.Title) == "" { + return nil, fmt.Errorf("artifact cards row %d has a blank title", i+1) + } + rows = append(rows, row) + } + return rows, nil +} + func (r artifactTodoRow) toCSVRecord() ([]string, error) { attachments, err := encodeJSONStringSlice(r.AttachmentURLs) if err != nil { @@ -326,6 +478,40 @@ func (r artifactTodoRow) toCSVRecord() ([]string, error) { }, nil } +func (r artifactTodoRow) toCardCSVRecord() ([]string, error) { + attachments, err := encodeJSONStringSlice(r.AttachmentURLs) + if err != nil { + return nil, err + } + comments, err := encodeJSONStringSlice(r.Comments) + if err != nil { + return nil, err + } + customFields, err := encodeJSONStringMap(r.CustomFields) + if err != nil { + return nil, err + } + return []string{ + r.SourcePath, + fmt.Sprintf("%d", r.SourceRow), + r.SourceRecordID, + r.ProjectID, + r.ProjectName, + formatOptionalInt64(r.CardTableID), + formatOptionalInt64(r.TodolistID), + r.TodolistName, + r.Title, + r.Description, + r.DueOn, + strings.Join(r.AssigneeEmails, ";"), + strings.Join(r.AssigneeNames, ";"), + r.Status, + attachments, + comments, + customFields, + }, nil +} + func artifactTodoRowFromCSVRecord(record []string) (artifactTodoRow, error) { var row artifactTodoRow row.SourcePath = record[0] @@ -359,6 +545,43 @@ func artifactTodoRowFromCSVRecord(record []string) (artifactTodoRow, error) { return row, nil } +func artifactCardRowFromCSVRecord(record []string) (artifactTodoRow, error) { + var row artifactTodoRow + row.SourcePath = record[0] + if _, err := fmt.Sscanf(record[1], "%d", &row.SourceRow); err != nil { + return row, fmt.Errorf("invalid source_row %q", record[1]) + } + row.SourceRecordID = record[2] + row.ProjectID = record[3] + row.ProjectName = record[4] + var err error + row.CardTableID, err = parseOptionalInt64(record[5]) + if err != nil { + return row, fmt.Errorf("invalid card_table_id %q", record[5]) + } + row.TodolistID, err = parseOptionalInt64(record[6]) + if err != nil { + return row, fmt.Errorf("invalid column_id %q", record[6]) + } + row.TodolistName = record[7] + row.Title = record[8] + row.Description = record[9] + row.DueOn = record[10] + row.AssigneeEmails = splitSemicolonList(record[11]) + row.AssigneeNames = splitSemicolonList(record[12]) + row.Status = record[13] + if err := decodeJSONStringSlice(record[14], &row.AttachmentURLs); err != nil { + return row, fmt.Errorf("invalid attachment_urls_json: %w", err) + } + if err := decodeJSONStringSlice(record[15], &row.Comments); err != nil { + return row, fmt.Errorf("invalid comments_json: %w", err) + } + if err := decodeJSONStringMap(record[16], &row.CustomFields); err != nil { + return row, fmt.Errorf("invalid custom_fields_json: %w", err) + } + return row, nil +} + func splitAssignees(values []string) ([]string, []string) { emails := make([]string, 0) names := make([]string, 0) @@ -393,6 +616,23 @@ func artifactTodolistNames(rows []artifactTodoRow) []string { return out } +func artifactCardColumnNames(rows []artifactTodoRow) []string { + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, row := range rows { + name := strings.TrimSpace(row.TodolistName) + if name == "" { + name = "Imported cards" + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + func encodeJSONStringSlice(values []string) (string, error) { if values == nil { values = []string{} diff --git a/internal/importer/artifact_test.go b/internal/importer/artifact_test.go index 980bb915..b864bcc4 100644 --- a/internal/importer/artifact_test.go +++ b/internal/importer/artifact_test.go @@ -89,6 +89,31 @@ func TestPlanFromArtifactMatchesCompiledOperations(t *testing.T) { } } +func TestCompileArtifactWritesCardsArtifact(t *testing.T) { + inspection := inspectTempCSV(t, "id,title,column\n1,First,Backlog\n2,Second,Doing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, RecordID: &ColumnRef{ColumnIndex: 0}, Title: &ColumnRef{ColumnIndex: 1}, Column: &ColumnRef{ColumnIndex: 2}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, ResourceType: resourceTypeCards, Mode: "existing_project", ProjectID: "123", CardTableID: "888", ColumnStrategy: "create_from_column"} + outDir := filepath.Join(t.TempDir(), "artifact") + + result, err := CompileArtifact(inspection, mapping, destination, outDir) + if err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + if result.Manifest.Counts.Cards != 2 || result.Manifest.Counts.CardColumns != 2 || result.Manifest.Files.Cards != artifactCardsFileName { + t.Fatalf("manifest = %+v", result.Manifest) + } + if _, err := os.Stat(filepath.Join(outDir, artifactCardsFileName)); err != nil { + t.Fatalf("cards.csv not written: %v", err) + } + plan, err := PlanFromArtifact(outDir) + if err != nil { + t.Fatalf("PlanFromArtifact() error = %v", err) + } + if plan.Operations[0].Op != "create_card_column" || plan.Operations[2].Op != "create_card" { + t.Fatalf("operations = %+v", plan.Operations) + } +} + func TestCompileArtifactRejectsUnconfirmedInputs(t *testing.T) { inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") mapping := &MappingConfig{SchemaVersion: planSchemaVersion} diff --git a/internal/importer/executor.go b/internal/importer/executor.go index 078b8417..8b32b308 100644 --- a/internal/importer/executor.go +++ b/internal/importer/executor.go @@ -23,6 +23,9 @@ type ArtifactWriteClient interface { CreateProject(ctx context.Context, name string) (int64, error) CreateTodolist(ctx context.Context, projectID int64, name string) (int64, error) CreateTodo(ctx context.Context, todolistID int64, todo ExecutableTodo) (int64, error) + CardTableID(ctx context.Context, projectID int64) (int64, error) + CreateCardColumn(ctx context.Context, cardTableID int64, name string) (int64, error) + CreateCard(ctx context.Context, columnID int64, card ExecutableCard) (int64, error) } // ExecutableTodo is the normalized todo payload sent to Basecamp. @@ -32,6 +35,12 @@ type ExecutableTodo struct { DueOn string } +// ExecutableCard is the normalized card payload sent to Basecamp. +type ExecutableCard struct { + Title string + Content string +} + // ExecuteResult reports records created from a validated import artifact. type ExecuteResult struct { SchemaVersion int `json:"schema_version"` @@ -43,9 +52,11 @@ type ExecuteResult struct { // ExecuteCounts counts records created by artifact execution. type ExecuteCounts struct { - Projects int `json:"projects"` - Todolists int `json:"todolists"` - Todos int `json:"todos"` + Projects int `json:"projects"` + Todolists int `json:"todolists"` + Todos int `json:"todos"` + CardColumns int `json:"card_columns,omitempty"` + Cards int `json:"cards,omitempty"` } // ExecuteSkipped reports artifact data that was preserved but not written as a native Basecamp field. @@ -79,6 +90,9 @@ type ExecutionLedgerOperation struct { ProjectName string `json:"project_name,omitempty"` TodolistID int64 `json:"todolist_id,omitempty"` TodolistName string `json:"todolist_name,omitempty"` + CardTableID int64 `json:"card_table_id,omitempty"` + ColumnID int64 `json:"column_id,omitempty"` + ColumnName string `json:"column_name,omitempty"` Title string `json:"title,omitempty"` CreatedID int64 `json:"created_id,omitempty"` At string `json:"at"` @@ -115,11 +129,32 @@ func ExecuteArtifact(ctx context.Context, artifactDir string, client ArtifactWri return nil, err } - listIDs, err := executeArtifactTodolists(ctx, artifactDir, client, projectID, manifest, rows, ledger, result) + resourceType, err := destinationResourceType(&manifest.Destination) if err != nil { return nil, err } + if resourceType == resourceTypeCards { + if err := executeArtifactCards(ctx, artifactDir, client, projectID, manifest, rows, ledger, result); err != nil { + return nil, err + } + } else { + if err := executeArtifactTodos(ctx, artifactDir, client, projectID, manifest, rows, ledger, result); err != nil { + return nil, err + } + } + if err := finishArtifactExecution(artifactDir, ledger, "completed", result, nil); err != nil { + ledgerFinalized = true + return nil, err + } + ledgerFinalized = true + return result, nil +} +func executeArtifactTodos(ctx context.Context, artifactDir string, client ArtifactWriteClient, projectID int64, manifest *ImportArtifactManifest, rows []artifactTodoRow, ledger *ExecutionLedger, result *ExecuteResult) error { + listIDs, err := executeArtifactTodolists(ctx, artifactDir, client, projectID, manifest, rows, ledger, result) + if err != nil { + return err + } for _, row := range rows { listName := row.TodolistName if strings.TrimSpace(listName) == "" { @@ -130,7 +165,7 @@ func ExecuteArtifact(ctx context.Context, artifactDir string, client ArtifactWri todolistID = listIDs[listName] } if todolistID == 0 { - return nil, fmt.Errorf("source row %d has no executable todolist", row.SourceRow) + return fmt.Errorf("source row %d has no executable todolist", row.SourceRow) } if len(row.AssigneeEmails) > 0 || len(row.AssigneeNames) > 0 { result.Skipped = append(result.Skipped, ExecuteSkipped{SourceRow: row.SourceRow, Field: "assignees", Reason: "artifact does not contain Basecamp person IDs"}) @@ -141,21 +176,54 @@ func ExecuteArtifact(ctx context.Context, artifactDir string, client ArtifactWri err := fmt.Errorf("create todo from source row %d: %w", row.SourceRow, createErr) appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todo", Status: "failed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, TodolistID: todolistID, TodolistName: listName, Title: row.Title, Error: err.Error()}) _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) - return nil, err + return err } result.Created.Todos++ ledger.Created.Todos = result.Created.Todos appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_todo", Status: "completed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, TodolistID: todolistID, TodolistName: listName, Title: row.Title, CreatedID: createdID}) if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { - return nil, err + return err } } - if err := finishArtifactExecution(artifactDir, ledger, "completed", result, nil); err != nil { - ledgerFinalized = true - return nil, err + return nil +} + +func executeArtifactCards(ctx context.Context, artifactDir string, client ArtifactWriteClient, projectID int64, manifest *ImportArtifactManifest, rows []artifactTodoRow, ledger *ExecutionLedger, result *ExecuteResult) error { + columnIDs, cardTableID, err := executeArtifactCardColumns(ctx, artifactDir, client, projectID, manifest, rows, ledger, result) + if err != nil { + return err } - ledgerFinalized = true - return result, nil + for _, row := range rows { + columnName := row.TodolistName + if strings.TrimSpace(columnName) == "" { + columnName = "Imported cards" + } + columnID := row.TodolistID + if columnID == 0 { + columnID = columnIDs[columnName] + } + if columnID == 0 { + return fmt.Errorf("source row %d has no executable card column", row.SourceRow) + } + if len(row.AssigneeEmails) > 0 || len(row.AssigneeNames) > 0 { + result.Skipped = append(result.Skipped, ExecuteSkipped{SourceRow: row.SourceRow, Field: "assignees", Reason: "artifact does not contain Basecamp person IDs"}) + } + card := ExecutableCard{Title: row.Title, Content: executionDescription(row)} + createdID, createErr := client.CreateCard(ctx, columnID, card) + if createErr != nil { + err := fmt.Errorf("create card from source row %d: %w", row.SourceRow, createErr) + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_card", Status: "failed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, CardTableID: cardTableID, ColumnID: columnID, ColumnName: columnName, Title: row.Title, Error: err.Error()}) + _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) + return err + } + result.Created.Cards++ + ledger.Created.Cards = result.Created.Cards + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_card", Status: "completed", SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, ProjectID: projectID, CardTableID: cardTableID, ColumnID: columnID, ColumnName: columnName, Title: row.Title, CreatedID: createdID}) + if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { + return err + } + } + return nil } func executeArtifactProject(ctx context.Context, artifactDir string, client ArtifactWriteClient, manifest *ImportArtifactManifest, ledger *ExecutionLedger, result *ExecuteResult) (int64, error) { @@ -216,6 +284,49 @@ func executeArtifactTodolists(ctx context.Context, artifactDir string, client Ar return listIDs, nil } +func executeArtifactCardColumns(ctx context.Context, artifactDir string, client ArtifactWriteClient, projectID int64, manifest *ImportArtifactManifest, rows []artifactTodoRow, ledger *ExecutionLedger, result *ExecuteResult) (map[string]int64, int64, error) { + cardTableID, err := parseOptionalInt64(manifest.Destination.CardTableID) + if err != nil { + return nil, 0, fmt.Errorf("invalid destination card_table_id: %w", err) + } + if cardTableID == 0 { + cardTableID, err = client.CardTableID(ctx, projectID) + if err != nil { + return nil, 0, err + } + } + columnIDs := make(map[string]int64) + if manifest.Destination.ColumnStrategy == "existing_column" { + id, err := parseOptionalInt64(manifest.Destination.ColumnID) + if err != nil { + return nil, 0, fmt.Errorf("invalid destination column_id: %w", err) + } + if id == 0 { + return nil, 0, fmt.Errorf("artifact destination column_id is required for card execution") + } + columnIDs[manifest.Destination.ColumnName] = id + columnIDs["Imported cards"] = id + return columnIDs, cardTableID, nil + } + for _, name := range artifactCardColumnNames(rows) { + id, createErr := client.CreateCardColumn(ctx, cardTableID, name) + if createErr != nil { + err := fmt.Errorf("create card column %q: %w", name, createErr) + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_card_column", Status: "failed", ProjectID: projectID, CardTableID: cardTableID, ColumnName: name, Error: err.Error()}) + _ = writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger) + return nil, 0, err + } + columnIDs[name] = id + result.Created.CardColumns++ + ledger.Created.CardColumns = result.Created.CardColumns + appendExecutionLedgerOperation(ledger, ExecutionLedgerOperation{Op: "create_card_column", Status: "completed", ProjectID: projectID, CardTableID: cardTableID, ColumnID: id, ColumnName: name, CreatedID: id}) + if err := writeExecutionLedger(filepath.Join(artifactDir, artifactExecutionFileName), ledger); err != nil { + return nil, 0, err + } + } + return columnIDs, cardTableID, nil +} + func appendExecutionLedgerOperation(ledger *ExecutionLedger, op ExecutionLedgerOperation) { op.At = time.Now().UTC().Format(time.RFC3339) ledger.Operations = append(ledger.Operations, op) diff --git a/internal/importer/executor_test.go b/internal/importer/executor_test.go index 917f73e8..12da2527 100644 --- a/internal/importer/executor_test.go +++ b/internal/importer/executor_test.go @@ -13,6 +13,8 @@ type fakeWriteClient struct { projects []string todolists []fakeCreatedTodolist todos []fakeCreatedTodo + cardColumns []fakeCreatedCardColumn + cards []fakeCreatedCard failTodoRows map[string]error } @@ -28,6 +30,18 @@ type fakeCreatedTodo struct { ID int64 } +type fakeCreatedCardColumn struct { + CardTableID int64 + Name string + ID int64 +} + +type fakeCreatedCard struct { + ColumnID int64 + Card ExecutableCard + ID int64 +} + func (f *fakeWriteClient) CreateProject(ctx context.Context, name string) (int64, error) { f.projects = append(f.projects, name) return f.next(), nil @@ -50,6 +64,22 @@ func (f *fakeWriteClient) CreateTodo(ctx context.Context, todolistID int64, todo return id, nil } +func (f *fakeWriteClient) CardTableID(ctx context.Context, projectID int64) (int64, error) { + return 888, nil +} + +func (f *fakeWriteClient) CreateCardColumn(ctx context.Context, cardTableID int64, name string) (int64, error) { + id := f.next() + f.cardColumns = append(f.cardColumns, fakeCreatedCardColumn{CardTableID: cardTableID, Name: name, ID: id}) + return id, nil +} + +func (f *fakeWriteClient) CreateCard(ctx context.Context, columnID int64, card ExecutableCard) (int64, error) { + id := f.next() + f.cards = append(f.cards, fakeCreatedCard{ColumnID: columnID, Card: card, ID: id}) + return id, nil +} + func (f *fakeWriteClient) next() int64 { if f.nextID == 0 { f.nextID = 100 @@ -92,6 +122,29 @@ func TestExecuteArtifactCreatesTodolistsAndTodos(t *testing.T) { } } +func TestExecuteArtifactCreatesCardColumnsAndCards(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, ResourceType: resourceTypeCards, Mode: "existing_project", ProjectID: "123", CardTableID: "888", ColumnStrategy: "create_from_column"}) + client := &fakeWriteClient{} + + result, err := ExecuteArtifact(context.Background(), outDir, client, ExecuteOptions{Approved: true}) + if err != nil { + t.Fatalf("ExecuteArtifact() error = %v", err) + } + if result.Created.CardColumns != 2 || result.Created.Cards != 2 { + t.Fatalf("created = %+v", result.Created) + } + if len(client.cardColumns) != 2 || client.cardColumns[0].Name != "Backlog" || len(client.cards) != 2 || client.cards[0].Card.Title != "First" { + t.Fatalf("client = %+v", client) + } + var ledger ExecutionLedger + if err := readJSONData(filepath.Join(outDir, artifactExecutionFileName), &ledger); err != nil { + t.Fatalf("read ledger: %v", err) + } + if ledger.Operations[0].Op != "create_card_column" || ledger.Operations[2].Op != "create_card" { + t.Fatalf("ledger operations = %+v", ledger.Operations) + } +} + func TestExecuteArtifactUsesExistingTodolist(t *testing.T) { outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) client := &fakeWriteClient{} diff --git a/internal/importer/followup.go b/internal/importer/followup.go index 2648e9cf..de2c2e89 100644 --- a/internal/importer/followup.go +++ b/internal/importer/followup.go @@ -19,7 +19,8 @@ type FollowupArtifactResult struct { Status string `json:"status"` ArtifactPath string `json:"artifact_path"` Manifest ImportArtifactManifest `json:"manifest"` - PendingTodos []RepairPendingTodo `json:"pending_todos"` + PendingTodos []RepairPendingTodo `json:"pending_todos,omitempty"` + PendingCards []RepairPendingTodo `json:"pending_cards,omitempty"` Guidance []string `json:"guidance"` } @@ -51,59 +52,82 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* if repair.Status != "review_required" { return nil, fmt.Errorf("follow-up artifact requires repair status review_required, got %q", repair.Status) } - if len(repair.PendingTodos) == 0 { - return nil, fmt.Errorf("follow-up artifact has no pending todos") + resourceType, err := destinationResourceType(&manifest.Destination) + if err != nil { + return nil, err + } + pendingRecords := repair.PendingTodos + if resourceType == resourceTypeCards { + pendingRecords = repair.PendingCards + } + if len(pendingRecords) == 0 { + return nil, fmt.Errorf("follow-up artifact has no pending %s", resourceType) } projectID, projectName, err := followupProject(manifest, repair.CompletedOperations) if err != nil { return nil, err } - listIDs := followupTodolistIDs(repair.CompletedOperations) - completedTodoRows := completedTodoSourceRows(repair.CompletedOperations) + groupIDs := followupGroupIDs(resourceType, repair.CompletedOperations) + completedRows := completedSourceRows(resourceType, repair.CompletedOperations) - pendingRows := make([]artifactTodoRow, 0, len(repair.PendingTodos)) - pendingSummaries := make([]RepairPendingTodo, 0, len(repair.PendingTodos)) + pendingRows := make([]artifactTodoRow, 0, len(pendingRecords)) + pendingSummaries := make([]RepairPendingTodo, 0, len(pendingRecords)) for _, row := range rows { - if _, completed := completedTodoRows[row.SourceRow]; completed { + if _, completed := completedRows[row.SourceRow]; completed { continue } - resolvedListID := row.TodolistID - listName := strings.TrimSpace(row.TodolistName) - if listName == "" { - listName = "Imported todos" + resolvedGroupID := row.TodolistID + groupName := strings.TrimSpace(row.TodolistName) + if groupName == "" { + groupName = "Imported todos" + if resourceType == resourceTypeCards { + groupName = "Imported cards" + } } - if resolvedListID == 0 { - resolvedListID = listIDs[listName] + if resolvedGroupID == 0 { + resolvedGroupID = groupIDs[groupName] } - if resolvedListID == 0 { - return nil, fmt.Errorf("source row %d cannot be added to follow-up artifact because todolist %q has no created ID in execution.json", row.SourceRow, listName) + if resolvedGroupID == 0 { + return nil, fmt.Errorf("source row %d cannot be added to follow-up artifact because %q has no created ID in execution.json", row.SourceRow, groupName) } row.ProjectID = projectID row.ProjectName = projectName - row.TodolistID = resolvedListID - row.TodolistName = listName + row.TodolistID = resolvedGroupID + row.TodolistName = groupName pendingRows = append(pendingRows, row) - pendingSummaries = append(pendingSummaries, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: listName}) + pendingSummaries = append(pendingSummaries, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: groupName}) } if len(pendingRows) == 0 { return nil, fmt.Errorf("follow-up artifact has no pending rows") } - firstTodolistID := formatOptionalInt64(pendingRows[0].TodolistID) + firstGroupID := formatOptionalInt64(pendingRows[0].TodolistID) followupDestination := manifest.Destination followupDestination.Mode = "existing_project" followupDestination.ProjectID = projectID followupDestination.ProjectName = projectName - followupDestination.TodolistStrategy = "existing_todolist" - followupDestination.TodolistID = firstTodolistID - followupDestination.TodolistName = pendingRows[0].TodolistName + if resourceType == resourceTypeCards { + followupDestination.ColumnStrategy = "existing_column" + followupDestination.ColumnID = firstGroupID + followupDestination.ColumnName = pendingRows[0].TodolistName + } else { + followupDestination.TodolistStrategy = "existing_todolist" + followupDestination.TodolistID = firstGroupID + followupDestination.TodolistName = pendingRows[0].TodolistName + } + followupCounts := PlanCounts{Todos: len(pendingRows)} + followupFiles := ArtifactFiles{Todos: artifactTodosFileName} + if resourceType == resourceTypeCards { + followupCounts = PlanCounts{Cards: len(pendingRows)} + followupFiles = ArtifactFiles{Cards: artifactCardsFileName} + } followupManifest := *manifest followupManifest.Status = "compiled" followupManifest.Destination = followupDestination - followupManifest.Counts = PlanCounts{Todos: len(pendingRows)} - followupManifest.Files = ArtifactFiles{Todos: artifactTodosFileName} + followupManifest.Counts = followupCounts + followupManifest.Files = followupFiles if err := writeArtifact(outDir, followupManifest, pendingRows); err != nil { return nil, err @@ -113,7 +137,8 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* Status: "compiled", ArtifactPath: outDir, Manifest: followupManifest, - PendingTodos: pendingSummaries, + PendingTodos: pendingSummariesFor(resourceTypeTodos, resourceType, pendingSummaries), + PendingCards: pendingSummariesFor(resourceTypeCards, resourceType, pendingSummaries), Guidance: []string{ "Review the follow-up artifact plan before preflight and execution.", "The source artifact remains closed and must not be rerun.", @@ -136,6 +161,44 @@ func followupProject(manifest *ImportArtifactManifest, operations []ExecutionLed return "", "", fmt.Errorf("new_project follow-up requires a completed create_project operation in execution.json") } +func pendingSummariesFor(want, got string, summaries []RepairPendingTodo) []RepairPendingTodo { + if want != got { + return nil + } + return summaries +} + +func followupGroupIDs(resourceType string, operations []ExecutionLedgerOperation) map[string]int64 { + if resourceType == resourceTypeCards { + out := make(map[string]int64) + for _, op := range operations { + if op.Op != "create_card_column" || op.Status != "completed" { + continue + } + name := strings.TrimSpace(op.ColumnName) + if name == "" { + name = "Imported cards" + } + id := op.CreatedID + if id == 0 { + id = op.ColumnID + } + if id != 0 { + out[name] = id + } + } + return out + } + return followupTodolistIDs(operations) +} + +func completedSourceRows(resourceType string, operations []ExecutionLedgerOperation) map[int]struct{} { + if resourceType == resourceTypeCards { + return completedRowsForOperation(operations, "create_card") + } + return completedTodoSourceRows(operations) +} + func followupTodolistIDs(operations []ExecutionLedgerOperation) map[string]int64 { out := make(map[string]int64) for _, op := range operations { @@ -158,9 +221,13 @@ func followupTodolistIDs(operations []ExecutionLedgerOperation) map[string]int64 } func completedTodoSourceRows(operations []ExecutionLedgerOperation) map[int]struct{} { + return completedRowsForOperation(operations, "create_todo") +} + +func completedRowsForOperation(operations []ExecutionLedgerOperation, operationName string) map[int]struct{} { out := make(map[int]struct{}) for _, op := range operations { - if op.Op == "create_todo" && op.Status == "completed" && op.SourceRow != 0 { + if op.Op == operationName && op.Status == "completed" && op.SourceRow != 0 { out[op.SourceRow] = struct{}{} } } diff --git a/internal/importer/planner.go b/internal/importer/planner.go index 5294a83d..26cf03f8 100644 --- a/internal/importer/planner.go +++ b/internal/importer/planner.go @@ -11,6 +11,11 @@ import ( const planSchemaVersion = 1 +const ( + resourceTypeTodos = "todos" + resourceTypeCards = "cards" +) + // MappingConfig records user-confirmed CSV-to-Basecamp mapping choices. type MappingConfig struct { SchemaVersion int `json:"schema_version"` @@ -18,6 +23,7 @@ type MappingConfig struct { Title *ColumnRef `json:"title,omitempty"` Description *ColumnRef `json:"description,omitempty"` Todolist *ColumnRef `json:"todolist,omitempty"` + Column *ColumnRef `json:"column,omitempty"` Status *ColumnRef `json:"status,omitempty"` Assignees *ColumnRef `json:"assignees,omitempty"` DueOn *ColumnRef `json:"due_on,omitempty"` @@ -37,12 +43,17 @@ type ColumnRef struct { // DestinationConfig records the Basecamp destination choices for a plan. type DestinationConfig struct { SchemaVersion int `json:"schema_version"` + ResourceType string `json:"resource_type,omitempty"` Mode string `json:"mode"` ProjectID string `json:"project_id,omitempty"` ProjectName string `json:"project_name,omitempty"` TodolistStrategy string `json:"todolist_strategy,omitempty"` TodolistID string `json:"todolist_id,omitempty"` TodolistName string `json:"todolist_name,omitempty"` + CardTableID string `json:"card_table_id,omitempty"` + ColumnStrategy string `json:"column_strategy,omitempty"` + ColumnID string `json:"column_id,omitempty"` + ColumnName string `json:"column_name,omitempty"` } // Plan describes the deterministic dry-run generated from confirmed mappings. @@ -61,9 +72,11 @@ type Plan struct { // PlanCounts summarizes planned write operations. type PlanCounts struct { - Projects int `json:"projects"` - Todolists int `json:"todolists"` - Todos int `json:"todos"` + Projects int `json:"projects"` + Todolists int `json:"todolists"` + Todos int `json:"todos"` + CardColumns int `json:"card_columns,omitempty"` + Cards int `json:"cards,omitempty"` } // PlannedOperation is one Basecamp write that can be executed after approval. @@ -75,6 +88,9 @@ type PlannedOperation struct { ProjectName string `json:"project_name,omitempty"` TodolistID string `json:"todolist_id,omitempty"` TodolistName string `json:"todolist_name,omitempty"` + CardTableID string `json:"card_table_id,omitempty"` + ColumnID string `json:"column_id,omitempty"` + ColumnName string `json:"column_name,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Status string `json:"status,omitempty"` @@ -131,18 +147,28 @@ func PlanImport(inspection *Inspection, mapping *MappingConfig, destination *Des Questions: make([]MappingQuestion, 0), } + resourceType, err := destinationResourceType(destination) + if err != nil { + return nil, err + } + plan.Destination.ResourceType = resourceType + if mapping.Title == nil { plan.RequiresUserInput = true - plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_title_column", Prompt: "Which column should become the Basecamp todo title?", Choices: candidateIndexes(inspection.RoleCandidates["title"])}) + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_title_column", Prompt: fmt.Sprintf("Which column should become the Basecamp %s title?", resourceSingular(resourceType)), Choices: candidateIndexes(inspection.RoleCandidates["title"])}) } if destination.Mode == "" { plan.RequiresUserInput = true - plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_destination", Prompt: "Which Basecamp project should receive the imported todos?"}) + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_destination", Prompt: fmt.Sprintf("Which Basecamp project should receive the imported %s?", resourceType)}) } - if destination.TodolistStrategy == "create_from_column" && mapping.Todolist == nil { + if resourceType == resourceTypeTodos && destination.TodolistStrategy == "create_from_column" && mapping.Todolist == nil { plan.RequiresUserInput = true plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_todolist_column", Prompt: "Which column should group todos into Basecamp todolists?", Choices: candidateIndexes(inspection.RoleCandidates["todolist"])}) } + if resourceType == resourceTypeCards && destination.ColumnStrategy == "create_from_column" && cardColumnMapping(mapping) == nil { + plan.RequiresUserInput = true + plan.Questions = append(plan.Questions, MappingQuestion{ID: "confirm_card_column", Prompt: "Which column should group cards into Basecamp card table columns?", Choices: candidateIndexes(inspection.RoleCandidates["todolist"])}) + } if assigneeNeedsPolicy(rows, mapping) { plan.RequiresUserInput = true plan.Warnings = append(plan.Warnings, ImportWarning{Code: "ambiguous_assignee_values", Columns: []int{mapping.Assignees.ColumnIndex}, Message: "Assignee values include display names. Choose an assignee mapping policy before planning assignments."}) @@ -170,12 +196,22 @@ func PlanImport(inspection *Inspection, mapping *MappingConfig, destination *Des return nil, fmt.Errorf("unsupported destination mode %q", destination.Mode) } - listNames := plannedTodolistNames(rows, mapping, destination) - if shouldCreateTodolists(destination) { - for _, name := range listNames { - operations = append(operations, PlannedOperation{Op: "create_todolist", ProjectID: destination.ProjectID, ProjectName: destination.ProjectName, TodolistName: name}) + if resourceType == resourceTypeTodos { + listNames := plannedTodolistNames(rows, mapping, destination) + if shouldCreateTodolists(destination) { + for _, name := range listNames { + operations = append(operations, PlannedOperation{Op: "create_todolist", ProjectID: destination.ProjectID, ProjectName: destination.ProjectName, TodolistName: name}) + } + plan.Counts.Todolists = len(listNames) + } + } else { + columnNames := plannedCardColumnNames(rows, mapping, destination) + if shouldCreateCardColumns(destination) { + for _, name := range columnNames { + operations = append(operations, PlannedOperation{Op: "create_card_column", ProjectID: destination.ProjectID, ProjectName: destination.ProjectName, CardTableID: destination.CardTableID, ColumnName: name}) + } + plan.Counts.CardColumns = len(columnNames) } - plan.Counts.Todolists = len(listNames) } dueOnValues, err := normalizedDueOnValues(rows, mapping) @@ -191,14 +227,21 @@ func PlanImport(inspection *Inspection, mapping *MappingConfig, destination *Des plan.Warnings = append(plan.Warnings, ImportWarning{Code: "blank_title", Columns: []int{mapping.Title.ColumnIndex}, Message: fmt.Sprintf("Source row %d has a blank title and will be skipped by execution.", rowIndex+1)}) } + opName := "create_todo" + if resourceType == resourceTypeCards { + opName = "create_card" + } op := PlannedOperation{ - Op: "create_todo", + Op: opName, SourceRow: rowIndex + 1, SourceRecordID: mappedValue(row, mapping.RecordID), ProjectID: destination.ProjectID, ProjectName: destination.ProjectName, TodolistID: destination.TodolistID, TodolistName: todolistNameForRow(row, mapping, destination), + CardTableID: destination.CardTableID, + ColumnID: destination.ColumnID, + ColumnName: cardColumnNameForRow(row, mapping, destination), Title: title, Description: mappedValue(row, mapping.Description), Status: mappedValue(row, mapping.Status), @@ -209,7 +252,11 @@ func PlanImport(inspection *Inspection, mapping *MappingConfig, destination *Des CustomFields: customFieldsForRow(row, inspection.Columns, mapped, duplicateColumns, mapping.CustomFields), } operations = append(operations, op) - plan.Counts.Todos++ + if resourceType == resourceTypeCards { + plan.Counts.Cards++ + } else { + plan.Counts.Todos++ + } } plan.Operations = operations @@ -283,6 +330,7 @@ func validateMappingIndexes(inspection *Inspection, mapping *MappingConfig) erro {"title", mapping.Title}, {"description", mapping.Description}, {"todolist", mapping.Todolist}, + {"column", mapping.Column}, {"status", mapping.Status}, {"assignees", mapping.Assignees}, {"due_on", mapping.DueOn}, @@ -338,6 +386,26 @@ func assigneeNeedsPolicy(rows [][]string, mapping *MappingConfig) bool { return false } +func destinationResourceType(destination *DestinationConfig) (string, error) { + resourceType := strings.TrimSpace(destination.ResourceType) + if resourceType == "" { + return resourceTypeTodos, nil + } + switch resourceType { + case resourceTypeTodos, resourceTypeCards: + return resourceType, nil + default: + return "", fmt.Errorf("unsupported destination resource_type %q", resourceType) + } +} + +func resourceSingular(resourceType string) string { + if resourceType == resourceTypeCards { + return "card" + } + return "todo" +} + func plannedTodolistNames(rows [][]string, mapping *MappingConfig, destination *DestinationConfig) []string { if destination.TodolistStrategy == "existing_todolist" { return nil @@ -368,6 +436,62 @@ func shouldCreateTodolists(destination *DestinationConfig) bool { return destination.TodolistStrategy == "" || destination.TodolistStrategy == "single_todolist" || destination.TodolistStrategy == "create_from_column" } +func plannedCardColumnNames(rows [][]string, mapping *MappingConfig, destination *DestinationConfig) []string { + if destination.ColumnStrategy == "existing_column" { + return nil + } + columnRef := cardColumnMapping(mapping) + if destination.ColumnStrategy == "single_column" || columnRef == nil { + name := strings.TrimSpace(destination.ColumnName) + if name == "" { + name = "Imported cards" + } + return []string{name} + } + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, row := range rows { + name := strings.TrimSpace(valueAt(row, columnRef.ColumnIndex)) + if name == "" { + name = "Imported cards" + } + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + out = append(out, name) + } + } + return out +} + +func shouldCreateCardColumns(destination *DestinationConfig) bool { + return destination.ColumnStrategy == "" || destination.ColumnStrategy == "single_column" || destination.ColumnStrategy == "create_from_column" +} + +func cardColumnMapping(mapping *MappingConfig) *ColumnRef { + if mapping.Column != nil { + return mapping.Column + } + return mapping.Todolist +} + +func cardColumnNameForRow(row []string, mapping *MappingConfig, destination *DestinationConfig) string { + if destination.ColumnStrategy == "existing_column" { + return destination.ColumnName + } + columnRef := cardColumnMapping(mapping) + if destination.ColumnStrategy == "single_column" || columnRef == nil { + if strings.TrimSpace(destination.ColumnName) != "" { + return strings.TrimSpace(destination.ColumnName) + } + return "Imported cards" + } + value := strings.TrimSpace(valueAt(row, columnRef.ColumnIndex)) + if value == "" { + return "Imported cards" + } + return value +} + func todolistNameForRow(row []string, mapping *MappingConfig, destination *DestinationConfig) string { if destination.TodolistStrategy == "existing_todolist" { return destination.TodolistName @@ -396,6 +520,7 @@ func mappedColumnIndexes(mapping *MappingConfig) map[int]struct{} { add(mapping.Title) add(mapping.Description) add(mapping.Todolist) + add(mapping.Column) add(mapping.Status) add(mapping.Assignees) add(mapping.DueOn) @@ -512,6 +637,8 @@ func renderDryRunMarkdown(plan *Plan) string { fmt.Fprintf(&b, "- Projects: %d\n", plan.Counts.Projects) fmt.Fprintf(&b, "- Todolists: %d\n", plan.Counts.Todolists) fmt.Fprintf(&b, "- Todos: %d\n", plan.Counts.Todos) + fmt.Fprintf(&b, "- Card columns: %d\n", plan.Counts.CardColumns) + fmt.Fprintf(&b, "- Cards: %d\n", plan.Counts.Cards) if len(plan.Operations) > 0 { b.WriteString("\n## Operations\n\n") @@ -521,12 +648,20 @@ func renderDryRunMarkdown(plan *Plan) string { fmt.Fprintf(&b, "- Create project: %s\n", op.ProjectName) case "create_todolist": fmt.Fprintf(&b, "- Create todolist: %s\n", op.TodolistName) + case "create_card_column": + fmt.Fprintf(&b, "- Create card column: %s\n", op.ColumnName) case "create_todo": fmt.Fprintf(&b, "- Row %d: create todo %q", op.SourceRow, op.Title) if op.TodolistName != "" { fmt.Fprintf(&b, " in %q", op.TodolistName) } b.WriteString("\n") + case "create_card": + fmt.Fprintf(&b, "- Row %d: create card %q", op.SourceRow, op.Title) + if op.ColumnName != "" { + fmt.Fprintf(&b, " in %q", op.ColumnName) + } + b.WriteString("\n") } } } diff --git a/internal/importer/preflight.go b/internal/importer/preflight.go index 168f2d06..a5501346 100644 --- a/internal/importer/preflight.go +++ b/internal/importer/preflight.go @@ -12,6 +12,9 @@ import ( type ArtifactPreflightClient interface { ExistingTodolists(ctx context.Context, projectID int64) ([]ExistingTodolist, error) ExistingTodos(ctx context.Context, todolistID int64) ([]ExistingTodo, error) + CardTableID(ctx context.Context, projectID int64) (int64, error) + ExistingCardColumns(ctx context.Context, cardTableID int64) ([]ExistingCardColumn, error) + ExistingCards(ctx context.Context, columnID int64) ([]ExistingCard, error) } // ExistingTodolist describes a Basecamp todolist considered during preflight. @@ -26,13 +29,27 @@ type ExistingTodo struct { Title string `json:"title"` } +// ExistingCardColumn describes a Basecamp card table column considered during preflight. +type ExistingCardColumn struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// ExistingCard describes a Basecamp card considered during preflight. +type ExistingCard struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + // PreflightResult reports readiness checks for an import artifact. type PreflightResult struct { - SchemaVersion int `json:"schema_version"` - Status string `json:"status"` - Checks []PreflightCheck `json:"checks"` - Collisions []TodolistCollision `json:"collisions,omitempty"` - TodoCollisions []TodoCollision `json:"todo_collisions,omitempty"` + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Checks []PreflightCheck `json:"checks"` + Collisions []TodolistCollision `json:"collisions,omitempty"` + TodoCollisions []TodoCollision `json:"todo_collisions,omitempty"` + ColumnCollisions []TodolistCollision `json:"column_collisions,omitempty"` + CardCollisions []TodoCollision `json:"card_collisions,omitempty"` } // PreflightCheck reports one artifact readiness check. @@ -76,6 +93,22 @@ func PreflightArtifact(ctx context.Context, artifactDir string, client ArtifactP return nil, fmt.Errorf("import preflight requires a read client") } + resourceType, err := destinationResourceType(&manifest.Destination) + if err != nil { + return nil, err + } + if resourceType == resourceTypeCards { + if err := checkPreflightCardColumnCollisions(ctx, result, manifest, rows, client); err != nil { + return nil, err + } + if result.Status == "blocked" { + return result, nil + } + if err := checkPreflightCardCollisions(ctx, result, manifest, rows, client); err != nil { + return nil, err + } + return result, nil + } if err := checkPreflightTodolistCollisions(ctx, result, manifest, rows, client); err != nil { return nil, err } @@ -160,6 +193,109 @@ func checkPreflightTodoCollisions(ctx context.Context, result *PreflightResult, return nil } +func checkPreflightCardColumnCollisions(ctx context.Context, result *PreflightResult, manifest *ImportArtifactManifest, rows []artifactTodoRow, client ArtifactPreflightClient) error { + if manifest.Destination.Mode != "existing_project" || !shouldCreateCardColumns(&manifest.Destination) { + result.Checks = append(result.Checks, PreflightCheck{Name: "card_column_name_collisions", Status: "passed", Message: "Artifact execution does not create card columns in an existing project."}) + return nil + } + plannedNames := artifactCardColumnNames(rows) + if len(plannedNames) == 0 { + result.Checks = append(result.Checks, PreflightCheck{Name: "card_column_name_collisions", Status: "passed", Message: "Artifact execution does not create card columns."}) + return nil + } + cardTableID, issue := preflightCardTableID(ctx, manifest, client) + if issue != "" { + result.Status = "blocked" + result.Checks = append(result.Checks, PreflightCheck{Name: "card_column_name_collisions", Status: "blocked", Message: issue}) + return nil + } + existing, err := client.ExistingCardColumns(ctx, cardTableID) + if err != nil { + return err + } + collisions := cardColumnCollisions(plannedNames, existing) + if len(collisions) > 0 { + result.Status = "blocked" + result.ColumnCollisions = collisions + result.Checks = append(result.Checks, PreflightCheck{Name: "card_column_name_collisions", Status: "blocked", Message: fmt.Sprintf("%d planned card column name(s) already exist in the destination card table.", len(collisions))}) + return nil + } + result.Checks = append(result.Checks, PreflightCheck{Name: "card_column_name_collisions", Status: "passed", Message: fmt.Sprintf("Checked %d planned card column name(s) against existing Basecamp card columns.", len(plannedNames))}) + return nil +} + +func checkPreflightCardCollisions(ctx context.Context, result *PreflightResult, manifest *ImportArtifactManifest, rows []artifactTodoRow, client ArtifactPreflightClient) error { + if manifest.Destination.Mode != "existing_project" || manifest.Destination.ColumnStrategy != "existing_column" { + result.Checks = append(result.Checks, PreflightCheck{Name: "card_title_collisions", Status: "passed", Message: "Artifact execution does not add cards to an existing card column."}) + return nil + } + targets, targetIssue := cardCollisionTargets(manifest, rows) + if targetIssue != "" { + result.Status = "blocked" + result.Checks = append(result.Checks, PreflightCheck{Name: "card_title_collisions", Status: "blocked", Message: targetIssue}) + return nil + } + allCollisions := make([]TodoCollision, 0) + checked := 0 + for columnID, targetRows := range targets { + existing, err := client.ExistingCards(ctx, columnID) + if err != nil { + return err + } + allCollisions = append(allCollisions, cardCollisions(targetRows, columnID, existing)...) + checked += len(targetRows) + } + if len(allCollisions) > 0 { + result.Status = "blocked" + result.CardCollisions = allCollisions + result.Checks = append(result.Checks, PreflightCheck{Name: "card_title_collisions", Status: "blocked", Message: fmt.Sprintf("%d planned card title(s) already exist in destination columns.", len(allCollisions))}) + return nil + } + result.Checks = append(result.Checks, PreflightCheck{Name: "card_title_collisions", Status: "passed", Message: fmt.Sprintf("Checked %d planned card title(s) against existing Basecamp cards.", checked)}) + return nil +} + +func preflightCardTableID(ctx context.Context, manifest *ImportArtifactManifest, client ArtifactPreflightClient) (int64, string) { + cardTableID, err := parseOptionalInt64(manifest.Destination.CardTableID) + if err != nil { + return 0, fmt.Sprintf("invalid destination card_table_id: %v", err) + } + if cardTableID != 0 { + return cardTableID, "" + } + projectID, err := parseOptionalInt64(manifest.Destination.ProjectID) + if err != nil { + return 0, fmt.Sprintf("invalid destination project_id: %v", err) + } + if projectID == 0 { + return 0, "artifact destination project_id or card_table_id is required to check card column collisions" + } + cardTableID, err = client.CardTableID(ctx, projectID) + if err != nil { + return 0, err.Error() + } + return cardTableID, "" +} + +func cardCollisionTargets(manifest *ImportArtifactManifest, rows []artifactTodoRow) (map[int64][]artifactTodoRow, string) { + manifestID, err := parseOptionalInt64(manifest.Destination.ColumnID) + if err != nil { + return nil, fmt.Sprintf("invalid destination column_id: %v", err) + } + targets := make(map[int64][]artifactTodoRow) + for _, row := range rows { + id := row.TodolistID + if id == 0 { + id = manifestID + } + if id == 0 { + return nil, "artifact destination column_id is required to check card title collisions" + } + targets[id] = append(targets[id], row) + } + return targets, "" +} + func todoCollisionTargets(manifest *ImportArtifactManifest, rows []artifactTodoRow) (map[int64][]artifactTodoRow, string) { manifestID, err := parseOptionalInt64(manifest.Destination.TodolistID) if err != nil { @@ -211,6 +347,36 @@ func todoCollisions(rows []artifactTodoRow, todolistID int64, existing []Existin return collisions } +func cardCollisions(rows []artifactTodoRow, columnID int64, existing []ExistingCard) []TodoCollision { + byTitle := make(map[string]ExistingCard) + for _, card := range existing { + title := strings.TrimSpace(card.Title) + if title == "" { + continue + } + byTitle[strings.ToLower(title)] = ExistingCard{ID: card.ID, Title: title} + } + collisions := make([]TodoCollision, 0) + for _, row := range rows { + title := strings.TrimSpace(row.Title) + if title == "" { + continue + } + if existing, ok := byTitle[strings.ToLower(title)]; ok { + collisions = append(collisions, TodoCollision{SourceRow: row.SourceRow, Title: title, TodolistID: columnID, ExistingID: existing.ID}) + } + } + return collisions +} + +func cardColumnCollisions(plannedNames []string, existing []ExistingCardColumn) []TodolistCollision { + converted := make([]ExistingTodolist, 0, len(existing)) + for _, column := range existing { + converted = append(converted, ExistingTodolist(column)) + } + return todolistCollisions(plannedNames, converted) +} + func todolistCollisions(plannedNames []string, existing []ExistingTodolist) []TodolistCollision { byName := make(map[string]ExistingTodolist) for _, list := range existing { @@ -250,5 +416,11 @@ func (r *PreflightResult) BlockedMessage() string { for _, collision := range r.TodoCollisions { messages = append(messages, fmt.Sprintf("Todo %q from source row %d already exists with ID %d.", collision.Title, collision.SourceRow, collision.ExistingID)) } + for _, collision := range r.ColumnCollisions { + messages = append(messages, fmt.Sprintf("Card column %q already exists with ID %d.", collision.Name, collision.ExistingID)) + } + for _, collision := range r.CardCollisions { + messages = append(messages, fmt.Sprintf("Card %q from source row %d already exists with ID %d.", collision.Title, collision.SourceRow, collision.ExistingID)) + } return strings.Join(messages, " ") } diff --git a/internal/importer/preflight_test.go b/internal/importer/preflight_test.go index 58a27c92..7f40b865 100644 --- a/internal/importer/preflight_test.go +++ b/internal/importer/preflight_test.go @@ -9,9 +9,27 @@ import ( ) type fakePreflightClient struct { - todolists []ExistingTodolist - todos []ExistingTodo - todosByList map[int64][]ExistingTodo + todolists []ExistingTodolist + todos []ExistingTodo + todosByList map[int64][]ExistingTodo + cardColumns []ExistingCardColumn + cards []ExistingCard + cardsByColumn map[int64][]ExistingCard +} + +func (f fakePreflightClient) CardTableID(ctx context.Context, projectID int64) (int64, error) { + return 888, nil +} + +func (f fakePreflightClient) ExistingCardColumns(ctx context.Context, cardTableID int64) ([]ExistingCardColumn, error) { + return f.cardColumns, nil +} + +func (f fakePreflightClient) ExistingCards(ctx context.Context, columnID int64) ([]ExistingCard, error) { + if f.cardsByColumn != nil { + return f.cardsByColumn[columnID], nil + } + return f.cards, nil } func (f fakePreflightClient) ExistingTodolists(ctx context.Context, projectID int64) ([]ExistingTodolist, error) { @@ -93,6 +111,19 @@ func TestPreflightArtifactChecksExistingTodolistTodos(t *testing.T) { } } +func TestPreflightArtifactBlocksCardColumnNameCollisions(t *testing.T) { + outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, ResourceType: resourceTypeCards, Mode: "existing_project", ProjectID: "123", CardTableID: "888", ColumnStrategy: "create_from_column"}) + client := fakePreflightClient{cardColumns: []ExistingCardColumn{{ID: 42, Name: "Backlog"}}} + + result, err := PreflightArtifact(context.Background(), outDir, client) + if err != nil { + t.Fatalf("PreflightArtifact() error = %v", err) + } + if result.Status != "blocked" || len(result.ColumnCollisions) != 1 || result.ColumnCollisions[0].Name != "Backlog" { + t.Fatalf("result = %+v", result) + } +} + func TestPreflightArtifactBlocksExistingExecutionLedger(t *testing.T) { outDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}) if err := os.WriteFile(filepath.Join(outDir, artifactExecutionFileName), []byte(`{"status":"completed"}`), 0o644); err != nil { diff --git a/internal/importer/repair.go b/internal/importer/repair.go index 05488702..13fe9dd1 100644 --- a/internal/importer/repair.go +++ b/internal/importer/repair.go @@ -10,6 +10,7 @@ type RepairResult struct { CompletedOperations []ExecutionLedgerOperation `json:"completed_operations,omitempty"` FailedOperations []ExecutionLedgerOperation `json:"failed_operations,omitempty"` PendingTodos []RepairPendingTodo `json:"pending_todos,omitempty"` + PendingCards []RepairPendingTodo `json:"pending_cards,omitempty"` Guidance []string `json:"guidance"` } @@ -23,7 +24,7 @@ type RepairPendingTodo struct { // RepairArtifact reads local artifact and execution files and summarizes recovery state. func RepairArtifact(artifactDir string) (*RepairResult, error) { - _, rows, err := readArtifact(artifactDir) + manifest, rows, err := readArtifact(artifactDir) if err != nil { return nil, err } @@ -50,7 +51,15 @@ func RepairArtifact(artifactDir string) (*RepairResult, error) { result.ExecutionStatus = ledger.Status result.Created = ledger.Created result.CompletedOperations, result.FailedOperations = splitLedgerOperations(ledger.Operations) - result.PendingTodos = pendingTodosForRepair(rows, ledger.Operations) + resourceType, err := destinationResourceType(&manifest.Destination) + if err != nil { + return nil, err + } + if resourceType == resourceTypeCards { + result.PendingCards = pendingRecordsForRepair(rows, ledger.Operations, "create_card") + } else { + result.PendingTodos = pendingRecordsForRepair(rows, ledger.Operations, "create_todo") + } switch ledger.Status { case "completed": @@ -84,10 +93,10 @@ func splitLedgerOperations(operations []ExecutionLedgerOperation) ([]ExecutionLe return completed, failed } -func pendingTodosForRepair(rows []artifactTodoRow, operations []ExecutionLedgerOperation) []RepairPendingTodo { +func pendingRecordsForRepair(rows []artifactTodoRow, operations []ExecutionLedgerOperation, operationName string) []RepairPendingTodo { completedRows := make(map[int]struct{}) for _, op := range operations { - if op.Op == "create_todo" && op.Status == "completed" && op.SourceRow != 0 { + if op.Op == operationName && op.Status == "completed" && op.SourceRow != 0 { completedRows[op.SourceRow] = struct{}{} } } diff --git a/scripts/demo-import.sh b/scripts/demo-import.sh index 13770838..3f8759f4 100755 --- a/scripts/demo-import.sh +++ b/scripts/demo-import.sh @@ -78,7 +78,7 @@ if [[ -n "$repair_artifact" ]]; then echo "" echo "== Repair review ==" basecamp import repair --artifact "$repair_artifact" --json | - jq '{ok, status: .data.status, created: .data.created, completed_operations: (.data.completed_operations | length), failed_operations: .data.failed_operations, pending_todos: .data.pending_todos, guidance: .data.guidance}' + jq '{ok, status: .data.status, created: .data.created, completed_operations: (.data.completed_operations | length), failed_operations: .data.failed_operations, pending_todos: .data.pending_todos, pending_cards: .data.pending_cards, guidance: .data.guidance}' if [[ -z "$followup_out" ]]; then echo "" @@ -96,7 +96,7 @@ if [[ -n "$repair_artifact" ]]; then echo "" echo "== Creating follow-up artifact ==" basecamp import followup --artifact "$repair_artifact" --out "$followup_out" --reviewed --json | - jq '{ok, status: .data.status, artifact_path: .data.artifact_path, counts: .data.manifest.counts, pending_todos: .data.pending_todos, guidance: .data.guidance}' + jq '{ok, status: .data.status, artifact_path: .data.artifact_path, counts: .data.manifest.counts, pending_todos: .data.pending_todos, pending_cards: .data.pending_cards, guidance: .data.guidance}' echo "" echo "== Planning follow-up artifact ==" @@ -164,7 +164,7 @@ echo "" echo "== Preflight artifact ==" preflight_json="$workdir/preflight.json" basecamp import preflight --artifact "$out" --json > "$preflight_json" -jq '{ok, status: .data.status, checks: .data.checks, collisions: .data.collisions, todo_collisions: .data.todo_collisions}' "$preflight_json" +jq '{ok, status: .data.status, checks: .data.checks, collisions: .data.collisions, todo_collisions: .data.todo_collisions, column_collisions: .data.column_collisions, card_collisions: .data.card_collisions}' "$preflight_json" preflight_status="$(jq -r '.data.status' "$preflight_json")" if [[ "$preflight_status" != "passed" ]]; then echo "Preflight did not pass. Resolve blockers before execution." >&2 diff --git a/skills/basecamp-import/SKILL.md b/skills/basecamp-import/SKILL.md index 89b61891..ba6741b5 100644 --- a/skills/basecamp-import/SKILL.md +++ b/skills/basecamp-import/SKILL.md @@ -18,7 +18,7 @@ argument-hint: "[csv path or import action]" # Basecamp CSV Import -Use this skill to turn CSV exports from spreadsheets, task apps, and internal tools into validated Basecamp todos. +Use this skill to turn CSV exports from spreadsheets, task apps, and internal tools into validated Basecamp todos or cards. The import pipeline is deterministic: @@ -80,7 +80,7 @@ The inspection can return no obvious title candidate. That is safe: ask the user ### 3. Confirm mappings with the user -Create `mapping.json` from confirmed answers. At minimum, `title` is required. +Ask whether the CSV rows should become Basecamp todos or Basecamp cards. Create `mapping.json` from confirmed answers. At minimum, `title` is required. Example: @@ -106,9 +106,10 @@ Example: Mapping guidance: - `record_id`: stable source ID, if available. -- `title`: required todo title. +- `title`: required todo or card title. - `description`: long notes/body/content. -- `todolist`: grouping column such as list, section, phase, stream, room, area, or project. +- `todolist`: grouping column for todo imports, such as list, section, phase, stream, room, area, or project. +- `column`: grouping column for card imports. The importer also accepts `todolist` as the grouping mapping for card imports. - `status`: source status preserved as metadata. - `assignees`: emails or names preserved as metadata unless Basecamp person IDs are available in a later workflow. - `due_on`: due/deadline column. Compile normalizes deterministic date values to `YYYY-MM-DD`. For ambiguous slash dates such as `06/01/2026`, add `"date_order": "mdy"` or `"date_order": "dmy"` after confirming the source convention with the user. @@ -143,6 +144,34 @@ Existing project and existing todolist: } ``` +Existing project with cards created in card table columns from a CSV column: + +```json +{ + "schema_version": 1, + "resource_type": "cards", + "mode": "existing_project", + "project_id": "12345", + "card_table_id": "67890", + "column_strategy": "create_from_column" +} +``` + +Existing project and existing card table column: + +```json +{ + "schema_version": 1, + "resource_type": "cards", + "mode": "existing_project", + "project_id": "12345", + "card_table_id": "67890", + "column_strategy": "existing_column", + "column_id": "24680", + "column_name": "To do" +} +``` + New project: ```json @@ -170,7 +199,11 @@ The artifact contains: ```text basecamp-import/ ├── import.json -└── todos.csv +└── todos.csv # todo imports + +basecamp-import/ +├── import.json +└── cards.csv # card imports ``` The artifact format is `basecamp-import-csv-v1`. Treat it as the durable checkpoint for the import. @@ -199,7 +232,7 @@ For failed or partial executions, run the local repair review: basecamp import repair --artifact basecamp-import/ --json ``` -Use `completed_operations`, `failed_operations`, and `pending_todos` to explain what needs manual review before a fresh follow-up artifact is created. +Use `completed_operations`, `failed_operations`, `pending_todos`, and `pending_cards` to explain what needs manual review before a fresh follow-up artifact is created. After the user confirms they reviewed Basecamp state and the repair summary, create a fresh follow-up artifact for pending rows: @@ -215,7 +248,7 @@ Plan and preflight the follow-up artifact before execution. Do not remove `execu basecamp import preflight --artifact basecamp-import/ --json ``` -If preflight returns `status: "blocked"`, resolve the reported blocker before execution. Todolist name collisions mean the destination project already has a todolist with a name the artifact plans to create. Todo title collisions mean an existing destination todolist already contains a todo with a title the artifact plans to import. +If preflight returns `status: "blocked"`, resolve the reported blocker before execution. Todolist or card column name collisions mean the destination project already has a group with a name the artifact plans to create. Todo or card title collisions mean an existing destination group already contains a record with a title the artifact plans to import. Then ask: @@ -236,6 +269,8 @@ Summarize: - `created.projects` - `created.todolists` - `created.todos` +- `created.card_columns` +- `created.cards` - `skipped` Skipped assignees mean the source assignee values were preserved as metadata but not assigned natively. From bc959acb41c5bd4eb110e20b8e6e21e670150fa7 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 5 Jun 2026 14:00:14 -0400 Subject: [PATCH 06/10] importer: address import review feedback --- e2e/smoke/smoke_import.bats | 29 ++++++++++----- internal/importer/artifact.go | 36 ++++++++++++------- internal/importer/csvprofiler.go | 2 +- internal/importer/executor.go | 4 +-- internal/importer/followup.go | 10 +++--- internal/importer/planner.go | 4 +-- internal/importer/repair.go | 22 +++++++----- internal/importer/status.go | 11 ++++-- .../import/csv/canonical/asana/sample-03.csv | 8 ++--- .../jira-attachments-custom-fields.csv | 6 ++-- 10 files changed, 84 insertions(+), 48 deletions(-) diff --git a/e2e/smoke/smoke_import.bats b/e2e/smoke/smoke_import.bats index 3168965c..dcc6acf4 100644 --- a/e2e/smoke/smoke_import.bats +++ b/e2e/smoke/smoke_import.bats @@ -27,28 +27,38 @@ JSON JSON } -@test "import inspect profiles local CSV" { - write_smoke_import_files +inspect_smoke_import_csv() { run_smoke basecamp import inspect tasks.csv --json assert_success assert_json_value '.ok' 'true' assert_json_value '.data.status' 'profiled' + printf '%s\n' "$output" > inspection.json } -@test "import compile creates local artifact" { - write_smoke_import_files - basecamp import inspect tasks.csv --json > inspection.json - +compile_smoke_import_artifact() { + inspect_smoke_import_csv run_smoke basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json assert_success assert_json_value '.ok' 'true' assert_json_value '.data.status' 'compiled' } +@test "import inspect profiles local CSV" { + write_smoke_import_files + run_smoke basecamp import inspect tasks.csv --json + assert_success + assert_json_value '.ok' 'true' + assert_json_value '.data.status' 'profiled' +} + +@test "import compile creates local artifact" { + write_smoke_import_files + compile_smoke_import_artifact +} + @test "import plan reads local artifact" { write_smoke_import_files - basecamp import inspect tasks.csv --json > inspection.json - basecamp import compile --inspection inspection.json --mapping mapping.json --destination destination.json --out basecamp-import --json >/dev/null + compile_smoke_import_artifact run_smoke basecamp import plan --artifact basecamp-import --json assert_success @@ -81,6 +91,9 @@ JSON } @test "import execute requires approval" { + write_smoke_import_files + compile_smoke_import_artifact + run_smoke basecamp import execute --artifact basecamp-import --json assert_failure assert_output_contains "--approved required" diff --git a/internal/importer/artifact.go b/internal/importer/artifact.go index 8dbfe2c7..3aad0b9e 100644 --- a/internal/importer/artifact.go +++ b/internal/importer/artifact.go @@ -276,10 +276,11 @@ func readArtifact(artifactDir string) (*ImportArtifactManifest, []artifactTodoRo return nil, nil, err } if resourceType == resourceTypeCards { - if manifest.Files.Cards == "" { - return nil, nil, fmt.Errorf("artifact cards file is required") + cardsPath, err := artifactMemberPath(artifactDir, manifest.Files.Cards, artifactCardsFileName) + if err != nil { + return nil, nil, err } - rows, err := readArtifactCards(filepath.Join(artifactDir, manifest.Files.Cards)) + rows, err := readArtifactCards(cardsPath) if err != nil { return nil, nil, err } @@ -288,10 +289,11 @@ func readArtifact(artifactDir string) (*ImportArtifactManifest, []artifactTodoRo } return &manifest, rows, nil } - if manifest.Files.Todos == "" { - return nil, nil, fmt.Errorf("artifact todos file is required") + todosPath, err := artifactMemberPath(artifactDir, manifest.Files.Todos, artifactTodosFileName) + if err != nil { + return nil, nil, err } - rows, err := readArtifactTodos(filepath.Join(artifactDir, manifest.Files.Todos)) + rows, err := readArtifactTodos(todosPath) if err != nil { return nil, nil, err } @@ -301,8 +303,18 @@ func readArtifact(artifactDir string) (*ImportArtifactManifest, []artifactTodoRo return &manifest, rows, nil } +func artifactMemberPath(artifactDir, filename, expected string) (string, error) { + if filename == "" { + return "", fmt.Errorf("artifact %s file is required", strings.TrimSuffix(expected, ".csv")) + } + if filename != expected { + return "", fmt.Errorf("artifact %s file must be %s", strings.TrimSuffix(expected, ".csv"), expected) + } + return filepath.Join(artifactDir, expected), nil +} + func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifactTodoRow) error { - if err := os.MkdirAll(outDir, 0o755); err != nil { //nolint:gosec // G301: Import artifacts are user-readable project files + if err := os.MkdirAll(outDir, 0o750); err != nil { return fmt.Errorf("create artifact directory: %w", err) } manifestData, err := json.MarshalIndent(manifest, "", " ") @@ -310,7 +322,7 @@ func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifa return fmt.Errorf("encode artifact manifest: %w", err) } manifestData = append(manifestData, '\n') - if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), manifestData, 0o644); err != nil { //nolint:gosec // G306: Import artifact manifests are not secrets + if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), manifestData, 0o600); err != nil { return fmt.Errorf("write artifact manifest: %w", err) } if manifest.Files.Cards != "" { @@ -326,7 +338,7 @@ func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifa } func writeArtifactTodos(path string, rows []artifactTodoRow) error { - file, err := os.Create(path) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 -- artifact file paths are compiled from the selected artifact directory if err != nil { return fmt.Errorf("write artifact todos: %w", err) } @@ -353,7 +365,7 @@ func writeArtifactTodos(path string, rows []artifactTodoRow) error { } func writeArtifactCards(path string, rows []artifactTodoRow) error { - file, err := os.Create(path) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 -- artifact file paths are compiled from the selected artifact directory if err != nil { return fmt.Errorf("write artifact cards: %w", err) } @@ -380,7 +392,7 @@ func writeArtifactCards(path string, rows []artifactTodoRow) error { } func readArtifactTodos(path string) ([]artifactTodoRow, error) { - file, err := os.Open(path) + file, err := os.Open(path) // #nosec G304 -- artifact readers validate user-selected artifact files if err != nil { return nil, fmt.Errorf("read artifact todos: %w", err) } @@ -413,7 +425,7 @@ func readArtifactTodos(path string) ([]artifactTodoRow, error) { } func readArtifactCards(path string) ([]artifactTodoRow, error) { - file, err := os.Open(path) + file, err := os.Open(path) // #nosec G304 -- artifact readers validate user-selected artifact files if err != nil { return nil, fmt.Errorf("read artifact cards: %w", err) } diff --git a/internal/importer/csvprofiler.go b/internal/importer/csvprofiler.go index 607f1a85..ae139803 100644 --- a/internal/importer/csvprofiler.go +++ b/internal/importer/csvprofiler.go @@ -124,7 +124,7 @@ func InspectCSV(path string, opts InspectOptions) (*Inspection, error) { opts.SampleSize = 5 } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- inspect reads the CSV path selected by the user if err != nil { return nil, fmt.Errorf("read CSV: %w", err) } diff --git a/internal/importer/executor.go b/internal/importer/executor.go index 8b32b308..24c4ae12 100644 --- a/internal/importer/executor.go +++ b/internal/importer/executor.go @@ -334,7 +334,7 @@ func appendExecutionLedgerOperation(ledger *ExecutionLedger, op ExecutionLedgerO func beginArtifactExecution(artifactDir string, manifest *ImportArtifactManifest) (*ExecutionLedger, error) { ledgerPath := filepath.Join(artifactDir, artifactExecutionFileName) - if data, err := os.ReadFile(ledgerPath); err == nil { + if data, err := os.ReadFile(ledgerPath); err == nil { // #nosec G304 -- execution reads the ledger within the selected artifact directory var existing ExecutionLedger if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil { return nil, fmt.Errorf("artifact execution ledger exists at %s and cannot be read; refusing to execute again", ledgerPath) @@ -381,7 +381,7 @@ func writeExecutionLedger(path string, ledger *ExecutionLedger) error { } data = append(data, '\n') tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { //nolint:gosec // G306: Execution ledgers are user-readable recovery files + if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("write artifact execution ledger: %w", err) } if err := os.Rename(tmp, path); err != nil { diff --git a/internal/importer/followup.go b/internal/importer/followup.go index de2c2e89..17f956b0 100644 --- a/internal/importer/followup.go +++ b/internal/importer/followup.go @@ -19,8 +19,8 @@ type FollowupArtifactResult struct { Status string `json:"status"` ArtifactPath string `json:"artifact_path"` Manifest ImportArtifactManifest `json:"manifest"` - PendingTodos []RepairPendingTodo `json:"pending_todos,omitempty"` - PendingCards []RepairPendingTodo `json:"pending_cards,omitempty"` + PendingTodos []RepairPendingRecord `json:"pending_todos,omitempty"` + PendingCards []RepairPendingRecord `json:"pending_cards,omitempty"` Guidance []string `json:"guidance"` } @@ -72,7 +72,7 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* completedRows := completedSourceRows(resourceType, repair.CompletedOperations) pendingRows := make([]artifactTodoRow, 0, len(pendingRecords)) - pendingSummaries := make([]RepairPendingTodo, 0, len(pendingRecords)) + pendingSummaries := make([]RepairPendingRecord, 0, len(pendingRecords)) for _, row := range rows { if _, completed := completedRows[row.SourceRow]; completed { continue @@ -96,7 +96,7 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* row.TodolistID = resolvedGroupID row.TodolistName = groupName pendingRows = append(pendingRows, row) - pendingSummaries = append(pendingSummaries, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: groupName}) + pendingSummaries = append(pendingSummaries, RepairPendingRecord{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, GroupName: groupName}) } if len(pendingRows) == 0 { return nil, fmt.Errorf("follow-up artifact has no pending rows") @@ -161,7 +161,7 @@ func followupProject(manifest *ImportArtifactManifest, operations []ExecutionLed return "", "", fmt.Errorf("new_project follow-up requires a completed create_project operation in execution.json") } -func pendingSummariesFor(want, got string, summaries []RepairPendingTodo) []RepairPendingTodo { +func pendingSummariesFor(want, got string, summaries []RepairPendingRecord) []RepairPendingRecord { if want != got { return nil } diff --git a/internal/importer/planner.go b/internal/importer/planner.go index 26cf03f8..77c8d647 100644 --- a/internal/importer/planner.go +++ b/internal/importer/planner.go @@ -292,7 +292,7 @@ func ReadDestinationFile(path string) (*DestinationConfig, error) { } func recordsForInspection(inspection *Inspection) ([][]string, error) { - data, err := os.ReadFile(inspection.ExportPath) + data, err := os.ReadFile(inspection.ExportPath) // #nosec G304 -- compile rereads the inspected CSV path and verifies its fingerprint if err != nil { return nil, fmt.Errorf("read inspected CSV: %w", err) } @@ -682,7 +682,7 @@ func renderDryRunMarkdown(plan *Plan) string { } func readJSONData(path string, target any) error { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- import commands read user-selected JSON input files if err != nil { return fmt.Errorf("read JSON: %w", err) } diff --git a/internal/importer/repair.go b/internal/importer/repair.go index 13fe9dd1..b2fafaba 100644 --- a/internal/importer/repair.go +++ b/internal/importer/repair.go @@ -9,17 +9,17 @@ type RepairResult struct { Created ExecuteCounts `json:"created,omitempty"` CompletedOperations []ExecutionLedgerOperation `json:"completed_operations,omitempty"` FailedOperations []ExecutionLedgerOperation `json:"failed_operations,omitempty"` - PendingTodos []RepairPendingTodo `json:"pending_todos,omitempty"` - PendingCards []RepairPendingTodo `json:"pending_cards,omitempty"` + PendingTodos []RepairPendingRecord `json:"pending_todos,omitempty"` + PendingCards []RepairPendingRecord `json:"pending_cards,omitempty"` Guidance []string `json:"guidance"` } -// RepairPendingTodo identifies an artifact todo row that has no completed ledger operation. -type RepairPendingTodo struct { +// RepairPendingRecord identifies an artifact row that has no completed ledger operation. +type RepairPendingRecord struct { SourceRow int `json:"source_row"` SourceRecordID string `json:"source_record_id,omitempty"` Title string `json:"title"` - TodolistName string `json:"todolist_name,omitempty"` + GroupName string `json:"group_name,omitempty"` } // RepairArtifact reads local artifact and execution files and summarizes recovery state. @@ -67,9 +67,13 @@ func RepairArtifact(artifactDir string) (*RepairResult, error) { result.Guidance = []string{"Execution completed. This artifact is closed and cannot be executed again."} case "failed", "started": result.Status = "review_required" + pendingField := "pending_todos" + if resourceType == resourceTypeCards { + pendingField = "pending_cards" + } result.Guidance = []string{ "Review completed_operations against Basecamp before taking further action.", - "Review failed_operations and pending_todos before creating a fresh follow-up artifact.", + "Review failed_operations and " + pendingField + " before creating a fresh follow-up artifact.", "Do not remove execution.json to rerun this artifact.", } default: @@ -93,19 +97,19 @@ func splitLedgerOperations(operations []ExecutionLedgerOperation) ([]ExecutionLe return completed, failed } -func pendingRecordsForRepair(rows []artifactTodoRow, operations []ExecutionLedgerOperation, operationName string) []RepairPendingTodo { +func pendingRecordsForRepair(rows []artifactTodoRow, operations []ExecutionLedgerOperation, operationName string) []RepairPendingRecord { completedRows := make(map[int]struct{}) for _, op := range operations { if op.Op == operationName && op.Status == "completed" && op.SourceRow != 0 { completedRows[op.SourceRow] = struct{}{} } } - pending := make([]RepairPendingTodo, 0) + pending := make([]RepairPendingRecord, 0) for _, row := range rows { if _, ok := completedRows[row.SourceRow]; ok { continue } - pending = append(pending, RepairPendingTodo{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, TodolistName: row.TodolistName}) + pending = append(pending, RepairPendingRecord{SourceRow: row.SourceRow, SourceRecordID: row.SourceRecordID, Title: row.Title, GroupName: row.TodolistName}) } return pending } diff --git a/internal/importer/status.go b/internal/importer/status.go index 7f436573..0377ad0c 100644 --- a/internal/importer/status.go +++ b/internal/importer/status.go @@ -47,7 +47,7 @@ func StatusArtifact(artifactDir string) (*ArtifactStatus, error) { Destination: manifest.Destination, Counts: manifest.Counts, Files: manifest.Files, - Checks: []ArtifactStatusCheck{{Name: "artifact", Status: "passed", Message: "Artifact manifest and todo CSV are valid."}}, + Checks: []ArtifactStatusCheck{{Name: "artifact", Status: "passed", Message: artifactStatusCheckMessage(manifest)}}, Guidance: "Run preflight before approved execution.", } @@ -60,7 +60,7 @@ func StatusArtifact(artifactDir string) (*ArtifactStatus, error) { } status.Status = "ledger_unreadable" status.Checks = append(status.Checks, ArtifactStatusCheck{Name: "execution_ledger", Status: "blocked", Message: fmt.Sprintf("Execution ledger cannot be read: %v", err)}) - status.Guidance = "Review or remove the unreadable execution ledger before using this artifact." + status.Guidance = "Execution ledger cannot be read. Inspect execution.json and avoid reusing this artifact for execution." return status, nil } @@ -80,6 +80,13 @@ func StatusArtifact(artifactDir string) (*ArtifactStatus, error) { return status, nil } +func artifactStatusCheckMessage(manifest *ImportArtifactManifest) string { + if manifest.Files.Cards != "" { + return "Artifact manifest and cards CSV are valid." + } + return "Artifact manifest and todos CSV are valid." +} + func readExecutionLedger(path string) (*ExecutionLedger, error) { var ledger ExecutionLedger if err := readJSONData(path, &ledger); err != nil { diff --git a/testdata/import/csv/canonical/asana/sample-03.csv b/testdata/import/csv/canonical/asana/sample-03.csv index 1af078a9..d6b6f9d6 100644 --- a/testdata/import/csv/canonical/asana/sample-03.csv +++ b/testdata/import/csv/canonical/asana/sample-03.csv @@ -1,4 +1,4 @@ -Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent Task -38330760681100,2015-06-18,,2022-04-15,Sensu Check: Client Keepalive,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", -38330760681093,2015-06-18,,2022-04-15,Sensu Client : remote server configuration setup,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", -15328349794967,2014-08-06,2014-09-09,2022-04-15,Add Install for ec2-ami-tools in imageServer,(no section),Person 9,user4@example.com,,2014-08-08,,http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/set-up-ami-tools.html,BrainDump,FogBugz (4866 and 4616) : Instance Store  +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent Task +38330760681100,2015-06-18,,2022-04-15,Sensu Check: Client Keepalive,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", +38330760681093,2015-06-18,,2022-04-15,Sensu Client : remote server configuration setup,(no section),Person 9,user3@example.com,,,,,"BrainDump,DCM-Sensu", +15328349794967,2014-08-06,2014-09-09,2022-04-15,Add Install for ec2-ami-tools in imageServer,(no section),Person 9,user4@example.com,,2014-08-08,,http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/set-up-ami-tools.html,BrainDump,FogBugz (4866 and 4616) : Instance Store diff --git a/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv b/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv index e41d1c75..88afb382 100644 --- a/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv +++ b/testdata/import/csv/synthetic/jira-attachments-custom-fields.csv @@ -1,3 +1,3 @@ -Issue Type,Issue key,Issue id,Summary,Fix versions,Fix versions,Status,Created,Updated,Priority,Resolution,Affects versions,Affects versions,Security Level,Labels,Labels,Labels,Labels,Labels -Task,BCJ-1,10001,Draft launch checklist,"","",To Do,01/Jan/26 9:00 AM,02/Jan/26 10:00 AM,Medium,"","","","",launch,planning,"","","" -Bug,BCJ-2,10002,Fix signup error,"","",In Progress,03/Jan/26 11:15 AM,04/Jan/26 12:00 PM,High,"","","","",bug,signup,"","","" +Issue Type,Issue key,Issue id,Summary,Fix versions,Fix versions,Status,Created,Updated,Priority,Resolution,Affects versions,Affects versions,Security Level,Labels,Labels,Labels,Labels,Labels,Attachment,Custom field (Story Points) +Task,BCJ-1,10001,Draft launch checklist,"","",To Do,01/Jan/26 9:00 AM,02/Jan/26 10:00 AM,Medium,"","","","",launch,planning,"","","",https://example.com/imports/launch-checklist.pdf,3 +Bug,BCJ-2,10002,Fix signup error,"","",In Progress,03/Jan/26 11:15 AM,04/Jan/26 12:00 PM,High,"","","","",bug,signup,"","","",https://example.com/imports/signup-error.log,5 From 54a91fc1d371293f7099a5ae93d0d4be465e45a0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 5 Jun 2026 14:32:20 -0400 Subject: [PATCH 07/10] importer: enforce artifact file permissions --- internal/importer/artifact.go | 27 +++++++++++++++++++--- internal/importer/artifact_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/importer/artifact.go b/internal/importer/artifact.go index 3aad0b9e..4e38e11d 100644 --- a/internal/importer/artifact.go +++ b/internal/importer/artifact.go @@ -317,14 +317,21 @@ func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifa if err := os.MkdirAll(outDir, 0o750); err != nil { return fmt.Errorf("create artifact directory: %w", err) } + if err := os.Chmod(outDir, 0o750); err != nil { // #nosec G302 -- artifact directories need owner/group traversal + return fmt.Errorf("secure artifact directory permissions: %w", err) + } manifestData, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("encode artifact manifest: %w", err) } manifestData = append(manifestData, '\n') - if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), manifestData, 0o600); err != nil { + manifestPath := filepath.Join(outDir, artifactManifestName) + if err := os.WriteFile(manifestPath, manifestData, 0o600); err != nil { return fmt.Errorf("write artifact manifest: %w", err) } + if err := os.Chmod(manifestPath, 0o600); err != nil { + return fmt.Errorf("secure artifact manifest permissions: %w", err) + } if manifest.Files.Cards != "" { if err := writeArtifactCards(filepath.Join(outDir, manifest.Files.Cards), rows); err != nil { return err @@ -342,7 +349,10 @@ func writeArtifactTodos(path string, rows []artifactTodoRow) error { if err != nil { return fmt.Errorf("write artifact todos: %w", err) } - defer file.Close() + if err := os.Chmod(path, 0o600); err != nil { + _ = file.Close() + return fmt.Errorf("secure artifact todos permissions: %w", err) + } writer := csv.NewWriter(file) if err := writer.Write(artifactTodoHeader); err != nil { @@ -359,6 +369,10 @@ func writeArtifactTodos(path string, rows []artifactTodoRow) error { } writer.Flush() if err := writer.Error(); err != nil { + _ = file.Close() + return fmt.Errorf("write artifact todos: %w", err) + } + if err := file.Close(); err != nil { return fmt.Errorf("write artifact todos: %w", err) } return nil @@ -369,7 +383,10 @@ func writeArtifactCards(path string, rows []artifactTodoRow) error { if err != nil { return fmt.Errorf("write artifact cards: %w", err) } - defer file.Close() + if err := os.Chmod(path, 0o600); err != nil { + _ = file.Close() + return fmt.Errorf("secure artifact cards permissions: %w", err) + } writer := csv.NewWriter(file) if err := writer.Write(artifactCardHeader); err != nil { @@ -386,6 +403,10 @@ func writeArtifactCards(path string, rows []artifactTodoRow) error { } writer.Flush() if err := writer.Error(); err != nil { + _ = file.Close() + return fmt.Errorf("write artifact cards: %w", err) + } + if err := file.Close(); err != nil { return fmt.Errorf("write artifact cards: %w", err) } return nil diff --git a/internal/importer/artifact_test.go b/internal/importer/artifact_test.go index b864bcc4..6ac08782 100644 --- a/internal/importer/artifact_test.go +++ b/internal/importer/artifact_test.go @@ -59,6 +59,43 @@ T-2,Book venue,Call two places,Events,todo,jamie@example.com,2026-06-03,https:// } } +func TestCompileArtifactEnforcesPrivateArtifactPermissions(t *testing.T) { + inspection := inspectTempCSV(t, "id,title\n1,Do the thing\n") + mapping := &MappingConfig{SchemaVersion: planSchemaVersion, Title: &ColumnRef{ColumnIndex: 1}} + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123"} + outDir := filepath.Join(t.TempDir(), "artifact") + if err := os.MkdirAll(outDir, 0o777); err != nil { + t.Fatalf("mkdir artifact: %v", err) + } + if err := os.Chmod(outDir, 0o777); err != nil { + t.Fatalf("chmod artifact dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, artifactManifestName), []byte("{}"), 0o666); err != nil { + t.Fatalf("seed manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, artifactTodosFileName), []byte("old"), 0o666); err != nil { + t.Fatalf("seed todos: %v", err) + } + + if _, err := CompileArtifact(inspection, mapping, destination, outDir); err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + assertFileMode(t, outDir, 0o750) + assertFileMode(t, filepath.Join(outDir, artifactManifestName), 0o600) + assertFileMode(t, filepath.Join(outDir, artifactTodosFileName), 0o600) +} + +func assertFileMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode %s = %o, want %o", path, got, want) + } +} + func TestPlanFromArtifactMatchesCompiledOperations(t *testing.T) { inspection := inspectTempCSV(t, "id,title,list\n1,First,Backlog\n2,Second,Doing\n") mapping := &MappingConfig{SchemaVersion: planSchemaVersion, RecordID: &ColumnRef{ColumnIndex: 0}, Title: &ColumnRef{ColumnIndex: 1}, Todolist: &ColumnRef{ColumnIndex: 2}} From 142cd8348a0ff5b552118c284a9743cc010a0736 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 5 Jun 2026 14:43:25 -0400 Subject: [PATCH 08/10] importer: tighten follow-up artifact writes --- internal/importer/artifact.go | 36 ++++++++++---- internal/importer/followup.go | 20 ++++++-- internal/importer/followup_test.go | 17 +++++++ .../import/csv/canonical/asana/sample-04.csv | 48 +++++++++---------- 4 files changed, 83 insertions(+), 38 deletions(-) diff --git a/internal/importer/artifact.go b/internal/importer/artifact.go index 4e38e11d..808ed751 100644 --- a/internal/importer/artifact.go +++ b/internal/importer/artifact.go @@ -344,13 +344,20 @@ func writeArtifact(outDir string, manifest ImportArtifactManifest, rows []artifa return nil } -func writeArtifactTodos(path string, rows []artifactTodoRow) error { +func writeArtifactTodos(path string, rows []artifactTodoRow) (err error) { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 -- artifact file paths are compiled from the selected artifact directory if err != nil { return fmt.Errorf("write artifact todos: %w", err) } + closed := false + defer func() { + if !closed { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("write artifact todos: %w", closeErr) + } + } + }() if err := os.Chmod(path, 0o600); err != nil { - _ = file.Close() return fmt.Errorf("secure artifact todos permissions: %w", err) } @@ -369,22 +376,30 @@ func writeArtifactTodos(path string, rows []artifactTodoRow) error { } writer.Flush() if err := writer.Error(); err != nil { - _ = file.Close() return fmt.Errorf("write artifact todos: %w", err) } - if err := file.Close(); err != nil { - return fmt.Errorf("write artifact todos: %w", err) + closeErr := file.Close() + closed = true + if closeErr != nil { + return fmt.Errorf("write artifact todos: %w", closeErr) } return nil } -func writeArtifactCards(path string, rows []artifactTodoRow) error { +func writeArtifactCards(path string, rows []artifactTodoRow) (err error) { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec G304 -- artifact file paths are compiled from the selected artifact directory if err != nil { return fmt.Errorf("write artifact cards: %w", err) } + closed := false + defer func() { + if !closed { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("write artifact cards: %w", closeErr) + } + } + }() if err := os.Chmod(path, 0o600); err != nil { - _ = file.Close() return fmt.Errorf("secure artifact cards permissions: %w", err) } @@ -403,11 +418,12 @@ func writeArtifactCards(path string, rows []artifactTodoRow) error { } writer.Flush() if err := writer.Error(); err != nil { - _ = file.Close() return fmt.Errorf("write artifact cards: %w", err) } - if err := file.Close(); err != nil { - return fmt.Errorf("write artifact cards: %w", err) + closeErr := file.Close() + closed = true + if closeErr != nil { + return fmt.Errorf("write artifact cards: %w", closeErr) } return nil } diff --git a/internal/importer/followup.go b/internal/importer/followup.go index 17f956b0..6dd2534e 100644 --- a/internal/importer/followup.go +++ b/internal/importer/followup.go @@ -35,10 +35,8 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* if samePath(artifactDir, outDir) { return nil, fmt.Errorf("follow-up artifact output must be different from the source artifact") } - if _, err := os.Stat(filepath.Join(outDir, artifactExecutionFileName)); err == nil { - return nil, fmt.Errorf("follow-up artifact output already contains execution.json") - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("checking follow-up output: %w", err) + if err := ensureFollowupOutputReady(outDir); err != nil { + return nil, err } manifest, rows, err := readArtifact(artifactDir) @@ -146,6 +144,20 @@ func CreateFollowupArtifact(artifactDir, outDir string, opts FollowupOptions) (* }, nil } +func ensureFollowupOutputReady(outDir string) error { + entries, err := os.ReadDir(outDir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("checking follow-up output: %w", err) + } + if len(entries) > 0 { + return fmt.Errorf("follow-up artifact output directory must be empty or not exist") + } + return nil +} + func followupProject(manifest *ImportArtifactManifest, operations []ExecutionLedgerOperation) (string, string, error) { if manifest.Destination.Mode == "existing_project" { if strings.TrimSpace(manifest.Destination.ProjectID) == "" { diff --git a/internal/importer/followup_test.go b/internal/importer/followup_test.go index 4d8e7be8..9b4d2ca7 100644 --- a/internal/importer/followup_test.go +++ b/internal/importer/followup_test.go @@ -2,6 +2,7 @@ package importer import ( "context" + "os" "path/filepath" "strings" "testing" @@ -15,6 +16,22 @@ func TestCreateFollowupArtifactRequiresReviewed(t *testing.T) { } } +func TestCreateFollowupArtifactRejectsNonEmptyOutputDirectory(t *testing.T) { + artifactDir := failedExecutionArtifact(t) + followupDir := filepath.Join(t.TempDir(), "followup") + if err := os.MkdirAll(followupDir, 0o755); err != nil { + t.Fatalf("mkdir followup: %v", err) + } + if err := os.WriteFile(filepath.Join(followupDir, "existing.txt"), []byte("keep"), 0o600); err != nil { + t.Fatalf("seed followup: %v", err) + } + + _, err := CreateFollowupArtifact(artifactDir, followupDir, FollowupOptions{Reviewed: true}) + if err == nil || !strings.Contains(err.Error(), "empty or not exist") { + t.Fatalf("expected non-empty output error, got %v", err) + } +} + func TestCreateFollowupArtifactFromFailedExistingTodolistExecution(t *testing.T) { artifactDir := compileSimpleExecutionArtifact(t, &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Imported"}) client := &fakeWriteClient{failTodoRows: map[string]error{"Second": assertError("boom")}} diff --git a/testdata/import/csv/canonical/asana/sample-04.csv b/testdata/import/csv/canonical/asana/sample-04.csv index 07350623..2ea3b0b4 100644 --- a/testdata/import/csv/canonical/asana/sample-04.csv +++ b/testdata/import/csv/canonical/asana/sample-04.csv @@ -1,26 +1,26 @@ -Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent task,Blocked By (Dependencies),Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocked By (Dependencies),Blocked By (Dependencies),Blocking (Dependencies),Assignee (imported),Dependents,Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported) -1210823029405447,2025-07-17,,2025-07-17,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,Untitled section,Person 10,user5@example.com,2025-07-16,2025-07-21,,"Complete documentation and collaboration strategy for TourPlan-HubSpot integration enhancement with TMIS. Includes comprehensive system diagrams, requirements framework, and collaboration protocols to ensure structured development approach and cost control.",Iwan's Board,,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405448,2025-07-17,,2025-07-17,Phase 1: System Documentation & Diagramming,,Person 10,user5@example.com,2025-07-16,2025-07-16,,Document current TourPlan-HubSpot integration architecture and create comprehensive technical diagrams. Like creating blueprints before renovation - provides complete system visibility for informed enhancement decisions and prevents costly miscommunication with TMIS.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405449,2025-07-17,,2025-07-17,Create interactive system architecture diagram,,,,2025-07-16,2025-07-16,,"Build comprehensive MermaidChart diagram showing all webhook handlers, system components, and data flow. Provides TMIS with complete technical understanding without exposing proprietary code. Essential for technical discussions and prevents scope creep. 🏗️ System Architecture Overview View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EY4T_yxKozJAnd1X9HqjaaQBbMx4N5PZXzvbr3_5WyOuGQ?e=j1Gcfj",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405450,2025-07-17,,2025-07-17,Create company webhook processing flow diagram,,,,2025-07-16,2025-07-16,,"Document detailed data flow for main business logic processing companies from HubSpot to TourPlan. Shows validation rules, error handling, and business logic. Critical for TMIS to understand core functionality before proposing enhancements. ↩️ Company Webhook Processing Flow View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EbBMbd5S9XlDuO5KGJyC-3YBApOPCYFQx0nQUIPXXLLFZA?e=P7MFGf",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405451,2025-07-17,,2025-07-17,Create complete webhook sequence diagram,,,,2025-07-16,2025-07-16,,"Build chronological interaction diagram between all system components. Shows timing, dependencies, and system behavior. Enables TMIS to understand system complexity and propose appropriate enhancement approaches. 📈 Complete Webhook Sequence View: https://angamasa-my.sharepoint.com/:u:/g/personal/joel_muriuki_angama_com/EXN4EJ6XUgZGntuy9xatTXwBVhrdr3_6KdoMjieIyq8hiA?e=XeCBYn",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405452,2025-07-17,,2025-07-17,Upload diagrams to MermaidChart,,,,2025-07-16,2025-07-16,,Make all technical diagrams publicly accessible via MermaidChart for TMIS review. Provides complete technical transparency while maintaining code repository control. Foundation for all technical discussions.,,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405453,2025-07-17,,2025-07-17,Phase 2: TMIS Collaboration Strategy,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Develop comprehensive strategy for TMIS collaboration including cost optimization, requirements control, and GitLab access protocols. Like creating negotiation strategy before important business discussion - ensures favorable outcomes and prevents unexpected costs.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405454,2025-07-17,,2025-07-17,Create TMIS collaboration guide,,,,2025-07-16,2025-07-16,,"Comprehensive guide for Kim & Siamanta covering meeting strategy, requirements template, cost-avoidance recommendations, and TMIS collaboration protocols. Prevents TMIS from charging extra fees for requirements gathering and maintains project control.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405455,2025-07-17,,2025-07-17,Develop requirements template for Kim & Siamanta,,,,2025-07-16,2025-07-16,,"Detailed checklist covering data integration needs, workflow enhancements, technical requirements, and business rules. Internally handling requirements analysis helps approach TMIS with comprehensive specifications ready upfront.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405456,2025-07-17,,2025-07-17,Document GitLab access logical reasoning,,,,2025-07-16,2025-07-16,,Establish clear rationale for controlled repository access based on technical requirements completion. Prevents uncontrolled development work that could deviate from specifications and generate unnecessary costs.,,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405457,2025-07-17,,2025-07-17,Phase 3: Communications Framework,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Establish organized communication protocols with TMIS and internal teams. Like setting up proper channels before important negotiations - ensures clear documentation, decision tracking, and professional relationship management.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405458,2025-07-17,,2025-07-17,Create communications directory structure,,,,2025-07-16,2025-07-16,,"Organize vendor and internal communications with proper documentation standards. Enables project transparency, decision tracking, and knowledge transfer. Essential for maintaining professional relationship management.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405459,2025-07-17,,2025-07-17,Draft TMIS technical alignment email,,,,2025-07-16,2025-07-16,,Professional email requesting existing technical materials from TMIS previous discussions. Positions GitLab access as dependent on mutual technical review. Avoids duplication and establishes collaborative workflow expectations.,,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405460,2025-07-17,,2025-07-17,TMIS technical materials response,,,,2025-07-17,2025-07-23,,"TMIS responds with existing technical documentation, integration approaches, architectural notes, and implementation frameworks from previous discussions. Critical dependency - all future development planning depends on understanding their current technical position and preparedness level.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405461,2025-07-17,,2025-07-17,Phase 4: Requirements Consolidation,,,,2025-07-17,2025-07-18,,Consolidate Kim & Siamanta business requirements with TMIS technical materials to create comprehensive development specifications. Like combining architectural plans with engineering requirements - ensures all stakeholder needs are addressed in technical solution.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405462,2025-07-17,,2025-07-17,Analyze TMIS technical materials,,,,2025-07-17,2025-07-17,,"Review and analyze technical documentation received from TMIS to identify integration patterns, capabilities, and constraints. Determines feasibility of proposed enhancements and identifies potential technical challenges or limitations.",,Phase 4: Requirements Consolidation,,Integrate business and technical requirements,,,,,,,,,,,,,,,,,,,,,,, -1210823029405463,2025-07-17,,2025-07-17,Integrate business and technical requirements,,,,2025-07-18,2025-07-18,,Combine Kim & Siamanta's business requirements with TMIS technical capabilities to create unified specification document. Ensures technical solution addresses business needs while leveraging TMIS strengths effectively.,,Phase 4: Requirements Consolidation,Analyze TMIS technical materials,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405464,2025-07-17,,2025-07-17,Consolidated requirements document,,,,2025-07-18,2025-07-18,,Complete specification document combining business requirements and technical approaches. Like final blueprints before construction - provides TMIS with exact implementation requirements and prevents scope creep or additional discovery charges.,,Phase 4: Requirements Consolidation,,,,,,,,,,,,,,,,,,,,,,,,, +Task ID,Created At,Completed At,Last Modified,Name,Section/Column,Assignee,Assignee Email,Start Date,Due Date,Tags,Notes,Projects,Parent task,Blocked By (Dependencies),Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocking (Dependencies),Parent task,Blocked By (Dependencies),Blocked By (Dependencies),Blocking (Dependencies),Assignee (imported),Dependents,Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported),Assignee (imported) +1210823029405447,2025-07-17,,2025-07-17,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,Untitled section,Person 10,user5@example.com,2025-07-16,2025-07-21,,"Complete documentation and collaboration strategy for TourPlan-HubSpot integration enhancement with TMIS. Includes comprehensive system diagrams, requirements framework, and collaboration protocols to ensure structured development approach and cost control.",Iwan's Board,,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405448,2025-07-17,,2025-07-17,Phase 1: System Documentation & Diagramming,,Person 10,user5@example.com,2025-07-16,2025-07-16,,Document current TourPlan-HubSpot integration architecture and create comprehensive technical diagrams. Like creating blueprints before renovation - provides complete system visibility for informed enhancement decisions and prevents costly miscommunication with TMIS.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405449,2025-07-17,,2025-07-17,Create interactive system architecture diagram,,,,2025-07-16,2025-07-16,,"Build comprehensive MermaidChart diagram showing all webhook handlers, system components, and data flow. Provides TMIS with complete technical understanding without exposing proprietary code. Essential for technical discussions and prevents scope creep. 🏗️ System Architecture Overview View: https://example.com/redacted/sharepoint-diagram-1",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405450,2025-07-17,,2025-07-17,Create company webhook processing flow diagram,,,,2025-07-16,2025-07-16,,"Document detailed data flow for main business logic processing companies from HubSpot to TourPlan. Shows validation rules, error handling, and business logic. Critical for TMIS to understand core functionality before proposing enhancements. ↩️ Company Webhook Processing Flow View: https://example.com/redacted/sharepoint-diagram-2",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405451,2025-07-17,,2025-07-17,Create complete webhook sequence diagram,,,,2025-07-16,2025-07-16,,"Build chronological interaction diagram between all system components. Shows timing, dependencies, and system behavior. Enables TMIS to understand system complexity and propose appropriate enhancement approaches. 📈 Complete Webhook Sequence View: https://example.com/redacted/sharepoint-diagram-3",,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405452,2025-07-17,,2025-07-17,Upload diagrams to MermaidChart,,,,2025-07-16,2025-07-16,,Make all technical diagrams publicly accessible via MermaidChart for TMIS review. Provides complete technical transparency while maintaining code repository control. Foundation for all technical discussions.,,Phase 1: System Documentation & Diagramming,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405453,2025-07-17,,2025-07-17,Phase 2: TMIS Collaboration Strategy,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Develop comprehensive strategy for TMIS collaboration including cost optimization, requirements control, and GitLab access protocols. Like creating negotiation strategy before important business discussion - ensures favorable outcomes and prevents unexpected costs.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405454,2025-07-17,,2025-07-17,Create TMIS collaboration guide,,,,2025-07-16,2025-07-16,,"Comprehensive guide for Kim & Siamanta covering meeting strategy, requirements template, cost-avoidance recommendations, and TMIS collaboration protocols. Prevents TMIS from charging extra fees for requirements gathering and maintains project control.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405455,2025-07-17,,2025-07-17,Develop requirements template for Kim & Siamanta,,,,2025-07-16,2025-07-16,,"Detailed checklist covering data integration needs, workflow enhancements, technical requirements, and business rules. Internally handling requirements analysis helps approach TMIS with comprehensive specifications ready upfront.",,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405456,2025-07-17,,2025-07-17,Document GitLab access logical reasoning,,,,2025-07-16,2025-07-16,,Establish clear rationale for controlled repository access based on technical requirements completion. Prevents uncontrolled development work that could deviate from specifications and generate unnecessary costs.,,Phase 2: TMIS Collaboration Strategy,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405457,2025-07-17,,2025-07-17,Phase 3: Communications Framework,,Person 10,user5@example.com,2025-07-16,2025-07-16,,"Establish organized communication protocols with TMIS and internal teams. Like setting up proper channels before important negotiations - ensures clear documentation, decision tracking, and professional relationship management.",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405458,2025-07-17,,2025-07-17,Create communications directory structure,,,,2025-07-16,2025-07-16,,"Organize vendor and internal communications with proper documentation standards. Enables project transparency, decision tracking, and knowledge transfer. Essential for maintaining professional relationship management.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405459,2025-07-17,,2025-07-17,Draft TMIS technical alignment email,,,,2025-07-16,2025-07-16,,Professional email requesting existing technical materials from TMIS previous discussions. Positions GitLab access as dependent on mutual technical review. Avoids duplication and establishes collaborative workflow expectations.,,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405460,2025-07-17,,2025-07-17,TMIS technical materials response,,,,2025-07-17,2025-07-23,,"TMIS responds with existing technical documentation, integration approaches, architectural notes, and implementation frameworks from previous discussions. Critical dependency - all future development planning depends on understanding their current technical position and preparedness level.",,Phase 3: Communications Framework,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405461,2025-07-17,,2025-07-17,Phase 4: Requirements Consolidation,,,,2025-07-17,2025-07-18,,Consolidate Kim & Siamanta business requirements with TMIS technical materials to create comprehensive development specifications. Like combining architectural plans with engineering requirements - ensures all stakeholder needs are addressed in technical solution.,,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405462,2025-07-17,,2025-07-17,Analyze TMIS technical materials,,,,2025-07-17,2025-07-17,,"Review and analyze technical documentation received from TMIS to identify integration patterns, capabilities, and constraints. Determines feasibility of proposed enhancements and identifies potential technical challenges or limitations.",,Phase 4: Requirements Consolidation,,Integrate business and technical requirements,,,,,,,,,,,,,,,,,,,,,,, +1210823029405463,2025-07-17,,2025-07-17,Integrate business and technical requirements,,,,2025-07-18,2025-07-18,,Combine Kim & Siamanta's business requirements with TMIS technical capabilities to create unified specification document. Ensures technical solution addresses business needs while leveraging TMIS strengths effectively.,,Phase 4: Requirements Consolidation,Analyze TMIS technical materials,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405464,2025-07-17,,2025-07-17,Consolidated requirements document,,,,2025-07-18,2025-07-18,,Complete specification document combining business requirements and technical approaches. Like final blueprints before construction - provides TMIS with exact implementation requirements and prevents scope creep or additional discovery charges.,,Phase 4: Requirements Consolidation,,,,,,,,,,,,,,,,,,,,,,,,, 1210823029405465,2025-07-17,,2025-07-17,Phase 5: Future State Planning,,,,2025-07-21,2025-07-21,,"Create comprehensive future-state diagrams and implementation specifications based on consolidated requirements. Provides TMIS with complete technical vision for implementation quotes, avoiding discovery fees and ensuring accurate project scoping. -Start development (coding) work!",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405466,2025-07-17,,2025-07-17,Create enhanced architecture diagrams,,,,2025-07-21,2025-07-21,,"Design future-state system architecture showing new integrations, enhanced workflows, and additional system components. Provides TMIS with clear technical vision and enables accurate implementation time estimates.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, -1210823029405467,2025-07-17,,2025-07-17,Develop detailed implementation specifications,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Document detailed technical specifications including APIs, data flows, error handling, security requirements, and integration patterns.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405468,2025-07-17,,2025-07-17,GitLab repository access,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Provide TMIS with GitLab repository access after all technical requirements, specifications, and future-state diagrams are complete. Ensures structured collaboration within defined framework and prevents uncontrolled development work.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 -1210823029405469,2025-07-17,,2025-07-17,TMIS implementation Workplan and Begin of Dev Work,,,,2025-07-21,2025-07-21,,"TMIS delivers precise implementation plans with estimated timelines, based on your complete specifications and future-state diagrams. Our upfront preparation ensures clarity on deliverables and schedules, empowering your team to make confident development decisions.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, +Start development (coding) work!",,TourPlan-HubSpot Integration Documentation & TMIS Collaboration,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405466,2025-07-17,,2025-07-17,Create enhanced architecture diagrams,,,,2025-07-21,2025-07-21,,"Design future-state system architecture showing new integrations, enhanced workflows, and additional system components. Provides TMIS with clear technical vision and enables accurate implementation time estimates.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, +1210823029405467,2025-07-17,,2025-07-17,Develop detailed implementation specifications,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Document detailed technical specifications including APIs, data flows, error handling, security requirements, and integration patterns.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405468,2025-07-17,,2025-07-17,GitLab repository access,,Person 10,user5@example.com,2025-07-21,2025-07-21,,"Provide TMIS with GitLab repository access after all technical requirements, specifications, and future-state diagrams are complete. Ensures structured collaboration within defined framework and prevents uncontrolled development work.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,,Person 10 +1210823029405469,2025-07-17,,2025-07-17,TMIS implementation Workplan and Begin of Dev Work,,,,2025-07-21,2025-07-21,,"TMIS delivers precise implementation plans with estimated timelines, based on your complete specifications and future-state diagrams. Our upfront preparation ensures clarity on deliverables and schedules, empowering your team to make confident development decisions.",,Phase 5: Future State Planning,,,,,,,,,,,,,,,,,,,,,,,,, From 509d6f5747ea198d6a4f1df2be3137603e8c2259 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 8 Jun 2026 15:51:57 -0400 Subject: [PATCH 09/10] Add adversarial import fixture coverage --- .../importer/adversarial_fixtures_test.go | 340 ++++++++++++++++++ internal/importer/csvprofiler.go | 2 +- testdata/import/csv/README.md | 6 + .../new-project-fallback-groups.csv | 4 + ...e-existing-todolist-many-urls-comments.csv | 4 + .../semicolon-ragged-generated-columns.csv | 5 + .../tab-cards-duplicate-columns.csv | 4 + .../adversarial/wide-duplicate-multiline.csv | 6 + 8 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 internal/importer/adversarial_fixtures_test.go create mode 100644 testdata/import/csv/synthetic/adversarial/new-project-fallback-groups.csv create mode 100644 testdata/import/csv/synthetic/adversarial/pipe-existing-todolist-many-urls-comments.csv create mode 100644 testdata/import/csv/synthetic/adversarial/semicolon-ragged-generated-columns.csv create mode 100644 testdata/import/csv/synthetic/adversarial/tab-cards-duplicate-columns.csv create mode 100644 testdata/import/csv/synthetic/adversarial/wide-duplicate-multiline.csv diff --git a/internal/importer/adversarial_fixtures_test.go b/internal/importer/adversarial_fixtures_test.go new file mode 100644 index 00000000..d1ef7c8e --- /dev/null +++ b/internal/importer/adversarial_fixtures_test.go @@ -0,0 +1,340 @@ +package importer + +import ( + "bytes" + "encoding/csv" + "fmt" + "path/filepath" + "strings" + "testing" +) + +func TestAdversarialSyntheticFixturesRoundTripWithoutBasecamp(t *testing.T) { + tests := []struct { + name string + fixture string + wantDelimiter string + mapping *MappingConfig + destination *DestinationConfig + wantCounts PlanCounts + wantCandidates map[string]int + assertRows func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) + }{ + { + name: "wide duplicate headers multiline todos", + fixture: "wide-duplicate-multiline.csv", + wantDelimiter: ",", + mapping: &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "ID"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "Title"}, + Description: &ColumnRef{ColumnIndex: 2, ColumnName: "Description"}, + Todolist: &ColumnRef{ColumnIndex: 3, ColumnName: "List"}, + Status: &ColumnRef{ColumnIndex: 4, ColumnName: "Status"}, + Assignees: &ColumnRef{ColumnIndex: 5, ColumnName: "Assignee Emails"}, + DueOn: &ColumnRef{ColumnIndex: 6, ColumnName: "Due Date"}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 7, ColumnName: "Attachment URL"}}, + Comments: []ColumnRef{{ColumnIndex: 8, ColumnName: "Comment"}}, + CustomFields: "all_unmapped_columns", + }, + destination: &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}, + wantCounts: PlanCounts{Todolists: 2, Todos: 3}, + wantCandidates: map[string]int{ + "record_id": 0, + "title": 1, + "description": 2, + "todolist": 3, + "status": 4, + "assignees": 5, + "due_on": 6, + "attachment_urls": 7, + "comments": 8, + }, + assertRows: func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) { + t.Helper() + if manifest.Files.Todos != artifactTodosFileName || manifest.Files.Cards != "" { + t.Fatalf("artifact files = %+v", manifest.Files) + } + if len(rows[0].AssigneeEmails) != 2 || rows[0].AssigneeEmails[1] != "jamie@example.com" { + t.Fatalf("assignee split = %+v", rows[0].AssigneeEmails) + } + if !strings.Contains(rows[0].Description, "wet paint") || !strings.Contains(rows[0].Comments[0], "second comment line") { + t.Fatalf("multiline text was not preserved: %+v", rows[0]) + } + if rows[1].DueOn != "2026-06-02" || rows[2].DueOn != "2026-06-04" { + t.Fatalf("normalized due dates = %q, %q", rows[1].DueOn, rows[2].DueOn) + } + if rows[0].CustomFields["Label [9]"] != "renovation" || rows[0].CustomFields["Label [10]"] != "Q2" || rows[1].CustomFields["Parent ID"] != "W-001" { + t.Fatalf("custom fields = %+v", rows[0].CustomFields) + } + }, + }, + { + name: "semicolon ragged rows generated columns", + fixture: "semicolon-ragged-generated-columns.csv", + wantDelimiter: ";", + mapping: &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "key"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "summary"}, + Todolist: &ColumnRef{ColumnIndex: 2, ColumnName: "bucket"}, + DueOn: &ColumnRef{ColumnIndex: 3, ColumnName: "due", DateOrder: "dmy"}, + Assignees: &ColumnRef{ColumnIndex: 4, ColumnName: "owner"}, + Description: &ColumnRef{ColumnIndex: 5, ColumnName: "detail"}, + CustomFields: "all_unmapped_columns", + }, + destination: &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"}, + wantCounts: PlanCounts{Todolists: 3, Todos: 4}, + wantCandidates: map[string]int{"record_id": 0, "title": 1, "todolist": 2, "due_on": 3, "assignees": 4, "description": 5}, + assertRows: func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) { + t.Helper() + if rows[2].TodolistName != "Imported todos" { + t.Fatalf("blank bucket fallback = %q", rows[2].TodolistName) + } + if rows[0].CustomFields["Column 7"] != "orphan custom value" || rows[2].CustomFields["Column 8"] != "generated two" { + t.Fatalf("generated-column custom fields: row1=%+v row3=%+v", rows[0].CustomFields, rows[2].CustomFields) + } + if rows[0].DueOn != "2026-06-18" || rows[3].DueOn != "2026-06-21" { + t.Fatalf("dmy due dates = %q, %q", rows[0].DueOn, rows[3].DueOn) + } + }, + }, + { + name: "tab card artifact duplicate custom fields", + fixture: "tab-cards-duplicate-columns.csv", + wantDelimiter: "\t", + mapping: &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "card_id"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "title"}, + Description: &ColumnRef{ColumnIndex: 2, ColumnName: "description"}, + Column: &ColumnRef{ColumnIndex: 3, ColumnName: "column"}, + Status: &ColumnRef{ColumnIndex: 4, ColumnName: "status"}, + Assignees: &ColumnRef{ColumnIndex: 5, ColumnName: "owner"}, + DueOn: &ColumnRef{ColumnIndex: 6, ColumnName: "due"}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 7, ColumnName: "link"}}, + Comments: []ColumnRef{{ColumnIndex: 8, ColumnName: "comment"}}, + CustomFields: "all_unmapped_columns", + }, + destination: &DestinationConfig{SchemaVersion: planSchemaVersion, ResourceType: resourceTypeCards, Mode: "existing_project", ProjectID: "123", CardTableID: "888", ColumnStrategy: "create_from_column"}, + wantCounts: PlanCounts{CardColumns: 2, Cards: 3}, + wantCandidates: map[string]int{"record_id": 0, "title": 1, "description": 2, "todolist": 3, "status": 4, "assignees": 5, "due_on": 6, "attachment_urls": 7, "comments": 8}, + assertRows: func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) { + t.Helper() + if manifest.Files.Cards != artifactCardsFileName || manifest.Files.Todos != "" { + t.Fatalf("artifact files = %+v", manifest.Files) + } + if rows[0].CardTableID != 888 || rows[0].TodolistName != "Backlog" { + t.Fatalf("card grouping = %+v", rows[0]) + } + if rows[0].CustomFields["rank [9]"] != "P1" || rows[0].CustomFields["rank [10]"] != "customer" { + t.Fatalf("duplicate custom fields = %+v", rows[0].CustomFields) + } + if plan.Operations[0].Op != "create_card_column" || plan.Operations[2].Op != "create_card" { + t.Fatalf("card operations = %+v", plan.Operations[:3]) + } + }, + }, + { + name: "pipe existing todolist many urls comments", + fixture: "pipe-existing-todolist-many-urls-comments.csv", + wantDelimiter: "|", + mapping: &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "external_id"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "todo title"}, + Description: &ColumnRef{ColumnIndex: 2, ColumnName: "body"}, + DueOn: &ColumnRef{ColumnIndex: 3, ColumnName: "due"}, + Assignees: &ColumnRef{ColumnIndex: 4, ColumnName: "owner"}, + AttachmentURLs: []ColumnRef{{ColumnIndex: 5, ColumnName: "file url"}, {ColumnIndex: 6, ColumnName: "source url"}}, + Comments: []ColumnRef{{ColumnIndex: 7, ColumnName: "comment one"}, {ColumnIndex: 8, ColumnName: "comment two"}}, + CustomFields: "all_unmapped_columns", + }, + destination: &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "existing_todolist", TodolistID: "456", TodolistName: "Security Tasks"}, + wantCounts: PlanCounts{Todos: 3}, + wantCandidates: map[string]int{"record_id": 0, "title": 1, "description": 2, "due_on": 3, "assignees": 4, "attachment_urls": 5, "comments": 7}, + assertRows: func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) { + t.Helper() + if len(rows[0].AttachmentURLs) != 2 || len(rows[0].Comments) != 2 { + t.Fatalf("urls/comments row1 = %+v / %+v", rows[0].AttachmentURLs, rows[0].Comments) + } + if rows[1].DueOn != "" || rows[2].DueOn != "2026-08-03" { + t.Fatalf("blank/rfc3339 due dates = %q, %q", rows[1].DueOn, rows[2].DueOn) + } + if rows[2].CustomFields["severity"] != "critical" || plan.Counts.Todolists != 0 { + t.Fatalf("custom/counts = %+v / %+v", rows[2].CustomFields, plan.Counts) + } + }, + }, + { + name: "new project fallback groups", + fixture: "new-project-fallback-groups.csv", + wantDelimiter: ",", + mapping: &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "ref"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "title"}, + Description: &ColumnRef{ColumnIndex: 2, ColumnName: "notes"}, + Todolist: &ColumnRef{ColumnIndex: 3, ColumnName: "list"}, + DueOn: &ColumnRef{ColumnIndex: 4, ColumnName: "due"}, + Assignees: &ColumnRef{ColumnIndex: 5, ColumnName: "email"}, + CustomFields: "all_unmapped_columns", + }, + destination: &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "new_project", ProjectName: "Imported Adversarial Project", TodolistStrategy: "create_from_column"}, + wantCounts: PlanCounts{Projects: 1, Todolists: 3, Todos: 3}, + wantCandidates: map[string]int{"record_id": 0, "title": 1, "description": 2, "todolist": 3, "due_on": 4, "assignees": 5}, + assertRows: func(t *testing.T, manifest *ImportArtifactManifest, rows []artifactTodoRow, plan *Plan) { + t.Helper() + if plan.Operations[0].Op != "create_project" || plan.Operations[0].ProjectName != "Imported Adversarial Project" { + t.Fatalf("first operation = %+v", plan.Operations[0]) + } + if rows[1].TodolistName != "Imported todos" || rows[1].CustomFields["custom_b"] != "two" { + t.Fatalf("fallback/custom row2 = %+v", rows[1]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inspection, err := InspectCSV(adversarialFixturePath(tt.fixture), InspectOptions{SampleSize: 3}) + if err != nil { + t.Fatalf("InspectCSV() error = %v", err) + } + if inspection.Dialect.Delimiter != tt.wantDelimiter { + t.Fatalf("delimiter = %q, want %q", inspection.Dialect.Delimiter, tt.wantDelimiter) + } + for role, columnIndex := range tt.wantCandidates { + assertRoleCandidate(t, inspection, role, columnIndex) + } + + outDir := filepath.Join(t.TempDir(), "artifact") + compiled, err := CompileArtifact(inspection, tt.mapping, tt.destination, outDir) + if err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + assertPlanCounts(t, compiled.Manifest.Counts, tt.wantCounts) + + plan, err := PlanFromArtifact(outDir) + if err != nil { + t.Fatalf("PlanFromArtifact() error = %v", err) + } + if plan.RequiresUserInput || plan.Status != "ready_for_approval" { + t.Fatalf("plan = %+v", plan) + } + assertPlanCounts(t, plan.Counts, tt.wantCounts) + if !strings.Contains(plan.DryRunMarkdown, "Import dry run") { + t.Fatalf("dry run markdown missing header: %s", plan.DryRunMarkdown) + } + + manifest, rows, err := readArtifact(outDir) + if err != nil { + t.Fatalf("readArtifact() error = %v", err) + } + wantRows := tt.wantCounts.Todos + if tt.wantCounts.Cards > 0 { + wantRows = tt.wantCounts.Cards + } + if len(rows) != wantRows { + t.Fatalf("artifact rows = %d, want %d", len(rows), wantRows) + } + tt.assertRows(t, manifest, rows, plan) + }) + } +} + +func TestLargeSyntheticCSVArtifactRoundTripWithoutBasecamp(t *testing.T) { + var buf bytes.Buffer + writer := csv.NewWriter(&buf) + header := []string{"id", "title", "list", "due", "owner", "notes", "flag"} + for j := 0; j < 40; j++ { + header = append(header, fmt.Sprintf("custom_%02d", j)) + } + if err := writer.Write(header); err != nil { + t.Fatalf("write header: %v", err) + } + for i := 1; i <= 250; i++ { + row := []string{ + fmt.Sprintf("L-%03d", i), + fmt.Sprintf("Import row %03d", i), + fmt.Sprintf("Group %02d", (i-1)%17), + fmt.Sprintf("2026-10-%02d", (i-1)%28+1), + fmt.Sprintf("person%03d@example.com", i), + fmt.Sprintf("Deterministic note with comma, quote \"%03d\", and enough text to exercise artifact CSV escaping.", i), + fmt.Sprintf("flag-%02d", i%5), + } + for j := 0; j < 40; j++ { + row = append(row, fmt.Sprintf("row-%03d-custom-%02d", i, j)) + } + if err := writer.Write(row); err != nil { + t.Fatalf("write row %d: %v", i, err) + } + } + writer.Flush() + if err := writer.Error(); err != nil { + t.Fatalf("flush CSV: %v", err) + } + + inspection := inspectTempCSV(t, buf.String()) + mapping := &MappingConfig{ + SchemaVersion: planSchemaVersion, + RecordID: &ColumnRef{ColumnIndex: 0, ColumnName: "id"}, + Title: &ColumnRef{ColumnIndex: 1, ColumnName: "title"}, + Todolist: &ColumnRef{ColumnIndex: 2, ColumnName: "list"}, + DueOn: &ColumnRef{ColumnIndex: 3, ColumnName: "due"}, + Assignees: &ColumnRef{ColumnIndex: 4, ColumnName: "owner"}, + Description: &ColumnRef{ColumnIndex: 5, ColumnName: "notes"}, + CustomFields: "all_unmapped_columns", + } + destination := &DestinationConfig{SchemaVersion: planSchemaVersion, Mode: "existing_project", ProjectID: "123", TodolistStrategy: "create_from_column"} + outDir := filepath.Join(t.TempDir(), "large-artifact") + + compiled, err := CompileArtifact(inspection, mapping, destination, outDir) + if err != nil { + t.Fatalf("CompileArtifact() error = %v", err) + } + assertPlanCounts(t, compiled.Manifest.Counts, PlanCounts{Todolists: 17, Todos: 250}) + + plan, err := PlanFromArtifact(outDir) + if err != nil { + t.Fatalf("PlanFromArtifact() error = %v", err) + } + assertPlanCounts(t, plan.Counts, PlanCounts{Todolists: 17, Todos: 250}) + if len(plan.Operations) != 267 { + t.Fatalf("operation count = %d, want 267", len(plan.Operations)) + } + if plan.Operations[17].Title != "Import row 001" || plan.Operations[266].Title != "Import row 250" { + t.Fatalf("first/last todo operations = %+v / %+v", plan.Operations[17], plan.Operations[266]) + } + _, rows, err := readArtifact(outDir) + if err != nil { + t.Fatalf("readArtifact() error = %v", err) + } + if len(rows) != 250 { + t.Fatalf("artifact rows = %d, want 250", len(rows)) + } + if len(rows[0].CustomFields) != 41 || rows[0].CustomFields["custom_39"] != "row-001-custom-39" { + t.Fatalf("wide custom fields = %+v", rows[0].CustomFields) + } +} + +func adversarialFixturePath(name string) string { + return filepath.Join("../../testdata/import/csv/synthetic/adversarial", name) +} + +func assertRoleCandidate(t *testing.T, inspection *Inspection, role string, wantColumn int) { + t.Helper() + for _, candidate := range inspection.RoleCandidates[role] { + if candidate.ColumnIndex == wantColumn { + return + } + } + t.Fatalf("role %s does not include column %d: %+v", role, wantColumn, inspection.RoleCandidates[role]) +} + +func assertPlanCounts(t *testing.T, got, want PlanCounts) { + t.Helper() + if got.Projects != want.Projects || got.Todolists != want.Todolists || got.Todos != want.Todos || got.CardColumns != want.CardColumns || got.Cards != want.Cards { + t.Fatalf("counts = %+v, want %+v", got, want) + } +} diff --git a/internal/importer/csvprofiler.go b/internal/importer/csvprofiler.go index ae139803..86d8ca31 100644 --- a/internal/importer/csvprofiler.go +++ b/internal/importer/csvprofiler.go @@ -459,7 +459,7 @@ func scoreRole(role string, st *columnStats, rowCount int) (float64, []string) { add(0.15, "values are title-length text") } case "description": - if nameMatches(name, "description", "notes", "content") { + if nameMatches(name, "description", "notes", "content", "body", "detail") { add(0.65, "header indicates description") } if nonEmpty > 0 && (averageLength(st.nonEmpty) > 80 || st.multilineCount > 0) { diff --git a/testdata/import/csv/README.md b/testdata/import/csv/README.md index a046bee8..325d2181 100644 --- a/testdata/import/csv/README.md +++ b/testdata/import/csv/README.md @@ -6,6 +6,7 @@ CSV fixtures for the generic `basecamp import inspect` profiler and later import - `canonical/` — redacted, real-shape CSV exports collected from public examples. These are broad profiler regression fixtures and are not source-specific parser contracts. - `synthetic/` — deterministic small CSVs used for planning, safety, and LLM eval scenarios. +- `synthetic/adversarial/` — deterministic stress fixtures for inspect → compile → plan artifact round-trips across wide rows, ragged rows, alternate delimiters, duplicate headers, multiline text, cards, todos, and fallback groups. ## Canonical fixture counts @@ -15,6 +16,11 @@ CSV fixtures for the generic `basecamp import inspect` profiler and later import - `canonical/linear/` — 4 CSVs - `canonical/trello/` — 1 CSV +## Synthetic fixture counts + +- `synthetic/adversarial/` — 5 CSVs +- `synthetic/random/` — 30 CSVs + ## Privacy and provenance Local copies are redacted. Emails have been mapped to `@example.com`, obvious person/account identifiers have been replaced, and source URLs/source-derived filenames have been removed. The fixtures preserve CSV shape: headers, duplicate headers, row/column structure, quoting, multiline fields, and representative values. diff --git a/testdata/import/csv/synthetic/adversarial/new-project-fallback-groups.csv b/testdata/import/csv/synthetic/adversarial/new-project-fallback-groups.csv new file mode 100644 index 00000000..3e0cd0e4 --- /dev/null +++ b/testdata/import/csv/synthetic/adversarial/new-project-fallback-groups.csv @@ -0,0 +1,4 @@ +ref,title,notes,list,due,email,custom_a,custom_b +N-001,Create kickoff agenda,"Invite stakeholders and prepare decisions",Planning,2026-09-01,pm@example.com,alpha,one +N-002,Publish migration FAQ,"Answer support questions",,2026-09-02,support@example.com,beta,two +N-003,Run pilot import,"Use a small reviewed batch first",Pilot,2026-09-03,ops@example.com,gamma,three diff --git a/testdata/import/csv/synthetic/adversarial/pipe-existing-todolist-many-urls-comments.csv b/testdata/import/csv/synthetic/adversarial/pipe-existing-todolist-many-urls-comments.csv new file mode 100644 index 00000000..8a870517 --- /dev/null +++ b/testdata/import/csv/synthetic/adversarial/pipe-existing-todolist-many-urls-comments.csv @@ -0,0 +1,4 @@ +external_id|todo title|body|due|owner|file url|source url|comment one|comment two|severity +P-001|Confirm security review|Check dependency report before release|2026-08-01|security@example.com|https://example.com/files/report|https://example.com/source/P-001|Initial review complete|Needs second approval|high +P-002|Update rollout checklist|Add rollback owner and monitoring link||release@example.com|https://example.com/files/checklist||Rollback owner assigned||medium +P-003|Validate backups|Run restore drill and record result|2026-08-03T09:00:00Z|infra@example.com||https://example.com/source/P-003|Restore drill scheduled|Include screenshots|critical diff --git a/testdata/import/csv/synthetic/adversarial/semicolon-ragged-generated-columns.csv b/testdata/import/csv/synthetic/adversarial/semicolon-ragged-generated-columns.csv new file mode 100644 index 00000000..8e443a9e --- /dev/null +++ b/testdata/import/csv/synthetic/adversarial/semicolon-ragged-generated-columns.csv @@ -0,0 +1,5 @@ +key;summary;bucket;due;owner;detail +R-001;Import legacy contract;Legal;18/06/2026;legal@example.com;Has an extra source field;orphan custom value +R-002;Review renewal language;Legal;19/06/2026;;Missing owner but valid title +R-003;Schedule vendor call;;20/06/2026;ops@example.com;Blank bucket uses fallback;generated one;generated two +R-004;Close procurement loop;Procurement;21/06/2026;procurement@example.com; diff --git a/testdata/import/csv/synthetic/adversarial/tab-cards-duplicate-columns.csv b/testdata/import/csv/synthetic/adversarial/tab-cards-duplicate-columns.csv new file mode 100644 index 00000000..1bebb99c --- /dev/null +++ b/testdata/import/csv/synthetic/adversarial/tab-cards-duplicate-columns.csv @@ -0,0 +1,4 @@ +card_id title description column status owner due link comment rank rank +C-001 Triage billing bug Customer sees duplicate charge Backlog new billing@example.com 2026-07-01 https://example.com/cards/billing Escalated by support P1 customer +C-002 Draft launch post Prepare announcement copy Doing active marketing@example.com 2026-07-02 https://example.com/cards/launch Needs legal review P2 launch +C-003 Archive stale board Move completed work to archive Backlog ready ops@example.com 2026-07-03 https://example.com/cards/archive Keep audit notes P3 cleanup diff --git a/testdata/import/csv/synthetic/adversarial/wide-duplicate-multiline.csv b/testdata/import/csv/synthetic/adversarial/wide-duplicate-multiline.csv new file mode 100644 index 00000000..52a18889 --- /dev/null +++ b/testdata/import/csv/synthetic/adversarial/wide-duplicate-multiline.csv @@ -0,0 +1,6 @@ +ID,Title,Description,List,Status,Assignee Emails,Due Date,Attachment URL,Comment,Label,Label,Priority,Estimate Hours,Empty Mostly,Parent ID +W-001,"Paint lobby, phase 2","Use low-VOC paint. +Protect the floor and note the ""wet paint"" signs.",Facilities,todo,"alex@example.com; jamie@example.com",2026-06-01,https://example.com/files/paint-spec,"First comment line +second comment line",renovation,Q2,High,3.5,, +W-002,"Repair café door","Hinge squeaks; includes unicode café and emoji ✅",Facilities,doing,sam@example.com,2026-06-02T15:30:00Z,https://example.com/files/door,"Vendor said ""parts arrive Friday""",maintenance,Q2,Medium,1.25,,W-001 +W-003,"Book all-hands venue","Call two places; ask about A/V, catering, and accessibility",Events,todo,,"June 4, 2026",https://example.com/files/venue,,planning,Q2,Low,2,, From 711986d7b963d7ea117f8ac2e21d4878d0af145d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 8 Jun 2026 16:02:59 -0400 Subject: [PATCH 10/10] Address import review safety comments --- demos/import/board-export.csv | 30 +++++++++---------- scripts/demo-import.sh | 25 ++++++++++++++-- .../import/csv/canonical/jira/sample-04.csv | 14 ++++----- .../import/csv/canonical/trello/sample-01.csv | 30 +++++++++---------- 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/demos/import/board-export.csv b/demos/import/board-export.csv index 91fdfa48..9bb7e205 100644 --- a/demos/import/board-export.csv +++ b/demos/import/board-export.csv @@ -1,5 +1,5 @@ Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment Count,Attachment Links,Checklist Item Total Count,Checklist Item Completed Count,Vote Count,Comment Count,Last Activity Date,List ID,List Name,Board ID,Board Name,Archived,Start Date,Due Complete,Due Reminder -62b4725bdec63838046a4ec8,How to use this board,https://trello.com/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template +62b4725bdec63838046a4ec8,How to use this board,https://example.com/trello/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template **Before** @@ -22,17 +22,17 @@ Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment C * Move topics that are closed to ""Done"" **For more 1-on-1 meeting tips...** -I put my top 7 tips on the Atlassian Blog: https://www.atlassian.com/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://trello.com/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://trello.com/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://trello.com/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://trello.com/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://trello.com/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec2,Goal (#1),https://trello.com/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://trello.com/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://trello.com/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://trello.com/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed4,New training program,https://trello.com/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://trello.com/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ece,Manage time chaos,https://trello.com/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed2,Mentor another developer,https://trello.com/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed0,Best practice blog,https://trello.com/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +I put my top 7 tips on the Atlassian Blog: https://example.com/atlassian/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://example.com/trello/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://example.com/trello/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://example.com/trello/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://example.com/trello/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec2,Goal (#1),https://example.com/trello/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://example.com/trello/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://example.com/trello/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://example.com/trello/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed4,New training program,https://example.com/trello/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://example.com/trello/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ece,Manage time chaos,https://example.com/trello/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed2,Mentor another developer,https://example.com/trello/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed0,Best practice blog,https://example.com/trello/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, diff --git a/scripts/demo-import.sh b/scripts/demo-import.sh index 3f8759f4..ad84295c 100755 --- a/scripts/demo-import.sh +++ b/scripts/demo-import.sh @@ -34,6 +34,26 @@ Recovery review flow with --repair-artifact: USAGE } +remove_demo_output_dir() { + local path="$1" + local flag="$2" + local trimmed="${path%/}" + + if [[ -z "$trimmed" || "$trimmed" == "/" || "$trimmed" == "." || "$trimmed" == ".." ]]; then + echo "$flag must point to a dedicated output directory, not '$path'" >&2 + exit 2 + fi + + local resolved + resolved="$(realpath -m -- "$path")" + if [[ "$resolved" == "/" || "$resolved" == "$PWD" || "$resolved" == "$HOME" ]]; then + echo "$flag must point to a dedicated output directory, not '$path'" >&2 + exit 2 + fi + + rm -rf -- "$path" +} + while [[ $# -gt 0 ]]; do case "$1" in --csv) @@ -92,7 +112,7 @@ if [[ -n "$repair_artifact" ]]; then exit 2 fi - rm -rf "$followup_out" + remove_demo_output_dir "$followup_out" "--followup-out" echo "" echo "== Creating follow-up artifact ==" basecamp import followup --artifact "$repair_artifact" --out "$followup_out" --reviewed --json | @@ -128,8 +148,7 @@ trap 'rm -rf "$workdir"' EXIT inspection="$workdir/inspection.json" plan="$workdir/plan.json" -rm -rf "$out" - +remove_demo_output_dir "$out" "--out" echo "== Inspecting CSV ==" basecamp import inspect "$csv" --json > "$inspection" jq -r '"Rows: \(.data.row_count), Columns: \(.data.columns | length), Status: \(.data.status)"' "$inspection" diff --git a/testdata/import/csv/canonical/jira/sample-04.csv b/testdata/import/csv/canonical/jira/sample-04.csv index 3584b4fe..96fde9be 100644 --- a/testdata/import/csv/canonical/jira/sample-04.csv +++ b/testdata/import/csv/canonical/jira/sample-04.csv @@ -1,9 +1,9 @@ -Summary,Issue key,Issue id,Issue Type,Status,Project key,Project name,Project type,Project lead,Project lead id,Project description,Priority,Resolution,Assignee,Assignee Id,Reporter,Reporter Id,Creator,Creator Id,Created,Updated,Last Viewed,Resolved,Due date,Votes,Description,Environment,Watchers,Watchers Id,Original estimate,Remaining Estimate,Time Spent,Work Ratio,Σ Original Estimate,Σ Remaining Estimate,Σ Time Spent,Security Level,Inward issue link (Defect),Inward issue link (Defect),Outward issue link (Test),Attachment,Attachment,Attachment,Attachment,Attachment,Custom field (Actual end),Custom field (Actual start),Custom field (Begin Date),Custom field (Change reason),Custom field (Change risk),Custom field (Change type),Custom field (Development),Custom field (End Date),Custom field (Epic Link),Epic Link Summary,Custom field (Impact),Custom field (Issue color),Custom field (Locked forms),Custom field (Open forms),Custom field (Rank),Custom field (Request Type),Custom field (Revision),Sprint,Custom field (Start date),Custom field (Story point estimate),Custom field (Submitted forms),Custom field (Target end),Custom field (Target start),Custom field (Team),Custom field (Total forms),Custom field ([CHART] Date of First Response),Parent,Parent summary,Status Category,Status Category Changed +Summary,Issue key,Issue id,Issue Type,Status,Project key,Project name,Project type,Project lead,Project lead id,Project description,Priority,Resolution,Assignee,Assignee Id,Reporter,Reporter Id,Creator,Creator Id,Created,Updated,Last Viewed,Resolved,Due date,Votes,Description,Environment,Watchers,Watchers Id,Original estimate,Remaining Estimate,Time Spent,Work Ratio,Σ Original Estimate,Σ Remaining Estimate,Σ Time Spent,Security Level,Inward issue link (Defect),Inward issue link (Defect),Outward issue link (Test),Attachment,Attachment,Attachment,Attachment,Attachment,Custom field (Actual end),Custom field (Actual start),Custom field (Begin Date),Custom field (Change reason),Custom field (Change risk),Custom field (Change type),Custom field (Development),Custom field (End Date),Custom field (Epic Link),Epic Link Summary,Custom field (Impact),Custom field (Issue color),Custom field (Locked forms),Custom field (Open forms),Custom field (Rank),Custom field (Request Type),Custom field (Revision),Sprint,Custom field (Start date),Custom field (Story point estimate),Custom field (Submitted forms),Custom field (Target end),Custom field (Target start),Custom field (Team),Custom field (Total forms),Custom field ([CHART] Date of First Response),Parent,Parent summary,Status Category,Status Category Changed QR code placed in the incorrect location on the site,T2-22,10058,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,27/Feb/24 10:01 PM,27/Feb/24 10:10 PM,29/Feb/24 2:02 PM,,,0,"The QR code is placed in the incorrect location on the registration subpage. It overlaps the video positioned below. h2. Steps for reproduction: -# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Visit [https://example.com/bank/|https://example.com/bank/|smart-link] # Click on *Menu* on the top left corner # Click on *Register* @@ -17,12 +17,12 @@ The QR code should be placed above the video and beside the ‘Download the app h2. Screenshot: -!Screenshot 2024-02-27 at 14.11.10.png|width=454,height=615!","iPad mini, iPadOS, Chrome Version 122.0.6261.69 (Official Build) (x86_64)",Person 21,account-id-3,,,,,,,,,,,T2-10,27/Feb/24 10:01 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 14.11.10.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10013,,,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000fb:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 10:01 PM +!Screenshot 2024-02-27 at 14.11.10.png|width=454,height=615!","iPad mini, iPadOS, Chrome Version 122.0.6261.69 (Official Build) (x86_64)",Person 21,account-id-3,,,,,,,,,,,T2-10,27/Feb/24 10:01 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 14.11.10.png;https://example.com/jira/attachment/content/10013,,,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000fb:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 10:01 PM On-Screen reader does not read aloud the Menu ,T2-21,10057,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,27/Feb/24 1:56 PM,27/Feb/24 10:07 PM,27/Feb/24 10:09 PM,,,0,"When hovering over the menu on the upper-left corner of the Accessibility subpage, the on-screen reader (VoiceOver) does not recognise the options and does not read aloud their titles. h2. Steps for reproduction: -# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Visit [https://example.com/bank/|https://example.com/bank/|smart-link] # Scroll down the main page and click on *Accessibility* (located in the footer) # Using Tab key choose any option from the upper-left corner *Personal |  Business | Corporate* @@ -52,12 +52,12 @@ Chrome *>* DevTools *>* Lighthouse report -h2. ",Opera 107 on macOS Sonoma 14.3.1 (23D60),Person 21,account-id-3,,,,,,,,,,,,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.30.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10009,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.53.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10010,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.09.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10012,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.27.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10011,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 13.46.15.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10008,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000f3:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 1:56 PM +h2. ",Opera 107 on macOS Sonoma 14.3.1 (23D60),Person 21,account-id-3,,,,,,,,,,,,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.30.png;https://example.com/jira/attachment/content/10009,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.36.53.png;https://example.com/jira/attachment/content/10010,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.09.png;https://example.com/jira/attachment/content/10012,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-26 at 14.37.27.png;https://example.com/jira/attachment/content/10011,27/Feb/24 1:56 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-27 at 13.46.15.png;https://example.com/jira/attachment/content/10008,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000f3:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,27/Feb/24 1:56 PM The heading on Accessibility subpage does not work,T2-18,10054,Bug,To Do,T2,Test 2.0,software,Person 21,account-id-3,,Medium,,Person 21,account-id-3,Person 21,account-id-3,Person 21,account-id-3,25/Feb/24 10:31 PM,28/Feb/24 1:10 PM,28/Feb/24 1:08 PM,,,0,"There is no response when clicking on the heading *‘Deaf, hearing or speech impairment’.* The user is not redirected to the dedicated subpage. h2. Steps for reproduction: -# Visit [https://www.hsbc.co.uk/|https://www.hsbc.co.uk/|smart-link] +# Visit [https://example.com/bank/|https://example.com/bank/|smart-link] # Scroll down the main page and click on *Accessibility* (located in the footer) # Hover over *Deaf, hearing or speech impairment* and click the heading @@ -79,4 +79,4 @@ h2. Console log: -h2. ",MacOS Ventura Version 13.6.3 (22G436) Safari Version 16.6 (18615.3.12.11.2),Person 21,account-id-3,,,,,,,,,T2-10,T2-17,T2-10,28/Feb/24 1:10 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18 (a037e9fd-3f73-4ab0-b1be-189de8f7156a).jpg;https://paulahum.atlassian.net/rest/api/3/attachment/content/10014,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18.jpg;https://paulahum.atlassian.net/rest/api/3/attachment/content/10007,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-25 at 22.38.30.png;https://paulahum.atlassian.net/rest/api/3/attachment/content/10006,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000ef:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,25/Feb/24 10:31 PM +h2. ",MacOS Ventura Version 13.6.3 (22G436) Safari Version 16.6 (18615.3.12.11.2),Person 21,account-id-3,,,,,,,,,T2-10,T2-17,T2-10,28/Feb/24 1:10 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18 (a037e9fd-3f73-4ab0-b1be-189de8f7156a).jpg;https://example.com/jira/attachment/content/10014,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;AccessibiityBug_T2-18.jpg;https://example.com/jira/attachment/content/10007,25/Feb/24 10:41 PM;557058:44b53122-faab-4556-9c81-aaf60855f89a;Screenshot 2024-02-25 at 22.38.30.png;https://example.com/jira/attachment/content/10006,,,,,,,,,,,T2-16,Comprehensive Testing and Security Enhancements,,,,,0|i000ef:,,,T2 Sprint 1,,,,,,,,,10052,Comprehensive Testing and Security Enhancements,To Do,25/Feb/24 10:31 PM diff --git a/testdata/import/csv/canonical/trello/sample-01.csv b/testdata/import/csv/canonical/trello/sample-01.csv index 91fdfa48..9bb7e205 100644 --- a/testdata/import/csv/canonical/trello/sample-01.csv +++ b/testdata/import/csv/canonical/trello/sample-01.csv @@ -1,5 +1,5 @@ Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment Count,Attachment Links,Checklist Item Total Count,Checklist Item Completed Count,Vote Count,Comment Count,Last Activity Date,List ID,List Name,Board ID,Board Name,Archived,Start Date,Due Complete,Due Reminder -62b4725bdec63838046a4ec8,How to use this board,https://trello.com/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template +62b4725bdec63838046a4ec8,How to use this board,https://example.com/trello/c/iXS2LcLg/4-how-to-use-this-board,"###Suggested use of this template **Before** @@ -22,17 +22,17 @@ Card ID,Card Name,Card URL,Card Description,Labels,Members,Due Date,Attachment C * Move topics that are closed to ""Done"" **For more 1-on-1 meeting tips...** -I put my top 7 tips on the Atlassian Blog: https://www.atlassian.com/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://trello.com/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://trello.com/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://trello.com/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://trello.com/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://trello.com/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ec2,Goal (#1),https://trello.com/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://trello.com/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://trello.com/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://trello.com/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://trello.com/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed4,New training program,https://trello.com/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://trello.com/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ece,Manage time chaos,https://trello.com/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed2,Mentor another developer,https://trello.com/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, -62b4725bdec63838046a4ed0,Best practice blog,https://trello.com/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +I put my top 7 tips on the Atlassian Blog: https://example.com/atlassian/blog/inside-atlassian/1-on-1-meeting-tips",,,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec8/attachments/62b4725cdec63838046a4ff9/download/ScratchPaper.jpg,0,0,0,0,2022-06-23T14:02:04.438Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec4,Blocker - Timely discussion (#4),https://example.com/trello/c/QINd6NVA/2-blocker-timely-discussion-4,This is blocking me/us.,Blocker (red),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec4/attachments/62b4725cdec63838046a4fea/download/image.png,0,0,0,0,2022-06-23T14:02:43.446Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec6,Discuss - Suggested topic (#3),https://example.com/trello/c/wpEFQ6Z4/3-discuss-suggested-topic-3,Would like to discuss this.,Discuss (orange),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec6/attachments/62b4725cdec63838046a4fef/download/image.png,0,0,0,0,2022-06-23T14:02:04.406Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eca,FYI - Discuss if interested (#6),https://example.com/trello/c/ZsKNrfR6/5-fyi-discuss-if-interested-6,,FYI (blue),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4eca/attachments/62b4725cdec63838046a4ffb/download/DotBlue.png,0,0,0,0,2022-06-23T14:02:04.464Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ecc,Paused - No need to discuss (#0),https://example.com/trello/c/RftsJaii/6-paused-no-need-to-discuss-0,,Paused (black),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ecc/attachments/62b4725cdec63838046a5000/download/DotTrelloBlack.png,0,0,0,0,2022-06-23T14:02:04.348Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ec2,Goal (#1),https://example.com/trello/c/mMl9Mcq8/1-goal-1,,Goal (green),,,1,https://example.com/trello/1/cards/62b4725bdec63838046a4ec2/attachments/62b4725cdec63838046a4fe5/download/image.png,0,0,0,0,2022-06-23T14:02:04.409Z,62b4725bdec63838046a4e8d,Info,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed6,"The team is stuck on X, how can we move forward?",https://example.com/trello/c/eZJpWxJf/11-the-team-is-stuck-on-x-how-can-we-move-forward,,Blocker (red),,,0,,0,0,0,0,2022-06-23T14:02:03.883Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed8,I've drafted my goals for the next few months. Any feedback?,https://example.com/trello/c/iQe6pQWb/12-ive-drafted-my-goals-for-the-next-few-months-any-feedback,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.865Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4eda,I think we can improve velocity if we make some tooling changes.,https://example.com/trello/c/ENwQETrP/13-i-think-we-can-improve-velocity-if-we-make-some-tooling-changes,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.848Z,62b4725bdec63838046a4e8f,Team Member's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed4,New training program,https://example.com/trello/c/QTCPuLj0/10-new-training-program,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.902Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4edc,Can you please give feedback on the report?,https://example.com/trello/c/oVPkvlmp/14-can-you-please-give-feedback-on-the-report,,Discuss (orange),,,0,,0,0,0,0,2022-06-23T14:02:03.831Z,62b4725bdec63838046a4e90,Manager's Topics,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ece,Manage time chaos,https://example.com/trello/c/7ERRdOav/7-manage-time-chaos,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.956Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed2,Mentor another developer,https://example.com/trello/c/rTOLfoDi/9-mentor-another-developer,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.919Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false, +62b4725bdec63838046a4ed0,Best practice blog,https://example.com/trello/c/cxvhJUm3/8-best-practice-blog,,Goal (green),,,0,,0,0,0,0,2022-06-23T14:02:03.935Z,62b4725bdec63838046a4e91,Goals,62b4725bdec63838046a4e8e,1-on-1 Meeting Agenda,false,,false,