From 2e0132de6d9802c4c9e245265056281c5f0e65a5 Mon Sep 17 00:00:00 2001 From: Bryan Date: Thu, 15 Oct 2026 22:42:43 +0800 Subject: [PATCH 1/3] feat: back up GitHub issues in independent metadata archives --- README.md | 2 + buf.gen.yaml | 6 +- .../20261015140526_wakeful_lake/migration.sql | 32 + .../20261015140526_wakeful_lake/snapshot.json | 1550 +++++++++++++++++ db/queries/issues.sql | 46 + db/schema.sql | 32 + db/sqlc/issues.sql.go | 249 +++ db/sqlc/models.go | 27 + db/src/schema.ts | 43 + docs/issues-backup.md | 101 ++ .../components/issues/IssueDetail.test.tsx | 89 + .../src/components/issues/IssueDetail.tsx | 323 ++++ .../issues/IssuesTab.browser.test.tsx | 300 ++++ frontend/src/components/issues/IssuesTab.tsx | 490 ++++++ .../src/components/sources/SourceCard.tsx | 8 + .../sources/SourceFormDrawer.browser.test.tsx | 127 ++ .../components/sources/SourceFormDrawer.tsx | 16 + frontend/src/lib/connect/client.ts | 2 + frontend/src/lib/issue-display.test.ts | 38 + frontend/src/lib/issue-display.ts | 49 + frontend/src/lib/issue-download.test.ts | 62 + frontend/src/lib/issue-download.ts | 28 + frontend/src/lib/issue-queries.ts | 60 + .../src/routes/_dashboard/config/sources.tsx | 2 + .../src/routes/_dashboard/repos.$repoId.tsx | 11 + .../src/rpc/gitplus/config/v1/config_pb.ts | 17 +- frontend/src/rpc/gitplus/issue/v1/issue_pb.ts | 452 +++++ go.mod | 4 +- pkg/app/issues.go | 124 ++ pkg/app/issues_test.go | 54 + pkg/app/root.go | 1 + pkg/config/README.md | 28 +- pkg/config/config.go | 1 + pkg/config/validate.go | 1 + pkg/configservice/issues_test.go | 47 + pkg/configservice/service.go | 7 + pkg/githubapi/client.go | 284 +++ pkg/githubapi/client_test.go | 345 ++++ pkg/issuearchive/README.md | 101 ++ pkg/issuearchive/lock.go | 44 + pkg/issuearchive/lock_other.go | 13 + pkg/issuearchive/lock_unix.go | 24 + pkg/issuearchive/lock_windows.go | 26 + pkg/issuearchive/markdown.go | 116 ++ pkg/issuearchive/objects.go | 432 +++++ pkg/issuearchive/snapshot.go | 292 ++++ pkg/issuearchive/store.go | 368 ++++ pkg/issuearchive/store_test.go | 806 +++++++++ pkg/issuearchive/sync_object_unix.go | 16 + pkg/issuearchive/sync_object_windows.go | 28 + pkg/issuearchive/types.go | 117 ++ pkg/issueservice/service.go | 309 ++++ pkg/issueservice/service_test.go | 177 ++ pkg/issuesync/index.go | 110 ++ pkg/issuesync/sync.go | 410 +++++ pkg/issuesync/sync_test.go | 512 ++++++ pkg/issuetask/task.go | 129 ++ pkg/issuetask/task_test.go | 300 ++++ pkg/rpc/gitplus/config/v1/config.pb.go | 38 +- pkg/rpc/gitplus/issue/v1/issue.pb.go | 1020 +++++++++++ .../issue/v1/issuev1connect/issue.connect.go | 222 +++ pkg/server/issues_test.go | 33 + pkg/server/server.go | 6 + pkg/taskservice/issues_test.go | 97 ++ pkg/taskservice/service.go | 17 +- proto/gitplus/config/v1/config.proto | 23 +- proto/gitplus/issue/v1/issue.proto | 98 ++ 67 files changed, 10903 insertions(+), 39 deletions(-) create mode 100644 db/migrations/20261015140526_wakeful_lake/migration.sql create mode 100644 db/migrations/20261015140526_wakeful_lake/snapshot.json create mode 100644 db/queries/issues.sql create mode 100644 db/sqlc/issues.sql.go create mode 100644 docs/issues-backup.md create mode 100644 frontend/src/components/issues/IssueDetail.test.tsx create mode 100644 frontend/src/components/issues/IssueDetail.tsx create mode 100644 frontend/src/components/issues/IssuesTab.browser.test.tsx create mode 100644 frontend/src/components/issues/IssuesTab.tsx create mode 100644 frontend/src/components/sources/SourceFormDrawer.browser.test.tsx create mode 100644 frontend/src/lib/issue-display.test.ts create mode 100644 frontend/src/lib/issue-display.ts create mode 100644 frontend/src/lib/issue-download.test.ts create mode 100644 frontend/src/lib/issue-download.ts create mode 100644 frontend/src/lib/issue-queries.ts create mode 100644 frontend/src/rpc/gitplus/issue/v1/issue_pb.ts create mode 100644 pkg/app/issues.go create mode 100644 pkg/app/issues_test.go create mode 100644 pkg/configservice/issues_test.go create mode 100644 pkg/githubapi/client.go create mode 100644 pkg/githubapi/client_test.go create mode 100644 pkg/issuearchive/README.md create mode 100644 pkg/issuearchive/lock.go create mode 100644 pkg/issuearchive/lock_other.go create mode 100644 pkg/issuearchive/lock_unix.go create mode 100644 pkg/issuearchive/lock_windows.go create mode 100644 pkg/issuearchive/markdown.go create mode 100644 pkg/issuearchive/objects.go create mode 100644 pkg/issuearchive/snapshot.go create mode 100644 pkg/issuearchive/store.go create mode 100644 pkg/issuearchive/store_test.go create mode 100644 pkg/issuearchive/sync_object_unix.go create mode 100644 pkg/issuearchive/sync_object_windows.go create mode 100644 pkg/issuearchive/types.go create mode 100644 pkg/issueservice/service.go create mode 100644 pkg/issueservice/service_test.go create mode 100644 pkg/issuesync/index.go create mode 100644 pkg/issuesync/sync.go create mode 100644 pkg/issuesync/sync_test.go create mode 100644 pkg/issuetask/task.go create mode 100644 pkg/issuetask/task_test.go create mode 100644 pkg/rpc/gitplus/issue/v1/issue.pb.go create mode 100644 pkg/rpc/gitplus/issue/v1/issuev1connect/issue.connect.go create mode 100644 pkg/server/issues_test.go create mode 100644 pkg/taskservice/issues_test.go create mode 100644 proto/gitplus/issue/v1/issue.proto diff --git a/README.md b/README.md index 6ccaade..8e5e3a4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ ![Commit history tracking](images/highlight-3.jpg) +4. Optionally back up GitHub Issues alongside code: independent metadata Git history, safe offline browsing, comments/timeline, and verified snapshot export. Enable it per source; attachments remain links only. See [Issues backup guide](docs/issues-backup.md). + ## Quick start 1. Set a secret used to encrypt tokens: diff --git a/buf.gen.yaml b/buf.gen.yaml index 7abd9e5..de2e5a0 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -9,15 +9,15 @@ managed: - file_option: go_package_prefix value: github.com/ImSingee/git-plus/pkg/rpc plugins: - - remote: buf.build/protocolbuffers/go + - remote: buf.build/protocolbuffers/go:v1.36.11 out: pkg/rpc opt: - paths=source_relative - - remote: buf.build/connectrpc/go + - remote: buf.build/connectrpc/go:v1.19.1 out: pkg/rpc opt: - paths=source_relative - - remote: buf.build/bufbuild/es + - remote: buf.build/bufbuild/es:v2.11.0 out: frontend/src/rpc include_imports: true opt: diff --git a/db/migrations/20261015140526_wakeful_lake/migration.sql b/db/migrations/20261015140526_wakeful_lake/migration.sql new file mode 100644 index 0000000..cd7cc7d --- /dev/null +++ b/db/migrations/20261015140526_wakeful_lake/migration.sql @@ -0,0 +1,32 @@ +CREATE TABLE `issue_sync_state` ( + `repo_id` integer PRIMARY KEY, + `status` text DEFAULT 'idle' NOT NULL, + `latest_commit` text DEFAULT '' NOT NULL, + `issues_since` text DEFAULT '' NOT NULL, + `comments_since` text DEFAULT '' NOT NULL, + `last_successful_at` text DEFAULT '' NOT NULL, + `last_full_reconcile_at` text DEFAULT '' NOT NULL, + `last_error` text DEFAULT '' NOT NULL, + `issue_count` integer DEFAULT 0 NOT NULL, + `comment_count` integer DEFAULT 0 NOT NULL, + `updated_at` text NOT NULL, + CONSTRAINT `fk_issue_sync_state_repo_id_repos_id_fk` FOREIGN KEY (`repo_id`) REFERENCES `repos`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `issues_current` ( + `repo_id` integer NOT NULL, + `issue_id` text NOT NULL, + `number` integer NOT NULL, + `title` text NOT NULL, + `state` text NOT NULL, + `author_login` text NOT NULL, + `updated_at` text NOT NULL, + `availability` text NOT NULL, + `labels_json` text DEFAULT '[]' NOT NULL, + `assignees_json` text DEFAULT '[]' NOT NULL, + CONSTRAINT `fk_issues_current_repo_id_repos_id_fk` FOREIGN KEY (`repo_id`) REFERENCES `repos`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `issues_current_repo_issue_unique` ON `issues_current` (`repo_id`,`issue_id`);--> statement-breakpoint +CREATE INDEX `issues_current_repo_number_idx` ON `issues_current` (`repo_id`,`number`);--> statement-breakpoint +CREATE INDEX `issues_current_repo_state_idx` ON `issues_current` (`repo_id`,`state`); \ No newline at end of file diff --git a/db/migrations/20261015140526_wakeful_lake/snapshot.json b/db/migrations/20261015140526_wakeful_lake/snapshot.json new file mode 100644 index 0000000..fe714e7 --- /dev/null +++ b/db/migrations/20261015140526_wakeful_lake/snapshot.json @@ -0,0 +1,1550 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "acf49c45-fa89-43ac-87f1-ce19edd23257", + "prevIds": [ + "88e1a580-c9ae-4641-85b3-451377620604" + ], + "ddl": [ + { + "name": "app_meta", + "entityType": "tables" + }, + { + "name": "issue_sync_state", + "entityType": "tables" + }, + { + "name": "issues_current", + "entityType": "tables" + }, + { + "name": "repo_ref_changes", + "entityType": "tables" + }, + { + "name": "repo_refs_current", + "entityType": "tables" + }, + { + "name": "repos", + "entityType": "tables" + }, + { + "name": "task_run_logs", + "entityType": "tables" + }, + { + "name": "task_runs", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "app_meta" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "app_meta" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repo_id", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'idle'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "latest_commit", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "issues_since", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "comments_since", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "last_successful_at", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "last_full_reconcile_at", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "issue_count", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "comment_count", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "issue_sync_state" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repo_id", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "issue_id", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "number", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "state", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_login", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "availability", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "labels_json", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "assignees_json", + "entityType": "columns", + "table": "issues_current" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repo_id", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "task_run_id", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ref_name", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ref_kind", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "old_hash", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_hash", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_commit_authored_at", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_commit_committed_at", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_commit_author_name", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_commit_author_email", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "new_commit_message", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "archive_ref_name", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repo_ref_changes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repo_id", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ref_name", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ref_kind", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_hash", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "archive_ref_name", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "first_seen_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_hash_updated_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_commit_authored_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_commit_committed_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_commit_author_name", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_commit_author_email", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "current_commit_message", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deleted_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repo_refs_current" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_id", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platform", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ref_id", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'active'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "full_name", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "html_url", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "clone_url", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ssh_url", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "default_branch", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "visibility", + "entityType": "columns", + "table": "repos" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "is_private", + "entityType": "columns", + "table": "repos" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "is_fork", + "entityType": "columns", + "table": "repos" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "is_archived", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "origin", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "meta", + "entityType": "columns", + "table": "repos" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "archive_repo_size_bytes", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "disabled_at", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repos" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repos" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "task_id", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "event_type", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "meta_json", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "task_run_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "task_id", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_task_id", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job_id", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job_type", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "args_json", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "started_at", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finished_at", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_progress_summary", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_progress_meta_json", + "entityType": "columns", + "table": "task_runs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "task_runs" + }, + { + "columns": [ + "repo_id" + ], + "tableTo": "repos", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_issue_sync_state_repo_id_repos_id_fk", + "entityType": "fks", + "table": "issue_sync_state" + }, + { + "columns": [ + "repo_id" + ], + "tableTo": "repos", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_issues_current_repo_id_repos_id_fk", + "entityType": "fks", + "table": "issues_current" + }, + { + "columns": [ + "repo_id" + ], + "tableTo": "repos", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_repo_ref_changes_repo_id_repos_id_fk", + "entityType": "fks", + "table": "repo_ref_changes" + }, + { + "columns": [ + "task_run_id" + ], + "tableTo": "task_runs", + "columnsTo": [ + "task_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_repo_ref_changes_task_run_id_task_runs_task_id_fk", + "entityType": "fks", + "table": "repo_ref_changes" + }, + { + "columns": [ + "repo_id" + ], + "tableTo": "repos", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_repo_refs_current_repo_id_repos_id_fk", + "entityType": "fks", + "table": "repo_refs_current" + }, + { + "columns": [ + "task_id" + ], + "tableTo": "task_runs", + "columnsTo": [ + "task_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_task_run_logs_task_id_task_runs_task_id_fk", + "entityType": "fks", + "table": "task_run_logs" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "app_meta_pk", + "table": "app_meta", + "entityType": "pks" + }, + { + "columns": [ + "repo_id" + ], + "nameExplicit": false, + "name": "issue_sync_state_pk", + "table": "issue_sync_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "repo_ref_changes_pk", + "table": "repo_ref_changes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "repo_refs_current_pk", + "table": "repo_refs_current", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "repos_pk", + "table": "repos", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "task_run_logs_pk", + "table": "task_run_logs", + "entityType": "pks" + }, + { + "columns": [ + "task_id" + ], + "nameExplicit": false, + "name": "task_runs_pk", + "table": "task_runs", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "issue_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "issues_current_repo_issue_unique", + "entityType": "indexes", + "table": "issues_current" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "number", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "issues_current_repo_number_idx", + "entityType": "indexes", + "table": "issues_current" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "state", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "issues_current_repo_state_idx", + "entityType": "indexes", + "table": "issues_current" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "task_run_id", + "isExpression": false + }, + { + "value": "ref_name", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repo_ref_changes_repo_run_ref_unique", + "entityType": "indexes", + "table": "repo_ref_changes" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repo_ref_changes_repo_created_at_idx", + "entityType": "indexes", + "table": "repo_ref_changes" + }, + { + "columns": [ + { + "value": "task_run_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repo_ref_changes_task_run_id_idx", + "entityType": "indexes", + "table": "repo_ref_changes" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "ref_name", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repo_ref_changes_repo_ref_created_at_idx", + "entityType": "indexes", + "table": "repo_ref_changes" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "ref_name", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repo_refs_current_repo_ref_unique", + "entityType": "indexes", + "table": "repo_refs_current" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repo_refs_current_repo_status_idx", + "entityType": "indexes", + "table": "repo_refs_current" + }, + { + "columns": [ + { + "value": "repo_id", + "isExpression": false + }, + { + "value": "ref_kind", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repo_refs_current_repo_kind_idx", + "entityType": "indexes", + "table": "repo_refs_current" + }, + { + "columns": [ + { + "value": "source_id", + "isExpression": false + }, + { + "value": "ref_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repos_source_ref_unique", + "entityType": "indexes", + "table": "repos" + }, + { + "columns": [ + { + "value": "source_id", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repos_source_status_idx", + "entityType": "indexes", + "table": "repos" + }, + { + "columns": [ + { + "value": "source_id", + "isExpression": false + }, + { + "value": "full_name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "repos_source_full_name_idx", + "entityType": "indexes", + "table": "repos" + }, + { + "columns": [ + { + "value": "task_id", + "isExpression": false + }, + { + "value": "created_at", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_run_logs_task_id_created_at_idx", + "entityType": "indexes", + "table": "task_run_logs" + }, + { + "columns": [ + { + "value": "started_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_runs_started_at_idx", + "entityType": "indexes", + "table": "task_runs" + }, + { + "columns": [ + { + "value": "job_type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_runs_job_type_idx", + "entityType": "indexes", + "table": "task_runs" + }, + { + "columns": [ + { + "value": "parent_task_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "task_runs_parent_task_id_idx", + "entityType": "indexes", + "table": "task_runs" + }, + { + "value": "\"ref_kind\" IN ('head', 'tag')", + "name": "repo_ref_changes_kind_check", + "entityType": "checks", + "table": "repo_ref_changes" + }, + { + "value": "\"action\" IN ('create', 'update', 'delete')", + "name": "repo_ref_changes_action_check", + "entityType": "checks", + "table": "repo_ref_changes" + }, + { + "value": "\"ref_kind\" IN ('head', 'tag')", + "name": "repo_refs_current_kind_check", + "entityType": "checks", + "table": "repo_refs_current" + }, + { + "value": "\"status\" IN ('active', 'deleted')", + "name": "repo_refs_current_status_check", + "entityType": "checks", + "table": "repo_refs_current" + }, + { + "value": "\"status\" IN ('active', 'auto_excluded')", + "name": "repos_status_check", + "entityType": "checks", + "table": "repos" + }, + { + "value": "json_valid(\"origin\")", + "name": "repos_origin_json_check", + "entityType": "checks", + "table": "repos" + }, + { + "value": "json_valid(\"meta\")", + "name": "repos_meta_json_check", + "entityType": "checks", + "table": "repos" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/db/queries/issues.sql b/db/queries/issues.sql new file mode 100644 index 0000000..55fdd59 --- /dev/null +++ b/db/queries/issues.sql @@ -0,0 +1,46 @@ +-- name: GetIssueSyncState :one +SELECT * FROM issue_sync_state WHERE repo_id = ?1; + +-- name: SetIssueSyncRunning :exec +INSERT INTO issue_sync_state (repo_id, status, last_error, updated_at) +VALUES (?1, 'running', '', ?2) +ON CONFLICT (repo_id) DO UPDATE SET status = 'running', last_error = '', updated_at = excluded.updated_at; + +-- name: SetIssueSyncFailed :exec +INSERT INTO issue_sync_state (repo_id, status, last_error, updated_at) +VALUES (?1, 'failed', ?2, ?3) +ON CONFLICT (repo_id) DO UPDATE SET status = 'failed', last_error = excluded.last_error, updated_at = excluded.updated_at; + +-- name: CompleteIssueSync :exec +INSERT INTO issue_sync_state (repo_id, status, latest_commit, issues_since, comments_since, + last_successful_at, last_full_reconcile_at, last_error, issue_count, comment_count, updated_at) +VALUES (?1, 'idle', ?2, ?3, ?4, ?5, ?6, '', ?7, ?8, ?5) +ON CONFLICT (repo_id) DO UPDATE SET status = 'idle', latest_commit = excluded.latest_commit, + issues_since = excluded.issues_since, comments_since = excluded.comments_since, + last_successful_at = excluded.last_successful_at, last_full_reconcile_at = excluded.last_full_reconcile_at, + last_error = '', issue_count = excluded.issue_count, comment_count = excluded.comment_count, + updated_at = excluded.updated_at; + +-- name: DeleteIssueIndex :exec +DELETE FROM issues_current WHERE repo_id = ?1; + +-- name: InsertIssueIndex :exec +INSERT INTO issues_current (repo_id, issue_id, number, title, state, author_login, updated_at, availability, labels_json, assignees_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10); + +-- name: ListIssuesCurrent :many +SELECT * FROM issues_current +WHERE repo_id = ?1 + AND (?2 = '' OR state = ?2) + AND (?3 = '' OR title LIKE '%' || ?3 || '%' OR CAST(number AS TEXT) = ?3) +ORDER BY number DESC, issue_id +LIMIT ?4 OFFSET ?5; + +-- name: CountIssuesCurrent :one +SELECT COUNT(*) FROM issues_current +WHERE repo_id = ?1 + AND (?2 = '' OR state = ?2) + AND (?3 = '' OR title LIKE '%' || ?3 || '%' OR CAST(number AS TEXT) = ?3); + +-- name: ListInterruptedIssueSyncs :many +SELECT repo_id FROM issue_sync_state WHERE status = 'running' ORDER BY repo_id; diff --git a/db/schema.sql b/db/schema.sql index 81bb481..17a0fad 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -3,6 +3,35 @@ CREATE TABLE app_meta ( value text NOT NULL ); +CREATE TABLE issue_sync_state ( + repo_id integer PRIMARY KEY, + status text DEFAULT 'idle' NOT NULL, + latest_commit text DEFAULT '' NOT NULL, + issues_since text DEFAULT '' NOT NULL, + comments_since text DEFAULT '' NOT NULL, + last_successful_at text DEFAULT '' NOT NULL, + last_full_reconcile_at text DEFAULT '' NOT NULL, + last_error text DEFAULT '' NOT NULL, + issue_count integer DEFAULT 0 NOT NULL, + comment_count integer DEFAULT 0 NOT NULL, + updated_at text NOT NULL, + CONSTRAINT fk_issue_sync_state_repo_id_repos_id_fk FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE +); + +CREATE TABLE issues_current ( + repo_id integer NOT NULL, + issue_id text NOT NULL, + number integer NOT NULL, + title text NOT NULL, + state text NOT NULL, + author_login text NOT NULL, + updated_at text NOT NULL, + availability text NOT NULL, + labels_json text DEFAULT '[]' NOT NULL, + assignees_json text DEFAULT '[]' NOT NULL, + CONSTRAINT fk_issues_current_repo_id_repos_id_fk FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE +); + CREATE TABLE repo_ref_changes ( id integer PRIMARY KEY AUTOINCREMENT, repo_id integer NOT NULL, @@ -107,6 +136,9 @@ CREATE TABLE task_runs ( updated_at text NOT NULL ); +CREATE UNIQUE INDEX issues_current_repo_issue_unique ON issues_current (repo_id,issue_id); +CREATE INDEX issues_current_repo_number_idx ON issues_current (repo_id,number); +CREATE INDEX issues_current_repo_state_idx ON issues_current (repo_id,state); CREATE UNIQUE INDEX repo_ref_changes_repo_run_ref_unique ON repo_ref_changes (repo_id,task_run_id,ref_name); CREATE INDEX repo_ref_changes_repo_created_at_idx ON repo_ref_changes (repo_id,created_at); CREATE INDEX repo_ref_changes_task_run_id_idx ON repo_ref_changes (task_run_id); diff --git a/db/sqlc/issues.sql.go b/db/sqlc/issues.sql.go new file mode 100644 index 0000000..320595c --- /dev/null +++ b/db/sqlc/issues.sql.go @@ -0,0 +1,249 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: issues.sql + +package sqlc + +import ( + "context" +) + +const completeIssueSync = `-- name: CompleteIssueSync :exec +INSERT INTO issue_sync_state (repo_id, status, latest_commit, issues_since, comments_since, + last_successful_at, last_full_reconcile_at, last_error, issue_count, comment_count, updated_at) +VALUES (?1, 'idle', ?2, ?3, ?4, ?5, ?6, '', ?7, ?8, ?5) +ON CONFLICT (repo_id) DO UPDATE SET status = 'idle', latest_commit = excluded.latest_commit, + issues_since = excluded.issues_since, comments_since = excluded.comments_since, + last_successful_at = excluded.last_successful_at, last_full_reconcile_at = excluded.last_full_reconcile_at, + last_error = '', issue_count = excluded.issue_count, comment_count = excluded.comment_count, + updated_at = excluded.updated_at +` + +type CompleteIssueSyncParams struct { + RepoID int64 + LatestCommit string + IssuesSince string + CommentsSince string + LastSuccessfulAt string + LastFullReconcileAt string + IssueCount int64 + CommentCount int64 +} + +func (q *Queries) CompleteIssueSync(ctx context.Context, arg CompleteIssueSyncParams) error { + _, err := q.db.ExecContext(ctx, completeIssueSync, + arg.RepoID, + arg.LatestCommit, + arg.IssuesSince, + arg.CommentsSince, + arg.LastSuccessfulAt, + arg.LastFullReconcileAt, + arg.IssueCount, + arg.CommentCount, + ) + return err +} + +const countIssuesCurrent = `-- name: CountIssuesCurrent :one +SELECT COUNT(*) FROM issues_current +WHERE repo_id = ?1 + AND (?2 = '' OR state = ?2) + AND (?3 = '' OR title LIKE '%' || ?3 || '%' OR CAST(number AS TEXT) = ?3) +` + +type CountIssuesCurrentParams struct { + RepoID int64 + Column2 interface{} + Column3 interface{} +} + +func (q *Queries) CountIssuesCurrent(ctx context.Context, arg CountIssuesCurrentParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countIssuesCurrent, arg.RepoID, arg.Column2, arg.Column3) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteIssueIndex = `-- name: DeleteIssueIndex :exec +DELETE FROM issues_current WHERE repo_id = ?1 +` + +func (q *Queries) DeleteIssueIndex(ctx context.Context, repoID int64) error { + _, err := q.db.ExecContext(ctx, deleteIssueIndex, repoID) + return err +} + +const getIssueSyncState = `-- name: GetIssueSyncState :one +SELECT repo_id, status, latest_commit, issues_since, comments_since, last_successful_at, last_full_reconcile_at, last_error, issue_count, comment_count, updated_at FROM issue_sync_state WHERE repo_id = ?1 +` + +func (q *Queries) GetIssueSyncState(ctx context.Context, repoID int64) (IssueSyncState, error) { + row := q.db.QueryRowContext(ctx, getIssueSyncState, repoID) + var i IssueSyncState + err := row.Scan( + &i.RepoID, + &i.Status, + &i.LatestCommit, + &i.IssuesSince, + &i.CommentsSince, + &i.LastSuccessfulAt, + &i.LastFullReconcileAt, + &i.LastError, + &i.IssueCount, + &i.CommentCount, + &i.UpdatedAt, + ) + return i, err +} + +const insertIssueIndex = `-- name: InsertIssueIndex :exec +INSERT INTO issues_current (repo_id, issue_id, number, title, state, author_login, updated_at, availability, labels_json, assignees_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) +` + +type InsertIssueIndexParams struct { + RepoID int64 + IssueID string + Number int64 + Title string + State string + AuthorLogin string + UpdatedAt string + Availability string + LabelsJson string + AssigneesJson string +} + +func (q *Queries) InsertIssueIndex(ctx context.Context, arg InsertIssueIndexParams) error { + _, err := q.db.ExecContext(ctx, insertIssueIndex, + arg.RepoID, + arg.IssueID, + arg.Number, + arg.Title, + arg.State, + arg.AuthorLogin, + arg.UpdatedAt, + arg.Availability, + arg.LabelsJson, + arg.AssigneesJson, + ) + return err +} + +const listInterruptedIssueSyncs = `-- name: ListInterruptedIssueSyncs :many +SELECT repo_id FROM issue_sync_state WHERE status = 'running' ORDER BY repo_id +` + +func (q *Queries) ListInterruptedIssueSyncs(ctx context.Context) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listInterruptedIssueSyncs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var repo_id int64 + if err := rows.Scan(&repo_id); err != nil { + return nil, err + } + items = append(items, repo_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listIssuesCurrent = `-- name: ListIssuesCurrent :many +SELECT repo_id, issue_id, number, title, state, author_login, updated_at, availability, labels_json, assignees_json FROM issues_current +WHERE repo_id = ?1 + AND (?2 = '' OR state = ?2) + AND (?3 = '' OR title LIKE '%' || ?3 || '%' OR CAST(number AS TEXT) = ?3) +ORDER BY number DESC, issue_id +LIMIT ?4 OFFSET ?5 +` + +type ListIssuesCurrentParams struct { + RepoID int64 + Column2 interface{} + Column3 interface{} + Limit int64 + Offset int64 +} + +func (q *Queries) ListIssuesCurrent(ctx context.Context, arg ListIssuesCurrentParams) ([]IssuesCurrent, error) { + rows, err := q.db.QueryContext(ctx, listIssuesCurrent, + arg.RepoID, + arg.Column2, + arg.Column3, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []IssuesCurrent + for rows.Next() { + var i IssuesCurrent + if err := rows.Scan( + &i.RepoID, + &i.IssueID, + &i.Number, + &i.Title, + &i.State, + &i.AuthorLogin, + &i.UpdatedAt, + &i.Availability, + &i.LabelsJson, + &i.AssigneesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setIssueSyncFailed = `-- name: SetIssueSyncFailed :exec +INSERT INTO issue_sync_state (repo_id, status, last_error, updated_at) +VALUES (?1, 'failed', ?2, ?3) +ON CONFLICT (repo_id) DO UPDATE SET status = 'failed', last_error = excluded.last_error, updated_at = excluded.updated_at +` + +type SetIssueSyncFailedParams struct { + RepoID int64 + LastError string + UpdatedAt string +} + +func (q *Queries) SetIssueSyncFailed(ctx context.Context, arg SetIssueSyncFailedParams) error { + _, err := q.db.ExecContext(ctx, setIssueSyncFailed, arg.RepoID, arg.LastError, arg.UpdatedAt) + return err +} + +const setIssueSyncRunning = `-- name: SetIssueSyncRunning :exec +INSERT INTO issue_sync_state (repo_id, status, last_error, updated_at) +VALUES (?1, 'running', '', ?2) +ON CONFLICT (repo_id) DO UPDATE SET status = 'running', last_error = '', updated_at = excluded.updated_at +` + +type SetIssueSyncRunningParams struct { + RepoID int64 + UpdatedAt string +} + +func (q *Queries) SetIssueSyncRunning(ctx context.Context, arg SetIssueSyncRunningParams) error { + _, err := q.db.ExecContext(ctx, setIssueSyncRunning, arg.RepoID, arg.UpdatedAt) + return err +} diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 52c0e3b..b494172 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -13,6 +13,33 @@ type AppMetum struct { Value string } +type IssueSyncState struct { + RepoID int64 + Status string + LatestCommit string + IssuesSince string + CommentsSince string + LastSuccessfulAt string + LastFullReconcileAt string + LastError string + IssueCount int64 + CommentCount int64 + UpdatedAt string +} + +type IssuesCurrent struct { + RepoID int64 + IssueID string + Number int64 + Title string + State string + AuthorLogin string + UpdatedAt string + Availability string + LabelsJson string + AssigneesJson string +} + type Repo struct { ID int64 SourceID string diff --git a/db/src/schema.ts b/db/src/schema.ts index 06e28da..75061ba 100644 --- a/db/src/schema.ts +++ b/db/src/schema.ts @@ -200,3 +200,46 @@ export const taskRunLogs = sqliteTable( ), ], ); + +// The Git archive is authoritative; these rows are rebuildable browser indexes. +export const issueSyncState = sqliteTable('issue_sync_state', { + repoId: integer('repo_id') + .primaryKey() + .references(() => repos.id, { onDelete: 'cascade' }), + status: text('status').notNull().default('idle'), + latestCommit: text('latest_commit').notNull().default(''), + issuesSince: text('issues_since').notNull().default(''), + commentsSince: text('comments_since').notNull().default(''), + lastSuccessfulAt: text('last_successful_at').notNull().default(''), + lastFullReconcileAt: text('last_full_reconcile_at').notNull().default(''), + lastError: text('last_error').notNull().default(''), + issueCount: integer('issue_count').notNull().default(0), + commentCount: integer('comment_count').notNull().default(0), + updatedAt: text('updated_at').notNull(), +}); + +export const issuesCurrent = sqliteTable( + 'issues_current', + { + repoId: integer('repo_id') + .notNull() + .references(() => repos.id, { onDelete: 'cascade' }), + issueId: text('issue_id').notNull(), + number: integer('number').notNull(), + title: text('title').notNull(), + state: text('state').notNull(), + authorLogin: text('author_login').notNull(), + updatedAt: text('updated_at').notNull(), + availability: text('availability').notNull(), + labelsJson: text('labels_json').notNull().default('[]'), + assigneesJson: text('assignees_json').notNull().default('[]'), + }, + (table) => [ + uniqueIndex('issues_current_repo_issue_unique').on( + table.repoId, + table.issueId, + ), + index('issues_current_repo_number_idx').on(table.repoId, table.number), + index('issues_current_repo_state_idx').on(table.repoId, table.state), + ], +); diff --git a/docs/issues-backup.md b/docs/issues-backup.md new file mode 100644 index 0000000..604ab3c --- /dev/null +++ b/docs/issues-backup.md @@ -0,0 +1,101 @@ +# GitHub Issues backups + +## Enable and use + +1. In **Configuration → Sources**, edit a GitHub source and enable **Back up GitHub Issues**. Existing sources default to off. The equivalent source YAML field is `issues_enabled: true`. +2. Run **Sync All**, synchronize that source, or wait for the existing cron schedule. After repository discovery/code backup, each active repository gets a separate `sync-issues` child task. Code and issue failures remain independently visible in task history. +3. Open a repository's **Issues** tab. It provides current search/state filters, descriptions/comments/timeline, historical detail selection, manual incremental/full sync, and verified snapshot ZIP export. The latest 100 snapshot commits are shown in the picker; the complete history remains in Git. The issue list is the latest indexed view, even when historical details are selected. + +Sources use their existing encrypted token. A fine-grained GitHub token needs **Issues: read** and **Metadata: read** for selected repositories; the existing private-code backup additionally needs its existing repository/content access. Organization approval/SSO may also be required. The app never requests a write permission for issue backups. Do not place plaintext tokens in YAML, archives, URLs, logs, or task arguments. + +### CLI + +`--repo` is the local database repository ID, visible in `/repos/`, not the GitHub issue number or GitHub repository ID. + +```sh +# Queue on the running server. Authentication uses the existing PASSWORD env var. +git-plus issues sync --repo 42 --server http://127.0.0.1:8080 +git-plus issues sync --repo 42 --full --server http://127.0.0.1:8080 + +# Offline/local operations do not contact GitHub or require a GitHub token. +git-plus issues verify --data-dir ./data --repo 42 +git-plus issues export --data-dir ./data --repo 42 --output ./issues-42.zip +git-plus issues export --data-dir ./data --repo 42 --snapshot --output ./older.zip +git-plus issues reindex --data-dir ./data --repo 42 +``` + +Export refuses to overwrite an existing output file. ZIP contains one selected snapshot, not the whole Git history. Reindex restores only the latest snapshot and is serialized against synchronization with an OS advisory lock; retry after a running sync finishes if it reports busy. Existing migrations and the repository inventory must exist. To recover a completely lost SQLite database, restore/recreate that inventory first, then reindex the corresponding repository archives. + +## Storage design + +```text +/ + config.yaml # existing encrypted source configuration + db.sqlite # repository inventory, tasks, disposable Issues index + repos/// # existing code archive, unchanged + metadata//.git/ + metadata//.git.sync.lock + staging/issues/// + checkpoint.json + issue-.json +``` + +The separate metadata repository is bare and local-only, with no remote. Its `refs/heads/snapshots` branch receives one commit only when observed content changes. SQLite is the browsing index and live checkpoint, not the only copy of issue content. This keeps ordinary Git history/diff inspection available without mixing application-generated files into the source-code archive. Copy the entire metadata archive, configuration, and inventory while the server is stopped to preserve history; protect any backup copy at least as strictly as the original private repositories. + +Each metadata commit has: + +```text +README.md +manifest.json +repository.json +labels.json +milestones.json +issues// + issue.json + comments.json + timeline.json + state.json + README.md +``` + +Canonical JSON preserves unknown response fields and integer precision. IDs are strings in the index and manifest paths, while original JSON numbers retain their representation. Filenames never derive from titles or owner/repository names. Repository identity is verified against GitHub's numeric ID before publication; renames update identity metadata without moving the archive. A transferred issue can appear in a new repository's archive under the same stable issue ID, while the old archive retains its previous observations. + +The versioned manifest records API version (`2022-11-28`), observation start/completion times, recovery watermarks, counts, coverage, and SHA-256 file checksums. Git commits also protect the manifest/tree. These are corruption checks, not signatures against a malicious archive editor. See [archive format and invariants](../pkg/issuearchive/README.md). + +## Coverage and fidelity + +Included: open and closed **issues**, their current descriptions/state/authors/assignees/labels/milestones, complete paginated comments, currently visible timeline events, and repository-wide labels and open/closed milestones. Pull-request entries returned by the Issues endpoint are excluded, including their ordinary comments. Raw API objects are authoritative; readable Markdown is derived with untrusted bodies fenced as text. The Web UI also escapes content and does not render raw HTML or fetch embedded images. + +Attachments are **links only**. No attachment bytes are downloaded, authenticated links may expire, and images/files may not work offline. No PR review data, Discussions, Projects fields, full reaction-member lists, unavailable timeline events, webhook event log, or historical body revisions predating the first observation are promised. Git stores the states this backup actually observed; polling cannot capture every intermediate edit. A crawl is an observation window, not a transactional GitHub point-in-time snapshot. + +There is no GitHub restore/import command. The public API cannot recreate original issue numbers, authors, timestamps, or every historical event faithfully. Archive export is for reading, analysis, and future explicit migration tooling. There is no automatic remote push of metadata repositories. + +## Incremental sync and failure handling + +- The first run is full. Later runs use independent issue and repository-comment `since` watermarks, with a five-minute overlap. A watermark is the run's **start**, never the newest item timestamp or completion time. +- Changes to comments can cause a thread to be refreshed even if its issue's `updated_at` did not change. Each selected thread fetches all comments and timeline pages. Labels and milestones are fully refreshed on every run. A full reconciliation rechecks every issue at least every seven days **when a sync runs**, or immediately via **Full reconcile**. There is no separate background schedule when cron is disabled. +- All list endpoints follow validated same-origin `Link: rel="next"` pagination. The API client pins its version, serializes requests per credential, bounds response/ETag-cache size, and rejects cross-origin redirects/next links. Network/5xx/short rate-limit errors get bounded retries. Longer rate-limit waits fail the task with the retry time, keeping the prior backup; retry manually or on the next scheduled run after that time. +- Successful per-thread results and the original observation window are checkpointed in private staging. An interrupted run re-enumerates pages and resumes matching completed threads. It does not trust old page offsets against a moving API dataset. In-progress jobs are requeued on server startup; queued-but-not-started tasks follow the existing in-memory task queue behavior and can be recreated by the next source sync. +- Git objects are written before the snapshot reference is atomically published. Only then does one SQLite transaction replace the index and advance watermarks. If publication succeeded but the index commit did not, the next sync rebuilds from Git. No-op runs advance SQLite watermarks without generating metadata-only commits; reindex may safely replay from an older archived watermark. +- A failed page, permission error, repository identity mismatch, 404/410, or partial collection never publishes a purported complete snapshot or advances successful watermarks. A **successful full crawl** that does not see a formerly archived issue marks it `unavailable`, retaining its last content and all prior commits. That marker deliberately does not claim deletion: transfer, visibility, and permissions can also explain absence. A later observation clears it. Deleted comments/labels can disappear from the current complete collection while remaining in old commits. +- OS advisory locks are released on process exit. Persistent lockfiles must not be removed while the application is running. Do not mutate the private snapshot branch using external Git tools or run two servers on one data directory. + +## Security and operational limits + +Private issue bodies/comments and staging files are private data even when the source Git repository is public. Archives do not contain configured credentials, but user-written issue content may itself contain secrets; the backup preserves that content. Keep the application data directory and exports access-controlled, keep API authentication enabled, use TLS when exposing the dashboard, and do not publish metadata archives unintentionally. Application SQLite directory permissions inherit the existing product's storage policy; encryption at rest is an operator responsibility. + +The archive currently bounds one snapshot to 512 MiB total content, 64 MiB per file, and one million files; the API bounds each response to 32 MiB. This MVP materializes snapshot content in memory, so very large issue collections need a later streaming/storage iteration. Export uses a private temporary file and verifies the archive before serving it. The history picker is intentionally bounded, and server-side filtering currently searches issue titles/numbers rather than full comment text. + +## Implementation map + +- `pkg/githubapi`: credential-scoped, read-only API client and pagination/retry policy. +- `pkg/issuearchive`: canonical format, independent Git history, checksums, safe Markdown, ZIP, advisory locks. +- `pkg/issuesync`: full/incremental crawl, staging/recovery, atomic SQLite index rebuild. +- `pkg/issuetask`: existing task queue integration and interrupted-job restart. +- `pkg/issueservice`, `proto/gitplus/issue/v1`: ConnectRPC and authenticated ZIP route. +- `pkg/taskservice`: source/sync-all/cron child-task integration. +- `pkg/app/issues.go`: sync/export/verify/reindex CLI. +- `frontend/src/components/issues`, `frontend/src/lib/issue-*`: current/history browsing and export. +- `db/src/schema.ts`, `db/queries/issues.sql`: disposable issue index and live sync status. Generated migration/schema/sqlc and RPC code follow the repository's normal generators; Buf plugins are pinned to the pre-existing generated versions for reproducibility. + +Further phases can add attachment byte capture with allowlisted hosts/SSRF controls/deduplication, archive streaming, richer search/history pagination, and explicit destination-specific import. These are not included in this feature. diff --git a/frontend/src/components/issues/IssueDetail.test.tsx b/frontend/src/components/issues/IssueDetail.test.tsx new file mode 100644 index 0000000..d8bd1c9 --- /dev/null +++ b/frontend/src/components/issues/IssueDetail.test.tsx @@ -0,0 +1,89 @@ +import { create } from '@bufbuild/protobuf'; +import { MantineProvider } from '@mantine/core'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ArchivedText, IssueDetailContent } from './IssueDetail'; +import { GetIssueResponseSchema } from '~rpc/gitplus/issue/v1/issue_pb'; + +function renderDetail( + rawJson: string, + commentsJson = '[]', + timelineJson = '[]', +) { + const data = create(GetIssueResponseSchema, { + issue: { + id: '123', + number: 7n, + title: 'Offline issue', + state: 'open', + authorLogin: 'octocat', + availability: 'unavailable', + }, + rawJson, + commentsJson, + timelineJson, + markdown: '# Derived archive document', + }); + return renderToStaticMarkup( + + + , + ); +} + +describe('archived issue HTML safety', () => { + it('preserves raw large integer IDs without parsing or rounding them', () => { + const raw = '{"id":900000000000000001,"body":"saved"}'; + const html = renderToStaticMarkup( + + {raw} + , + ); + expect(html).toContain('900000000000000001'); + expect(html).not.toContain('900000000000000000'); + }); + it('escapes hostile bodies, comments, and timeline text without active images or links', () => { + const html = renderDetail( + JSON.stringify({ + body: ' ![remote](https://example.test/image.png)', + }), + JSON.stringify([ + { + body: '', + }, + ]), + JSON.stringify([ + { + event: 'commented', + body: '', + }, + ]), + ); + expect(html).toContain('<script>alert(1)</script>'); + expect(html).toContain('<img'); + expect(html).toContain('<iframe'); + expect(html).not.toMatch(/<(script|img|iframe)\b/i); + expect(html).not.toContain('href="https://example.test'); + expect(html).toContain('Last known copy'); + expect(html).toContain('does not prove the issue was deleted'); + }); + + it('keeps a missing description empty instead of substituting the entire derived document', () => { + const html = renderDetail('{"body":null}'); + expect(html).toContain('No description.'); + expect(html).not.toContain('# Derived archive document'); + expect(html).toContain('No comments recorded'); + expect(html).toContain('No timeline events recorded'); + }); + + it('exposes unreadable archived collections rather than silently showing empty coverage', () => { + const html = renderDetail( + '{"body":"Readable description"}', + 'broken', + 'null', + ); + expect(html).toContain('Comments could not be read'); + expect(html).toContain('Timeline could not be read'); + expect(html).toContain('Original JSON'); + }); +}); diff --git a/frontend/src/components/issues/IssueDetail.tsx b/frontend/src/components/issues/IssueDetail.tsx new file mode 100644 index 0000000..4a7d795 --- /dev/null +++ b/frontend/src/components/issues/IssueDetail.tsx @@ -0,0 +1,323 @@ +import { useState } from 'react'; +import { + Accordion, + Alert, + Badge, + Box, + Button, + Divider, + Group, + Loader, + Paper, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react'; +import type { GetIssueResponse } from '~rpc/gitplus/issue/v1/issue_pb'; +import { issueDetailQueryOptions } from '~lib/issue-queries'; +import { + issueStateLabel, + issueTime, + jsonObject, + jsonString, + parseIssueObject, + parseIssueRecords, + recordAuthor, +} from '~lib/issue-display'; + +// React escapes all content here. Never parse archived HTML or fetch embedded media. +export function ArchivedText({ children }: { children: string }) { + return ( + + {children} + + ); +} + +export function IssueDetail({ + repoId, + issueId, + snapshot, + latestCommit, + onBack, +}: { + repoId: string; + issueId: string; + snapshot: string; + latestCommit: string; + onBack: () => void; +}) { + const detail = useQuery( + issueDetailQueryOptions(repoId, issueId, snapshot, latestCommit), + ); + + return ( + + + {detail.isPending ? ( + + + Loading archived issue… + + ) : detail.isError ? ( + } + > + {detail.error.message} + + An issue may not exist in an older snapshot. Try another snapshot or + return to the latest version. + + + + ) : ( + + )} + + ); +} + +export function IssueDetailContent({ data }: { data: GetIssueResponse }) { + const raw = parseIssueObject(data.rawJson); + const comments = parseIssueRecords(data.commentsJson); + const timeline = parseIssueRecords(data.timelineJson); + const issue = data.issue; + const body = raw ? jsonString(raw.body) : data.markdown; + const [commentLimit, setCommentLimit] = useState(30); + const [eventLimit, setEventLimit] = useState(50); + + if (!issue) { + return ( + + No issue was recorded in this snapshot. + + ); + } + + return ( + + + + + {issueStateLabel(issue.state)} + + {issue.availability && issue.availability !== 'available' && ( + + {issueStateLabel(issue.availability)} + + )} + + #{String(issue.number)} + + + + {issue.title} + + + @{issue.authorLogin || 'unknown'} · Updated{' '} + {issueTime(issue.updatedAt)} + + {data.snapshot && ( + + Snapshot {data.snapshot.slice(0, 12)} + + )} + {issue.labels.length > 0 && ( + + {issue.labels.map((label) => ( + + {label} + + ))} + + )} + {issue.assignees.length > 0 && ( + + Assignees: {issue.assignees.map((login) => `@${login}`).join(', ')} + + )} + {issue.availability && issue.availability !== 'available' && ( + + This issue was not available at the last check recorded in this + snapshot. The archived content is preserved; this does not prove the + issue was deleted. + + )} + + + + + Description + + + Original Markdown shown as text. HTML, images, and attachments are not + loaded. + + + {body || 'No description.'} + + + + + + Comments{comments ? ` (${comments.length})` : ''} + + {comments === null ? ( + + Comments could not be read. The original data is available below. + + ) : comments.length === 0 ? ( + + No comments recorded in this snapshot. + + ) : ( + + {comments.slice(0, commentLimit).map((comment, index) => ( + + + + @{recordAuthor(comment)} + + + {issueTime(jsonString(comment.created_at))} + + + + {jsonString(comment.body) || 'No comment body recorded.'} + + + + ))} + {comments.length > commentLimit && ( + + )} + + )} + + + + + Timeline{timeline ? ` (${timeline.length})` : ''} + + + Only events returned by GitHub and captured during backup are + included. + + {timeline === null ? ( + + Timeline could not be read. The original data is available below. + + ) : timeline.length === 0 ? ( + + No timeline events recorded in this snapshot. + + ) : ( + + {timeline.slice(0, eventLimit).map((event, index) => { + const label = jsonString(jsonObject(event.label)?.name); + const assignee = jsonString(jsonObject(event.assignee)?.login); + const rename = jsonObject(event.rename); + return ( + + + + {issueStateLabel(jsonString(event.event) || 'event')} + + + @{recordAuthor(event)} ·{' '} + {issueTime(jsonString(event.created_at))} + + + {label && Label: {label}} + {assignee && Assignee: @{assignee}} + {rename && ( + + {jsonString(rename.from)} → {jsonString(rename.to)} + + )} + {jsonString(event.body) && ( + {jsonString(event.body)} + )} + + ); + })} + {timeline.length > eventLimit && ( + + )} + + )} + + + + + Original JSON + + + + Preserved API records for inspection. Export the snapshot ZIP to + keep these files. + + {[ + ['Issue', data.rawJson], + ['Comments', data.commentsJson], + ['Timeline', data.timelineJson], + ].map(([label, value]) => ( + + + {label} + + + {/* Preserve raw numeric IDs without JavaScript rounding. */} + {value} + + + ))} + + + + + + ); +} diff --git a/frontend/src/components/issues/IssuesTab.browser.test.tsx b/frontend/src/components/issues/IssuesTab.browser.test.tsx new file mode 100644 index 0000000..ffe4ada --- /dev/null +++ b/frontend/src/components/issues/IssuesTab.browser.test.tsx @@ -0,0 +1,300 @@ +import { create } from '@bufbuild/protobuf'; +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IssuesTab } from './IssuesTab'; +import { issueClient } from '~lib/connect/client'; +import { downloadIssueSnapshot } from '~lib/issue-download'; +import { + EnqueueSyncResponseSchema, + GetIssueResponseSchema, + GetStatusResponseSchema, + ListIssuesResponseSchema, + ListSnapshotsResponseSchema, +} from '~rpc/gitplus/issue/v1/issue_pb'; +import { TaskEnqueueResult } from '~rpc/gitplus/task/v1/task_pb'; + +vi.mock('~lib/connect/client', () => ({ + issueClient: { + getStatus: vi.fn(), + listIssues: vi.fn(), + getIssue: vi.fn(), + listSnapshots: vi.fn(), + enqueueSync: vi.fn(), + }, +})); +vi.mock('~lib/issue-download', () => ({ downloadIssueSnapshot: vi.fn() })); + +const issue = { + id: '123', + number: 7n, + title: 'Preserve offline discussions', + state: 'open', + authorLogin: 'octocat', + updatedAt: '2026-10-15T10:00:00Z', + availability: 'available', + labels: ['backup'], + assignees: ['maintainer'], +}; +const clients: Array = []; + +function renderTab() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + clients.push(client); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.mocked(issueClient.getStatus).mockResolvedValue( + create(GetStatusResponseSchema, { + status: { + enabled: true, + state: 'ready', + latestCommit: 'abcdef1234', + issueCount: 1, + commentCount: 1, + lastSuccessfulAt: '2026-10-15T10:00:00Z', + }, + }), + ); + vi.mocked(issueClient.listIssues).mockResolvedValue( + create(ListIssuesResponseSchema, { issues: [issue], totalCount: 1 }), + ); + vi.mocked(issueClient.listSnapshots).mockResolvedValue( + create(ListSnapshotsResponseSchema, { + snapshots: [ + { + commit: 'abcdef1234', + createdAt: '2026-10-15T10:00:00Z', + issueCount: 1, + commentCount: 1, + }, + ], + }), + ); + vi.mocked(issueClient.getIssue).mockResolvedValue( + create(GetIssueResponseSchema, { + issue, + rawJson: JSON.stringify({ body: 'Archived issue body' }), + commentsJson: JSON.stringify([ + { + id: 1, + body: 'An offline comment', + user: { login: 'reader' }, + created_at: '2026-10-15T09:00:00Z', + }, + ]), + timelineJson: JSON.stringify([ + { + id: 2, + event: 'labeled', + actor: { login: 'maintainer' }, + label: { name: 'backup' }, + created_at: '2026-10-15T09:00:00Z', + }, + ]), + snapshot: 'abcdef1234', + }), + ); + vi.mocked(issueClient.enqueueSync).mockResolvedValue( + create(EnqueueSyncResponseSchema, { result: TaskEnqueueResult.QUEUED }), + ); + vi.mocked(downloadIssueSnapshot).mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); + clients.splice(0).forEach((client) => client.clear()); + vi.resetAllMocks(); +}); + +describe('offline issue browser', () => { + it('shows loading status and list states', () => { + vi.mocked(issueClient.getStatus).mockReturnValue(new Promise(() => {})); + vi.mocked(issueClient.listIssues).mockReturnValue(new Promise(() => {})); + renderTab(); + expect(screen.getByText('Loading backup status…')).toBeVisible(); + expect(screen.getByText('Loading archived issues…')).toBeVisible(); + }); + + it('explains empty backups and disabled source settings', async () => { + vi.mocked(issueClient.getStatus).mockResolvedValue( + create(GetStatusResponseSchema, { status: { enabled: false } }), + ); + vi.mocked(issueClient.listIssues).mockResolvedValue( + create(ListIssuesResponseSchema), + ); + renderTab(); + expect(await screen.findByText('No issues backed up yet')).toBeVisible(); + expect(screen.getByText('Issue backups are turned off')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Sync issues' })).toBeDisabled(); + }); + + it('shows list failures with retry instead of claiming the archive is empty', async () => { + vi.mocked(issueClient.listIssues).mockRejectedValueOnce( + new Error('Archive temporarily unavailable'), + ); + renderTab(); + expect( + await screen.findByText('Could not load archived issues'), + ).toBeVisible(); + expect(screen.queryByText('No issues backed up yet')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect( + await screen.findByRole('button', { + name: /Preserve offline discussions/, + }), + ).toBeVisible(); + }); + + it('opens the saved body, comments, timeline, and original JSON', async () => { + renderTab(); + fireEvent.click( + await screen.findByRole('button', { + name: /Preserve offline discussions/, + }), + ); + expect(await screen.findByText('Archived issue body')).toBeVisible(); + expect(screen.getByText('An offline comment')).toBeVisible(); + expect(screen.getByText('Labeled')).toBeVisible(); + expect(screen.getByText('Label: backup')).toBeVisible(); + expect(screen.getByText(/Original Markdown shown as text/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Original JSON' })); + expect(await screen.findByText(/Preserved API records/)).toBeVisible(); + expect(issueClient.getIssue).toHaveBeenCalledWith( + { repoId: 42n, issueId: '123', snapshot: '' }, + expect.anything(), + ); + }); + + it('renders hostile Markdown, HTML, and remote images only as escaped text', async () => { + const hostile = + ' ![remote](https://example.test/image.png) [bad](javascript:alert(1))'; + vi.mocked(issueClient.getIssue).mockResolvedValue( + create(GetIssueResponseSchema, { + issue, + rawJson: JSON.stringify({ body: hostile }), + commentsJson: JSON.stringify([ + { body: '' }, + ]), + timelineJson: '[]', + }), + ); + const { container } = renderTab(); + fireEvent.click( + await screen.findByRole('button', { + name: /Preserve offline discussions/, + }), + ); + expect(await screen.findByText(hostile)).toBeVisible(); + expect( + screen.getByText(''), + ).toBeVisible(); + expect( + container.querySelector('img, iframe, script, a[href^="javascript:"]'), + ).toBeNull(); + }); + + it('handles missing historical details without losing the index', async () => { + vi.mocked(issueClient.getIssue).mockRejectedValue( + new Error('Issue not present in this snapshot'), + ); + renderTab(); + fireEvent.click( + await screen.findByRole('button', { + name: /Preserve offline discussions/, + }), + ); + expect( + await screen.findByText('Could not load archived issue'), + ).toBeVisible(); + fireEvent.click( + screen.getByRole('button', { name: 'Back to latest issue index' }), + ); + expect( + await screen.findByRole('button', { + name: /Preserve offline discussions/, + }), + ).toBeVisible(); + }); + + it('uses opaque page tokens and resets pagination for new searches', async () => { + vi.mocked(issueClient.listIssues).mockResolvedValue( + create(ListIssuesResponseSchema, { + issues: [issue], + totalCount: 31, + nextPageToken: 'opaque-next', + }), + ); + renderTab(); + await screen.findByRole('button', { name: /Preserve offline discussions/ }); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(issueClient.listIssues).toHaveBeenLastCalledWith( + expect.objectContaining({ pageToken: 'opaque-next' }), + expect.anything(), + ), + ); + fireEvent.change(screen.getByLabelText('Search latest issue index'), { + target: { value: 'offline' }, + }); + await waitFor(() => + expect(issueClient.listIssues).toHaveBeenLastCalledWith( + expect.objectContaining({ search: 'offline', pageToken: '' }), + expect.anything(), + ), + ); + }); + + it('queues incremental and full sync and exports the selected snapshot', async () => { + renderTab(); + await screen.findByRole('button', { name: /Preserve offline discussions/ }); + fireEvent.click(screen.getByRole('button', { name: 'Sync issues' })); + await waitFor(() => + expect(issueClient.enqueueSync).toHaveBeenCalledWith({ + repoId: 42n, + full: false, + }), + ); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Full reconcile' }), + ).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: 'Full reconcile' })); + await waitFor(() => + expect(issueClient.enqueueSync).toHaveBeenCalledWith({ + repoId: 42n, + full: true, + }), + ); + fireEvent.click( + screen.getByLabelText('Snapshot for issue details and export'), + ); + fireEvent.click(await screen.findByRole('option', { name: /abcdef12/ })); + expect( + screen.getByText( + /The issue index and counts still show the latest backup/, + ), + ).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Export ZIP' })); + await waitFor(() => + expect(downloadIssueSnapshot).toHaveBeenCalledWith('42', 'abcdef1234'), + ); + }); +}); diff --git a/frontend/src/components/issues/IssuesTab.tsx b/frontend/src/components/issues/IssuesTab.tsx new file mode 100644 index 0000000..84e0932 --- /dev/null +++ b/frontend/src/components/issues/IssuesTab.tsx @@ -0,0 +1,490 @@ +import { useState } from 'react'; +import { + Alert, + Badge, + Box, + Button, + Group, + Loader, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, + UnstyledButton, +} from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + IconAlertTriangle, + IconDownload, + IconRefresh, + IconSearch, +} from '@tabler/icons-react'; +import { toast } from 'sonner'; +import { IssueDetail } from './IssueDetail'; +import { issueClient } from '~lib/connect/client'; +import { issueStateLabel, issueTime } from '~lib/issue-display'; +import { downloadIssueSnapshot } from '~lib/issue-download'; +import { + issueListQueryOptions, + issueSnapshotsQueryOptions, + issueStatusQueryOptions, +} from '~lib/issue-queries'; +import { TaskEnqueueResult } from '~rpc/gitplus/task/v1/task_pb'; + +export function IssuesTab({ repoId }: { repoId: string }) { + const queryClient = useQueryClient(); + const statusQuery = useQuery(issueStatusQueryOptions(repoId)); + const status = statusQuery.data?.status; + const latestCommit = status?.latestCommit ?? ''; + const snapshotsQuery = useQuery( + issueSnapshotsQueryOptions(repoId, latestCommit), + ); + const [snapshot, setSnapshot] = useState(''); + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [search, setSearch] = useState(''); + const [debouncedSearch] = useDebouncedValue(search.trim(), 250); + const [state, setState] = useState(''); + + const syncMutation = useMutation({ + mutationFn: (full: boolean) => + issueClient.enqueueSync({ repoId: BigInt(repoId), full }), + onSuccess: (response) => { + toast.success( + response.result === TaskEnqueueResult.DEDUPED + ? 'An issue backup is already queued or running' + : 'Issue backup queued', + ); + void queryClient.invalidateQueries({ queryKey: ['issues', repoId] }); + void queryClient.invalidateQueries({ queryKey: ['task'] }); + }, + onError: (error) => toast.error(error.message), + }); + const exportMutation = useMutation({ + mutationFn: () => downloadIssueSnapshot(repoId, snapshot), + onSuccess: () => toast.success('Issue backup download started'), + onError: (error) => toast.error(error.message), + }); + + const snapshots = snapshotsQuery.data?.snapshots ?? []; + const snapshotOptions = [ + { value: '', label: 'Latest snapshot' }, + ...snapshots.map((item) => ({ + value: item.commit, + label: `${issueTime(item.createdAt)} · ${item.commit.slice(0, 8)} · ${item.issueCount} issues`, + })), + ]; + // Keep the selected historical version reachable even if the history query fails. + if (snapshot && !snapshots.some((item) => item.commit === snapshot)) { + snapshotOptions.push({ + value: snapshot, + label: `Snapshot ${snapshot.slice(0, 12)}`, + }); + } + const busy = + syncMutation.isPending || + status?.state === 'running' || + status?.state === 'syncing'; + + return ( + + + + + GitHub Issues backup + {status && ( + + {!status.enabled ? 'Disabled' : issueStateLabel(status.state)} + + )} + + + Read saved issues without contacting GitHub. Sync actions fetch + updates from GitHub. + + + + + + + + + {statusQuery.isPending && ( + + + Loading backup status… + + )} + {statusQuery.isError && ( + void statusQuery.refetch()} + /> + )} + {status && !status.enabled && ( + + Enable “Back up GitHub Issues” in this repository's source settings to + start backing up. Previously saved snapshots remain readable. + + )} + {status?.lastError && ( + } + > + + {status.lastError} + + + The last successful snapshot remains available. Check source access + and retry the sync. + + + )} + {status && ( + + + + + + + + )} + + Coverage: issues, comments, and available timeline events. Pull requests + are excluded. Attachments are links only; files are not downloaded and + may be unavailable offline. Full reconcile rechecks all issues, + including older changes. + + + + setState(value ?? '')} + allowDeselect={false} + data={[ + { value: '', label: 'All states' }, + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, + ]} + w={160} + /> + + + + )} + + ); +} + +function StatusValue({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function QueryError({ + title, + message, + retry, +}: { + title: string; + message: string; + retry: () => void; +}) { + return ( + }> + + {message} + + + + ); +} + +function IssueList({ + repoId, + state, + search, + latestCommit, + onSelect, +}: { + repoId: string; + state: string; + search: string; + latestCommit: string; + onSelect: (issueId: string) => void; +}) { + const [tokens, setTokens] = useState>(['']); + const list = useQuery( + issueListQueryOptions( + repoId, + state, + search, + tokens.at(-1) ?? '', + latestCommit, + ), + ); + + if (list.isPending) + return ( + + + Loading archived issues… + + ); + if (list.isError) + return ( + void list.refetch()} + /> + ); + + return ( + + {list.data.issues.length === 0 ? ( + + + {search || state + ? 'No matching issues' + : latestCommit + ? 'No issues in the latest backup' + : 'No issues backed up yet'} + + + {search || state + ? 'Try another search or choose all states.' + : latestCommit + ? 'This snapshot contains no issues. Pull requests are not included in issue backups.' + : 'Enable issue backups for this source, then sync issues. Repositories with no issues will remain empty.'} + + + ) : ( + + + + + Issue + State + Updated + + + + {list.data.issues.map((issue) => ( + + + onSelect(issue.id)} + style={{ display: 'block', maxWidth: '100%' }} + > + + {issue.title} + + + #{String(issue.number)} · @ + {issue.authorLogin || 'unknown'} + + + {issue.labels.length > 0 && ( + + {issue.labels.slice(0, 4).map((label) => ( + + {label} + + ))} + {issue.labels.length > 4 && ( + + +{issue.labels.length - 4} + + )} + + )} + + + + + {issueStateLabel(issue.state)} + + {issue.availability && + issue.availability !== 'available' && ( + + {issueStateLabel(issue.availability)} + + )} + + + + + {issueTime(issue.updatedAt)} + + + + ))} + +
+
+ )} + + + {list.data.totalCount.toLocaleString()} issues · Page {tokens.length} + + + + + + +
+ ); +} diff --git a/frontend/src/components/sources/SourceCard.tsx b/frontend/src/components/sources/SourceCard.tsx index b4291ea..6a01fcb 100644 --- a/frontend/src/components/sources/SourceCard.tsx +++ b/frontend/src/components/sources/SourceCard.tsx @@ -99,6 +99,14 @@ export function SourceCard({ {badge.label} ))} + + Issues {source.issuesEnabled ? 'enabled' : 'off'} + {source.onlyIncludeRepos.slice(0, 2).map((repo) => ( { + it('starts disabled on new sources and submits explicit opt-in', async () => { + const onSubmit = vi.fn(); + render( + + {}} + onSubmit={onSubmit} + /> + , + ); + fireEvent.click(screen.getByRole('button', { name: 'GitHub' })); + const toggle = screen.getByRole('switch', { + name: /^Back up GitHub Issues/, + }); + expect(toggle).not.toBeChecked(); + fireEvent.change(screen.getByLabelText('Username', { exact: false }), { + target: { value: 'octocat' }, + }); + fireEvent.change(screen.getByLabelText('Token', { exact: false }), { + target: { value: 'test-token' }, + }); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole('button', { name: 'Create Source' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ issuesEnabled: true }), + }), + ), + ); + }); + + it.each([true, false])( + 'preserves existing opt-in %s when editing unrelated fields', + async (enabled) => { + const onSubmit = vi.fn(); + const source = create(SourceSchema, { + id: 'source-1', + platform: Platform.GITHUB, + name: 'My source', + username: 'octocat', + includeDefaults: true, + issuesEnabled: enabled, + }); + render( + + {}} + onSubmit={onSubmit} + /> + , + ); + const toggle = await screen.findByRole('switch', { + name: /^Back up GitHub Issues/, + }); + expect((toggle as HTMLInputElement).checked).toBe(enabled); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Renamed source' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + sourceId: source.id, + patch: expect.objectContaining({ + name: 'Renamed source', + issuesEnabled: enabled, + }), + }), + ), + ); + }, + ); + + it('can explicitly disable an enabled backup', async () => { + const onSubmit = vi.fn(); + const source = create(SourceSchema, { + id: 'source-1', + platform: Platform.GITHUB, + username: 'octocat', + issuesEnabled: true, + }); + render( + + {}} + onSubmit={onSubmit} + /> + , + ); + fireEvent.click( + await screen.findByRole('switch', { name: /^Back up GitHub Issues/ }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + patch: expect.objectContaining({ issuesEnabled: false }), + }), + ), + ); + }); +}); diff --git a/frontend/src/components/sources/SourceFormDrawer.tsx b/frontend/src/components/sources/SourceFormDrawer.tsx index e6cd708..7b056f0 100644 --- a/frontend/src/components/sources/SourceFormDrawer.tsx +++ b/frontend/src/components/sources/SourceFormDrawer.tsx @@ -38,6 +38,7 @@ interface CreateData { includeDefaults: boolean; includeStarred: boolean; includeWatching: boolean; + issuesEnabled: boolean; }; } @@ -52,6 +53,7 @@ interface UpdateData { includeDefaults: boolean; includeStarred: boolean; includeWatching: boolean; + issuesEnabled: boolean; }; } @@ -73,6 +75,7 @@ interface FormValues { includeDefaults: boolean; includeStarred: boolean; includeWatching: boolean; + issuesEnabled: boolean; } export function SourceFormDrawer({ @@ -96,6 +99,7 @@ export function SourceFormDrawer({ includeDefaults: true, includeStarred: false, includeWatching: false, + issuesEnabled: false, }, validate: { username: (value) => (value.trim() ? null : 'Username is required'), @@ -117,6 +121,7 @@ export function SourceFormDrawer({ includeDefaults: source.includeDefaults, includeStarred: source.includeStarred, includeWatching: source.includeWatching, + issuesEnabled: source.issuesEnabled, }); form.resetDirty(); setAdvancedOpen( @@ -138,6 +143,7 @@ export function SourceFormDrawer({ includeDefaults: true, includeStarred: false, includeWatching: false, + issuesEnabled: false, }); form.reset(); } @@ -161,6 +167,7 @@ export function SourceFormDrawer({ includeDefaults: values.includeDefaults, includeStarred: values.includeStarred, includeWatching: values.includeWatching, + issuesEnabled: values.issuesEnabled, }, }); } else { @@ -175,6 +182,7 @@ export function SourceFormDrawer({ includeDefaults: values.includeDefaults, includeStarred: values.includeStarred, includeWatching: values.includeWatching, + issuesEnabled: values.issuesEnabled, }, }); } @@ -272,6 +280,14 @@ export function SourceFormDrawer({ )} + {platformSelected && ( + + )} + {/* Tier 3: Advanced Options */} {platformSelected && ( diff --git a/frontend/src/lib/connect/client.ts b/frontend/src/lib/connect/client.ts index 1b86b5f..d2e755c 100644 --- a/frontend/src/lib/connect/client.ts +++ b/frontend/src/lib/connect/client.ts @@ -3,6 +3,7 @@ import { apiTransport } from './transport'; import { ConfigService } from '~rpc/gitplus/config/v1/config_pb'; import { CronService } from '~rpc/gitplus/cron/v1/cron_pb'; import { EventService } from '~rpc/gitplus/event/v1/event_pb'; +import { IssueService } from '~rpc/gitplus/issue/v1/issue_pb'; import { RepoService } from '~rpc/gitplus/repo/v1/repo_pb'; import { TaskService } from '~rpc/gitplus/task/v1/task_pb'; @@ -11,3 +12,4 @@ export const cronClient = createClient(CronService, apiTransport); export const repoClient = createClient(RepoService, apiTransport); export const taskClient = createClient(TaskService, apiTransport); export const eventClient = createClient(EventService, apiTransport); +export const issueClient = createClient(IssueService, apiTransport); diff --git a/frontend/src/lib/issue-display.test.ts b/frontend/src/lib/issue-display.test.ts new file mode 100644 index 0000000..c298e41 --- /dev/null +++ b/frontend/src/lib/issue-display.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + issueStateLabel, + issueTime, + jsonString, + parseIssueObject, + parseIssueRecords, + recordAuthor, +} from './issue-display'; + +describe('archived issue display', () => { + it('handles missing and malformed API records without throwing', () => { + expect(parseIssueObject('broken')).toBeUndefined(); + expect(parseIssueObject('[]')).toBeUndefined(); + expect(parseIssueRecords('null')).toBeNull(); + expect(parseIssueRecords('broken')).toBeNull(); + expect(parseIssueRecords('[null,1,{"body":"hello"}]')).toEqual([ + { body: 'hello' }, + ]); + expect(jsonString({ body: 'not a string' })).toBe(''); + }); + + it('preserves original text and JSON including unsafe-looking content', () => { + const body = + ' ![image](https://example.test/image.png)'; + expect(parseIssueObject(JSON.stringify({ body }))?.body).toBe(body); + }); + + it('formats archive states, dates, and author fallbacks', () => { + expect(issueTime('')).toBe('Not recorded'); + expect(issueTime('invalid')).toBe('Not recorded'); + expect(issueStateLabel('not_available')).toBe('Not available'); + expect(issueStateLabel('')).toBe('Not synced'); + expect(recordAuthor({ user: { login: 'octocat' } })).toBe('octocat'); + expect(recordAuthor({ actor: { login: 'maintainer' } })).toBe('maintainer'); + expect(recordAuthor({ user: null })).toBe('Unknown author'); + }); +}); diff --git a/frontend/src/lib/issue-display.ts b/frontend/src/lib/issue-display.ts new file mode 100644 index 0000000..b499c0b --- /dev/null +++ b/frontend/src/lib/issue-display.ts @@ -0,0 +1,49 @@ +import dayjs from 'dayjs'; + +export function issueTime(value: string) { + return value && dayjs(value).isValid() + ? dayjs(value).format('YYYY-MM-DD HH:mm') + : 'Not recorded'; +} + +export function issueStateLabel(value: string) { + const words = value.replace(/[_-]/g, ' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Not synced'; +} + +export function jsonObject( + value: unknown, +): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function jsonString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +export function parseIssueObject(raw: string) { + try { + return jsonObject(JSON.parse(raw)); + } catch { + return undefined; + } +} + +export function parseIssueRecords( + raw: string, +): Array> | null { + try { + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) return null; + return value.map(jsonObject).filter((item) => item !== undefined); + } catch { + return null; + } +} + +export function recordAuthor(record: Record) { + const user = jsonObject(record.user) ?? jsonObject(record.actor); + return jsonString(user?.login) || 'Unknown author'; +} diff --git a/frontend/src/lib/issue-download.test.ts b/frontend/src/lib/issue-download.test.ts new file mode 100644 index 0000000..aea3a4a --- /dev/null +++ b/frontend/src/lib/issue-download.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { downloadIssueSnapshot, issueExportUrl } from './issue-download'; +import { apiFetch } from './connect/transport'; + +vi.mock('./connect/transport', () => ({ apiFetch: vi.fn() })); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.mocked(apiFetch).mockReset(); +}); + +describe('issue snapshot export', () => { + it('encodes the snapshot without putting credentials in the URL', () => { + expect(issueExportUrl('42', '')).toBe('/api/repos/42/issues/export'); + expect(issueExportUrl('42', 'a/b')).toBe( + '/api/repos/42/issues/export?snapshot=a%2Fb', + ); + }); + + it('uses the shared authenticated fetch and downloads the returned ZIP', async () => { + vi.mocked(apiFetch).mockResolvedValue(new Response('zip contents')); + const anchor = { + href: '', + download: '', + style: { display: '' }, + click: vi.fn(), + remove: vi.fn(), + }; + const append = vi.fn(); + vi.stubGlobal('document', { + createElement: vi.fn(() => anchor), + body: { append }, + }); + const createUrl = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:test'); + const revokeUrl = vi + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}); + + await downloadIssueSnapshot('42', 'abc123'); + + expect(apiFetch).toHaveBeenCalledWith( + '/api/repos/42/issues/export?snapshot=abc123', + ); + expect(createUrl).toHaveBeenCalledWith(expect.any(Blob)); + expect(anchor.download).toBe('issues-42-abc123.zip'); + expect(anchor.click).toHaveBeenCalledOnce(); + expect(anchor.remove).toHaveBeenCalledOnce(); + expect(revokeUrl).toHaveBeenCalledWith('blob:test'); + }); + + it('surfaces HTTP errors and does not try to download an error response', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response('Authentication required', { status: 401 }), + ); + await expect(downloadIssueSnapshot('42', '')).rejects.toThrow( + 'Authentication required', + ); + }); +}); diff --git a/frontend/src/lib/issue-download.ts b/frontend/src/lib/issue-download.ts new file mode 100644 index 0000000..a1b49fb --- /dev/null +++ b/frontend/src/lib/issue-download.ts @@ -0,0 +1,28 @@ +import { apiFetch } from './connect/transport'; + +export function issueExportUrl(repoId: string, snapshot: string) { + const query = snapshot ? `?${new URLSearchParams({ snapshot })}` : ''; + return `/api/repos/${encodeURIComponent(repoId)}/issues/export${query}`; +} + +export async function downloadIssueSnapshot(repoId: string, snapshot: string) { + const response = await apiFetch(issueExportUrl(repoId, snapshot)); + if (!response.ok) { + const message = await response.text(); + throw new Error(message.trim() || 'Failed to export issue backup'); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = `issues-${repoId}-${snapshot ? snapshot.slice(0, 12) : 'latest'}.zip`; + anchor.style.display = 'none'; + document.body.append(anchor); + try { + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(objectUrl); + } +} diff --git a/frontend/src/lib/issue-queries.ts b/frontend/src/lib/issue-queries.ts new file mode 100644 index 0000000..9f55cf5 --- /dev/null +++ b/frontend/src/lib/issue-queries.ts @@ -0,0 +1,60 @@ +import { queryOptions } from '@tanstack/react-query'; +import { issueClient } from './connect/client'; + +export function issueStatusQueryOptions(repoId: string) { + return queryOptions({ + queryKey: ['issues', repoId, 'status'], + queryFn: ({ signal }) => + issueClient.getStatus({ repoId: BigInt(repoId) }, { signal }), + refetchInterval: 5_000, + }); +} + +export function issueListQueryOptions( + repoId: string, + state: string, + search: string, + pageToken: string, + latestCommit: string, +) { + return queryOptions({ + queryKey: [ + 'issues', + repoId, + 'list', + { state, search, pageToken, latestCommit }, + ], + queryFn: ({ signal }) => + issueClient.listIssues( + { repoId: BigInt(repoId), pageSize: 30, pageToken, state, search }, + { signal }, + ), + }); +} + +export function issueDetailQueryOptions( + repoId: string, + issueId: string, + snapshot: string, + latestCommit: string, +) { + return queryOptions({ + queryKey: ['issues', repoId, 'detail', { issueId, snapshot, latestCommit }], + queryFn: ({ signal }) => + issueClient.getIssue( + { repoId: BigInt(repoId), issueId, snapshot }, + { signal }, + ), + }); +} + +export function issueSnapshotsQueryOptions( + repoId: string, + latestCommit: string, +) { + return queryOptions({ + queryKey: ['issues', repoId, 'snapshots', latestCommit], + queryFn: ({ signal }) => + issueClient.listSnapshots({ repoId: BigInt(repoId) }, { signal }), + }); +} diff --git a/frontend/src/routes/_dashboard/config/sources.tsx b/frontend/src/routes/_dashboard/config/sources.tsx index d931d9a..339b628 100644 --- a/frontend/src/routes/_dashboard/config/sources.tsx +++ b/frontend/src/routes/_dashboard/config/sources.tsx @@ -64,6 +64,7 @@ function SourcesPage() { includeDefaults: boolean; includeStarred: boolean; includeWatching: boolean; + issuesEnabled: boolean; }; }) => configClient.createSource(input), onSuccess: () => { @@ -86,6 +87,7 @@ function SourcesPage() { includeDefaults: boolean; includeStarred: boolean; includeWatching: boolean; + issuesEnabled: boolean; }; }) => configClient.updateSource(input), onSuccess: () => { diff --git a/frontend/src/routes/_dashboard/repos.$repoId.tsx b/frontend/src/routes/_dashboard/repos.$repoId.tsx index 4941533..6b79ee2 100644 --- a/frontend/src/routes/_dashboard/repos.$repoId.tsx +++ b/frontend/src/routes/_dashboard/repos.$repoId.tsx @@ -43,6 +43,7 @@ import { IconFileZip, IconGitBranch, IconHistory, + IconMessageCircle, IconPackage, IconTag, IconX, @@ -69,6 +70,7 @@ import { repoRefsQueryOptions, } from '~lib/repo-queries'; import { sourcePrimaryLabel, sourceSecondaryLabel } from '~lib/source-display'; +import { IssuesTab } from '~components/issues/IssuesTab'; export const Route = createFileRoute('/_dashboard/repos/$repoId')({ loader: ({ context: { queryClient }, params: { repoId } }) => @@ -324,6 +326,12 @@ function RepoDetailPage() { }> Changes + } + > + Issues + }> Download @@ -369,6 +377,9 @@ function RepoDetailPage() { /> )} + + {activeTab === 'issues' && } + ); diff --git a/frontend/src/rpc/gitplus/config/v1/config_pb.ts b/frontend/src/rpc/gitplus/config/v1/config_pb.ts index bdd9d18..8b3970e 100644 --- a/frontend/src/rpc/gitplus/config/v1/config_pb.ts +++ b/frontend/src/rpc/gitplus/config/v1/config_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file gitplus/config/v1/config.proto. */ export const file_gitplus_config_v1_config: GenFile = /*@__PURE__*/ - fileDesc("Ch5naXRwbHVzL2NvbmZpZy92MS9jb25maWcucHJvdG8SEWdpdHBsdXMuY29uZmlnLnYxIoECCg9WYWxpZGF0aW9uSXNzdWUSPQoIc2V2ZXJpdHkYASABKA4yKy5naXRwbHVzLmNvbmZpZy52MS5WYWxpZGF0aW9uSXNzdWUuU2V2ZXJpdHkSDAoEY29kZRgCIAEoCRIPCgdtZXNzYWdlGAMgASgJEgwKBHBhdGgYBCABKAkSEQoJc291cmNlX2lkGAUgASgJEgwKBGxpbmUYBiABKAUiYQoIU2V2ZXJpdHkSGAoUU0VWRVJJVFlfVU5TUEVDSUZJRUQQABISCg5TRVZFUklUWV9FUlJPUhABEhQKEFNFVkVSSVRZX1dBUk5JTkcQAhIRCg1TRVZFUklUWV9JTkZPEAMiPAoMSXNzdWVTdW1tYXJ5Eg0KBWVycm9yGAEgASgFEg8KB3dhcm5pbmcYAiABKAUSDAoEaW5mbxgDIAEoBSLIAgoGU291cmNlEhMKAmlkGAEgASgJQge6SARyAhABEjkKCHBsYXRmb3JtGAIgASgOMhsuZ2l0cGx1cy5jb25maWcudjEuUGxhdGZvcm1CCrpIB4IBBBABIAASGQoIdXNlcm5hbWUYAyABKAlCB7pIBHICEAESJQoFdG9rZW4YBCABKAlCFrpIE3IREAE6DSRlbmNyeXB0ZWQkMSQSKgoSb25seV9pbmNsdWRlX3JlcG9zGAUgAygJQg66SAuSAQgYASIEcgIQARIlCg1leGNsdWRlX3JlcG9zGAYgAygJQg66SAuSAQgYASIEcgIQARIYChBpbmNsdWRlX2RlZmF1bHRzGAcgASgIEhcKD2luY2x1ZGVfc3RhcnJlZBgIIAEoCBIYChBpbmNsdWRlX3dhdGNoaW5nGAkgASgIEgwKBG5hbWUYCiABKAkiigEKDkNvbmZpZ1NuYXBzaG90EhwKC2NvbmN1cnJlbmN5GAEgASgFQge6SAQaAiAAEioKB3NvdXJjZXMYAiADKAsyGS5naXRwbHVzLmNvbmZpZy52MS5Tb3VyY2USIAoPbWF4X3JldHJ5X3RpbWVzGAMgASgFQge6SAQaAigAEgwKBGNyb24YBCABKAkiMQoPU3RyaW5nTGlzdFZhbHVlEh4KBnZhbHVlcxgBIAMoCUIOukgLkgEIGAEiBHICEAEivwIKEUNyZWF0ZVNvdXJjZUlucHV0EjkKCHBsYXRmb3JtGAIgASgOMhsuZ2l0cGx1cy5jb25maWcudjEuUGxhdGZvcm1CCrpIB4IBBBABIAASGQoIdXNlcm5hbWUYAyABKAlCB7pIBHICEAESIAoPdG9rZW5fcGxhaW50ZXh0GAQgASgJQge6SARyAhABEioKEm9ubHlfaW5jbHVkZV9yZXBvcxgFIAMoCUIOukgLkgEIGAEiBHICEAESJQoNZXhjbHVkZV9yZXBvcxgGIAMoCUIOukgLkgEIGAEiBHICEAESGAoQaW5jbHVkZV9kZWZhdWx0cxgHIAEoCBIXCg9pbmNsdWRlX3N0YXJyZWQYCCABKAgSGAoQaW5jbHVkZV93YXRjaGluZxgJIAEoCBIMCgRuYW1lGAogASgJSgQIARACIsUCChFVcGRhdGVTb3VyY2VQYXRjaBI5CghwbGF0Zm9ybRgCIAEoDjIbLmdpdHBsdXMuY29uZmlnLnYxLlBsYXRmb3JtQgq6SAeCAQQQASAAEhkKCHVzZXJuYW1lGAMgASgJQge6SARyAhABEj4KEm9ubHlfaW5jbHVkZV9yZXBvcxgEIAEoCzIiLmdpdHBsdXMuY29uZmlnLnYxLlN0cmluZ0xpc3RWYWx1ZRI5Cg1leGNsdWRlX3JlcG9zGAUgASgLMiIuZ2l0cGx1cy5jb25maWcudjEuU3RyaW5nTGlzdFZhbHVlEhgKEGluY2x1ZGVfZGVmYXVsdHMYBiABKAgSFwoPaW5jbHVkZV9zdGFycmVkGAcgASgIEhgKEGluY2x1ZGVfd2F0Y2hpbmcYCCABKAgSDAoEbmFtZRgJIAEoCUoECAEQAiIUChJDaGVja0NvbmZpZ1JlcXVlc3QiewoTQ2hlY2tDb25maWdSZXNwb25zZRIyCgZpc3N1ZXMYASADKAsyIi5naXRwbHVzLmNvbmZpZy52MS5WYWxpZGF0aW9uSXNzdWUSMAoHc3VtbWFyeRgCIAEoCzIfLmdpdHBsdXMuY29uZmlnLnYxLklzc3VlU3VtbWFyeSItChhDaGVja1NvdXJjZUNvbmZpZ1JlcXVlc3QSEQoJc291cmNlX2lkGAEgASgJIqQBChlDaGVja1NvdXJjZUNvbmZpZ1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBIRCglzb3VyY2VfaWQYAiABKAkSMgoGaXNzdWVzGAMgAygLMiIuZ2l0cGx1cy5jb25maWcudjEuVmFsaWRhdGlvbklzc3VlEjAKB3N1bW1hcnkYBCABKAsyHy5naXRwbHVzLmNvbmZpZy52MS5Jc3N1ZVN1bW1hcnkiEgoQR2V0Q29uZmlnUmVxdWVzdCJWChFHZXRDb25maWdSZXNwb25zZRIOCgZleGlzdHMYASABKAgSMQoGY29uZmlnGAIgASgLMiEuZ2l0cGx1cy5jb25maWcudjEuQ29uZmlnU25hcHNob3QiVQoTVXBkYXRlQ29uZmlnUmVxdWVzdBIcCgtjb25jdXJyZW5jeRgBIAEoBUIHukgEGgIgABIgCg9tYXhfcmV0cnlfdGltZXMYAiABKAVCB7pIBBoCKAAiSQoUVXBkYXRlQ29uZmlnUmVzcG9uc2USMQoGY29uZmlnGAEgASgLMiEuZ2l0cGx1cy5jb25maWcudjEuQ29uZmlnU25hcHNob3QiUwoTQ3JlYXRlU291cmNlUmVxdWVzdBI8CgZzb3VyY2UYASABKAsyJC5naXRwbHVzLmNvbmZpZy52MS5DcmVhdGVTb3VyY2VJbnB1dEIGukgDyAEBIkkKFENyZWF0ZVNvdXJjZVJlc3BvbnNlEjEKBmNvbmZpZxgBIAEoCzIhLmdpdHBsdXMuY29uZmlnLnYxLkNvbmZpZ1NuYXBzaG90Im4KE1VwZGF0ZVNvdXJjZVJlcXVlc3QSGgoJc291cmNlX2lkGAEgASgJQge6SARyAhABEjsKBXBhdGNoGAIgASgLMiQuZ2l0cGx1cy5jb25maWcudjEuVXBkYXRlU291cmNlUGF0Y2hCBrpIA8gBASJJChRVcGRhdGVTb3VyY2VSZXNwb25zZRIxCgZjb25maWcYASABKAsyIS5naXRwbHVzLmNvbmZpZy52MS5Db25maWdTbmFwc2hvdCJZChlSZXBsYWNlU291cmNlVG9rZW5SZXF1ZXN0EhoKCXNvdXJjZV9pZBgBIAEoCUIHukgEcgIQARIgCg90b2tlbl9wbGFpbnRleHQYAiABKAlCB7pIBHICEAEiRwoaUmVwbGFjZVNvdXJjZVRva2VuUmVzcG9uc2USKQoGc291cmNlGAEgASgLMhkuZ2l0cGx1cy5jb25maWcudjEuU291cmNlIjEKE0RlbGV0ZVNvdXJjZVJlcXVlc3QSGgoJc291cmNlX2lkGAEgASgJQge6SARyAhABIkkKFERlbGV0ZVNvdXJjZVJlc3BvbnNlEjEKBmNvbmZpZxgBIAEoCzIhLmdpdHBsdXMuY29uZmlnLnYxLkNvbmZpZ1NuYXBzaG90Ig0KC1BpbmdSZXF1ZXN0Ig4KDFBpbmdSZXNwb25zZSo5CghQbGF0Zm9ybRIYChRQTEFURk9STV9VTlNQRUNJRklFRBAAEhMKD1BMQVRGT1JNX0dJVEhVQhABMvUGCg1Db25maWdTZXJ2aWNlEkcKBFBpbmcSHi5naXRwbHVzLmNvbmZpZy52MS5QaW5nUmVxdWVzdBofLmdpdHBsdXMuY29uZmlnLnYxLlBpbmdSZXNwb25zZRJcCgtDaGVja0NvbmZpZxIlLmdpdHBsdXMuY29uZmlnLnYxLkNoZWNrQ29uZmlnUmVxdWVzdBomLmdpdHBsdXMuY29uZmlnLnYxLkNoZWNrQ29uZmlnUmVzcG9uc2USbgoRQ2hlY2tTb3VyY2VDb25maWcSKy5naXRwbHVzLmNvbmZpZy52MS5DaGVja1NvdXJjZUNvbmZpZ1JlcXVlc3QaLC5naXRwbHVzLmNvbmZpZy52MS5DaGVja1NvdXJjZUNvbmZpZ1Jlc3BvbnNlElYKCUdldENvbmZpZxIjLmdpdHBsdXMuY29uZmlnLnYxLkdldENvbmZpZ1JlcXVlc3QaJC5naXRwbHVzLmNvbmZpZy52MS5HZXRDb25maWdSZXNwb25zZRJfCgxVcGRhdGVDb25maWcSJi5naXRwbHVzLmNvbmZpZy52MS5VcGRhdGVDb25maWdSZXF1ZXN0GicuZ2l0cGx1cy5jb25maWcudjEuVXBkYXRlQ29uZmlnUmVzcG9uc2USXwoMQ3JlYXRlU291cmNlEiYuZ2l0cGx1cy5jb25maWcudjEuQ3JlYXRlU291cmNlUmVxdWVzdBonLmdpdHBsdXMuY29uZmlnLnYxLkNyZWF0ZVNvdXJjZVJlc3BvbnNlEl8KDFVwZGF0ZVNvdXJjZRImLmdpdHBsdXMuY29uZmlnLnYxLlVwZGF0ZVNvdXJjZVJlcXVlc3QaJy5naXRwbHVzLmNvbmZpZy52MS5VcGRhdGVTb3VyY2VSZXNwb25zZRJxChJSZXBsYWNlU291cmNlVG9rZW4SLC5naXRwbHVzLmNvbmZpZy52MS5SZXBsYWNlU291cmNlVG9rZW5SZXF1ZXN0Gi0uZ2l0cGx1cy5jb25maWcudjEuUmVwbGFjZVNvdXJjZVRva2VuUmVzcG9uc2USXwoMRGVsZXRlU291cmNlEiYuZ2l0cGx1cy5jb25maWcudjEuRGVsZXRlU291cmNlUmVxdWVzdBonLmdpdHBsdXMuY29uZmlnLnYxLkRlbGV0ZVNvdXJjZVJlc3BvbnNlQtUBChVjb20uZ2l0cGx1cy5jb25maWcudjFCC0NvbmZpZ1Byb3RvUAFaP2dpdGh1Yi5jb20vSW1TaW5nZWUvZ2l0LXBsdXMvcGtnL3JwYy9naXRwbHVzL2NvbmZpZy92MTtjb25maWd2MaICA0dDWKoCEUdpdHBsdXMuQ29uZmlnLlYxygIRR2l0cGx1c1xDb25maWdcVjHiAh1HaXRwbHVzXENvbmZpZ1xWMVxHUEJNZXRhZGF0YeoCE0dpdHBsdXM6OkNvbmZpZzo6VjGSAwcIAdI+AhABYghlZGl0aW9uc3DoBw", [file_buf_validate_validate, file_google_protobuf_go_features]); + fileDesc("Ch5naXRwbHVzL2NvbmZpZy92MS9jb25maWcucHJvdG8SEWdpdHBsdXMuY29uZmlnLnYxIoECCg9WYWxpZGF0aW9uSXNzdWUSPQoIc2V2ZXJpdHkYASABKA4yKy5naXRwbHVzLmNvbmZpZy52MS5WYWxpZGF0aW9uSXNzdWUuU2V2ZXJpdHkSDAoEY29kZRgCIAEoCRIPCgdtZXNzYWdlGAMgASgJEgwKBHBhdGgYBCABKAkSEQoJc291cmNlX2lkGAUgASgJEgwKBGxpbmUYBiABKAUiYQoIU2V2ZXJpdHkSGAoUU0VWRVJJVFlfVU5TUEVDSUZJRUQQABISCg5TRVZFUklUWV9FUlJPUhABEhQKEFNFVkVSSVRZX1dBUk5JTkcQAhIRCg1TRVZFUklUWV9JTkZPEAMiPAoMSXNzdWVTdW1tYXJ5Eg0KBWVycm9yGAEgASgFEg8KB3dhcm5pbmcYAiABKAUSDAoEaW5mbxgDIAEoBSLgAgoGU291cmNlEhMKAmlkGAEgASgJQge6SARyAhABEjkKCHBsYXRmb3JtGAIgASgOMhsuZ2l0cGx1cy5jb25maWcudjEuUGxhdGZvcm1CCrpIB4IBBBABIAASGQoIdXNlcm5hbWUYAyABKAlCB7pIBHICEAESJQoFdG9rZW4YBCABKAlCFrpIE3IREAE6DSRlbmNyeXB0ZWQkMSQSKgoSb25seV9pbmNsdWRlX3JlcG9zGAUgAygJQg66SAuSAQgYASIEcgIQARIlCg1leGNsdWRlX3JlcG9zGAYgAygJQg66SAuSAQgYASIEcgIQARIYChBpbmNsdWRlX2RlZmF1bHRzGAcgASgIEhcKD2luY2x1ZGVfc3RhcnJlZBgIIAEoCBIYChBpbmNsdWRlX3dhdGNoaW5nGAkgASgIEgwKBG5hbWUYCiABKAkSFgoOaXNzdWVzX2VuYWJsZWQYCyABKAgiigEKDkNvbmZpZ1NuYXBzaG90EhwKC2NvbmN1cnJlbmN5GAEgASgFQge6SAQaAiAAEioKB3NvdXJjZXMYAiADKAsyGS5naXRwbHVzLmNvbmZpZy52MS5Tb3VyY2USIAoPbWF4X3JldHJ5X3RpbWVzGAMgASgFQge6SAQaAigAEgwKBGNyb24YBCABKAkiMQoPU3RyaW5nTGlzdFZhbHVlEh4KBnZhbHVlcxgBIAMoCUIOukgLkgEIGAEiBHICEAEi1wIKEUNyZWF0ZVNvdXJjZUlucHV0EjkKCHBsYXRmb3JtGAIgASgOMhsuZ2l0cGx1cy5jb25maWcudjEuUGxhdGZvcm1CCrpIB4IBBBABIAASGQoIdXNlcm5hbWUYAyABKAlCB7pIBHICEAESIAoPdG9rZW5fcGxhaW50ZXh0GAQgASgJQge6SARyAhABEioKEm9ubHlfaW5jbHVkZV9yZXBvcxgFIAMoCUIOukgLkgEIGAEiBHICEAESJQoNZXhjbHVkZV9yZXBvcxgGIAMoCUIOukgLkgEIGAEiBHICEAESGAoQaW5jbHVkZV9kZWZhdWx0cxgHIAEoCBIXCg9pbmNsdWRlX3N0YXJyZWQYCCABKAgSGAoQaW5jbHVkZV93YXRjaGluZxgJIAEoCBIMCgRuYW1lGAogASgJEhYKDmlzc3Vlc19lbmFibGVkGAsgASgISgQIARACIt0CChFVcGRhdGVTb3VyY2VQYXRjaBI5CghwbGF0Zm9ybRgCIAEoDjIbLmdpdHBsdXMuY29uZmlnLnYxLlBsYXRmb3JtQgq6SAeCAQQQASAAEhkKCHVzZXJuYW1lGAMgASgJQge6SARyAhABEj4KEm9ubHlfaW5jbHVkZV9yZXBvcxgEIAEoCzIiLmdpdHBsdXMuY29uZmlnLnYxLlN0cmluZ0xpc3RWYWx1ZRI5Cg1leGNsdWRlX3JlcG9zGAUgASgLMiIuZ2l0cGx1cy5jb25maWcudjEuU3RyaW5nTGlzdFZhbHVlEhgKEGluY2x1ZGVfZGVmYXVsdHMYBiABKAgSFwoPaW5jbHVkZV9zdGFycmVkGAcgASgIEhgKEGluY2x1ZGVfd2F0Y2hpbmcYCCABKAgSDAoEbmFtZRgJIAEoCRIWCg5pc3N1ZXNfZW5hYmxlZBgKIAEoCEoECAEQAiIUChJDaGVja0NvbmZpZ1JlcXVlc3QiewoTQ2hlY2tDb25maWdSZXNwb25zZRIyCgZpc3N1ZXMYASADKAsyIi5naXRwbHVzLmNvbmZpZy52MS5WYWxpZGF0aW9uSXNzdWUSMAoHc3VtbWFyeRgCIAEoCzIfLmdpdHBsdXMuY29uZmlnLnYxLklzc3VlU3VtbWFyeSItChhDaGVja1NvdXJjZUNvbmZpZ1JlcXVlc3QSEQoJc291cmNlX2lkGAEgASgJIqQBChlDaGVja1NvdXJjZUNvbmZpZ1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBIRCglzb3VyY2VfaWQYAiABKAkSMgoGaXNzdWVzGAMgAygLMiIuZ2l0cGx1cy5jb25maWcudjEuVmFsaWRhdGlvbklzc3VlEjAKB3N1bW1hcnkYBCABKAsyHy5naXRwbHVzLmNvbmZpZy52MS5Jc3N1ZVN1bW1hcnkiEgoQR2V0Q29uZmlnUmVxdWVzdCJWChFHZXRDb25maWdSZXNwb25zZRIOCgZleGlzdHMYASABKAgSMQoGY29uZmlnGAIgASgLMiEuZ2l0cGx1cy5jb25maWcudjEuQ29uZmlnU25hcHNob3QiVQoTVXBkYXRlQ29uZmlnUmVxdWVzdBIcCgtjb25jdXJyZW5jeRgBIAEoBUIHukgEGgIgABIgCg9tYXhfcmV0cnlfdGltZXMYAiABKAVCB7pIBBoCKAAiSQoUVXBkYXRlQ29uZmlnUmVzcG9uc2USMQoGY29uZmlnGAEgASgLMiEuZ2l0cGx1cy5jb25maWcudjEuQ29uZmlnU25hcHNob3QiUwoTQ3JlYXRlU291cmNlUmVxdWVzdBI8CgZzb3VyY2UYASABKAsyJC5naXRwbHVzLmNvbmZpZy52MS5DcmVhdGVTb3VyY2VJbnB1dEIGukgDyAEBIkkKFENyZWF0ZVNvdXJjZVJlc3BvbnNlEjEKBmNvbmZpZxgBIAEoCzIhLmdpdHBsdXMuY29uZmlnLnYxLkNvbmZpZ1NuYXBzaG90Im4KE1VwZGF0ZVNvdXJjZVJlcXVlc3QSGgoJc291cmNlX2lkGAEgASgJQge6SARyAhABEjsKBXBhdGNoGAIgASgLMiQuZ2l0cGx1cy5jb25maWcudjEuVXBkYXRlU291cmNlUGF0Y2hCBrpIA8gBASJJChRVcGRhdGVTb3VyY2VSZXNwb25zZRIxCgZjb25maWcYASABKAsyIS5naXRwbHVzLmNvbmZpZy52MS5Db25maWdTbmFwc2hvdCJZChlSZXBsYWNlU291cmNlVG9rZW5SZXF1ZXN0EhoKCXNvdXJjZV9pZBgBIAEoCUIHukgEcgIQARIgCg90b2tlbl9wbGFpbnRleHQYAiABKAlCB7pIBHICEAEiRwoaUmVwbGFjZVNvdXJjZVRva2VuUmVzcG9uc2USKQoGc291cmNlGAEgASgLMhkuZ2l0cGx1cy5jb25maWcudjEuU291cmNlIjEKE0RlbGV0ZVNvdXJjZVJlcXVlc3QSGgoJc291cmNlX2lkGAEgASgJQge6SARyAhABIkkKFERlbGV0ZVNvdXJjZVJlc3BvbnNlEjEKBmNvbmZpZxgBIAEoCzIhLmdpdHBsdXMuY29uZmlnLnYxLkNvbmZpZ1NuYXBzaG90Ig0KC1BpbmdSZXF1ZXN0Ig4KDFBpbmdSZXNwb25zZSo5CghQbGF0Zm9ybRIYChRQTEFURk9STV9VTlNQRUNJRklFRBAAEhMKD1BMQVRGT1JNX0dJVEhVQhABMvUGCg1Db25maWdTZXJ2aWNlEkcKBFBpbmcSHi5naXRwbHVzLmNvbmZpZy52MS5QaW5nUmVxdWVzdBofLmdpdHBsdXMuY29uZmlnLnYxLlBpbmdSZXNwb25zZRJcCgtDaGVja0NvbmZpZxIlLmdpdHBsdXMuY29uZmlnLnYxLkNoZWNrQ29uZmlnUmVxdWVzdBomLmdpdHBsdXMuY29uZmlnLnYxLkNoZWNrQ29uZmlnUmVzcG9uc2USbgoRQ2hlY2tTb3VyY2VDb25maWcSKy5naXRwbHVzLmNvbmZpZy52MS5DaGVja1NvdXJjZUNvbmZpZ1JlcXVlc3QaLC5naXRwbHVzLmNvbmZpZy52MS5DaGVja1NvdXJjZUNvbmZpZ1Jlc3BvbnNlElYKCUdldENvbmZpZxIjLmdpdHBsdXMuY29uZmlnLnYxLkdldENvbmZpZ1JlcXVlc3QaJC5naXRwbHVzLmNvbmZpZy52MS5HZXRDb25maWdSZXNwb25zZRJfCgxVcGRhdGVDb25maWcSJi5naXRwbHVzLmNvbmZpZy52MS5VcGRhdGVDb25maWdSZXF1ZXN0GicuZ2l0cGx1cy5jb25maWcudjEuVXBkYXRlQ29uZmlnUmVzcG9uc2USXwoMQ3JlYXRlU291cmNlEiYuZ2l0cGx1cy5jb25maWcudjEuQ3JlYXRlU291cmNlUmVxdWVzdBonLmdpdHBsdXMuY29uZmlnLnYxLkNyZWF0ZVNvdXJjZVJlc3BvbnNlEl8KDFVwZGF0ZVNvdXJjZRImLmdpdHBsdXMuY29uZmlnLnYxLlVwZGF0ZVNvdXJjZVJlcXVlc3QaJy5naXRwbHVzLmNvbmZpZy52MS5VcGRhdGVTb3VyY2VSZXNwb25zZRJxChJSZXBsYWNlU291cmNlVG9rZW4SLC5naXRwbHVzLmNvbmZpZy52MS5SZXBsYWNlU291cmNlVG9rZW5SZXF1ZXN0Gi0uZ2l0cGx1cy5jb25maWcudjEuUmVwbGFjZVNvdXJjZVRva2VuUmVzcG9uc2USXwoMRGVsZXRlU291cmNlEiYuZ2l0cGx1cy5jb25maWcudjEuRGVsZXRlU291cmNlUmVxdWVzdBonLmdpdHBsdXMuY29uZmlnLnYxLkRlbGV0ZVNvdXJjZVJlc3BvbnNlQtUBChVjb20uZ2l0cGx1cy5jb25maWcudjFCC0NvbmZpZ1Byb3RvUAFaP2dpdGh1Yi5jb20vSW1TaW5nZWUvZ2l0LXBsdXMvcGtnL3JwYy9naXRwbHVzL2NvbmZpZy92MTtjb25maWd2MaICA0dDWKoCEUdpdHBsdXMuQ29uZmlnLlYxygIRR2l0cGx1c1xDb25maWdcVjHiAh1HaXRwbHVzXENvbmZpZ1xWMVxHUEJNZXRhZGF0YeoCE0dpdHBsdXM6OkNvbmZpZzo6VjGSAwcIAdI+AhABYghlZGl0aW9uc3DoBw", [file_buf_validate_validate, file_google_protobuf_go_features]); /** * @generated from message gitplus.config.v1.ValidationIssue @@ -168,6 +168,11 @@ export type Source = Message<"gitplus.config.v1.Source"> & { * @generated from field: string name = 10; */ name: string; + + /** + * @generated from field: bool issues_enabled = 11; + */ + issuesEnabled: boolean; }; /** @@ -274,6 +279,11 @@ export type CreateSourceInput = Message<"gitplus.config.v1.CreateSourceInput"> & * @generated from field: string name = 10; */ name: string; + + /** + * @generated from field: bool issues_enabled = 11; + */ + issuesEnabled: boolean; }; /** @@ -326,6 +336,11 @@ export type UpdateSourcePatch = Message<"gitplus.config.v1.UpdateSourcePatch"> & * @generated from field: string name = 9; */ name: string; + + /** + * @generated from field: bool issues_enabled = 10; + */ + issuesEnabled: boolean; }; /** diff --git a/frontend/src/rpc/gitplus/issue/v1/issue_pb.ts b/frontend/src/rpc/gitplus/issue/v1/issue_pb.ts new file mode 100644 index 0000000..1426dbf --- /dev/null +++ b/frontend/src/rpc/gitplus/issue/v1/issue_pb.ts @@ -0,0 +1,452 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file gitplus/issue/v1/issue.proto (package gitplus.issue.v1, edition 2023) +// option features.field_presence = EXPLICIT; +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Task, TaskEnqueueResult } from "../../task/v1/task_pb"; +import { file_gitplus_task_v1_task } from "../../task/v1/task_pb"; +import { file_google_protobuf_go_features } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file gitplus/issue/v1/issue.proto. + */ +export const file_gitplus_issue_v1_issue: GenFile = /*@__PURE__*/ + fileDesc("ChxnaXRwbHVzL2lzc3VlL3YxL2lzc3VlLnByb3RvEhBnaXRwbHVzLmlzc3VlLnYxIqsBCgxJc3N1ZVN1bW1hcnkSCgoCaWQYASABKAkSDgoGbnVtYmVyGAIgASgDEg0KBXRpdGxlGAMgASgJEg0KBXN0YXRlGAQgASgJEhQKDGF1dGhvcl9sb2dpbhgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEhQKDGF2YWlsYWJpbGl0eRgHIAEoCRIOCgZsYWJlbHMYCCADKAkSEQoJYXNzaWduZWVzGAkgAygJIsYBChFJc3N1ZUJhY2t1cFN0YXR1cxIPCgdlbmFibGVkGAEgASgIEg0KBXN0YXRlGAIgASgJEhoKEmxhc3Rfc3VjY2Vzc2Z1bF9hdBgDIAEoCRIeChZsYXN0X2Z1bGxfcmVjb25jaWxlX2F0GAQgASgJEhIKCmxhc3RfZXJyb3IYBSABKAkSFQoNbGF0ZXN0X2NvbW1pdBgGIAEoCRITCgtpc3N1ZV9jb3VudBgHIAEoBRIVCg1jb21tZW50X2NvdW50GAggASgFIl8KDUlzc3VlU25hcHNob3QSDgoGY29tbWl0GAEgASgJEhIKCmNyZWF0ZWRfYXQYAiABKAkSEwoLaXNzdWVfY291bnQYAyABKAUSFQoNY29tbWVudF9jb3VudBgEIAEoBSIsChBHZXRTdGF0dXNSZXF1ZXN0EhgKB3JlcG9faWQYASABKANCB7pIBCICIAAiSAoRR2V0U3RhdHVzUmVzcG9uc2USMwoGc3RhdHVzGAEgASgLMiMuZ2l0cGx1cy5pc3N1ZS52MS5Jc3N1ZUJhY2t1cFN0YXR1cyJzChFMaXN0SXNzdWVzUmVxdWVzdBIYCgdyZXBvX2lkGAEgASgDQge6SAQiAiAAEhEKCXBhZ2Vfc2l6ZRgCIAEoBRISCgpwYWdlX3Rva2VuGAMgASgJEg0KBXN0YXRlGAQgASgJEg4KBnNlYXJjaBgFIAEoCSJyChJMaXN0SXNzdWVzUmVzcG9uc2USLgoGaXNzdWVzGAEgAygLMh4uZ2l0cGx1cy5pc3N1ZS52MS5Jc3N1ZVN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFIlgKD0dldElzc3VlUmVxdWVzdBIYCgdyZXBvX2lkGAEgASgDQge6SAQiAiAAEhkKCGlzc3VlX2lkGAIgASgJQge6SARyAhABEhAKCHNuYXBzaG90GAMgASgJIqUBChBHZXRJc3N1ZVJlc3BvbnNlEi0KBWlzc3VlGAEgASgLMh4uZ2l0cGx1cy5pc3N1ZS52MS5Jc3N1ZVN1bW1hcnkSEAoIcmF3X2pzb24YAiABKAkSFQoNY29tbWVudHNfanNvbhgDIAEoCRIVCg10aW1lbGluZV9qc29uGAQgASgJEhAKCG1hcmtkb3duGAUgASgJEhAKCHNuYXBzaG90GAYgASgJIjAKFExpc3RTbmFwc2hvdHNSZXF1ZXN0EhgKB3JlcG9faWQYASABKANCB7pIBCICIAAiSwoVTGlzdFNuYXBzaG90c1Jlc3BvbnNlEjIKCXNuYXBzaG90cxgBIAMoCzIfLmdpdHBsdXMuaXNzdWUudjEuSXNzdWVTbmFwc2hvdCI8ChJFbnF1ZXVlU3luY1JlcXVlc3QSGAoHcmVwb19pZBgBIAEoA0IHukgEIgIgABIMCgRmdWxsGAIgASgIIm4KE0VucXVldWVTeW5jUmVzcG9uc2USIwoEdGFzaxgBIAEoCzIVLmdpdHBsdXMudGFzay52MS5UYXNrEjIKBnJlc3VsdBgCIAEoDjIiLmdpdHBsdXMudGFzay52MS5UYXNrRW5xdWV1ZVJlc3VsdDLOAwoMSXNzdWVTZXJ2aWNlElQKCUdldFN0YXR1cxIiLmdpdHBsdXMuaXNzdWUudjEuR2V0U3RhdHVzUmVxdWVzdBojLmdpdHBsdXMuaXNzdWUudjEuR2V0U3RhdHVzUmVzcG9uc2USVwoKTGlzdElzc3VlcxIjLmdpdHBsdXMuaXNzdWUudjEuTGlzdElzc3Vlc1JlcXVlc3QaJC5naXRwbHVzLmlzc3VlLnYxLkxpc3RJc3N1ZXNSZXNwb25zZRJRCghHZXRJc3N1ZRIhLmdpdHBsdXMuaXNzdWUudjEuR2V0SXNzdWVSZXF1ZXN0GiIuZ2l0cGx1cy5pc3N1ZS52MS5HZXRJc3N1ZVJlc3BvbnNlEmAKDUxpc3RTbmFwc2hvdHMSJi5naXRwbHVzLmlzc3VlLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GicuZ2l0cGx1cy5pc3N1ZS52MS5MaXN0U25hcHNob3RzUmVzcG9uc2USWgoLRW5xdWV1ZVN5bmMSJC5naXRwbHVzLmlzc3VlLnYxLkVucXVldWVTeW5jUmVxdWVzdBolLmdpdHBsdXMuaXNzdWUudjEuRW5xdWV1ZVN5bmNSZXNwb25zZULNAQoUY29tLmdpdHBsdXMuaXNzdWUudjFCCklzc3VlUHJvdG9QAVo9Z2l0aHViLmNvbS9JbVNpbmdlZS9naXQtcGx1cy9wa2cvcnBjL2dpdHBsdXMvaXNzdWUvdjE7aXNzdWV2MaICA0dJWKoCEEdpdHBsdXMuSXNzdWUuVjHKAhBHaXRwbHVzXElzc3VlXFYx4gIcR2l0cGx1c1xJc3N1ZVxWMVxHUEJNZXRhZGF0YeoCEkdpdHBsdXM6Oklzc3VlOjpWMZIDBwgB0j4CEAFiCGVkaXRpb25zcOgH", [file_buf_validate_validate, file_gitplus_task_v1_task, file_google_protobuf_go_features]); + +/** + * @generated from message gitplus.issue.v1.IssueSummary + */ +export type IssueSummary = Message<"gitplus.issue.v1.IssueSummary"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: int64 number = 2; + */ + number: bigint; + + /** + * @generated from field: string title = 3; + */ + title: string; + + /** + * @generated from field: string state = 4; + */ + state: string; + + /** + * @generated from field: string author_login = 5; + */ + authorLogin: string; + + /** + * @generated from field: string updated_at = 6; + */ + updatedAt: string; + + /** + * @generated from field: string availability = 7; + */ + availability: string; + + /** + * @generated from field: repeated string labels = 8; + */ + labels: string[]; + + /** + * @generated from field: repeated string assignees = 9; + */ + assignees: string[]; +}; + +/** + * Describes the message gitplus.issue.v1.IssueSummary. + * Use `create(IssueSummarySchema)` to create a new message. + */ +export const IssueSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 0); + +/** + * @generated from message gitplus.issue.v1.IssueBackupStatus + */ +export type IssueBackupStatus = Message<"gitplus.issue.v1.IssueBackupStatus"> & { + /** + * @generated from field: bool enabled = 1; + */ + enabled: boolean; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: string last_successful_at = 3; + */ + lastSuccessfulAt: string; + + /** + * @generated from field: string last_full_reconcile_at = 4; + */ + lastFullReconcileAt: string; + + /** + * @generated from field: string last_error = 5; + */ + lastError: string; + + /** + * @generated from field: string latest_commit = 6; + */ + latestCommit: string; + + /** + * @generated from field: int32 issue_count = 7; + */ + issueCount: number; + + /** + * @generated from field: int32 comment_count = 8; + */ + commentCount: number; +}; + +/** + * Describes the message gitplus.issue.v1.IssueBackupStatus. + * Use `create(IssueBackupStatusSchema)` to create a new message. + */ +export const IssueBackupStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 1); + +/** + * @generated from message gitplus.issue.v1.IssueSnapshot + */ +export type IssueSnapshot = Message<"gitplus.issue.v1.IssueSnapshot"> & { + /** + * @generated from field: string commit = 1; + */ + commit: string; + + /** + * @generated from field: string created_at = 2; + */ + createdAt: string; + + /** + * @generated from field: int32 issue_count = 3; + */ + issueCount: number; + + /** + * @generated from field: int32 comment_count = 4; + */ + commentCount: number; +}; + +/** + * Describes the message gitplus.issue.v1.IssueSnapshot. + * Use `create(IssueSnapshotSchema)` to create a new message. + */ +export const IssueSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 2); + +/** + * @generated from message gitplus.issue.v1.GetStatusRequest + */ +export type GetStatusRequest = Message<"gitplus.issue.v1.GetStatusRequest"> & { + /** + * @generated from field: int64 repo_id = 1; + */ + repoId: bigint; +}; + +/** + * Describes the message gitplus.issue.v1.GetStatusRequest. + * Use `create(GetStatusRequestSchema)` to create a new message. + */ +export const GetStatusRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 3); + +/** + * @generated from message gitplus.issue.v1.GetStatusResponse + */ +export type GetStatusResponse = Message<"gitplus.issue.v1.GetStatusResponse"> & { + /** + * @generated from field: gitplus.issue.v1.IssueBackupStatus status = 1; + */ + status?: IssueBackupStatus; +}; + +/** + * Describes the message gitplus.issue.v1.GetStatusResponse. + * Use `create(GetStatusResponseSchema)` to create a new message. + */ +export const GetStatusResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 4); + +/** + * @generated from message gitplus.issue.v1.ListIssuesRequest + */ +export type ListIssuesRequest = Message<"gitplus.issue.v1.ListIssuesRequest"> & { + /** + * @generated from field: int64 repo_id = 1; + */ + repoId: bigint; + + /** + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * @generated from field: string page_token = 3; + */ + pageToken: string; + + /** + * @generated from field: string state = 4; + */ + state: string; + + /** + * @generated from field: string search = 5; + */ + search: string; +}; + +/** + * Describes the message gitplus.issue.v1.ListIssuesRequest. + * Use `create(ListIssuesRequestSchema)` to create a new message. + */ +export const ListIssuesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 5); + +/** + * @generated from message gitplus.issue.v1.ListIssuesResponse + */ +export type ListIssuesResponse = Message<"gitplus.issue.v1.ListIssuesResponse"> & { + /** + * @generated from field: repeated gitplus.issue.v1.IssueSummary issues = 1; + */ + issues: IssueSummary[]; + + /** + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message gitplus.issue.v1.ListIssuesResponse. + * Use `create(ListIssuesResponseSchema)` to create a new message. + */ +export const ListIssuesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 6); + +/** + * @generated from message gitplus.issue.v1.GetIssueRequest + */ +export type GetIssueRequest = Message<"gitplus.issue.v1.GetIssueRequest"> & { + /** + * @generated from field: int64 repo_id = 1; + */ + repoId: bigint; + + /** + * @generated from field: string issue_id = 2; + */ + issueId: string; + + /** + * @generated from field: string snapshot = 3; + */ + snapshot: string; +}; + +/** + * Describes the message gitplus.issue.v1.GetIssueRequest. + * Use `create(GetIssueRequestSchema)` to create a new message. + */ +export const GetIssueRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 7); + +/** + * @generated from message gitplus.issue.v1.GetIssueResponse + */ +export type GetIssueResponse = Message<"gitplus.issue.v1.GetIssueResponse"> & { + /** + * @generated from field: gitplus.issue.v1.IssueSummary issue = 1; + */ + issue?: IssueSummary; + + /** + * @generated from field: string raw_json = 2; + */ + rawJson: string; + + /** + * @generated from field: string comments_json = 3; + */ + commentsJson: string; + + /** + * @generated from field: string timeline_json = 4; + */ + timelineJson: string; + + /** + * @generated from field: string markdown = 5; + */ + markdown: string; + + /** + * @generated from field: string snapshot = 6; + */ + snapshot: string; +}; + +/** + * Describes the message gitplus.issue.v1.GetIssueResponse. + * Use `create(GetIssueResponseSchema)` to create a new message. + */ +export const GetIssueResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 8); + +/** + * @generated from message gitplus.issue.v1.ListSnapshotsRequest + */ +export type ListSnapshotsRequest = Message<"gitplus.issue.v1.ListSnapshotsRequest"> & { + /** + * @generated from field: int64 repo_id = 1; + */ + repoId: bigint; +}; + +/** + * Describes the message gitplus.issue.v1.ListSnapshotsRequest. + * Use `create(ListSnapshotsRequestSchema)` to create a new message. + */ +export const ListSnapshotsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 9); + +/** + * @generated from message gitplus.issue.v1.ListSnapshotsResponse + */ +export type ListSnapshotsResponse = Message<"gitplus.issue.v1.ListSnapshotsResponse"> & { + /** + * @generated from field: repeated gitplus.issue.v1.IssueSnapshot snapshots = 1; + */ + snapshots: IssueSnapshot[]; +}; + +/** + * Describes the message gitplus.issue.v1.ListSnapshotsResponse. + * Use `create(ListSnapshotsResponseSchema)` to create a new message. + */ +export const ListSnapshotsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 10); + +/** + * @generated from message gitplus.issue.v1.EnqueueSyncRequest + */ +export type EnqueueSyncRequest = Message<"gitplus.issue.v1.EnqueueSyncRequest"> & { + /** + * @generated from field: int64 repo_id = 1; + */ + repoId: bigint; + + /** + * @generated from field: bool full = 2; + */ + full: boolean; +}; + +/** + * Describes the message gitplus.issue.v1.EnqueueSyncRequest. + * Use `create(EnqueueSyncRequestSchema)` to create a new message. + */ +export const EnqueueSyncRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 11); + +/** + * @generated from message gitplus.issue.v1.EnqueueSyncResponse + */ +export type EnqueueSyncResponse = Message<"gitplus.issue.v1.EnqueueSyncResponse"> & { + /** + * @generated from field: gitplus.task.v1.Task task = 1; + */ + task?: Task; + + /** + * @generated from field: gitplus.task.v1.TaskEnqueueResult result = 2; + */ + result: TaskEnqueueResult; +}; + +/** + * Describes the message gitplus.issue.v1.EnqueueSyncResponse. + * Use `create(EnqueueSyncResponseSchema)` to create a new message. + */ +export const EnqueueSyncResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_gitplus_issue_v1_issue, 12); + +/** + * @generated from service gitplus.issue.v1.IssueService + */ +export const IssueService: GenService<{ + /** + * @generated from rpc gitplus.issue.v1.IssueService.GetStatus + */ + getStatus: { + methodKind: "unary"; + input: typeof GetStatusRequestSchema; + output: typeof GetStatusResponseSchema; + }, + /** + * @generated from rpc gitplus.issue.v1.IssueService.ListIssues + */ + listIssues: { + methodKind: "unary"; + input: typeof ListIssuesRequestSchema; + output: typeof ListIssuesResponseSchema; + }, + /** + * @generated from rpc gitplus.issue.v1.IssueService.GetIssue + */ + getIssue: { + methodKind: "unary"; + input: typeof GetIssueRequestSchema; + output: typeof GetIssueResponseSchema; + }, + /** + * @generated from rpc gitplus.issue.v1.IssueService.ListSnapshots + */ + listSnapshots: { + methodKind: "unary"; + input: typeof ListSnapshotsRequestSchema; + output: typeof ListSnapshotsResponseSchema; + }, + /** + * @generated from rpc gitplus.issue.v1.IssueService.EnqueueSync + */ + enqueueSync: { + methodKind: "unary"; + input: typeof EnqueueSyncRequestSchema; + output: typeof EnqueueSyncResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_gitplus_issue_v1_issue, 0); + diff --git a/go.mod b/go.mod index 4214777..55b717a 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,12 @@ require ( connectrpc.com/connect v1.19.1 connectrpc.com/validate v0.1.0 github.com/go-co-op/gocron/v2 v2.19.1 + github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-git/v5 v5.17.2 github.com/google/uuid v1.6.0 github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.49.0 + golang.org/x/sys v0.42.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.48.1 @@ -29,7 +31,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.17.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -48,7 +49,6 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect diff --git a/pkg/app/issues.go b/pkg/app/issues.go new file mode 100644 index 0000000..7cd0022 --- /dev/null +++ b/pkg/app/issues.go @@ -0,0 +1,124 @@ +package app + +import ( + "fmt" + "os" + "strings" + + "connectrpc.com/connect" + appdb "github.com/ImSingee/git-plus/db" + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + "github.com/ImSingee/git-plus/pkg/issuearchive" + "github.com/ImSingee/git-plus/pkg/issuesync" + issuev1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1" + "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1/issuev1connect" + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" +) + +func newIssuesCommand(dataDir *string) *cobra.Command { + parent := &cobra.Command{Use: "issues", Short: "Back up, verify, and export GitHub issue metadata"} + var syncRepoID int64 + var full bool + var serverURL string + syncCmd := &cobra.Command{Use: "sync", Short: "Queue an issue backup on the running server", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if syncRepoID <= 0 { + return fmt.Errorf("--repo must be a positive local repository ID") + } + client, err := newAuthenticatedServerAPIClient() + if err != nil { + return err + } + rpc := issuev1connect.NewIssueServiceClient(client, strings.TrimRight(resolveServerURL(serverURL, defaultListenAddr, false), "/")+"/api") + response, err := rpc.EnqueueSync(cmd.Context(), connect.NewRequest(&issuev1.EnqueueSyncRequest{RepoId: proto.Int64(syncRepoID), Full: proto.Bool(full)})) + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Issue backup task: %s (%s)\n", response.Msg.GetTask().GetTaskId(), response.Msg.GetResult()) + return err + }} + syncCmd.Flags().Int64Var(&syncRepoID, "repo", 0, "local repository ID shown in the Web URL") + syncCmd.Flags().BoolVar(&full, "full", false, "force a full reconciliation") + syncCmd.Flags().StringVar(&serverURL, "server", "", "git-plus server base URL (uses PASSWORD for authentication)") + parent.AddCommand(syncCmd) + for _, mode := range []string{"export", "verify", "reindex"} { + mode := mode + var repoID int64 + var revision, output string + command := &cobra.Command{Use: mode, Short: map[string]string{"export": "Export a verified local snapshot ZIP (attachments remain links)", "verify": "Verify a local snapshot and its checksums", "reindex": "Rebuild the disposable SQLite index from the latest local archive"}[mode], Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if repoID <= 0 { + return fmt.Errorf("--repo must be a positive local repository ID") + } + directory, err := normalizeDataDir(*dataDir) + if err != nil { + return err + } + db, err := appdb.Open(cmd.Context(), directory) + if err != nil { + return err + } + defer db.Close() + repo, err := dbsqlc.New(db).GetRepoById(cmd.Context(), repoID) + if err != nil { + return fmt.Errorf("find local repository: %w", err) + } + if mode == "reindex" { + if err := issuesync.New(directory, db).Reindex(cmd.Context(), repo, ""); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Issue index rebuilt") + return err + } + path, err := issuearchive.Path(directory, repo.SourceID, repo.RefID) + if err != nil { + return err + } + store, err := issuearchive.NewStore(path) + if err != nil { + return err + } + if mode == "verify" { + if err := store.Verify(cmd.Context(), revision); err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Issue snapshot verified") + return err + } + if output == "" { + return fmt.Errorf("--output is required") + } + file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + success := false + defer func() { + file.Close() + if !success { + os.Remove(output) + } + }() + if err := store.ExportZIP(cmd.Context(), file, revision); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + success = true + _, err = fmt.Fprintln(cmd.OutOrStdout(), "Issue snapshot exported:", output) + return err + }} + command.Flags().Int64Var(&repoID, "repo", 0, "local repository ID shown in the Web URL") + if mode != "reindex" { + command.Flags().StringVar(&revision, "snapshot", "", "snapshot commit (default: latest)") + } + if mode == "export" { + command.Flags().StringVar(&output, "output", "", "new ZIP output path; never overwrites an existing file") + } + parent.AddCommand(command) + } + return parent +} diff --git a/pkg/app/issues_test.go b/pkg/app/issues_test.go new file mode 100644 index 0000000..19bba70 --- /dev/null +++ b/pkg/app/issues_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "bytes" + "connectrpc.com/connect" + "context" + issuev1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1" + "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1/issuev1connect" + taskv1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/task/v1" + "github.com/ImSingee/git-plus/pkg/server" + "google.golang.org/protobuf/proto" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type syncServer struct { + issuev1connect.UnimplementedIssueServiceHandler + received bool +} + +func (s *syncServer) EnqueueSync(_ context.Context, req *connect.Request[issuev1.EnqueueSyncRequest]) (*connect.Response[issuev1.EnqueueSyncResponse], error) { + s.received = req.Msg.GetRepoId() == 7 && req.Msg.GetFull() && req.Header().Get("Authorization") == "Bearer test-password" + return connect.NewResponse(&issuev1.EnqueueSyncResponse{Task: &taskv1.Task{TaskId: proto.String("task-7")}}), nil +} +func TestIssuesSyncCommandUsesAuthenticatedRPC(t *testing.T) { + t.Setenv(server.PasswordEnvVar, "test-password") + handler := &syncServer{} + path, rpc := issuev1connect.NewIssueServiceHandler(handler) + mux := http.NewServeMux() + mux.Handle("/api"+path, http.StripPrefix("/api", rpc)) + srv := httptest.NewServer(mux) + defer srv.Close() + root := NewRootCommand("test", nil) + var out bytes.Buffer + root.SetOut(&out) + root.SetArgs([]string{"issues", "sync", "--repo", "7", "--full", "--server", srv.URL}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !handler.received || !strings.Contains(out.String(), "task-7") { + t.Fatalf("command did not queue expected request: %s", out.String()) + } +} +func TestIssuesCommandsRejectMissingRepository(t *testing.T) { + for _, name := range []string{"sync", "export", "verify", "reindex"} { + root := NewRootCommand("test", nil) + root.SetArgs([]string{"issues", name}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--repo") { + t.Fatalf("%s accepted missing repo: %v", name, err) + } + } +} diff --git a/pkg/app/root.go b/pkg/app/root.go index 3d86ff7..287ba9d 100644 --- a/pkg/app/root.go +++ b/pkg/app/root.go @@ -51,6 +51,7 @@ func NewRootCommand(version string, frontendHandlerFactory server.FrontendHandle cmd.AddCommand(newDBCommand(&dataDir)) cmd.AddCommand(newConfigCommand()) cmd.AddCommand(newCronCommand()) + cmd.AddCommand(newIssuesCommand(&dataDir)) return cmd } diff --git a/pkg/config/README.md b/pkg/config/README.md index 243c72a..f943d7f 100644 --- a/pkg/config/README.md +++ b/pkg/config/README.md @@ -24,6 +24,7 @@ sources: include_defaults: true include_starred: false include_watching: false + issues_enabled: false concurrency: 5 max_retry_times: 2 cron: '0 * * * *' @@ -38,18 +39,19 @@ cron: '0 * * * *' Each source supports the following fields: -| Field | Type | Required | Notes | -| -------------------- | -------- | -------- | ---------------------------------- | -| `id` | string | yes | System-generated stable identifier | -| `name` | string | no | Optional display name / remark | -| `platform` | string | yes | v1 only supports `github` | -| `username` | string | yes | Git provider username | -| `token` | string | yes | Must use `$encrypted$1$...` format | -| `only_include_repos` | string[] | no | Optional allowlist | -| `exclude_repos` | string[] | no | Optional blocklist | -| `include_defaults` | bool | no | Default `true` | -| `include_starred` | bool | no | Default `false` | -| `include_watching` | bool | no | Default `false` | +| Field | Type | Required | Notes | +| -------------------- | -------- | -------- | ------------------------------------------------------- | +| `id` | string | yes | System-generated stable identifier | +| `name` | string | no | Optional display name / remark | +| `platform` | string | yes | v1 only supports `github` | +| `username` | string | yes | Git provider username | +| `token` | string | yes | Must use `$encrypted$1$...` format | +| `only_include_repos` | string[] | no | Optional allowlist | +| `exclude_repos` | string[] | no | Optional blocklist | +| `include_defaults` | bool | no | Default `true` | +| `include_starred` | bool | no | Default `false` | +| `include_watching` | bool | no | Default `false` | +| `issues_enabled` | bool | no | Default `false`; back up Issues for active repositories | Meaning of the include fields: @@ -207,3 +209,5 @@ The `git-plus` server command also requires `ENCRYPTION_PASSPHRASE` to be presen - The config file is optional in v1. - The app currently only defines and validates this config. It does not yet use the config to drive business behavior. - Advanced YAML features are not part of the supported contract. Keep the file to plain mappings, sequences, and scalar values. + +Issues backups reuse source sync and cron; see the [Issues backup guide](../../docs/issues-backup.md) for scope, permissions, storage, and recovery. diff --git a/pkg/config/config.go b/pkg/config/config.go index c0f55bd..5530f17 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,6 +25,7 @@ type Config struct { } type SourceConfig struct { + IssuesEnabled bool `yaml:"issues_enabled"` ID string `yaml:"id"` Name string `yaml:"name,omitempty"` Platform string `yaml:"platform"` diff --git a/pkg/config/validate.go b/pkg/config/validate.go index bf615cc..298fab7 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -146,6 +146,7 @@ var sourceFields = []string{ "include_defaults", "include_starred", "include_watching", + "issues_enabled", } func validateSourceCollection(sources []SourceConfig, root *yaml.Node, opts SecretOptions) []ValidationIssue { diff --git a/pkg/configservice/issues_test.go b/pkg/configservice/issues_test.go new file mode 100644 index 0000000..c7c67bf --- /dev/null +++ b/pkg/configservice/issues_test.go @@ -0,0 +1,47 @@ +package configservice + +import ( + "connectrpc.com/connect" + "context" + appconfig "github.com/ImSingee/git-plus/pkg/config" + configv1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/config/v1" + "google.golang.org/protobuf/proto" + "testing" +) + +func TestIssuesEnabledPatchPreservesAndClears(t *testing.T) { + dir := t.TempDir() + source := appconfig.SourceConfig{ID: "source", Platform: "github", Username: "owner", Token: "opaque-existing-ciphertext", IssuesEnabled: true} + if err := appconfig.Save(appconfig.PathForDataDir(dir), appconfig.Config{Sources: []appconfig.SourceConfig{source}}); err != nil { + t.Fatal(err) + } + s := &serviceServer{dataDir: dir} + _, err := s.UpdateSource(context.Background(), connect.NewRequest(&configv1.UpdateSourceRequest{SourceId: proto.String("source"), Patch: &configv1.UpdateSourcePatch{Name: proto.String("renamed")}})) + if err != nil { + t.Fatal(err) + } + loaded, err := appconfig.LoadFromDataDir(dir) + if err != nil { + t.Fatal(err) + } + if !loaded.Data.Sources[0].IssuesEnabled || loaded.Data.Sources[0].Token != source.Token { + t.Fatal("unrelated edit changed issue setting or ciphertext") + } + _, err = s.UpdateSource(context.Background(), connect.NewRequest(&configv1.UpdateSourceRequest{SourceId: proto.String("source"), Patch: &configv1.UpdateSourcePatch{IssuesEnabled: proto.Bool(false)}})) + if err != nil { + t.Fatal(err) + } + loaded, err = appconfig.LoadFromDataDir(dir) + if err != nil { + t.Fatal(err) + } + if loaded.Data.Sources[0].IssuesEnabled { + t.Fatal("explicit false was ignored") + } + if isEmptySourcePatch(&configv1.UpdateSourcePatch{IssuesEnabled: proto.Bool(false)}) { + t.Fatal("false patch considered empty") + } + if toProtoSource(source).GetIssuesEnabled() != true { + t.Fatal("source RPC omitted issue setting") + } +} diff --git a/pkg/configservice/service.go b/pkg/configservice/service.go index 63f761c..a2499dc 100644 --- a/pkg/configservice/service.go +++ b/pkg/configservice/service.go @@ -347,6 +347,7 @@ func toProtoSource(source appconfig.SourceConfig) *configv1.Source { IncludeDefaults: boolPtr(source.IncludeDefaults), IncludeStarred: boolPtr(source.IncludeStarred), IncludeWatching: boolPtr(source.IncludeWatching), + IssuesEnabled: boolPtr(source.IssuesEnabled), Name: stringPtr(appconfig.EffectiveSourceName(source)), } } @@ -410,6 +411,7 @@ func createSourceFromProto(input *configv1.CreateSourceInput) (appconfig.SourceC IncludeDefaults: boolValueOrDefault(input.IncludeDefaults, appconfig.DefaultIncludeDefaults), IncludeStarred: boolValueOrDefault(input.IncludeStarred, false), IncludeWatching: boolValueOrDefault(input.IncludeWatching, false), + IssuesEnabled: boolValueOrDefault(input.IssuesEnabled, false), }, nil } @@ -463,6 +465,10 @@ func applySourcePatch(input *configv1.UpdateSourcePatch, existingSource appconfi updatedSource.IncludeStarred = input.GetIncludeStarred() } + if input.IssuesEnabled != nil { + updatedSource.IssuesEnabled = input.GetIssuesEnabled() + } + if input.IncludeWatching != nil { updatedSource.IncludeWatching = input.GetIncludeWatching() } @@ -576,6 +582,7 @@ func isEmptySourcePatch(input *configv1.UpdateSourcePatch) bool { input.IncludeDefaults == nil && input.IncludeStarred == nil && input.IncludeWatching == nil && + input.IssuesEnabled == nil && input.Name == nil } diff --git a/pkg/githubapi/client.go b/pkg/githubapi/client.go new file mode 100644 index 0000000..1cbe83a --- /dev/null +++ b/pkg/githubapi/client.go @@ -0,0 +1,284 @@ +// Package githubapi provides read-only, bounded GitHub API access for archives. +package githubapi + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const Version = "2022-11-28" +const maxBody = 32 << 20 + +// Error deliberately excludes server response bodies and credential-bearing URLs. +type Error struct { + Status int + RetryAt time.Time +} + +func (e *Error) Error() string { + if !e.RetryAt.IsZero() { + return fmt.Sprintf("GitHub API rate limited; retry after %s", e.RetryAt.UTC().Format(time.RFC3339)) + } + switch e.Status { + case 401: + return "GitHub API authentication failed" + case 403: + return "GitHub API access forbidden; check Issues read permission" + case 404: + return "GitHub resource unavailable or inaccessible; previous backup retained" + case 410: + return "GitHub Issues unavailable or disabled; previous backup retained" + default: + return fmt.Sprintf("GitHub API returned HTTP %d", e.Status) + } +} + +type cached struct { + etag string + body []byte + next string +} +type Client struct { + BaseURL string + HTTP *http.Client + Sleep func(context.Context, time.Duration) error + Now func() time.Time + mu sync.Mutex + cache map[string]cached + cacheBytes int +} + +var credentialLocks sync.Map + +func New(baseURL string, client *http.Client) *Client { + if baseURL == "" { + baseURL = "https://api.github.com" + } + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: client, Now: time.Now, Sleep: sleep, cache: map[string]cached{}} +} +func sleep(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +func (c *Client) Object(ctx context.Context, token, path string) (json.RawMessage, error) { + body, _, err := c.get(ctx, token, path) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err = json.Unmarshal(body, &object); err != nil || object == nil { + return nil, fmt.Errorf("invalid GitHub object response") + } + return json.RawMessage(body), nil +} +func (c *Client) Pages(ctx context.Context, token, path string) ([]json.RawMessage, error) { + result := make([]json.RawMessage, 0) + seen := map[string]bool{} + for path != "" { + if seen[path] || len(seen) >= 100000 { + return nil, fmt.Errorf("invalid GitHub pagination cycle or excessive pages") + } + seen[path] = true + body, next, err := c.get(ctx, token, path) + if err != nil { + return nil, err + } + var page []json.RawMessage + if err = json.Unmarshal(body, &page); err != nil || page == nil { + return nil, fmt.Errorf("invalid GitHub paginated response") + } + result = append(result, page...) + path = next + } + return result, nil +} +func (c *Client) validateURL(path string) (*url.URL, error) { + base, err := url.Parse(c.BaseURL) + if err != nil || base.Host == "" || base.User != nil || (base.Scheme != "https" && base.Scheme != "http") { + return nil, fmt.Errorf("invalid GitHub API base URL") + } + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("invalid GitHub API URL") + } + u = base.ResolveReference(u) + if u.Scheme != base.Scheme || !strings.EqualFold(u.Host, base.Host) || u.User != nil || u.Fragment != "" { + return nil, fmt.Errorf("refusing GitHub API URL outside configured origin") + } + return u, nil +} +func (c *Client) get(ctx context.Context, token, path string) ([]byte, string, error) { + u, err := c.validateURL(path) + if err != nil { + return nil, "", err + } + key := fmt.Sprintf("%x", sha256.Sum256([]byte(c.BaseURL+"\x00"+token))) + value, _ := credentialLocks.LoadOrStore(key, make(chan struct{}, 1)) + gate := value.(chan struct{}) + select { + case gate <- struct{}{}: + case <-ctx.Done(): + return nil, "", ctx.Err() + } + defer func() { <-gate }() + cacheKey := key + "|" + u.String() + c.mu.Lock() + prev := c.cache[cacheKey] + c.mu.Unlock() + client := *c.HTTP + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many GitHub API redirects") + } + _, err := c.validateURL(req.URL.String()) + return err + } + for attempt := 0; attempt < 3; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "", fmt.Errorf("create GitHub request") + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", Version) + req.Header.Set("User-Agent", "git-plus-issue-backup") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if prev.etag != "" { + req.Header.Set("If-None-Match", prev.etag) + } + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + if attempt == 2 { + return nil, "", fmt.Errorf("GitHub API request failed (network or redirect)") + } + if err = c.Sleep(ctx, time.Duration(1< maxBody { + return nil, "", fmt.Errorf("GitHub API response exceeds size limit") + } + if resp.StatusCode == http.StatusNotModified && prev.body != nil { + return append([]byte(nil), prev.body...), prev.next, nil + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + next, err := nextLink(resp.Header.Values("Link")) + if err != nil { + return nil, "", err + } + if next != "" { + relative, parseErr := url.Parse(next) + if parseErr != nil { + return nil, "", fmt.Errorf("invalid GitHub pagination URL") + } + next = resp.Request.URL.ResolveReference(relative).String() + if _, err = c.validateURL(next); err != nil { + return nil, "", err + } + } + // Conditional response bodies are bounded in memory, and never persisted. + if etag := resp.Header.Get("ETag"); etag != "" && len(body) <= 4<<20 { + entry := cached{etag: etag, body: append([]byte(nil), body...), next: next} + c.mu.Lock() + if len(c.cache) >= 2000 || c.cacheBytes+len(body) > 32<<20 { + c.cache = map[string]cached{} + c.cacheBytes = 0 + } + c.cacheBytes -= len(c.cache[cacheKey].body) + c.cache[cacheKey] = entry + c.cacheBytes += len(body) + c.mu.Unlock() + } + return body, next, nil + } + apiErr := &Error{Status: resp.StatusCode} + delay := time.Duration(1<= 0 { + if seconds > math.MaxInt64/int64(time.Second) { + delay = time.Duration(math.MaxInt64) + } else { + delay = time.Duration(seconds) * time.Second + } + } else if when, parseErr := http.ParseTime(retryAfter); parseErr == nil { + delay = when.Sub(c.Now()) + } else if reset, parseErr := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); parseErr == nil { + delay = time.Unix(reset, 0).Sub(c.Now()) + } + if delay < time.Second { + delay = time.Second + } + apiErr.RetryAt = c.Now().Add(delay) + } + if (!limited && resp.StatusCode < 500) || attempt == 2 || delay > 30*time.Second { + return nil, "", apiErr + } + if err = c.Sleep(ctx, delay); err != nil { + return nil, "", err + } + } + return nil, "", fmt.Errorf("GitHub request retries exhausted") +} +func nextLink(headers []string) (string, error) { + for _, header := range headers { + for _, part := range strings.Split(header, ",") { + sections := strings.Split(part, ";") + if len(sections) < 2 { + continue + } + next := false + for _, param := range sections[1:] { + p := strings.TrimSpace(param) + if strings.HasPrefix(p, "rel=") { + for _, rel := range strings.Fields(strings.Trim(strings.TrimPrefix(p, "rel="), "\"")) { + if rel == "next" { + next = true + } + } + } + } + if next { + target := strings.TrimSpace(sections[0]) + if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") { + return "", fmt.Errorf("invalid GitHub pagination Link") + } + return target[1 : len(target)-1], nil + } + } + } + return "", nil +} diff --git a/pkg/githubapi/client_test.go b/pkg/githubapi/client_test.go new file mode 100644 index 0000000..7802ec6 --- /dev/null +++ b/pkg/githubapi/client_test.go @@ -0,0 +1,345 @@ +package githubapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func testClient(server *httptest.Server) *Client { + client := New(server.URL, server.Client()) + client.Sleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() } + return client +} + +func TestPagesFollowEveryLinkAndPreserveRawObjects(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer secret" || r.Header.Get("X-GitHub-Api-Version") != Version || r.Header.Get("Accept") != "application/vnd.github+json" { + t.Error("missing API headers") + } + page := r.URL.Query().Get("page") + switch page { + case "": + w.Header().Add("Link", fmt.Sprintf(`; rel="last", ; rel="next"`, r.Host, r.Host)) + io.WriteString(w, `[{"id":9007199254740993,"number":1,"unknown":{"a":true}}]`) + case "2": + w.Header().Add("Link", fmt.Sprintf(`; rel="next"`, r.Host)) + io.WriteString(w, `[{"id":2,"number":2,"pull_request":{}}]`) + case "3": + io.WriteString(w, `[]`) + default: + t.Errorf("unexpected page %q", page) + w.WriteHeader(500) + } + })) + defer server.Close() + pages, err := testClient(server).Pages(context.Background(), "secret", "/issues") + if err != nil { + t.Fatal(err) + } + if len(pages) != 2 || calls != 3 || !strings.Contains(string(pages[0]), "9007199254740993") || !strings.Contains(string(pages[1]), "pull_request") { + t.Fatalf("pagination must preserve all raw API objects for higher-level PR filtering: %s calls=%d", pages, calls) + } +} + +func TestPaginationRejectsCrossOriginAndCycles(t *testing.T) { + var externalCalls atomic.Int32 + external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalCalls.Add(1); io.WriteString(w, `[]`) })) + defer external.Close() + for _, mode := range []string{"cross-origin", "cycle", "invalid-link"} { + t.Run(mode, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + switch mode { + case "cross-origin": + w.Header().Set("Link", fmt.Sprintf(`<%s/stolen>; rel="next"`, external.URL)) + case "cycle": + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host)) + case "invalid-link": + w.Header().Set("Link", `missing-brackets; rel="next"`) + } + io.WriteString(w, `[{"id":1}]`) + })) + defer server.Close() + if _, err := testClient(server).Pages(context.Background(), "secret", "/issues"); err == nil { + t.Fatal("unsafe pagination was accepted") + } + if calls > 2 { + t.Fatalf("pagination cycle made %d calls", calls) + } + }) + } + if externalCalls.Load() != 0 { + t.Fatal("sent a request or token to another origin") + } +} + +func TestConditionalPagesCacheIsolatedByCredential(t *testing.T) { + var mu sync.Mutex + requests := map[string]int{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + key := token + "|" + r.URL.String() + mu.Lock() + requests[key]++ + count := requests[key] + mu.Unlock() + etag := `"` + token + r.URL.Path + `"` + if count > 1 { + if r.Header.Get("If-None-Match") != etag { + t.Errorf("missing or cross-credential conditional ETag for %s: %q", key, r.Header.Get("If-None-Match")) + } + w.WriteHeader(304) + return + } + if r.Header.Get("If-None-Match") != "" { + t.Errorf("new credential got another token's ETag: %s", key) + } + w.Header().Set("ETag", etag) + if r.URL.Path == "/first" { + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host)) + } + fmt.Fprintf(w, `[{"token_owner":%q,"page":%q}]`, token, r.URL.Path) + })) + defer server.Close() + client := testClient(server) + for _, token := range []string{"alpha", "alpha", "beta", "beta", ""} { + result, err := client.Pages(context.Background(), token, "/first") + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Fatalf("304 lost cached pagination next link: %s", result) + } + for _, raw := range result { + var item map[string]string + if err := json.Unmarshal(raw, &item); err != nil { + t.Fatal(err) + } + if item["token_owner"] != token { + t.Fatalf("credential cache leak: got %s for token %q", raw, token) + } + } + } +} + +func TestUncached304IsNotSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(304) })) + defer server.Close() + if _, err := testClient(server).Object(context.Background(), "new-token", "/object"); err == nil { + t.Fatal("accepted 304 without a cached response") + } +} + +func TestRetryPolicyAndRateLimitDelays(t *testing.T) { + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + status int + headers map[string]string + body string + calls int + sleeps []time.Duration + retryAt time.Time + }{ + {name: "server-errors", status: 503, calls: 3, sleeps: []time.Duration{time.Second, 2 * time.Second}}, + {name: "permission-denied", status: 403, calls: 1}, + {name: "short-429", status: 429, headers: map[string]string{"Retry-After": "2"}, calls: 3, sleeps: []time.Duration{2 * time.Second, 2 * time.Second}, retryAt: now.Add(2 * time.Second)}, + {name: "long-429", status: 429, headers: map[string]string{"Retry-After": "120"}, calls: 1, retryAt: now.Add(120 * time.Second)}, + {name: "reset-403", status: 403, headers: map[string]string{"X-RateLimit-Remaining": "0", "X-RateLimit-Reset": strconv.FormatInt(now.Add(time.Hour).Unix(), 10)}, calls: 1, retryAt: now.Add(time.Hour)}, + {name: "secondary-403", status: 403, body: `{"message":"You have exceeded a secondary rate limit"}`, calls: 1, retryAt: now.Add(time.Minute)}, + {name: "404", status: 404, calls: 1}, + {name: "410", status: 410, calls: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + for key, value := range test.headers { + w.Header().Set(key, value) + } + w.WriteHeader(test.status) + io.WriteString(w, test.body) + })) + defer server.Close() + client := testClient(server) + client.Now = func() time.Time { return now } + var sleeps []time.Duration + client.Sleep = func(ctx context.Context, delay time.Duration) error { sleeps = append(sleeps, delay); return ctx.Err() } + _, err := client.Object(context.Background(), "token", "/object") + var apiError *Error + if !errors.As(err, &apiError) || apiError.Status != test.status || !apiError.RetryAt.Equal(test.retryAt) { + t.Fatalf("unexpected error: %+v (%v)", apiError, err) + } + if calls != test.calls || fmt.Sprint(sleeps) != fmt.Sprint(test.sleeps) { + t.Fatalf("retry policy: calls=%d sleeps=%v, wanted calls=%d sleeps=%v", calls, sleeps, test.calls, test.sleeps) + } + }) + } +} + +func TestTransientServerErrorEventuallySucceeds(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls < 3 { + w.WriteHeader(502) + return + } + io.WriteString(w, `{"id":1}`) + })) + defer server.Close() + if _, err := testClient(server).Object(context.Background(), "token", "/object"); err != nil || calls != 3 { + t.Fatalf("retry result calls=%d err=%v", calls, err) + } +} + +func TestCancellationDuringRequestAndRetry(t *testing.T) { + t.Run("request", func(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done() })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := testClient(server).Object(ctx, "token", "/object"); done <- err }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation not propagated: %v", err) + } + }) + t.Run("retry", func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; w.WriteHeader(503) })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := testClient(server) + client.Sleep = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() } + if _, err := client.Object(ctx, "token", "/object"); !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("retry cancellation calls=%d err=%v", calls, err) + } + }) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return fn(r) } + +func TestErrorsNeverExposeTokensBodiesOrURLs(t *testing.T) { + secret := "ghp_DO_NOT_DISCLOSE" + for _, status := range []int{401, 403, 404, 410, 429, 500} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + fmt.Fprintf(w, `{"message":%q}`, secret) + })) + defer server.Close() + _, err := testClient(server).Object(context.Background(), secret, "/object?secret="+secret) + if err == nil || strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), server.URL) { + t.Fatalf("unsafe API error: %v", err) + } + }) + } + client := New("https://api.github.test", &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("transport failed at %s using %s", r.URL, r.Header.Get("Authorization")) + })}) + client.Sleep = func(context.Context, time.Duration) error { return nil } + if _, err := client.Object(context.Background(), secret, "/object?secret="+secret); err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("unsafe network error: %v", err) + } + for _, path := range []string{"https://user:" + secret + "@evil.test/path", "https://evil.test/" + secret, "/x#" + secret, "%" + secret} { + if _, err := client.Object(context.Background(), secret, path); err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("unsafe validation error: %v", err) + } + } +} + +func TestRedirectCannotForwardCredentialsAcrossOrigins(t *testing.T) { + var externalCalls atomic.Int32 + external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalCalls.Add(1); io.WriteString(w, `{}`) })) + defer external.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, external.URL+"/capture", http.StatusFound) + })) + defer server.Close() + if _, err := testClient(server).Object(context.Background(), "secret", "/object"); err == nil { + t.Fatal("cross-origin redirect succeeded") + } + if externalCalls.Load() != 0 { + t.Fatal("credential-bearing redirect reached another origin") + } +} + +func TestHTTPDateRetryAfter(t *testing.T) { + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", now.Add(2*time.Hour).Format(http.TimeFormat)) + w.WriteHeader(429) + })) + defer server.Close() + client := testClient(server) + client.Now = func() time.Time { return now } + _, err := client.Object(context.Background(), "token", "/object") + var apiError *Error + if !errors.As(err, &apiError) || !apiError.RetryAt.Equal(now.Add(2*time.Hour)) { + t.Fatalf("HTTP-date Retry-After was not respected: %+v, %v", apiError, err) + } +} + +func TestVeryLargeRetryAfterDoesNotOverflow(t *testing.T) { + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Retry-After", "9223372037") + w.WriteHeader(429) + })) + defer server.Close() + client := testClient(server) + client.Now = func() time.Time { return now } + client.Sleep = func(context.Context, time.Duration) error { + t.Error("must not retry a huge Retry-After delay") + return nil + } + _, err := client.Object(context.Background(), "token", "/object") + var apiError *Error + if !errors.As(err, &apiError) || !apiError.RetryAt.After(now) || calls != 1 { + t.Fatalf("overflowed Retry-After: calls=%d error=%+v", calls, apiError) + } +} + +func TestRelativeNextLinkUsesCurrentPageURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/issues" { + w.WriteHeader(404) + return + } + if r.URL.Query().Get("page") == "2" { + io.WriteString(w, `[{"id":2}]`) + return + } + w.Header().Set("Link", `; rel="next"`) + io.WriteString(w, `[{"id":1}]`) + })) + defer server.Close() + items, err := testClient(server).Pages(context.Background(), "token", "/repos/owner/repo/issues") + if err != nil || len(items) != 2 { + t.Fatalf("relative Link lost current page path: items=%s err=%v", items, err) + } +} diff --git a/pkg/issuearchive/README.md b/pkg/issuearchive/README.md new file mode 100644 index 0000000..503840c --- /dev/null +++ b/pkg/issuearchive/README.md @@ -0,0 +1,101 @@ +# Local GitHub issue archive + +This package stores metadata separately from code repositories at: + +``` +/metadata//.git +``` + +Archives are local-only bare Git repositories. The only branch is +`refs/heads/snapshots`, with one immutable commit for each content change. Runtime +operations use go-git and the local filesystem, never a Git subprocess. No remote +or authentication token is configured. Existing code repositories and archives +using Git alternates are rejected. + +## Snapshot layout + +``` +README.md +manifest.json +repository.json +labels.json +milestones.json +issues// + issue.json + comments.json + timeline.json + state.json + README.md +``` + +IDs and path components are validated; titles, repository names and issue numbers +never become filenames. JSON preserves unknown API fields and numeric precision. +Object keys and entity collections are deterministically ordered. The original +numeric JSON representations are retained rather than converted through +floating-point numbers. Markdown is derived: issue/comment bodies are fenced +plain text, and titles are escaped, so raw HTML and arbitrary Markdown links are +not rendered as active content. + +The versioned manifest records API version, crawl times, safe recovery +watermarks, counts, and explicit coverage. SHA-256 checksums cover every exported +file except the manifest itself. Its `content_hash` is the SHA-256 of the compact +JSON-encoded, sorted `files` map. Git object hashes also cover the manifest, trees, +commits, and historical snapshots. + +## Save and recovery contract + +- Incremental input merges into the prior snapshot; omitted issues are retained. +- A successful full reconciliation marks omitted issues `unavailable`, retaining + their last observed raw issue, comments and timeline. This does not assert that + they were deleted. A later observation clears the marker. +- Explicit unavailable entries also retain previously observed content. +- Nil collections mean “not fetched”; nonnil empty collections mean “observed + empty.” This applies to issue comments/timeline and repository labels/milestones. +- Complete collection fetches must succeed before calling `Save`; an interrupted + fetch must not be presented as an authoritative empty or partial collection. +- Pull requests are rejected. Only issue API payloads belong in this archive. +- Runs that only change crawl times, watermarks or the full/incremental flag do + not create commits. Thus database recovery can safely replay from an earlier + stored cursor. Raw content changes, edits, state changes, repository renames and + availability changes create commits. +- Archive objects are written before the snapshots reference is published by a + unique-temporary-file/atomic-rename operation. Failed writes leave the prior + snapshot readable. An OS advisory lock serializes writers across + independent Store instances/processes and is automatically released on exit. + The persistent `.issuearchive.lock` file must not be removed or replaced while + the application is running. A killed publisher may leave an unused temporary + file, which cannot prevent later saves. External Git writes to the private + snapshots branch are unsupported. `AcquireLock` exposes the same advisory lock + for callers coordinating archive and index operations through a separate file. +- `Load` verifies and returns all data needed to rebuild a disposable database + index, without contacting GitHub. `History` reads checked manifests efficiently. + `Verify` additionally checks all selected snapshot files and derived content. +- Empty revision or `latest` means the latest snapshot. Other revisions must be + full lowercase 40-character commit IDs reachable on the snapshots branch. + +Attachments are **links only**. Binaries and content deleted before the backup +first observed it cannot be recovered. Timeline coverage is limited to events +returned by GitHub's REST timeline API to the backup account. + +## Export and integrity + +`ExportZIP` verifies before writing any output, then emits the selected snapshot's +raw JSON, Markdown and manifest with safe relative regular-file paths. It does +not include complete Git history. Preserve the local bare Git repository to keep +all historical snapshots; copy it while no writer is active. + +Checksums detect accidental corruption, not a malicious party who can rewrite +both data and checksums. Git hashes are integrity checks, not signatures. + +To bound memory use, a snapshot permits up to one million files, 64 MiB per file +and 512 MiB total content. Archives are private to the application. Protect the +configured data directory appropriately, especially when backing up private +repositories. + +Run package verification with: + +``` +go test ./pkg/issuearchive +go vet ./pkg/issuearchive +CGO_ENABLED=1 go test -race ./pkg/issuearchive +``` diff --git a/pkg/issuearchive/lock.go b/pkg/issuearchive/lock.go new file mode 100644 index 0000000..9bc8eb6 --- /dev/null +++ b/pkg/issuearchive/lock.go @@ -0,0 +1,44 @@ +package issuearchive + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// AcquireLock acquires a nonblocking, cross-process exclusive advisory lock on +// the exact file path. It creates the parent directory if necessary. Contention +// returns ErrBusy. The returned function is idempotent; the OS also releases the +// lock when the process exits. The persistent lock file must not be removed or +// replaced while the application is running, as that would split the lock domain. +// Callers may use a separate .sync.lock to serialize archive/index work. +func AcquireLock(path string) (func(), error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("lock path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, fmt.Errorf("create lock directory: %w", err) + } + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() { + return nil, errors.New("lock path must be a regular file, not a symlink") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open archive lock: %w", err) + } + if err := acquireFileLock(file); err != nil { + file.Close() + return nil, err + } + var once sync.Once + return func() { once.Do(func() { releaseFileLock(file); _ = file.Close() }) }, nil +} + +func lock(path string) (func(), error) { + return AcquireLock(filepath.Join(path, ".issuearchive.lock")) +} diff --git a/pkg/issuearchive/lock_other.go b/pkg/issuearchive/lock_other.go new file mode 100644 index 0000000..5c33f15 --- /dev/null +++ b/pkg/issuearchive/lock_other.go @@ -0,0 +1,13 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows + +package issuearchive + +import ( + "errors" + "os" +) + +func acquireFileLock(*os.File) error { + return errors.New("issue archive advisory locks are unsupported on this operating system") +} +func releaseFileLock(*os.File) {} diff --git a/pkg/issuearchive/lock_unix.go b/pkg/issuearchive/lock_unix.go new file mode 100644 index 0000000..61673b1 --- /dev/null +++ b/pkg/issuearchive/lock_unix.go @@ -0,0 +1,24 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package issuearchive + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func acquireFileLock(file *os.File) error { + err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return ErrBusy + } + if err != nil { + return fmt.Errorf("lock issue archive: %w", err) + } + return nil +} + +func releaseFileLock(file *os.File) { _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) } diff --git a/pkg/issuearchive/lock_windows.go b/pkg/issuearchive/lock_windows.go new file mode 100644 index 0000000..4b1684e --- /dev/null +++ b/pkg/issuearchive/lock_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package issuearchive + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +func acquireFileLock(file *os.File) error { + err := windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return ErrBusy + } + if err != nil { + return fmt.Errorf("lock issue archive: %w", err) + } + return nil +} + +func releaseFileLock(file *os.File) { + _ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &windows.Overlapped{}) +} diff --git a/pkg/issuearchive/markdown.go b/pkg/issuearchive/markdown.go new file mode 100644 index 0000000..3e26009 --- /dev/null +++ b/pkg/issuearchive/markdown.go @@ -0,0 +1,116 @@ +package issuearchive + +import ( + "encoding/json" + "fmt" + "html" + "sort" + "strings" +) + +// RenderMarkdown produces a readable, inert representation of an issue. User +// text is never interpreted as HTML or active Markdown: bodies are fenced text, +// and titles/metadata are escaped. Raw JSON remains authoritative. +func RenderMarkdown(issue Issue) (string, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(issue.Raw, &fields); err != nil { + return "", fmt.Errorf("render issue: %w", err) + } + var b strings.Builder + fmt.Fprintf(&b, "# #%d %s\n\n", issue.Number, markdownText(stringField(fields, "title"))) + fmt.Fprintf(&b, "- Stable ID: %s\n- State: %s\n- Availability: %s\n", markdownText(issue.ID), markdownText(stringField(fields, "state")), markdownText(issue.Availability)) + if issue.MissingSince != "" { + fmt.Fprintf(&b, "- Unavailable since: %s\n", markdownText(issue.MissingSince)) + } + if url := stringField(fields, "html_url"); url != "" { + fmt.Fprintf(&b, "- Source: %s\n", markdownText(url)) + } + b.WriteString("\n## Description\n\n") + b.WriteString(fencedText(stringField(fields, "body"))) + b.WriteString("\n## Comments\n\n") + if len(issue.Comments) == 0 { + b.WriteString("No comments captured.\n") + } + for _, comment := range issue.Comments { + var c map[string]json.RawMessage + if err := json.Unmarshal(comment, &c); err != nil { + return "", fmt.Errorf("render comment: %w", err) + } + var author map[string]json.RawMessage + _ = json.Unmarshal(c["user"], &author) + fmt.Fprintf(&b, "### Comment %s\n\nAuthor: %s\n\n", markdownText(rawID(comment)), markdownText(stringField(author, "login"))) + b.WriteString(fencedText(stringField(c, "body"))) + b.WriteByte('\n') + } + b.WriteString("\n## Timeline\n\n") + if len(issue.Timeline) == 0 { + b.WriteString("No timeline events captured.\n") + } + for _, event := range issue.Timeline { + var e map[string]json.RawMessage + if err := json.Unmarshal(event, &e); err != nil { + return "", fmt.Errorf("render timeline: %w", err) + } + fmt.Fprintf(&b, "- %s: %s\n", markdownText(stringField(e, "created_at")), markdownText(stringField(e, "event"))) + } + b.WriteString("\nAttachments are links only. Original JSON files contain the complete captured fields.\n") + return b.String(), nil +} + +func stringField(fields map[string]json.RawMessage, key string) string { + var value string + _ = json.Unmarshal(fields[key], &value) + return value +} + +func markdownText(value string) string { + value = strings.Join(strings.Fields(value), " ") + value = html.EscapeString(value) + return strings.NewReplacer("\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_", "[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)", "#", "\\#", "!", "\\!", "|", "\\|").Replace(value) +} + +func fencedText(text string) string { + // A body can itself contain arbitrary Markdown fences. Choose a delimiter + // longer than every backtick run so the body cannot escape its text block. + maxRun, run := 0, 0 + for _, r := range text { + if r == '`' { + run++ + if run > maxRun { + maxRun = run + } + } else { + run = 0 + } + } + length := maxRun + 1 + if length < 3 { + length = 3 + } + fence := strings.Repeat("`", length) + return fence + "text\n" + text + "\n" + fence + "\n" +} + +func archiveReadme(snapshot Snapshot) string { + var b strings.Builder + fmt.Fprintf(&b, "# GitHub Issues archive: %s\n\n", markdownText(snapshot.Repository.FullName)) + b.WriteString("Raw JSON is authoritative. Markdown is a derived, plain-text view.\n\nThis snapshot covers non-pull-request issues, issue comments, repository labels and milestones, and REST-supported timeline events visible to the backup account. Attachments are links only; attachment binaries are not downloaded. Unavailable issues retain previously observed content. Missing content is not proof of deletion. Content removed before it was observed cannot be recovered.\n\nThe ZIP export contains this snapshot only; copy the local bare Git archive to preserve all snapshot history. manifest.json lists SHA-256 checksums for every other file.\n\n## Issues\n\n") + ids := make([]string, 0, len(snapshot.Issues)) + for id := range snapshot.Issues { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + a, b := snapshot.Issues[ids[i]], snapshot.Issues[ids[j]] + if a.Number != b.Number { + return a.Number < b.Number + } + return ids[i] < ids[j] + }) + for _, id := range ids { + issue := snapshot.Issues[id] + var fields map[string]json.RawMessage + _ = json.Unmarshal(issue.Raw, &fields) + fmt.Fprintf(&b, "- [#%d](issues/%s/README.md) %s (%s)\n", issue.Number, id, markdownText(stringField(fields, "title")), issue.Availability) + } + return b.String() +} diff --git a/pkg/issuearchive/objects.go b/pkg/issuearchive/objects.go new file mode 100644 index 0000000..3ae4ee4 --- /dev/null +++ b/pkg/issuearchive/objects.go @@ -0,0 +1,432 @@ +package issuearchive + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// Bounds apply equally on writing and reading, including archives modified by +// outside tools. They prevent malicious trees/blobs from exhausting memory. +const maxFileBytes = 64 << 20 +const maxSnapshotBytes = 512 << 20 +const maxSnapshotFiles = 1_000_000 + +type treeNode struct { + files map[string][]byte + dirs map[string]*treeNode +} + +func newTreeNode() *treeNode { + return &treeNode{files: map[string][]byte{}, dirs: map[string]*treeNode{}} +} + +func validArchivePath(name string) bool { + if name == "" || strings.ContainsAny(name, "\\\x00\r\n") || path.IsAbs(name) || path.Clean(name) != name { + return false + } + for _, part := range strings.Split(name, "/") { + if part == "" || part == "." || part == ".." || strings.Contains(part, ":") { + return false + } + } + return true +} + +func (s *Store) writeTree(ctx context.Context, files map[string][]byte) (plumbing.Hash, error) { + root := newTreeNode() + total := 0 + if len(files) > maxSnapshotFiles { + return plumbing.ZeroHash, errors.New("snapshot contains too many files") + } + for name, data := range files { + if !validArchivePath(name) { + return plumbing.ZeroHash, fmt.Errorf("unsafe archive path %q", name) + } + total += len(data) + if len(data) > maxFileBytes || total > maxSnapshotBytes { + return plumbing.ZeroHash, errors.New("snapshot exceeds archive size limit") + } + parts := strings.Split(name, "/") + node := root + for _, part := range parts[:len(parts)-1] { + if node.dirs[part] == nil { + node.dirs[part] = newTreeNode() + } + node = node.dirs[part] + } + node.files[parts[len(parts)-1]] = data + } + return s.writeTreeNode(ctx, root) +} + +func (s *Store) writeTreeNode(ctx context.Context, node *treeNode) (plumbing.Hash, error) { + tree := &object.Tree{} + for name, data := range node.files { + if err := ctx.Err(); err != nil { + return plumbing.ZeroHash, err + } + encoded := s.repo.Storer.NewEncodedObject() + encoded.SetType(plumbing.BlobObject) + encoded.SetSize(int64(len(data))) + writer, err := encoded.Writer() + if err != nil { + return plumbing.ZeroHash, err + } + if _, err := writer.Write(data); err != nil { + writer.Close() + return plumbing.ZeroHash, err + } + if err := writer.Close(); err != nil { + return plumbing.ZeroHash, err + } + hash, err := s.repo.Storer.SetEncodedObject(encoded) + if err != nil { + return plumbing.ZeroHash, err + } + if err := s.syncObject(hash); err != nil { + return plumbing.ZeroHash, err + } + if _, _, err := s.checkedObject(hash, plumbing.BlobObject); err != nil { + return plumbing.ZeroHash, err + } + tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: hash}) + } + for name, child := range node.dirs { + hash, err := s.writeTreeNode(ctx, child) + if err != nil { + return plumbing.ZeroHash, err + } + tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Dir, Hash: hash}) + } + sort.Sort(object.TreeEntrySorter(tree.Entries)) + encoded := s.repo.Storer.NewEncodedObject() + if err := tree.Encode(encoded); err != nil { + return plumbing.ZeroHash, err + } + hash, err := s.repo.Storer.SetEncodedObject(encoded) + if err != nil { + return plumbing.ZeroHash, err + } + if err := s.syncObject(hash); err != nil { + return plumbing.ZeroHash, err + } + if _, _, err := s.checkedObject(hash, plumbing.TreeObject); err != nil { + return plumbing.ZeroHash, err + } + return hash, nil +} + +func (s *Store) checkedObject(hash plumbing.Hash, kind plumbing.ObjectType) (plumbing.EncodedObject, []byte, error) { + encoded, err := s.repo.Storer.EncodedObject(kind, hash) + if err != nil { + return nil, nil, fmt.Errorf("read Git object %s: %w", hash, err) + } + if encoded.Size() < 0 || encoded.Size() > maxFileBytes { + return nil, nil, errors.New("Git object exceeds archive size limit") + } + reader, err := encoded.Reader() + if err != nil { + return nil, nil, err + } + data, err := io.ReadAll(io.LimitReader(reader, maxFileBytes+1)) + closeErr := reader.Close() + if err != nil { + return nil, nil, err + } + if closeErr != nil { + return nil, nil, closeErr + } + if int64(len(data)) != encoded.Size() || plumbing.ComputeHash(kind, data) != hash { + return nil, nil, fmt.Errorf("Git object checksum mismatch: %s", hash) + } + return encoded, data, nil +} + +func (s *Store) checkedCommit(hash plumbing.Hash) (*object.Commit, error) { + encoded, _, err := s.checkedObject(hash, plumbing.CommitObject) + if err != nil { + return nil, err + } + commit, err := object.DecodeCommit(s.repo.Storer, encoded) + if err != nil { + return nil, err + } + if len(commit.ParentHashes) > 1 { + return nil, errors.New("snapshot commits must have at most one parent") + } + return commit, nil +} + +func (s *Store) readFiles(ctx context.Context, treeHash plumbing.Hash) (map[string][]byte, error) { + files := map[string][]byte{} + total := 0 + visitedEntries := 0 + var walk func(plumbing.Hash, string, int) error + walk = func(hash plumbing.Hash, prefix string, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + if depth > 3 { + return errors.New("snapshot tree is too deeply nested") + } + encoded, _, err := s.checkedObject(hash, plumbing.TreeObject) + if err != nil { + return err + } + tree, err := object.DecodeTree(s.repo.Storer, encoded) + if err != nil { + return err + } + seen := map[string]bool{} + for _, entry := range tree.Entries { + visitedEntries++ + if visitedEntries > 2*maxSnapshotFiles { + return errors.New("snapshot contains too many tree entries") + } + if err := ctx.Err(); err != nil { + return err + } + if strings.Contains(entry.Name, "/") || !validArchivePath(entry.Name) || seen[entry.Name] { + return errors.New("unsafe or duplicate Git tree entry") + } + seen[entry.Name] = true + name := prefix + entry.Name + switch entry.Mode { + case filemode.Dir: + if err := walk(entry.Hash, name+"/", depth+1); err != nil { + return err + } + case filemode.Regular: + if len(files) >= maxSnapshotFiles { + return errors.New("snapshot contains too many files") + } + _, data, err := s.checkedObject(entry.Hash, plumbing.BlobObject) + if err != nil { + return err + } + total += len(data) + if total > maxSnapshotBytes { + return errors.New("snapshot exceeds archive size limit") + } + files[name] = data + default: + return errors.New("snapshot contains a symlink, submodule or non-regular file") + } + } + return nil + } + if err := walk(treeHash, "", 0); err != nil { + return nil, err + } + return files, nil +} + +func (s *Store) readSnapshot(ctx context.Context, hash plumbing.Hash) (Snapshot, Manifest, map[string][]byte, error) { + fail := func(err error) (Snapshot, Manifest, map[string][]byte, error) { + return Snapshot{}, Manifest{}, nil, err + } + if err := ctx.Err(); err != nil { + return fail(err) + } + commit, err := s.checkedCommit(hash) + if err != nil { + return fail(err) + } + files, err := s.readFiles(ctx, commit.TreeHash) + if err != nil { + return fail(err) + } + var m Manifest + if err := json.Unmarshal(files["manifest.json"], &m); err != nil { + return fail(fmt.Errorf("read manifest: %w", err)) + } + if m.SchemaVersion != SchemaVersion { + return fail(fmt.Errorf("unsupported issue archive schema version %d", m.SchemaVersion)) + } + if m.Coverage != snapshotCoverage() { + return fail(errors.New("unsupported or incomplete snapshot coverage")) + } + if m.CompletedAt.IsZero() || m.StartedAt.IsZero() || m.CompletedAt.Before(m.StartedAt) { + return fail(errors.New("invalid snapshot timestamps")) + } + if len(m.Files) != len(files)-1 || m.ContentHash != filesHash(m.Files) { + return fail(errors.New("manifest file list or content hash mismatch")) + } + if _, exists := m.Files["manifest.json"]; exists { + return fail(errors.New("manifest must not checksum itself")) + } + for name, expected := range m.Files { + data, ok := files[name] + if !validArchivePath(name) || !ok || sha256Hex(data) != expected { + return fail(fmt.Errorf("manifest checksum mismatch: %s", name)) + } + } + snapshot := Snapshot{Issues: map[string]Issue{}, APIVersion: m.APIVersion, StartedAt: m.StartedAt, CompletedAt: m.CompletedAt, IssuesSince: m.IssuesSince, CommentsSince: m.CommentsSince, LastFullReconcileAt: m.LastFullReconcileAt, Full: m.Full} + if err := json.Unmarshal(files["repository.json"], &snapshot.Repository); err != nil { + return fail(err) + } + if err := validateIdentity(snapshot.Repository.SourceID, snapshot.Repository.ID); err != nil { + return fail(err) + } + if err := json.Unmarshal(files["labels.json"], &snapshot.Labels); err != nil { + return fail(err) + } + if err := json.Unmarshal(files["milestones.json"], &snapshot.Milestones); err != nil { + return fail(err) + } + comments := 0 + for name, data := range files { + if !strings.HasPrefix(name, "issues/") || !strings.HasSuffix(name, "/state.json") { + continue + } + parts := strings.Split(name, "/") + if len(parts) != 3 || !numericIDPattern.MatchString(parts[1]) { + return fail(errors.New("invalid issue path")) + } + var state issueState + if err := json.Unmarshal(data, &state); err != nil { + return fail(err) + } + if state.ID != parts[1] { + return fail(errors.New("issue ID does not match filename")) + } + prefix := "issues/" + state.ID + "/" + issue := Issue{ID: state.ID, Number: state.Number, Raw: files[prefix+"issue.json"], Availability: state.Availability, MissingSince: state.MissingSince} + if err := json.Unmarshal(files[prefix+"comments.json"], &issue.Comments); err != nil { + return fail(err) + } + if err := json.Unmarshal(files[prefix+"timeline.json"], &issue.Timeline); err != nil { + return fail(err) + } + normalized, err := normalizeIssue(issue) + if err != nil { + return fail(err) + } + snapshot.Issues[state.ID] = normalized + comments += len(issue.Comments) + } + if m.IssueCount != len(snapshot.Issues) || m.CommentCount != comments { + return fail(errors.New("manifest counts do not match snapshot")) + } + // Regeneration validates the canonical JSON, exact permitted file set, and + // derived Markdown. Extra files and user-controlled export paths are rejected. + expected, _, err := snapshotFiles(ctx, snapshot) + if err != nil { + return fail(err) + } + if len(files) != len(expected) { + return fail(errors.New("unexpected snapshot files")) + } + for name, data := range expected { + if !bytes.Equal(data, files[name]) { + return fail(fmt.Errorf("noncanonical or invalid snapshot file: %s", name)) + } + } + return snapshot, m, files, nil +} + +// ExportZIP writes a self-contained selected snapshot, including its manifest, +// raw JSON and Markdown. It deliberately does not include complete Git history. +// To preserve history, copy the local bare Git archive while no writer is active. +func (s *Store) ExportZIP(ctx context.Context, writer io.Writer, revision string) error { + s.mu.Lock() + defer s.mu.Unlock() + if writer == nil { + return errors.New("ZIP writer is required") + } + hash, err := s.resolve(ctx, revision) + if err != nil { + return err + } + _, manifest, files, err := s.readSnapshot(ctx, hash) + if err != nil { + return err + } + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + archive := zip.NewWriter(writer) + for _, name := range names { + if err := ctx.Err(); err != nil { + return err + } + header := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: manifest.CompletedAt} + header.SetMode(0600) + entry, err := archive.CreateHeader(header) + if err != nil { + return err + } + data := files[name] + for len(data) > 0 { + if err := ctx.Err(); err != nil { + return err + } + size := min(len(data), 32<<10) + written, err := entry.Write(data[:size]) + if err != nil { + return err + } + if written != size { + return io.ErrShortWrite + } + data = data[written:] + } + } + return archive.Close() +} + +// readManifest lets history listings inspect immutable commit metadata without +// loading every issue body in every historical snapshot. Full integrity checks +// remain available through Verify and are always performed by Load and ExportZIP. +func (s *Store) readManifest(hash plumbing.Hash) (Manifest, *object.Commit, error) { + commit, err := s.checkedCommit(hash) + if err != nil { + return Manifest{}, nil, err + } + encoded, _, err := s.checkedObject(commit.TreeHash, plumbing.TreeObject) + if err != nil { + return Manifest{}, nil, err + } + tree, err := object.DecodeTree(s.repo.Storer, encoded) + if err != nil { + return Manifest{}, nil, err + } + var manifestHash plumbing.Hash + for _, entry := range tree.Entries { + if entry.Name == "manifest.json" { + if !manifestHash.IsZero() || entry.Mode != filemode.Regular { + return Manifest{}, nil, errors.New("invalid manifest tree entry") + } + manifestHash = entry.Hash + } + } + if manifestHash.IsZero() { + return Manifest{}, nil, errors.New("snapshot manifest is missing") + } + _, data, err := s.checkedObject(manifestHash, plumbing.BlobObject) + if err != nil { + return Manifest{}, nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return Manifest{}, nil, err + } + if m.SchemaVersion != SchemaVersion || m.Coverage != snapshotCoverage() || m.CompletedAt.IsZero() || m.StartedAt.IsZero() || m.CompletedAt.Before(m.StartedAt) || m.IssueCount < 0 || m.CommentCount < 0 || m.ContentHash != filesHash(m.Files) { + return Manifest{}, nil, errors.New("invalid snapshot manifest") + } + return m, commit, nil +} diff --git a/pkg/issuearchive/snapshot.go b/pkg/issuearchive/snapshot.go new file mode 100644 index 0000000..f38ff96 --- /dev/null +++ b/pkg/issuearchive/snapshot.go @@ -0,0 +1,292 @@ +package issuearchive + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" +) + +// canonicalJSON keeps all unknown fields and json.Number values. In particular, +// it never round-trips GitHub IDs through float64. +func canonicalJSON(raw []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, errors.New("expected exactly one JSON value") + } + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func encodeJSON(value any) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return canonicalJSON(data) +} + +func canonicalObject(raw json.RawMessage) (json.RawMessage, error) { + result, err := canonicalJSON(raw) + if err != nil { + return nil, err + } + if len(result) == 0 || result[0] != '{' { + return nil, errors.New("expected a JSON object") + } + return result, nil +} + +func canonicalCollection(values []json.RawMessage) ([]json.RawMessage, error) { + result := make([]json.RawMessage, len(values)) + for i, raw := range values { + value, err := canonicalObject(raw) + if err != nil { + return nil, fmt.Errorf("item %d: %w", i, err) + } + result[i] = value + } + // API order and page boundaries are not content. Numeric IDs sort correctly + // without conversion to a bounded integer. Events without IDs sort by JSON. + sort.SliceStable(result, func(i, j int) bool { + left, right := rawID(result[i]), rawID(result[j]) + if left != "" && right != "" && len(left) != len(right) { + return len(left) < len(right) + } + if left != right { + return left < right + } + return bytes.Compare(result[i], result[j]) < 0 + }) + return result, nil +} + +func rawID(raw json.RawMessage) string { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return "" + } + return strings.Trim(string(fields["id"]), `"`) +} + +func normalizeSnapshot(input, previous Snapshot) (Snapshot, error) { + if err := validateIdentity(input.Repository.SourceID, input.Repository.ID); err != nil { + return Snapshot{}, err + } + if previous.Repository.ID != "" && (input.Repository.ID != previous.Repository.ID || input.Repository.SourceID != previous.Repository.SourceID) { + return Snapshot{}, errors.New("cannot change the stable repository identity of an archive") + } + input.CompletedAt = effectiveTime(input.CompletedAt) + if input.StartedAt.IsZero() { + input.StartedAt = input.CompletedAt + } else { + input.StartedAt = input.StartedAt.UTC() + } + if input.CompletedAt.Before(input.StartedAt) { + return Snapshot{}, errors.New("snapshot completed before it started") + } + output := input + output.Issues = make(map[string]Issue, len(input.Issues)+len(previous.Issues)) + for id, issue := range previous.Issues { + if _, present := input.Issues[id]; !present && input.Full { + issue.Availability = AvailabilityUnavailable + if issue.MissingSince == "" { + issue.MissingSince = input.CompletedAt.Format(time.RFC3339Nano) + } + } + output.Issues[id] = issue + } + for id, issue := range input.Issues { + if !numericIDPattern.MatchString(id) || issue.ID != id { + return Snapshot{}, fmt.Errorf("invalid or mismatched issue ID %q", id) + } + prev, existed := previous.Issues[id] + if issue.Availability == "" { + issue.Availability = AvailabilityAvailable + } + switch issue.Availability { + case AvailabilityUnavailable: + if existed { + issue.Raw, issue.Comments, issue.Timeline, issue.Number = prev.Raw, prev.Comments, prev.Timeline, prev.Number + if prev.MissingSince != "" { + issue.MissingSince = prev.MissingSince + } + } + if issue.MissingSince == "" { + issue.MissingSince = input.CompletedAt.Format(time.RFC3339Nano) + } + case AvailabilityAvailable: + issue.MissingSince = "" + if issue.Comments == nil && existed { + issue.Comments = prev.Comments + } + if issue.Timeline == nil && existed { + issue.Timeline = prev.Timeline + } + default: + return Snapshot{}, fmt.Errorf("invalid availability for issue %s", id) + } + normalized, err := normalizeIssue(issue) + if err != nil { + return Snapshot{}, fmt.Errorf("issue %s: %w", id, err) + } + output.Issues[id] = normalized + } + if output.Labels == nil { + output.Labels = previous.Labels + } + if output.Milestones == nil { + output.Milestones = previous.Milestones + } + var err error + output.Labels, err = canonicalCollection(output.Labels) + if err != nil { + return Snapshot{}, fmt.Errorf("labels: %w", err) + } + output.Milestones, err = canonicalCollection(output.Milestones) + if err != nil { + return Snapshot{}, fmt.Errorf("milestones: %w", err) + } + return output, nil +} + +func normalizeIssue(issue Issue) (Issue, error) { + if !numericIDPattern.MatchString(issue.ID) || issue.Number <= 0 { + return Issue{}, errors.New("valid stable ID and positive issue number are required") + } + raw, err := canonicalObject(issue.Raw) + if err != nil { + return Issue{}, fmt.Errorf("raw issue: %w", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Issue{}, err + } + if rawID(raw) != issue.ID { + return Issue{}, errors.New("raw issue ID does not match archive ID") + } + if number, err := strconv.ParseInt(string(fields["number"]), 10, 64); err != nil || number != issue.Number { + return Issue{}, errors.New("raw issue number does not match archive number") + } + if _, isPR := fields["pull_request"]; isPR { + return Issue{}, errors.New("pull requests are outside issue archive coverage") + } + if issue.Availability != AvailabilityAvailable && issue.Availability != AvailabilityUnavailable { + return Issue{}, errors.New("invalid issue availability") + } + if issue.MissingSince != "" { + if _, err := time.Parse(time.RFC3339Nano, issue.MissingSince); err != nil { + return Issue{}, errors.New("invalid missing_since timestamp") + } + } + if (issue.Availability == AvailabilityUnavailable) != (issue.MissingSince != "") { + return Issue{}, errors.New("availability and missing_since disagree") + } + issue.Raw = raw + issue.Comments, err = canonicalCollection(issue.Comments) + if err != nil { + return Issue{}, fmt.Errorf("comments: %w", err) + } + issue.Timeline, err = canonicalCollection(issue.Timeline) + if err != nil { + return Issue{}, fmt.Errorf("timeline: %w", err) + } + return issue, nil +} + +type issueState struct { + ID string `json:"id"` + Number int64 `json:"number"` + Availability string `json:"availability"` + MissingSince string `json:"missing_since,omitempty"` +} + +func snapshotFiles(ctx context.Context, snapshot Snapshot) (map[string][]byte, Manifest, error) { + files := make(map[string][]byte, len(snapshot.Issues)*5+5) + addJSON := func(path string, value any) error { + data, err := encodeJSON(value) + if err != nil { + return err + } + files[path] = data + return nil + } + if err := addJSON("repository.json", snapshot.Repository); err != nil { + return nil, Manifest{}, err + } + if err := addJSON("labels.json", snapshot.Labels); err != nil { + return nil, Manifest{}, err + } + if err := addJSON("milestones.json", snapshot.Milestones); err != nil { + return nil, Manifest{}, err + } + var comments int + for id, issue := range snapshot.Issues { + if err := ctx.Err(); err != nil { + return nil, Manifest{}, err + } + prefix := "issues/" + id + "/" + files[prefix+"issue.json"] = issue.Raw + if err := addJSON(prefix+"comments.json", issue.Comments); err != nil { + return nil, Manifest{}, err + } + if err := addJSON(prefix+"timeline.json", issue.Timeline); err != nil { + return nil, Manifest{}, err + } + state := issueState{ID: id, Number: issue.Number, Availability: issue.Availability, MissingSince: issue.MissingSince} + if err := addJSON(prefix+"state.json", state); err != nil { + return nil, Manifest{}, err + } + markdown, err := RenderMarkdown(issue) + if err != nil { + return nil, Manifest{}, err + } + files[prefix+"README.md"] = []byte(markdown) + comments += len(issue.Comments) + } + files["README.md"] = []byte(archiveReadme(snapshot)) + m := Manifest{ + SchemaVersion: SchemaVersion, APIVersion: snapshot.APIVersion, StartedAt: snapshot.StartedAt, CompletedAt: snapshot.CompletedAt, + IssuesSince: snapshot.IssuesSince, CommentsSince: snapshot.CommentsSince, LastFullReconcileAt: snapshot.LastFullReconcileAt, + Full: snapshot.Full, IssueCount: len(snapshot.Issues), CommentCount: comments, Coverage: snapshotCoverage(), Files: make(map[string]string, len(files)), + } + for path, data := range files { + m.Files[path] = sha256Hex(data) + } + m.ContentHash = filesHash(m.Files) + if err := addJSON("manifest.json", m); err != nil { + return nil, Manifest{}, err + } + return files, m, nil +} + +func sha256Hex(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +func filesHash(files map[string]string) string { + // json.Marshal sorts string map keys; all values are hexadecimal strings. + data, _ := json.Marshal(files) + return sha256Hex(data) +} diff --git a/pkg/issuearchive/store.go b/pkg/issuearchive/store.go new file mode 100644 index 0000000..a09f912 --- /dev/null +++ b/pkg/issuearchive/store.go @@ -0,0 +1,368 @@ +package issuearchive + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/go-git/go-billy/v5/osfs" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/storage/filesystem" +) + +var sourceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`) +var numericIDPattern = regexp.MustCompile(`^[1-9][0-9]{0,39}$`) +var revisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +const formatMarker = "git-plus-issue-archive-v1\n" + +// Store never configures a remote, fetches, pushes, or executes a Git command. +// Its path is private to issue metadata and must not be an existing code archive. +type Store struct { + mu sync.Mutex // go-git filesystem storage has mutable internal caches. + path string + repo *git.Repository +} + +// Path constructs the stable, title-independent path of an issue archive. +func Path(dataDir, sourceID, repositoryID string) (string, error) { + if strings.TrimSpace(dataDir) == "" { + return "", errors.New("data directory is required") + } + if err := validateIdentity(sourceID, repositoryID); err != nil { + return "", err + } + return filepath.Join(dataDir, "metadata", sourceID, repositoryID+".git"), nil +} + +func validateIdentity(sourceID, repositoryID string) error { + if !sourceIDPattern.MatchString(sourceID) { + return errors.New("invalid source ID: use 1-128 ASCII letters, digits, underscores or hyphens") + } + if !numericIDPattern.MatchString(repositoryID) { + return errors.New("invalid GitHub repository ID") + } + return nil +} + +// NewStore opens or initializes a local bare metadata repository. Existing +// repositories without our format marker are rejected to protect code archives. +func NewStore(path string) (*Store, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("archive path is required") + } + absolute, err := filepath.Abs(path) + if err != nil { + return nil, err + } + if info, err := os.Lstat(absolute); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) { + return nil, errors.New("archive path must be a directory, not a symlink") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if err := os.MkdirAll(absolute, 0700); err != nil { + return nil, fmt.Errorf("create archive: %w", err) + } + repo, err := git.PlainOpen(absolute) + if errors.Is(err, git.ErrRepositoryNotExists) { + release, lockErr := lock(absolute) + if lockErr != nil { + return nil, lockErr + } + defer release() + // Another initializer may have completed before the lock was acquired. + repo, err = git.PlainOpen(absolute) + if errors.Is(err, git.ErrRepositoryNotExists) { + entries, readErr := os.ReadDir(absolute) + if readErr != nil { + return nil, readErr + } + for _, entry := range entries { + if entry.Name() != ".issuearchive.lock" { + return nil, errors.New("refusing to initialize a nonempty archive directory") + } + } + repo, err = git.PlainInit(absolute, true) + if err != nil { + return nil, fmt.Errorf("initialize archive: %w", err) + } + if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName(SnapshotsRef))); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(absolute, "issuearchive-format"), []byte(formatMarker), 0600); err != nil { + return nil, err + } + } + } + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + if _, err := os.Lstat(filepath.Join(absolute, "objects", "info", "alternates")); !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("issue archive must not use alternate object stores") + } + marker, err := os.ReadFile(filepath.Join(absolute, "issuearchive-format")) + if err != nil || string(marker) != formatMarker { + return nil, errors.New("repository is not a git-plus issue archive") + } + cfg, err := repo.Config() + if err != nil { + return nil, err + } + if !cfg.Core.IsBare || len(cfg.Remotes) != 0 { + return nil, errors.New("issue archive must be bare and have no remotes") + } + // Disable object caching so Verify checks the current on-disk bytes, even + // after an object has already been read through this Store. + repo, err = git.Open(filesystem.NewStorage(osfs.New(absolute), cache.NewObjectLRU(0)), nil) + if err != nil { + return nil, err + } + return &Store{path: absolute, repo: repo}, nil +} + +func (s *Store) latest() (plumbing.Hash, error) { + ref, err := s.repo.Reference(plumbing.ReferenceName(SnapshotsRef), false) + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return plumbing.ZeroHash, ErrNoSnapshots + } + if err != nil { + return plumbing.ZeroHash, err + } + if ref.Type() != plumbing.HashReference { + return plumbing.ZeroHash, errors.New("snapshot branch is not a direct reference") + } + return ref.Hash(), nil +} + +func (s *Store) Save(ctx context.Context, snapshot Snapshot) (Result, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return Result{}, err + } + release, err := lock(s.path) + if err != nil { + return Result{}, err + } + defer release() + old, err := s.latest() + if err != nil && !errors.Is(err, ErrNoSnapshots) { + return Result{}, err + } + var previous Snapshot + var previousManifest Manifest + if err == nil { + previous, previousManifest, _, err = s.readSnapshot(ctx, old) + if err != nil { + return Result{}, fmt.Errorf("read previous snapshot: %w", err) + } + } + snapshot, err = normalizeSnapshot(snapshot, previous) + if err != nil { + return Result{}, err + } + files, manifest, err := snapshotFiles(ctx, snapshot) + if err != nil { + return Result{}, err + } + if !old.IsZero() && manifest.ContentHash == previousManifest.ContentHash { + return Result{Commit: old.String(), CreatedAt: previousManifest.CompletedAt, IssueCount: previousManifest.IssueCount, CommentCount: previousManifest.CommentCount}, nil + } + treeHash, err := s.writeTree(ctx, files) + if err != nil { + return Result{}, err + } + signature := object.Signature{Name: "git-plus", Email: "archive@localhost", When: snapshot.CompletedAt} + commit := &object.Commit{Author: signature, Committer: signature, Message: "Snapshot GitHub issue metadata\n", TreeHash: treeHash} + if !old.IsZero() { + commit.ParentHashes = []plumbing.Hash{old} + } + encoded := s.repo.Storer.NewEncodedObject() + if err := commit.Encode(encoded); err != nil { + return Result{}, err + } + hash, err := s.repo.Storer.SetEncodedObject(encoded) + if err != nil { + return Result{}, fmt.Errorf("write snapshot commit: %w", err) + } + if err := s.syncObject(hash); err != nil { + return Result{}, err + } + if _, _, err := s.checkedObject(hash, plumbing.CommitObject); err != nil { + return Result{}, err + } + if err := ctx.Err(); err != nil { + return Result{}, err + } + if err := s.updateRef(old, hash); err != nil { + return Result{}, err + } + return Result{Commit: hash.String(), Changed: true, CreatedAt: manifest.CompletedAt, IssueCount: manifest.IssueCount, CommentCount: manifest.CommentCount}, nil +} + +// updateRef publishes a unique temporary file by atomic rename after all objects +// are durable. The archive advisory lock serializes writers; unique temporary +// names cannot block later saves after a crash. External writes to this private +// metadata branch are not supported. +func (s *Store) updateRef(old, next plumbing.Hash) error { + refPath := filepath.Join(s.path, "refs", "heads", "snapshots") + if err := os.MkdirAll(filepath.Dir(refPath), 0700); err != nil { + return err + } + f, err := os.CreateTemp(s.path, ".snapshots-*") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + current, err := s.latest() + if err != nil && !errors.Is(err, ErrNoSnapshots) { + f.Close() + return err + } + if current != old { + f.Close() + return errors.New("snapshot branch changed concurrently") + } + if _, err := io.WriteString(f, next.String()+"\n"); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(name, refPath); err != nil { + return fmt.Errorf("publish snapshot: %w", err) + } + // Directory sync is unavailable on some platforms. The atomic rename is still + // required there; do not turn a published snapshot into a misleading failure. + if dir, err := os.Open(filepath.Dir(refPath)); err == nil { + _ = dir.Sync() + _ = dir.Close() + } + return nil +} + +func (s *Store) syncObject(hash plumbing.Hash) error { + h := hash.String() + if err := syncObjectFile(filepath.Join(s.path, "objects", h[:2], h[2:])); err != nil { + return err + } + for _, name := range []string{filepath.Join(s.path, "objects", h[:2]), filepath.Join(s.path, "objects")} { + if dir, err := os.Open(name); err == nil { + _ = dir.Sync() + _ = dir.Close() + } + } + return nil +} + +// resolve only accepts a full immutable commit hash or the current snapshot. +// Git revision expressions, refs and unreachable objects are not exposed. +func (s *Store) resolve(ctx context.Context, revision string) (plumbing.Hash, error) { + if revision != "" && revision != "latest" && !revisionPattern.MatchString(revision) { + return plumbing.ZeroHash, ErrInvalidRevision + } + head, err := s.latest() + if err != nil { + return plumbing.ZeroHash, err + } + if revision == "" || revision == "latest" { + return head, nil + } + target := plumbing.NewHash(revision) + for current := head; !current.IsZero(); { + if err := ctx.Err(); err != nil { + return plumbing.ZeroHash, err + } + if current == target { + return target, nil + } + commit, err := s.checkedCommit(current) + if err != nil { + return plumbing.ZeroHash, err + } + if len(commit.ParentHashes) == 0 { + break + } + if len(commit.ParentHashes) != 1 { + return plumbing.ZeroHash, errors.New("invalid snapshot ancestry") + } + current = commit.ParentHashes[0] + } + return plumbing.ZeroHash, ErrInvalidRevision +} + +func (s *Store) Load(ctx context.Context, revision string) (Snapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + hash, err := s.resolve(ctx, revision) + if err != nil { + return Snapshot{}, err + } + snapshot, _, _, err := s.readSnapshot(ctx, hash) + return snapshot, err +} + +// History returns newest first. A nonpositive limit returns all snapshots. +func (s *Store) History(ctx context.Context, limit int) ([]SnapshotInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + current, err := s.latest() + if errors.Is(err, ErrNoSnapshots) { + return []SnapshotInfo{}, nil + } + if err != nil { + return nil, err + } + result := []SnapshotInfo{} + for !current.IsZero() && (limit <= 0 || len(result) < limit) { + if err := ctx.Err(); err != nil { + return nil, err + } + m, commit, err := s.readManifest(current) + if err != nil { + return nil, err + } + result = append(result, SnapshotInfo{Commit: current.String(), CreatedAt: m.CompletedAt, IssueCount: m.IssueCount, CommentCount: m.CommentCount, Full: m.Full}) + if len(commit.ParentHashes) == 0 { + break + } + if len(commit.ParentHashes) != 1 { + return nil, errors.New("invalid snapshot ancestry") + } + current = commit.ParentHashes[0] + } + return result, nil +} + +// Verify checks the selected commit, tree/blob hashes, manifest checksums, +// coverage and the complete snapshot schema. It does not contact GitHub. +func (s *Store) Verify(ctx context.Context, revision string) error { + _, err := s.Load(ctx, revision) + return err +} + +func effectiveTime(t time.Time) time.Time { + if t.IsZero() { + return time.Now().UTC() + } + return t.UTC() +} diff --git a/pkg/issuearchive/store_test.go b/pkg/issuearchive/store_test.go new file mode 100644 index 0000000..1e2b6f6 --- /dev/null +++ b/pkg/issuearchive/store_test.go @@ -0,0 +1,806 @@ +package issuearchive + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +var testTime = time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + +func testSnapshot() Snapshot { + return Snapshot{ + Repository: Repository{SourceID: "source-1", ID: "123456", NodeID: "R_example", FullName: "owner/repo", HTMLURL: "https://github.com/owner/repo", Private: true}, + APIVersion: "2022-11-28", StartedAt: testTime, CompletedAt: testTime.Add(time.Minute), Full: true, + IssuesSince: "2026-10-15T12:00:00Z", CommentsSince: "2026-10-15T12:00:00Z", LastFullReconcileAt: "2026-10-15T12:00:00Z", + Issues: map[string]Issue{"9007199254740993": { + ID: "9007199254740993", Number: 1, + Raw: json.RawMessage(`{"id":9007199254740993,"number":1,"title":"First issue","body":"Body with [attachment](https://example.com/file.png)","state":"open","unknown":{"large":1844674407370955161600001}}`), + Comments: []json.RawMessage{json.RawMessage(`{"id":9007199254740995,"body":"Comment","user":{"login":"writer"}}`)}, + Timeline: []json.RawMessage{json.RawMessage(`{"id":12,"event":"labeled","created_at":"2026-10-15T12:00:00Z","unknown":"preserved"}`)}, + }}, + Labels: []json.RawMessage{json.RawMessage(`{"id":7,"name":"bug"}`)}, + Milestones: []json.RawMessage{}, + } +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + path, err := Path(t.TempDir(), "source-1", "123456") + if err != nil { + t.Fatal(err) + } + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + return store +} + +func saveTest(t *testing.T, s *Store, snapshot Snapshot) Result { + t.Helper() + result, err := s.Save(context.Background(), snapshot) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestRoundTripCanonicalJSONAndManifest(t *testing.T) { + s := newTestStore(t) + initial := testSnapshot() + result := saveTest(t, s, initial) + if !result.Changed || len(result.Commit) != 40 || result.IssueCount != 1 || result.CommentCount != 1 { + t.Fatalf("unexpected result: %+v", result) + } + loaded, err := s.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + issue := loaded.Issues["9007199254740993"] + if !bytes.Contains(issue.Raw, []byte(`1844674407370955161600001`)) || !bytes.Contains(issue.Raw, []byte(`9007199254740993`)) || !bytes.Contains(issue.Raw, []byte(`"unknown"`)) { + t.Fatalf("raw JSON lost data: %s", issue.Raw) + } + if issue.Availability != AvailabilityAvailable || len(issue.Comments) != 1 || len(issue.Timeline) != 1 { + t.Fatalf("unexpected issue: %+v", issue) + } + if loaded.Repository != initial.Repository || loaded.IssuesSince != initial.IssuesSince || !loaded.CompletedAt.Equal(initial.CompletedAt) { + t.Fatalf("metadata did not round trip: %+v", loaded) + } + if err := s.Verify(context.Background(), result.Commit); err != nil { + t.Fatal(err) + } + cfg, err := s.repo.Config() + if err != nil { + t.Fatal(err) + } + if !cfg.Core.IsBare || len(cfg.Remotes) != 0 { + t.Fatal("archive is not local-only bare Git") + } + head, err := s.repo.Head() + if err != nil || head.Hash().String() != result.Commit { + t.Fatalf("HEAD does not point to snapshots: %v, %v", head, err) + } + reopened, err := NewStore(s.path) + if err != nil { + t.Fatal(err) + } + if err := reopened.Verify(context.Background(), ""); err != nil { + t.Fatal(err) + } +} + +func TestUnchangedRunsDoNotCommitOrAdvanceStoredWatermarks(t *testing.T) { + s := newTestStore(t) + first := saveTest(t, s, testSnapshot()) + again := testSnapshot() + again.Full = false + again.StartedAt = testTime.Add(time.Hour) + again.CompletedAt = testTime.Add(2 * time.Hour) + again.IssuesSince = "2026-10-15T14:00:00Z" + again.CommentsSince = again.IssuesSince + again.LastFullReconcileAt = again.IssuesSince + issue := again.Issues["9007199254740993"] + issue.Raw = json.RawMessage(`{"unknown":{"large":1844674407370955161600001},"body":"Body with [attachment](https://example.com/file.png)","title":"First issue","number":1,"state":"open","id":9007199254740993}`) + again.Issues[issue.ID] = issue + result := saveTest(t, s, again) + if result.Changed || result.Commit != first.Commit || !result.CreatedAt.Equal(first.CreatedAt) { + t.Fatalf("no-op created a commit: %+v", result) + } + loaded, err := s.Load(context.Background(), "latest") + if err != nil { + t.Fatal(err) + } + if loaded.IssuesSince != testSnapshot().IssuesSince { + t.Fatal("no-op must retain the earlier safe recovery watermark") + } + history, err := s.History(context.Background(), 0) + if err != nil || len(history) != 1 { + t.Fatalf("history: %+v, %v", history, err) + } +} + +func TestEditsRetainImmutableHistoryAndOldRevisions(t *testing.T) { + s := newTestStore(t) + first := saveTest(t, s, testSnapshot()) + changed := testSnapshot() + issue := changed.Issues["9007199254740993"] + issue.Raw = bytes.ReplaceAll(issue.Raw, []byte("First issue"), []byte("Edited issue")) + issue.Comments = []json.RawMessage{} + changed.Issues[issue.ID] = issue + changed.CompletedAt = testTime.Add(time.Hour) + second := saveTest(t, s, changed) + if !second.Changed || first.Commit == second.Commit || second.CommentCount != 0 { + t.Fatalf("edit not archived: %+v", second) + } + old, err := s.Load(context.Background(), first.Commit) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(old.Issues[issue.ID].Raw, []byte("First issue")) || len(old.Issues[issue.ID].Comments) != 1 { + t.Fatal("previous snapshot changed") + } + latest, err := s.Load(context.Background(), second.Commit) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(latest.Issues[issue.ID].Raw, []byte("Edited issue")) || len(latest.Issues[issue.ID].Comments) != 0 { + t.Fatal("edit not visible") + } + history, err := s.History(context.Background(), 0) + if err != nil || len(history) != 2 || history[0].Commit != second.Commit || history[1].Commit != first.Commit { + t.Fatalf("history: %+v, %v", history, err) + } + limited, err := s.History(context.Background(), 1) + if err != nil || len(limited) != 1 { + t.Fatalf("limited history: %+v %v", limited, err) + } +} + +func TestMissingIssuesRetainContentAndRecover(t *testing.T) { + s := newTestStore(t) + original := testSnapshot() + first := saveTest(t, s, original) + incremental := original + incremental.Full = false + incremental.Issues = nil + incremental.Labels = nil + incremental.Milestones = nil + noChange := saveTest(t, s, incremental) + if noChange.Changed || noChange.Commit != first.Commit { + t.Fatal("incremental omission modified archive") + } + full := incremental + full.Full = true + full.CompletedAt = testTime.Add(time.Hour) + missing := saveTest(t, s, full) + if !missing.Changed { + t.Fatal("full reconciliation did not mark unavailable") + } + loaded, err := s.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + issue := loaded.Issues["9007199254740993"] + if issue.Availability != AvailabilityUnavailable || issue.MissingSince != full.CompletedAt.Format(time.RFC3339Nano) || len(issue.Raw) == 0 || len(issue.Comments) != 1 || len(issue.Timeline) != 1 { + t.Fatalf("unavailable issue content not retained: %+v", issue) + } + full.CompletedAt = testTime.Add(2 * time.Hour) + repeated := saveTest(t, s, full) + if repeated.Changed { + t.Fatal("repeated unavailability should be a no-op") + } + recovered := saveTest(t, s, original) + if !recovered.Changed { + t.Fatal("recovery was not recorded") + } + loaded, err = s.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if loaded.Issues[issue.ID].Availability != AvailabilityAvailable || loaded.Issues[issue.ID].MissingSince != "" { + t.Fatal("issue did not recover") + } + explicit := original + explicit.Full = false + explicit.Issues = map[string]Issue{issue.ID: {ID: issue.ID, Availability: AvailabilityUnavailable}} + saveTest(t, s, explicit) + loaded, err = s.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(loaded.Issues[issue.ID].Comments) != 1 || loaded.Issues[issue.ID].Number != 1 { + t.Fatal("explicit unavailable marker did not retain content") + } +} + +func TestNilCollectionsRetainAndEmptyCollectionsClear(t *testing.T) { + s := newTestStore(t) + original := testSnapshot() + saveTest(t, s, original) + incremental := testSnapshot() + issue := incremental.Issues["9007199254740993"] + issue.Comments = nil + issue.Timeline = nil + incremental.Issues[issue.ID] = issue + incremental.Labels = nil + incremental.Milestones = nil + if result := saveTest(t, s, incremental); result.Changed { + t.Fatal("nil collections did not retain content") + } + incremental.Labels = []json.RawMessage{} + issue.Comments = []json.RawMessage{} + issue.Timeline = []json.RawMessage{} + incremental.Issues[issue.ID] = issue + if result := saveTest(t, s, incremental); !result.Changed { + t.Fatal("explicit empty collections not recorded") + } + loaded, err := s.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if len(loaded.Labels) != 0 || len(loaded.Issues[issue.ID].Comments) != 0 || len(loaded.Issues[issue.ID].Timeline) != 0 { + t.Fatal("collections were not cleared") + } +} + +func TestRejectUnsafeIdentifiersAndRevisions(t *testing.T) { + for _, id := range []string{"", ".", "..", "../escape", "a/b", `a\b`, "a:b", "a\x00b", "你好", strings.Repeat("a", 129)} { + if _, err := Path(t.TempDir(), id, "123"); err == nil { + t.Errorf("accepted unsafe source ID %q", id) + } + } + for _, id := range []string{"", "0", "01", "-1", ".", "../escape", "1/2", `1\2`, "1.0", "1e3", strings.Repeat("1", 41)} { + if _, err := Path(t.TempDir(), "source", id); err == nil { + t.Errorf("accepted unsafe repository ID %q", id) + } + } + path, err := Path("/data", "source_ab-12", "9007199254740993") + if err != nil || path != filepath.Join("/data", "metadata", "source_ab-12", "9007199254740993.git") { + t.Fatalf("path: %s %v", path, err) + } + s := newTestStore(t) + saveTest(t, s, testSnapshot()) + for _, revision := range []string{"HEAD", "HEAD~1", "refs/heads/snapshots", "../config", "aabbcc", strings.Repeat("f", 40), strings.Repeat("A", 40)} { + if _, err := s.Load(context.Background(), revision); !errors.Is(err, ErrInvalidRevision) { + t.Errorf("revision %q: got %v", revision, err) + } + } + bad := testSnapshot() + bad.Issues = map[string]Issue{"../evil": {ID: "../evil", Number: 1, Raw: json.RawMessage(`{}`)}} + if _, err := s.Save(context.Background(), bad); err == nil { + t.Fatal("accepted unsafe issue ID") + } +} + +func TestValidateRawJSONAndRepositoryIdentity(t *testing.T) { + s := newTestStore(t) + first := saveTest(t, s, testSnapshot()) + for _, raw := range []string{ + `{"id":2,"number":1}`, `{"id":9007199254740993,"number":2}`, `{"id":9007199254740993,"number":1,"pull_request":{}}`, + `{"id":9007199254740993,"number":1} {}`, `null`, `[]`, `{invalid}`, + } { + snapshot := testSnapshot() + issue := snapshot.Issues["9007199254740993"] + issue.Raw = json.RawMessage(raw) + snapshot.Issues[issue.ID] = issue + if _, err := s.Save(context.Background(), snapshot); err == nil { + t.Errorf("accepted invalid raw %s", raw) + } + } + snapshot := testSnapshot() + snapshot.Repository.ID = "654321" + if _, err := s.Save(context.Background(), snapshot); err == nil { + t.Fatal("allowed repository identity change") + } + head, err := s.latest() + if err != nil || head.String() != first.Commit { + t.Fatal("failed save moved branch") + } +} + +func TestMarkdownIsInertAndTitleNeverUsedAsPath(t *testing.T) { + s := newTestStore(t) + snapshot := testSnapshot() + issue := snapshot.Issues["9007199254740993"] + issue.Raw = json.RawMessage(`{"id":9007199254740993,"number":1,"title":"../../evil \n# injected","body":"\n\n\x60\x60\x60\n[click](javascript:alert(1))"}`) + // JSON uses Unicode escapes for backticks, not invalid JSON \x escapes. + issue.Raw = bytes.ReplaceAll(issue.Raw, []byte(`\x60`), []byte(`\u0060`)) + issue.Comments = []json.RawMessage{json.RawMessage(`{"id":1,"body":"\n\u0060\u0060\u0060\u0060"}`)} + snapshot.Issues[issue.ID] = issue + result := saveTest(t, s, snapshot) + _, _, files, err := s.readSnapshot(context.Background(), plumbing.NewHash(result.Commit)) + if err != nil { + t.Fatal(err) + } + markdown := string(files["issues/"+issue.ID+"/README.md"]) + if !strings.Contains(markdown, "<script>title</script>") { + t.Fatal("title HTML was not escaped") + } + if !strings.Contains(markdown, "````text\n") || !strings.Contains(markdown, "`````text\n 500 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("search is too long")) + } + cursor := pageCursor{RepoID: repo.ID, State: state, Search: search} + if token := req.Msg.GetPageToken(); token != "" { + cursor = pageCursor{} + b, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || json.Unmarshal(b, &cursor) != nil || cursor.RepoID != repo.ID || cursor.State != state || cursor.Search != search || cursor.Offset < 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid page token")) + } + } + size := int64(req.Msg.GetPageSize()) + if size <= 0 { + size = 30 + } + if size > 100 { + size = 100 + } + q := dbsqlc.New(s.DB) + total, err := q.CountIssuesCurrent(ctx, dbsqlc.CountIssuesCurrentParams{RepoID: repo.ID, Column2: state, Column3: search}) + if err != nil { + return nil, rpcError(err) + } + rows, err := q.ListIssuesCurrent(ctx, dbsqlc.ListIssuesCurrentParams{RepoID: repo.ID, Column2: state, Column3: search, Limit: size, Offset: cursor.Offset}) + if err != nil { + return nil, rpcError(err) + } + result := &issuev1.ListIssuesResponse{TotalCount: proto.Int32(int32(total)), Issues: make([]*issuev1.IssueSummary, 0, len(rows))} + for _, row := range rows { + result.Issues = append(result.Issues, summary(row)) + } + cursor.Offset += int64(len(rows)) + if cursor.Offset < total { + b, _ := json.Marshal(cursor) + result.NextPageToken = proto.String(base64.RawURLEncoding.EncodeToString(b)) + } + return connect.NewResponse(result), nil +} +func summary(row dbsqlc.IssuesCurrent) *issuev1.IssueSummary { + var labels, assignees []string + _ = json.Unmarshal([]byte(row.LabelsJson), &labels) + _ = json.Unmarshal([]byte(row.AssigneesJson), &assignees) + return &issuev1.IssueSummary{Id: proto.String(row.IssueID), Number: proto.Int64(row.Number), Title: proto.String(row.Title), State: proto.String(row.State), AuthorLogin: proto.String(row.AuthorLogin), UpdatedAt: proto.String(row.UpdatedAt), Availability: proto.String(row.Availability), Labels: labels, Assignees: assignees} +} +func archivedSummary(issue issuearchive.Issue) (*issuev1.IssueSummary, error) { + var f struct { + Title, State string + UpdatedAt string `json:"updated_at"` + User struct{ Login string } + Labels []struct{ Name string } + Assignees []struct{ Login string } + } + if err := json.Unmarshal(issue.Raw, &f); err != nil { + return nil, err + } + out := &issuev1.IssueSummary{Id: proto.String(issue.ID), Number: proto.Int64(issue.Number), Title: proto.String(f.Title), State: proto.String(f.State), AuthorLogin: proto.String(f.User.Login), UpdatedAt: proto.String(f.UpdatedAt), Availability: proto.String(issue.Availability)} + for _, label := range f.Labels { + out.Labels = append(out.Labels, label.Name) + } + for _, assignee := range f.Assignees { + out.Assignees = append(out.Assignees, assignee.Login) + } + return out, nil +} +func (s *Service) GetIssue(ctx context.Context, req *connect.Request[issuev1.GetIssueRequest]) (*connect.Response[issuev1.GetIssueResponse], error) { + repo, err := s.repo(ctx, req.Msg.GetRepoId()) + if err != nil { + return nil, err + } + store, err := s.store(repo) + if err != nil { + return nil, rpcError(err) + } + revision := req.Msg.GetSnapshot() + if revision == "" { + history, err := store.History(ctx, 1) + if err != nil { + return nil, rpcError(err) + } + if len(history) == 0 { + return nil, rpcError(issuearchive.ErrNoSnapshots) + } + revision = history[0].Commit + } + snapshot, err := store.Load(ctx, revision) + if err != nil { + return nil, rpcError(err) + } + issue, ok := snapshot.Issues[req.Msg.GetIssueId()] + if !ok { + return nil, connect.NewError(connect.CodeNotFound, errors.New("issue not present in snapshot")) + } + item, err := archivedSummary(issue) + if err != nil { + return nil, rpcError(err) + } + comments, _ := json.Marshal(issue.Comments) + timeline, _ := json.Marshal(issue.Timeline) + markdown, err := issuearchive.RenderMarkdown(issue) + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&issuev1.GetIssueResponse{Issue: item, RawJson: proto.String(string(issue.Raw)), CommentsJson: proto.String(string(comments)), TimelineJson: proto.String(string(timeline)), Markdown: proto.String(markdown), Snapshot: proto.String(revision)}), nil +} +func (s *Service) ListSnapshots(ctx context.Context, req *connect.Request[issuev1.ListSnapshotsRequest]) (*connect.Response[issuev1.ListSnapshotsResponse], error) { + repo, err := s.repo(ctx, req.Msg.GetRepoId()) + if err != nil { + return nil, err + } + store, err := s.store(repo) + if err != nil { + return nil, rpcError(err) + } + history, err := store.History(ctx, 100) + if err != nil && !errors.Is(err, issuearchive.ErrNoSnapshots) { + return nil, rpcError(err) + } + out := &issuev1.ListSnapshotsResponse{} + for _, snapshot := range history { + out.Snapshots = append(out.Snapshots, &issuev1.IssueSnapshot{Commit: proto.String(snapshot.Commit), CreatedAt: proto.String(snapshot.CreatedAt.UTC().Format(time.RFC3339Nano)), IssueCount: proto.Int32(int32(snapshot.IssueCount)), CommentCount: proto.Int32(int32(snapshot.CommentCount))}) + } + return connect.NewResponse(out), nil +} +func (s *Service) EnqueueSync(ctx context.Context, req *connect.Request[issuev1.EnqueueSyncRequest]) (*connect.Response[issuev1.EnqueueSyncResponse], error) { + if _, err := s.repo(ctx, req.Msg.GetRepoId()); err != nil { + return nil, err + } + result, item, err := s.Runner.Enqueue(ctx, req.Msg.GetRepoId(), req.Msg.GetFull(), "") + if err != nil { + return nil, rpcError(err) + } + state := taskv1.TaskState_TASK_STATE_QUEUED + if item.State == task.StateRunning { + state = taskv1.TaskState_TASK_STATE_RUNNING + } + enqueue := taskv1.TaskEnqueueResult_TASK_ENQUEUE_RESULT_QUEUED + if result == task.EnqueueResultStarted { + enqueue = taskv1.TaskEnqueueResult_TASK_ENQUEUE_RESULT_STARTED + } + if result == task.EnqueueResultDeduped { + enqueue = taskv1.TaskEnqueueResult_TASK_ENQUEUE_RESULT_DEDUPED + } + return connect.NewResponse(&issuev1.EnqueueSyncResponse{Result: &enqueue, Task: &taskv1.Task{TaskId: proto.String(item.TaskID), JobId: proto.String(item.JobID), JobType: proto.String(item.JobType), Name: proto.String(item.Name), State: &state, CreatedAt: timestamppb.New(item.CreatedAt)}}), nil +} +func (s *Service) export(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("repoID"), 10, 64) + if err != nil { + http.Error(w, "invalid repository", http.StatusBadRequest) + return + } + repo, err := s.repo(r.Context(), id) + if err != nil { + http.Error(w, "repository not found", http.StatusNotFound) + return + } + store, err := s.store(repo) + if err != nil { + http.Error(w, "archive unavailable", http.StatusInternalServerError) + return + } + file, err := os.CreateTemp("", "git-plus-issues-*.zip") + if err != nil { + http.Error(w, "export unavailable", http.StatusInternalServerError) + return + } + defer os.Remove(file.Name()) + defer file.Close() + if err := store.ExportZIP(r.Context(), file, r.URL.Query().Get("snapshot")); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, issuearchive.ErrNoSnapshots) { + status = http.StatusNotFound + } + if errors.Is(err, issuearchive.ErrInvalidRevision) { + status = http.StatusBadRequest + } + http.Error(w, "issue archive export failed", status) + return + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + http.Error(w, "export unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="issues-%d.zip"`, id)) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = io.Copy(w, file) +} +func rpcError(err error) error { + code := connect.CodeInternal + switch { + case errors.Is(err, issuearchive.ErrNoSnapshots): + code = connect.CodeNotFound + case errors.Is(err, issuearchive.ErrInvalidRevision): + code = connect.CodeInvalidArgument + case errors.Is(err, issuetask.ErrDisabled): + code = connect.CodeFailedPrecondition + case errors.Is(err, sql.ErrNoRows): + code = connect.CodeNotFound + } + return connect.NewError(code, err) +} diff --git a/pkg/issueservice/service_test.go b/pkg/issueservice/service_test.go new file mode 100644 index 0000000..c9af603 --- /dev/null +++ b/pkg/issueservice/service_test.go @@ -0,0 +1,177 @@ +package issueservice + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "connectrpc.com/connect" + appdb "github.com/ImSingee/git-plus/db" + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + appconfig "github.com/ImSingee/git-plus/pkg/config" + "github.com/ImSingee/git-plus/pkg/issuearchive" + "github.com/ImSingee/git-plus/pkg/issuesync" + issuev1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1" + "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1/issuev1connect" + "github.com/ImSingee/git-plus/pkg/task" + "google.golang.org/protobuf/proto" +) + +func fixture(t *testing.T) (*Service, dbsqlc.Repo, *issuearchive.Store) { + t.Helper() + ctx := context.Background() + dir := t.TempDir() + if err := appdb.Migrate(ctx, dir); err != nil { + t.Fatal(err) + } + db, err := appdb.Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + now := "2026-10-15T12:00:00Z" + if err := dbsqlc.New(db).CreateRepo(ctx, dbsqlc.CreateRepoParams{SourceID: "test", Platform: "github", RefID: "123", Status: "active", Name: "repo", FullName: "owner/repo", Owner: "owner", Origin: `["default"]`, Meta: "{}", LastSeenAt: now, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + repos, err := dbsqlc.New(db).ListReposForSource(ctx, "test") + if err != nil { + t.Fatal(err) + } + if err := appconfig.Save(appconfig.PathForDataDir(dir), appconfig.Config{Sources: []appconfig.SourceConfig{{ID: "test", Platform: "github", Username: "owner", IssuesEnabled: false}}}); err != nil { + t.Fatal(err) + } + path, err := issuearchive.Path(dir, "test", "123") + if err != nil { + t.Fatal(err) + } + store, err := issuearchive.NewStore(path) + if err != nil { + t.Fatal(err) + } + snapshot := issuearchive.Snapshot{Repository: issuearchive.Repository{SourceID: "test", ID: "123", FullName: "owner/repo"}, Issues: map[string]issuearchive.Issue{ + "9007199254740993": {ID: "9007199254740993", Number: 1, Raw: json.RawMessage(`{"id":9007199254740993,"number":1,"title":"First ","user":{"login":"author"},"labels":[{"name":"bug"}],"assignees":[{"login":"owner"}]}`), Comments: []json.RawMessage{json.RawMessage(`{"id":10,"body":"comment"}`)}, Timeline: []json.RawMessage{}, Availability: "available"}, + "9007199254740994": {ID: "9007199254740994", Number: 2, Raw: json.RawMessage(`{"id":9007199254740994,"number":2,"title":"Second","state":"closed"}`), Comments: []json.RawMessage{}, Timeline: []json.RawMessage{}, Availability: "unavailable"}, + }, Labels: []json.RawMessage{}, Milestones: []json.RawMessage{}, APIVersion: "2022-11-28", StartedAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 10, 15, 12, 1, 0, 0, time.UTC), IssuesSince: now, CommentsSince: now, LastFullReconcileAt: now, Full: false} + result, err := store.Save(ctx, snapshot) + if err != nil { + t.Fatal(err) + } + if err := issuesync.New(dir, db).IndexSnapshot(ctx, repos[0], snapshot, result.Commit); err != nil { + t.Fatal(err) + } + manager := task.NewManager() + t.Cleanup(manager.Close) + return New(dir, db, manager), repos[0], store +} +func TestReadSavedIssuesWhileDisabled(t *testing.T) { + s, repo, store := fixture(t) + ctx := context.Background() + status, err := s.GetStatus(ctx, connect.NewRequest(&issuev1.GetStatusRequest{RepoId: proto.Int64(repo.ID)})) + if err != nil { + t.Fatal(err) + } + if status.Msg.GetStatus().GetEnabled() || status.Msg.GetStatus().GetIssueCount() != 2 { + t.Fatalf("wrong status: %v", status.Msg) + } + first, err := s.ListIssues(ctx, connect.NewRequest(&issuev1.ListIssuesRequest{RepoId: proto.Int64(repo.ID), PageSize: proto.Int32(1)})) + if err != nil { + t.Fatal(err) + } + if len(first.Msg.Issues) != 1 || first.Msg.Issues[0].GetNumber() != 2 || first.Msg.GetNextPageToken() == "" { + t.Fatalf("wrong page: %v", first.Msg) + } + second, err := s.ListIssues(ctx, connect.NewRequest(&issuev1.ListIssuesRequest{RepoId: proto.Int64(repo.ID), PageToken: first.Msg.NextPageToken, PageSize: proto.Int32(1)})) + if err != nil { + t.Fatal(err) + } + if second.Msg.Issues[0].GetId() != "9007199254740993" || second.Msg.GetNextPageToken() != "" { + t.Fatalf("wrong second page: %v", second.Msg) + } + filtered, err := s.ListIssues(ctx, connect.NewRequest(&issuev1.ListIssuesRequest{RepoId: proto.Int64(repo.ID), State: proto.String("open"), Search: proto.String("First")})) + if err != nil || filtered.Msg.GetTotalCount() != 1 { + t.Fatalf("filter: %v %v", filtered, err) + } + history, err := store.History(ctx, 1) + if err != nil { + t.Fatal(err) + } + detail, err := s.GetIssue(ctx, connect.NewRequest(&issuev1.GetIssueRequest{RepoId: proto.Int64(repo.ID), IssueId: proto.String("9007199254740993"), Snapshot: proto.String(history[0].Commit)})) + if err != nil { + t.Fatal(err) + } + if detail.Msg.GetIssue().GetId() != "9007199254740993" || !bytes.Contains([]byte(detail.Msg.GetRawJson()), []byte("9007199254740993")) || detail.Msg.GetCommentsJson() == "[]" || detail.Msg.GetSnapshot() != history[0].Commit { + t.Fatalf("wrong detail: %v", detail.Msg) + } + snapshots, err := s.ListSnapshots(ctx, connect.NewRequest(&issuev1.ListSnapshotsRequest{RepoId: proto.Int64(repo.ID)})) + if err != nil || len(snapshots.Msg.Snapshots) != 1 { + t.Fatalf("snapshots: %v %v", snapshots, err) + } + _, err = s.EnqueueSync(ctx, connect.NewRequest(&issuev1.EnqueueSyncRequest{RepoId: proto.Int64(repo.ID)})) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("disabled sync: %v", err) + } +} +func TestInvalidRequestsAndVerifiedExport(t *testing.T) { + s, repo, _ := fixture(t) + ctx := context.Background() + for _, token := range []string{"not base64!", "e30"} { + _, err := s.ListIssues(ctx, connect.NewRequest(&issuev1.ListIssuesRequest{RepoId: proto.Int64(repo.ID), PageToken: proto.String(token)})) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("token %q: %v", token, err) + } + } + _, err := s.GetIssue(ctx, connect.NewRequest(&issuev1.GetIssueRequest{RepoId: proto.Int64(repo.ID), IssueId: proto.String("9007199254740993"), Snapshot: proto.String("../../HEAD")})) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("revision: %v", err) + } + _, err = s.GetIssue(ctx, connect.NewRequest(&issuev1.GetIssueRequest{RepoId: proto.Int64(repo.ID), IssueId: proto.String("missing")})) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Fatalf("missing: %v", err) + } + mux := http.NewServeMux() + RegisterHandlers(mux, s.DataDir, s.DB, s.Runner.Manager) + response := httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/repos/1/issues/export", nil)) + if response.Code != 200 || response.Header().Get("Content-Type") != "application/zip" || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("export: %d %s", response.Code, response.Body.String()) + } + reader, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len())) + if err != nil { + t.Fatal(err) + } + found := false + for _, f := range reader.File { + if f.Name == "manifest.json" { + found = true + } + } + if !found { + t.Fatal("missing manifest") + } + response = httptest.NewRecorder() + mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/repos/1/issues/export?snapshot=HEAD", nil)) + if response.Code != 400 { + t.Fatalf("invalid export revision: %d", response.Code) + } +} +func TestConnectRoundTrip(t *testing.T) { + s, repo, _ := fixture(t) + mux := http.NewServeMux() + RegisterHandlers(mux, s.DataDir, s.DB, s.Runner.Manager) + server := httptest.NewServer(mux) + defer server.Close() + client := issuev1connect.NewIssueServiceClient(server.Client(), server.URL) + response, err := client.ListIssues(context.Background(), connect.NewRequest(&issuev1.ListIssuesRequest{RepoId: proto.Int64(repo.ID)})) + if err != nil || len(response.Msg.GetIssues()) != 2 { + t.Fatalf("RPC: %v %v", response, err) + } + _, err = client.GetStatus(context.Background(), connect.NewRequest(&issuev1.GetStatusRequest{RepoId: proto.Int64(-1)})) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("validation: %v", err) + } +} diff --git a/pkg/issuesync/index.go b/pkg/issuesync/index.go new file mode 100644 index 0000000..e566b3b --- /dev/null +++ b/pkg/issuesync/index.go @@ -0,0 +1,110 @@ +package issuesync + +import ( + "context" + "encoding/json" + "fmt" + "time" + + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + "github.com/ImSingee/git-plus/pkg/issuearchive" +) + +// IndexSnapshot replaces the disposable index atomically, after Git publication. +func (e *Engine) IndexSnapshot(ctx context.Context, repo dbsqlc.Repo, snapshot issuearchive.Snapshot, commit string) error { + if snapshot.Repository.ID != repo.RefID || snapshot.Repository.SourceID != repo.SourceID { + return fmt.Errorf("snapshot repository identity does not match index target") + } + tx, err := e.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + q := dbsqlc.New(tx) + if err := q.DeleteIssueIndex(ctx, repo.ID); err != nil { + return err + } + commentCount := int64(0) + for id, issue := range snapshot.Issues { + parsedID, number, isPR, err := ParseIssue(issue.Raw) + if err != nil { + return err + } + if parsedID != id || isPR { + return fmt.Errorf("invalid issue identity in snapshot") + } + var fields issueFields + if err := json.Unmarshal(issue.Raw, &fields); err != nil { + return err + } + labels := make([]string, 0, len(fields.Labels)) + assignees := make([]string, 0, len(fields.Assignees)) + for _, label := range fields.Labels { + labels = append(labels, label.Name) + } + for _, assignee := range fields.Assignees { + assignees = append(assignees, assignee.Login) + } + labelsJSON, _ := json.Marshal(labels) + assigneesJSON, _ := json.Marshal(assignees) + if err := q.InsertIssueIndex(ctx, dbsqlc.InsertIssueIndexParams{ + RepoID: repo.ID, IssueID: id, Number: number, Title: fields.Title, State: fields.State, + AuthorLogin: fields.User.Login, UpdatedAt: fields.UpdatedAt, Availability: issue.Availability, + LabelsJson: string(labelsJSON), AssigneesJson: string(assigneesJSON), + }); err != nil { + return err + } + commentCount += int64(len(issue.Comments)) + } + if err := q.CompleteIssueSync(ctx, dbsqlc.CompleteIssueSyncParams{ + RepoID: repo.ID, LatestCommit: commit, IssuesSince: snapshot.IssuesSince, CommentsSince: snapshot.CommentsSince, + LastSuccessfulAt: snapshot.CompletedAt.UTC().Format(time.RFC3339Nano), LastFullReconcileAt: snapshot.LastFullReconcileAt, + IssueCount: int64(len(snapshot.Issues)), CommentCount: commentCount, + }); err != nil { + return err + } + return tx.Commit() +} + +// Reindex restores the latest archive index. Historical snapshots never become current. +func (e *Engine) Reindex(ctx context.Context, repo dbsqlc.Repo, revision string) error { + if revision != "" { + return fmt.Errorf("only the latest snapshot can rebuild the current index") + } + path, err := issuearchive.Path(e.DataDir, repo.SourceID, repo.RefID) + if err != nil { + return err + } + release, err := issuearchive.AcquireLock(path + ".sync.lock") + if err != nil { + return err + } + defer release() + return e.reindexUnlocked(ctx, repo) +} + +func (e *Engine) reindexUnlocked(ctx context.Context, repo dbsqlc.Repo) error { + path, err := issuearchive.Path(e.DataDir, repo.SourceID, repo.RefID) + if err != nil { + return err + } + store, err := issuearchive.NewStore(path) + if err != nil { + return err + } + if err := store.Verify(ctx, ""); err != nil { + return err + } + snapshot, err := store.Load(ctx, "") + if err != nil { + return err + } + history, err := store.History(ctx, 1) + if err != nil { + return err + } + if len(history) == 0 { + return issuearchive.ErrNoSnapshots + } + return e.IndexSnapshot(ctx, repo, snapshot, history[0].Commit) +} diff --git a/pkg/issuesync/sync.go b/pkg/issuesync/sync.go new file mode 100644 index 0000000..47f9015 --- /dev/null +++ b/pkg/issuesync/sync.go @@ -0,0 +1,410 @@ +// Package issuesync synchronizes observable GitHub issue state to a local archive. +package issuesync + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + "github.com/ImSingee/git-plus/pkg/githubapi" + "github.com/ImSingee/git-plus/pkg/issuearchive" +) + +const ReconcileInterval = 7 * 24 * time.Hour +const Overlap = 5 * time.Minute + +var numericID = regexp.MustCompile(`^[1-9][0-9]*$`) +var repoLocks sync.Map + +type Reporter interface { + SetProgress(string, map[string]any) error +} +type Request struct { + Repo dbsqlc.Repo + Token string + Full bool +} +type Engine struct { + DataDir string + DB *sql.DB + Client *githubapi.Client + Now func() time.Time +} + +func New(dataDir string, db *sql.DB) *Engine { + return &Engine{DataDir: dataDir, DB: db, Client: githubapi.New("", nil), Now: time.Now} +} + +type issueFields struct { + ID json.Number `json:"id"` + Number int64 `json:"number"` + NodeID string `json:"node_id"` + URL string `json:"url"` + RepositoryURL string `json:"repository_url"` + Title string `json:"title"` + State string `json:"state"` + UpdatedAt string `json:"updated_at"` + PullRequest json.RawMessage `json:"pull_request"` + User struct { + Login string `json:"login"` + } `json:"user"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Assignees []struct { + Login string `json:"login"` + } `json:"assignees"` +} + +func ParseIssue(raw json.RawMessage) (id string, number int64, isPR bool, err error) { + var f issueFields + if err = json.Unmarshal(raw, &f); err != nil { + return "", 0, false, fmt.Errorf("decode issue object: %w", err) + } + if !numericID.MatchString(f.ID.String()) || f.Number <= 0 { + return "", 0, false, fmt.Errorf("issue missing valid id or number") + } + return f.ID.String(), f.Number, len(f.PullRequest) > 0 && !bytes.Equal(f.PullRequest, []byte("null")), nil +} + +type checkpoint struct { + BaseCommit string `json:"base_commit"` + StartedAt time.Time `json:"started_at"` + Full bool `json:"full"` + IssuesSince string `json:"issues_since"` + CommentsSince string `json:"comments_since"` +} + +func (e *Engine) Sync(ctx context.Context, request Request, reporter Reporter) (err error) { + repo := request.Repo + archivePath, err := issuearchive.Path(e.DataDir, repo.SourceID, repo.RefID) + if err != nil { + return err + } + gateValue, _ := repoLocks.LoadOrStore(archivePath, make(chan struct{}, 1)) + gate := gateValue.(chan struct{}) + select { + case gate <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-gate }() + release, err := issuearchive.AcquireLock(archivePath + ".sync.lock") + if err != nil { + return err + } + defer release() + if e.DB == nil { + return fmt.Errorf("issue database is required") + } + queries := dbsqlc.New(e.DB) + if err = queries.SetIssueSyncRunning(ctx, dbsqlc.SetIssueSyncRunningParams{RepoID: repo.ID, UpdatedAt: e.Now().UTC().Format(time.RFC3339Nano)}); err != nil { + return err + } + defer func() { + if err != nil { + failureCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + writeErr := queries.SetIssueSyncFailed(failureCtx, dbsqlc.SetIssueSyncFailedParams{RepoID: repo.ID, LastError: err.Error(), UpdatedAt: e.Now().UTC().Format(time.RFC3339Nano)}) + if writeErr != nil { + err = errors.Join(err, fmt.Errorf("record issue sync failure: %w", writeErr)) + } + } + }() + store, err := issuearchive.NewStore(archivePath) + if err != nil { + return err + } + previous, loadErr := store.Load(ctx, "") + if loadErr != nil && !errors.Is(loadErr, issuearchive.ErrNoSnapshots) { + return loadErr + } + state, stateErr := queries.GetIssueSyncState(ctx, repo.ID) + if stateErr != nil && !errors.Is(stateErr, sql.ErrNoRows) { + return stateErr + } + history, err := store.History(ctx, 1) + if err != nil && !errors.Is(err, issuearchive.ErrNoSnapshots) { + return err + } + baseCommit := "" + if len(history) > 0 { + baseCommit = history[0].Commit + } + // A published Git snapshot wins after a crash between ref publication and index commit. + if baseCommit != "" && state.LatestCommit != baseCommit { + if err = e.reindexUnlocked(ctx, repo); err != nil { + return err + } + state, err = queries.GetIssueSyncState(ctx, repo.ID) + if err != nil { + return err + } + if err = queries.SetIssueSyncRunning(ctx, dbsqlc.SetIssueSyncRunningParams{RepoID: repo.ID, UpdatedAt: e.Now().UTC().Format(time.RFC3339Nano)}); err != nil { + return err + } + } + full := request.Full || baseCommit == "" || state.IssuesSince == "" || state.CommentsSince == "" + if last, parseErr := time.Parse(time.RFC3339Nano, state.LastFullReconcileAt); parseErr != nil || e.Now().Sub(last) >= ReconcileInterval { + full = true + } + cp := checkpoint{BaseCommit: baseCommit, StartedAt: e.Now().UTC(), Full: full, IssuesSince: state.IssuesSince, CommentsSince: state.CommentsSince} + stage := filepath.Join(e.DataDir, "staging", "issues", repo.SourceID, repo.RefID) + if err = os.MkdirAll(stage, 0o700); err != nil { + return err + } + reusedCheckpoint := false + if data, readErr := os.ReadFile(filepath.Join(stage, "checkpoint.json")); readErr == nil { + var old checkpoint + if json.Unmarshal(data, &old) == nil && old.BaseCommit == baseCommit && !old.StartedAt.IsZero() && (!full || old.Full) { + cp = old + reusedCheckpoint = true + } + } + if !reusedCheckpoint { + if err = os.RemoveAll(stage); err != nil { + return err + } + if err = os.MkdirAll(stage, 0o700); err != nil { + return err + } + } + if err = atomicJSON(filepath.Join(stage, "checkpoint.json"), cp); err != nil { + return err + } + full = cp.Full + if err = progress(reporter, "Reading GitHub issues", map[string]any{"phase": "issues", "repo_id": repo.ID, "full": full}); err != nil { + return err + } + parts := strings.Split(repo.FullName, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid repository full name") + } + endpoint := "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + rawRepo, err := e.Client.Object(ctx, request.Token, endpoint) + if err != nil { + return err + } + var remote struct { + ID json.Number `json:"id"` + NodeID string `json:"node_id"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + Private bool `json:"private"` + HasIssues *bool `json:"has_issues"` + } + if err = json.Unmarshal(rawRepo, &remote); err != nil { + return fmt.Errorf("decode repository: %w", err) + } + if remote.ID.String() != repo.RefID { + return fmt.Errorf("repository identity changed; refusing to overwrite archive") + } + if remote.HasIssues != nil && !*remote.HasIssues { + return &githubapi.Error{Status: 410} + } + // Use the canonical API URL after a same-origin rename redirect. + if remote.URL != "" { + endpoint = remote.URL + } + query := url.Values{"state": {"all"}, "sort": {"created"}, "direction": {"asc"}, "per_page": {"100"}} + if !full { + query.Set("since", overlap(cp.IssuesSince)) + } + rawIssues, err := e.Client.Pages(ctx, request.Token, endpoint+"/issues?"+query.Encode()) + if err != nil { + return err + } + candidates := map[string]issuearchive.Issue{} + byURL := map[string]string{} + for id, issue := range previous.Issues { + var f issueFields + if json.Unmarshal(issue.Raw, &f) == nil && f.URL != "" { + byURL[f.URL] = id + } + } + for _, raw := range rawIssues { + id, number, isPR, parseErr := ParseIssue(raw) + if parseErr != nil { + return parseErr + } + if isPR { + continue + } + candidates[id] = issuearchive.Issue{ID: id, Number: number, Raw: raw, Availability: "available"} + var f issueFields + _ = json.Unmarshal(raw, &f) + byURL[f.URL] = id + } + // Comment edits have an independent cursor. Never rely solely on issue.updated_at. + commentQuery := url.Values{"sort": {"created"}, "direction": {"asc"}, "per_page": {"100"}} + commentSince := cp.CommentsSince + if full { + commentSince = cp.StartedAt.Format(time.RFC3339Nano) + } + if commentSince != "" { + commentQuery.Set("since", overlap(commentSince)) + } + changedComments, err := e.Client.Pages(ctx, request.Token, endpoint+"/issues/comments?"+commentQuery.Encode()) + if err != nil { + return err + } + commentChanged := map[string]bool{} + for _, raw := range changedComments { + var comment struct { + IssueURL string `json:"issue_url"` + } + if json.Unmarshal(raw, &comment) != nil || comment.IssueURL == "" { + return fmt.Errorf("comment missing issue_url") + } + id := byURL[comment.IssueURL] + if id == "" || candidates[id].ID == "" { + issueRaw, fetchErr := e.Client.Object(ctx, request.Token, comment.IssueURL) + if fetchErr != nil { + return fetchErr + } + issueID, number, isPR, parseErr := ParseIssue(issueRaw) + if parseErr != nil { + return parseErr + } + if isPR { + continue + } + var f issueFields + _ = json.Unmarshal(issueRaw, &f) + if f.RepositoryURL != "" && remote.URL != "" && f.RepositoryURL != remote.URL { + continue + } + id = issueID + candidates[id] = issuearchive.Issue{ID: id, Number: number, Raw: issueRaw, Availability: "available"} + byURL[comment.IssueURL] = id + } + commentChanged[id] = true + } + ids := make([]string, 0, len(candidates)) + for id := range candidates { + ids = append(ids, id) + } + sort.Strings(ids) + for index, id := range ids { + issue := candidates[id] + cacheFile := filepath.Join(stage, "issue-"+id+".json") + cachedIssue := issuearchive.Issue{} + if data, readErr := os.ReadFile(cacheFile); readErr == nil { + _ = json.Unmarshal(data, &cachedIssue) + } + // Cache only this interrupted observation window; comment changes force a refresh. + if cachedIssue.ID == id && bytes.Equal(compact(cachedIssue.Raw), compact(issue.Raw)) && !commentChanged[id] && cachedIssue.Comments != nil && cachedIssue.Timeline != nil { + issue.Comments = cachedIssue.Comments + issue.Timeline = cachedIssue.Timeline + } else { + issuePath := endpoint + "/issues/" + strconv.FormatInt(issue.Number, 10) + issue.Comments, err = e.Client.Pages(ctx, request.Token, issuePath+"/comments?per_page=100") + if err != nil { + return err + } + issue.Timeline, err = e.Client.Pages(ctx, request.Token, issuePath+"/timeline?per_page=100") + if err != nil { + return err + } + if err = atomicJSON(cacheFile, issue); err != nil { + return err + } + } + candidates[id] = issue + if err = progress(reporter, fmt.Sprintf("Archived issue threads (%d/%d)", index+1, len(ids)), map[string]any{"phase": "threads", "processed": index + 1, "total": len(ids), "repo_id": repo.ID}); err != nil { + return err + } + } + labels, err := e.Client.Pages(ctx, request.Token, endpoint+"/labels?per_page=100") + if err != nil { + return err + } + milestones, err := e.Client.Pages(ctx, request.Token, endpoint+"/milestones?state=all&per_page=100") + if err != nil { + return err + } + watermark := cp.StartedAt.UTC().Format(time.RFC3339Nano) + lastFull := state.LastFullReconcileAt + if full { + lastFull = watermark + } + snapshot := issuearchive.Snapshot{Repository: issuearchive.Repository{SourceID: repo.SourceID, ID: repo.RefID, NodeID: remote.NodeID, FullName: remote.FullName, HTMLURL: remote.HTMLURL, Private: remote.Private}, Issues: candidates, Labels: labels, Milestones: milestones, APIVersion: githubapi.Version, StartedAt: cp.StartedAt, CompletedAt: e.Now().UTC(), IssuesSince: watermark, CommentsSince: watermark, LastFullReconcileAt: lastFull, Full: full} + saved, err := store.Save(ctx, snapshot) + if err != nil { + return err + } + merged, err := store.Load(ctx, saved.Commit) + if err != nil { + return err + } + // No-op snapshots retain their old manifest. Advance only the live safe cursor. + merged.IssuesSince = watermark + merged.CommentsSince = watermark + merged.LastFullReconcileAt = lastFull + merged.CompletedAt = snapshot.CompletedAt + if err = e.IndexSnapshot(ctx, repo, merged, saved.Commit); err != nil { + return err + } + if err = os.RemoveAll(stage); err != nil { + return fmt.Errorf("remove completed checkpoint: %w", err) + } + return progress(reporter, "Issues backup complete (attachments are links only)", map[string]any{"phase": "done", "repo_id": repo.ID, "snapshot": saved.Commit, "changed": saved.Changed, "issues": saved.IssueCount, "comments": saved.CommentCount, "attachments": "links-only"}) +} +func overlap(value string) string { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return value + } + return parsed.Add(-Overlap).UTC().Format(time.RFC3339) +} +func compact(raw []byte) []byte { + var b bytes.Buffer + if json.Compact(&b, raw) != nil { + return raw + } + return b.Bytes() +} +func progress(r Reporter, s string, m map[string]any) error { + if r == nil { + return nil + } + return r.SetProgress(s, m) +} +func atomicJSON(path string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".checkpoint-*") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err = f.Write(data); err != nil { + f.Close() + return err + } + if err = f.Sync(); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(name, path) +} diff --git a/pkg/issuesync/sync_test.go b/pkg/issuesync/sync_test.go new file mode 100644 index 0000000..703c640 --- /dev/null +++ b/pkg/issuesync/sync_test.go @@ -0,0 +1,512 @@ +package issuesync + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + appdb "github.com/ImSingee/git-plus/db" + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + "github.com/ImSingee/git-plus/pkg/githubapi" + "github.com/ImSingee/git-plus/pkg/issuearchive" +) + +var syncTime = time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + +const fixtureRepoPath = "/repos/owner/repo" +const fixtureIssueID = "9007199254740993" + +// fixtureAPI uses real HTTP and generated SQLite queries. The tests intentionally +// preserve unknown fields and >2^53 numeric IDs across API, Git and index layers. +type fixtureAPI struct { + server *httptest.Server + mu sync.Mutex + requests []string + issues []int + comments map[int][]json.RawMessage + changed []json.RawMessage + failures map[string]int + paginate bool + remoteID string +} + +func newFixture(t *testing.T) *fixtureAPI { + t.Helper() + f := &fixtureAPI{issues: []int{1, 2, 99}, comments: map[int][]json.RawMessage{1: {json.RawMessage(`{"id":501,"body":"original comment"}`)}}, failures: map[string]int{}, remoteID: "123"} + f.server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.server.Close) + return f +} + +func (f *fixtureAPI) issue(number int) json.RawMessage { + id := "2" + if number == 1 { + id = fixtureIssueID + } + if number == 99 { + return json.RawMessage(`{"id":99,"number":99,"title":"A pull request","pull_request":{"url":"ignored"}}`) + } + return json.RawMessage(fmt.Sprintf(`{"id":%s,"number":%d,"title":"Issue %d","body":"Body","state":"open","url":%q,"repository_url":%q,"html_url":"https://github.com/owner/repo/issues/%d","updated_at":"2026-10-15T11:00:00Z","unknown":{"large":18446744073709551616001},"user":{"login":"author"},"labels":[{"name":"bug"}],"assignees":[{"login":"reviewer"}]}`, id, number, number, f.server.URL+fixtureRepoPath+fmt.Sprintf("/issues/%d", number), f.server.URL+fixtureRepoPath, number)) +} + +func (f *fixtureAPI) serve(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.URL.String()) + if status := f.failures[r.URL.Path]; status != 0 { + w.WriteHeader(status) + io.WriteString(w, `{"message":"private-token-do-not-log"}`) + return + } + if r.URL.Path == fixtureRepoPath { + fmt.Fprintf(w, `{"id":%s,"node_id":"R_123","full_name":"owner/repo","url":%q,"html_url":"https://github.com/owner/repo","private":true,"has_issues":true}`, f.remoteID, f.server.URL+fixtureRepoPath) + return + } + page := func(items []json.RawMessage) { + if items == nil { + items = []json.RawMessage{} + } + if f.paginate && len(items) > 1 { + if r.URL.Query().Get("page") != "2" { + query := r.URL.Query() + query.Set("page", "2") + w.Header().Set("Link", fmt.Sprintf(`<%s%s?%s>; rel="next"`, f.server.URL, r.URL.Path, query.Encode())) + items = items[:1] + } else { + items = items[1:] + } + } + _ = json.NewEncoder(w).Encode(items) + } + switch r.URL.Path { + case fixtureRepoPath + "/issues": + items := make([]json.RawMessage, 0, len(f.issues)) + for _, number := range f.issues { + items = append(items, f.issue(number)) + } + page(items) + case fixtureRepoPath + "/issues/comments": + page(f.changed) + case fixtureRepoPath + "/issues/1": + w.Write(f.issue(1)) + case fixtureRepoPath + "/issues/2": + w.Write(f.issue(2)) + case fixtureRepoPath + "/issues/99": + w.Write(f.issue(99)) + case fixtureRepoPath + "/issues/1/comments": + page(f.comments[1]) + case fixtureRepoPath + "/issues/2/comments": + page(f.comments[2]) + case fixtureRepoPath + "/issues/1/timeline", fixtureRepoPath + "/issues/2/timeline": + page([]json.RawMessage{json.RawMessage(`{"id":11,"event":"labeled"}`), json.RawMessage(`{"id":12,"event":"assigned"}`)}) + case fixtureRepoPath + "/labels": + page([]json.RawMessage{json.RawMessage(`{"id":1,"name":"bug"}`), json.RawMessage(`{"id":2,"name":"help wanted"}`)}) + case fixtureRepoPath + "/milestones": + page([]json.RawMessage{json.RawMessage(`{"id":1,"title":"v1","state":"open"}`), json.RawMessage(`{"id":2,"title":"old","state":"closed"}`)}) + default: + w.WriteHeader(404) + } +} + +func (f *fixtureAPI) count(path string) int { + f.mu.Lock() + defer f.mu.Unlock() + count := 0 + for _, request := range f.requests { + if strings.Split(request, "?")[0] == path { + count++ + } + } + return count +} + +func syncTestDB(t *testing.T) (*sql.DB, dbsqlc.Repo) { + t.Helper() + dir := t.TempDir() + if err := appdb.Migrate(context.Background(), dir); err != nil { + t.Fatal(err) + } + db, err := appdb.Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + q := dbsqlc.New(db) + if err := q.CreateRepo(context.Background(), dbsqlc.CreateRepoParams{SourceID: "source-1", Platform: "github", RefID: "123", Status: "active", Name: "repo", FullName: "owner/repo", Owner: "owner", Origin: "{}", Meta: "{}", LastSeenAt: syncTime.Format(time.RFC3339), CreatedAt: syncTime.Format(time.RFC3339), UpdatedAt: syncTime.Format(time.RFC3339)}); err != nil { + t.Fatal(err) + } + repo, err := q.GetRepoById(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + return db, repo +} + +func syncHarness(t *testing.T) (*Engine, *fixtureAPI, dbsqlc.Repo) { + t.Helper() + db, repo := syncTestDB(t) + api := newFixture(t) + engine := New(t.TempDir(), db) + engine.Client = githubapi.New(api.server.URL, api.server.Client()) + engine.Client.Sleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() } + engine.Now = func() time.Time { return syncTime } + return engine, api, repo +} + +func runSync(t *testing.T, e *Engine, repo dbsqlc.Repo, full bool) { + t.Helper() + if err := e.Sync(context.Background(), Request{Repo: repo, Token: "private-token-do-not-log", Full: full}, nil); err != nil { + t.Fatal(err) + } +} + +func syncState(t *testing.T, e *Engine, repo dbsqlc.Repo) dbsqlc.IssueSyncState { + t.Helper() + state, err := dbsqlc.New(e.DB).GetIssueSyncState(context.Background(), repo.ID) + if err != nil { + t.Fatal(err) + } + return state +} + +func syncStore(t *testing.T, e *Engine, repo dbsqlc.Repo) *issuearchive.Store { + t.Helper() + path, err := issuearchive.Path(e.DataDir, repo.SourceID, repo.RefID) + if err != nil { + t.Fatal(err) + } + store, err := issuearchive.NewStore(path) + if err != nil { + t.Fatal(err) + } + return store +} + +func TestFullSyncPaginatesAllCollectionsAndSkipsPullRequests(t *testing.T) { + e, api, repo := syncHarness(t) + api.paginate = true + api.comments[1] = append(api.comments[1], json.RawMessage(`{"id":502,"body":"page two"}`)) + runSync(t, e, repo, false) + state := syncState(t, e, repo) + if state.Status != "idle" || state.IssueCount != 2 || state.CommentCount != 2 || state.LatestCommit == "" || state.IssuesSince != syncTime.Format(time.RFC3339) || state.CommentsSince != state.IssuesSince { + t.Fatalf("unexpected state: %+v", state) + } + store := syncStore(t, e, repo) + snapshot, err := store.Load(context.Background(), state.LatestCommit) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Issues) != 2 || len(snapshot.Labels) != 2 || len(snapshot.Milestones) != 2 || len(snapshot.Issues[fixtureIssueID].Timeline) != 2 { + t.Fatalf("pagination data missing: %+v", snapshot) + } + if !strings.Contains(string(snapshot.Issues[fixtureIssueID].Raw), "18446744073709551616001") { + t.Fatal("large numeric unknown field lost") + } + for _, path := range []string{"/issues", "/issues/1/comments", "/issues/1/timeline", "/labels", "/milestones"} { + if api.count(fixtureRepoPath+path) != 2 { + t.Errorf("collection not fully paginated: %s", path) + } + } + if api.count(fixtureRepoPath+"/issues/99/comments") != 0 { + t.Fatal("fetched PR comments") + } + rows, err := dbsqlc.New(e.DB).ListIssuesCurrent(context.Background(), dbsqlc.ListIssuesCurrentParams{RepoID: repo.ID, Column2: "", Column3: "", Limit: 100}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[1].IssueID != fixtureIssueID || rows[1].LabelsJson != `["bug"]` || rows[1].AssigneesJson != `["reviewer"]` { + t.Fatalf("index incorrect: %+v", rows) + } + if _, err := os.Stat(filepath.Join(e.DataDir, "staging", "issues", repo.SourceID, repo.RefID)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("successful checkpoint not removed: %v", err) + } +} + +func TestIndependentCommentCursorCapturesEditAndNoOpAdvancesLiveCursor(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + next := syncTime.Add(time.Hour) + e.Now = func() time.Time { return next } + api.issues = nil // The issue.updated_at feed did not change. + api.comments[1] = []json.RawMessage{json.RawMessage(`{"id":501,"body":"edited independently"}`)} + api.changed = []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"id":501,"issue_url":%q}`, api.server.URL+fixtureRepoPath+"/issues/1"))} + runSync(t, e, repo, false) + after := syncState(t, e, repo) + if after.LatestCommit == before.LatestCommit || after.IssueCount != 2 || after.CommentsSince != next.Format(time.RFC3339) { + t.Fatalf("independent comment edit not saved: %+v", after) + } + snapshot, err := syncStore(t, e, repo).Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(snapshot.Issues[fixtureIssueID].Comments[0]), "edited independently") { + t.Fatal("comment edit lost") + } + if api.count(fixtureRepoPath+"/issues/1") != 1 { + t.Fatal("comment-only change did not fetch issue") + } + api.changed = nil + next = next.Add(time.Hour) + runSync(t, e, repo, false) + noop := syncState(t, e, repo) + if noop.LatestCommit != after.LatestCommit || noop.IssuesSince != next.Format(time.RFC3339) || noop.CommentsSince != next.Format(time.RFC3339) { + t.Fatalf("no-op live cursor incorrect: %+v", noop) + } + history, err := syncStore(t, e, repo).History(context.Background(), 0) + if err != nil || len(history) != 2 { + t.Fatalf("no-op created a snapshot: %+v %v", history, err) + } + api.mu.Lock() + defer api.mu.Unlock() + foundIssue, foundComment := false, false + for _, request := range api.requests { + if strings.Contains(request, "since=2026-10-15T11%3A55%3A00Z") { + if strings.HasPrefix(request, fixtureRepoPath+"/issues?") { + foundIssue = true + } + if strings.HasPrefix(request, fixtureRepoPath+"/issues/comments?") { + foundComment = true + } + } + } + if !foundIssue || !foundComment { + t.Fatal("independent overlapping cursors missing") + } +} + +func TestFailedSyncDoesNotAdvanceWatermarksOrPublishPartialSnapshot(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + e.Now = func() time.Time { return syncTime.Add(time.Hour) } + api.failures[fixtureRepoPath+"/issues/1/timeline"] = 403 + if err := e.Sync(context.Background(), Request{Repo: repo, Token: "private-token-do-not-log"}, nil); err == nil { + t.Fatal("expected permission failure") + } + after := syncState(t, e, repo) + if after.Status != "failed" || after.LatestCommit != before.LatestCommit || after.IssuesSince != before.IssuesSince || after.CommentsSince != before.CommentsSince || after.LastSuccessfulAt != before.LastSuccessfulAt { + t.Fatalf("failure advanced state: before=%+v after=%+v", before, after) + } + if strings.Contains(after.LastError, "private-token-do-not-log") { + t.Fatal("failure leaked token or API body into SQLite") + } + history, err := syncStore(t, e, repo).History(context.Background(), 0) + if err != nil || len(history) != 1 { + t.Fatalf("partial snapshot published: %+v %v", history, err) + } +} + +func TestInaccessibleRepositoryRetainsArchiveAndIndex(t *testing.T) { + for _, status := range []int{403, 404, 410} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + api.failures[fixtureRepoPath] = status + if err := e.Sync(context.Background(), Request{Repo: repo, Full: true}, nil); err == nil { + t.Fatal("expected inaccessible repository failure") + } + after := syncState(t, e, repo) + if after.LatestCommit != before.LatestCommit || after.IssueCount != before.IssueCount || after.IssuesSince != before.IssuesSince { + t.Fatal("inaccessible repo replaced a good backup") + } + snapshot, err := syncStore(t, e, repo).Load(context.Background(), "") + if err != nil || len(snapshot.Issues) != 2 || snapshot.Issues[fixtureIssueID].Availability != issuearchive.AvailabilityAvailable { + t.Fatalf("retained snapshot changed: %+v %v", snapshot, err) + } + }) + } +} + +func TestFullReconcileRetainsMissingIssueAsUnavailable(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + api.issues = []int{2} + e.Now = func() time.Time { return syncTime.Add(24 * time.Hour) } + runSync(t, e, repo, true) + snapshot, err := syncStore(t, e, repo).Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + missing := snapshot.Issues[fixtureIssueID] + if missing.Availability != issuearchive.AvailabilityUnavailable || missing.MissingSince == "" || len(missing.Comments) != 1 || len(missing.Raw) == 0 { + t.Fatalf("missing issue was not safely retained: %+v", missing) + } + if snapshot.Issues["2"].Availability != issuearchive.AvailabilityAvailable { + t.Fatal("present issue was marked unavailable") + } + rows, err := dbsqlc.New(e.DB).ListIssuesCurrent(context.Background(), dbsqlc.ListIssuesCurrentParams{RepoID: repo.ID, Column2: "", Column3: "", Limit: 100}) + if err != nil || len(rows) != 2 || rows[1].Availability != issuearchive.AvailabilityUnavailable { + t.Fatalf("index did not retain missing issue: %+v %v", rows, err) + } +} + +func TestInterruptedCheckpointResumesCompletedIssueThreads(t *testing.T) { + e, api, repo := syncHarness(t) + // IDs are processed lexicographically: issue 2 completes before the large ID. + api.failures[fixtureRepoPath+"/issues/1/timeline"] = 503 + if err := e.Sync(context.Background(), Request{Repo: repo}, nil); err == nil { + t.Fatal("expected interrupted first snapshot") + } + stage := filepath.Join(e.DataDir, "staging", "issues", repo.SourceID, repo.RefID) + if _, err := os.Stat(filepath.Join(stage, "issue-2.json")); err != nil { + t.Fatalf("completed issue checkpoint missing: %v", err) + } + if _, err := syncStore(t, e, repo).Load(context.Background(), ""); !errors.Is(err, issuearchive.ErrNoSnapshots) { + t.Fatalf("failed first run published data: %v", err) + } + completedCalls := api.count(fixtureRepoPath + "/issues/2/comments") + delete(api.failures, fixtureRepoPath+"/issues/1/timeline") + e.Now = func() time.Time { return syncTime.Add(time.Hour) } + runSync(t, e, repo, false) + if api.count(fixtureRepoPath+"/issues/2/comments") != completedCalls { + t.Fatal("resume re-fetched an already completed thread") + } + state := syncState(t, e, repo) + if state.IssuesSince != syncTime.Format(time.RFC3339) || state.CommentsSince != syncTime.Format(time.RFC3339) { + t.Fatalf("resume skipped original observation window: %+v", state) + } + if state.LastSuccessfulAt != syncTime.Add(time.Hour).Format(time.RFC3339) { + t.Fatal("completion time not updated after resume") + } +} + +func TestOfflineReindexRebuildsFreshSQLiteAndSharesSyncLock(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + original := syncState(t, e, repo) + beforeCalls := api.count(fixtureRepoPath) + rebuiltDB, rebuiltRepo := syncTestDB(t) + e.DB = rebuiltDB + path, err := issuearchive.Path(e.DataDir, repo.SourceID, repo.RefID) + if err != nil { + t.Fatal(err) + } + release, err := issuearchive.AcquireLock(path + ".sync.lock") + if err != nil { + t.Fatal(err) + } + if err := e.Reindex(context.Background(), rebuiltRepo, ""); !errors.Is(err, issuearchive.ErrBusy) { + t.Fatalf("reindex ignored cross-process sync lock: %v", err) + } + if err := e.Sync(context.Background(), Request{Repo: rebuiltRepo}, nil); !errors.Is(err, issuearchive.ErrBusy) { + t.Fatalf("sync ignored cross-process lock: %v", err) + } + release() + if err := e.Reindex(context.Background(), rebuiltRepo, ""); err != nil { + t.Fatal(err) + } + restored := syncState(t, e, rebuiltRepo) + if restored.LatestCommit != original.LatestCommit || restored.IssueCount != 2 || restored.CommentCount != 1 || restored.IssuesSince != original.IssuesSince { + t.Fatalf("reindex state incorrect: %+v", restored) + } + rows, err := dbsqlc.New(e.DB).ListIssuesCurrent(context.Background(), dbsqlc.ListIssuesCurrentParams{RepoID: rebuiltRepo.ID, Column2: "", Column3: "", Limit: 100}) + if err != nil || len(rows) != 2 { + t.Fatalf("index not rebuilt: %+v %v", rows, err) + } + if api.count(fixtureRepoPath) != beforeCalls { + t.Fatal("offline reindex contacted GitHub") + } + if err := e.Reindex(context.Background(), rebuiltRepo, original.LatestCommit); err == nil { + t.Fatal("historical reindex allowed replacing current index") + } +} + +func TestRepositoryIdentityChangeCannotReplaceBackup(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + api.remoteID = "124" + if err := e.Sync(context.Background(), Request{Repo: repo, Full: true}, nil); err == nil { + t.Fatal("accepted repository identity change") + } + if after := syncState(t, e, repo); after.LatestCommit != before.LatestCommit { + t.Fatal("repository identity change overwrote archive") + } +} + +func TestPublishedGitSnapshotWinsAfterIndexCommitCrash(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + store := syncStore(t, e, repo) + snapshot, err := store.Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + issue := snapshot.Issues[fixtureIssueID] + issue.Comments = append(issue.Comments, json.RawMessage(`{"id":502,"body":"published before database crash"}`)) + snapshot.Issues[fixtureIssueID] = issue + snapshot.CompletedAt = syncTime.Add(time.Hour) + snapshot.IssuesSince = snapshot.CompletedAt.Format(time.RFC3339) + snapshot.CommentsSince = snapshot.IssuesSince + published, err := store.Save(context.Background(), snapshot) + if err != nil || !published.Changed { + t.Fatalf("publish simulated crash snapshot: %+v %v", published, err) + } + if state := syncState(t, e, repo); state.LatestCommit != before.LatestCommit { + t.Fatal("test must leave SQLite behind Git") + } + api.failures[fixtureRepoPath] = 404 + e.Now = func() time.Time { return syncTime.Add(2 * time.Hour) } + if err := e.Sync(context.Background(), Request{Repo: repo}, nil); err == nil { + t.Fatal("expected subsequent API failure") + } + recovered := syncState(t, e, repo) + if recovered.LatestCommit != published.Commit || recovered.CommentCount != 2 || recovered.IssuesSince != snapshot.IssuesSince || recovered.Status != "failed" { + t.Fatalf("published snapshot did not win during crash recovery: %+v", recovered) + } +} + +func TestFailedIndexReplacementRollsBackEntireTransaction(t *testing.T) { + e, _, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + snapshot, err := syncStore(t, e, repo).Load(context.Background(), "") + if err != nil { + t.Fatal(err) + } + issue := snapshot.Issues[fixtureIssueID] + issue.Raw = json.RawMessage(`{"id":3,"number":1,"title":"identity mismatch"}`) + snapshot.Issues[fixtureIssueID] = issue + if err := e.IndexSnapshot(context.Background(), repo, snapshot, "unpublished"); err == nil { + t.Fatal("invalid index replacement succeeded") + } + after := syncState(t, e, repo) + if after.LatestCommit != before.LatestCommit || after.IssueCount != before.IssueCount { + t.Fatal("failed transaction changed sync state") + } + rows, err := dbsqlc.New(e.DB).ListIssuesCurrent(context.Background(), dbsqlc.ListIssuesCurrentParams{RepoID: repo.ID, Column2: "", Column3: "", Limit: 100}) + if err != nil || len(rows) != 2 || rows[1].IssueID != fixtureIssueID { + t.Fatalf("failed transaction lost prior index rows: %+v %v", rows, err) + } +} + +func TestSyncCancellationRetainsPriorSnapshot(t *testing.T) { + e, api, repo := syncHarness(t) + runSync(t, e, repo, false) + before := syncState(t, e, repo) + api.failures[fixtureRepoPath+"/issues/1/comments"] = 503 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + e.Client.Sleep = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() } + if err := e.Sync(ctx, Request{Repo: repo}, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("sync cancellation not propagated: %v", err) + } + after := syncState(t, e, repo) + if after.LatestCommit != before.LatestCommit || after.IssuesSince != before.IssuesSince || after.CommentsSince != before.CommentsSince || after.Status != "failed" { + t.Fatalf("cancelled sync changed snapshot/cursors: %+v", after) + } +} diff --git a/pkg/issuetask/task.go b/pkg/issuetask/task.go new file mode 100644 index 0000000..542a604 --- /dev/null +++ b/pkg/issuetask/task.go @@ -0,0 +1,129 @@ +// Package issuetask integrates issue archives with the existing task queue. +package issuetask + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "time" + + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + appconfig "github.com/ImSingee/git-plus/pkg/config" + "github.com/ImSingee/git-plus/pkg/issuesync" + "github.com/ImSingee/git-plus/pkg/task" +) + +const JobType = "sync-issues" + +var ErrDisabled = errors.New("issue backups are disabled for this source") + +type Runner struct { + DataDir string + DB *sql.DB + Manager *task.Manager + Engine *issuesync.Engine +} + +func New(dataDir string, db *sql.DB, manager *task.Manager) *Runner { + return &Runner{DataDir: dataDir, DB: db, Manager: manager, Engine: issuesync.New(dataDir, db)} +} +func (r *Runner) Enqueue(ctx context.Context, repoID int64, full bool, parent string) (task.EnqueueResult, task.Snapshot, error) { + if r.DB == nil || r.Manager == nil { + return 0, task.Snapshot{}, fmt.Errorf("issue task database and manager are required") + } + repo, err := dbsqlc.New(r.DB).GetRepoById(ctx, repoID) + if err != nil { + return 0, task.Snapshot{}, err + } + enabled, err := IsEnabled(r.DataDir, repo.SourceID) + if err != nil { + return 0, task.Snapshot{}, err + } + if !enabled { + return 0, task.Snapshot{}, ErrDisabled + } + if repo.Status != "active" { + return 0, task.Snapshot{}, fmt.Errorf("repository is not active") + } + jobID := fmt.Sprintf("sync-issues::%d", repoID) + if full { + jobID += "::full" + } + return r.Manager.Enqueue(task.Spec{ + ParentTaskID: parent, JobID: jobID, JobType: JobType, Name: "Back up issues: " + repo.FullName, + Args: map[string]any{"repo_id": repoID, "source_id": repo.SourceID, "full": full}, + Run: func(progress *task.ExecutionContext) (runErr error) { + defer func() { + if runErr != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := dbsqlc.New(r.DB).SetIssueSyncFailed(ctx, dbsqlc.SetIssueSyncFailedParams{RepoID: repoID, LastError: runErr.Error(), UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)}); err != nil { + runErr = errors.Join(runErr, err) + } + } + }() + // Reload source settings and credentials at execution time, never in task arguments. + current, err := dbsqlc.New(r.DB).GetRepoById(context.Background(), repoID) + if err != nil { + return err + } + if current.Status != "active" { + return fmt.Errorf("repository is no longer active") + } + loaded, err := appconfig.Load(appconfig.PathForDataDir(r.DataDir)) + if err != nil { + return err + } + for _, source := range loaded.Data.Sources { + if source.ID != current.SourceID { + continue + } + if !source.IssuesEnabled { + return ErrDisabled + } + token, err := appconfig.DecryptToken(source.Token, os.Getenv(appconfig.TokenPassphraseEnvVar)) + if err != nil { + return fmt.Errorf("decrypt issue source token: %w", err) + } + return r.Engine.Sync(context.Background(), issuesync.Request{Repo: current, Token: token, Full: full}, progress) + } + return fmt.Errorf("issue source was removed") + }, + }) +} +func IsEnabled(dataDir, sourceID string) (bool, error) { + loaded, _, err := appconfig.LoadOrDefault(appconfig.PathForDataDir(dataDir)) + if err != nil { + return false, err + } + for _, source := range loaded.Data.Sources { + if source.ID == sourceID { + return source.IssuesEnabled, nil + } + } + return false, nil +} + +// ResumeInterrupted requeues only jobs marked running when the previous server exited. +func (r *Runner) ResumeInterrupted(ctx context.Context) error { + states, err := dbsqlc.New(r.DB).ListInterruptedIssueSyncs(ctx) + if err != nil { + return err + } + var failures []error + for _, id := range states { + _, _, err := r.Enqueue(ctx, id, false, "") + if err != nil { + reason := fmt.Errorf("interrupted issue backup was not resumed: %w", err) + if writeErr := dbsqlc.New(r.DB).SetIssueSyncFailed(ctx, dbsqlc.SetIssueSyncFailedParams{RepoID: id, LastError: reason.Error(), UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano)}); writeErr != nil { + failures = append(failures, writeErr) + } + if !errors.Is(err, ErrDisabled) { + failures = append(failures, reason) + } + } + } + return errors.Join(failures...) +} diff --git a/pkg/issuetask/task_test.go b/pkg/issuetask/task_test.go new file mode 100644 index 0000000..77834d2 --- /dev/null +++ b/pkg/issuetask/task_test.go @@ -0,0 +1,300 @@ +package issuetask + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + appdb "github.com/ImSingee/git-plus/db" + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + appconfig "github.com/ImSingee/git-plus/pkg/config" + "github.com/ImSingee/git-plus/pkg/githubapi" + "github.com/ImSingee/git-plus/pkg/task" + "github.com/ImSingee/git-plus/pkg/taskstore" +) + +type runnerFixture struct { + runner *Runner + db *sql.DB + repo dbsqlc.Repo + events chan task.Event + headers chan string + config appconfig.Config +} + +func newRunnerFixture(t *testing.T, enabled bool, token string) *runnerFixture { + t.Helper() + dir := t.TempDir() + if err := appdb.Migrate(context.Background(), dir); err != nil { + t.Fatal(err) + } + db, err := appdb.Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + q := dbsqlc.New(db) + now := "2026-10-15T12:00:00Z" + if err := q.CreateRepo(context.Background(), dbsqlc.CreateRepoParams{SourceID: "source-1", Platform: "github", RefID: "123", Status: "active", Name: "repo", FullName: "owner/repo", Owner: "owner", Origin: "{}", Meta: "{}", LastSeenAt: now, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + repo, err := q.GetRepoById(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + events := make(chan task.Event, 100) + manager := task.NewManager(task.WithRecorder(taskstore.NewRecorder(db)), task.WithObserver(func(event task.Event) { events <- event })) + t.Cleanup(manager.Close) + headers := make(chan string, 100) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Get("Authorization") + if r.URL.Path == "/repos/owner/repo" { + io.WriteString(w, `{"id":123,"full_name":"owner/repo","has_issues":true}`) + return + } + io.WriteString(w, `[]`) + })) + t.Cleanup(server.Close) + runner := New(dir, db, manager) + runner.Engine.Client = githubapi.New(server.URL, server.Client()) + runner.Engine.Now = func() time.Time { return time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) } + fixture := &runnerFixture{runner: runner, db: db, repo: repo, events: events, headers: headers, config: appconfig.Config{Sources: []appconfig.SourceConfig{{ID: repo.SourceID, Platform: "github", Username: "owner", IssuesEnabled: enabled, Token: token}}}} + fixture.save(t) + return fixture +} + +func (f *runnerFixture) save(t *testing.T) { + t.Helper() + if err := appconfig.Save(appconfig.PathForDataDir(f.runner.DataDir), f.config); err != nil { + t.Fatal(err) + } +} + +func (f *runnerFixture) waitTerminal(t *testing.T, taskID string) task.Event { + t.Helper() + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case event := <-f.events: + if event.Task.TaskID == taskID && (event.Name == task.EventTaskFinished || event.Name == task.EventTaskFailed) { + return event + } + case <-timer.C: + t.Fatal("timed out waiting for issue task") + return task.Event{} + } + } +} + +func (f *runnerFixture) blockQueue(t *testing.T) func() { + t.Helper() + blocked := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(blocked) }) } + _, _, err := f.runner.Manager.Enqueue(task.Spec{JobID: "test-blocker", JobType: "test", Name: "Hold task queue", Run: func(*task.ExecutionContext) error { <-blocked; return nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(release) + return release +} + +func encryptedTestToken(t *testing.T, token string) string { + t.Helper() + encrypted, err := appconfig.EncryptToken(token, "test-passphrase") + if err != nil { + t.Fatal(err) + } + return encrypted +} + +func TestDisabledOrMissingSourcesDoNotQueue(t *testing.T) { + f := newRunnerFixture(t, false, "invalid-token-must-not-be-decrypted") + if _, _, err := f.runner.Enqueue(context.Background(), f.repo.ID, false, ""); !errors.Is(err, ErrDisabled) { + t.Fatalf("disabled source: %v", err) + } + f.config.Sources = nil + f.save(t) + if enabled, err := IsEnabled(f.runner.DataDir, f.repo.SourceID); err != nil || enabled { + t.Fatalf("missing source enabled=%v err=%v", enabled, err) + } + if _, _, err := f.runner.Enqueue(context.Background(), f.repo.ID, false, ""); !errors.Is(err, ErrDisabled) { + t.Fatalf("missing source: %v", err) + } + if runtime := f.runner.Manager.Runtime(); runtime.Running != nil || len(runtime.Queued) != 0 { + t.Fatal("disabled source queued work") + } + select { + case <-f.headers: + t.Fatal("disabled source contacted API") + default: + } +} + +func TestCredentialsResolvedOnlyAtExecutionAndParentRecorded(t *testing.T) { + t.Setenv(appconfig.TokenPassphraseEnvVar, "test-passphrase") + // Deliberately malformed at enqueue: any eager decryption would fail. + f := newRunnerFixture(t, true, "invalid-token-at-enqueue") + release := f.blockQueue(t) + result, item, err := f.runner.Enqueue(context.Background(), f.repo.ID, true, "parent-source-task") + if err != nil || result != task.EnqueueResultQueued { + t.Fatalf("enqueue should not decrypt credentials: result=%v err=%v", result, err) + } + if item.ParentTaskID != "parent-source-task" || item.JobType != JobType || item.JobID != fmt.Sprintf("sync-issues::%d::full", f.repo.ID) { + t.Fatalf("queue identity incorrect: %+v", item) + } + raw, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "invalid-token") || strings.Contains(string(raw), "test-passphrase") || len(item.Args) != 3 { + t.Fatalf("credentials present in task snapshot: %s", raw) + } + freshToken := "fresh-token-at-execution" + f.config.Sources[0].Token = encryptedTestToken(t, freshToken) + f.save(t) + release() + event := f.waitTerminal(t, item.TaskID) + if event.Name != task.EventTaskFinished { + t.Fatalf("task failed: %+v", event) + } + select { + case authorization := <-f.headers: + if authorization != "Bearer "+freshToken { + t.Fatalf("used stale credential: %q", authorization) + } + default: + t.Fatal("enabled task did not contact API") + } + stored, err := dbsqlc.New(f.db).GetTaskRun(context.Background(), item.TaskID) + if err != nil { + t.Fatal(err) + } + if !stored.ParentTaskID.Valid || stored.ParentTaskID.String != "parent-source-task" || strings.Contains(stored.ArgsJson.String, freshToken) || strings.Contains(stored.ArgsJson.String, f.config.Sources[0].Token) { + t.Fatalf("task record lost parent or leaked credential: %+v", stored) + } + state, err := dbsqlc.New(f.db).GetIssueSyncState(context.Background(), f.repo.ID) + if err != nil || state.Status != "idle" || state.LatestCommit == "" { + t.Fatalf("task failed to publish snapshot: %+v %v", state, err) + } +} + +func TestSourceDisabledWhileQueuedPreventsExecution(t *testing.T) { + f := newRunnerFixture(t, true, "invalid-token-must-not-be-decrypted") + release := f.blockQueue(t) + _, item, err := f.runner.Enqueue(context.Background(), f.repo.ID, false, "") + if err != nil { + t.Fatal(err) + } + f.config.Sources[0].IssuesEnabled = false + f.save(t) + release() + event := f.waitTerminal(t, item.TaskID) + if event.Name != task.EventTaskFailed || event.ErrorMessage != ErrDisabled.Error() { + t.Fatalf("queued disable was ignored: %+v", event) + } + select { + case <-f.headers: + t.Fatal("disabled queued task contacted API") + default: + } + state, err := dbsqlc.New(f.db).GetIssueSyncState(context.Background(), f.repo.ID) + if err != nil || state.Status != "failed" { + t.Fatalf("disabled execution status: %+v %v", state, err) + } +} + +func TestDecryptFailureDoesNotLeakToken(t *testing.T) { + secret := "private-token-never-print" + f := newRunnerFixture(t, true, secret) + _, item, err := f.runner.Enqueue(context.Background(), f.repo.ID, false, "") + if err != nil { + t.Fatal(err) + } + event := f.waitTerminal(t, item.TaskID) + if event.Name != task.EventTaskFailed || !strings.Contains(event.ErrorMessage, "decrypt issue source token") || strings.Contains(event.ErrorMessage, secret) { + t.Fatalf("unsafe decryption failure: %+v", event) + } + stored, err := dbsqlc.New(f.db).GetTaskRun(context.Background(), item.TaskID) + if err != nil { + t.Fatal(err) + } + if strings.Contains(stored.ErrorMessage.String, secret) || strings.Contains(stored.ArgsJson.String, secret) { + t.Fatal("token leaked into persistent task log") + } + select { + case <-f.headers: + t.Fatal("failed decryption contacted API") + default: + } +} + +func TestResumeRequeuesOnlyRunningIssueSync(t *testing.T) { + t.Setenv(appconfig.TokenPassphraseEnvVar, "test-passphrase") + f := newRunnerFixture(t, true, encryptedTestToken(t, "resume-token")) + q := dbsqlc.New(f.db) + if err := q.SetIssueSyncRunning(context.Background(), dbsqlc.SetIssueSyncRunningParams{RepoID: f.repo.ID, UpdatedAt: "2026-10-15T11:00:00Z"}); err != nil { + t.Fatal(err) + } + release := f.blockQueue(t) + if err := f.runner.ResumeInterrupted(context.Background()); err != nil { + t.Fatal(err) + } + runtime := f.runner.Manager.Runtime() + if len(runtime.Queued) != 1 || runtime.Queued[0].JobType != JobType || runtime.Queued[0].Args["repo_id"] != f.repo.ID { + t.Fatalf("running sync was not requeued: %+v", runtime) + } + item := runtime.Queued[0] + release() + if event := f.waitTerminal(t, item.TaskID); event.Name != task.EventTaskFinished { + t.Fatalf("resumed task failed: %+v", event) + } + if err := f.runner.ResumeInterrupted(context.Background()); err != nil { + t.Fatal(err) + } + runtime = f.runner.Manager.Runtime() + if runtime.Running != nil || len(runtime.Queued) != 0 { + t.Fatal("completed sync was requeued on restart") + } +} + +func TestDisabledInterruptedSyncDoesNotRemainRunning(t *testing.T) { + f := newRunnerFixture(t, false, "unused") + q := dbsqlc.New(f.db) + if err := q.SetIssueSyncRunning(context.Background(), dbsqlc.SetIssueSyncRunningParams{RepoID: f.repo.ID, UpdatedAt: "2026-10-15T11:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := f.runner.ResumeInterrupted(context.Background()); err != nil { + t.Fatal(err) + } + state, err := q.GetIssueSyncState(context.Background(), f.repo.ID) + if err != nil { + t.Fatal(err) + } + if state.Status == "running" { + t.Fatal("disabled interrupted sync is displayed as running forever") + } + if runtime := f.runner.Manager.Runtime(); runtime.Running != nil || len(runtime.Queued) != 0 { + t.Fatal("disabled interrupted sync was queued") + } +} + +func TestInactiveRepositoryCannotQueue(t *testing.T) { + f := newRunnerFixture(t, true, "unused") + if err := dbsqlc.New(f.db).MarkRepoAutoExcluded(context.Background(), dbsqlc.MarkRepoAutoExcludedParams{SourceID: f.repo.SourceID, RefID: f.repo.RefID, Status: "auto_excluded", UpdatedAt: "2026-10-15T12:00:00Z"}); err != nil { + t.Fatal(err) + } + if _, _, err := f.runner.Enqueue(context.Background(), f.repo.ID, false, ""); err == nil || !strings.Contains(err.Error(), "not active") { + t.Fatalf("inactive repository queued: %v", err) + } +} diff --git a/pkg/rpc/gitplus/config/v1/config.pb.go b/pkg/rpc/gitplus/config/v1/config.pb.go index 5e3efa8..0751b88 100644 --- a/pkg/rpc/gitplus/config/v1/config.pb.go +++ b/pkg/rpc/gitplus/config/v1/config.pb.go @@ -277,6 +277,7 @@ type Source struct { IncludeStarred *bool `protobuf:"varint,8,opt,name=include_starred,json=includeStarred" json:"include_starred,omitempty"` IncludeWatching *bool `protobuf:"varint,9,opt,name=include_watching,json=includeWatching" json:"include_watching,omitempty"` Name *string `protobuf:"bytes,10,opt,name=name" json:"name,omitempty"` + IssuesEnabled *bool `protobuf:"varint,11,opt,name=issues_enabled,json=issuesEnabled" json:"issues_enabled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -381,6 +382,13 @@ func (x *Source) GetName() string { return "" } +func (x *Source) GetIssuesEnabled() bool { + if x != nil && x.IssuesEnabled != nil { + return *x.IssuesEnabled + } + return false +} + type ConfigSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` Concurrency *int32 `protobuf:"varint,1,opt,name=concurrency" json:"concurrency,omitempty"` @@ -504,6 +512,7 @@ type CreateSourceInput struct { IncludeStarred *bool `protobuf:"varint,8,opt,name=include_starred,json=includeStarred" json:"include_starred,omitempty"` IncludeWatching *bool `protobuf:"varint,9,opt,name=include_watching,json=includeWatching" json:"include_watching,omitempty"` Name *string `protobuf:"bytes,10,opt,name=name" json:"name,omitempty"` + IssuesEnabled *bool `protobuf:"varint,11,opt,name=issues_enabled,json=issuesEnabled" json:"issues_enabled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -601,6 +610,13 @@ func (x *CreateSourceInput) GetName() string { return "" } +func (x *CreateSourceInput) GetIssuesEnabled() bool { + if x != nil && x.IssuesEnabled != nil { + return *x.IssuesEnabled + } + return false +} + type UpdateSourcePatch struct { state protoimpl.MessageState `protogen:"open.v1"` Platform *Platform `protobuf:"varint,2,opt,name=platform,enum=gitplus.config.v1.Platform" json:"platform,omitempty"` @@ -611,6 +627,7 @@ type UpdateSourcePatch struct { IncludeStarred *bool `protobuf:"varint,7,opt,name=include_starred,json=includeStarred" json:"include_starred,omitempty"` IncludeWatching *bool `protobuf:"varint,8,opt,name=include_watching,json=includeWatching" json:"include_watching,omitempty"` Name *string `protobuf:"bytes,9,opt,name=name" json:"name,omitempty"` + IssuesEnabled *bool `protobuf:"varint,10,opt,name=issues_enabled,json=issuesEnabled" json:"issues_enabled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -701,6 +718,13 @@ func (x *UpdateSourcePatch) GetName() string { return "" } +func (x *UpdateSourcePatch) GetIssuesEnabled() bool { + if x != nil && x.IssuesEnabled != nil { + return *x.IssuesEnabled + } + return false +} + type CheckConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1545,7 +1569,7 @@ const file_gitplus_config_v1_config_proto_rawDesc = "" + "\fIssueSummary\x12\x14\n" + "\x05error\x18\x01 \x01(\x05R\x05error\x12\x18\n" + "\awarning\x18\x02 \x01(\x05R\awarning\x12\x12\n" + - "\x04info\x18\x03 \x01(\x05R\x04info\"\xbf\x03\n" + + "\x04info\x18\x03 \x01(\x05R\x04info\"\xe6\x03\n" + "\x06Source\x12\x17\n" + "\x02id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x02id\x12C\n" + "\bplatform\x18\x02 \x01(\x0e2\x1b.gitplus.config.v1.PlatformB\n" + @@ -1558,14 +1582,15 @@ const file_gitplus_config_v1_config_proto_rawDesc = "" + "\x0finclude_starred\x18\b \x01(\bR\x0eincludeStarred\x12)\n" + "\x10include_watching\x18\t \x01(\bR\x0fincludeWatching\x12\x12\n" + "\x04name\x18\n" + - " \x01(\tR\x04name\"\xb5\x01\n" + + " \x01(\tR\x04name\x12%\n" + + "\x0eissues_enabled\x18\v \x01(\bR\rissuesEnabled\"\xb5\x01\n" + "\x0eConfigSnapshot\x12)\n" + "\vconcurrency\x18\x01 \x01(\x05B\a\xbaH\x04\x1a\x02 \x00R\vconcurrency\x123\n" + "\asources\x18\x02 \x03(\v2\x19.gitplus.config.v1.SourceR\asources\x12/\n" + "\x0fmax_retry_times\x18\x03 \x01(\x05B\a\xbaH\x04\x1a\x02(\x00R\rmaxRetryTimes\x12\x12\n" + "\x04cron\x18\x04 \x01(\tR\x04cron\"9\n" + "\x0fStringListValue\x12&\n" + - "\x06values\x18\x01 \x03(\tB\x0e\xbaH\v\x92\x01\b\x18\x01\"\x04r\x02\x10\x01R\x06values\"\xbb\x03\n" + + "\x06values\x18\x01 \x03(\tB\x0e\xbaH\v\x92\x01\b\x18\x01\"\x04r\x02\x10\x01R\x06values\"\xe2\x03\n" + "\x11CreateSourceInput\x12C\n" + "\bplatform\x18\x02 \x01(\x0e2\x1b.gitplus.config.v1.PlatformB\n" + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\bplatform\x12#\n" + @@ -1577,7 +1602,8 @@ const file_gitplus_config_v1_config_proto_rawDesc = "" + "\x0finclude_starred\x18\b \x01(\bR\x0eincludeStarred\x12)\n" + "\x10include_watching\x18\t \x01(\bR\x0fincludeWatching\x12\x12\n" + "\x04name\x18\n" + - " \x01(\tR\x04nameJ\x04\b\x01\x10\x02\"\xb1\x03\n" + + " \x01(\tR\x04name\x12%\n" + + "\x0eissues_enabled\x18\v \x01(\bR\rissuesEnabledJ\x04\b\x01\x10\x02\"\xd8\x03\n" + "\x11UpdateSourcePatch\x12C\n" + "\bplatform\x18\x02 \x01(\x0e2\x1b.gitplus.config.v1.PlatformB\n" + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\bplatform\x12#\n" + @@ -1587,7 +1613,9 @@ const file_gitplus_config_v1_config_proto_rawDesc = "" + "\x10include_defaults\x18\x06 \x01(\bR\x0fincludeDefaults\x12'\n" + "\x0finclude_starred\x18\a \x01(\bR\x0eincludeStarred\x12)\n" + "\x10include_watching\x18\b \x01(\bR\x0fincludeWatching\x12\x12\n" + - "\x04name\x18\t \x01(\tR\x04nameJ\x04\b\x01\x10\x02\"\x14\n" + + "\x04name\x18\t \x01(\tR\x04name\x12%\n" + + "\x0eissues_enabled\x18\n" + + " \x01(\bR\rissuesEnabledJ\x04\b\x01\x10\x02\"\x14\n" + "\x12CheckConfigRequest\"\x8c\x01\n" + "\x13CheckConfigResponse\x12:\n" + "\x06issues\x18\x01 \x03(\v2\".gitplus.config.v1.ValidationIssueR\x06issues\x129\n" + diff --git a/pkg/rpc/gitplus/issue/v1/issue.pb.go b/pkg/rpc/gitplus/issue/v1/issue.pb.go new file mode 100644 index 0000000..99efe1c --- /dev/null +++ b/pkg/rpc/gitplus/issue/v1/issue.pb.go @@ -0,0 +1,1020 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: gitplus/issue/v1/issue.proto + +package issuev1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + v1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/task/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + _ "google.golang.org/protobuf/types/gofeaturespb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type IssueSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id *string `protobuf:"bytes,1,opt,name=id" json:"id,omitempty"` + Number *int64 `protobuf:"varint,2,opt,name=number" json:"number,omitempty"` + Title *string `protobuf:"bytes,3,opt,name=title" json:"title,omitempty"` + State *string `protobuf:"bytes,4,opt,name=state" json:"state,omitempty"` + AuthorLogin *string `protobuf:"bytes,5,opt,name=author_login,json=authorLogin" json:"author_login,omitempty"` + UpdatedAt *string `protobuf:"bytes,6,opt,name=updated_at,json=updatedAt" json:"updated_at,omitempty"` + Availability *string `protobuf:"bytes,7,opt,name=availability" json:"availability,omitempty"` + Labels []string `protobuf:"bytes,8,rep,name=labels" json:"labels,omitempty"` + Assignees []string `protobuf:"bytes,9,rep,name=assignees" json:"assignees,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSummary) Reset() { + *x = IssueSummary{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSummary) ProtoMessage() {} + +func (x *IssueSummary) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueSummary.ProtoReflect.Descriptor instead. +func (*IssueSummary) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{0} +} + +func (x *IssueSummary) GetId() string { + if x != nil && x.Id != nil { + return *x.Id + } + return "" +} + +func (x *IssueSummary) GetNumber() int64 { + if x != nil && x.Number != nil { + return *x.Number + } + return 0 +} + +func (x *IssueSummary) GetTitle() string { + if x != nil && x.Title != nil { + return *x.Title + } + return "" +} + +func (x *IssueSummary) GetState() string { + if x != nil && x.State != nil { + return *x.State + } + return "" +} + +func (x *IssueSummary) GetAuthorLogin() string { + if x != nil && x.AuthorLogin != nil { + return *x.AuthorLogin + } + return "" +} + +func (x *IssueSummary) GetUpdatedAt() string { + if x != nil && x.UpdatedAt != nil { + return *x.UpdatedAt + } + return "" +} + +func (x *IssueSummary) GetAvailability() string { + if x != nil && x.Availability != nil { + return *x.Availability + } + return "" +} + +func (x *IssueSummary) GetLabels() []string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *IssueSummary) GetAssignees() []string { + if x != nil { + return x.Assignees + } + return nil +} + +type IssueBackupStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled *bool `protobuf:"varint,1,opt,name=enabled" json:"enabled,omitempty"` + State *string `protobuf:"bytes,2,opt,name=state" json:"state,omitempty"` + LastSuccessfulAt *string `protobuf:"bytes,3,opt,name=last_successful_at,json=lastSuccessfulAt" json:"last_successful_at,omitempty"` + LastFullReconcileAt *string `protobuf:"bytes,4,opt,name=last_full_reconcile_at,json=lastFullReconcileAt" json:"last_full_reconcile_at,omitempty"` + LastError *string `protobuf:"bytes,5,opt,name=last_error,json=lastError" json:"last_error,omitempty"` + LatestCommit *string `protobuf:"bytes,6,opt,name=latest_commit,json=latestCommit" json:"latest_commit,omitempty"` + IssueCount *int32 `protobuf:"varint,7,opt,name=issue_count,json=issueCount" json:"issue_count,omitempty"` + CommentCount *int32 `protobuf:"varint,8,opt,name=comment_count,json=commentCount" json:"comment_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueBackupStatus) Reset() { + *x = IssueBackupStatus{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueBackupStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueBackupStatus) ProtoMessage() {} + +func (x *IssueBackupStatus) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueBackupStatus.ProtoReflect.Descriptor instead. +func (*IssueBackupStatus) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{1} +} + +func (x *IssueBackupStatus) GetEnabled() bool { + if x != nil && x.Enabled != nil { + return *x.Enabled + } + return false +} + +func (x *IssueBackupStatus) GetState() string { + if x != nil && x.State != nil { + return *x.State + } + return "" +} + +func (x *IssueBackupStatus) GetLastSuccessfulAt() string { + if x != nil && x.LastSuccessfulAt != nil { + return *x.LastSuccessfulAt + } + return "" +} + +func (x *IssueBackupStatus) GetLastFullReconcileAt() string { + if x != nil && x.LastFullReconcileAt != nil { + return *x.LastFullReconcileAt + } + return "" +} + +func (x *IssueBackupStatus) GetLastError() string { + if x != nil && x.LastError != nil { + return *x.LastError + } + return "" +} + +func (x *IssueBackupStatus) GetLatestCommit() string { + if x != nil && x.LatestCommit != nil { + return *x.LatestCommit + } + return "" +} + +func (x *IssueBackupStatus) GetIssueCount() int32 { + if x != nil && x.IssueCount != nil { + return *x.IssueCount + } + return 0 +} + +func (x *IssueBackupStatus) GetCommentCount() int32 { + if x != nil && x.CommentCount != nil { + return *x.CommentCount + } + return 0 +} + +type IssueSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Commit *string `protobuf:"bytes,1,opt,name=commit" json:"commit,omitempty"` + CreatedAt *string `protobuf:"bytes,2,opt,name=created_at,json=createdAt" json:"created_at,omitempty"` + IssueCount *int32 `protobuf:"varint,3,opt,name=issue_count,json=issueCount" json:"issue_count,omitempty"` + CommentCount *int32 `protobuf:"varint,4,opt,name=comment_count,json=commentCount" json:"comment_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSnapshot) Reset() { + *x = IssueSnapshot{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSnapshot) ProtoMessage() {} + +func (x *IssueSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IssueSnapshot.ProtoReflect.Descriptor instead. +func (*IssueSnapshot) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{2} +} + +func (x *IssueSnapshot) GetCommit() string { + if x != nil && x.Commit != nil { + return *x.Commit + } + return "" +} + +func (x *IssueSnapshot) GetCreatedAt() string { + if x != nil && x.CreatedAt != nil { + return *x.CreatedAt + } + return "" +} + +func (x *IssueSnapshot) GetIssueCount() int32 { + if x != nil && x.IssueCount != nil { + return *x.IssueCount + } + return 0 +} + +func (x *IssueSnapshot) GetCommentCount() int32 { + if x != nil && x.CommentCount != nil { + return *x.CommentCount + } + return 0 +} + +type GetStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RepoId *int64 `protobuf:"varint,1,opt,name=repo_id,json=repoId" json:"repo_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatusRequest) Reset() { + *x = GetStatusRequest{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusRequest) ProtoMessage() {} + +func (x *GetStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusRequest.ProtoReflect.Descriptor instead. +func (*GetStatusRequest) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{3} +} + +func (x *GetStatusRequest) GetRepoId() int64 { + if x != nil && x.RepoId != nil { + return *x.RepoId + } + return 0 +} + +type GetStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status *IssueBackupStatus `protobuf:"bytes,1,opt,name=status" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetStatusResponse) Reset() { + *x = GetStatusResponse{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetStatusResponse) ProtoMessage() {} + +func (x *GetStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetStatusResponse.ProtoReflect.Descriptor instead. +func (*GetStatusResponse) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{4} +} + +func (x *GetStatusResponse) GetStatus() *IssueBackupStatus { + if x != nil { + return x.Status + } + return nil +} + +type ListIssuesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RepoId *int64 `protobuf:"varint,1,opt,name=repo_id,json=repoId" json:"repo_id,omitempty"` + PageSize *int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize" json:"page_size,omitempty"` + PageToken *string `protobuf:"bytes,3,opt,name=page_token,json=pageToken" json:"page_token,omitempty"` + State *string `protobuf:"bytes,4,opt,name=state" json:"state,omitempty"` + Search *string `protobuf:"bytes,5,opt,name=search" json:"search,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListIssuesRequest) Reset() { + *x = ListIssuesRequest{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListIssuesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListIssuesRequest) ProtoMessage() {} + +func (x *ListIssuesRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListIssuesRequest.ProtoReflect.Descriptor instead. +func (*ListIssuesRequest) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{5} +} + +func (x *ListIssuesRequest) GetRepoId() int64 { + if x != nil && x.RepoId != nil { + return *x.RepoId + } + return 0 +} + +func (x *ListIssuesRequest) GetPageSize() int32 { + if x != nil && x.PageSize != nil { + return *x.PageSize + } + return 0 +} + +func (x *ListIssuesRequest) GetPageToken() string { + if x != nil && x.PageToken != nil { + return *x.PageToken + } + return "" +} + +func (x *ListIssuesRequest) GetState() string { + if x != nil && x.State != nil { + return *x.State + } + return "" +} + +func (x *ListIssuesRequest) GetSearch() string { + if x != nil && x.Search != nil { + return *x.Search + } + return "" +} + +type ListIssuesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issues []*IssueSummary `protobuf:"bytes,1,rep,name=issues" json:"issues,omitempty"` + NextPageToken *string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken" json:"next_page_token,omitempty"` + TotalCount *int32 `protobuf:"varint,3,opt,name=total_count,json=totalCount" json:"total_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListIssuesResponse) Reset() { + *x = ListIssuesResponse{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListIssuesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListIssuesResponse) ProtoMessage() {} + +func (x *ListIssuesResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListIssuesResponse.ProtoReflect.Descriptor instead. +func (*ListIssuesResponse) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{6} +} + +func (x *ListIssuesResponse) GetIssues() []*IssueSummary { + if x != nil { + return x.Issues + } + return nil +} + +func (x *ListIssuesResponse) GetNextPageToken() string { + if x != nil && x.NextPageToken != nil { + return *x.NextPageToken + } + return "" +} + +func (x *ListIssuesResponse) GetTotalCount() int32 { + if x != nil && x.TotalCount != nil { + return *x.TotalCount + } + return 0 +} + +type GetIssueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RepoId *int64 `protobuf:"varint,1,opt,name=repo_id,json=repoId" json:"repo_id,omitempty"` + IssueId *string `protobuf:"bytes,2,opt,name=issue_id,json=issueId" json:"issue_id,omitempty"` + Snapshot *string `protobuf:"bytes,3,opt,name=snapshot" json:"snapshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetIssueRequest) Reset() { + *x = GetIssueRequest{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetIssueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetIssueRequest) ProtoMessage() {} + +func (x *GetIssueRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetIssueRequest.ProtoReflect.Descriptor instead. +func (*GetIssueRequest) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{7} +} + +func (x *GetIssueRequest) GetRepoId() int64 { + if x != nil && x.RepoId != nil { + return *x.RepoId + } + return 0 +} + +func (x *GetIssueRequest) GetIssueId() string { + if x != nil && x.IssueId != nil { + return *x.IssueId + } + return "" +} + +func (x *GetIssueRequest) GetSnapshot() string { + if x != nil && x.Snapshot != nil { + return *x.Snapshot + } + return "" +} + +type GetIssueResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issue *IssueSummary `protobuf:"bytes,1,opt,name=issue" json:"issue,omitempty"` + RawJson *string `protobuf:"bytes,2,opt,name=raw_json,json=rawJson" json:"raw_json,omitempty"` + CommentsJson *string `protobuf:"bytes,3,opt,name=comments_json,json=commentsJson" json:"comments_json,omitempty"` + TimelineJson *string `protobuf:"bytes,4,opt,name=timeline_json,json=timelineJson" json:"timeline_json,omitempty"` + Markdown *string `protobuf:"bytes,5,opt,name=markdown" json:"markdown,omitempty"` + Snapshot *string `protobuf:"bytes,6,opt,name=snapshot" json:"snapshot,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetIssueResponse) Reset() { + *x = GetIssueResponse{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetIssueResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetIssueResponse) ProtoMessage() {} + +func (x *GetIssueResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetIssueResponse.ProtoReflect.Descriptor instead. +func (*GetIssueResponse) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{8} +} + +func (x *GetIssueResponse) GetIssue() *IssueSummary { + if x != nil { + return x.Issue + } + return nil +} + +func (x *GetIssueResponse) GetRawJson() string { + if x != nil && x.RawJson != nil { + return *x.RawJson + } + return "" +} + +func (x *GetIssueResponse) GetCommentsJson() string { + if x != nil && x.CommentsJson != nil { + return *x.CommentsJson + } + return "" +} + +func (x *GetIssueResponse) GetTimelineJson() string { + if x != nil && x.TimelineJson != nil { + return *x.TimelineJson + } + return "" +} + +func (x *GetIssueResponse) GetMarkdown() string { + if x != nil && x.Markdown != nil { + return *x.Markdown + } + return "" +} + +func (x *GetIssueResponse) GetSnapshot() string { + if x != nil && x.Snapshot != nil { + return *x.Snapshot + } + return "" +} + +type ListSnapshotsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RepoId *int64 `protobuf:"varint,1,opt,name=repo_id,json=repoId" json:"repo_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSnapshotsRequest) Reset() { + *x = ListSnapshotsRequest{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSnapshotsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSnapshotsRequest) ProtoMessage() {} + +func (x *ListSnapshotsRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSnapshotsRequest.ProtoReflect.Descriptor instead. +func (*ListSnapshotsRequest) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{9} +} + +func (x *ListSnapshotsRequest) GetRepoId() int64 { + if x != nil && x.RepoId != nil { + return *x.RepoId + } + return 0 +} + +type ListSnapshotsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Snapshots []*IssueSnapshot `protobuf:"bytes,1,rep,name=snapshots" json:"snapshots,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSnapshotsResponse) Reset() { + *x = ListSnapshotsResponse{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSnapshotsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSnapshotsResponse) ProtoMessage() {} + +func (x *ListSnapshotsResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSnapshotsResponse.ProtoReflect.Descriptor instead. +func (*ListSnapshotsResponse) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{10} +} + +func (x *ListSnapshotsResponse) GetSnapshots() []*IssueSnapshot { + if x != nil { + return x.Snapshots + } + return nil +} + +type EnqueueSyncRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RepoId *int64 `protobuf:"varint,1,opt,name=repo_id,json=repoId" json:"repo_id,omitempty"` + Full *bool `protobuf:"varint,2,opt,name=full" json:"full,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSyncRequest) Reset() { + *x = EnqueueSyncRequest{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSyncRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSyncRequest) ProtoMessage() {} + +func (x *EnqueueSyncRequest) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSyncRequest.ProtoReflect.Descriptor instead. +func (*EnqueueSyncRequest) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{11} +} + +func (x *EnqueueSyncRequest) GetRepoId() int64 { + if x != nil && x.RepoId != nil { + return *x.RepoId + } + return 0 +} + +func (x *EnqueueSyncRequest) GetFull() bool { + if x != nil && x.Full != nil { + return *x.Full + } + return false +} + +type EnqueueSyncResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Task *v1.Task `protobuf:"bytes,1,opt,name=task" json:"task,omitempty"` + Result *v1.TaskEnqueueResult `protobuf:"varint,2,opt,name=result,enum=gitplus.task.v1.TaskEnqueueResult" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSyncResponse) Reset() { + *x = EnqueueSyncResponse{} + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSyncResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSyncResponse) ProtoMessage() {} + +func (x *EnqueueSyncResponse) ProtoReflect() protoreflect.Message { + mi := &file_gitplus_issue_v1_issue_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSyncResponse.ProtoReflect.Descriptor instead. +func (*EnqueueSyncResponse) Descriptor() ([]byte, []int) { + return file_gitplus_issue_v1_issue_proto_rawDescGZIP(), []int{12} +} + +func (x *EnqueueSyncResponse) GetTask() *v1.Task { + if x != nil { + return x.Task + } + return nil +} + +func (x *EnqueueSyncResponse) GetResult() v1.TaskEnqueueResult { + if x != nil && x.Result != nil { + return *x.Result + } + return v1.TaskEnqueueResult(0) +} + +var File_gitplus_issue_v1_issue_proto protoreflect.FileDescriptor + +const file_gitplus_issue_v1_issue_proto_rawDesc = "" + + "\n" + + "\x1cgitplus/issue/v1/issue.proto\x12\x10gitplus.issue.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1agitplus/task/v1/task.proto\x1a!google/protobuf/go_features.proto\"\xfe\x01\n" + + "\fIssueSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06number\x18\x02 \x01(\x03R\x06number\x12\x14\n" + + "\x05title\x18\x03 \x01(\tR\x05title\x12\x14\n" + + "\x05state\x18\x04 \x01(\tR\x05state\x12!\n" + + "\fauthor_login\x18\x05 \x01(\tR\vauthorLogin\x12\x1d\n" + + "\n" + + "updated_at\x18\x06 \x01(\tR\tupdatedAt\x12\"\n" + + "\favailability\x18\a \x01(\tR\favailability\x12\x16\n" + + "\x06labels\x18\b \x03(\tR\x06labels\x12\x1c\n" + + "\tassignees\x18\t \x03(\tR\tassignees\"\xb0\x02\n" + + "\x11IssueBackupStatus\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12,\n" + + "\x12last_successful_at\x18\x03 \x01(\tR\x10lastSuccessfulAt\x123\n" + + "\x16last_full_reconcile_at\x18\x04 \x01(\tR\x13lastFullReconcileAt\x12\x1d\n" + + "\n" + + "last_error\x18\x05 \x01(\tR\tlastError\x12#\n" + + "\rlatest_commit\x18\x06 \x01(\tR\flatestCommit\x12\x1f\n" + + "\vissue_count\x18\a \x01(\x05R\n" + + "issueCount\x12#\n" + + "\rcomment_count\x18\b \x01(\x05R\fcommentCount\"\x8c\x01\n" + + "\rIssueSnapshot\x12\x16\n" + + "\x06commit\x18\x01 \x01(\tR\x06commit\x12\x1d\n" + + "\n" + + "created_at\x18\x02 \x01(\tR\tcreatedAt\x12\x1f\n" + + "\vissue_count\x18\x03 \x01(\x05R\n" + + "issueCount\x12#\n" + + "\rcomment_count\x18\x04 \x01(\x05R\fcommentCount\"4\n" + + "\x10GetStatusRequest\x12 \n" + + "\arepo_id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x06repoId\"P\n" + + "\x11GetStatusResponse\x12;\n" + + "\x06status\x18\x01 \x01(\v2#.gitplus.issue.v1.IssueBackupStatusR\x06status\"\x9f\x01\n" + + "\x11ListIssuesRequest\x12 \n" + + "\arepo_id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x06repoId\x12\x1b\n" + + "\tpage_size\x18\x02 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x03 \x01(\tR\tpageToken\x12\x14\n" + + "\x05state\x18\x04 \x01(\tR\x05state\x12\x16\n" + + "\x06search\x18\x05 \x01(\tR\x06search\"\x95\x01\n" + + "\x12ListIssuesResponse\x126\n" + + "\x06issues\x18\x01 \x03(\v2\x1e.gitplus.issue.v1.IssueSummaryR\x06issues\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1f\n" + + "\vtotal_count\x18\x03 \x01(\x05R\n" + + "totalCount\"s\n" + + "\x0fGetIssueRequest\x12 \n" + + "\arepo_id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x06repoId\x12\"\n" + + "\bissue_id\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aissueId\x12\x1a\n" + + "\bsnapshot\x18\x03 \x01(\tR\bsnapshot\"\xe5\x01\n" + + "\x10GetIssueResponse\x124\n" + + "\x05issue\x18\x01 \x01(\v2\x1e.gitplus.issue.v1.IssueSummaryR\x05issue\x12\x19\n" + + "\braw_json\x18\x02 \x01(\tR\arawJson\x12#\n" + + "\rcomments_json\x18\x03 \x01(\tR\fcommentsJson\x12#\n" + + "\rtimeline_json\x18\x04 \x01(\tR\ftimelineJson\x12\x1a\n" + + "\bmarkdown\x18\x05 \x01(\tR\bmarkdown\x12\x1a\n" + + "\bsnapshot\x18\x06 \x01(\tR\bsnapshot\"8\n" + + "\x14ListSnapshotsRequest\x12 \n" + + "\arepo_id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x06repoId\"V\n" + + "\x15ListSnapshotsResponse\x12=\n" + + "\tsnapshots\x18\x01 \x03(\v2\x1f.gitplus.issue.v1.IssueSnapshotR\tsnapshots\"J\n" + + "\x12EnqueueSyncRequest\x12 \n" + + "\arepo_id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x06repoId\x12\x12\n" + + "\x04full\x18\x02 \x01(\bR\x04full\"|\n" + + "\x13EnqueueSyncResponse\x12)\n" + + "\x04task\x18\x01 \x01(\v2\x15.gitplus.task.v1.TaskR\x04task\x12:\n" + + "\x06result\x18\x02 \x01(\x0e2\".gitplus.task.v1.TaskEnqueueResultR\x06result2\xce\x03\n" + + "\fIssueService\x12T\n" + + "\tGetStatus\x12\".gitplus.issue.v1.GetStatusRequest\x1a#.gitplus.issue.v1.GetStatusResponse\x12W\n" + + "\n" + + "ListIssues\x12#.gitplus.issue.v1.ListIssuesRequest\x1a$.gitplus.issue.v1.ListIssuesResponse\x12Q\n" + + "\bGetIssue\x12!.gitplus.issue.v1.GetIssueRequest\x1a\".gitplus.issue.v1.GetIssueResponse\x12`\n" + + "\rListSnapshots\x12&.gitplus.issue.v1.ListSnapshotsRequest\x1a'.gitplus.issue.v1.ListSnapshotsResponse\x12Z\n" + + "\vEnqueueSync\x12$.gitplus.issue.v1.EnqueueSyncRequest\x1a%.gitplus.issue.v1.EnqueueSyncResponseB\xcd\x01\n" + + "\x14com.gitplus.issue.v1B\n" + + "IssueProtoP\x01Z=github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1;issuev1\xa2\x02\x03GIX\xaa\x02\x10Gitplus.Issue.V1\xca\x02\x10Gitplus\\Issue\\V1\xe2\x02\x1cGitplus\\Issue\\V1\\GPBMetadata\xea\x02\x12Gitplus::Issue::V1\x92\x03\a\xd2>\x02\x10\x01\b\x01b\beditionsp\xe8\a" + +var ( + file_gitplus_issue_v1_issue_proto_rawDescOnce sync.Once + file_gitplus_issue_v1_issue_proto_rawDescData []byte +) + +func file_gitplus_issue_v1_issue_proto_rawDescGZIP() []byte { + file_gitplus_issue_v1_issue_proto_rawDescOnce.Do(func() { + file_gitplus_issue_v1_issue_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_gitplus_issue_v1_issue_proto_rawDesc), len(file_gitplus_issue_v1_issue_proto_rawDesc))) + }) + return file_gitplus_issue_v1_issue_proto_rawDescData +} + +var file_gitplus_issue_v1_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_gitplus_issue_v1_issue_proto_goTypes = []any{ + (*IssueSummary)(nil), // 0: gitplus.issue.v1.IssueSummary + (*IssueBackupStatus)(nil), // 1: gitplus.issue.v1.IssueBackupStatus + (*IssueSnapshot)(nil), // 2: gitplus.issue.v1.IssueSnapshot + (*GetStatusRequest)(nil), // 3: gitplus.issue.v1.GetStatusRequest + (*GetStatusResponse)(nil), // 4: gitplus.issue.v1.GetStatusResponse + (*ListIssuesRequest)(nil), // 5: gitplus.issue.v1.ListIssuesRequest + (*ListIssuesResponse)(nil), // 6: gitplus.issue.v1.ListIssuesResponse + (*GetIssueRequest)(nil), // 7: gitplus.issue.v1.GetIssueRequest + (*GetIssueResponse)(nil), // 8: gitplus.issue.v1.GetIssueResponse + (*ListSnapshotsRequest)(nil), // 9: gitplus.issue.v1.ListSnapshotsRequest + (*ListSnapshotsResponse)(nil), // 10: gitplus.issue.v1.ListSnapshotsResponse + (*EnqueueSyncRequest)(nil), // 11: gitplus.issue.v1.EnqueueSyncRequest + (*EnqueueSyncResponse)(nil), // 12: gitplus.issue.v1.EnqueueSyncResponse + (*v1.Task)(nil), // 13: gitplus.task.v1.Task + (v1.TaskEnqueueResult)(0), // 14: gitplus.task.v1.TaskEnqueueResult +} +var file_gitplus_issue_v1_issue_proto_depIdxs = []int32{ + 1, // 0: gitplus.issue.v1.GetStatusResponse.status:type_name -> gitplus.issue.v1.IssueBackupStatus + 0, // 1: gitplus.issue.v1.ListIssuesResponse.issues:type_name -> gitplus.issue.v1.IssueSummary + 0, // 2: gitplus.issue.v1.GetIssueResponse.issue:type_name -> gitplus.issue.v1.IssueSummary + 2, // 3: gitplus.issue.v1.ListSnapshotsResponse.snapshots:type_name -> gitplus.issue.v1.IssueSnapshot + 13, // 4: gitplus.issue.v1.EnqueueSyncResponse.task:type_name -> gitplus.task.v1.Task + 14, // 5: gitplus.issue.v1.EnqueueSyncResponse.result:type_name -> gitplus.task.v1.TaskEnqueueResult + 3, // 6: gitplus.issue.v1.IssueService.GetStatus:input_type -> gitplus.issue.v1.GetStatusRequest + 5, // 7: gitplus.issue.v1.IssueService.ListIssues:input_type -> gitplus.issue.v1.ListIssuesRequest + 7, // 8: gitplus.issue.v1.IssueService.GetIssue:input_type -> gitplus.issue.v1.GetIssueRequest + 9, // 9: gitplus.issue.v1.IssueService.ListSnapshots:input_type -> gitplus.issue.v1.ListSnapshotsRequest + 11, // 10: gitplus.issue.v1.IssueService.EnqueueSync:input_type -> gitplus.issue.v1.EnqueueSyncRequest + 4, // 11: gitplus.issue.v1.IssueService.GetStatus:output_type -> gitplus.issue.v1.GetStatusResponse + 6, // 12: gitplus.issue.v1.IssueService.ListIssues:output_type -> gitplus.issue.v1.ListIssuesResponse + 8, // 13: gitplus.issue.v1.IssueService.GetIssue:output_type -> gitplus.issue.v1.GetIssueResponse + 10, // 14: gitplus.issue.v1.IssueService.ListSnapshots:output_type -> gitplus.issue.v1.ListSnapshotsResponse + 12, // 15: gitplus.issue.v1.IssueService.EnqueueSync:output_type -> gitplus.issue.v1.EnqueueSyncResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_gitplus_issue_v1_issue_proto_init() } +func file_gitplus_issue_v1_issue_proto_init() { + if File_gitplus_issue_v1_issue_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_gitplus_issue_v1_issue_proto_rawDesc), len(file_gitplus_issue_v1_issue_proto_rawDesc)), + NumEnums: 0, + NumMessages: 13, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_gitplus_issue_v1_issue_proto_goTypes, + DependencyIndexes: file_gitplus_issue_v1_issue_proto_depIdxs, + MessageInfos: file_gitplus_issue_v1_issue_proto_msgTypes, + }.Build() + File_gitplus_issue_v1_issue_proto = out.File + file_gitplus_issue_v1_issue_proto_goTypes = nil + file_gitplus_issue_v1_issue_proto_depIdxs = nil +} diff --git a/pkg/rpc/gitplus/issue/v1/issuev1connect/issue.connect.go b/pkg/rpc/gitplus/issue/v1/issuev1connect/issue.connect.go new file mode 100644 index 0000000..6fe8ec5 --- /dev/null +++ b/pkg/rpc/gitplus/issue/v1/issuev1connect/issue.connect.go @@ -0,0 +1,222 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: gitplus/issue/v1/issue.proto + +package issuev1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/issue/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // IssueServiceName is the fully-qualified name of the IssueService service. + IssueServiceName = "gitplus.issue.v1.IssueService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // IssueServiceGetStatusProcedure is the fully-qualified name of the IssueService's GetStatus RPC. + IssueServiceGetStatusProcedure = "/gitplus.issue.v1.IssueService/GetStatus" + // IssueServiceListIssuesProcedure is the fully-qualified name of the IssueService's ListIssues RPC. + IssueServiceListIssuesProcedure = "/gitplus.issue.v1.IssueService/ListIssues" + // IssueServiceGetIssueProcedure is the fully-qualified name of the IssueService's GetIssue RPC. + IssueServiceGetIssueProcedure = "/gitplus.issue.v1.IssueService/GetIssue" + // IssueServiceListSnapshotsProcedure is the fully-qualified name of the IssueService's + // ListSnapshots RPC. + IssueServiceListSnapshotsProcedure = "/gitplus.issue.v1.IssueService/ListSnapshots" + // IssueServiceEnqueueSyncProcedure is the fully-qualified name of the IssueService's EnqueueSync + // RPC. + IssueServiceEnqueueSyncProcedure = "/gitplus.issue.v1.IssueService/EnqueueSync" +) + +// IssueServiceClient is a client for the gitplus.issue.v1.IssueService service. +type IssueServiceClient interface { + GetStatus(context.Context, *connect.Request[v1.GetStatusRequest]) (*connect.Response[v1.GetStatusResponse], error) + ListIssues(context.Context, *connect.Request[v1.ListIssuesRequest]) (*connect.Response[v1.ListIssuesResponse], error) + GetIssue(context.Context, *connect.Request[v1.GetIssueRequest]) (*connect.Response[v1.GetIssueResponse], error) + ListSnapshots(context.Context, *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ListSnapshotsResponse], error) + EnqueueSync(context.Context, *connect.Request[v1.EnqueueSyncRequest]) (*connect.Response[v1.EnqueueSyncResponse], error) +} + +// NewIssueServiceClient constructs a client for the gitplus.issue.v1.IssueService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewIssueServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) IssueServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + issueServiceMethods := v1.File_gitplus_issue_v1_issue_proto.Services().ByName("IssueService").Methods() + return &issueServiceClient{ + getStatus: connect.NewClient[v1.GetStatusRequest, v1.GetStatusResponse]( + httpClient, + baseURL+IssueServiceGetStatusProcedure, + connect.WithSchema(issueServiceMethods.ByName("GetStatus")), + connect.WithClientOptions(opts...), + ), + listIssues: connect.NewClient[v1.ListIssuesRequest, v1.ListIssuesResponse]( + httpClient, + baseURL+IssueServiceListIssuesProcedure, + connect.WithSchema(issueServiceMethods.ByName("ListIssues")), + connect.WithClientOptions(opts...), + ), + getIssue: connect.NewClient[v1.GetIssueRequest, v1.GetIssueResponse]( + httpClient, + baseURL+IssueServiceGetIssueProcedure, + connect.WithSchema(issueServiceMethods.ByName("GetIssue")), + connect.WithClientOptions(opts...), + ), + listSnapshots: connect.NewClient[v1.ListSnapshotsRequest, v1.ListSnapshotsResponse]( + httpClient, + baseURL+IssueServiceListSnapshotsProcedure, + connect.WithSchema(issueServiceMethods.ByName("ListSnapshots")), + connect.WithClientOptions(opts...), + ), + enqueueSync: connect.NewClient[v1.EnqueueSyncRequest, v1.EnqueueSyncResponse]( + httpClient, + baseURL+IssueServiceEnqueueSyncProcedure, + connect.WithSchema(issueServiceMethods.ByName("EnqueueSync")), + connect.WithClientOptions(opts...), + ), + } +} + +// issueServiceClient implements IssueServiceClient. +type issueServiceClient struct { + getStatus *connect.Client[v1.GetStatusRequest, v1.GetStatusResponse] + listIssues *connect.Client[v1.ListIssuesRequest, v1.ListIssuesResponse] + getIssue *connect.Client[v1.GetIssueRequest, v1.GetIssueResponse] + listSnapshots *connect.Client[v1.ListSnapshotsRequest, v1.ListSnapshotsResponse] + enqueueSync *connect.Client[v1.EnqueueSyncRequest, v1.EnqueueSyncResponse] +} + +// GetStatus calls gitplus.issue.v1.IssueService.GetStatus. +func (c *issueServiceClient) GetStatus(ctx context.Context, req *connect.Request[v1.GetStatusRequest]) (*connect.Response[v1.GetStatusResponse], error) { + return c.getStatus.CallUnary(ctx, req) +} + +// ListIssues calls gitplus.issue.v1.IssueService.ListIssues. +func (c *issueServiceClient) ListIssues(ctx context.Context, req *connect.Request[v1.ListIssuesRequest]) (*connect.Response[v1.ListIssuesResponse], error) { + return c.listIssues.CallUnary(ctx, req) +} + +// GetIssue calls gitplus.issue.v1.IssueService.GetIssue. +func (c *issueServiceClient) GetIssue(ctx context.Context, req *connect.Request[v1.GetIssueRequest]) (*connect.Response[v1.GetIssueResponse], error) { + return c.getIssue.CallUnary(ctx, req) +} + +// ListSnapshots calls gitplus.issue.v1.IssueService.ListSnapshots. +func (c *issueServiceClient) ListSnapshots(ctx context.Context, req *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ListSnapshotsResponse], error) { + return c.listSnapshots.CallUnary(ctx, req) +} + +// EnqueueSync calls gitplus.issue.v1.IssueService.EnqueueSync. +func (c *issueServiceClient) EnqueueSync(ctx context.Context, req *connect.Request[v1.EnqueueSyncRequest]) (*connect.Response[v1.EnqueueSyncResponse], error) { + return c.enqueueSync.CallUnary(ctx, req) +} + +// IssueServiceHandler is an implementation of the gitplus.issue.v1.IssueService service. +type IssueServiceHandler interface { + GetStatus(context.Context, *connect.Request[v1.GetStatusRequest]) (*connect.Response[v1.GetStatusResponse], error) + ListIssues(context.Context, *connect.Request[v1.ListIssuesRequest]) (*connect.Response[v1.ListIssuesResponse], error) + GetIssue(context.Context, *connect.Request[v1.GetIssueRequest]) (*connect.Response[v1.GetIssueResponse], error) + ListSnapshots(context.Context, *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ListSnapshotsResponse], error) + EnqueueSync(context.Context, *connect.Request[v1.EnqueueSyncRequest]) (*connect.Response[v1.EnqueueSyncResponse], error) +} + +// NewIssueServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewIssueServiceHandler(svc IssueServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + issueServiceMethods := v1.File_gitplus_issue_v1_issue_proto.Services().ByName("IssueService").Methods() + issueServiceGetStatusHandler := connect.NewUnaryHandler( + IssueServiceGetStatusProcedure, + svc.GetStatus, + connect.WithSchema(issueServiceMethods.ByName("GetStatus")), + connect.WithHandlerOptions(opts...), + ) + issueServiceListIssuesHandler := connect.NewUnaryHandler( + IssueServiceListIssuesProcedure, + svc.ListIssues, + connect.WithSchema(issueServiceMethods.ByName("ListIssues")), + connect.WithHandlerOptions(opts...), + ) + issueServiceGetIssueHandler := connect.NewUnaryHandler( + IssueServiceGetIssueProcedure, + svc.GetIssue, + connect.WithSchema(issueServiceMethods.ByName("GetIssue")), + connect.WithHandlerOptions(opts...), + ) + issueServiceListSnapshotsHandler := connect.NewUnaryHandler( + IssueServiceListSnapshotsProcedure, + svc.ListSnapshots, + connect.WithSchema(issueServiceMethods.ByName("ListSnapshots")), + connect.WithHandlerOptions(opts...), + ) + issueServiceEnqueueSyncHandler := connect.NewUnaryHandler( + IssueServiceEnqueueSyncProcedure, + svc.EnqueueSync, + connect.WithSchema(issueServiceMethods.ByName("EnqueueSync")), + connect.WithHandlerOptions(opts...), + ) + return "/gitplus.issue.v1.IssueService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case IssueServiceGetStatusProcedure: + issueServiceGetStatusHandler.ServeHTTP(w, r) + case IssueServiceListIssuesProcedure: + issueServiceListIssuesHandler.ServeHTTP(w, r) + case IssueServiceGetIssueProcedure: + issueServiceGetIssueHandler.ServeHTTP(w, r) + case IssueServiceListSnapshotsProcedure: + issueServiceListSnapshotsHandler.ServeHTTP(w, r) + case IssueServiceEnqueueSyncProcedure: + issueServiceEnqueueSyncHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedIssueServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedIssueServiceHandler struct{} + +func (UnimplementedIssueServiceHandler) GetStatus(context.Context, *connect.Request[v1.GetStatusRequest]) (*connect.Response[v1.GetStatusResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitplus.issue.v1.IssueService.GetStatus is not implemented")) +} + +func (UnimplementedIssueServiceHandler) ListIssues(context.Context, *connect.Request[v1.ListIssuesRequest]) (*connect.Response[v1.ListIssuesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitplus.issue.v1.IssueService.ListIssues is not implemented")) +} + +func (UnimplementedIssueServiceHandler) GetIssue(context.Context, *connect.Request[v1.GetIssueRequest]) (*connect.Response[v1.GetIssueResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitplus.issue.v1.IssueService.GetIssue is not implemented")) +} + +func (UnimplementedIssueServiceHandler) ListSnapshots(context.Context, *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ListSnapshotsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitplus.issue.v1.IssueService.ListSnapshots is not implemented")) +} + +func (UnimplementedIssueServiceHandler) EnqueueSync(context.Context, *connect.Request[v1.EnqueueSyncRequest]) (*connect.Response[v1.EnqueueSyncResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitplus.issue.v1.IssueService.EnqueueSync is not implemented")) +} diff --git a/pkg/server/issues_test.go b/pkg/server/issues_test.go new file mode 100644 index 0000000..8ef9fe5 --- /dev/null +++ b/pkg/server/issues_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "net/http" + "strings" + "testing" +) + +func TestIssueRoutesRequireExistingAPIAuthentication(t *testing.T) { + server := newTestServerWithPassword(t, t.TempDir(), "test-dashboard-password") + for _, test := range []struct{ method, path string }{ + {http.MethodPost, "/api/gitplus.issue.v1.IssueService/GetStatus"}, + {http.MethodPost, "/api/gitplus.issue.v1.IssueService/ListIssues"}, + {http.MethodPost, "/api/gitplus.issue.v1.IssueService/GetIssue"}, + {http.MethodPost, "/api/gitplus.issue.v1.IssueService/ListSnapshots"}, + {http.MethodPost, "/api/gitplus.issue.v1.IssueService/EnqueueSync"}, + {http.MethodGet, "/api/repos/1/issues/export?password=test-dashboard-password"}, + } { + request, err := http.NewRequest(test.method, server.URL+test.path, strings.NewReader(`{"repoId":"1"}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("%s: got %d", test.path, response.StatusCode) + } + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 65f6fdc..6043a51 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -14,6 +14,8 @@ import ( "github.com/ImSingee/git-plus/pkg/cronservice" "github.com/ImSingee/git-plus/pkg/eventbus" "github.com/ImSingee/git-plus/pkg/eventservice" + "github.com/ImSingee/git-plus/pkg/issueservice" + "github.com/ImSingee/git-plus/pkg/issuetask" "github.com/ImSingee/git-plus/pkg/reposervice" "github.com/ImSingee/git-plus/pkg/task" "github.com/ImSingee/git-plus/pkg/taskservice" @@ -49,6 +51,9 @@ func Run(ctx context.Context, cfg Config, frontendHandlerFactory FrontendHandler } defer sqliteDB.Close() taskManager.SetRecorder(taskstore.NewRecorder(sqliteDB)) + if err := issuetask.New(cfg.DataDir, sqliteDB, taskManager).ResumeInterrupted(ctx); err != nil { + log.Printf("resume issue backups: %v", err) + } cronRuntime, err := newCronRuntime(cfg.DataDir, sqliteDB, taskManager) if err != nil { @@ -103,6 +108,7 @@ func NewHandler(dataDir string, sqliteDB *sql.DB, taskManager *task.Manager, bus taskServiceOptions = append(taskServiceOptions, taskservice.WithDatabase(sqliteDB)) taskservice.RegisterHandlers(apiMux, dataDir, taskManager, taskServiceOptions...) reposervice.RegisterHandlers(apiMux, dataDir, reposervice.WithDatabase(sqliteDB)) + issueservice.RegisterHandlers(apiMux, dataDir, sqliteDB, taskManager) eventservice.RegisterHandlers(apiMux, bus) mux.Handle("/api/", apiAuthMiddleware(http.StripPrefix("/api", apiMux))) mux.HandleFunc("/api", notFoundAPIHandler) diff --git a/pkg/taskservice/issues_test.go b/pkg/taskservice/issues_test.go new file mode 100644 index 0000000..27e9932 --- /dev/null +++ b/pkg/taskservice/issues_test.go @@ -0,0 +1,97 @@ +package taskservice + +import ( + "context" + "testing" + "time" + + appdb "github.com/ImSingee/git-plus/db" + dbsqlc "github.com/ImSingee/git-plus/db/sqlc" + appconfig "github.com/ImSingee/git-plus/pkg/config" + "github.com/ImSingee/git-plus/pkg/issuetask" + "github.com/ImSingee/git-plus/pkg/syncsource" + "github.com/ImSingee/git-plus/pkg/task" +) + +type noopSourceExecutor struct{} + +func (noopSourceExecutor) Sync(context.Context, syncsource.SyncRequest, SourceSyncReporter) error { + return nil +} +func TestSourceSyncQueuesOptedInIssueChildTasks(t *testing.T) { + for _, enabled := range []bool{false, true} { + name := "disabled" + if enabled { + name = "enabled" + } + t.Run(name, func(t *testing.T) { + t.Setenv(appconfig.TokenPassphraseEnvVar, serviceTestPassphrase) + ctx := context.Background() + dir := t.TempDir() + if err := appdb.Migrate(ctx, dir); err != nil { + t.Fatal(err) + } + db, err := appdb.Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + defer db.Close() + token, err := appconfig.EncryptToken("synthetic-token", serviceTestPassphrase) + if err != nil { + t.Fatal(err) + } + if err := appconfig.Save(appconfig.PathForDataDir(dir), appconfig.Config{Sources: []appconfig.SourceConfig{{ID: "source", Platform: "github", Username: "owner", Token: token, IssuesEnabled: enabled}}}); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Format(time.RFC3339) + if err := dbsqlc.New(db).CreateRepo(ctx, dbsqlc.CreateRepoParams{SourceID: "source", Platform: "github", RefID: "123", Status: "active", Name: "repo", FullName: "owner/repo", Owner: "owner", Origin: `["default"]`, Meta: "{}", LastSeenAt: now, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + child := make(chan task.Snapshot, 1) + finished := make(chan task.Event, 1) + var manager *task.Manager + manager = task.NewManager(task.WithObserver(func(event task.Event) { + if event.Name == task.EventTaskEnqueued && event.Task.JobType == issuetask.JobType { + // Cancel while the source parent is still running. No GitHub request is made. + if _, err := manager.CancelQueuedTask(event.Task.TaskID); err != nil { + t.Error(err) + } + child <- event.Task + } + if event.Task.JobID == "test-source" && (event.Name == task.EventTaskFinished || event.Name == task.EventTaskFailed) { + finished <- event + } + })) + defer manager.Close() + service := newServiceServer(dir, manager, WithDatabase(db), WithSourceSyncExecutor(noopSourceExecutor{})) + _, parent, err := manager.Enqueue(task.Spec{JobID: "test-source", JobType: task.JobTypeSyncSource, Name: "Source sync", Run: service.sourceSyncRunner("source")}) + if err != nil { + t.Fatal(err) + } + select { + case event := <-finished: + if event.Name != task.EventTaskFinished { + t.Fatalf("source failed: %s", event.Task.ErrorMessage) + } + case <-time.After(5 * time.Second): + t.Fatal("source did not finish") + } + if enabled { + select { + case item := <-child: + if item.ParentTaskID != parent.TaskID || item.Args["source_id"] != "source" { + t.Fatalf("bad child: %+v", item) + } + default: + t.Fatal("opted-in source did not queue issue child") + } + } else { + select { + case <-child: + t.Fatal("disabled source queued issues") + default: + } + } + }) + } +} diff --git a/pkg/taskservice/service.go b/pkg/taskservice/service.go index 5279e03..4a1f447 100644 --- a/pkg/taskservice/service.go +++ b/pkg/taskservice/service.go @@ -20,6 +20,7 @@ import ( appdb "github.com/ImSingee/git-plus/db" dbsqlc "github.com/ImSingee/git-plus/db/sqlc" appconfig "github.com/ImSingee/git-plus/pkg/config" + "github.com/ImSingee/git-plus/pkg/issuetask" taskv1 "github.com/ImSingee/git-plus/pkg/rpc/gitplus/task/v1" "github.com/ImSingee/git-plus/pkg/rpc/gitplus/task/v1/taskv1connect" "github.com/ImSingee/git-plus/pkg/syncsource" @@ -445,7 +446,21 @@ func (s *serviceServer) sourceSyncRunner(sourceID string) func(*task.ExecutionCo return fmt.Errorf("source sync executor is required") } - return s.sourceSyncExecutor.Sync(context.Background(), request, ctx) + syncErr := s.sourceSyncExecutor.Sync(context.Background(), request, ctx) + if !request.Source.IssuesEnabled || s.db == nil { + return syncErr + } + repos, err := dbsqlc.New(s.db).ListActiveReposForSource(context.Background(), sourceID) + if err != nil { + return errors.Join(syncErr, err) + } + runner := issuetask.New(s.dataDir, s.db, s.manager) + for _, repo := range repos { + if _, _, err := runner.Enqueue(context.Background(), repo.ID, false, ctx.TaskID()); err != nil { + syncErr = errors.Join(syncErr, err) + } + } + return syncErr } } diff --git a/proto/gitplus/config/v1/config.proto b/proto/gitplus/config/v1/config.proto index b9384d4..accb2c1 100644 --- a/proto/gitplus/config/v1/config.proto +++ b/proto/gitplus/config/v1/config.proto @@ -49,23 +49,20 @@ message Source { repeated string only_include_repos = 5 [(buf.validate.field).repeated = { unique: true items: { - string: { - min_len: 1 - } + string: {min_len: 1} } }]; repeated string exclude_repos = 6 [(buf.validate.field).repeated = { unique: true items: { - string: { - min_len: 1 - } + string: {min_len: 1} } }]; bool include_defaults = 7; bool include_starred = 8; bool include_watching = 9; string name = 10; + bool issues_enabled = 11; } message ConfigSnapshot { @@ -79,9 +76,7 @@ message StringListValue { repeated string values = 1 [(buf.validate.field).repeated = { unique: true items: { - string: { - min_len: 1 - } + string: {min_len: 1} } }]; } @@ -97,23 +92,20 @@ message CreateSourceInput { repeated string only_include_repos = 5 [(buf.validate.field).repeated = { unique: true items: { - string: { - min_len: 1 - } + string: {min_len: 1} } }]; repeated string exclude_repos = 6 [(buf.validate.field).repeated = { unique: true items: { - string: { - min_len: 1 - } + string: {min_len: 1} } }]; bool include_defaults = 7; bool include_starred = 8; bool include_watching = 9; string name = 10; + bool issues_enabled = 11; } message UpdateSourcePatch { @@ -129,6 +121,7 @@ message UpdateSourcePatch { bool include_starred = 7; bool include_watching = 8; string name = 9; + bool issues_enabled = 10; } message CheckConfigRequest {} diff --git a/proto/gitplus/issue/v1/issue.proto b/proto/gitplus/issue/v1/issue.proto new file mode 100644 index 0000000..18a565a --- /dev/null +++ b/proto/gitplus/issue/v1/issue.proto @@ -0,0 +1,98 @@ +edition = "2023"; + +package gitplus.issue.v1; + +import "buf/validate/validate.proto"; +import "gitplus/task/v1/task.proto"; +import "google/protobuf/go_features.proto"; + +option features.(pb.go).api_level = API_OPEN; +option features.field_presence = EXPLICIT; + +message IssueSummary { + string id = 1; + int64 number = 2; + string title = 3; + string state = 4; + string author_login = 5; + string updated_at = 6; + string availability = 7; + repeated string labels = 8; + repeated string assignees = 9; +} + +message IssueBackupStatus { + bool enabled = 1; + string state = 2; + string last_successful_at = 3; + string last_full_reconcile_at = 4; + string last_error = 5; + string latest_commit = 6; + int32 issue_count = 7; + int32 comment_count = 8; +} + +message IssueSnapshot { + string commit = 1; + string created_at = 2; + int32 issue_count = 3; + int32 comment_count = 4; +} + +message GetStatusRequest { + int64 repo_id = 1 [(buf.validate.field).int64.gt = 0]; +} +message GetStatusResponse { + IssueBackupStatus status = 1; +} + +message ListIssuesRequest { + int64 repo_id = 1 [(buf.validate.field).int64.gt = 0]; + int32 page_size = 2; + string page_token = 3; + string state = 4; + string search = 5; +} +message ListIssuesResponse { + repeated IssueSummary issues = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +message GetIssueRequest { + int64 repo_id = 1 [(buf.validate.field).int64.gt = 0]; + string issue_id = 2 [(buf.validate.field).string.min_len = 1]; + string snapshot = 3; +} +message GetIssueResponse { + IssueSummary issue = 1; + string raw_json = 2; + string comments_json = 3; + string timeline_json = 4; + string markdown = 5; + string snapshot = 6; +} + +message ListSnapshotsRequest { + int64 repo_id = 1 [(buf.validate.field).int64.gt = 0]; +} +message ListSnapshotsResponse { + repeated IssueSnapshot snapshots = 1; +} + +message EnqueueSyncRequest { + int64 repo_id = 1 [(buf.validate.field).int64.gt = 0]; + bool full = 2; +} +message EnqueueSyncResponse { + gitplus.task.v1.Task task = 1; + gitplus.task.v1.TaskEnqueueResult result = 2; +} + +service IssueService { + rpc GetStatus(GetStatusRequest) returns (GetStatusResponse); + rpc ListIssues(ListIssuesRequest) returns (ListIssuesResponse); + rpc GetIssue(GetIssueRequest) returns (GetIssueResponse); + rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse); + rpc EnqueueSync(EnqueueSyncRequest) returns (EnqueueSyncResponse); +} From e0b1a9dae6e8c109c9dd677b28f3291c3c3b2592 Mon Sep 17 00:00:00 2001 From: Bryan Date: Thu, 15 Oct 2026 22:55:33 +0800 Subject: [PATCH 2/3] fix: stabilize issue browser tests and share rate-limit cooldown --- docs/issues-backup.md | 2 +- .../issues/IssuesTab.browser.test.tsx | 35 ++++++++++++---- pkg/githubapi/client.go | 21 +++++++++- pkg/githubapi/client_test.go | 41 +++++++++++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/issues-backup.md b/docs/issues-backup.md index 604ab3c..72f46c6 100644 --- a/docs/issues-backup.md +++ b/docs/issues-backup.md @@ -74,7 +74,7 @@ There is no GitHub restore/import command. The public API cannot recreate origin - The first run is full. Later runs use independent issue and repository-comment `since` watermarks, with a five-minute overlap. A watermark is the run's **start**, never the newest item timestamp or completion time. - Changes to comments can cause a thread to be refreshed even if its issue's `updated_at` did not change. Each selected thread fetches all comments and timeline pages. Labels and milestones are fully refreshed on every run. A full reconciliation rechecks every issue at least every seven days **when a sync runs**, or immediately via **Full reconcile**. There is no separate background schedule when cron is disabled. -- All list endpoints follow validated same-origin `Link: rel="next"` pagination. The API client pins its version, serializes requests per credential, bounds response/ETag-cache size, and rejects cross-origin redirects/next links. Network/5xx/short rate-limit errors get bounded retries. Longer rate-limit waits fail the task with the retry time, keeping the prior backup; retry manually or on the next scheduled run after that time. +- All list endpoints follow validated same-origin `Link: rel="next"` pagination. The API client pins its version, serializes requests per credential, bounds response/ETag-cache size, and rejects cross-origin redirects/next links. Network/5xx/short rate-limit errors get bounded retries. Longer rate-limit waits fail the task with the retry time, keeping the prior backup; retry manually or on the next scheduled run after that time. Known reset times are shared across client instances, so other queued repositories using that credential do not make requests before the reset. - Successful per-thread results and the original observation window are checkpointed in private staging. An interrupted run re-enumerates pages and resumes matching completed threads. It does not trust old page offsets against a moving API dataset. In-progress jobs are requeued on server startup; queued-but-not-started tasks follow the existing in-memory task queue behavior and can be recreated by the next source sync. - Git objects are written before the snapshot reference is atomically published. Only then does one SQLite transaction replace the index and advance watermarks. If publication succeeded but the index commit did not, the next sync rebuilds from Git. No-op runs advance SQLite watermarks without generating metadata-only commits; reindex may safely replay from an older archived watermark. - A failed page, permission error, repository identity mismatch, 404/410, or partial collection never publishes a purported complete snapshot or advances successful watermarks. A **successful full crawl** that does not see a formerly archived issue marks it `unavailable`, retaining its last content and all prior commits. That marker deliberately does not claim deletion: transfer, visibility, and permissions can also explain absence. A later observation clears it. Deleted comments/labels can disappear from the current complete collection while remaining in old commits. diff --git a/frontend/src/components/issues/IssuesTab.browser.test.tsx b/frontend/src/components/issues/IssuesTab.browser.test.tsx index ffe4ada..ddc08ac 100644 --- a/frontend/src/components/issues/IssuesTab.browser.test.tsx +++ b/frontend/src/components/issues/IssuesTab.browser.test.tsx @@ -59,6 +59,13 @@ function renderTab() { ); } +async function waitForLoadedIndex() { + // The first status response changes latestCommit and remounts the issue list. + // Wait for that response before finding the current, attached row button. + await screen.findByText('Ready'); + await screen.findByRole('button', { name: /Preserve offline discussions/ }); +} + beforeEach(() => { vi.mocked(issueClient.getStatus).mockResolvedValue( create(GetStatusResponseSchema, { @@ -141,19 +148,26 @@ describe('offline issue browser', () => { ); renderTab(); expect(await screen.findByText('No issues backed up yet')).toBeVisible(); - expect(screen.getByText('Issue backups are turned off')).toBeVisible(); + expect( + await screen.findByText('Issue backups are turned off'), + ).toBeVisible(); expect(screen.getByRole('button', { name: 'Sync issues' })).toBeDisabled(); }); it('shows list failures with retry instead of claiming the archive is empty', async () => { - vi.mocked(issueClient.listIssues).mockRejectedValueOnce( + vi.mocked(issueClient.listIssues).mockRejectedValue( new Error('Archive temporarily unavailable'), ); renderTab(); + await screen.findByText('Ready'); expect( await screen.findByText('Could not load archived issues'), ).toBeVisible(); + expect(screen.getByText('Archive temporarily unavailable')).toBeVisible(); expect(screen.queryByText('No issues backed up yet')).toBeNull(); + vi.mocked(issueClient.listIssues).mockResolvedValue( + create(ListIssuesResponseSchema, { issues: [issue], totalCount: 1 }), + ); fireEvent.click(screen.getByRole('button', { name: 'Retry' })); expect( await screen.findByRole('button', { @@ -164,8 +178,9 @@ describe('offline issue browser', () => { it('opens the saved body, comments, timeline, and original JSON', async () => { renderTab(); + await waitForLoadedIndex(); fireEvent.click( - await screen.findByRole('button', { + screen.getByRole('button', { name: /Preserve offline discussions/, }), ); @@ -196,8 +211,9 @@ describe('offline issue browser', () => { }), ); const { container } = renderTab(); + await waitForLoadedIndex(); fireEvent.click( - await screen.findByRole('button', { + screen.getByRole('button', { name: /Preserve offline discussions/, }), ); @@ -215,8 +231,9 @@ describe('offline issue browser', () => { new Error('Issue not present in this snapshot'), ); renderTab(); + await waitForLoadedIndex(); fireEvent.click( - await screen.findByRole('button', { + screen.getByRole('button', { name: /Preserve offline discussions/, }), ); @@ -242,7 +259,7 @@ describe('offline issue browser', () => { }), ); renderTab(); - await screen.findByRole('button', { name: /Preserve offline discussions/ }); + await waitForLoadedIndex(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); await waitFor(() => expect(issueClient.listIssues).toHaveBeenLastCalledWith( @@ -263,7 +280,7 @@ describe('offline issue browser', () => { it('queues incremental and full sync and exports the selected snapshot', async () => { renderTab(); - await screen.findByRole('button', { name: /Preserve offline discussions/ }); + await waitForLoadedIndex(); fireEvent.click(screen.getByRole('button', { name: 'Sync issues' })); await waitFor(() => expect(issueClient.enqueueSync).toHaveBeenCalledWith({ @@ -284,7 +301,9 @@ describe('offline issue browser', () => { }), ); fireEvent.click( - screen.getByLabelText('Snapshot for issue details and export'), + screen.getByRole('combobox', { + name: 'Snapshot for issue details and export', + }), ); fireEvent.click(await screen.findByRole('option', { name: /abcdef12/ })); expect( diff --git a/pkg/githubapi/client.go b/pkg/githubapi/client.go index 1cbe83a..f68397e 100644 --- a/pkg/githubapi/client.go +++ b/pkg/githubapi/client.go @@ -58,6 +58,12 @@ type Client struct { cacheBytes int } +type credentialGate struct { + gate chan struct{} + limitedUntil time.Time + status int +} + var credentialLocks sync.Map func New(baseURL string, client *http.Client) *Client { @@ -133,14 +139,21 @@ func (c *Client) get(ctx context.Context, token, path string) ([]byte, string, e return nil, "", err } key := fmt.Sprintf("%x", sha256.Sum256([]byte(c.BaseURL+"\x00"+token))) - value, _ := credentialLocks.LoadOrStore(key, make(chan struct{}, 1)) - gate := value.(chan struct{}) + value, _ := credentialLocks.LoadOrStore(key, &credentialGate{gate: make(chan struct{}, 1)}) + credential := value.(*credentialGate) + gate := credential.gate select { case gate <- struct{}{}: case <-ctx.Done(): return nil, "", ctx.Err() } defer func() { <-gate }() + // A long reset must also stop later queued repositories using this credential. + // Keep this state across Client instances; never hammer GitHub until reset. + if credential.limitedUntil.After(c.Now()) { + return nil, "", &Error{Status: credential.status, RetryAt: credential.limitedUntil} + } + credential.limitedUntil = time.Time{} cacheKey := key + "|" + u.String() c.mu.Lock() prev := c.cache[cacheKey] @@ -189,9 +202,11 @@ func (c *Client) get(ctx context.Context, token, path string) ([]byte, string, e return nil, "", fmt.Errorf("GitHub API response exceeds size limit") } if resp.StatusCode == http.StatusNotModified && prev.body != nil { + credential.limitedUntil = time.Time{} return append([]byte(nil), prev.body...), prev.next, nil } if resp.StatusCode >= 200 && resp.StatusCode < 300 { + credential.limitedUntil = time.Time{} next, err := nextLink(resp.Header.Values("Link")) if err != nil { return nil, "", err @@ -243,6 +258,8 @@ func (c *Client) get(ctx context.Context, token, path string) ([]byte, string, e delay = time.Second } apiErr.RetryAt = c.Now().Add(delay) + credential.limitedUntil = apiErr.RetryAt + credential.status = apiErr.Status } if (!limited && resp.StatusCode < 500) || attempt == 2 || delay > 30*time.Second { return nil, "", apiErr diff --git a/pkg/githubapi/client_test.go b/pkg/githubapi/client_test.go index 7802ec6..64dce2f 100644 --- a/pkg/githubapi/client_test.go +++ b/pkg/githubapi/client_test.go @@ -343,3 +343,44 @@ func TestRelativeNextLinkUsesCurrentPageURL(t *testing.T) { t.Fatalf("relative Link lost current page path: items=%s err=%v", items, err) } } + +func TestLongRateLimitStopsOtherClientsForSameCredential(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Header.Get("Authorization") == "Bearer limited" { + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"id":1}`)) + })) + defer server.Close() + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + first := New(server.URL, server.Client()) + first.Now = func() time.Time { return now } + if _, err := first.Object(context.Background(), "limited", "/repo"); err == nil { + t.Fatal("expected rate limit") + } + second := New(server.URL, server.Client()) + second.Now = first.Now + if _, err := second.Object(context.Background(), "limited", "/another-repo"); err == nil { + t.Fatal("expected shared rate limit") + } + if requests.Load() != 1 { + t.Fatalf("requested during known reset: %d requests", requests.Load()) + } + if _, err := second.Object(context.Background(), "other", "/repo"); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("unrelated credential was blocked: %d", requests.Load()) + } + now = now.Add(121 * time.Second) + if _, err := second.Object(context.Background(), "limited", "/repo"); err == nil { + t.Fatal("expected new server rate response") + } + if requests.Load() != 3 { + t.Fatalf("did not retry after reset: %d", requests.Load()) + } +} From 266a91e11b78884270f13faaf536c7f5e8c57fd9 Mon Sep 17 00:00:00 2001 From: Bryan Date: Thu, 15 Oct 2026 23:04:08 +0800 Subject: [PATCH 3/3] test: isolate rate-limit state and await accordion visibility --- .../components/issues/IssuesTab.browser.test.tsx | 4 +++- pkg/githubapi/client_test.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/issues/IssuesTab.browser.test.tsx b/frontend/src/components/issues/IssuesTab.browser.test.tsx index ddc08ac..3c7f5cb 100644 --- a/frontend/src/components/issues/IssuesTab.browser.test.tsx +++ b/frontend/src/components/issues/IssuesTab.browser.test.tsx @@ -190,7 +190,9 @@ describe('offline issue browser', () => { expect(screen.getByText('Label: backup')).toBeVisible(); expect(screen.getByText(/Original Markdown shown as text/)).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: 'Original JSON' })); - expect(await screen.findByText(/Preserved API records/)).toBeVisible(); + await waitFor(() => + expect(screen.getByText(/Preserved API records/)).toBeVisible(), + ); expect(issueClient.getIssue).toHaveBeenCalledWith( { repoId: 42n, issueId: '123', snapshot: '' }, expect.anything(), diff --git a/pkg/githubapi/client_test.go b/pkg/githubapi/client_test.go index 64dce2f..b9437d1 100644 --- a/pkg/githubapi/client_test.go +++ b/pkg/githubapi/client_test.go @@ -2,6 +2,7 @@ package githubapi import ( "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -22,6 +23,14 @@ func testClient(server *httptest.Server) *Client { return client } +func cleanupCredentialGate(t *testing.T, baseURL, token string) { + t.Helper() + key := fmt.Sprintf("%x", sha256.Sum256([]byte(baseURL+"\x00"+token))) + // A later httptest server can reuse this port, even within one -count run. + // All requests must finish before cleanup; only this test's credential is removed. + t.Cleanup(func() { credentialLocks.Delete(key) }) +} + func TestPagesFollowEveryLinkAndPreserveRawObjects(t *testing.T) { var calls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -177,6 +186,7 @@ func TestRetryPolicyAndRateLimitDelays(t *testing.T) { })) defer server.Close() client := testClient(server) + cleanupCredentialGate(t, client.BaseURL, "token") client.Now = func() time.Time { return now } var sleeps []time.Duration client.Sleep = func(ctx context.Context, delay time.Duration) error { sleeps = append(sleeps, delay); return ctx.Err() } @@ -250,6 +260,7 @@ func TestErrorsNeverExposeTokensBodiesOrURLs(t *testing.T) { fmt.Fprintf(w, `{"message":%q}`, secret) })) defer server.Close() + cleanupCredentialGate(t, server.URL, secret) _, err := testClient(server).Object(context.Background(), secret, "/object?secret="+secret) if err == nil || strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), server.URL) { t.Fatalf("unsafe API error: %v", err) @@ -294,6 +305,7 @@ func TestHTTPDateRetryAfter(t *testing.T) { })) defer server.Close() client := testClient(server) + cleanupCredentialGate(t, client.BaseURL, "token") client.Now = func() time.Time { return now } _, err := client.Object(context.Background(), "token", "/object") var apiError *Error @@ -312,6 +324,7 @@ func TestVeryLargeRetryAfterDoesNotOverflow(t *testing.T) { })) defer server.Close() client := testClient(server) + cleanupCredentialGate(t, client.BaseURL, "token") client.Now = func() time.Time { return now } client.Sleep = func(context.Context, time.Duration) error { t.Error("must not retry a huge Retry-After delay") @@ -358,6 +371,8 @@ func TestLongRateLimitStopsOtherClientsForSameCredential(t *testing.T) { defer server.Close() now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) first := New(server.URL, server.Client()) + cleanupCredentialGate(t, first.BaseURL, "limited") + cleanupCredentialGate(t, first.BaseURL, "other") first.Now = func() time.Time { return now } if _, err := first.Object(context.Background(), "limited", "/repo"); err == nil { t.Fatal("expected rate limit")