From 03bfe4079e684872fa399135ac9b88f3ef7a1c43 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 18 May 2025 09:37:23 +0200 Subject: [PATCH] Adding upstream version 3.10.8. Signed-off-by: Daniel Baumann --- .deadcode-out | 84 +++ .editorconfig | 10 + .forgejo/workflows/release.yml | 35 + .forgejo/workflows/test-gitea.yml | 38 ++ .forgejo/workflows/test.yml | 54 ++ .gitignore | 7 + .gitmodules | 6 + .golangci.yml | 81 +++ LICENSE | 20 + Makefile | 75 +++ README.md | 226 +++++++ api/api.go | 24 + cmd/cli.go | 49 ++ cmd/enumtype.go | 48 ++ cmd/main.go | 49 ++ cmd/mirror.go | 129 ++++ cmd/mirror_test.go | 139 ++++ cmd/signal.go | 33 + cmd/testhelpers.go | 38 ++ f3/ci.go | 20 + f3/comment.go | 35 + f3/equal.go | 37 ++ f3/equal_test.go | 53 ++ f3/file_format.go | 109 +++ f3/file_format_test.go | 166 +++++ f3/file_format_testdata/ci/bad.json | 3 + f3/file_format_testdata/ci/good.json | 3 + f3/file_format_testdata/comment/bad.json | 7 + f3/file_format_testdata/comment/good.json | 14 + f3/file_format_testdata/issue/bad.json | 20 + f3/file_format_testdata/issue/good.json | 21 + f3/file_format_testdata/label/bad.json | 7 + f3/file_format_testdata/label/good.json | 7 + f3/file_format_testdata/milestone/bad.json | 2 + f3/file_format_testdata/milestone/good.json | 10 + f3/file_format_testdata/organization/bad.json | 2 + .../organization/good.json | 5 + f3/file_format_testdata/project/bad.json | 28 + f3/file_format_testdata/project/good.json | 28 + f3/file_format_testdata/pullrequest/bad.json | 2 + f3/file_format_testdata/pullrequest/good.json | 32 + f3/file_format_testdata/reaction/bad.json | 2 + f3/file_format_testdata/reaction/good.json | 5 + f3/file_format_testdata/release/bad.json | 2 + f3/file_format_testdata/release/good.json | 11 + f3/file_format_testdata/releaseasset/bad.json | 2 + .../releaseasset/good.json | 11 + f3/file_format_testdata/repository/bad.json | 2 + f3/file_format_testdata/repository/good.json | 4 + f3/file_format_testdata/review/bad.json | 3 + f3/file_format_testdata/review/good.json | 12 + .../reviewcomment/bad.json | 2 + .../reviewcomment/good.json | 14 + f3/file_format_testdata/topic.json | 4 + f3/file_format_testdata/user/bad.json | 2 + f3/file_format_testdata/user/good.json | 8 + f3/forge.go | 20 + f3/formatbase.go | 99 +++ f3/formatbase_test.go | 57 ++ f3/issue.go | 58 ++ f3/label.go | 25 + f3/milestone.go | 37 ++ f3/new.go | 81 +++ f3/organization.go | 27 + f3/project.go | 50 ++ f3/pullrequest.go | 72 ++ f3/pullrequestbranch.go | 20 + f3/reaction.go | 31 + f3/release.go | 48 ++ f3/releaseasset.go | 38 ++ f3/repository.go | 45 ++ f3/resources.go | 39 ++ f3/review.go | 48 ++ f3/reviewcomment.go | 46 ++ f3/schemas/ci.json | 42 ++ f3/schemas/comment.json | 49 ++ f3/schemas/index.rst | 22 + f3/schemas/issue.json | 96 +++ f3/schemas/label.json | 37 ++ f3/schemas/milestone.json | 62 ++ f3/schemas/object.json | 35 + f3/schemas/organization.json | 29 + f3/schemas/project.json | 117 ++++ f3/schemas/pullrequest.json | 134 ++++ f3/schemas/pullrequestbranch.json | 30 + f3/schemas/reaction.json | 30 + f3/schemas/release.json | 60 ++ f3/schemas/releaseasset.json | 61 ++ f3/schemas/repository.json | 31 + f3/schemas/review.json | 63 ++ f3/schemas/reviewcomment.json | 77 +++ f3/schemas/topic.json | 25 + f3/schemas/user.json | 42 ++ f3/topic.go | 21 + f3/user.go | 32 + forges/filesystem/asset.go | 85 +++ forges/filesystem/json.go | 37 ++ forges/filesystem/main.go | 16 + forges/filesystem/node.go | 249 +++++++ forges/filesystem/options.go | 16 + forges/filesystem/options/name.go | 7 + forges/filesystem/options/options.go | 60 ++ forges/filesystem/pullrequest.go | 68 ++ forges/filesystem/repository.go | 97 +++ forges/filesystem/tests/helpers.go | 23 + forges/filesystem/tests/init.go | 14 + forges/filesystem/tests/new.go | 27 + forges/filesystem/tree.go | 45 ++ forges/forgejo/asset.go | 178 +++++ forges/forgejo/assets.go | 43 ++ forges/forgejo/comment.go | 140 ++++ forges/forgejo/comments.go | 44 ++ forges/forgejo/common.go | 120 ++++ forges/forgejo/container.go | 43 ++ forges/forgejo/forge.go | 92 +++ forges/forgejo/issue.go | 211 ++++++ forges/forgejo/issues.go | 47 ++ forges/forgejo/label.go | 133 ++++ forges/forgejo/labels.go | 42 ++ forges/forgejo/main.go | 19 + forges/forgejo/milestone.go | 158 +++++ forges/forgejo/milestones.go | 42 ++ forges/forgejo/options.go | 16 + forges/forgejo/options/name.go | 10 + forges/forgejo/options/options.go | 33 + forges/forgejo/organization.go | 125 ++++ forges/forgejo/organizations.go | 86 +++ forges/forgejo/project.go | 189 ++++++ forges/forgejo/projects.go | 66 ++ forges/forgejo/pullrequest.go | 289 ++++++++ forges/forgejo/pullrequests.go | 47 ++ forges/forgejo/reaction.go | 130 ++++ forges/forgejo/reactions.go | 48 ++ forges/forgejo/release.go | 154 +++++ forges/forgejo/releases.go | 44 ++ forges/forgejo/repositories.go | 51 ++ forges/forgejo/repository.go | 103 +++ forges/forgejo/review.go | 165 +++++ forges/forgejo/reviewcomment.go | 168 +++++ forges/forgejo/reviewcomments.go | 39 ++ forges/forgejo/reviews.go | 40 ++ forges/forgejo/root.go | 42 ++ forges/forgejo/sdk/admin_cron.go | 47 ++ forges/forgejo/sdk/admin_org.go | 39 ++ forges/forgejo/sdk/admin_repo.go | 25 + forges/forgejo/sdk/admin_user.go | 130 ++++ forges/forgejo/sdk/agent.go | 38 ++ forges/forgejo/sdk/agent_windows.go | 28 + forges/forgejo/sdk/attachment.go | 112 ++++ forges/forgejo/sdk/client.go | 499 ++++++++++++++ forges/forgejo/sdk/fork.go | 51 ++ forges/forgejo/sdk/git_blob.go | 28 + forges/forgejo/sdk/git_hook.go | 71 ++ forges/forgejo/sdk/gof3_topic.go | 38 ++ forges/forgejo/sdk/helper.go | 20 + forges/forgejo/sdk/hook.go | 196 ++++++ forges/forgejo/sdk/hook_validate.go | 59 ++ forges/forgejo/sdk/httpsign.go | 253 +++++++ forges/forgejo/sdk/issue.go | 309 +++++++++ forges/forgejo/sdk/issue_comment.go | 154 +++++ forges/forgejo/sdk/issue_label.go | 211 ++++++ forges/forgejo/sdk/issue_milestone.go | 237 +++++++ forges/forgejo/sdk/issue_reaction.go | 104 +++ forges/forgejo/sdk/issue_stopwatch.go | 57 ++ forges/forgejo/sdk/issue_subscription.go | 87 +++ forges/forgejo/sdk/issue_template.go | 97 +++ forges/forgejo/sdk/issue_tracked_time.go | 142 ++++ forges/forgejo/sdk/list_options.go | 40 ++ forges/forgejo/sdk/notifications.go | 257 +++++++ forges/forgejo/sdk/oauth2.go | 93 +++ forges/forgejo/sdk/org.go | 163 +++++ forges/forgejo/sdk/org_action.go | 29 + forges/forgejo/sdk/org_member.go | 142 ++++ forges/forgejo/sdk/org_team.go | 283 ++++++++ forges/forgejo/sdk/package.go | 93 +++ forges/forgejo/sdk/pull.go | 383 +++++++++++ forges/forgejo/sdk/pull_review.go | 368 ++++++++++ forges/forgejo/sdk/release.go | 202 ++++++ forges/forgejo/sdk/repo.go | 538 +++++++++++++++ forges/forgejo/sdk/repo_branch.go | 143 ++++ forges/forgejo/sdk/repo_branch_protection.go | 173 +++++ forges/forgejo/sdk/repo_collaborator.go | 163 +++++ forges/forgejo/sdk/repo_commit.go | 141 ++++ forges/forgejo/sdk/repo_file.go | 277 ++++++++ forges/forgejo/sdk/repo_key.go | 91 +++ forges/forgejo/sdk/repo_migrate.go | 132 ++++ forges/forgejo/sdk/repo_refs.go | 78 +++ forges/forgejo/sdk/repo_stars.go | 96 +++ forges/forgejo/sdk/repo_tag.go | 130 ++++ forges/forgejo/sdk/repo_team.go | 65 ++ forges/forgejo/sdk/repo_template.go | 65 ++ forges/forgejo/sdk/repo_topics.go | 68 ++ forges/forgejo/sdk/repo_transfer.go | 62 ++ forges/forgejo/sdk/repo_tree.go | 44 ++ forges/forgejo/sdk/repo_watch.go | 87 +++ forges/forgejo/sdk/secret.go | 14 + forges/forgejo/sdk/settings.go | 78 +++ forges/forgejo/sdk/status.go | 108 +++ forges/forgejo/sdk/user.go | 85 +++ forges/forgejo/sdk/user_app.go | 143 ++++ forges/forgejo/sdk/user_email.go | 64 ++ forges/forgejo/sdk/user_follow.go | 93 +++ forges/forgejo/sdk/user_gpgkey.go | 89 +++ forges/forgejo/sdk/user_key.go | 83 +++ forges/forgejo/sdk/user_search.go | 48 ++ forges/forgejo/sdk/user_settings.go | 62 ++ forges/forgejo/sdk/version.go | 126 ++++ forges/forgejo/tests/init.go | 19 + forges/forgejo/tests/new.go | 58 ++ forges/forgejo/tests/testhelpers.go | 60 ++ forges/forgejo/topics.go | 17 + forges/forgejo/tree.go | 287 ++++++++ forges/forgejo/user.go | 194 ++++++ forges/forgejo/users.go | 60 ++ forges/gitlab/common.go | 84 +++ forges/gitlab/container.go | 43 ++ forges/gitlab/forge.go | 46 ++ forges/gitlab/main.go | 16 + forges/gitlab/options.go | 16 + forges/gitlab/options/name.go | 9 + forges/gitlab/options/options.go | 33 + forges/gitlab/organization.go | 121 ++++ forges/gitlab/organizations.go | 56 ++ forges/gitlab/projects.go | 18 + forges/gitlab/root.go | 42 ++ forges/gitlab/tests/init.go | 16 + forges/gitlab/tests/new.go | 57 ++ forges/gitlab/tests/testhelpers.go | 60 ++ forges/gitlab/topics.go | 18 + forges/gitlab/tree.go | 213 ++++++ forges/gitlab/user.go | 157 +++++ forges/gitlab/users.go | 50 ++ forges/helpers/auth/auth.go | 81 +++ forges/helpers/auth/cli.go | 61 ++ forges/helpers/pullrequest/helper.go | 73 ++ forges/helpers/repository/git.go | 103 +++ forges/helpers/repository/git_test.go | 33 + forges/helpers/repository/helper.go | 98 +++ .../helpers/tests/repository/helper_test.go | 48 ++ forges/helpers/tests/repository/interface.go | 15 + .../tests/repository/repositoryhelpers.go | 182 +++++ forges/main.go | 12 + go.mod | 30 + go.sum | 60 ++ id/id.go | 36 + id/id_test.go | 21 + internal/hoverfly/hoverfly.go | 201 ++++++ internal/hoverfly/hoverfly_test.go | 139 ++++ internal/hoverfly/main_test.go | 23 + kind/kind.go | 12 + logger/context.go | 24 + logger/context_test.go | 20 + logger/interface.go | 70 ++ logger/logger.go | 230 +++++++ logger/logger_test.go | 29 + logger/logger_test_helper.go | 54 ++ main/main.go | 34 + options/auth/interface.go | 16 + options/cli/interface.go | 16 + options/cli/options.go | 20 + options/factory.go | 35 + options/http/implementation.go | 71 ++ options/http/implementation_test.go | 41 ++ options/http/interface.go | 20 + options/interface.go | 26 + options/logger/interface.go | 14 + options/logger/options.go | 16 + options/options.go | 12 + options/url/interface.go | 10 + path/interface.go | 51 ++ path/operations.go | 61 ++ path/path.go | 106 +++ path/path_test.go | 182 +++++ path/pathstring.go | 33 + renovate.json | 13 + tests/forgejo-app.ini | 35 + tests/gitea-app.ini | 31 + tests/run.sh | 133 ++++ tree/f3/f3.go | 124 ++++ tree/f3/fixed_children.go | 49 ++ tree/f3/forge_factory.go | 30 + tree/f3/helpers.go | 181 +++++ tree/f3/kind.go | 102 +++ tree/f3/label.go | 27 + tree/f3/milestone.go | 23 + tree/f3/objects/objects.go | 99 +++ tree/f3/objects/sha.go | 68 ++ tree/f3/objects/sha_test.go | 35 + tree/f3/organizations.go | 11 + tree/f3/path.go | 188 ++++++ tree/f3/path_test.go | 181 +++++ tree/f3/project.go | 29 + tree/f3/pullrequest.go | 47 ++ tree/f3/repository.go | 66 ++ tree/f3/topic.go | 23 + tree/f3/topics.go | 11 + tree/f3/user.go | 23 + tree/f3/users.go | 11 + tree/generic/compare.go | 72 ++ tree/generic/compare_test.go | 226 +++++++ tree/generic/driver_node.go | 107 +++ tree/generic/driver_tree.go | 78 +++ tree/generic/factory.go | 31 + tree/generic/interface_node.go | 101 +++ tree/generic/interface_tree.go | 58 ++ tree/generic/main_test.go | 139 ++++ tree/generic/mirror.go | 52 ++ tree/generic/node.go | 414 ++++++++++++ tree/generic/node_test.go | 296 +++++++++ tree/generic/path.go | 13 + tree/generic/references.go | 92 +++ tree/generic/references_test.go | 166 +++++ tree/generic/tree.go | 134 ++++ tree/generic/tree_test.go | 90 +++ tree/generic/unify.go | 320 +++++++++ tree/generic/unify_test.go | 57 ++ tree/generic/walk_options.go | 63 ++ tree/memory/memory.go | 350 ++++++++++ tree/tests/f3/creator.go | 397 +++++++++++ tree/tests/f3/f3_test.go | 260 ++++++++ tree/tests/f3/filesystem_test.go | 105 +++ tree/tests/f3/fixture.go | 224 +++++++ tree/tests/f3/forge/base.go | 30 + tree/tests/f3/forge/factory.go | 35 + tree/tests/f3/forge/interface.go | 26 + tree/tests/f3/forge_compliance.go | 254 +++++++ tree/tests/f3/forge_test.go | 19 + tree/tests/f3/generator.go | 474 +++++++++++++ tree/tests/f3/helpers_repository_test.go | 125 ++++ tree/tests/f3/init.go | 19 + tree/tests/f3/interface.go | 16 + tree/tests/f3/main_test.go | 9 + tree/tests/f3/pullrequest_test.go | 71 ++ tree/tests/generic/compare_test.go | 74 +++ tree/tests/generic/memory_test.go | 193 ++++++ tree/tests/generic/mirror_test.go | 98 +++ tree/tests/generic/node_walk_test.go | 341 ++++++++++ tree/tests/generic/references_test.go | 74 +++ tree/tests/generic/unify_test.go | 626 ++++++++++++++++++ util/convert.go | 29 + util/convert_test.go | 26 + util/exec.go | 152 +++++ util/exec_test.go | 66 ++ util/exec_unix.go | 24 + util/exec_windows.go | 22 + util/file.go | 20 + util/json.go | 51 ++ util/json_test.go | 39 ++ util/panic.go | 98 +++ util/panic_test.go | 24 + util/rand.go | 23 + util/rand_test.go | 16 + util/retry.go | 33 + util/retry_test.go | 24 + util/terminate.go | 42 ++ util/terminate_test.go | 22 + 356 files changed, 28857 insertions(+) create mode 100644 .deadcode-out create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/release.yml create mode 100644 .forgejo/workflows/test-gitea.yml create mode 100644 .forgejo/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 api/api.go create mode 100644 cmd/cli.go create mode 100644 cmd/enumtype.go create mode 100644 cmd/main.go create mode 100644 cmd/mirror.go create mode 100644 cmd/mirror_test.go create mode 100644 cmd/signal.go create mode 100644 cmd/testhelpers.go create mode 100644 f3/ci.go create mode 100644 f3/comment.go create mode 100644 f3/equal.go create mode 100644 f3/equal_test.go create mode 100644 f3/file_format.go create mode 100644 f3/file_format_test.go create mode 100644 f3/file_format_testdata/ci/bad.json create mode 100644 f3/file_format_testdata/ci/good.json create mode 100644 f3/file_format_testdata/comment/bad.json create mode 100644 f3/file_format_testdata/comment/good.json create mode 100644 f3/file_format_testdata/issue/bad.json create mode 100644 f3/file_format_testdata/issue/good.json create mode 100644 f3/file_format_testdata/label/bad.json create mode 100644 f3/file_format_testdata/label/good.json create mode 100644 f3/file_format_testdata/milestone/bad.json create mode 100644 f3/file_format_testdata/milestone/good.json create mode 100644 f3/file_format_testdata/organization/bad.json create mode 100644 f3/file_format_testdata/organization/good.json create mode 100644 f3/file_format_testdata/project/bad.json create mode 100644 f3/file_format_testdata/project/good.json create mode 100644 f3/file_format_testdata/pullrequest/bad.json create mode 100644 f3/file_format_testdata/pullrequest/good.json create mode 100644 f3/file_format_testdata/reaction/bad.json create mode 100644 f3/file_format_testdata/reaction/good.json create mode 100644 f3/file_format_testdata/release/bad.json create mode 100644 f3/file_format_testdata/release/good.json create mode 100644 f3/file_format_testdata/releaseasset/bad.json create mode 100644 f3/file_format_testdata/releaseasset/good.json create mode 100644 f3/file_format_testdata/repository/bad.json create mode 100644 f3/file_format_testdata/repository/good.json create mode 100644 f3/file_format_testdata/review/bad.json create mode 100644 f3/file_format_testdata/review/good.json create mode 100644 f3/file_format_testdata/reviewcomment/bad.json create mode 100644 f3/file_format_testdata/reviewcomment/good.json create mode 100644 f3/file_format_testdata/topic.json create mode 100644 f3/file_format_testdata/user/bad.json create mode 100644 f3/file_format_testdata/user/good.json create mode 100644 f3/forge.go create mode 100644 f3/formatbase.go create mode 100644 f3/formatbase_test.go create mode 100644 f3/issue.go create mode 100644 f3/label.go create mode 100644 f3/milestone.go create mode 100644 f3/new.go create mode 100644 f3/organization.go create mode 100644 f3/project.go create mode 100644 f3/pullrequest.go create mode 100644 f3/pullrequestbranch.go create mode 100644 f3/reaction.go create mode 100644 f3/release.go create mode 100644 f3/releaseasset.go create mode 100644 f3/repository.go create mode 100644 f3/resources.go create mode 100644 f3/review.go create mode 100644 f3/reviewcomment.go create mode 100644 f3/schemas/ci.json create mode 100644 f3/schemas/comment.json create mode 100644 f3/schemas/index.rst create mode 100644 f3/schemas/issue.json create mode 100644 f3/schemas/label.json create mode 100644 f3/schemas/milestone.json create mode 100644 f3/schemas/object.json create mode 100644 f3/schemas/organization.json create mode 100644 f3/schemas/project.json create mode 100644 f3/schemas/pullrequest.json create mode 100644 f3/schemas/pullrequestbranch.json create mode 100644 f3/schemas/reaction.json create mode 100644 f3/schemas/release.json create mode 100644 f3/schemas/releaseasset.json create mode 100644 f3/schemas/repository.json create mode 100644 f3/schemas/review.json create mode 100644 f3/schemas/reviewcomment.json create mode 100644 f3/schemas/topic.json create mode 100644 f3/schemas/user.json create mode 100644 f3/topic.go create mode 100644 f3/user.go create mode 100644 forges/filesystem/asset.go create mode 100644 forges/filesystem/json.go create mode 100644 forges/filesystem/main.go create mode 100644 forges/filesystem/node.go create mode 100644 forges/filesystem/options.go create mode 100644 forges/filesystem/options/name.go create mode 100644 forges/filesystem/options/options.go create mode 100644 forges/filesystem/pullrequest.go create mode 100644 forges/filesystem/repository.go create mode 100644 forges/filesystem/tests/helpers.go create mode 100644 forges/filesystem/tests/init.go create mode 100644 forges/filesystem/tests/new.go create mode 100644 forges/filesystem/tree.go create mode 100644 forges/forgejo/asset.go create mode 100644 forges/forgejo/assets.go create mode 100644 forges/forgejo/comment.go create mode 100644 forges/forgejo/comments.go create mode 100644 forges/forgejo/common.go create mode 100644 forges/forgejo/container.go create mode 100644 forges/forgejo/forge.go create mode 100644 forges/forgejo/issue.go create mode 100644 forges/forgejo/issues.go create mode 100644 forges/forgejo/label.go create mode 100644 forges/forgejo/labels.go create mode 100644 forges/forgejo/main.go create mode 100644 forges/forgejo/milestone.go create mode 100644 forges/forgejo/milestones.go create mode 100644 forges/forgejo/options.go create mode 100644 forges/forgejo/options/name.go create mode 100644 forges/forgejo/options/options.go create mode 100644 forges/forgejo/organization.go create mode 100644 forges/forgejo/organizations.go create mode 100644 forges/forgejo/project.go create mode 100644 forges/forgejo/projects.go create mode 100644 forges/forgejo/pullrequest.go create mode 100644 forges/forgejo/pullrequests.go create mode 100644 forges/forgejo/reaction.go create mode 100644 forges/forgejo/reactions.go create mode 100644 forges/forgejo/release.go create mode 100644 forges/forgejo/releases.go create mode 100644 forges/forgejo/repositories.go create mode 100644 forges/forgejo/repository.go create mode 100644 forges/forgejo/review.go create mode 100644 forges/forgejo/reviewcomment.go create mode 100644 forges/forgejo/reviewcomments.go create mode 100644 forges/forgejo/reviews.go create mode 100644 forges/forgejo/root.go create mode 100644 forges/forgejo/sdk/admin_cron.go create mode 100644 forges/forgejo/sdk/admin_org.go create mode 100644 forges/forgejo/sdk/admin_repo.go create mode 100644 forges/forgejo/sdk/admin_user.go create mode 100644 forges/forgejo/sdk/agent.go create mode 100644 forges/forgejo/sdk/agent_windows.go create mode 100644 forges/forgejo/sdk/attachment.go create mode 100644 forges/forgejo/sdk/client.go create mode 100644 forges/forgejo/sdk/fork.go create mode 100644 forges/forgejo/sdk/git_blob.go create mode 100644 forges/forgejo/sdk/git_hook.go create mode 100644 forges/forgejo/sdk/gof3_topic.go create mode 100644 forges/forgejo/sdk/helper.go create mode 100644 forges/forgejo/sdk/hook.go create mode 100644 forges/forgejo/sdk/hook_validate.go create mode 100644 forges/forgejo/sdk/httpsign.go create mode 100644 forges/forgejo/sdk/issue.go create mode 100644 forges/forgejo/sdk/issue_comment.go create mode 100644 forges/forgejo/sdk/issue_label.go create mode 100644 forges/forgejo/sdk/issue_milestone.go create mode 100644 forges/forgejo/sdk/issue_reaction.go create mode 100644 forges/forgejo/sdk/issue_stopwatch.go create mode 100644 forges/forgejo/sdk/issue_subscription.go create mode 100644 forges/forgejo/sdk/issue_template.go create mode 100644 forges/forgejo/sdk/issue_tracked_time.go create mode 100644 forges/forgejo/sdk/list_options.go create mode 100644 forges/forgejo/sdk/notifications.go create mode 100644 forges/forgejo/sdk/oauth2.go create mode 100644 forges/forgejo/sdk/org.go create mode 100644 forges/forgejo/sdk/org_action.go create mode 100644 forges/forgejo/sdk/org_member.go create mode 100644 forges/forgejo/sdk/org_team.go create mode 100644 forges/forgejo/sdk/package.go create mode 100644 forges/forgejo/sdk/pull.go create mode 100644 forges/forgejo/sdk/pull_review.go create mode 100644 forges/forgejo/sdk/release.go create mode 100644 forges/forgejo/sdk/repo.go create mode 100644 forges/forgejo/sdk/repo_branch.go create mode 100644 forges/forgejo/sdk/repo_branch_protection.go create mode 100644 forges/forgejo/sdk/repo_collaborator.go create mode 100644 forges/forgejo/sdk/repo_commit.go create mode 100644 forges/forgejo/sdk/repo_file.go create mode 100644 forges/forgejo/sdk/repo_key.go create mode 100644 forges/forgejo/sdk/repo_migrate.go create mode 100644 forges/forgejo/sdk/repo_refs.go create mode 100644 forges/forgejo/sdk/repo_stars.go create mode 100644 forges/forgejo/sdk/repo_tag.go create mode 100644 forges/forgejo/sdk/repo_team.go create mode 100644 forges/forgejo/sdk/repo_template.go create mode 100644 forges/forgejo/sdk/repo_topics.go create mode 100644 forges/forgejo/sdk/repo_transfer.go create mode 100644 forges/forgejo/sdk/repo_tree.go create mode 100644 forges/forgejo/sdk/repo_watch.go create mode 100644 forges/forgejo/sdk/secret.go create mode 100644 forges/forgejo/sdk/settings.go create mode 100644 forges/forgejo/sdk/status.go create mode 100644 forges/forgejo/sdk/user.go create mode 100644 forges/forgejo/sdk/user_app.go create mode 100644 forges/forgejo/sdk/user_email.go create mode 100644 forges/forgejo/sdk/user_follow.go create mode 100644 forges/forgejo/sdk/user_gpgkey.go create mode 100644 forges/forgejo/sdk/user_key.go create mode 100644 forges/forgejo/sdk/user_search.go create mode 100644 forges/forgejo/sdk/user_settings.go create mode 100644 forges/forgejo/sdk/version.go create mode 100644 forges/forgejo/tests/init.go create mode 100644 forges/forgejo/tests/new.go create mode 100644 forges/forgejo/tests/testhelpers.go create mode 100644 forges/forgejo/topics.go create mode 100644 forges/forgejo/tree.go create mode 100644 forges/forgejo/user.go create mode 100644 forges/forgejo/users.go create mode 100644 forges/gitlab/common.go create mode 100644 forges/gitlab/container.go create mode 100644 forges/gitlab/forge.go create mode 100644 forges/gitlab/main.go create mode 100644 forges/gitlab/options.go create mode 100644 forges/gitlab/options/name.go create mode 100644 forges/gitlab/options/options.go create mode 100644 forges/gitlab/organization.go create mode 100644 forges/gitlab/organizations.go create mode 100644 forges/gitlab/projects.go create mode 100644 forges/gitlab/root.go create mode 100644 forges/gitlab/tests/init.go create mode 100644 forges/gitlab/tests/new.go create mode 100644 forges/gitlab/tests/testhelpers.go create mode 100644 forges/gitlab/topics.go create mode 100644 forges/gitlab/tree.go create mode 100644 forges/gitlab/user.go create mode 100644 forges/gitlab/users.go create mode 100644 forges/helpers/auth/auth.go create mode 100644 forges/helpers/auth/cli.go create mode 100644 forges/helpers/pullrequest/helper.go create mode 100644 forges/helpers/repository/git.go create mode 100644 forges/helpers/repository/git_test.go create mode 100644 forges/helpers/repository/helper.go create mode 100644 forges/helpers/tests/repository/helper_test.go create mode 100644 forges/helpers/tests/repository/interface.go create mode 100644 forges/helpers/tests/repository/repositoryhelpers.go create mode 100644 forges/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 id/id.go create mode 100644 id/id_test.go create mode 100644 internal/hoverfly/hoverfly.go create mode 100644 internal/hoverfly/hoverfly_test.go create mode 100644 internal/hoverfly/main_test.go create mode 100644 kind/kind.go create mode 100644 logger/context.go create mode 100644 logger/context_test.go create mode 100644 logger/interface.go create mode 100644 logger/logger.go create mode 100644 logger/logger_test.go create mode 100644 logger/logger_test_helper.go create mode 100644 main/main.go create mode 100644 options/auth/interface.go create mode 100644 options/cli/interface.go create mode 100644 options/cli/options.go create mode 100644 options/factory.go create mode 100644 options/http/implementation.go create mode 100644 options/http/implementation_test.go create mode 100644 options/http/interface.go create mode 100644 options/interface.go create mode 100644 options/logger/interface.go create mode 100644 options/logger/options.go create mode 100644 options/options.go create mode 100644 options/url/interface.go create mode 100644 path/interface.go create mode 100644 path/operations.go create mode 100644 path/path.go create mode 100644 path/path_test.go create mode 100644 path/pathstring.go create mode 100644 renovate.json create mode 100644 tests/forgejo-app.ini create mode 100644 tests/gitea-app.ini create mode 100755 tests/run.sh create mode 100644 tree/f3/f3.go create mode 100644 tree/f3/fixed_children.go create mode 100644 tree/f3/forge_factory.go create mode 100644 tree/f3/helpers.go create mode 100644 tree/f3/kind.go create mode 100644 tree/f3/label.go create mode 100644 tree/f3/milestone.go create mode 100644 tree/f3/objects/objects.go create mode 100644 tree/f3/objects/sha.go create mode 100644 tree/f3/objects/sha_test.go create mode 100644 tree/f3/organizations.go create mode 100644 tree/f3/path.go create mode 100644 tree/f3/path_test.go create mode 100644 tree/f3/project.go create mode 100644 tree/f3/pullrequest.go create mode 100644 tree/f3/repository.go create mode 100644 tree/f3/topic.go create mode 100644 tree/f3/topics.go create mode 100644 tree/f3/user.go create mode 100644 tree/f3/users.go create mode 100644 tree/generic/compare.go create mode 100644 tree/generic/compare_test.go create mode 100644 tree/generic/driver_node.go create mode 100644 tree/generic/driver_tree.go create mode 100644 tree/generic/factory.go create mode 100644 tree/generic/interface_node.go create mode 100644 tree/generic/interface_tree.go create mode 100644 tree/generic/main_test.go create mode 100644 tree/generic/mirror.go create mode 100644 tree/generic/node.go create mode 100644 tree/generic/node_test.go create mode 100644 tree/generic/path.go create mode 100644 tree/generic/references.go create mode 100644 tree/generic/references_test.go create mode 100644 tree/generic/tree.go create mode 100644 tree/generic/tree_test.go create mode 100644 tree/generic/unify.go create mode 100644 tree/generic/unify_test.go create mode 100644 tree/generic/walk_options.go create mode 100644 tree/memory/memory.go create mode 100644 tree/tests/f3/creator.go create mode 100644 tree/tests/f3/f3_test.go create mode 100644 tree/tests/f3/filesystem_test.go create mode 100644 tree/tests/f3/fixture.go create mode 100644 tree/tests/f3/forge/base.go create mode 100644 tree/tests/f3/forge/factory.go create mode 100644 tree/tests/f3/forge/interface.go create mode 100644 tree/tests/f3/forge_compliance.go create mode 100644 tree/tests/f3/forge_test.go create mode 100644 tree/tests/f3/generator.go create mode 100644 tree/tests/f3/helpers_repository_test.go create mode 100644 tree/tests/f3/init.go create mode 100644 tree/tests/f3/interface.go create mode 100644 tree/tests/f3/main_test.go create mode 100644 tree/tests/f3/pullrequest_test.go create mode 100644 tree/tests/generic/compare_test.go create mode 100644 tree/tests/generic/memory_test.go create mode 100644 tree/tests/generic/mirror_test.go create mode 100644 tree/tests/generic/node_walk_test.go create mode 100644 tree/tests/generic/references_test.go create mode 100644 tree/tests/generic/unify_test.go create mode 100644 util/convert.go create mode 100644 util/convert_test.go create mode 100644 util/exec.go create mode 100644 util/exec_test.go create mode 100644 util/exec_unix.go create mode 100644 util/exec_windows.go create mode 100644 util/file.go create mode 100644 util/json.go create mode 100644 util/json_test.go create mode 100644 util/panic.go create mode 100644 util/panic_test.go create mode 100644 util/rand.go create mode 100644 util/rand_test.go create mode 100644 util/retry.go create mode 100644 util/retry_test.go create mode 100644 util/terminate.go create mode 100644 util/terminate_test.go diff --git a/.deadcode-out b/.deadcode-out new file mode 100644 index 0000000..bb1fe17 --- /dev/null +++ b/.deadcode-out @@ -0,0 +1,84 @@ +code.forgejo.org/f3/gof3/v3/api + TreeMirror + +code.forgejo.org/f3/gof3/v3/f3 + RepositoryDirname + +code.forgejo.org/f3/gof3/v3/forges/forgejo + common.isContainer + +code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk + hasAgent + GetAgent + Version + NewClientWithHTTP + UseSSHCert + UseSSHPubkey + SetOTP + SetContext + SetSudo + SetUserAgent + SetDebugMode + OptionalBool + OptionalString + OptionalInt64 + VerifyWebhookSignature + VerifyWebhookSignatureMiddleware + NewHTTPSignWithPubkey + NewHTTPSignWithCert + newHTTPSign + findCertSigner + findPubkeySigner + SetGiteaVersion + +code.forgejo.org/f3/gof3/v3/forges/gitlab + common.getTree + common.getF3Tree + common.getChildDriver + common.isContainer + common.getURL + common.getPushURL + common.getNewMigrationHTTPClient + common.getIsAdmin + common.getVersion + treeDriver.maybeSudo + +code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository + TestHelper.GetNode + TestHelper.RevList + TestHelper.AssertRepositoryNotFileExists + TestHelper.BranchRepositoryFeature + +code.forgejo.org/f3/gof3/v3/internal/hoverfly + MainTest + GetSingleton + testSimulate.Run + +code.forgejo.org/f3/gof3/v3/options/cli + OptionsCLI.FromFlags + OptionsCLI.GetFlags + +code.forgejo.org/f3/gof3/v3/tree/f3 + NewLabelReference + NewPullRequestLabelReference + NewMilestoneReference + pullRequestNode.GetPullRequestHead + pullRequestNode.GetPullRequestRef + pullRequestNode.GetPullRequestPushRefs + newPullRequestNode + NewRepositoryPath + NewTopicPath + NewTopicPathString + NewTopicReference + +code.forgejo.org/f3/gof3/v3/tree/f3/objects + FuncReadURLAndSetSHA + +code.forgejo.org/f3/gof3/v3/tree/generic + MirrorOptions.SetNoRemap + TreePartialMirror + +code.forgejo.org/f3/gof3/v3/tree/tests/f3 + Creator.GetDirectory + Creator.Generate + diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..aaaa7a4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +tab_width = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..fdfd940 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,35 @@ +on: + push: + tags: 'v*' + +jobs: + publish: + runs-on: docker-bookworm + container: + image: 'data.forgejo.org/oci/ci:1' + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - run: | + make f3-cli + mkdir release + mv f3-cli release/ + + - name: publish release + uses: https://data.forgejo.org/actions/forgejo-release@v2.6.0 + with: + url: "https://code.forgejo.org" + repo: "f3/gof3" + direction: upload + tag: "${{ github.ref_name }}" + sha: "${{ github.sha }}" + release-dir: release + token: ${{ secrets.RELEASE_NOTES_ASSISTANT_TOKEN }} + override: true + verbose: ${{ vars.VERBOSE || "false" }} + release-notes-assistant: true + hide-archive-link: true diff --git a/.forgejo/workflows/test-gitea.yml b/.forgejo/workflows/test-gitea.yml new file mode 100644 index 0000000..7ec3fe2 --- /dev/null +++ b/.forgejo/workflows/test-gitea.yml @@ -0,0 +1,38 @@ +# +# secrets.F3_READ_PRIVATE_MIRRORS_TOKEN +# https://code.forgejo.org/forgejo-mirror scope read:repository +# +on: + pull_request_target: + push: + branches: + - 'main' + - 'wip-gitea' + +jobs: + compliance-gitea: + runs-on: lxc-bookworm + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + with: + submodules: true + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: install jq make + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get -q install -qq -y jq make + + - run: make deps-backend lint + + - name: install zstd for actions/cache@v4 + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get -q install -y -qq zstd + + - name: run tests + run: | + ./tests/run.sh prepare_container + su forgejo -c "./tests/run.sh test_gitea ${{ secrets.F3_READ_PRIVATE_MIRRORS_TOKEN }}" diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..3b5ba51 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,54 @@ +on: + pull_request: + push: + branches: + - 'main' + +jobs: + compliance: + runs-on: lxc-bookworm + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + with: + submodules: true + - uses: https://data.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: install jq make + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get -q install -qq -y jq make + + - run: make deps-backend lint + + - name: install hoverfly + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get -q install -qq -y unzip wget + version=$(./tests/run.sh hoverfly_version) + wget https://github.com/SpectoLabs/hoverfly/releases/download/v$version/hoverfly_bundle_linux_amd64.zip + unzip hoverfly_bundle_linux_amd64.zip + mv hoverfly hoverctl /usr/local/bin + + - name: install zstd for actions/cache@v4 + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get -q install -y -qq zstd + + - name: get GitLab version + id: gitlab + run: | + echo "version=$(./tests/run.sh gitlab_version)" >> "$GITHUB_OUTPUT" + + - name: cache GitLab OCI image + uses: https://data.forgejo.org/actions/cache@v4 + with: + path: | + /srv/forgejo-binaries/gitlab + key: gitlab-${{ steps.gitlab.outputs.version }} + + - name: run tests + run: | + ./tests/run.sh prepare_container + su forgejo -c "./tests/run.sh run" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c62b963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*~ +f3-cli +coverage.out +coverage.html +tests/*.out +format/schemas/.gitignore +.cur-deadcode-out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a24df1c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "tests/setup-forgejo"] + path = tests/setup-forgejo + url = https://code.forgejo.org/actions/setup-forgejo +[submodule "tests/end-to-end"] + path = tests/end-to-end + url = https://code.forgejo.org/forgejo/end-to-end diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f65c34f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,81 @@ +# +# Copied from https://github.com/go-gitea/gitea/blob/cc649f0cb338a085373fd85a8b71e315701cbdc1/.golangci.yml +# +linters: + enable: + - gosimple + - typecheck + - govet + - errcheck + - staticcheck + #- unused # disabled because it gets it wrong with golangci-lint@v1.51.2 run & go 1.20.3 + - gofmt + - misspell + - gocritic + - bidichk + - ineffassign + - revive + - gofumpt + - depguard + - nakedret + - unconvert + - wastedassign + - nolintlint + - stylecheck + enable-all: false + disable-all: true + fast: false + +run: + go: 1.22 + timeout: 10m + +issues: + exclude-dirs: + - forges/forgejo/sdk + +linters-settings: + stylecheck: + checks: ["all", "-ST1005", "-ST1003"] + nakedret: + max-func-lines: 0 + gocritic: + disabled-checks: + - ifElseChain + - singleCaseSwitch # Every time this occurred in the code, there was no other way. + revive: + ignore-generated-header: false + severity: warning + confidence: 0.8 + errorCode: 1 + warningCode: 1 + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: if-return + - name: increment-decrement + - name: var-naming + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: duplicated-imports + - name: modifies-value-receiver + depguard: + #list-type: denylist + # Check the list against standard lib. + #include-go-root: true + rules: + main: + deny: + - pkg: io/ioutil + desc: use os or io instead diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ccd0dd7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright Earl Warren +Copyright Loïc Dachary + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a881de1 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +GO ?= $(shell go env GOROOT)/bin/go + +DIFF ?= diff --unified + +EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v2/cmd/editorconfig-checker@2.8.0 # renovate: datasource=go +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0 # renovate: datasource=go +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 # renovate: datasource=go +UNCOVER_PACKAGE ?= github.com/gregoryv/uncover/cmd/uncover@latest +MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.6.0 # renovate: datasource=go +DEADCODE_PACKAGE ?= golang.org/x/tools/cmd/deadcode@v0.24.0 # renovate: datasource=go +GOMOCK_PACKAGE ?= go.uber.org/mock/mockgen@v0.5.0 # renovate: datasource=go + +SCHEMAS_VERSION ?= v3 + +DEADCODE_ARGS ?= -generated=false -test -f='{{println .Path}}{{range .Funcs}}{{printf "\t%s\n" .Name}}{{end}}{{println}}' code.forgejo.org/f3/gof3/v3/... + +VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') + +LDFLAGS := $(LDFLAGS) -X "code.forgejo.org/f3/gof3/v3/cmd.Version=$(VERSION)" + +EXECUTABLE := f3-cli + +GO_DIRS = cmd f3 forges logger main options tree util +GO_SOURCES = $(shell find $(GO_DIRS) -type f -name "*.go") + +$(EXECUTABLE): $(GO_SOURCES) + $(GO) build -tags 'netgo osusergo' -ldflags '-extldflags -static -s -w $(LDFLAGS)' -o $@ code.forgejo.org/f3/gof3/v3/main + +.PHONY: deps-backend +deps-backend: + $(GO) mod download + $(GO) install $(GOFUMPT_PACKAGE) + $(GO) install $(GOLANGCI_LINT_PACKAGE) + $(GO) install $(UNCOVER_PACKAGE) + $(GO) install $(MISSPELL_PACKAGE) + $(GO) install $(DEADCODE_PACKAGE) + $(GO) install $(GOMOCK_PACKAGE) + +.PHONY: lint +lint: + @if ! $(MAKE) lint-run ; then echo "Please run 'make lint-fix' and commit the result" ; exit 1 ; fi + +.PHONY: lint-run +lint-run: lint-schemas + $(GO) run $(GOLANGCI_LINT_PACKAGE) run $(GOLANGCI_LINT_ARGS) + $(GO) run $(DEADCODE_PACKAGE) $(DEADCODE_ARGS) > .cur-deadcode-out + $(DIFF) .deadcode-out .cur-deadcode-out + +.PHONY: lint-schemas +lint-schemas: + status=0 ; for schema in f3/schemas/*.json ; do if ! jq '.|empty' $$schema ; then status=1 ; echo $$schema error ; fi ; done ; exit $$status + d=`mktemp -d` ; trap "rm -fr $$d" EXIT ; git clone --branch $(SCHEMAS_VERSION) --quiet https://code.forgejo.org/f3/f3-schemas $$d/schemas ; diff --exclude '.*' -ru $$d/schemas f3/schemas + +.PHONY: lint-schemas-fix +lint-schemas-fix: + d=`mktemp -d` ; trap "rm -fr $$d" EXIT ; git clone --branch $(SCHEMAS_VERSION) --quiet https://code.forgejo.org/f3/f3-schemas $$d/schemas ; cp $$d/schemas/* f3/schemas + +.PHONY: lint-fix +lint-fix: lint-schemas-fix + $(GO) run $(GOLANGCI_LINT_PACKAGE) run $(GOLANGCI_LINT_ARGS) --fix + $(GO) run $(DEADCODE_PACKAGE) $(DEADCODE_ARGS) > .deadcode-out + +.PHONY: fmt +fmt: + GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) gofumpt -extra -w . + +SPELLCHECK_FILES = $(GO_DIRS) + +.PHONY: lint-spell +lint-spell: + @$(GO) run $(MISSPELL_PACKAGE) -error $(SPELLCHECK_FILES) + +.PHONY: lint-spell-fix +lint-spell-fix: + @$(GO) run $(MISSPELL_PACKAGE) -w $(SPELLCHECK_FILES) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5293484 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +## gof3 + +As a CLI or as a library, GoF3 provides a single operation: mirroring. The origin and destination are designated by the URL of a forge and a path to the resource. For instance, `mirror --from-type forgejo --from https://code.forgejo.org/forgejo/lxc-helpers --to-type F3 --to /some/directory` will mirror a project in a local directory using the F3 format. + +## Building + +* Install go >= v1.21 +* make f3-cli +* ./f3-cli mirror -h + +## Example + +### To F3 + +Login to https://code.forgejo.org and obtain an application token with +read permissions at https://code.forgejo.org/user/settings/applications. + +```sh +f3-cli mirror \ + --from-type forgejo --from-forgejo-url https://code.forgejo.org \ + --from-forgejo-token $codetoken \ + --from-path /forge/organizations/actions/projects/cascading-pr \ + --to-type filesystem --to-filesystem-directory /tmp/cascading-pr +``` + +### From F3 + +Run a local Forgejo instance with `serials=1 tests/setup-forgejo.sh` and obtain +an application token with: + +```sh +docker exec --user 1000 forgejo1 forgejo admin user generate-access-token -u root --raw --scopes 'all,sudo' +``` + +Mirror issues + +```sh +f3-cli mirror \ + --from-type filesystem --from-filesystem-directory /tmp/cascading-pr \ + --from-path /forge/organizations/actions/projects/cascading-pr/issues \ + --to-type forgejo --to-forgejo-url http://0.0.0.0:3001 \ + --to-forgejo-token $localtoken +``` + +Visit them at http://0.0.0.0:3001/actions/cascading-pr/issues + +## Testing + +### Requirements + +The tests require a live GitLab instance as well as a live Forgejo instance and will use up to 16GB of RAM. + +* Install docker +* `./test/run.sh` + +## License + +This project is [MIT licensed](LICENSE). + +## Architecture + +[F3](https://f3.forgefriends.org/) is a hierarchy designed to be stored in a file system. It is represented in memory with the [tree/generic](tree/generic) abstract data structure that can be saved and loaded from disk by the [forges/filesystem](forges/filesystem) driver. Each forge (e.g. [forges/forgejo](forges/forgejo)) is supported by a driver that is responsible for the interactions of each resource (e.g `issues`, `asset`, etc.). + +### Tree + +[tree/f3](tree/f3) implements a [F3](https://f3.forgefriends.org/) hierarchy based on the [tree/generic](tree/generic) data structure. The [tree](tree/generic/tree.go) has a [logger](logger) for messages, [options](options) defining which forge it relates to and how and a pointer to the root [node](tree/generic/node.go) of the hierarchy (i.e. the `forge` F3 resource). + +The node ([tree/generic/node.go](tree/generic/node.go)) has: + +* a unique id (e.g. the numerical id of an `issue`) +* a parent +* chidren (e.g. `issues` children are `issues`, `issue` children are `comments` and `reactions`) +* a kind that maps to a F3 resource (e.g. `issue`, etc.) +* a driver for its concrete implementation for a given forge + +It relies on a forge driver for the concrete implemenation of a F3 resource (issue, reaction, repository, etc.). For instance the `issues` driver for Forgejo is responsible for listing the existing issues and the `issue` driver is responsible for creating, updating or deleting a Forgejo issue. + +### F3 archive + +The [F3 JSON schemas](https://code.forgejo.org/f3/f3-schemas/-/tree/main) are copied in [f3/schemas](f3/schemas). Their internal representation and validation is found in a source file named after the resource (e.g. an `issue` represented by [f3/schemas/issue.json](f3/schemas/issue.json) is implemented by [f3/issue.go](f3/issue.go)). + +When a F3 resource includes data external to the JSON file (i.e. a Git repository or an asset file), the internal representation has a function to copy the data to the destination given in argument. For instance: + +* [f3/repository.go](f3/repository.go) `FetchFunc(destination)` will `git fetch --mirror` the repository to the `destination` directory. +* [f3/releaseasset.go](f3/releaseasset.go) `DownloadFunc()` returns a `io.ReadCloser` that will be used by the caller to copy the asset to its destination. + +### Options + +The Forge options at [options/interface.go](options/interface.go) define the parameters given when a forge is created: + +Each forge driver is responsible for registering the options (e.g. [Forgejo options](forges/forgejo/options/options.go)) and for registering a factory that will create these options (e.g. [Forgejo options registration](forgejo/main.go)). In addition to the options that are shared by all forges such as the logger, it may define additional options. + +### Driver interface + +For each [F3](https://f3.forgefriends.org/) resource, the driver is responsible for: + +* copying the [f3](f3) argument to `FromFormat` to the forge +* `ToFormat` reads from the forge and convert the data into an [f3/resources.go](f3/resources.go) + +A driver must have a unique name (e.g. `forgejo`) and [register](forges/forgejo/main.go): + +* an [options factory](options/factory.go) +* a [forge factory](tree/f3/forge_factory.go) + +#### Tree driver + +The [tree driver](tree/generic/driver_tree.go) functions (e.g. [forges/forgejo/tree.go](forges/forgejo/tree.go)) specialize [NullTreeDriver](tree/generic/driver_tree.go). + +* **Factory(ctx context.Context, kind generic.Kind) generic.NodeDriverInterface** creates a new node driver for a given [`Kind`](tree/f3/kind.go). +* **GetPageSize() int** returns the default page size. + +#### Node driver + +The [node driver](tree/generic/driver_node.go) functions for [each `Kind`](tree/f3/kind.go) (e.g. `issues`, `issue`, etc.) specialize [NullNodeDriver](tree/generic/driver_node.go). The examples are given for the Forgejo [`issue`](forges/forgejo/issue.go) and [`issues`](forges/forgejo/issues.go) drivers, matching the REST API endpoint to the driver function. + +* **ListPage(context.Context, page int) ChildrenSlice** returns children of the node paginated [GET /repos/{owner}/{repo}/issues](https://code.forgejo.org/api/swagger/#/issue/issueListIssues) +* **Get(context.Context)** get the content of the resource (e.g. [GET /repos/{owner}/{repo}/issues/{index}](https://code.forgejo.org/api/swagger/#/issue/issueGetIssue)) +* **Put(context.Context) NodeID** create a new resource and return the identifier (e.g. [POST /repos/{owner}/{repo}/issues](https://code.forgejo.org/api/swagger/#/issue/issueCreateIssue)) +* **Patch(context.Context)** modify an existing resource (e.g. [PATCH /repos/{owner}/{repo}/issues/{index}](https://code.forgejo.org/api/swagger/#/issue/issueEditIssue)) +* **Delete(context.Context)** delete an existing resource (e.g. [DELETE /repos/{owner}/{repo}/issues/{index}](https://code.forgejo.org/api/swagger/#/issue/issueDelete)) +* **NewFormat() f3.Interface** create a new `issue` F3 object +* **FromFormat(f3.Interface)** set the internal representation from the given F3 resource +* **ToFormat() f3.Interface** convert the internal representation into the corresponding F3 resource. For instance the internal representation of an `issue` for the Forgejo driver is the `Issue` struct of the Forgejo SDK. + +#### Options + +The [options](options) created by the factory are expected to provide the [options interfaces](options/interface.go): + +* Required + * LoggerInterface + * URLInterface +* Optional + * CLIInterface if additional CLI arguments specific to the forge are supported + +For instance [forges/forgejo/options/options.go](forges/forgejo/options/options.go) is created by [forges/forgejo/options.go](forges/forgejo/options.go). + +### Driver implementation + +A driver for a forge must be self contained in a directory (e.g. [forges/forgejo](forges/forgejo)). Functions shared by multiple forges are grouped in the [forges/helpers](forges/helpers) directory and split into one directory per `Kind` (e.g. [forges/helpers/pullrequest](forges/helpers/pullrequest)). + +* [options.go](forges/forgejo/options.go) defines the name of the forge in the Name variable (e.g. Name = "forgejo") +* [options/options.go](forges/forgejo/options/options.go) defines the options specific to the forge and the corresponding CLI flags +* [main.go](forges/forgejo/main.go) calls f3_tree.RegisterForgeFactory to create the forge given its name +* [tree.go](forges/forgejo/tree.go) has the `Factory()` function that maps a node kind (`issue`, `reaction`, etc.) into an object that is capable of interacting with it (CRUD). +* one file per `Kind` (e.g. [forges/forgejo/issues.go](forges/forgejo/issues.go)). + +### Idempotency + +Mirroring is idempotent: it will produce the same result if repeated multiple times. The drivers functions are not required to be idempotent. + +* The `Put` function will only be called if the resource does not already exist. +* The `Patch` and `Delete` functions will only be called if the resource exists. + +### Identifiers mapping + +When a forge (e.g. Forgejo) is mirrored on the filesystem, the identifiers are preserved verbatim (e.g. the `issue` identifier). When the filesystem is mirrored to a forge, the identifiers cannot always be preserved. For instance if an `issue` with the identifier 1234 is downloaded from Forgejo and created on another Forgejo instance, it will be allocated an identifier by the Forgejo instance. It cannot request to be given a specific identifier. + +### References + +A F3 resource may reference another F3 resource by a path. For instance the user that authored an issue is represented by `/forge/users/1234` where `1234` is the unique identifier of the user. The reference is relative to the forge. The mirroring of a forge to another is responsible for converting the references using the identifier mapping stored in the origin forge. For instance if `/forge/users/1234` stored in the filesystem is created in Forgejo as `/forge/users/58`, the `issue` stored in the filesystem with its authored as `/forge/users/1234` will be created in Forgejo to be authored by `/forge/users/58` instead. + +### Logger + +The [tree/generic](tree/generic) has a pointer to a logger implementing [logger.Interface](logger/interface.go) which is made available to the nodes and the drivers. + +### Context + +All functions except for setters and getters have a `context.Context` argument which is checked (using [util/terminate.go](util/terminate.go)) to not be `Done` before performing a long lasting operation (e.g. a REST API call or a call to the Git CLI). It is not used otherwise. + +### Error model + +When an error that cannot be recovered from happens, `panic` is called, otherwise an `Error` is logged. + +### CLI + +The CLI is in [cmd](cmd) and relies on [options](options) to figure out which options are to be implemented for each supported forge. + +## Hacking + +### Local tests + +The forge instance is deleted before each run and left running for forensic analysis when the run completes. + +```sh +./tests/run.sh test_forgejo # http://0.0.0.0:3001 user root, password admin1234 +./tests/run.sh test_gitlab # http://0.0.0.0:8181 user root, password Wrobyak4 +./tests/run.sh test_gitea # http://0.0.0.0:3001 user root, password admin1234 +``` + +Restart a new forge with: + +```sh +./tests/run.sh run_forgejo # http://0.0.0.0:3001 user root, password admin1234 +./tests/run.sh run_gitlab # http://0.0.0.0:8181 user root, password Wrobyak4 +./tests/run.sh run_gitea # http://0.0.0.0:3001 user root, password admin1234 +``` + +The compliance test resources are deleted, except if the environment variable `GOF3_TEST_COMPLIANCE_CLEANUP=false`. + +```sh +GOF3_TEST_COMPLIANCE_CLEANUP=false GOF3_FORGEJO_HOST_PORT=0.0.0.0:3001 go test -run=TestF3Forge/forgejo -v code.forgejo.org/f3/gof3/... +``` + +### Code coverage + +```sh +export SCRATCHDIR=/tmp/gof3 +./tests/run.sh # collect coverage for every test +./tests/run.sh run_forgejo # update coverage for forgejo +./tests/run.sh test_merge_coverage # merge coverage from every test +go tool cover -func /tmp/gof3/merged.out # show coverage per function +uncover /tmp/gof3/merged.out GeneratorSetReviewComment # show which lines of the GeneratorSetReviewComment function are not covered +``` + +### F3 schemas + +The JSON schemas come from [the f3-schemas repository](https://code.forgejo.org/f3/f3-schemas) and +should be updated as follows: + +``` +cd f3 ; rm -fr schemas ; git --work-tree schemas clone https://code.forgejo.org/f3/f3-schemas ; rm -fr f3-schemas schemas/.gitignore schemas/.forgejo +``` + +## Funding + +See the page dedicated to funding in the [F3 documentation](https://f3.forgefriends.org/funding.html) diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..383bf19 --- /dev/null +++ b/api/api.go @@ -0,0 +1,24 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +func TreeMirror(ctx context.Context, originTree, destinationTree generic.TreeInterface, p path.Path, options *generic.MirrorOptions) error { + err := util.PanicToError(func() { + generic.TreeMirror(ctx, originTree, destinationTree, p, options) + }) + if err != nil { + originTree.Error(err.Error()) + originTree.Debug(err.Stack()) + } + return err +} diff --git a/cmd/cli.go b/cmd/cli.go new file mode 100644 index 0000000..568cee9 --- /dev/null +++ b/cmd/cli.go @@ -0,0 +1,49 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "sort" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/options" + + "github.com/urfave/cli/v3" +) + +func ForgeTypeOption(direction string) string { + return direction + "-type" +} + +func GetFlagsCommon(prefix, category string) []cli.Flag { + flags := make([]cli.Flag, 0, 10) + + forgeTypes := make([]string, 0, 10) + for name := range options.GetFactories() { + forgeTypes = append(forgeTypes, name) + } + sort.Strings(forgeTypes) + values := &enumType{ + Enum: forgeTypes, + Default: filesystem_options.Name, + } + + flags = append(flags, &cli.GenericFlag{ + Name: ForgeTypeOption(prefix), + Usage: fmt.Sprintf("`TYPE` of the %s forge", prefix), + Value: values, + DefaultText: values.GetDefaultText(), + Category: prefix, + }) + + flags = append(flags, &cli.StringFlag{ + Name: BuildForgePrefix(prefix, "path"), + Usage: "resource to mirror (e.g. /forge/users/myuser/projects/myproject)", + Category: prefix, + }) + + return flags +} diff --git a/cmd/enumtype.go b/cmd/enumtype.go new file mode 100644 index 0000000..a52b43e --- /dev/null +++ b/cmd/enumtype.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "strings" +) + +type enumType struct { + Enum []string + Default string + selected string +} + +func (o enumType) Join() string { + return strings.Join(o.Enum, ",") +} + +func (o *enumType) Set(value string) error { + for _, enum := range o.Enum { + if strings.EqualFold(enum, value) { + o.selected = value + return nil + } + } + + return fmt.Errorf("%v", o.Allowed()) +} + +func (o *enumType) Allowed() string { + return fmt.Sprintf("allowed values are %s", o.Join()) +} + +func (o *enumType) GetDefaultText() string { + return fmt.Sprintf("%s, %s", o.Default, o.Allowed()) +} + +func (o enumType) Get() any { + return o.String() +} + +func (o enumType) String() string { + if o.selected == "" { + return o.Default + } + return o.selected +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..2859a81 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,49 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/logger" + + "github.com/urfave/cli/v3" +) + +var Version = "development" + +func SetVerbosity(ctx context.Context, verbosity int) { + l := logger.ContextGetLogger(ctx) + switch verbosity { + case 0: + l.SetLevel(logger.Info) + default: + l.SetLevel(logger.Trace) + } +} + +func NewApp() *cli.Command { + return &cli.Command{ + Name: "F3", + Usage: "Friendly Forge Format", + Description: `Friendly Forge Format`, + Version: Version, + Before: func(ctx context.Context, c *cli.Command) (context.Context, error) { + SetVerbosity(ctx, c.Count("verbose")) + return nil, nil + }, + Commands: []*cli.Command{ + CreateCmdMirror(), + }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Usage: "increase the verbosity level", + }, + cli.VersionFlag, + }, + EnableShellCompletion: true, + } +} diff --git a/cmd/mirror.go b/cmd/mirror.go new file mode 100644 index 0000000..83488f6 --- /dev/null +++ b/cmd/mirror.go @@ -0,0 +1,129 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "github.com/urfave/cli/v3" +) + +var ( + directionFrom = "from" + flagFrom = "--" + directionFrom + directionTo = "to" + flagTo = "--" + directionTo +) + +func BuildForgePrefix(prefix, forge string) string { + return prefix + "-" + forge +} + +func FlagsToTree(ctx context.Context, c *cli.Command, direction string) generic.TreeInterface { + forgeType := c.String(ForgeTypeOption(direction)) + opts := options.GetFactory(forgeType)() + opts.(options.LoggerInterface).SetLogger(logger.ContextGetLogger(ctx)) + if o, ok := opts.(options.CLIInterface); ok { + o.FromFlags(ctx, c, BuildForgePrefix(direction, forgeType)) + } else { + panic("not implemented") + } + return generic.GetFactory("f3")(ctx, opts) +} + +func CreateCmdMirror() *cli.Command { + flags := make([]cli.Flag, 0, 10) + for _, direction := range []string{"from", "to"} { + flags = append(flags, GetFlagsCommon(direction, "common")...) + for name, factory := range options.GetFactories() { + if opts, ok := factory().(options.CLIInterface); ok { + flags = append(flags, opts.GetFlags(BuildForgePrefix(direction, name), name)...) + } + } + } + + flags = func(flags []cli.Flag) []cli.Flag { + dedup := make([]cli.Flag, 0, 10) + names := make(map[string]any, 10) + flagLoop: + for _, flag := range flags { + for _, name := range flag.Names() { + _, found := names[name] + if found { + continue flagLoop + } + } + dedup = append(dedup, flag) + for _, name := range flag.Names() { + names[name] = nil + } + } + return dedup + }(flags) + + return &cli.Command{ + Name: "mirror", + Usage: "Mirror", + Description: "Mirror", + Action: func(ctx context.Context, c *cli.Command) error { + return util.PanicToError(func() { runMirror(ctx, c) }) + }, + Flags: flags, + } +} + +func runMirror(ctx context.Context, c *cli.Command) { + from := FlagsToTree(ctx, c, directionFrom) + to := FlagsToTree(ctx, c, directionTo) + fromPathString := c.String(BuildForgePrefix(directionFrom, "path")) + fromPath := generic.NewPathFromString(fromPathString) + toPathString := c.String(BuildForgePrefix(directionTo, "path")) + toPath := generic.NewPathFromString(toPathString) + + log := from.GetLogger() + + fromURL := "(unset)" + if url, ok := from.GetOptions().(options.URLInterface); ok { + fromURL = url.GetURL() + } + toURL := "(unset)" + if url, ok := to.GetOptions().(options.URLInterface); ok { + toURL = url.GetURL() + } + log.Info("mirror %s (%s at %s) to %s (%s at %s)", + fromPath, c.String(ForgeTypeOption(directionFrom)), fromURL, + toPath, c.String(ForgeTypeOption(directionTo)), toURL, + ) + + log.Debug("read %s from %T", fromPath, from) + var fromNode generic.NodeInterface + fromNode = generic.NilNode + walkAndGet := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + node.WalkAndGet(ctx, parent, generic.NewWalkOptions(nil)) + fromNode = node + } + from.ApplyAndGet(ctx, fromPath, generic.NewApplyOptions(walkAndGet)) + if fromNode == generic.NilNode { + panic(fmt.Errorf("from %s not found", fromPath)) + } + from.Debug("copy %s from %T to %T", fromPath, from, to) + + if toPathString == "" { + generic.TreeMirror(ctx, from, to, fromPath, generic.NewMirrorOptions()) + } else { + toNode := to.FindAndGet(ctx, toPath) + if toNode == generic.NilNode { + panic(fmt.Errorf("to %s not found", toPath)) + } + generic.NodeMirror(ctx, fromNode, toNode, generic.NewMirrorOptions()) + } +} diff --git a/cmd/mirror_test.go b/cmd/mirror_test.go new file mode 100644 index 0000000..033f954 --- /dev/null +++ b/cmd/mirror_test.go @@ -0,0 +1,139 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/tree/generic" + f3_tests "code.forgejo.org/f3/gof3/v3/tree/tests/f3" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CmdMirrorArguments(t *testing.T) { + ctx := context.Background() + + output, err := runApp(ctx, "f3", "mirror", "--from-type", "garbage") + assert.ErrorContains(t, err, `allowed values are `) + assert.Contains(t, output, "Incorrect Usage:") +} + +func Test_CmdMirrorIntegrationDefaultToPath(t *testing.T) { + ctx := context.Background() + + fixtureOptions := tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t) + fixtureTree := generic.GetFactory("f3")(ctx, fixtureOptions) + log := fixtureTree.GetLogger() + log.Trace("======= build fixture") + f3_tests.TreeBuild(t, "CmdMirrorDefault", fixtureOptions, fixtureTree) + + log.Trace("======= create mirror") + mirrorOptions := tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t) + mirrorTree := generic.GetFactory("f3")(ctx, mirrorOptions) + + p := "/forge/users/10111" + log.Trace("======= mirror %s", p) + output, err := runApp(ctx, "f3", "--verbose", "mirror", + "--from-filesystem-directory", fixtureOptions.(options.URLInterface).GetURL(), + "--from-path", p, + "--to-filesystem-directory", mirrorOptions.(options.URLInterface).GetURL(), + ) + log.Trace("======= assert") + assert.NoError(t, err) + require.Contains(t, output, fmt.Sprintf("mirror %s (filesystem", p)) + + mirrorTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + found := mirrorTree.Find(generic.NewPathFromString(p)) + require.NotEqualValues(t, found, generic.NilNode) + assert.EqualValues(t, p, found.GetCurrentPath().String()) +} + +func Test_CmdMirrorIntegrationSpecificToPath(t *testing.T) { + ctx := context.Background() + + mirrorOptions := tests_forge.GetFactory(forgejo_options.Name)().NewOptions(t) + mirrorTree := generic.GetFactory("f3")(ctx, mirrorOptions) + + fixtureOptions := tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t) + fixtureTree := generic.GetFactory("f3")(ctx, fixtureOptions) + + log := fixtureTree.GetLogger() + creator := f3_tests.NewCreator(t, "CmdMirrorSpecific", log) + + log.Trace("======= build fixture") + + var fromPath string + { + fixtureUserID := "userID01" + fixtureProjectID := "projectID01" + + userFormat := creator.GenerateUser() + userFormat.SetID(fixtureUserID) + users := fixtureTree.MustFind(generic.NewPathFromString("/forge/users")) + user := users.CreateChild(ctx) + user.FromFormat(userFormat) + user.Upsert(ctx) + require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName)) + + projectFormat := creator.GenerateProject() + projectFormat.SetID(fixtureProjectID) + projects := user.MustFind(generic.NewPathFromString("projects")) + project := projects.CreateChild(ctx) + project.FromFormat(projectFormat) + project.Upsert(ctx) + require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name)) + + fromPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name) + } + + log.Trace("======= create mirror") + + var toPath string + var projects generic.NodeInterface + { + userFormat := creator.GenerateUser() + users := mirrorTree.MustFind(generic.NewPathFromString("/forge/users")) + user := users.CreateChild(ctx) + user.FromFormat(userFormat) + user.Upsert(ctx) + require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName)) + + projectFormat := creator.GenerateProject() + projects = user.MustFind(generic.NewPathFromString("projects")) + project := projects.CreateChild(ctx) + project.FromFormat(projectFormat) + project.Upsert(ctx) + require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name)) + + toPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name) + } + + log.Trace("======= mirror %s", fromPath) + output, err := runApp(ctx, "f3", "--verbose", "mirror", + "--from-type", filesystem_options.Name, + "--from-path", fromPath, + "--from-filesystem-directory", fixtureOptions.(options.URLInterface).GetURL(), + + "--to-type", forgejo_options.Name, + "--to-path", toPath, + "--to-forgejo-user", mirrorOptions.(options.AuthInterface).GetUsername(), + "--to-forgejo-password", mirrorOptions.(options.AuthInterface).GetPassword(), + "--to-forgejo-url", mirrorOptions.(options.URLInterface).GetURL(), + ) + assert.NoError(t, err) + log.Trace("======= assert") + require.Contains(t, output, fmt.Sprintf("mirror %s", fromPath)) + projects.List(ctx) + require.NotEmpty(t, projects.GetChildren()) + log.Trace("======= project %s", projects.GetChildren()[0]) +} diff --git a/cmd/signal.go b/cmd/signal.go new file mode 100644 index 0000000..8504a51 --- /dev/null +++ b/cmd/signal.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +func InstallSignals() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + signalChannel := make(chan os.Signal, 1) + + signal.Notify( + signalChannel, + syscall.SIGINT, + syscall.SIGTERM, + ) + select { + case <-signalChannel: + case <-ctx.Done(): + } + cancel() + signal.Reset() + }() + + return ctx, cancel +} diff --git a/cmd/testhelpers.go b/cmd/testhelpers.go new file mode 100644 index 0000000..0320cf4 --- /dev/null +++ b/cmd/testhelpers.go @@ -0,0 +1,38 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/logger" + // allow forges to register + _ "code.forgejo.org/f3/gof3/v3/forges/filesystem" + _ "code.forgejo.org/f3/gof3/v3/forges/forgejo" +) + +func runApp(ctx context.Context, args ...string) (string, error) { + l := logger.NewCaptureLogger() + ctx = logger.ContextSetLogger(ctx, l) + + app := NewApp() + + app.Writer = l.GetBuffer() + app.ErrWriter = l.GetBuffer() + + defer func() { + if r := recover(); r != nil { + fmt.Println(l.String()) + panic(r) + } + }() + + err := app.Run(ctx, args) + + fmt.Println(l.String()) + + return l.String(), err +} diff --git a/f3/ci.go b/f3/ci.go new file mode 100644 index 0000000..fd153a2 --- /dev/null +++ b/f3/ci.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package f3 + +type CI struct { + Common +} + +func (o CI) Equal(other CI) bool { + return o.Common.Equal(other.Common) +} + +func (o *CI) Clone() Interface { + clone := &CI{} + *clone = *o + return clone +} diff --git a/f3/comment.go b/f3/comment.go new file mode 100644 index 0000000..79e858a --- /dev/null +++ b/f3/comment.go @@ -0,0 +1,35 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import "time" + +type Comment struct { + Common + PosterID *Reference `json:"poster_id"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Content string `json:"content"` +} + +func (o *Comment) GetReferences() References { + references := o.Common.GetReferences() + if !o.PosterID.IsNil() { + references = append(references, o.PosterID) + } + return references +} + +func (o Comment) Equal(other Comment) bool { + return o.Common.Equal(other.Common) && + nilOrEqual(o.PosterID, other.PosterID) && + o.Content == other.Content +} + +func (o *Comment) Clone() Interface { + clone := &Comment{} + *clone = *o + return clone +} diff --git a/f3/equal.go b/f3/equal.go new file mode 100644 index 0000000..a21c768 --- /dev/null +++ b/f3/equal.go @@ -0,0 +1,37 @@ +// Copyright limiting-factor +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "time" +) + +type equalConstraint[T any] interface { + Equal(T) bool + *T +} + +func nilOrEqual[P any, T equalConstraint[P]](a, b T) bool { + return (a == nil && b == nil) || + (a != nil && b != nil && a.Equal(*b)) +} + +func arrayEqual[P any, T equalConstraint[P]](a, b []T) bool { + if len(a) != len(b) { + return false + } + + for i := 0; i < len(a); i++ { + if !nilOrEqual(a[i], b[i]) { + return false + } + } + + return true +} + +func nilOrEqualTimeToDate(a, b *time.Time) bool { + return (a == nil && b == nil) || + (a != nil && b != nil && a.Format(time.DateOnly) == b.Format(time.DateOnly)) +} diff --git a/f3/equal_test.go b/f3/equal_test.go new file mode 100644 index 0000000..cbf2dc6 --- /dev/null +++ b/f3/equal_test.go @@ -0,0 +1,53 @@ +// Copyright limiting-factor +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type s struct { + v int +} + +func (o s) Equal(other s) bool { + return o.v == other.v +} + +func TestNilOrEqual(t *testing.T) { + s1 := &s{1} + s2 := &s{1} + s3 := &s{2} + assert.True(t, nilOrEqual[s](nil, nil)) + assert.False(t, nilOrEqual(s1, nil)) + assert.False(t, nilOrEqual(nil, s2)) + assert.True(t, nilOrEqual(s1, s2)) + assert.False(t, nilOrEqual(s1, s3)) +} + +func TestArrayEqual(t *testing.T) { + s1 := []*s{{1}, {2}} + s2 := []*s{{1}, {2}} + s3 := []*s{{1}, {2}, {3}} + assert.True(t, arrayEqual(s1, s2)) + assert.False(t, arrayEqual(s1, s3)) +} + +func TestNilOrEqualTimeToDate(t *testing.T) { + t1, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") + require.NoError(t, err) + t2, err := time.Parse(time.RFC3339, "2006-01-02T00:01:00Z") + require.NoError(t, err) + t3, err := time.Parse(time.RFC3339, "2026-01-02T11:01:00Z") + require.NoError(t, err) + assert.True(t, nilOrEqualTimeToDate(nil, nil)) + assert.False(t, nilOrEqualTimeToDate(&t1, nil)) + assert.False(t, nilOrEqualTimeToDate(nil, &t2)) + assert.True(t, nilOrEqualTimeToDate(&t1, &t2)) + assert.False(t, nilOrEqualTimeToDate(&t1, &t3)) +} diff --git a/f3/file_format.go b/f3/file_format.go new file mode 100644 index 0000000..ae3ddeb --- /dev/null +++ b/f3/file_format.go @@ -0,0 +1,109 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// Load project data from file, with optional validation +func Load(filename string, data any, validation bool) error { + bs, err := os.ReadFile(filename) + if err != nil { + return err + } + + if validation { + err := validate(bs, data) + if err != nil { + return err + } + } + return unmarshal(bs, data) +} + +func Store(filename string, data any) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + bs, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + if _, err := f.Write(bs); err != nil { + return err + } + if _, err := f.Write([]byte("\n")); err != nil { + return err + } + return nil +} + +func unmarshal(bs []byte, data any) error { + return json.Unmarshal(bs, data) +} + +func getSchema(filename string) (*jsonschema.Schema, error) { + c := jsonschema.NewCompiler() + return c.Compile(filename) +} + +func validate(bs []byte, datatype any) error { + var v any + err := unmarshal(bs, &v) + if err != nil { + return err + } + + var schemaFilename string + switch datatype := datatype.(type) { + case *User: + schemaFilename = "schemas/user.json" + case *Organization: + schemaFilename = "schemas/organization.json" + case *Project: + schemaFilename = "schemas/project.json" + case *Topic: + schemaFilename = "schemas/topic.json" + case *Issue: + schemaFilename = "schemas/issue.json" + case *PullRequest: + schemaFilename = "schemas/pullrequest.json" + case *Label: + schemaFilename = "schemas/label.json" + case *Milestone: + schemaFilename = "schemas/milestone.json" + case *Release: + schemaFilename = "schemas/release.json" + case *ReleaseAsset: + schemaFilename = "schemas/releaseasset.json" + case *Comment: + schemaFilename = "schemas/comment.json" + case *Reaction: + schemaFilename = "schemas/reaction.json" + case *Repository: + schemaFilename = "schemas/repository.json" + case *Review: + schemaFilename = "schemas/review.json" + case *ReviewComment: + schemaFilename = "schemas/reviewcomment.json" + case *CI: + schemaFilename = "schemas/ci.json" + default: + return fmt.Errorf("file_format:validate: %T does not have a schema that could be used for validation", datatype) + } + + sch, err := getSchema(schemaFilename) + if err != nil { + return err + } + return sch.Validate(v) +} diff --git a/f3/file_format_test.go b/f3/file_format_test.go new file mode 100644 index 0000000..5646709 --- /dev/null +++ b/f3/file_format_test.go @@ -0,0 +1,166 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" +) + +func TestStoreLoad(t *testing.T) { + tmpDir := t.TempDir() + + type S struct { + A int + B string + } + original := S{A: 1, B: "B"} + p := filepath.Join(tmpDir, "s.json") + assert.NoError(t, Store(p, original)) + var loaded S + assert.NoError(t, Load(p, &loaded, false)) + assert.EqualValues(t, original, loaded) +} + +func TestF3_CI(t *testing.T) { + var ci CI + err := Load("file_format_testdata/ci/good.json", &ci, true) + assert.NoError(t, err) + err = Load("file_format_testdata/ci/bad.json", &ci, true) + assert.ErrorContains(t, err, "'/index': value must be one of") +} + +func TestF3_User(t *testing.T) { + var user User + err := Load("file_format_testdata/user/good.json", &user, true) + assert.NoError(t, err) + err = Load("file_format_testdata/user/bad.json", &user, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Organization(t *testing.T) { + var organization Organization + err := Load("file_format_testdata/organization/good.json", &organization, true) + assert.NoError(t, err) + err = Load("file_format_testdata/organization/bad.json", &organization, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Project(t *testing.T) { + var project Project + err := Load("file_format_testdata/project/good.json", &project, true) + assert.NoError(t, err) + err = Load("file_format_testdata/project/bad.json", &project, true) + assert.ErrorContains(t, err, "'/stars': got string, want number") +} + +func TestF3_Issue(t *testing.T) { + var issue Issue + err := Load("file_format_testdata/issue/good.json", &issue, true) + assert.NoError(t, err) + err = Load("file_format_testdata/issue/bad.json", &issue, true) + assert.ErrorContains(t, err, "missing property 'index'") +} + +func TestF3_PullRequest(t *testing.T) { + var pullRequest PullRequest + err := Load("file_format_testdata/pullrequest/good.json", &pullRequest, true) + assert.NoError(t, err) + err = Load("file_format_testdata/pullrequest/bad.json", &pullRequest, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Release(t *testing.T) { + var release Release + err := Load("file_format_testdata/release/good.json", &release, true) + assert.NoError(t, err) + err = Load("file_format_testdata/release/bad.json", &release, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_ReleaseAsset(t *testing.T) { + var releaseAsset ReleaseAsset + err := Load("file_format_testdata/releaseasset/good.json", &releaseAsset, true) + assert.NoError(t, err) + err = Load("file_format_testdata/releaseasset/bad.json", &releaseAsset, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Comment(t *testing.T) { + var comment Comment + err := Load("file_format_testdata/comment/good.json", &comment, true) + assert.NoError(t, err) + err = Load("file_format_testdata/comment/bad.json", &comment, true) + assert.ErrorContains(t, err, "'/created': 'AAAAAAAAA' is not valid date-time") +} + +func TestF3_Label(t *testing.T) { + var label Label + err := Load("file_format_testdata/label/good.json", &label, true) + assert.NoError(t, err) + err = Load("file_format_testdata/label/bad.json", &label, true) + assert.ErrorContains(t, err, "'/exclusive': got string, want boolean") +} + +func TestF3_Milestone(t *testing.T) { + var milestone Milestone + err := Load("file_format_testdata/milestone/good.json", &milestone, true) + assert.NoError(t, err) + err = Load("file_format_testdata/milestone/bad.json", &milestone, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Review(t *testing.T) { + var review Review + err := Load("file_format_testdata/review/good.json", &review, true) + assert.NoError(t, err) + err = Load("file_format_testdata/review/bad.json", &review, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Reaction(t *testing.T) { + var reaction Reaction + err := Load("file_format_testdata/reaction/good.json", &reaction, true) + assert.NoError(t, err) + err = Load("file_format_testdata/reaction/bad.json", &reaction, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Repository(t *testing.T) { + var repository Repository + err := Load("file_format_testdata/repository/good.json", &repository, true) + assert.NoError(t, err) + err = Load("file_format_testdata/repository/bad.json", &repository, true) + assert.ErrorContains(t, err, "missing property 'name'") +} + +func TestF3_ReviewComment(t *testing.T) { + var reviewComment ReviewComment + err := Load("file_format_testdata/reviewcomment/good.json", &reviewComment, true) + assert.NoError(t, err) + err = Load("file_format_testdata/reviewcomment/bad.json", &reviewComment, true) + assert.ErrorContains(t, err, "missing properties 'index'") +} + +func TestF3_Topic(t *testing.T) { + var topic Topic + err := Load("file_format_testdata/topic.json", &topic, true) + assert.NoError(t, err) +} + +func TestF3_ValidationFail(t *testing.T) { + var issue Issue + err := Load("file_format_testdata/issue/bad.json", &issue, true) + if _, ok := err.(*jsonschema.ValidationError); ok { + errors := strings.Split(err.(*jsonschema.ValidationError).GoString(), "\n") + assert.Contains(t, errors[1], "missing property") + } else { + t.Fatalf("got: type %T with value %s, want: *jsonschema.ValidationError", err, err) + } +} diff --git a/f3/file_format_testdata/ci/bad.json b/f3/file_format_testdata/ci/bad.json new file mode 100644 index 0000000..2e83ab8 --- /dev/null +++ b/f3/file_format_testdata/ci/bad.json @@ -0,0 +1,3 @@ +{ + "index": "Unknown" +} diff --git a/f3/file_format_testdata/ci/good.json b/f3/file_format_testdata/ci/good.json new file mode 100644 index 0000000..196ee2a --- /dev/null +++ b/f3/file_format_testdata/ci/good.json @@ -0,0 +1,3 @@ +{ + "index": "Forgejo Actions" +} diff --git a/f3/file_format_testdata/comment/bad.json b/f3/file_format_testdata/comment/bad.json new file mode 100644 index 0000000..791943d --- /dev/null +++ b/f3/file_format_testdata/comment/bad.json @@ -0,0 +1,7 @@ +{ + "index": "5", + "poster_id": "1", + "created": "AAAAAAAAA", + "updated": "1986-04-12T23:20:50.52Z", + "content": "comment_content_5" +} diff --git a/f3/file_format_testdata/comment/good.json b/f3/file_format_testdata/comment/good.json new file mode 100644 index 0000000..1edbd20 --- /dev/null +++ b/f3/file_format_testdata/comment/good.json @@ -0,0 +1,14 @@ +{ + "index": "5", + "poster_id": "/user/1", + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "content": "comment_content_5", + "reactions": [ + { + "index": "8", + "user_id": "/user/23", + "content": "laugh" + } + ] +} diff --git a/f3/file_format_testdata/issue/bad.json b/f3/file_format_testdata/issue/bad.json new file mode 100644 index 0000000..cf3a307 --- /dev/null +++ b/f3/file_format_testdata/issue/bad.json @@ -0,0 +1,20 @@ +{ + "poster_id": "/forge/users/1", + "title": "title_a", + "content": "content_a", + "milestone": "../../milestones/23", + "state": "closed", + "is_locked": false, + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "closed": null, + "due": "1986-04-12", + "labels": [ + "../../labels/435" + ], + "reactions": null, + "assignees": [ + "/forge/users/1", + "/forge/users/2" + ] +} diff --git a/f3/file_format_testdata/issue/good.json b/f3/file_format_testdata/issue/good.json new file mode 100644 index 0000000..ba6d291 --- /dev/null +++ b/f3/file_format_testdata/issue/good.json @@ -0,0 +1,21 @@ +{ + "index": "1", + "poster_id": "/forge/users/1", + "title": "title_a", + "content": "content_a", + "milestone": "../../milestones/23", + "state": "closed", + "is_locked": false, + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "closed": "1986-04-12T23:20:50.52Z", + "due": "1986-04-12", + "labels": [ + "../../labels/435" + ], + "reactions": [], + "assignees": [ + "/forge/users/1", + "/forge/users/2" + ] +} diff --git a/f3/file_format_testdata/label/bad.json b/f3/file_format_testdata/label/bad.json new file mode 100644 index 0000000..94e4171 --- /dev/null +++ b/f3/file_format_testdata/label/bad.json @@ -0,0 +1,7 @@ +{ + "index": "1", + "name": "label1", + "description": "label1 description", + "color": "ffffff", + "exclusive": "CCCCCCCC" +} diff --git a/f3/file_format_testdata/label/good.json b/f3/file_format_testdata/label/good.json new file mode 100644 index 0000000..724c203 --- /dev/null +++ b/f3/file_format_testdata/label/good.json @@ -0,0 +1,7 @@ +{ + "index": "1", + "name": "label1", + "description": "label1 description", + "color": "ffffff", + "exclusive": false +} diff --git a/f3/file_format_testdata/milestone/bad.json b/f3/file_format_testdata/milestone/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/milestone/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/milestone/good.json b/f3/file_format_testdata/milestone/good.json new file mode 100644 index 0000000..bb9265b --- /dev/null +++ b/f3/file_format_testdata/milestone/good.json @@ -0,0 +1,10 @@ +{ + "index": "1", + "title": "title_a", + "description": "description_a", + "deadline": "1988-04-12T23:20:50.52Z", + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "closed": "1987-04-12T23:20:50.52Z", + "state": "closed" +} diff --git a/f3/file_format_testdata/organization/bad.json b/f3/file_format_testdata/organization/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/organization/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/organization/good.json b/f3/file_format_testdata/organization/good.json new file mode 100644 index 0000000..d9ad31a --- /dev/null +++ b/f3/file_format_testdata/organization/good.json @@ -0,0 +1,5 @@ +{ + "index": "9", + "name": "orgunique", + "full_name": "Org Unique" +} diff --git a/f3/file_format_testdata/project/bad.json b/f3/file_format_testdata/project/bad.json new file mode 100644 index 0000000..f96137a --- /dev/null +++ b/f3/file_format_testdata/project/bad.json @@ -0,0 +1,28 @@ +{ + "index": "1", + "name": "projectname", + "is_private": false, + "is_mirror": false, + "description": "project description", + "default_branch": "main", + "repositories": [ + { + "name": "vcs", + "vcs": "hg" + }, + { + "name": "vcs.wiki" + } + ], + "archived": true, + "archived_at": "1987-04-12T23:20:50.52Z", + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "url": "https://example.com", + "stars": "BBBBBB", + "has_ci": true, + "has_issues": true, + "has_packages": true, + "has_pull_requests": true, + "has_wiki": true +} diff --git a/f3/file_format_testdata/project/good.json b/f3/file_format_testdata/project/good.json new file mode 100644 index 0000000..66878c1 --- /dev/null +++ b/f3/file_format_testdata/project/good.json @@ -0,0 +1,28 @@ +{ + "index": "1", + "name": "projectname", + "is_private": false, + "is_mirror": false, + "description": "project description", + "default_branch": "main", + "repositories": [ + { + "name": "vcs", + "vcs": "hg" + }, + { + "name": "vcs.wiki" + } + ], + "archived": true, + "archived_at": "1987-04-12T23:20:50.52Z", + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "url": "https://example.com", + "stars": 20, + "has_ci": true, + "has_issues": true, + "has_packages": true, + "has_pull_requests": true, + "has_wiki": true +} diff --git a/f3/file_format_testdata/pullrequest/bad.json b/f3/file_format_testdata/pullrequest/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/pullrequest/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/pullrequest/good.json b/f3/file_format_testdata/pullrequest/good.json new file mode 100644 index 0000000..fee893d --- /dev/null +++ b/f3/file_format_testdata/pullrequest/good.json @@ -0,0 +1,32 @@ +{ + "index": "1", + "poster_id": "/forgejo/users/1", + "title": "title_a", + "content": "content_a", + "milestone": "../../milestones/5", + "state": "closed", + "is_locked": false, + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "closed": "1986-04-12T23:20:50.52Z", + "labels": [ + "../../labels/435" + ], + "reactions": [], + "assignees": [], + "merged": false, + "merged_time": "1986-04-12T23:20:50.52Z", + "merged_commit_sha": "shashasha", + "head": { + "clone_url": "head_clone_url", + "ref": "head_branch", + "sha": "head_sha", + "repository": "/forge/user/1/projects/2/repositories/vcs" + }, + "base": { + "clone_url": "base_clone_url", + "ref": "base_branch", + "sha": "base _sha", + "repository": "/forge/user/3/projects/4/repositories/vcs" + } +} diff --git a/f3/file_format_testdata/reaction/bad.json b/f3/file_format_testdata/reaction/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/reaction/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/reaction/good.json b/f3/file_format_testdata/reaction/good.json new file mode 100644 index 0000000..85fed43 --- /dev/null +++ b/f3/file_format_testdata/reaction/good.json @@ -0,0 +1,5 @@ +{ + "index": "8", + "user_id": "/forge/users/912", + "content": "laugh" +} diff --git a/f3/file_format_testdata/release/bad.json b/f3/file_format_testdata/release/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/release/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/release/good.json b/f3/file_format_testdata/release/good.json new file mode 100644 index 0000000..5240733 --- /dev/null +++ b/f3/file_format_testdata/release/good.json @@ -0,0 +1,11 @@ +{ + "index": "123", + "tag_name": "v12", + "target_commitish": "stable", + "name": "v12 name", + "body": "v12 body", + "draft": false, + "prerelease": false, + "publisher_id": "/forgejo/user/1", + "created": "1985-04-12T23:20:50.52Z" +} diff --git a/f3/file_format_testdata/releaseasset/bad.json b/f3/file_format_testdata/releaseasset/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/releaseasset/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/releaseasset/good.json b/f3/file_format_testdata/releaseasset/good.json new file mode 100644 index 0000000..fe14e02 --- /dev/null +++ b/f3/file_format_testdata/releaseasset/good.json @@ -0,0 +1,11 @@ +{ + "index": "5", + "name": "asset_5", + "content_type": "application/zip", + "size": 50, + "download_count": 10, + "download_url": "http://example.com/something", + "created": "1985-04-12T23:20:50.52Z", + "updated": "1986-04-12T23:20:50.52Z", + "sha256": "4c5b2f412017de78124ae3a063d08e76566eea0cba6deb2533fb176d816a54fc" +} diff --git a/f3/file_format_testdata/repository/bad.json b/f3/file_format_testdata/repository/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/repository/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/repository/good.json b/f3/file_format_testdata/repository/good.json new file mode 100644 index 0000000..28c00bb --- /dev/null +++ b/f3/file_format_testdata/repository/good.json @@ -0,0 +1,4 @@ +{ + "name": "vcs.wiki", + "vcs": "fossil" +} diff --git a/f3/file_format_testdata/review/bad.json b/f3/file_format_testdata/review/bad.json new file mode 100644 index 0000000..bfd870e --- /dev/null +++ b/f3/file_format_testdata/review/bad.json @@ -0,0 +1,3 @@ +{ +} + diff --git a/f3/file_format_testdata/review/good.json b/f3/file_format_testdata/review/good.json new file mode 100644 index 0000000..c981bba --- /dev/null +++ b/f3/file_format_testdata/review/good.json @@ -0,0 +1,12 @@ +{ + "index": "1", + "reviewer_id": "/forge/user/50", + "official": false, + "commit_id": "shashashasha", + "content": "cover review comment", + "created_at": "1985-04-12T23:20:50.52Z", + "state": "PENDING", + "dissmissed": false, + "stale": false +} + diff --git a/f3/file_format_testdata/reviewcomment/bad.json b/f3/file_format_testdata/reviewcomment/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/reviewcomment/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/reviewcomment/good.json b/f3/file_format_testdata/reviewcomment/good.json new file mode 100644 index 0000000..ebddd2a --- /dev/null +++ b/f3/file_format_testdata/reviewcomment/good.json @@ -0,0 +1,14 @@ +{ + "index": "100", + "content": "review comment", + "tree_path": "dir/file1.txt", + "diff_hunk": "@@hunkhunk", + "line": 1, + "lines_count": 1, + "commit_id": "shashashasha", + "poster_id": "/forge/users/10", + "reactions": [], + "created_at": "1985-04-12T23:20:50.52Z", + "updated_at": "1985-04-12T23:20:50.52Z", + "resolver": "/forge/users/10" +} diff --git a/f3/file_format_testdata/topic.json b/f3/file_format_testdata/topic.json new file mode 100644 index 0000000..7364472 --- /dev/null +++ b/f3/file_format_testdata/topic.json @@ -0,0 +1,4 @@ +{ + "index": "1", + "name": "category1" +} diff --git a/f3/file_format_testdata/user/bad.json b/f3/file_format_testdata/user/bad.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/f3/file_format_testdata/user/bad.json @@ -0,0 +1,2 @@ +{ +} diff --git a/f3/file_format_testdata/user/good.json b/f3/file_format_testdata/user/good.json new file mode 100644 index 0000000..d240ce0 --- /dev/null +++ b/f3/file_format_testdata/user/good.json @@ -0,0 +1,8 @@ +{ + "index": "7", + "name": "User Name One", + "email": "user1@example.com", + "username": "user1", + "password": "gloxKainlo", + "admin": false +} diff --git a/f3/forge.go b/f3/forge.go new file mode 100644 index 0000000..55cf270 --- /dev/null +++ b/f3/forge.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Forge struct { + Common + URL string `json:"url"` +} + +func (o Forge) Equal(other Forge) bool { + return o.Common.Equal(other.Common) +} + +func (o *Forge) Clone() Interface { + clone := &Forge{} + *clone = *o + return clone +} diff --git a/f3/formatbase.go b/f3/formatbase.go new file mode 100644 index 0000000..da96045 --- /dev/null +++ b/f3/formatbase.go @@ -0,0 +1,99 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "encoding/json" + "strings" + + "code.forgejo.org/f3/gof3/v3/util" +) + +type Interface interface { + GetID() string + GetName() string + SetID(id string) + IsNil() bool + GetReferences() References + ToReference() *Reference + Clone() Interface +} + +type ReferenceInterface interface { + Get() string + Set(reference string) + GetIDAsString() string + GetIDAsInt() int64 +} + +type Reference struct { + ID string +} + +func (r *Reference) Get() string { + return r.ID +} + +func (r *Reference) Set(reference string) { + r.ID = reference +} + +func (r *Reference) GetIDAsInt() int64 { + return util.ParseInt(r.GetIDAsString()) +} + +func (r *Reference) GetIDAsString() string { + s := strings.Split(r.ID, "/") + return s[len(s)-1] +} + +type References []ReferenceInterface + +func (r *Reference) UnmarshalJSON(b []byte) error { + return json.Unmarshal(b, &r.ID) +} + +func (r Reference) MarshalJSON() ([]byte, error) { + return json.Marshal(r.ID) +} + +func (r *Reference) GetID() string { return r.ID } +func (r *Reference) SetID(id string) { r.ID = id } +func (r *Reference) IsNil() bool { return r == nil || r.ID == "0" || r.ID == "" } +func (r Reference) Equal(other Reference) bool { return r.ID == other.ID } + +func NewReferences() References { + return make([]ReferenceInterface, 0, 1) +} + +func NewReference(id string) *Reference { + r := &Reference{} + r.SetID(id) + return r +} + +type Common struct { + Index Reference `json:"index"` +} + +func (c *Common) GetID() string { return c.Index.GetID() } +func (c *Common) GetName() string { return c.GetID() } +func (c *Common) SetID(id string) { c.Index.SetID(id) } +func (c *Common) IsNil() bool { return c == nil || c.Index.IsNil() } +func (c *Common) GetReferences() References { return NewReferences() } +func (c *Common) ToReference() *Reference { return &c.Index } +func (c Common) Equal(other Common) bool { return true } + +var Nil = &Common{} + +func NewCommon(id string) Common { + return Common{Index: *NewReference(id)} +} + +func (c *Common) Clone() Interface { + clone := &Common{} + *clone = *c + return clone +} diff --git a/f3/formatbase_test.go b/f3/formatbase_test.go new file mode 100644 index 0000000..f8d1702 --- /dev/null +++ b/f3/formatbase_test.go @@ -0,0 +1,57 @@ +// Copyright limiting-factor +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestF3Reference(t *testing.T) { + ref := "reference" + r := NewReference(ref) + assert.Equal(t, ref, r.GetID()) + otherRef := "other" + r.SetID(otherRef) + assert.Equal(t, otherRef, r.GetID()) + r.Set(otherRef) + assert.Equal(t, otherRef, r.Get()) + assert.True(t, r.Equal(*r)) + + m, err := r.MarshalJSON() + require.NoError(t, err) + u := NewReference("???") + require.NoError(t, u.UnmarshalJSON(m)) + assert.True(t, r.Equal(*u)) + + assert.False(t, r.IsNil()) + r.SetID("") + assert.True(t, r.IsNil()) + r.SetID("0") + assert.True(t, r.IsNil()) + + var nilRef *Reference + assert.True(t, nilRef.IsNil()) +} + +func TestF3Common(t *testing.T) { + id := "ID" + c := NewCommon(id) + assert.Equal(t, id, c.GetID()) + assert.Equal(t, id, c.GetName()) + otherID := "otherID" + c.SetID(otherID) + assert.Equal(t, otherID, c.GetID()) + + assert.False(t, c.IsNil()) + c.SetID("") + assert.True(t, c.IsNil()) + c.SetID("0") + assert.True(t, c.IsNil()) + + var nilCommon *Common + assert.True(t, nilCommon.IsNil()) +} diff --git a/f3/issue.go b/f3/issue.go new file mode 100644 index 0000000..82dd06a --- /dev/null +++ b/f3/issue.go @@ -0,0 +1,58 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import "time" + +const ( + IssueStateOpen = "open" + IssueStateClosed = "closed" +) + +type Issue struct { + Common + PosterID *Reference `json:"poster_id"` + Assignees []*Reference `json:"assignees"` + Labels []*Reference `json:"labels"` + Title string `json:"title"` + Content string `json:"content"` + Milestone *Reference `json:"milestone"` + State string `json:"state"` // open, closed + IsLocked bool `json:"is_locked"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Closed *time.Time `json:"closed"` +} + +func (o *Issue) GetReferences() References { + references := o.Common.GetReferences() + for _, assignee := range o.Assignees { + references = append(references, assignee) + } + for _, label := range o.Labels { + references = append(references, label) + } + if !o.Milestone.IsNil() { + references = append(references, o.Milestone) + } + return append(references, o.PosterID) +} + +func (o Issue) Equal(other Issue) bool { + return o.Common.Equal(other.Common) && + nilOrEqual(o.PosterID, other.PosterID) && + arrayEqual(o.Assignees, other.Assignees) && + arrayEqual(o.Labels, other.Labels) && + o.Title == other.Title && + nilOrEqual(o.Milestone, other.Milestone) && + o.State == other.State && + o.IsLocked == other.IsLocked +} + +func (o *Issue) Clone() Interface { + clone := &Issue{} + *clone = *o + return clone +} diff --git a/f3/label.go b/f3/label.go new file mode 100644 index 0000000..b4e8ce1 --- /dev/null +++ b/f3/label.go @@ -0,0 +1,25 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Label struct { + Common + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +func (o Label) Equal(other Label) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name && + o.Color == other.Color && + o.Description == other.Description +} + +func (o *Label) Clone() Interface { + clone := &Label{} + *clone = *o + return clone +} diff --git a/f3/milestone.go b/f3/milestone.go new file mode 100644 index 0000000..dae25a2 --- /dev/null +++ b/f3/milestone.go @@ -0,0 +1,37 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import "time" + +const ( + MilestoneStateOpen = "open" + MilestoneStateClosed = "closed" +) + +type Milestone struct { + Common + Title string `json:"title"` + Description string `json:"description"` + Deadline *time.Time `json:"deadline"` + Created time.Time `json:"created"` + Updated *time.Time `json:"updated"` + Closed *time.Time `json:"closed"` + State string `json:"state"` // open, closed +} + +func (o Milestone) Equal(other Milestone) bool { + return o.Common.Equal(other.Common) && + o.Title == other.Title && + o.Description == other.Description && + nilOrEqualTimeToDate(o.Deadline, other.Deadline) && + o.State == other.State +} + +func (o *Milestone) Clone() Interface { + clone := &Milestone{} + *clone = *o + return clone +} diff --git a/f3/new.go b/f3/new.go new file mode 100644 index 0000000..9dd8b1e --- /dev/null +++ b/f3/new.go @@ -0,0 +1,81 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" +) + +func New(name string) Interface { + common := NewCommon(name) + switch name { + case "": + return &common + case ResourceAsset: + return &ReleaseAsset{} + case ResourceAssets: + return &common + case ResourceComment: + return &Comment{} + case ResourceComments: + return &common + case ResourceIssue: + return &Issue{} + case ResourceIssues: + return &common + case ResourceLabel: + return &Label{} + case ResourceLabels: + return &common + case ResourceMilestone: + return &Milestone{} + case ResourceMilestones: + return &common + case ResourceOrganization: + return &Organization{} + case ResourceOrganizations: + return &common + case ResourceProject: + return &Project{} + case ResourceProjects: + return &common + case ResourcePullRequest: + return &PullRequest{} + case ResourcePullRequests: + return &common + case ResourceReaction: + return &Reaction{} + case ResourceReactions: + return &common + case ResourceRelease: + return &Release{} + case ResourceReleases: + return &common + case ResourceRepository: + return &Repository{} + case ResourceRepositories: + return &common + case ResourceReview: + return &Review{} + case ResourceReviews: + return &common + case ResourceReviewComment: + return &ReviewComment{} + case ResourceReviewComments: + return &common + case ResourceTopic: + return &Topic{} + case ResourceTopics: + return &common + case ResourceUser: + return &User{} + case ResourceUsers: + return &common + case ResourceForge: + return &Forge{} + default: + panic(fmt.Errorf("unknown %s", name)) + } +} diff --git a/f3/organization.go b/f3/organization.go new file mode 100644 index 0000000..8450570 --- /dev/null +++ b/f3/organization.go @@ -0,0 +1,27 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Organization struct { + Common + FullName string `json:"full_name"` + Name string `json:"name"` +} + +func (o Organization) Equal(other Organization) bool { + return o.Common.Equal(other.Common) && + o.FullName == other.FullName && + o.Name == other.Name +} + +func (o *Organization) GetName() string { + return o.Name +} + +func (o *Organization) Clone() Interface { + clone := &Organization{} + *clone = *o + return clone +} diff --git a/f3/project.go b/f3/project.go new file mode 100644 index 0000000..d8cddcf --- /dev/null +++ b/f3/project.go @@ -0,0 +1,50 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Project struct { + Common + Name string `json:"name"` + IsPrivate bool `json:"is_private"` + IsMirror bool `json:"is_mirror"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Forked *Reference `json:"forked"` + HasWiki bool `json:"has_wiki"` + Topics []*Reference `json:"topics"` +} + +func (o Project) Project(other Project) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name && + o.IsPrivate == other.IsPrivate && + o.IsMirror == other.IsMirror && + o.Description == other.Description && + o.DefaultBranch == other.DefaultBranch && + nilOrEqual(o.Forked, other.Forked) && + o.HasWiki == other.HasWiki && + arrayEqual(o.Topics, other.Topics) +} + +func (o *Project) GetName() string { + return o.Name +} + +func (o *Project) GetReferences() References { + references := o.Common.GetReferences() + if !o.Forked.IsNil() { + references = append(references, o.Forked) + } + for _, topic := range o.Topics { + references = append(references, topic) + } + return references +} + +func (o *Project) Clone() Interface { + clone := &Project{} + *clone = *o + return clone +} diff --git a/f3/pullrequest.go b/f3/pullrequest.go new file mode 100644 index 0000000..1be335d --- /dev/null +++ b/f3/pullrequest.go @@ -0,0 +1,72 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "time" +) + +type PullRequestFetchFunc func(ctx context.Context, url, ref string) + +const ( + PullRequestStateOpen = "open" + PullRequestStateClosed = "closed" +) + +type PullRequest struct { + Common + PosterID *Reference `json:"poster_id"` + Title string `json:"title"` + Content string `json:"content"` + Milestone *Reference `json:"milestone"` + State string `json:"state"` // open, closed + IsLocked bool `json:"is_locked"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Closed *time.Time `json:"closed"` + Merged bool `json:"merged"` + MergedTime *time.Time `json:"merged_time"` + MergeCommitSHA string `json:"merged_commit_sha"` + Head PullRequestBranch `json:"head"` + Base PullRequestBranch `json:"base"` + + FetchFunc PullRequestFetchFunc `json:"-"` +} + +func (o PullRequest) Equal(other PullRequest) bool { + return o.Common.Equal(other.Common) && + nilOrEqual(o.PosterID, other.PosterID) && + o.Title == other.Title && + o.Content == other.Content && + nilOrEqual(o.Milestone, other.Milestone) && + o.State == other.State && + o.IsLocked == other.IsLocked && + o.Merged == other.Merged && + nilOrEqual(o.MergedTime, other.MergedTime) && + o.MergeCommitSHA == other.MergeCommitSHA && + o.Head.Equal(other.Head) && + o.Base.Equal(other.Base) +} + +func (o *PullRequest) GetReferences() References { + references := o.Common.GetReferences() + if !o.Milestone.IsNil() { + references = append(references, o.Milestone) + } + references = append(references, o.Base.GetReferences()...) + references = append(references, o.Head.GetReferences()...) + return append(references, o.PosterID) +} + +func (o *PullRequest) IsForkPullRequest() bool { + return o.Head.Repository != o.Base.Repository +} + +func (o *PullRequest) Clone() Interface { + clone := &PullRequest{} + *clone = *o + return clone +} diff --git a/f3/pullrequestbranch.go b/f3/pullrequestbranch.go new file mode 100644 index 0000000..d63afc8 --- /dev/null +++ b/f3/pullrequestbranch.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type PullRequestBranch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repository *Reference `json:"repository"` +} + +func (o PullRequestBranch) Equal(other PullRequestBranch) bool { + return o.Ref == other.Ref && + o.SHA == other.SHA +} + +func (o *PullRequestBranch) GetReferences() References { + return References{o.Repository} +} diff --git a/f3/reaction.go b/f3/reaction.go new file mode 100644 index 0000000..b2b4b34 --- /dev/null +++ b/f3/reaction.go @@ -0,0 +1,31 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Reaction struct { + Common + UserID *Reference `json:"user_id"` + Content string `json:"content"` +} + +func (o Reaction) Equal(other Reaction) bool { + return o.Common.Equal(other.Common) && + nilOrEqual(o.UserID, other.UserID) && + o.Content == other.Content +} + +func (o *Reaction) GetReferences() References { + references := o.Common.GetReferences() + if !o.UserID.IsNil() { + references = append(references, o.UserID) + } + return references +} + +func (o *Reaction) Clone() Interface { + clone := &Reaction{} + *clone = *o + return clone +} diff --git a/f3/release.go b/f3/release.go new file mode 100644 index 0000000..132e9da --- /dev/null +++ b/f3/release.go @@ -0,0 +1,48 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "time" +) + +type Release struct { + Common + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + PublisherID *Reference `json:"publisher_id"` + Assets []*ReleaseAsset `json:"assets"` + Created time.Time `json:"created"` +} + +func (o Release) Equal(other Release) bool { + return o.Common.Equal(other.Common) && + o.TagName == other.TagName && + o.TargetCommitish == other.TargetCommitish && + o.Name == other.Name && + o.Body == other.Body && + o.Draft == other.Draft && + o.Prerelease == other.Prerelease && + nilOrEqual(o.PublisherID, other.PublisherID) && + arrayEqual(o.Assets, other.Assets) +} + +func (o *Release) GetReferences() References { + references := o.Common.GetReferences() + if !o.PublisherID.IsNil() { + references = append(references, o.PublisherID) + } + return references +} + +func (o *Release) Clone() Interface { + clone := &Release{} + *clone = *o + return clone +} diff --git a/f3/releaseasset.go b/f3/releaseasset.go new file mode 100644 index 0000000..ecbaf43 --- /dev/null +++ b/f3/releaseasset.go @@ -0,0 +1,38 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "io" + "time" +) + +type DownloadFuncType func() io.ReadCloser + +type ReleaseAsset struct { + Common + Name string `json:"name"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + Created time.Time `json:"created"` + SHA256 string `json:"sha256"` + DownloadURL string `json:"download_url"` + DownloadFunc DownloadFuncType `json:"-"` +} + +func (o ReleaseAsset) Equal(other ReleaseAsset) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name && + o.ContentType == other.ContentType && + o.Size == other.Size && + o.SHA256 == other.SHA256 +} + +func (o *ReleaseAsset) Clone() Interface { + clone := &ReleaseAsset{} + *clone = *o + return clone +} diff --git a/f3/repository.go b/f3/repository.go new file mode 100644 index 0000000..97263da --- /dev/null +++ b/f3/repository.go @@ -0,0 +1,45 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" +) + +const ( + RepositoryNameDefault = "vcs" + RepositoryNameWiki = "vcs.wiki" +) + +var nameToID = map[string]int64{ + RepositoryNameDefault: 1, + RepositoryNameWiki: 2, +} + +// var RepositoryNames = []string{RepositoryNameDefault, RepositoryNameWiki} + +var RepositoryNames = []string{RepositoryNameDefault} + +type Repository struct { + Common + + Name string + FetchFunc func(ctx context.Context, destination string, internalRefs []string) `json:"-"` +} + +func (o Repository) Equal(other Repository) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name +} + +func (o *Repository) Clone() Interface { + clone := &Repository{} + *clone = *o + return clone +} + +func RepositoryDirname(name string) string { + return "git" + name +} diff --git a/f3/resources.go b/f3/resources.go new file mode 100644 index 0000000..3919fe8 --- /dev/null +++ b/f3/resources.go @@ -0,0 +1,39 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +const ( + ResourceAsset = "asset" + ResourceAssets = "assets" + ResourceComment = "comment" + ResourceComments = "comments" + ResourceIssue = "issue" + ResourceIssues = "issues" + ResourceLabel = "label" + ResourceLabels = "labels" + ResourceMilestone = "milestone" + ResourceMilestones = "milestones" + ResourceOrganization = "organization" + ResourceOrganizations = "organizations" + ResourceProject = "project" + ResourceProjects = "projects" + ResourcePullRequest = "pull_request" + ResourcePullRequests = "pull_requests" + ResourceReaction = "reaction" + ResourceReactions = "reactions" + ResourceRelease = "release" + ResourceReleases = "releases" + ResourceRepository = "repository" + ResourceRepositories = "repositories" + ResourceReview = "review" + ResourceReviews = "reviews" + ResourceReviewComment = "reviewcomment" + ResourceReviewComments = "reviewcomments" + ResourceTopic = "topic" + ResourceTopics = "topics" + ResourceUser = "user" + ResourceUsers = "users" + ResourceForge = "forge" +) diff --git a/f3/review.go b/f3/review.go new file mode 100644 index 0000000..55cffb8 --- /dev/null +++ b/f3/review.go @@ -0,0 +1,48 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import "time" + +const ( + ReviewStatePending = "PENDING" + ReviewStateApproved = "APPROVED" + ReviewStateChangesRequested = "CHANGES_REQUESTED" + ReviewStateCommented = "COMMENTED" + ReviewStateRequestReview = "REQUEST_REVIEW" + ReviewStateUnknown = "" +) + +type Review struct { + Common + ReviewerID *Reference `json:"reviewer_id"` + Official bool `json:"official"` + CommitID string `json:"commit_id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + State string `json:"state"` +} + +func (o Review) Equal(other Review) bool { + return o.Common.Equal(other.Common) && + nilOrEqual(o.ReviewerID, other.ReviewerID) && + o.CommitID == other.CommitID && + o.Content == other.Content && + o.State == other.State +} + +func (o *Review) GetReferences() References { + references := o.Common.GetReferences() + if !o.ReviewerID.IsNil() { + references = append(references, o.ReviewerID) + } + return references +} + +func (o *Review) Clone() Interface { + clone := &Review{} + *clone = *o + return clone +} diff --git a/f3/reviewcomment.go b/f3/reviewcomment.go new file mode 100644 index 0000000..44c8d99 --- /dev/null +++ b/f3/reviewcomment.go @@ -0,0 +1,46 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "time" +) + +type ReviewComment struct { + Common + Content string `json:"content"` + TreePath string `json:"tree_path"` + DiffHunk string `json:"diff_hunk"` + Line int `json:"line"` + LinesCount int `json:"lines_count"` + CommitID string `json:"commit_id"` + PosterID *Reference `json:"poster_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (o ReviewComment) Equal(other ReviewComment) bool { + return o.Common.Equal(other.Common) && + o.Content == other.Content && + o.TreePath == other.TreePath && + o.Line == other.Line && + o.LinesCount == other.LinesCount && + o.CommitID == other.CommitID && + nilOrEqual(o.PosterID, other.PosterID) +} + +func (o *ReviewComment) GetReferences() References { + references := o.Common.GetReferences() + if !o.PosterID.IsNil() { + references = append(references, o.PosterID) + } + return references +} + +func (o *ReviewComment) Clone() Interface { + clone := &ReviewComment{} + *clone = *o + return clone +} diff --git a/f3/schemas/ci.json b/f3/schemas/ci.json new file mode 100644 index 0000000..eadadb3 --- /dev/null +++ b/f3/schemas/ci.json @@ -0,0 +1,42 @@ +{ + "title": "CI", + "description": "The Continuous Integration supported by the project. The configuration files are found in the repository itself, under a path that depends on the CI system.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "The type of continuous integration", + "enum": [ + "Apache Gump", + "Azure DevOps Server", + "Bamboo", + "Buddy", + "Buildbot", + "BuildMaster", + "CircleCI", + "Drone", + "Forgejo Actions", + "Gitea Actions", + "GitHub Actions", + "GitLab", + "GoCD", + "Jenkins", + "OpenMake", + "Semaphore", + "TeamCity", + "tekton", + "Travis CI", + "Vexor", + "Woodpecker CI" + ] + } + }, + "required": [ + "index" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/ci.json", + "$$target": "ci.json" +} diff --git a/f3/schemas/comment.json b/f3/schemas/comment.json new file mode 100644 index 0000000..2f39a18 --- /dev/null +++ b/f3/schemas/comment.json @@ -0,0 +1,49 @@ +{ + "title": "Comment", + "description": "Comment associated to a commentable object (i.e. issue, review, etc.). Forge users add a comment to an object to create a non-threaded conversation.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the comment.", + "type": "string" + }, + "poster_id": { + "description": "Unique identifier of the comment author.", + "type": "string" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "content": { + "description": "Markdown content of the comment.", + "type": "string" + }, + "reactions": { + "description": "List of reactions.", + "type": "array", + "items": { + "$ref": "reaction.json" + } + } + }, + "required": [ + "index", + "poster_id", + "created", + "updated", + "content" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/comment.json", + "$$target": "comment.json" +} diff --git a/f3/schemas/index.rst b/f3/schemas/index.rst new file mode 100644 index 0000000..5009b21 --- /dev/null +++ b/f3/schemas/index.rst @@ -0,0 +1,22 @@ +.. toctree:: + :maxdepth: 2 + +.. jsonschema:: ci.json +.. jsonschema:: comment.json +.. jsonschema:: issue.json +.. jsonschema:: label.json +.. jsonschema:: milestone.json +.. jsonschema:: object.json +.. jsonschema:: organization.json +.. jsonschema:: project.json +.. jsonschema:: pullrequest.json +.. jsonschema:: pullrequestbranch.json +.. jsonschema:: reaction.json +.. jsonschema:: release.json +.. jsonschema:: releaseasset.json +.. jsonschema:: repository.json +.. jsonschema:: review.json +.. jsonschema:: reviewcomment.json +.. jsonschema:: topic.json +.. jsonschema:: user.json + diff --git a/f3/schemas/issue.json b/f3/schemas/issue.json new file mode 100644 index 0000000..ad91b55 --- /dev/null +++ b/f3/schemas/issue.json @@ -0,0 +1,96 @@ +{ + "title": "Issue", + "description": "An issue within an issue tracking system, relative to a project.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the issue.", + "type": "string" + }, + "poster_id": { + "description": "Unique identifier of the user who authored the issue.", + "type": "string" + }, + "title": { + "description": "Short description displayed as the title.", + "type": "string" + }, + "content": { + "description": "Description of the issue.", + "type": "string" + }, + "milestone": { + "description": "Unique identifier of the milestone.", + "type": "string" + }, + "state": { + "description": "An issue is 'closed' when it is resolved, 'open' otherwise. Issues that do not relate to a topic that needs to be resolved, such as an open conversation, may never be closed.", + "enum": [ + "closed", + "open" + ] + }, + "is_locked": { + "description": "A locked issue can only be modified by privileged users. It is commonly used for moderation purposes when comments associated with the issue are too heated.", + "type": "boolean" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "closed": { + "description": "The last time 'state' changed to 'closed'.", + "type": "string", + "format": "date-time" + }, + "due": { + "description": "Due date.", + "type": "string", + "format": "date" + }, + "labels": { + "description": "List of label unique identifiers.", + "type": "array", + "items": { + "type": "string" + } + }, + "reactions": { + "description": "List of reactions.", + "type": "array", + "items": { + "$ref": "reaction.json" + } + }, + "assignees": { + "description": "List of assignees.", + "type": "array", + "items": { + "description": "Name of a user assigned to the issue.", + "type": "string" + } + } + }, + "required": [ + "index", + "poster_id", + "title", + "content", + "state", + "is_locked", + "created", + "updated" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/issue.json", + "$$target": "issue.json" +} diff --git a/f3/schemas/label.json b/f3/schemas/label.json new file mode 100644 index 0000000..2757898 --- /dev/null +++ b/f3/schemas/label.json @@ -0,0 +1,37 @@ +{ + "title": "Label", + "description": "Label associated to an issue.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier.", + "type": "string" + }, + "name": { + "description": "Name of the label, unique within the repository.", + "type": "string" + }, + "color": { + "description": "Color code of the label in RGB notation 'xxx' or 'xxxxxx'.", + "type": "string" + }, + "description": { + "description": "Long description.", + "type": "string" + }, + "exclusive": { + "description": "There can only be one label with the prefix found before the first slash (/).", + "type": "boolean" + } + }, + "required": [ + "index", + "name" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/label.json", + "$$target": "label.json" +} diff --git a/f3/schemas/milestone.json b/f3/schemas/milestone.json new file mode 100644 index 0000000..6bc9a29 --- /dev/null +++ b/f3/schemas/milestone.json @@ -0,0 +1,62 @@ +{ + "title": "Milestone", + "description": "Milestone relative to a project, for the purpose of grouping objects due to a given date (issues, etc.).", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier.", + "type": "string" + }, + "title": { + "description": "Short description.", + "type": "string" + }, + "description": { + "description": "Long description.", + "type": "string" + }, + "deadline": { + "description": "Deadline after which the milestone is overdue.", + "type": "string", + "format": "date-time" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "closed": { + "description": "The last time 'state' changed to 'closed'.", + "type": "string", + "format": "date-time" + }, + "state": { + "description": "A 'closed' milestone will not see any activity in the future, otherwise it is 'open'.", + "enum": [ + "closed", + "open" + ] + } + }, + "required": [ + "index", + "title", + "description", + "deadline", + "created", + "updated", + "closed", + "state" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/milestone.json", + "$$target": "milestone.json" +} diff --git a/f3/schemas/object.json b/f3/schemas/object.json new file mode 100644 index 0000000..b9b3f3d --- /dev/null +++ b/f3/schemas/object.json @@ -0,0 +1,35 @@ +{ + "title": "Object", + "description": "Meta information and reference to an opaque content such as an image. The unique identifier is the SHA-256 of the content of the object.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier.", + "type": "string" + }, + "mime": { + "description": "Mime type of the object.", + "type": "string" + }, + "name": { + "description": "Human readable file name.", + "type": "string" + }, + "description": { + "description": "Description.", + "type": "string" + } + }, + "required": [ + "index", + "mime", + "name", + "description" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/object.json", + "$$target": "object.json" +} diff --git a/f3/schemas/organization.json b/f3/schemas/organization.json new file mode 100644 index 0000000..b1f4277 --- /dev/null +++ b/f3/schemas/organization.json @@ -0,0 +1,29 @@ +{ + "title": "Organization", + "description": "A forge organization.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the organization.", + "type": "string" + }, + "name": { + "description": "Unique name of the organization.", + "type": "string" + }, + "full_name": { + "description": "Readable name of the organization.", + "type": "string" + } + }, + "required": [ + "index", + "name" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/organization.json", + "$$target": "organization.json" +} diff --git a/f3/schemas/project.json b/f3/schemas/project.json new file mode 100644 index 0000000..db3c641 --- /dev/null +++ b/f3/schemas/project.json @@ -0,0 +1,117 @@ +{ + "title": "Project", + "description": "A software project contains a code repository, an issue tracker, etc.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the project.", + "type": "string" + }, + "name": { + "description": "Name of the project, relative to the owner.", + "type": "string" + }, + "is_private": { + "description": "True if the visibility of the project is not public.", + "type": "boolean" + }, + "is_mirror": { + "description": "True if it is a mirror of a project residing on another forge.", + "type": "boolean" + }, + "description": { + "description": "Long description of the project.", + "type": "string" + }, + "default_branch": { + "description": "Name of the default branch in the code repository.", + "type": "string" + }, + "repositories": { + "type": "array", + "items": { + "$ref": "repository.json" + } + }, + "forked": { + "description": "Unique identifier of the project from which this one was forked.", + "type": "string" + }, + "ci": { + "type": "array", + "items": { + "$ref": "ci.json" + } + }, + "archived": { + "description": "True if archived and read only.", + "type": "boolean" + }, + "archived_at": { + "description": "Time of archival.", + "type": "string", + "format": "date-time" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "url": { + "description": "URL associated with the project, for instance the project home page.", + "type": "string" + }, + "stars": { + "description": "Number of stars.", + "type": "number" + }, + "has_ci": { + "description": "True if CI is enabled.", + "type": "boolean" + }, + "has_issues": { + "description": "True if the issue tracker is enabled.", + "type": "boolean" + }, + "has_packages": { + "description": "True if the software packages are enabled.", + "type": "boolean" + }, + "has_kanban": { + "description": "True if the kanban is enabled.", + "type": "boolean" + }, + "has_pull_requests": { + "description": "True if pull requests are enabled.", + "type": "boolean" + }, + "has_releases": { + "description": "True if releases are enabled.", + "type": "boolean" + }, + "has_wiki": { + "description": "True if the wiki is enabled.", + "type": "boolean" + } + }, + "required": [ + "index", + "name", + "is_private", + "is_mirror", + "description", + "default_branch", + "repositories" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/project.json", + "$$target": "project.json" +} diff --git a/f3/schemas/pullrequest.json b/f3/schemas/pullrequest.json new file mode 100644 index 0000000..a83b2fe --- /dev/null +++ b/f3/schemas/pullrequest.json @@ -0,0 +1,134 @@ +{ + "title": "Pull request", + "description": "A pull requests to merge a commit from a 'head' that may be another branch in the same repository or a branch in a forked repository.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the pull request.", + "type": "string" + }, + "poster_id": { + "description": "Unique identifier of the user who authored the pull request.", + "type": "string" + }, + "title": { + "description": "Short description displayed as the title.", + "type": "string" + }, + "content": { + "description": "Long description.", + "type": "string" + }, + "milestone": { + "description": "Unique identifier of the milestone.", + "type": "string" + }, + "state": { + "description": "A 'closed' pull request will not see any activity in the future, otherwise it is 'open'.", + "enum": [ + "closed", + "open" + ] + }, + "is_locked": { + "description": "A locked pull request can only be modified by privileged users.", + "type": "boolean" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "closed": { + "description": "The last time 'state' changed to 'closed'.", + "type": "string", + "format": "date-time" + }, + "labels": { + "description": "List of labels unique identifiers.", + "type": "array", + "items": { + "type": "string" + } + }, + "reactions": { + "description": "List of reactions.", + "type": "array", + "items": { + "$ref": "reaction.json" + } + }, + "assignees": { + "description": "List of assignees.", + "type": "array", + "items": { + "description": "Name of a user assigned to the issue.", + "type": "string" + } + }, + "merged": { + "description": "True if the pull request was merged.", + "type": "boolean" + }, + "merged_time": { + "description": "The time when the pull request was merged.", + "type": "string", + "format": "date-time" + }, + "merged_commit_sha": { + "description": "The SHA of the merge commit.", + "type": "string" + }, + "head": { + "description": "The changes proposed in the pull request.", + "type": "object", + "items": { + "$ref": "pullrequestbranch.json" + } + }, + "base": { + "description": "The branch where the pull request changes in the head are to be merged.", + "type": "object", + "items": { + "$ref": "pullrequestbranch.json" + } + }, + "merged_by": { + "description": "Unique identifier of the user who merged the pull request.", + "type": "string" + }, + "due": { + "description": "Due date.", + "type": "string", + "format": "date" + }, + "allow_edit": { + "description": "True when the author of the pull request allows pushing new commits to its branch.", + "type": "boolean" + } + }, + "required": [ + "index", + "poster_id", + "title", + "content", + "state", + "is_locked", + "created", + "updated", + "merged", + "head", + "base" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/pullrequest.json", + "$$target": "pullrequest.json" +} diff --git a/f3/schemas/pullrequestbranch.json b/f3/schemas/pullrequestbranch.json new file mode 100644 index 0000000..c60bc4b --- /dev/null +++ b/f3/schemas/pullrequestbranch.json @@ -0,0 +1,30 @@ +{ + "title": "Pull request reference to a commit", + "description": "The location of a commit and the repository where it can be found.", + + "type": "object", + "additionalProperties": false, + "properties": { + "ref": { + "description": "Repository reference of the commit (branch, tag, etc.).", + "type": "string" + }, + "sha": { + "description": "SHA of the commit.", + "type": "string" + }, + "repository": { + "description": "Unique identifier of the repository.", + "type": "string" + } + }, + "required": [ + "ref", + "sha", + "repository" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/pullrequestbranch.json", + "$$target": "pullrequestbranch.json" +} diff --git a/f3/schemas/reaction.json b/f3/schemas/reaction.json new file mode 100644 index 0000000..0bb4621 --- /dev/null +++ b/f3/schemas/reaction.json @@ -0,0 +1,30 @@ +{ + "title": "Reaction", + "description": "Reaction associated to a comment that is displayed as a single emoji.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the reaction.", + "type": "string" + }, + "user_id": { + "description": "Unique identifier of the user who authored the reaction.", + "type": "string" + }, + "content": { + "description": "Representation of the reaction. The rendering of the reaction depends on the forge displaying it.", + "type": "string" + } + }, + "required": [ + "index", + "user_id", + "content" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/reaction.json", + "$$target": "reaction.json" +} diff --git a/f3/schemas/release.json b/f3/schemas/release.json new file mode 100644 index 0000000..ff38bf5 --- /dev/null +++ b/f3/schemas/release.json @@ -0,0 +1,60 @@ +{ + "title": "Release", + "description": "A release is associated with a tag in a repository and contains of a set of files (release assets).", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the release.", + "type": "string" + }, + "tag_name": { + "description": "Tag name of the release.", + "type": "string" + }, + "target_commitish": { + "description": "Specifies the commitish value that determines where the tag is created from. Can be any branch or commit SHA. Unused if the tag already exists.", + "type": "string" + }, + "name": { + "description": "The name of the release.", + "type": "string" + }, + "body": { + "description": "Text describing the contents of the release, usually the release notes.", + "type": "string" + }, + "draft": { + "description": "True if the release is a draft.", + "type": "boolean" + }, + "prerelease": { + "description": "True if the release is a pre-release.", + "type": "boolean" + }, + "publisher_id": { + "description": "Unique identifier of the user who authored the release.", + "type": "string" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "index", + "tag_name", + "name", + "body", + "draft", + "prerelease", + "publisher_id", + "created" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/release.json", + "$$target": "release.json" +} diff --git a/f3/schemas/releaseasset.json b/f3/schemas/releaseasset.json new file mode 100644 index 0000000..8cef5c6 --- /dev/null +++ b/f3/schemas/releaseasset.json @@ -0,0 +1,61 @@ +{ + "title": "Release asset", + "description": "A file associated with a release. The content of the file is opaque.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the release asset.", + "type": "string" + }, + "name": { + "description": "The name of the release asset.", + "type": "string" + }, + "content_type": { + "description": "The content type of the release asset (application/zip, etc.).", + "type": "string" + }, + "size": { + "description": "Size in bytes of the release asset.", + "type": "number" + }, + "download_count": { + "description": "The number of times the release asset was downloaded.", + "type": "number" + }, + "download_url": { + "description": "The URL from which the release asset can be downloaded.", + "type": "string" + }, + "created": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "sha256": { + "description": "SHA256 of the cnotent of the asset.", + "type": "string" + } + }, + "required": [ + "index", + "name", + "content_type", + "size", + "download_count", + "created", + "updated", + "sha256" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/releaseasset.json", + "$$target": "releaseasset.json" +} diff --git a/f3/schemas/repository.json b/f3/schemas/repository.json new file mode 100644 index 0000000..a2f3f58 --- /dev/null +++ b/f3/schemas/repository.json @@ -0,0 +1,31 @@ +{ + "title": "Repository", + "description": "VCS repository relative to a project. The actual content of the repository is found in the sibling 'repository' directory.", + + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "Unique name of the repository relative to the project (e.g. vcs or vcs.wiki).", + "type": "string" + }, + "vcs": { + "description": "The type of the repository, defaults to 'git'", + "enum": [ + "git", + "hg", + "bazaar", + "darcs", + "fossil", + "svn" + ] + } + }, + "required": [ + "name" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/repository.json", + "$$target": "repository.json" +} diff --git a/f3/schemas/review.json b/f3/schemas/review.json new file mode 100644 index 0000000..f9efa11 --- /dev/null +++ b/f3/schemas/review.json @@ -0,0 +1,63 @@ +{ + "title": "Review", + "description": "A set of review comments on a pull/merge request.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the review.", + "type": "string" + }, + "reviewer_id": { + "description": "Unique identifier of review author.", + "type": "string" + }, + "official": { + "description": "True if a positive review counts to reach the required threshold.", + "type": "boolean" + }, + "commit_id": { + "description": "SHA of the commit targeted by the review.", + "type": "string" + }, + "content": { + "description": "Cover message of the review.", + "type": "string" + }, + "created_at": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "state": { + "description": "State of the review.", + "enum": [ + "PENDING", + "APPROVED", + "CHANGES_REQUESTED", + "COMMENTED" + ] + }, + "dissmissed": { + "description": "True if the review was dismissed.", + "type": "boolean" + }, + "stale": { + "description": "True if the review is stale because the pull request content changed after it was published.", + "type": "boolean" + } + }, + "required": [ + "index", + "reviewer_id", + "commit_id", + "content", + "created_at", + "state" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/review.json", + "$$target": "review.json" +} diff --git a/f3/schemas/reviewcomment.json b/f3/schemas/reviewcomment.json new file mode 100644 index 0000000..8e4f4af --- /dev/null +++ b/f3/schemas/reviewcomment.json @@ -0,0 +1,77 @@ +{ + "title": "Review comment", + "description": "A comment in the context of a review.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the review comment.", + "type": "string" + }, + "content": { + "description": "The text of the review comment.", + "type": "string" + }, + "tree_path": { + "description": "The relative path to the file commented on.", + "type": "string" + }, + "diff_hunk": { + "description": "The hunk commented on.", + "type": "string" + }, + "line": { + "description": "The line number of the comment relative to the tree_path.", + "type": "number" + }, + "lines_count": { + "description": "The range of lines that are commented on. If absent it defaults to one and is a single line comment. If specified it must be a positive number. If line is N and lines_count is C, the range of lines commented on is ]N-C,N]. In other words, the range starts lines_count before line, which is the last of the range", + "type": "number" + }, + "commit_id": { + "description": "The SHA of the tree_path commented on.", + "type": "string" + }, + "poster_id": { + "description": "Unique identifier of the user who authored the comment.", + "type": "string" + }, + "reactions": { + "description": "List of reactions.", + "type": "array", + "items": { + "$ref": "reaction.json" + } + }, + "created_at": { + "description": "Creation time.", + "type": "string", + "format": "date-time" + }, + "updated_at": { + "description": "Last update time.", + "type": "string", + "format": "date-time" + }, + "resolver": { + "description": "Unique identifier of the user who resolved the comment.", + "type": "string" + } + }, + "required": [ + "index", + "content", + "tree_path", + "diff_hunk", + "line", + "commit_id", + "poster_id", + "created_at", + "updated_at" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/reviewcomment.json", + "$$target": "reviewcomment.json" +} diff --git a/f3/schemas/topic.json b/f3/schemas/topic.json new file mode 100644 index 0000000..ad82a8a --- /dev/null +++ b/f3/schemas/topic.json @@ -0,0 +1,25 @@ +{ + "title": "Topic", + "description": "A category associated with a project. There can be multiple topics/categories for a given project.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier.", + "type": "string" + }, + "name": { + "description": "The name of the category the project belongs to.", + "type": "string" + } + }, + "required": [ + "index", + "name" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/topic.json", + "$$target": "topic.json" +} diff --git a/f3/schemas/user.json b/f3/schemas/user.json new file mode 100644 index 0000000..b7f1bd9 --- /dev/null +++ b/f3/schemas/user.json @@ -0,0 +1,42 @@ +{ + "title": "User", + "description": "A forge user.", + + "type": "object", + "additionalProperties": false, + "properties": { + "index": { + "description": "Unique identifier of the user.", + "type": "string" + }, + "name": { + "description": "User readable name of the user.", + "type": "string" + }, + "email": { + "description": "Mail of the user.", + "type": "string" + }, + "username": { + "description": "Unique name of the user.", + "type": "string" + }, + "password": { + "description": "Password of the user.", + "type": "string" + }, + "admin": { + "description": "True if the user has administrative permissions on the forge.", + "type": "boolean" + } + }, + "required": [ + "index", + "name", + "username" + ], + + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://code.forgejo.org/f3/f3-schemas/src/branch/main/user.json", + "$$target": "user.json" +} diff --git a/f3/topic.go b/f3/topic.go new file mode 100644 index 0000000..3117fa3 --- /dev/null +++ b/f3/topic.go @@ -0,0 +1,21 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type Topic struct { + Common + Name string `json:"name"` +} + +func (o Topic) Equal(other Topic) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name +} + +func (o *Topic) Clone() Interface { + clone := &Topic{} + *clone = *o + return clone +} diff --git a/f3/user.go b/f3/user.go new file mode 100644 index 0000000..fecb0fe --- /dev/null +++ b/f3/user.go @@ -0,0 +1,32 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +type User struct { + Common + Name string `json:"name"` + Email string `json:"email"` + UserName string `json:"username"` + Password string `json:"password"` + IsAdmin bool `json:"admin"` +} + +func (o User) Equal(other User) bool { + return o.Common.Equal(other.Common) && + o.Name == other.Name && + o.Email == other.Email && + o.UserName == other.UserName && + o.IsAdmin == other.IsAdmin +} + +func (o *User) GetName() string { + return o.UserName +} + +func (o *User) Clone() Interface { + clone := &User{} + *clone = *o + return clone +} diff --git a/forges/filesystem/asset.go b/forges/filesystem/asset.go new file mode 100644 index 0000000..b0152c3 --- /dev/null +++ b/forges/filesystem/asset.go @@ -0,0 +1,85 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "context" + "io" + "os" + "path/filepath" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type assetDriver struct { + nodeDriver +} + +func newAssetDriver(content f3.Interface) generic.NodeDriverInterface { + n := newNodeDriver(content).(*nodeDriver) + a := &assetDriver{ + nodeDriver: *n, + } + return a +} + +func (o *assetDriver) getPath() string { + f := o.nodeDriver.content.(*f3.ReleaseAsset) + options := o.GetTreeDriver().(*treeDriver).options + return filepath.Join(options.Directory, "objects", f.SHA256[0:2], f.SHA256[2:4], f.SHA256) +} + +func (o *assetDriver) save(ctx context.Context) { + assetFormat := o.nodeDriver.content.(*f3.ReleaseAsset) + objectsHelper := o.getF3Tree().GetObjectsHelper() + sha, tmpPath := objectsHelper.Save(assetFormat.DownloadFunc()) + assetFormat.SHA256 = sha + path := o.getPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + panic(err) + } + if err := os.Rename(tmpPath, path); err != nil { + panic(err) + } + o.GetNode().Trace("%s %s", assetFormat.SHA256, path) +} + +func (o *assetDriver) setDownloadFunc() { + f := o.nodeDriver.content.(*f3.ReleaseAsset) + f.DownloadFunc = func() io.ReadCloser { + f, err := os.Open(o.getPath()) + if err != nil { + panic(err) + } + return f + } +} + +func (o *assetDriver) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *assetDriver) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *assetDriver) upsert(ctx context.Context) id.NodeID { + assetFormat := o.nodeDriver.content.(*f3.ReleaseAsset) + if assetFormat.SHA256 != "" { + o.save(ctx) + } + o.setDownloadFunc() + return o.nodeDriver.upsert(ctx) +} + +func (o *assetDriver) Get(ctx context.Context) bool { + if o.nodeDriver.Get(ctx) { + o.setDownloadFunc() + return true + } + return false +} diff --git a/forges/filesystem/json.go b/forges/filesystem/json.go new file mode 100644 index 0000000..8573bdc --- /dev/null +++ b/forges/filesystem/json.go @@ -0,0 +1,37 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "encoding/json" + "fmt" + "os" +) + +func loadJSON(filename string, data any) { + bs, err := os.ReadFile(filename) + if err != nil { + panic(fmt.Errorf("ReadFile %w", err)) + } + + if err := json.Unmarshal(bs, data); err != nil { + panic(fmt.Errorf("Unmarshal %s %s %w", filename, string(bs), err)) + } +} + +func saveJSON(filename string, data any) { + f, err := os.Create(filename) + if err != nil { + panic(fmt.Errorf("Create %w", err)) + } + defer f.Close() + bs, err := json.MarshalIndent(data, "", " ") + if err != nil { + panic(fmt.Errorf("MarshalIndent %w", err)) + } + if _, err := f.Write(bs); err != nil { + panic(fmt.Errorf("Write %w", err)) + } +} diff --git a/forges/filesystem/main.go b/forges/filesystem/main.go new file mode 100644 index 0000000..24f532b --- /dev/null +++ b/forges/filesystem/main.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/options" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" +) + +func init() { + f3_tree.RegisterForgeFactory(filesystem_options.Name, newTreeDriver) + options.RegisterFactory(filesystem_options.Name, newOptions) +} diff --git a/forges/filesystem/node.go b/forges/filesystem/node.go new file mode 100644 index 0000000..ab15d02 --- /dev/null +++ b/forges/filesystem/node.go @@ -0,0 +1,249 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +type nodeDriver struct { + generic.NullDriver + + content f3.Interface +} + +func newNodeDriver(content f3.Interface) generic.NodeDriverInterface { + return &nodeDriver{ + content: content.Clone(), + } +} + +func (o *nodeDriver) SetNative(any) {} + +func (o *nodeDriver) GetNativeID() string { + return o.GetNode().GetID().String() +} + +func (o *nodeDriver) getBasePath() string { + options := o.GetTreeDriver().(*treeDriver).options + return options.Directory + o.GetNode().GetCurrentPath().String() +} + +func (o *nodeDriver) getTree() generic.TreeInterface { + return o.GetNode().GetTree() +} + +func (o *nodeDriver) getF3Tree() f3_tree.TreeInterface { + return o.getTree().(f3_tree.TreeInterface) +} + +func (o *nodeDriver) isContainer() bool { + return o.getF3Tree().IsContainer(o.getKind()) +} + +func (o *nodeDriver) getKind() kind.Kind { + return o.GetNode().GetKind() +} + +func (o *nodeDriver) IsNull() bool { return false } + +func (o *nodeDriver) GetIDFromName(ctx context.Context, name string) id.NodeID { + switch o.getKind() { + case kind.KindRoot, f3_tree.KindProjects, f3_tree.KindUsers, f3_tree.KindOrganizations, f3_tree.KindRepositories: + default: + panic(fmt.Errorf("unxpected kind %s", o.getKind())) + } + for _, child := range o.GetNode().List(ctx) { + child.Get(ctx) + if child.ToFormat().GetName() == name { + return child.GetID() + } + } + return id.NilID +} + +func (o *nodeDriver) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + node := o.GetNode() + node.Trace("%s '%s'", o.getKind(), node.GetID()) + children := generic.NewChildrenSlice(0) + + if o.getKind() == kind.KindRoot || page > 1 { + return children + } + basePath := o.getBasePath() + if !util.FileExists(basePath) { + return children + } + + f3Tree := o.getF3Tree() + if !f3Tree.IsContainer(o.getKind()) { + return children + } + + node.Trace("%d '%s'", page, basePath) + + dirEntries, err := os.ReadDir(basePath) + if err != nil { + panic(fmt.Errorf("ReadDir %s %w", basePath, err)) + } + + for _, dirEntry := range dirEntries { + if !strings.HasSuffix(dirEntry.Name(), ".json") { + continue + } + node.Trace(" add %s", dirEntry.Name()) + child := node.CreateChild(ctx) + i := strings.TrimSuffix(dirEntry.Name(), ".json") + childID := id.NewNodeID(i) + child.SetID(childID) + children = append(children, child) + } + + return children +} + +func (o *nodeDriver) Equals(context.Context, generic.NodeInterface) bool { panic("") } + +func (o *nodeDriver) LookupMappedID(id id.NodeID) id.NodeID { + o.GetNode().Trace("%s", id) + return id +} + +func (o *nodeDriver) hasJSON() bool { + kind := o.getKind() + if kind == f3_tree.KindForge { + return true + } + return !o.isContainer() +} + +func (o *nodeDriver) Get(context.Context) bool { + o.GetNode().Trace("'%s' '%s'", o.getKind(), o.GetNode().GetID()) + if !o.hasJSON() || o.GetNode().GetID() == id.NilID { + return true + } + filename := o.getBasePath() + ".json" + o.GetNode().Trace("'%s'", filename) + if !util.FileExists(filename) { + return false + } + f := o.NewFormat() + loadJSON(filename, f) + o.content = f + o.GetNode().Trace("%s %s id=%s", o.getKind(), filename, o.content.GetID()) + + idFilename := o.getBasePath() + ".id" + if !util.FileExists(idFilename) { + return true + } + mappedID, err := os.ReadFile(idFilename) + if err != nil { + panic(fmt.Errorf("Get %s %w", idFilename, err)) + } + o.NullDriver.SetMappedID(id.NewNodeID(string(mappedID))) + return true +} + +func (o *nodeDriver) SetMappedID(mapped id.NodeID) { + o.NullDriver.SetMappedID(mapped) + o.saveMappedID() +} + +func (o *nodeDriver) saveMappedID() { + k := o.getKind() + switch k { + case kind.KindRoot, f3_tree.KindForge: + return + } + if o.isContainer() { + return + } + mappedID := o.GetMappedID() + if mappedID == id.NilID { + return + } + basePath := o.getBasePath() + idFilename := basePath + ".id" + o.Trace("%s", idFilename) + if err := os.WriteFile(idFilename, []byte(o.GetMappedID().String()), 0o644); err != nil { + panic(fmt.Errorf("%s %w", idFilename, err)) + } +} + +func (o *nodeDriver) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *nodeDriver) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *nodeDriver) upsert(context.Context) id.NodeID { + i := o.GetNode().GetID() + o.GetNode().Trace("%s %s", o.getKind(), i) + o.content.SetID(i.String()) + if !o.hasJSON() || i == id.NilID { + return i + } + basePath := o.getBasePath() + dirname := filepath.Dir(basePath) + if !util.FileExists(dirname) { + if err := os.MkdirAll(dirname, 0o777); err != nil { + panic(fmt.Errorf("MakeDirAll %s %w", dirname, err)) + } + } + saveJSON(basePath+".json", o.content) + o.saveMappedID() + return i +} + +func (o *nodeDriver) Delete(context.Context) { + if o.isContainer() { + return + } + basePath := o.getBasePath() + if util.FileExists(basePath) { + if err := os.RemoveAll(basePath); err != nil { + panic(fmt.Errorf("RemoveAll %s %w", basePath, err)) + } + } + + for _, ext := range []string{".id", ".json"} { + jsonFilename := basePath + ext + if util.FileExists(jsonFilename) { + if err := os.Remove(jsonFilename); err != nil { + panic(fmt.Errorf("RemoveAll %s %w", basePath, err)) + } + } + } + o.content = o.NewFormat() +} + +func (o *nodeDriver) NewFormat() f3.Interface { + return o.getTree().(f3_tree.TreeInterface).NewFormat(o.getKind()) +} + +func (o *nodeDriver) FromFormat(content f3.Interface) { + o.content = content +} + +func (o *nodeDriver) ToFormat() f3.Interface { + return o.content.Clone() +} + +func (o *nodeDriver) String() string { + return o.content.GetID() +} diff --git a/forges/filesystem/options.go b/forges/filesystem/options.go new file mode 100644 index 0000000..3646879 --- /dev/null +++ b/forges/filesystem/options.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/options" +) + +func newOptions() options.Interface { + o := &filesystem_options.Options{} + o.SetName(filesystem_options.Name) + return o +} diff --git a/forges/filesystem/options/name.go b/forges/filesystem/options/name.go new file mode 100644 index 0000000..3c0ff27 --- /dev/null +++ b/forges/filesystem/options/name.go @@ -0,0 +1,7 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +const Name = "filesystem" diff --git a/forges/filesystem/options/options.go b/forges/filesystem/options/options.go new file mode 100644 index 0000000..ef317f4 --- /dev/null +++ b/forges/filesystem/options/options.go @@ -0,0 +1,60 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/options/logger" + + "github.com/urfave/cli/v3" +) + +type Options struct { + options.Options + logger.OptionsLogger + + Validation bool + Directory string +} + +func (o *Options) GetURL() string { return o.Directory } +func (o *Options) SetURL(url string) { o.Directory = url } + +func (o *Options) FromFlags(ctx context.Context, c *cli.Command, prefix string) { + o.Directory = c.String(forgeDirectoryOption(prefix)) + if o.Directory == "" { + panic(fmt.Errorf("--%s is required", forgeDirectoryOption(prefix))) + } + o.Validation = c.Bool(forgeValidationOption(prefix)) +} + +func forgeValidationOption(direction string) string { + return direction + "-validation" +} + +func forgeDirectoryOption(direction string) string { + return direction + "-directory" +} + +func (o *Options) GetFlags(prefix, category string) []cli.Flag { + flags := make([]cli.Flag, 0, 10) + + flags = append(flags, &cli.BoolFlag{ + Name: forgeValidationOption(prefix), + Usage: "validate the JSON files against F3 JSON schemas", + Category: prefix, + }) + + flags = append(flags, &cli.StringFlag{ + Name: forgeDirectoryOption(prefix), + Usage: "path to the F3 archive", + Category: prefix, + }) + + return flags +} diff --git a/forges/filesystem/pullrequest.go b/forges/filesystem/pullrequest.go new file mode 100644 index 0000000..cde8130 --- /dev/null +++ b/forges/filesystem/pullrequest.go @@ -0,0 +1,68 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + helper_pullrequest "code.forgejo.org/f3/gof3/v3/forges/helpers/pullrequest" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type pullRequestDriver struct { + nodeDriver + h helper_pullrequest.Interface +} + +func newPullRequestDriver(ctx context.Context, content f3.Interface) generic.NodeDriverInterface { + n := newNodeDriver(content).(*nodeDriver) + r := &pullRequestDriver{ + nodeDriver: *n, + } + r.h = helper_pullrequest.NewHelper(ctx, r) + return r +} + +func (o *pullRequestDriver) GetPullRequestPushRefs() []string { + return []string{fmt.Sprintf("refs/f3/%s/head", o.GetNativeID())} +} + +func (o *pullRequestDriver) GetPullRequestRef() string { + return fmt.Sprintf("refs/f3/%s/head", o.GetNativeID()) +} + +func (o *pullRequestDriver) GetPullRequestHead() string { + f := o.nodeDriver.content.(*f3.PullRequest) + return f.Head.Ref +} + +func (o *pullRequestDriver) SetFetchFunc(fetchFunc func(ctx context.Context, url, ref string)) { + f := o.nodeDriver.content.(*f3.PullRequest) + f.FetchFunc = fetchFunc +} + +func (o *pullRequestDriver) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *pullRequestDriver) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *pullRequestDriver) upsert(ctx context.Context) id.NodeID { + o.nodeDriver.upsert(ctx) + return o.h.Upsert(ctx) +} + +func (o *pullRequestDriver) Get(ctx context.Context) bool { + if o.nodeDriver.Get(ctx) { + o.h.Get(ctx) + return true + } + return false +} diff --git a/forges/filesystem/repository.go b/forges/filesystem/repository.go new file mode 100644 index 0000000..d154412 --- /dev/null +++ b/forges/filesystem/repository.go @@ -0,0 +1,97 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "context" + "os" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +type repositoryDriver struct { + nodeDriver + h helpers_repository.Interface +} + +func newRepositoryDriver(content f3.Interface) generic.NodeDriverInterface { + n := newNodeDriver(content).(*nodeDriver) + r := &repositoryDriver{ + nodeDriver: *n, + } + r.h = helpers_repository.NewHelper(r) + return r +} + +func (o *repositoryDriver) ToFormat() f3.Interface { + from := o.GetRepositoryURL() + o.Trace("%s", from) + f := o.nodeDriver.ToFormat().(*f3.Repository) + f.FetchFunc = func(ctx context.Context, destination string, internalRefs []string) { + o.Trace("git clone %s %s", from, destination) + helpers_repository.GitMirror(ctx, o, from, destination, internalRefs) + } + return f +} + +func (o *repositoryDriver) GetHelper() any { + return o.h +} + +func (o *repositoryDriver) SetFetchFunc(fetchFunc func(ctx context.Context, destination string, internalRefs []string)) { + f := o.nodeDriver.content.(*f3.Repository) + f.FetchFunc = fetchFunc +} + +func (o *repositoryDriver) GetRepositoryURL() string { + return o.getBasePath() +} + +func (o *repositoryDriver) GetRepositoryPushURL() string { + return o.getBasePath() +} + +func (o *repositoryDriver) GetRepositoryInternalRefs() []string { + return []string{} +} + +func (o *repositoryDriver) ensureRepository(ctx context.Context, directory string) { + if !util.FileExists(directory) { + if err := os.MkdirAll(directory, 0o700); err != nil { + panic(err) + } + util.Command(ctx, o, "git", "-C", directory, "init", "--bare") + } +} + +func (o *repositoryDriver) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *repositoryDriver) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *repositoryDriver) upsert(ctx context.Context) id.NodeID { + o.nodeDriver.upsert(ctx) + repoDir := o.GetRepositoryPushURL() + o.GetNode().Trace("%s %s", repoDir, o.GetNode().GetID()) + o.ensureRepository(ctx, repoDir) + return o.h.Upsert(ctx, o.content.(*f3.Repository)) +} + +func (o *repositoryDriver) Get(ctx context.Context) bool { + o.nodeDriver.Get(ctx) + f := o.nodeDriver.content.(*f3.Repository) + f.SetID(o.GetNode().GetID().String()) + repoDir := o.GetRepositoryURL() + o.GetNode().Trace("%s", repoDir) + o.h.Get(ctx) + return true +} diff --git a/forges/filesystem/tests/helpers.go b/forges/filesystem/tests/helpers.go new file mode 100644 index 0000000..5b010a7 --- /dev/null +++ b/forges/filesystem/tests/helpers.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" +) + +func newTestOptions(t *testing.T) options.Interface { + t.Helper() + o := options.GetFactory(filesystem_options.Name)().(*filesystem_options.Options) + o.Directory = t.TempDir() + l := logger.NewLogger() + l.SetLevel(logger.Trace) + o.OptionsLogger.SetLogger(l) + return o +} diff --git a/forges/filesystem/tests/init.go b/forges/filesystem/tests/init.go new file mode 100644 index 0000000..b198fab --- /dev/null +++ b/forges/filesystem/tests/init.go @@ -0,0 +1,14 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +func init() { + tests_forge.RegisterFactory(filesystem_options.Name, newForgeTest) +} diff --git a/forges/filesystem/tests/new.go b/forges/filesystem/tests/new.go new file mode 100644 index 0000000..06fd992 --- /dev/null +++ b/forges/filesystem/tests/new.go @@ -0,0 +1,27 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/options" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +type forgeTest struct { + tests_forge.Base +} + +func (o *forgeTest) NewOptions(t *testing.T) options.Interface { + return newTestOptions(t) +} + +func newForgeTest() tests_forge.Interface { + t := &forgeTest{} + t.SetName(filesystem_options.Name) + return t +} diff --git a/forges/filesystem/tree.go b/forges/filesystem/tree.go new file mode 100644 index 0000000..42427d6 --- /dev/null +++ b/forges/filesystem/tree.go @@ -0,0 +1,45 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package filesystem + +import ( + "context" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/kind" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type treeDriver struct { + generic.NullTreeDriver + + options *filesystem_options.Options +} + +func newTreeDriver(tree generic.TreeInterface, anyOptions any) generic.TreeDriverInterface { + driver := &treeDriver{ + options: anyOptions.(*filesystem_options.Options), + } + driver.SetTree(tree) + driver.Init() + return driver +} + +func (o *treeDriver) AllocateID() bool { return false } + +func (o *treeDriver) Factory(ctx context.Context, k kind.Kind) generic.NodeDriverInterface { + content := o.GetTree().(f3_tree.TreeInterface).NewFormat(k) + switch k { + case f3_tree.KindRepository: + return newRepositoryDriver(content) + case f3_tree.KindAsset: + return newAssetDriver(content) + case f3_tree.KindPullRequest: + return newPullRequestDriver(ctx, content) + default: + return newNodeDriver(content) + } +} diff --git a/forges/forgejo/asset.go b/forges/forgejo/asset.go new file mode 100644 index 0000000..6aabfb1 --- /dev/null +++ b/forges/forgejo/asset.go @@ -0,0 +1,178 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type asset struct { + common + + forgejoAsset *forgejo_sdk.Attachment + sha string + contentType string + downloadFunc f3.DownloadFuncType +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newAsset() generic.NodeDriverInterface { + return &asset{} +} + +func (o *asset) SetNative(asset any) { + o.forgejoAsset = asset.(*forgejo_sdk.Attachment) +} + +func (o *asset) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoAsset.ID) +} + +func (o *asset) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *asset) ToFormat() f3.Interface { + if o.forgejoAsset == nil { + return o.NewFormat() + } + + if o.sha == "" { + // the API does not provide the SHA256, save the asset in a temporary file + // to get it + objectsHelper := o.getF3Tree().GetObjectsHelper() + + o.Trace("download from %s", o.forgejoAsset.DownloadURL) + req, err := http.NewRequest("GET", o.forgejoAsset.DownloadURL, nil) + if err != nil { + panic(err) + } + httpClient := o.getNewMigrationHTTPClient()() + resp, err := httpClient.Do(req) + if err != nil { + panic(fmt.Errorf("while downloading %s %w", o.forgejoAsset.DownloadURL, err)) + } + + sha, path := objectsHelper.Save(resp.Body) + o.sha = sha + + o.downloadFunc = func() io.ReadCloser { + o.Trace("download %s from copy stored in temporary file %s", o.forgejoAsset.DownloadURL, path) + f, err := os.Open(path) + if err != nil { + panic(err) + } + return f + } + } + + return &f3.ReleaseAsset{ + Common: f3.NewCommon(o.GetNativeID()), + Name: o.forgejoAsset.Name, + Size: o.forgejoAsset.Size, + ContentType: o.contentType, + DownloadCount: o.forgejoAsset.DownloadCount, + SHA256: o.sha, + DownloadURL: o.forgejoAsset.DownloadURL, + Created: o.forgejoAsset.Created, + DownloadFunc: o.downloadFunc, + } +} + +func (o *asset) FromFormat(content f3.Interface) { + asset := content.(*f3.ReleaseAsset) + o.forgejoAsset = &forgejo_sdk.Attachment{ + ID: util.ParseInt(asset.GetID()), + Name: asset.Name, + Size: asset.Size, + DownloadCount: asset.DownloadCount, + Created: asset.Created, + DownloadURL: asset.DownloadURL, + } + + o.sha = asset.SHA256 + o.contentType = asset.ContentType + o.downloadFunc = asset.DownloadFunc +} + +func (o *asset) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + release := f3_tree.GetReleaseID(o.GetNode()) + + asset, resp, err := o.getClient().GetReleaseAttachment(owner, project, release, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("asset %v %w", o, err)) + } + o.forgejoAsset = asset + return true +} + +func (o *asset) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + release := f3_tree.GetReleaseID(o.GetNode()) + + asset, _, err := o.getClient().CreateReleaseAttachment(owner, project, release, o.downloadFunc(), o.forgejoAsset.Name) + if err != nil { + panic(err) + } + o.forgejoAsset = asset + o.sha = "" + return id.NewNodeID(o.GetNativeID()) +} + +func (o *asset) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + release := f3_tree.GetReleaseID(o.GetNode()) + + _, _, err := o.getClient().EditReleaseAttachment(owner, project, release, node.GetID().Int64(), forgejo_sdk.EditAttachmentOptions{ + Name: o.forgejoAsset.Name, + }) + if err != nil { + panic(err) + } +} + +func (o *asset) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + release := f3_tree.GetReleaseID(o.GetNode()) + + _, err := o.getClient().DeleteReleaseAttachment(owner, project, release, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/assets.go b/forges/forgejo/assets.go new file mode 100644 index 0000000..951a33b --- /dev/null +++ b/forges/forgejo/assets.go @@ -0,0 +1,43 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type assets struct { + container +} + +func (o *assets) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoAssets []*forgejo_sdk.Attachment + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + release := f3_tree.GetReleaseID(o.GetNode()) + + forgejoAssets, _, err = o.getClient().ListReleaseAttachments(owner, project, release, forgejo_sdk.ListReleaseAttachmentsOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing release attachments: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoAssets...)...) +} + +func newAssets() generic.NodeDriverInterface { + return &assets{} +} diff --git a/forges/forgejo/comment.go b/forges/forgejo/comment.go new file mode 100644 index 0000000..2d52d1e --- /dev/null +++ b/forges/forgejo/comment.go @@ -0,0 +1,140 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type comment struct { + common + + forgejoComment *forgejo_sdk.Comment +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newComment() generic.NodeDriverInterface { + return &comment{} +} + +func (o *comment) SetNative(comment any) { + o.forgejoComment = comment.(*forgejo_sdk.Comment) +} + +func (o *comment) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoComment.ID) +} + +func (o *comment) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *comment) ToFormat() f3.Interface { + if o.forgejoComment == nil { + return o.NewFormat() + } + + return &f3.Comment{ + Common: f3.NewCommon(o.GetNativeID()), + PosterID: f3_tree.NewUserReference(o.forgejoComment.Poster.ID), + Content: o.forgejoComment.Body, + Created: o.forgejoComment.Created, + Updated: o.forgejoComment.Updated, + } +} + +func (o *comment) FromFormat(content f3.Interface) { + comment := content.(*f3.Comment) + o.forgejoComment = &forgejo_sdk.Comment{ + ID: util.ParseInt(comment.GetID()), + Poster: &forgejo_sdk.User{ + ID: comment.PosterID.GetIDAsInt(), + }, + Body: comment.Content, + Created: comment.Created, + Updated: comment.Updated, + } +} + +func (o *comment) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + comment, resp, err := o.getClient().GetIssueComment(owner, project, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("comment %v %w", o, err)) + } + o.forgejoComment = comment + return true +} + +func (o *comment) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + commentable := f3_tree.GetCommentableID(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoComment.Poster.ID) + defer o.notSudo() + + comment, _, err := o.getClient().CreateIssueComment(owner, project, commentable, forgejo_sdk.CreateIssueCommentOption{ + Body: o.forgejoComment.Body, + }) + if err != nil { + panic(err) + } + o.forgejoComment = comment + return id.NewNodeID(o.GetNativeID()) +} + +func (o *comment) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, _, err := o.getClient().EditIssueComment(owner, project, node.GetID().Int64(), forgejo_sdk.EditIssueCommentOption{ + Body: o.forgejoComment.Body, + }) + if err != nil { + panic(err) + } +} + +func (o *comment) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + resp, err := o.getClient().DeleteIssueComment(owner, project, node.GetID().Int64()) + if resp.StatusCode != 204 { + panic(fmt.Errorf("unexpected status code deleting %s %d %v", node.GetID().String(), resp.StatusCode, resp)) + } + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/comments.go b/forges/forgejo/comments.go new file mode 100644 index 0000000..d8b98f4 --- /dev/null +++ b/forges/forgejo/comments.go @@ -0,0 +1,44 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type comments struct { + container +} + +func (o *comments) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + children := generic.NewChildrenSlice(0) + if page > 1 { + return children + } + + var err error + var forgejoComments []*forgejo_sdk.Comment + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + commentable := f3_tree.GetCommentableID(o.GetNode()) + + forgejoComments, _, err = o.getClient().ListIssueComments(owner, project, commentable, forgejo_sdk.ListIssueCommentOptions{}) + if err != nil { + panic(fmt.Errorf("error while listing comments: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoComments...)...) +} + +func newComments() generic.NodeDriverInterface { + return &comments{} +} diff --git a/forges/forgejo/common.go b/forges/forgejo/common.go new file mode 100644 index 0000000..1331009 --- /dev/null +++ b/forges/forgejo/common.go @@ -0,0 +1,120 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + options_http "code.forgejo.org/f3/gof3/v3/options/http" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" + "github.com/hashicorp/go-version" +) + +func maybeMilestoneID(milestone *forgejo_sdk.Milestone) *int64 { + if milestone != nil { + id := milestone.ID + return &id + } + return nil +} + +func labelListToIDs(labels []*forgejo_sdk.Label) []int64 { + ids := make([]int64, 0, len(labels)) + for _, label := range labels { + ids = append(ids, label.ID) + } + return ids +} + +type common struct { + generic.NullDriver +} + +func (o *common) GetHelper() any { + panic("not implemented") +} + +func (o *common) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + return generic.NewChildrenSlice(0) +} + +func (o *common) getTree() generic.TreeInterface { + return o.GetNode().GetTree() +} + +func (o *common) getForge() *forge { + return o.getTree().GetRoot().GetChild(id.NewNodeID(f3_tree.KindForge)).GetDriver().(*forge) +} + +func (o *common) getPageSize() int { + return o.getTreeDriver().GetPageSize() +} + +func (o *common) getF3Tree() f3_tree.TreeInterface { + return o.getTree().(f3_tree.TreeInterface) +} + +func (o *common) getKind() kind.Kind { + return o.GetNode().GetKind() +} + +func (o *common) getChildDriver(kind kind.Kind) generic.NodeDriverInterface { + return o.GetNode().GetChild(id.NewNodeID(kind)).GetDriver() +} + +func (o *common) getProject() *project { + return f3_tree.GetProject(o.GetNode()).GetDriver().(*project) +} + +func (o *common) isContainer() bool { + return o.getF3Tree().IsContainer(o.getKind()) +} + +func (o *common) getURL() string { + return o.getTreeDriver().options.GetURL() +} + +func (o *common) getPushURL() string { + return o.getTreeDriver().options.GetPushURL() +} + +func (o *common) getNewMigrationHTTPClient() options_http.NewMigrationHTTPClientFun { + return o.getTreeDriver().options.GetNewMigrationHTTPClient() +} + +func (o *common) getTreeDriver() *treeDriver { + return o.GetTreeDriver().(*treeDriver) +} + +func (o *common) getIsAdmin() bool { + return o.getTreeDriver().GetIsAdmin() +} + +func (o *common) getClient() *forgejo_sdk.Client { + return o.getTreeDriver().GetClient() +} + +func (o *common) maybeSudoName(name string) { + o.getTreeDriver().MaybeSudoName(name) +} + +func (o *common) maybeSudoID(ctx context.Context, id int64) { + o.getTreeDriver().maybeSudoID(ctx, id) +} + +func (o *common) notSudo() { + o.getTreeDriver().NotSudo() +} + +func (o *common) getVersion() *version.Version { + return o.getTreeDriver().GetVersion() +} + +func (o *common) IsNull() bool { return false } diff --git a/forges/forgejo/container.go b/forges/forgejo/container.go new file mode 100644 index 0000000..83812dc --- /dev/null +++ b/forges/forgejo/container.go @@ -0,0 +1,43 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" +) + +type container struct { + common +} + +func (o *container) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *container) ToFormat() f3.Interface { + return o.NewFormat() +} + +func (o *container) FromFormat(content f3.Interface) { +} + +func (o *container) Get(context.Context) bool { return true } + +func (o *container) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *container) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *container) upsert(context.Context) id.NodeID { + return id.NewNodeID(o.getKind()) +} diff --git a/forges/forgejo/forge.go b/forges/forgejo/forge.go new file mode 100644 index 0000000..16243d4 --- /dev/null +++ b/forges/forgejo/forge.go @@ -0,0 +1,92 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +type ownerInfo struct { + kind kind.Kind + name string +} + +type forge struct { + common + + ownersInfo map[string]ownerInfo +} + +func newForge() generic.NodeDriverInterface { + return &forge{ + ownersInfo: make(map[string]ownerInfo), + } +} + +func (o *forge) getOwnersKind(ctx context.Context, id string) kind.Kind { + return o.getOwnersInfo(ctx, id).kind +} + +func (o *forge) getOwnersName(ctx context.Context, id string) string { + return o.getOwnersInfo(ctx, id).name +} + +func (o *forge) getOwnersInfo(ctx context.Context, id string) ownerInfo { + info, ok := o.ownersInfo[id] + if !ok { + user, _, err := o.getClient().GetUserByID(util.ParseInt(id)) + if err != nil { + if strings.Contains(err.Error(), "user not found") { + organizations := o.getChildDriver(f3_tree.KindOrganizations).(*organizations) + info.name = organizations.getNameFromID(ctx, util.ParseInt(id)) + if info.name != "" { + info.kind = f3_tree.KindOrganizations + } else { + panic(fmt.Errorf("%v does not match any user or organization", id)) + } + } else { + panic(fmt.Errorf("GetUserByID(%s) failed %w", id, err)) + } + } else { + info.kind = f3_tree.KindUsers + info.name = user.UserName + } + o.ownersInfo[id] = info + } + return info +} + +func (o *forge) getOwnersPath(ctx context.Context, id string) f3_tree.Path { + return f3_tree.NewPathFromString("/").SetForge().SetOwners(o.getOwnersKind(ctx, id)) +} + +func (o *forge) Equals(context.Context, generic.NodeInterface) bool { return true } +func (o *forge) Get(context.Context) bool { return true } +func (o *forge) Put(context.Context) id.NodeID { return id.NewNodeID("forge") } +func (o *forge) Patch(context.Context) {} +func (o *forge) Delete(context.Context) {} +func (o *forge) NewFormat() f3.Interface { return &f3.Forge{} } +func (o *forge) FromFormat(f3.Interface) {} + +func (o *forge) ToFormat() f3.Interface { + return &f3.Forge{ + Common: f3.NewCommon("forge"), + URL: o.String(), + } +} + +func (o *forge) String() string { + options := o.GetTreeDriver().(*treeDriver).options + return options.ForgeAuth.GetURL() +} diff --git a/forges/forgejo/issue.go b/forges/forgejo/issue.go new file mode 100644 index 0000000..612a737 --- /dev/null +++ b/forges/forgejo/issue.go @@ -0,0 +1,211 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type issue struct { + common + + forgejoIssue *forgejo_sdk.Issue +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newIssue() generic.NodeDriverInterface { + return &issue{} +} + +func (o *issue) SetNative(issue any) { + o.forgejoIssue = issue.(*forgejo_sdk.Issue) +} + +func (o *issue) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoIssue.Index) +} + +func (o *issue) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *issue) ToFormat() f3.Interface { + if o.forgejoIssue == nil { + return o.NewFormat() + } + + milestone := &f3.Reference{} + if o.forgejoIssue.Milestone != nil { + milestone = f3_tree.NewIssueMilestoneReference(o.forgejoIssue.Milestone.ID) + } + + assignees := make([]*f3.Reference, 0, len(o.forgejoIssue.Assignees)) + for _, assignee := range o.forgejoIssue.Assignees { + assignees = append(assignees, f3_tree.NewUserReference(assignee.ID)) + } + + labels := make([]*f3.Reference, 0, len(o.forgejoIssue.Labels)) + for _, label := range o.forgejoIssue.Labels { + labels = append(labels, f3_tree.NewIssueLabelReference(label.ID)) + } + + return &f3.Issue{ + Title: o.forgejoIssue.Title, + Common: f3.NewCommon(o.GetNativeID()), + PosterID: f3_tree.NewUserReference(o.forgejoIssue.Poster.ID), + Assignees: assignees, + Labels: labels, + Content: o.forgejoIssue.Body, + Milestone: milestone, + State: string(o.forgejoIssue.State), + Created: o.forgejoIssue.Created, + Updated: o.forgejoIssue.Updated, + Closed: o.forgejoIssue.Closed, + IsLocked: o.forgejoIssue.IsLocked, + } +} + +func (o *issue) FromFormat(content f3.Interface) { + issue := content.(*f3.Issue) + var milestone *forgejo_sdk.Milestone + if issue.Milestone != nil { + milestone = &forgejo_sdk.Milestone{ + ID: issue.Milestone.GetIDAsInt(), + } + } + o.forgejoIssue = &forgejo_sdk.Issue{ + Title: issue.Title, + Index: util.ParseInt(issue.GetID()), + Poster: &forgejo_sdk.User{ + ID: issue.PosterID.GetIDAsInt(), + }, + Body: issue.Content, + Milestone: milestone, + State: forgejo_sdk.StateType(issue.State), + Created: issue.Created, + Updated: issue.Updated, + Closed: issue.Closed, + IsLocked: issue.IsLocked, + } + + assignees := make([]*forgejo_sdk.User, 0, 5) + for _, assignee := range issue.Assignees { + assignees = append(assignees, &forgejo_sdk.User{ID: assignee.GetIDAsInt()}) + } + o.forgejoIssue.Assignees = assignees + + labels := make([]*forgejo_sdk.Label, 0, 5) + for _, label := range issue.Labels { + labels = append(labels, &forgejo_sdk.Label{ID: label.GetIDAsInt()}) + } + o.forgejoIssue.Labels = labels +} + +func (o *issue) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + issue, resp, err := o.getClient().GetIssue(owner, project, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("issue %v %w", o, err)) + } + o.forgejoIssue = issue + return true +} + +func usersToNames(ctx context.Context, tree f3_tree.TreeInterface, users []*forgejo_sdk.User) []string { + names := make([]string, 0, len(users)) + for _, user := range users { + names = append(names, f3_tree.GetUsernameFromID(ctx, tree, user.ID)) + } + return names +} + +func (o *issue) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoIssue.Poster.ID) + defer o.notSudo() + + createIssueOption := forgejo_sdk.CreateIssueOption{ + Title: o.forgejoIssue.Title, + Body: o.forgejoIssue.Body, + Assignees: usersToNames(ctx, node.GetTree().(f3_tree.TreeInterface), o.forgejoIssue.Assignees), + Labels: labelListToIDs(o.forgejoIssue.Labels), + Closed: o.forgejoIssue.State == forgejo_sdk.StateClosed, + } + if milestone := maybeMilestoneID(o.forgejoIssue.Milestone); milestone != nil { + createIssueOption.Milestone = *milestone + } + issue, _, err := o.getClient().CreateIssue(owner, project, createIssueOption) + if err != nil { + panic(err) + } + o.forgejoIssue = issue + return id.NewNodeID(o.GetNativeID()) +} + +func (o *issue) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoIssue.Poster.ID) + defer o.notSudo() + + _, _, err := o.getClient().EditIssue(owner, project, node.GetID().Int64(), forgejo_sdk.EditIssueOption{ + Title: o.forgejoIssue.Title, + Body: &o.forgejoIssue.Body, + State: &o.forgejoIssue.State, + Milestone: maybeMilestoneID(o.forgejoIssue.Milestone), + Assignees: usersToNames(ctx, node.GetTree().(f3_tree.TreeInterface), o.forgejoIssue.Assignees), + }) + if err != nil { + panic(fmt.Errorf("EditIssue %v %w", o, err)) + } + + _, _, err = o.getClient().ReplaceIssueLabels(owner, project, node.GetID().Int64(), forgejo_sdk.IssueLabelsOption{ + Labels: labelListToIDs(o.forgejoIssue.Labels), + }) + if err != nil { + panic(fmt.Errorf("ReplaceIssueLabels %v %w", o, err)) + } +} + +func (o *issue) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, err := o.getClient().DeleteIssue(owner, project, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/issues.go b/forges/forgejo/issues.go new file mode 100644 index 0000000..4049349 --- /dev/null +++ b/forges/forgejo/issues.go @@ -0,0 +1,47 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type issues struct { + container +} + +func (o *issues) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoIssues []*forgejo_sdk.Issue + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + forgejoIssues, resp, err := o.getClient().ListRepoIssues(owner, project, forgejo_sdk.ListIssueOption{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + State: forgejo_sdk.StateAll, + Type: forgejo_sdk.IssueTypeIssue, + }) + if resp.StatusCode == 404 { + return generic.NewChildrenSlice(0) + } + if err != nil { + panic(fmt.Errorf("error while listing issues: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoIssues...)...) +} + +func newIssues() generic.NodeDriverInterface { + return &issues{} +} diff --git a/forges/forgejo/label.go b/forges/forgejo/label.go new file mode 100644 index 0000000..8471160 --- /dev/null +++ b/forges/forgejo/label.go @@ -0,0 +1,133 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type label struct { + common + + forgejoLabel *forgejo_sdk.Label +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newLabel() generic.NodeDriverInterface { + return &label{} +} + +func (o *label) SetNative(label any) { + o.forgejoLabel = label.(*forgejo_sdk.Label) +} + +func (o *label) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoLabel.ID) +} + +func (o *label) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *label) ToFormat() f3.Interface { + if o.forgejoLabel == nil { + return o.NewFormat() + } + + return &f3.Label{ + Common: f3.NewCommon(o.GetNativeID()), + Name: o.forgejoLabel.Name, + Description: o.forgejoLabel.Description, + Color: o.forgejoLabel.Color, + } +} + +func (o *label) FromFormat(content f3.Interface) { + label := content.(*f3.Label) + o.forgejoLabel = &forgejo_sdk.Label{ + ID: util.ParseInt(label.GetID()), + Name: label.Name, + Description: label.Description, + Color: label.Color, + } +} + +func (o *label) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + label, resp, err := o.getClient().GetRepoLabel(owner, project, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("label %v %w", o, err)) + } + o.forgejoLabel = label + return true +} + +func (o *label) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + label, _, err := o.getClient().CreateLabel(owner, project, forgejo_sdk.CreateLabelOption{ + Name: o.forgejoLabel.Name, + Color: o.forgejoLabel.Color, + Description: o.forgejoLabel.Description, + }) + if err != nil { + panic(err) + } + o.forgejoLabel = label + return id.NewNodeID(o.GetNativeID()) +} + +func (o *label) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, _, err := o.getClient().EditLabel(owner, project, node.GetID().Int64(), forgejo_sdk.EditLabelOption{ + Name: &o.forgejoLabel.Name, + Color: &o.forgejoLabel.Color, + Description: &o.forgejoLabel.Description, + }) + if err != nil { + panic(err) + } +} + +func (o *label) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, err := o.getClient().DeleteLabel(owner, project, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/labels.go b/forges/forgejo/labels.go new file mode 100644 index 0000000..f536c15 --- /dev/null +++ b/forges/forgejo/labels.go @@ -0,0 +1,42 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type labels struct { + container +} + +func (o *labels) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoLabels []*forgejo_sdk.Label + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + forgejoLabels, _, err = o.getClient().ListRepoLabels(owner, project, forgejo_sdk.ListLabelsOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing labels: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoLabels...)...) +} + +func newLabels() generic.NodeDriverInterface { + return &labels{} +} diff --git a/forges/forgejo/main.go b/forges/forgejo/main.go new file mode 100644 index 0000000..a0836a6 --- /dev/null +++ b/forges/forgejo/main.go @@ -0,0 +1,19 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/options" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" +) + +func init() { + f3_tree.RegisterForgeFactory(forgejo_options.Name, newTreeDriver) + options.RegisterFactory(forgejo_options.Name, newOptions) + + f3_tree.RegisterForgeFactory(forgejo_options.NameAliasGitea, newTreeDriver) + options.RegisterFactory(forgejo_options.NameAliasGitea, newOptions) +} diff --git a/forges/forgejo/milestone.go b/forges/forgejo/milestone.go new file mode 100644 index 0000000..e34be6e --- /dev/null +++ b/forges/forgejo/milestone.go @@ -0,0 +1,158 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type milestone struct { + common + + forgejoMilestone *forgejo_sdk.Milestone +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newMilestone() generic.NodeDriverInterface { + return &milestone{} +} + +func (o *milestone) SetNative(milestone any) { + o.forgejoMilestone = milestone.(*forgejo_sdk.Milestone) +} + +func (o *milestone) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoMilestone.ID) +} + +func (o *milestone) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *milestone) ToFormat() f3.Interface { + if o.forgejoMilestone == nil { + return o.NewFormat() + } + + createdAT := time.Time{} + var updatedAT *time.Time + if o.forgejoMilestone.Closed != nil { + createdAT = *o.forgejoMilestone.Closed + updatedAT = o.forgejoMilestone.Closed + } + + if !o.forgejoMilestone.Created.IsZero() { + createdAT = o.forgejoMilestone.Created + } + if o.forgejoMilestone.Updated != nil && !o.forgejoMilestone.Updated.IsZero() { + updatedAT = o.forgejoMilestone.Updated + } + + return &f3.Milestone{ + Common: f3.NewCommon(o.GetNativeID()), + Title: o.forgejoMilestone.Title, + Description: o.forgejoMilestone.Description, + Deadline: o.forgejoMilestone.Deadline, + Created: createdAT, + Updated: updatedAT, + Closed: o.forgejoMilestone.Closed, + State: string(o.forgejoMilestone.State), + } +} + +func (o *milestone) FromFormat(content f3.Interface) { + milestone := content.(*f3.Milestone) + o.forgejoMilestone = &forgejo_sdk.Milestone{ + ID: util.ParseInt(milestone.GetID()), + Title: milestone.Title, + Description: milestone.Description, + State: forgejo_sdk.StateType(milestone.State), + Created: milestone.Created, + Updated: milestone.Updated, + Closed: milestone.Closed, + Deadline: milestone.Deadline, + } +} + +func (o *milestone) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + milestone, resp, err := o.getClient().GetMilestone(owner, project, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("milestone %v %w", o, err)) + } + o.forgejoMilestone = milestone + return true +} + +func (o *milestone) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + milestone, _, err := o.getClient().CreateMilestone(owner, project, forgejo_sdk.CreateMilestoneOption{ + Title: o.forgejoMilestone.Title, + Description: o.forgejoMilestone.Description, + State: o.forgejoMilestone.State, + Deadline: o.forgejoMilestone.Deadline, + }) + if err != nil { + panic(err) + } + o.forgejoMilestone = milestone + return id.NewNodeID(o.GetNativeID()) +} + +func (o *milestone) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, _, err := o.getClient().EditMilestone(owner, project, node.GetID().Int64(), forgejo_sdk.EditMilestoneOption{ + Title: o.forgejoMilestone.Title, + Description: &o.forgejoMilestone.Description, + State: &o.forgejoMilestone.State, + Deadline: o.forgejoMilestone.Deadline, + }) + if err != nil { + panic(err) + } +} + +func (o *milestone) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, err := o.getClient().DeleteMilestone(owner, project, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/milestones.go b/forges/forgejo/milestones.go new file mode 100644 index 0000000..d0ac2ce --- /dev/null +++ b/forges/forgejo/milestones.go @@ -0,0 +1,42 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type milestones struct { + container +} + +func (o *milestones) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoMilestones []*forgejo_sdk.Milestone + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + forgejoMilestones, _, err = o.getClient().ListRepoMilestones(owner, project, forgejo_sdk.ListMilestoneOption{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing milestones: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoMilestones...)...) +} + +func newMilestones() generic.NodeDriverInterface { + return &milestones{} +} diff --git a/forges/forgejo/options.go b/forges/forgejo/options.go new file mode 100644 index 0000000..cf4f001 --- /dev/null +++ b/forges/forgejo/options.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/options" +) + +func newOptions() options.Interface { + o := &forgejo_options.Options{} + o.SetName(forgejo_options.Name) + return o +} diff --git a/forges/forgejo/options/name.go b/forges/forgejo/options/name.go new file mode 100644 index 0000000..eb21a6b --- /dev/null +++ b/forges/forgejo/options/name.go @@ -0,0 +1,10 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +const ( + Name = "forgejo" + NameAliasGitea = "gitea" +) diff --git a/forges/forgejo/options/options.go b/forges/forgejo/options/options.go new file mode 100644 index 0000000..4a27f9b --- /dev/null +++ b/forges/forgejo/options/options.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/forges/helpers/auth" + "code.forgejo.org/f3/gof3/v3/options" + options_http "code.forgejo.org/f3/gof3/v3/options/http" + "code.forgejo.org/f3/gof3/v3/options/logger" + + "github.com/urfave/cli/v3" +) + +type Options struct { + options.Options + logger.OptionsLogger + auth.ForgeAuth + options_http.Implementation + + Version string +} + +func (o *Options) FromFlags(ctx context.Context, c *cli.Command, prefix string) { + o.ForgeAuth.FromFlags(ctx, c, prefix) +} + +func (o *Options) GetFlags(prefix, category string) []cli.Flag { + return o.ForgeAuth.GetFlags(prefix, category) +} diff --git a/forges/forgejo/organization.go b/forges/forgejo/organization.go new file mode 100644 index 0000000..b3edca7 --- /dev/null +++ b/forges/forgejo/organization.go @@ -0,0 +1,125 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type organization struct { + common + forgejoOrganization *forgejo_sdk.Organization +} + +var _ f3_tree.ForgeDriverInterface = &organization{} + +func newOrganization() generic.NodeDriverInterface { + return &organization{} +} + +func (o *organization) SetNative(organization any) { + o.forgejoOrganization = organization.(*forgejo_sdk.Organization) +} + +func (o *organization) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoOrganization.ID) +} + +func (o *organization) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *organization) ToFormat() f3.Interface { + if o.forgejoOrganization == nil { + return o.NewFormat() + } + return &f3.Organization{ + Common: f3.NewCommon(fmt.Sprintf("%d", o.forgejoOrganization.ID)), + Name: o.forgejoOrganization.UserName, + FullName: o.forgejoOrganization.FullName, + } +} + +func (o *organization) FromFormat(content f3.Interface) { + organization := content.(*f3.Organization) + o.forgejoOrganization = &forgejo_sdk.Organization{ + ID: util.ParseInt(organization.GetID()), + UserName: organization.Name, + FullName: organization.FullName, + } +} + +func (o *organization) getName(ctx context.Context) string { + node := o.GetNode() + organizations := f3_tree.GetFirstNodeKind(node, f3_tree.KindOrganizations).GetDriver().(*organizations) + return organizations.getNameFromID(ctx, node.GetID().Int64()) +} + +func (o *organization) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + name := o.getName(ctx) + if name == "" { + return false + } + + organization, resp, err := o.getClient().GetOrg(name) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("organization %v %w", o, err)) + } + o.forgejoOrganization = organization + return true +} + +func (o *organization) Patch(context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + _, err := o.getClient().EditOrg(o.forgejoOrganization.UserName, forgejo_sdk.EditOrgOption{ + FullName: o.forgejoOrganization.FullName, + }) + if err != nil { + panic(err) + } +} + +func (o *organization) Put(context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + organization, _, err := o.getClient().CreateOrg(forgejo_sdk.CreateOrgOption{ + Name: o.forgejoOrganization.UserName, + FullName: o.forgejoOrganization.FullName, + }) + if err != nil { + panic(err) + } + o.forgejoOrganization = organization + o.Trace("%s", organization) + return id.NewNodeID(o.GetNativeID()) +} + +func (o *organization) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + _, err := o.getClient().DeleteOrg(o.forgejoOrganization.UserName) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/organizations.go b/forges/forgejo/organizations.go new file mode 100644 index 0000000..4f01da5 --- /dev/null +++ b/forges/forgejo/organizations.go @@ -0,0 +1,86 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type organizations struct { + container +} + +func (o *organizations) listOrganizationsPage(ctx context.Context, page int) []*forgejo_sdk.Organization { + pageSize := o.getPageSize() + + var organizationFounds []*forgejo_sdk.Organization + var err error + organizationFounds, _, err = o.getClient().ListOrgs(forgejo_sdk.ListOrgsOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing organizations: %v", err)) + } + + return organizationFounds +} + +func (o *organizations) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(o.listOrganizationsPage(ctx, page)...)...) +} + +func (o *organizations) GetIDFromName(ctx context.Context, name string) id.NodeID { + organization, resp, err := o.getClient().GetOrg(name) + if resp.StatusCode == 404 { + return id.NilID + } + if err != nil { + if strings.Contains(err.Error(), "organization not found") { + return id.NilID + } + panic(fmt.Errorf("organization %v %w", o, err)) + } + return id.NewNodeID(organization.ID) +} + +func (o *organizations) getNameFromID(ctx context.Context, i int64) string { + o.Trace("%d", i) + node := o.GetNode() + nodeID := id.NewNodeID(i) + organization := node.GetChild(nodeID) + if organization != generic.NilNode { + return organization.ToFormat().(*f3.Organization).Name + } + + o.Trace("look for %d", i) + for page := 1; ; page++ { + organizations := o.listOrganizationsPage(ctx, page) + if len(organizations) == 0 { + break + } + o.Trace("look for %d page %d", i, page) + for _, organization := range organizations { + o.Trace("look for %d page %d organization %v", i, page, organization) + if organization.ID == i { + return organization.UserName + } + } + } + o.Trace("no organization found for id %d", i) + return "" +} + +func newOrganizations() generic.NodeDriverInterface { + return &organizations{} +} diff --git a/forges/forgejo/project.go b/forges/forgejo/project.go new file mode 100644 index 0000000..9509dc1 --- /dev/null +++ b/forges/forgejo/project.go @@ -0,0 +1,189 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type project struct { + common + forgejoProject *forgejo_sdk.Repository + forked *f3.Reference +} + +var _ f3_tree.ForgeDriverInterface = &project{} + +func newProject() generic.NodeDriverInterface { + return &project{} +} + +func (o *project) SetNative(project any) { + o.forgejoProject = project.(*forgejo_sdk.Repository) +} + +func (o *project) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoProject.ID) +} + +func (o *project) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *project) getForkedReference() *f3.Reference { + if !o.forgejoProject.Fork { + return nil + } + + if o.forked == nil { + _, resp, err := o.getClient().GetOrg(o.forgejoProject.Parent.Owner.UserName) + owners := "organizations" + if resp.StatusCode == 404 { + owners = "users" + } else if err != nil { + panic(fmt.Errorf("get org %v %w", o, err)) + } + + o.forked = f3_tree.NewProjectReference(owners, fmt.Sprintf("%d", o.forgejoProject.Parent.Owner.ID), fmt.Sprintf("%d", o.forgejoProject.Parent.ID)) + } + + return o.forked +} + +func (o *project) ToFormat() f3.Interface { + if o.forgejoProject == nil { + return o.NewFormat() + } + return &f3.Project{ + Common: f3.NewCommon(o.GetNativeID()), + Name: o.forgejoProject.Name, + IsPrivate: o.forgejoProject.Private, + Description: o.forgejoProject.Description, + DefaultBranch: o.forgejoProject.DefaultBranch, + Forked: o.getForkedReference(), + } +} + +func (o *project) FromFormat(content f3.Interface) { + project := content.(*f3.Project) + + o.forgejoProject = &forgejo_sdk.Repository{ + ID: util.ParseInt(project.GetID()), + Name: project.Name, + Private: project.IsPrivate, + Description: project.Description, + DefaultBranch: project.DefaultBranch, + } + o.forked = project.Forked +} + +func (o *project) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + var project *forgejo_sdk.Repository + var err error + var resp *forgejo_sdk.Response + if node.GetID() != id.NilID { + project, resp, err = o.getClient().GetRepoByID(node.GetID().Int64()) + } else { + panic("GetID() == 0") + } + if resp.StatusCode == 404 { + return false + } + if err != nil { + if strings.Contains(err.Error(), "project not found") { + return false + } + panic(fmt.Errorf("project %v %w", o, err)) + } + o.forgejoProject = project + return true +} + +func (o *project) Put(ctx context.Context) id.NodeID { + owner := f3_tree.GetOwner(o.GetNode()).ToFormat() + var ownerName string + var isOrganization bool + switch v := owner.(type) { + case *f3.User: + ownerName = v.UserName + o.maybeSudoName(ownerName) + case *f3.Organization: + ownerName = v.Name + isOrganization = true + default: + panic(fmt.Errorf("Put unexpected type %T", owner)) + } + defer o.notSudo() + + if o.forked == nil { + var repo *forgejo_sdk.Repository + var err error + if isOrganization { + repo, _, err = o.getClient().CreateOrgRepo(ownerName, forgejo_sdk.CreateRepoOption{ + Name: o.forgejoProject.Name, + Description: o.forgejoProject.Description, + Private: o.forgejoProject.Private, + DefaultBranch: o.forgejoProject.DefaultBranch, + }) + } else { + repo, _, err = o.getClient().CreateRepo(forgejo_sdk.CreateRepoOption{ + Name: o.forgejoProject.Name, + Description: o.forgejoProject.Description, + Private: o.forgejoProject.Private, + DefaultBranch: o.forgejoProject.DefaultBranch, + }) + } + if err != nil { + panic(err) + } + o.forgejoProject = repo + o.Trace("project created %d", o.forgejoProject.ID) + } else { + options := forgejo_sdk.CreateForkOption{ + Name: &o.forgejoProject.Name, + } + owner, project := f3_tree.ResolveProjectReference(ctx, o.getTree(), o.forked) + repo, _, err := o.getClient().CreateFork(owner, project, options) + if err != nil { + panic(fmt.Errorf("CreateFork %s %s - %w", owner, project, err)) + } + o.forgejoProject = repo + o.Trace("forked project created %d", o.forgejoProject.ID) + } + return id.NewNodeID(o.GetNativeID()) +} + +func (o *project) Patch(context.Context) { + owner := f3_tree.GetOwnerName(o.GetNode()) + repo, _, err := o.getClient().EditRepo(owner, o.forgejoProject.Name, forgejo_sdk.EditRepoOption{ + Description: &o.forgejoProject.Description, + }) + if err != nil { + panic(err) + } + o.forgejoProject = repo + o.Trace("%d", o.forgejoProject.ID) +} + +func (o *project) Delete(ctx context.Context) { + _, err := o.getClient().DeleteRepo(o.forgejoProject.Owner.UserName, o.forgejoProject.Name) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/projects.go b/forges/forgejo/projects.go new file mode 100644 index 0000000..84fbc20 --- /dev/null +++ b/forges/forgejo/projects.go @@ -0,0 +1,66 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type projects struct { + container +} + +func (o *projects) GetIDFromName(ctx context.Context, name string) id.NodeID { + owner := f3_tree.GetOwnerName(o.GetNode()) + forgejoProject, resp, err := o.getClient().GetRepo(owner, name) + if resp.StatusCode == 404 { + return id.NilID + } + + if err != nil { + panic(fmt.Errorf("error GetRepo(%s, %s): %v", owner, name, err)) + } + + return id.NewNodeID(forgejoProject.ID) +} + +func (o *projects) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoProjects []*forgejo_sdk.Repository + + owner := f3_tree.GetOwner(o.GetNode()).ToFormat() + switch v := owner.(type) { + case *f3.User: + forgejoProjects, _, err = o.getClient().ListUserRepos(v.UserName, forgejo_sdk.ListReposOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + case *f3.Organization: + forgejoProjects, _, err = o.getClient().ListOrgRepos(v.Name, forgejo_sdk.ListOrgReposOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + default: + panic(fmt.Errorf("unexpected type %T", owner)) + } + + if err != nil { + panic(fmt.Errorf("error while listing projects: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoProjects...)...) +} + +func newProjects() generic.NodeDriverInterface { + return &projects{} +} diff --git a/forges/forgejo/pullrequest.go b/forges/forgejo/pullrequest.go new file mode 100644 index 0000000..38f4fc8 --- /dev/null +++ b/forges/forgejo/pullrequest.go @@ -0,0 +1,289 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type pullRequest struct { + common + + forgejoPullRequest *forgejo_sdk.PullRequest + headRepository *f3.Reference + baseRepository *f3.Reference + fetchFunc f3.PullRequestFetchFunc +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newPullRequest() generic.NodeDriverInterface { + return &pullRequest{} +} + +func (o *pullRequest) SetNative(pullRequest any) { + o.forgejoPullRequest = pullRequest.(*forgejo_sdk.PullRequest) +} + +func (o *pullRequest) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoPullRequest.Index) +} + +func (o *pullRequest) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *pullRequest) repositoryToReference(ctx context.Context, repository *forgejo_sdk.Repository) *f3.Reference { + if repository == nil { + panic("unexpected nil repository") + } + owners := o.getForge().getOwnersPath(ctx, fmt.Sprintf("%d", repository.Owner.ID)) + return f3_tree.NewRepositoryReference(owners.String(), repository.Owner.ID, repository.ID) +} + +func (o *pullRequest) referenceToRepository(reference *f3.Reference) *forgejo_sdk.Repository { + var owner, project int64 + if reference.Get() == "../../repository/vcs" { + project = f3_tree.GetProjectID(o.GetNode()) + owner = f3_tree.GetOwnerID(o.GetNode()) + } else { + p := f3_tree.ToPath(path.PathAbsolute(generic.NewElementNode, o.GetNode().GetCurrentPath().String(), reference.Get())) + o.Trace("%v %v", o.GetNode().GetCurrentPath().ReadableString(), p) + owner, project = p.OwnerAndProjectID() + } + return &forgejo_sdk.Repository{ + ID: project, + Owner: &forgejo_sdk.User{ + ID: owner, + }, + } +} + +func (o *pullRequest) ToFormat() f3.Interface { + if o.forgejoPullRequest == nil { + return o.NewFormat() + } + + var milestone *f3.Reference + if o.forgejoPullRequest.Milestone != nil { + milestone = f3_tree.NewIssueMilestoneReference(o.forgejoPullRequest.Milestone.ID) + } + + createdAt := time.Time{} + if o.forgejoPullRequest.Created != nil { + createdAt = *o.forgejoPullRequest.Created + } + updatedAt := time.Time{} + if o.forgejoPullRequest.Created != nil { + updatedAt = *o.forgejoPullRequest.Updated + } + + var ( + headRef string + headSHA string + ) + if o.forgejoPullRequest.Head != nil { + headSHA = o.forgejoPullRequest.Head.Sha + headRef = o.forgejoPullRequest.Head.Ref + } + + var ( + baseRef string + baseSHA string + ) + if o.forgejoPullRequest.Base != nil { + baseSHA = o.forgejoPullRequest.Base.Sha + baseRef = o.forgejoPullRequest.Base.Ref + } + + var mergeCommitSHA string + if o.forgejoPullRequest.MergedCommitID != nil { + mergeCommitSHA = *o.forgejoPullRequest.MergedCommitID + } + + closedAt := o.forgejoPullRequest.Closed + if o.forgejoPullRequest.Merged != nil && closedAt == nil { + closedAt = o.forgejoPullRequest.Merged + } + + return &f3.PullRequest{ + Title: o.forgejoPullRequest.Title, + Common: f3.NewCommon(o.GetNativeID()), + PosterID: f3_tree.NewUserReference(o.forgejoPullRequest.Poster.ID), + Content: o.forgejoPullRequest.Body, + State: string(o.forgejoPullRequest.State), + Created: createdAt, + Updated: updatedAt, + Closed: closedAt, + Milestone: milestone, + Merged: o.forgejoPullRequest.HasMerged, + MergedTime: o.forgejoPullRequest.Merged, + MergeCommitSHA: mergeCommitSHA, + IsLocked: o.forgejoPullRequest.IsLocked, + Head: f3.PullRequestBranch{ + Ref: headRef, + SHA: headSHA, + Repository: o.headRepository, + }, + Base: f3.PullRequestBranch{ + Ref: baseRef, + SHA: baseSHA, + Repository: o.baseRepository, + }, + FetchFunc: o.fetchFunc, + } +} + +func (o *pullRequest) FromFormat(content f3.Interface) { + pullRequest := content.(*f3.PullRequest) + + var milestone *forgejo_sdk.Milestone + if pullRequest.Milestone != nil { + milestone = &forgejo_sdk.Milestone{ + ID: pullRequest.Milestone.GetIDAsInt(), + } + } + + o.forgejoPullRequest = &forgejo_sdk.PullRequest{ + Index: util.ParseInt(pullRequest.GetID()), + Poster: &forgejo_sdk.User{ + ID: pullRequest.PosterID.GetIDAsInt(), + }, + Title: pullRequest.Title, + Body: pullRequest.Content, + Milestone: milestone, + State: forgejo_sdk.StateType(pullRequest.State), + IsLocked: pullRequest.IsLocked, + HasMerged: pullRequest.Merged, + Merged: pullRequest.MergedTime, + MergedCommitID: &pullRequest.MergeCommitSHA, + Base: &forgejo_sdk.PRBranchInfo{ + Ref: pullRequest.Base.Ref, + Sha: pullRequest.Base.SHA, + Repository: o.referenceToRepository(pullRequest.Base.Repository), + }, + Head: &forgejo_sdk.PRBranchInfo{ + Ref: pullRequest.Head.Ref, + Sha: pullRequest.Head.SHA, + Repository: o.referenceToRepository(pullRequest.Head.Repository), + }, + Created: &pullRequest.Created, + Updated: &pullRequest.Updated, + Closed: pullRequest.Closed, + } + if o.forgejoPullRequest.Base.Repository.ID != o.forgejoPullRequest.Head.Repository.ID { + o.forgejoPullRequest.Head.Repository.Fork = true + } + o.fetchFunc = pullRequest.FetchFunc +} + +func (o *pullRequest) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + ownerName := f3_tree.GetOwnerName(o.GetNode()) + projectName := f3_tree.GetProjectName(o.GetNode()) + + pr, resp, err := o.getClient().GetPullRequest(ownerName, projectName, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("pullRequest %v %w", o, err)) + } + + o.headRepository = o.repositoryToReference(ctx, o.forgejoPullRequest.Head.Repository) + o.baseRepository = o.repositoryToReference(ctx, o.forgejoPullRequest.Base.Repository) + o.forgejoPullRequest = pr + return true +} + +func (o *pullRequest) GetPullRequestHead(ctx context.Context) string { + head := o.forgejoPullRequest.Head + if head.Repository.Fork { + return o.getForge().getOwnersName(ctx, fmt.Sprintf("%d", head.Repository.Owner.ID)) + ":" + head.Ref + } + return head.Ref +} + +func (o *pullRequest) GetPullRequestPushRefs() []string { + return []string{ + fmt.Sprintf("refs/f3/%s/head", o.GetNativeID()), + fmt.Sprintf("refs/pull/%s/head", o.GetNativeID()), + } +} + +func (o *pullRequest) GetPullRequestRef() string { + return fmt.Sprintf("refs/pull/%s/head", o.GetNativeID()) +} + +func (o *pullRequest) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(node) + project := f3_tree.GetProjectName(node) + + o.maybeSudoID(ctx, o.forgejoPullRequest.Poster.ID) + defer o.notSudo() + + options := forgejo_sdk.CreatePullRequestOption{ + Head: o.GetPullRequestHead(ctx), + Base: o.forgejoPullRequest.Base.Ref, + Title: o.forgejoPullRequest.Title, + Body: o.forgejoPullRequest.Body, + } + + pr, _, err := o.getClient().CreatePullRequest(owner, project, options) + if err != nil { + panic(fmt.Errorf("%+v: %w", options, err)) + } + o.forgejoPullRequest = pr + return id.NewNodeID(o.GetNativeID()) +} + +func (o *pullRequest) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoPullRequest.Poster.ID) + defer o.notSudo() + + _, _, err := o.getClient().EditPullRequest(owner, project, node.GetID().Int64(), forgejo_sdk.EditPullRequestOption{ + Title: o.forgejoPullRequest.Title, + Body: o.forgejoPullRequest.Body, + }) + if err != nil { + panic(err) + } +} + +func (o *pullRequest) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, err := o.getClient().DeleteIssue(owner, project, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/pullrequests.go b/forges/forgejo/pullrequests.go new file mode 100644 index 0000000..4da4771 --- /dev/null +++ b/forges/forgejo/pullrequests.go @@ -0,0 +1,47 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type pullRequests struct { + container +} + +func (o *pullRequests) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoPullRequests []*forgejo_sdk.PullRequest + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + forgejoPullRequests, resp, err := o.getClient().ListRepoPullRequests(owner, project, forgejo_sdk.ListPullRequestsOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + State: forgejo_sdk.StateAll, + }) + // When there are not pull requests it returns 404 instead of an empty list + if resp.StatusCode == 404 { + return generic.NewChildrenSlice(0) + } + if err != nil { + panic(fmt.Errorf("error while listing pullRequests: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoPullRequests...)...) +} + +func newPullRequests() generic.NodeDriverInterface { + return &pullRequests{} +} diff --git a/forges/forgejo/reaction.go b/forges/forgejo/reaction.go new file mode 100644 index 0000000..dd8b3ae --- /dev/null +++ b/forges/forgejo/reaction.go @@ -0,0 +1,130 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type reaction struct { + common + + forgejoReaction *forgejo_sdk.Reaction + exists bool + id string +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newReaction() generic.NodeDriverInterface { + return &reaction{} +} + +func (o *reaction) SetNative(reaction any) { + o.forgejoReaction = reaction.(*forgejo_sdk.Reaction) + o.exists = true + o.id = fmt.Sprintf("%d %s", o.forgejoReaction.User.ID, o.forgejoReaction.Reaction) +} + +func (o *reaction) GetNativeID() string { + return o.id +} + +func (o *reaction) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *reaction) ToFormat() f3.Interface { + if o.forgejoReaction == nil { + return o.NewFormat() + } + + return &f3.Reaction{ + Common: f3.NewCommon(o.GetNativeID()), + UserID: f3_tree.NewUserReference(o.forgejoReaction.User.ID), + Content: o.forgejoReaction.Reaction, + } +} + +func (o *reaction) FromFormat(content f3.Interface) { + reaction := content.(*f3.Reaction) + o.forgejoReaction = &forgejo_sdk.Reaction{ + User: &forgejo_sdk.User{ + ID: reaction.UserID.GetIDAsInt(), + }, + Reaction: reaction.Content, + } + o.id = reaction.GetID() +} + +func (o *reaction) Get(ctx context.Context) bool { + return o.exists +} + +func (o *reaction) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + reactionable := f3_tree.GetReactionable(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoReaction.User.ID) + defer o.notSudo() + + var err error + var reaction *forgejo_sdk.Reaction + + switch reactionable.GetKind() { + case f3_tree.KindIssue, f3_tree.KindPullRequest: + commentable := f3_tree.GetCommentableID(o.GetNode()) + reaction, _, err = o.getClient().PostIssueReaction(owner, project, commentable, o.forgejoReaction.Reaction) + case f3_tree.KindComment: + comment := f3_tree.GetCommentID(o.GetNode()) + reaction, _, err = o.getClient().PostIssueCommentReaction(owner, project, comment, o.forgejoReaction.Reaction) + default: + panic(fmt.Errorf("unexpected type %T", owner)) + } + + if err != nil { + panic(err) + } + o.SetNative(reaction) + return id.NewNodeID(o.GetNativeID()) +} + +func (o *reaction) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + reactionable := f3_tree.GetReactionable(o.GetNode()) + reactionableID := f3_tree.GetReactionableID(o.GetNode()) + + var err error + + switch reactionable.GetKind() { + case f3_tree.KindIssue, f3_tree.KindPullRequest: + _, err = o.getClient().DeleteIssueReaction(owner, project, reactionableID, o.forgejoReaction.Reaction) + case f3_tree.KindComment: + _, err = o.getClient().DeleteIssueCommentReaction(owner, project, reactionableID, o.forgejoReaction.Reaction) + default: + panic(fmt.Errorf("unexpected type %T", owner)) + } + if err != nil { + panic(err) + } + o.exists = false +} diff --git a/forges/forgejo/reactions.go b/forges/forgejo/reactions.go new file mode 100644 index 0000000..08735b3 --- /dev/null +++ b/forges/forgejo/reactions.go @@ -0,0 +1,48 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type reactions struct { + container +} + +func (o *reactions) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + var err error + var forgejoReactions []*forgejo_sdk.Reaction + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + reactionable := f3_tree.GetReactionable(o.GetNode()) + reactionableID := f3_tree.GetReactionableID(o.GetNode()) + + switch reactionable.GetKind() { + case f3_tree.KindIssue, f3_tree.KindPullRequest: + forgejoReactions, _, err = o.getClient().GetIssueReactions(owner, project, reactionableID) + case f3_tree.KindComment: + forgejoReactions, _, err = o.getClient().GetIssueCommentReactions(owner, project, reactionableID) + default: + panic(fmt.Errorf("unexpected type %T", owner)) + } + + if err != nil { + panic(fmt.Errorf("error while listing reactions: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoReactions...)...) +} + +func newReactions() generic.NodeDriverInterface { + return &reactions{} +} diff --git a/forges/forgejo/release.go b/forges/forgejo/release.go new file mode 100644 index 0000000..a5b25cf --- /dev/null +++ b/forges/forgejo/release.go @@ -0,0 +1,154 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type release struct { + common + + forgejoRelease *forgejo_sdk.Release +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newRelease() generic.NodeDriverInterface { + return &release{} +} + +func (o *release) SetNative(release any) { + o.forgejoRelease = release.(*forgejo_sdk.Release) +} + +func (o *release) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoRelease.ID) +} + +func (o *release) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *release) ToFormat() f3.Interface { + if o.forgejoRelease == nil { + return o.NewFormat() + } + + return &f3.Release{ + Common: f3.NewCommon(o.GetNativeID()), + TagName: o.forgejoRelease.TagName, + TargetCommitish: o.forgejoRelease.Target, + Name: o.forgejoRelease.Title, + Body: o.forgejoRelease.Note, + Draft: o.forgejoRelease.IsDraft, + Prerelease: o.forgejoRelease.IsPrerelease, + PublisherID: f3_tree.NewUserReference(o.forgejoRelease.Publisher.ID), + Created: o.forgejoRelease.CreatedAt, + } +} + +func (o *release) FromFormat(content f3.Interface) { + release := content.(*f3.Release) + o.forgejoRelease = &forgejo_sdk.Release{ + ID: util.ParseInt(release.GetID()), + TagName: release.TagName, + Target: release.TargetCommitish, + Title: release.Name, + Note: release.Body, + IsDraft: release.Draft, + IsPrerelease: release.Prerelease, + Publisher: &forgejo_sdk.User{ + ID: release.PublisherID.GetIDAsInt(), + }, + CreatedAt: release.Created, + } +} + +func (o *release) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + release, resp, err := o.getClient().GetRelease(owner, project, node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("release %v %w", o, err)) + } + o.forgejoRelease = release + return true +} + +func (o *release) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoRelease.Publisher.ID) + defer o.notSudo() + + release, _, err := o.getClient().CreateRelease(owner, project, forgejo_sdk.CreateReleaseOption{ + TagName: o.forgejoRelease.TagName, + Target: o.forgejoRelease.Target, + Title: o.forgejoRelease.Title, + Note: o.forgejoRelease.Note, + IsDraft: o.forgejoRelease.IsDraft, + IsPrerelease: o.forgejoRelease.IsPrerelease, + }) + if err != nil { + panic(err) + } + o.forgejoRelease = release + return id.NewNodeID(o.GetNativeID()) +} + +func (o *release) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, _, err := o.getClient().EditRelease(owner, project, node.GetID().Int64(), forgejo_sdk.EditReleaseOption{ + TagName: o.forgejoRelease.TagName, + Target: o.forgejoRelease.Target, + Title: o.forgejoRelease.Title, + Note: o.forgejoRelease.Note, + IsDraft: &o.forgejoRelease.IsDraft, + IsPrerelease: &o.forgejoRelease.IsPrerelease, + }) + if err != nil { + panic(err) + } +} + +func (o *release) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, err := o.getClient().DeleteRelease(owner, project, node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/releases.go b/forges/forgejo/releases.go new file mode 100644 index 0000000..45d7ee3 --- /dev/null +++ b/forges/forgejo/releases.go @@ -0,0 +1,44 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type releases struct { + container +} + +func (o *releases) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var err error + var forgejoReleases []*forgejo_sdk.Release + + if o.getProject().forgejoProject.HasReleases { + + owner := f3_tree.GetOwnerName(o.GetNode()) + projectName := f3_tree.GetProjectName(o.GetNode()) + + forgejoReleases, _, err = o.getClient().ListReleases(owner, projectName, forgejo_sdk.ListReleasesOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing releases: %v", err)) + } + } + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(forgejoReleases...)...) +} + +func newReleases() generic.NodeDriverInterface { + return &releases{} +} diff --git a/forges/forgejo/repositories.go b/forges/forgejo/repositories.go new file mode 100644 index 0000000..41ba0cd --- /dev/null +++ b/forges/forgejo/repositories.go @@ -0,0 +1,51 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type repositories struct { + container +} + +func (o *repositories) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + children := generic.NewChildrenSlice(0) + if page > 1 { + return children + } + + names := []string{f3.RepositoryNameDefault} + project := f3_tree.GetProject(o.GetNode()).ToFormat().(*f3.Project) + if project.HasWiki { + names = append(names, f3.RepositoryNameWiki) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(names...)...) +} + +func (o *repositories) GetIDFromName(ctx context.Context, name string) id.NodeID { + switch name { + case f3.RepositoryNameDefault: + case f3.RepositoryNameWiki: + project := f3_tree.GetProject(o.GetNode()).ToFormat().(*f3.Project) + if !project.HasWiki { + return id.NilID + } + default: + return id.NilID + } + return id.NewNodeID(name) +} + +func newRepositories() generic.NodeDriverInterface { + return &repositories{} +} diff --git a/forges/forgejo/repository.go b/forges/forgejo/repository.go new file mode 100644 index 0000000..4f4aee2 --- /dev/null +++ b/forges/forgejo/repository.go @@ -0,0 +1,103 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type repository struct { + common + + name string + h helpers_repository.Interface + f *f3.Repository +} + +func (o *repository) SetNative(repository any) { + o.name = repository.(string) +} + +func (o *repository) GetNativeID() string { + return o.name +} + +func (o *repository) NewFormat() f3.Interface { + return &f3.Repository{} +} + +func (o *repository) ToFormat() f3.Interface { + return &f3.Repository{ + Common: f3.NewCommon(o.GetNativeID()), + Name: o.GetNativeID(), + FetchFunc: o.f.FetchFunc, + } +} + +func (o *repository) FromFormat(content f3.Interface) { + f := content.Clone().(*f3.Repository) + o.f = f + o.f.SetID(f.Name) + o.name = f.Name +} + +func (o *repository) Get(ctx context.Context) bool { + return o.h.Get(ctx) +} + +func (o *repository) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *repository) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *repository) upsert(ctx context.Context) id.NodeID { + o.Trace("%s", o.GetNativeID()) + o.h.Upsert(ctx, o.f) + return id.NewNodeID(o.f.Name) +} + +func (o *repository) urlToRepositoryURL(url string) string { + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + repositoryURL := fmt.Sprintf("%s/%s/%s", url, owner, project) + if o.f.GetID() == f3.RepositoryNameWiki { + repositoryURL += ".wiki" + } + return repositoryURL +} + +func (o *repository) SetFetchFunc(fetchFunc func(ctx context.Context, destination string, internalRefs []string)) { + o.f.FetchFunc = fetchFunc +} + +func (o *repository) GetRepositoryURL() string { + return o.urlToRepositoryURL(o.getURL()) +} + +func (o *repository) GetRepositoryPushURL() string { + return o.urlToRepositoryURL(o.getPushURL()) +} + +func (o *repository) GetRepositoryInternalRefs() []string { + return []string{"refs/pull/*"} +} + +func newRepository(ctx context.Context) generic.NodeDriverInterface { + r := &repository{ + f: &f3.Repository{}, + } + r.h = helpers_repository.NewHelper(r) + return r +} diff --git a/forges/forgejo/review.go b/forges/forgejo/review.go new file mode 100644 index 0000000..3d446aa --- /dev/null +++ b/forges/forgejo/review.go @@ -0,0 +1,165 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + helper_pullrequest "code.forgejo.org/f3/gof3/v3/forges/helpers/pullrequest" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type review struct { + common + + forgejoReview *forgejo_sdk.PullReview + h helper_pullrequest.Interface +} + +var _ f3_tree.ForgeDriverInterface = &review{} + +func newReview() generic.NodeDriverInterface { + return &review{} +} + +func (o *review) SetNative(review any) { + o.forgejoReview = review.(*forgejo_sdk.PullReview) +} + +func (o *review) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoReview.ID) +} + +func (o *review) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +var sdkStateToFormatState = map[string]string{ + string(forgejo_sdk.ReviewStateApproved): f3.ReviewStateApproved, + string(forgejo_sdk.ReviewStatePending): f3.ReviewStatePending, + string(forgejo_sdk.ReviewStateComment): f3.ReviewStateCommented, + string(forgejo_sdk.ReviewStateRequestChanges): f3.ReviewStateChangesRequested, + string(forgejo_sdk.ReviewStateRequestReview): f3.ReviewStateRequestReview, + string(forgejo_sdk.ReviewStateUnknown): f3.ReviewStateUnknown, +} + +var formatStateToSdkState = func() map[string]string { + r := make(map[string]string, len(sdkStateToFormatState)) + for k, v := range sdkStateToFormatState { + r[v] = k + } + return r +}() + +func convertState(m map[string]string, unknown, fromState string) string { + if toState, ok := m[fromState]; ok { + return toState + } + return unknown +} + +func (o *review) ToFormat() f3.Interface { + if o.forgejoReview == nil { + return o.NewFormat() + } + + review := &f3.Review{ + Common: f3.NewCommon(o.GetNativeID()), + Official: o.forgejoReview.Official, + CommitID: o.forgejoReview.CommitID, + Content: o.forgejoReview.Body, + CreatedAt: o.forgejoReview.Submitted, + State: convertState(sdkStateToFormatState, f3.ReviewStateUnknown, string(o.forgejoReview.State)), + } + + if o.forgejoReview.Reviewer != nil { + review.ReviewerID = f3_tree.NewUserReference(o.forgejoReview.Reviewer.ID) + } + + return review +} + +func (o *review) FromFormat(content f3.Interface) { + review := content.(*f3.Review) + o.forgejoReview = &forgejo_sdk.PullReview{ + ID: util.ParseInt(review.GetID()), + Reviewer: &forgejo_sdk.User{ + ID: review.ReviewerID.GetIDAsInt(), + }, + Official: review.Official, + CommitID: review.CommitID, + Body: review.Content, + Submitted: review.CreatedAt, + State: forgejo_sdk.ReviewStateType(convertState(formatStateToSdkState, string(forgejo_sdk.ReviewStateUnknown), review.State)), + } +} + +func (o *review) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequest(o.GetNode()) + + review, resp, err := o.getClient().GetPullReview(owner, project, pullRequest.GetID().Int64(), node.GetID().Int64()) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("review %v %w", o, err)) + } + o.forgejoReview = review + return true +} + +func (o *review) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequest(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoReview.Reviewer.ID) + defer o.notSudo() + + review, _, err := o.getClient().CreatePullReview(owner, project, pullRequest.GetID().Int64(), forgejo_sdk.CreatePullReviewOptions{ + State: o.forgejoReview.State, + Body: o.forgejoReview.Body, + CommitID: o.forgejoReview.CommitID, + }) + if err != nil { + panic(err) + } + o.forgejoReview = review + return id.NewNodeID(o.GetNativeID()) +} + +func (o *review) Patch(ctx context.Context) { + panic("cannot edit an existing review") +} + +func (o *review) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequest(o.GetNode()) + + _, err := o.getClient().DeletePullReview(owner, project, pullRequest.GetID().Int64(), node.GetID().Int64()) + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/reviewcomment.go b/forges/forgejo/reviewcomment.go new file mode 100644 index 0000000..a9e762d --- /dev/null +++ b/forges/forgejo/reviewcomment.go @@ -0,0 +1,168 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type reviewComment struct { + common + + forgejoReviewComment *forgejo_sdk.PullReviewComment +} + +var _ f3_tree.ForgeDriverInterface = &pullRequest{} + +func newReviewComment() generic.NodeDriverInterface { + return &reviewComment{} +} + +func (o *reviewComment) SetNative(reviewComment any) { + o.forgejoReviewComment = reviewComment.(*forgejo_sdk.PullReviewComment) +} + +func (o *reviewComment) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoReviewComment.ID) +} + +func (o *reviewComment) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *reviewComment) ToFormat() f3.Interface { + if o.forgejoReviewComment == nil { + return o.NewFormat() + } + + line := int(o.forgejoReviewComment.LineNum) + if o.forgejoReviewComment.OldLineNum > 0 { + line = int(o.forgejoReviewComment.OldLineNum) * -1 + } + + return &f3.ReviewComment{ + Common: f3.NewCommon(o.GetNativeID()), + PosterID: f3_tree.NewUserReference(o.forgejoReviewComment.Reviewer.ID), + Content: o.forgejoReviewComment.Body, + TreePath: o.forgejoReviewComment.Path, + DiffHunk: o.forgejoReviewComment.DiffHunk, + Line: line, + CommitID: o.forgejoReviewComment.CommitID, + CreatedAt: o.forgejoReviewComment.Created, + UpdatedAt: o.forgejoReviewComment.Updated, + } +} + +func (o *reviewComment) FromFormat(content f3.Interface) { + reviewComment := content.(*f3.ReviewComment) + o.forgejoReviewComment = &forgejo_sdk.PullReviewComment{ + ID: util.ParseInt(reviewComment.GetID()), + Reviewer: &forgejo_sdk.User{ + ID: reviewComment.PosterID.GetIDAsInt(), + }, + Path: reviewComment.TreePath, + Body: reviewComment.Content, + DiffHunk: reviewComment.DiffHunk, + CommitID: reviewComment.CommitID, + Created: reviewComment.CreatedAt, + Updated: reviewComment.UpdatedAt, + } + + if reviewComment.Line > 0 { + o.forgejoReviewComment.LineNum = uint64(reviewComment.Line) + } else { + o.forgejoReviewComment.OldLineNum = uint64(reviewComment.Line * -1) + } +} + +func (o *reviewComment) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequestID(o.GetNode()) + review := f3_tree.GetReviewID(o.GetNode()) + comment := f3_tree.GetReviewCommentID(o.GetNode()) + + reviewComment, resp, err := o.getClient().GetPullReviewComment(owner, project, pullRequest, review, comment) + if resp.StatusCode == 404 { + return false + } + if err != nil { + panic(fmt.Errorf("reviewComment %v %w", o, err)) + } + o.forgejoReviewComment = reviewComment + return true +} + +func (o *reviewComment) Put(ctx context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequestID(o.GetNode()) + review := f3_tree.GetReviewID(o.GetNode()) + + o.maybeSudoID(ctx, o.forgejoReviewComment.Reviewer.ID) + defer o.notSudo() + + reviewComment, _, err := o.getClient().CreatePullReviewComment(owner, project, pullRequest, review, forgejo_sdk.CreatePullReviewComment{ + Path: o.forgejoReviewComment.Path, + Body: o.forgejoReviewComment.Body, + LineNum: o.forgejoReviewComment.LineNum, + OldLineNum: o.forgejoReviewComment.OldLineNum, + }) + if err != nil { + panic(err) + } + o.forgejoReviewComment = reviewComment + return id.NewNodeID(o.GetNativeID()) +} + +func (o *reviewComment) Patch(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + + _, _, err := o.getClient().EditIssueComment(owner, project, node.GetID().Int64(), forgejo_sdk.EditIssueCommentOption{ + Body: o.forgejoReviewComment.Body, + }) + if err != nil { + panic(err) + } +} + +func (o *reviewComment) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequestID(o.GetNode()) + review := f3_tree.GetReviewID(o.GetNode()) + comment := f3_tree.GetReviewCommentID(o.GetNode()) + + resp, err := o.getClient().DeletePullReviewComment(owner, project, pullRequest, review, comment) + if resp.StatusCode != 204 { + panic(fmt.Errorf("unexpected status code deleting %d %d %v", comment, resp.StatusCode, resp)) + } + if err != nil { + panic(err) + } +} diff --git a/forges/forgejo/reviewcomments.go b/forges/forgejo/reviewcomments.go new file mode 100644 index 0000000..b967269 --- /dev/null +++ b/forges/forgejo/reviewcomments.go @@ -0,0 +1,39 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type reviewComments struct { + container +} + +func (o *reviewComments) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + if page > 1 { + return generic.NewChildrenSlice(0) + } + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequestID(o.GetNode()) + review := f3_tree.GetReviewID(o.GetNode()) + + reviewComments, _, err := o.getClient().ListPullReviewComments(owner, project, pullRequest, review) + if err != nil { + panic(fmt.Errorf("error while listing reviewComments: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(reviewComments...)...) +} + +func newReviewComments() generic.NodeDriverInterface { + return &reviewComments{} +} diff --git a/forges/forgejo/reviews.go b/forges/forgejo/reviews.go new file mode 100644 index 0000000..fbdf099 --- /dev/null +++ b/forges/forgejo/reviews.go @@ -0,0 +1,40 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type reviews struct { + container +} + +func (o *reviews) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + owner := f3_tree.GetOwnerName(o.GetNode()) + project := f3_tree.GetProjectName(o.GetNode()) + pullRequest := f3_tree.GetPullRequest(o.GetNode()) + + reviews, _, err := o.getClient().ListPullReviews(owner, project, pullRequest.GetID().Int64(), forgejo_sdk.ListPullReviewsOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing reviews: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(reviews...)...) +} + +func newReviews() generic.NodeDriverInterface { + return &reviews{} +} diff --git a/forges/forgejo/root.go b/forges/forgejo/root.go new file mode 100644 index 0000000..059f9ad --- /dev/null +++ b/forges/forgejo/root.go @@ -0,0 +1,42 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type root struct { + generic.NullDriver + + content f3.Interface +} + +func newRoot(content f3.Interface) generic.NodeDriverInterface { + return &root{ + content: content, + } +} + +func (o *root) FromFormat(content f3.Interface) { + o.content = content +} + +func (o *root) ToFormat() f3.Interface { + return o.content +} + +func (o *root) Get(context.Context) bool { return true } + +func (o *root) Put(context.Context) id.NodeID { + return id.NilID +} + +func (o *root) Patch(context.Context) { +} diff --git a/forges/forgejo/sdk/admin_cron.go b/forges/forgejo/sdk/admin_cron.go new file mode 100644 index 0000000..7c1127a --- /dev/null +++ b/forges/forgejo/sdk/admin_cron.go @@ -0,0 +1,47 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "time" +) + +// CronTask represents a Cron task +type CronTask struct { + Name string `json:"name"` + Schedule string `json:"schedule"` + Next time.Time `json:"next"` + Prev time.Time `json:"prev"` + ExecTimes int64 `json:"exec_times"` +} + +// ListCronTaskOptions list options for ListCronTasks +type ListCronTaskOptions struct { + ListOptions +} + +// ListCronTasks list available cron tasks +func (c *Client) ListCronTasks(opt ListCronTaskOptions) ([]*CronTask, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + opt.setDefaults() + ct := make([]*CronTask, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/cron?%s", opt.getURLQuery().Encode()), jsonHeader, nil, &ct) + return ct, resp, err +} + +// RunCronTasks run a cron task +func (c *Client) RunCronTasks(task string) (*Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, err + } + if err := escapeValidatePathSegments(&task); err != nil { + return nil, err + } + _, resp, err := c.getResponse("POST", fmt.Sprintf("/admin/cron/%s", task), jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/admin_org.go b/forges/forgejo/sdk/admin_org.go new file mode 100644 index 0000000..40a5f9f --- /dev/null +++ b/forges/forgejo/sdk/admin_org.go @@ -0,0 +1,39 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// AdminListOrgsOptions options for listing admin's organizations +type AdminListOrgsOptions struct { + ListOptions +} + +// AdminListOrgs lists all orgs +func (c *Client) AdminListOrgs(opt AdminListOrgsOptions) ([]*Organization, *Response, error) { + opt.setDefaults() + orgs := make([]*Organization, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/orgs?%s", opt.getURLQuery().Encode()), nil, nil, &orgs) + return orgs, resp, err +} + +// AdminCreateOrg create an organization +func (c *Client) AdminCreateOrg(user string, opt CreateOrgOption) (*Organization, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + org := new(Organization) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/orgs", user), jsonHeader, bytes.NewReader(body), org) + return org, resp, err +} diff --git a/forges/forgejo/sdk/admin_repo.go b/forges/forgejo/sdk/admin_repo.go new file mode 100644 index 0000000..3f3fa8f --- /dev/null +++ b/forges/forgejo/sdk/admin_repo.go @@ -0,0 +1,25 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// AdminCreateRepo create a repo +func (c *Client) AdminCreateRepo(user string, opt CreateRepoOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/repos", user), jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} diff --git a/forges/forgejo/sdk/admin_user.go b/forges/forgejo/sdk/admin_user.go new file mode 100644 index 0000000..e14a038 --- /dev/null +++ b/forges/forgejo/sdk/admin_user.go @@ -0,0 +1,130 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// AdminListUsersOptions options for listing admin users +type AdminListUsersOptions struct { + ListOptions +} + +// AdminListUsers lists all users +func (c *Client) AdminListUsers(opt AdminListUsersOptions) ([]*User, *Response, error) { + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/users?%s", opt.getURLQuery().Encode()), nil, nil, &users) + return users, resp, err +} + +// CreateUserOption create user options +type CreateUserOption struct { + SourceID int64 `json:"source_id"` + LoginName string `json:"login_name"` + Username string `json:"username"` + FullName string `json:"full_name"` + Email string `json:"email"` + Password string `json:"password"` + MustChangePassword *bool `json:"must_change_password"` + SendNotify bool `json:"send_notify"` + Visibility *VisibleType `json:"visibility"` +} + +// Validate the CreateUserOption struct +func (opt CreateUserOption) Validate() error { + if len(opt.Email) == 0 { + return fmt.Errorf("email is empty") + } + if len(opt.Username) == 0 { + return fmt.Errorf("username is empty") + } + return nil +} + +// AdminCreateUser create a user +func (c *Client) AdminCreateUser(opt CreateUserOption) (*User, *Response, error) { + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + user := new(User) + resp, err := c.getParsedResponse("POST", "/admin/users", jsonHeader, bytes.NewReader(body), user) + return user, resp, err +} + +// EditUserOption edit user options +type EditUserOption struct { + SourceID int64 `json:"source_id"` + LoginName string `json:"login_name"` + Email *string `json:"email"` + FullName *string `json:"full_name"` + Password string `json:"password"` + Description *string `json:"description"` + MustChangePassword *bool `json:"must_change_password"` + Website *string `json:"website"` + Location *string `json:"location"` + Active *bool `json:"active"` + Admin *bool `json:"admin"` + AllowGitHook *bool `json:"allow_git_hook"` + AllowImportLocal *bool `json:"allow_import_local"` + MaxRepoCreation *int `json:"max_repo_creation"` + ProhibitLogin *bool `json:"prohibit_login"` + AllowCreateOrganization *bool `json:"allow_create_organization"` + Restricted *bool `json:"restricted"` + Visibility *VisibleType `json:"visibility"` +} + +// AdminEditUser modify user informations +func (c *Client) AdminEditUser(user string, opt EditUserOption) (*Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/admin/users/%s", user), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// AdminDeleteUser delete one user according name +func (c *Client) AdminDeleteUser(user string) (*Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/admin/users/%s", user), nil, nil) + return resp, err +} + +// AdminCreateUserPublicKey adds a public key for the user +func (c *Client) AdminCreateUserPublicKey(user string, opt CreateKeyOption) (*PublicKey, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + key := new(PublicKey) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/keys", user), jsonHeader, bytes.NewReader(body), key) + return key, resp, err +} + +// AdminDeleteUserPublicKey deletes a user's public key +func (c *Client) AdminDeleteUserPublicKey(user string, keyID int) (*Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/admin/users/%s/keys/%d", user, keyID), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/agent.go b/forges/forgejo/sdk/agent.go new file mode 100644 index 0000000..43afaeb --- /dev/null +++ b/forges/forgejo/sdk/agent.go @@ -0,0 +1,38 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +//go:build !windows + +package sdk + +import ( + "fmt" + "net" + "os" + + "golang.org/x/crypto/ssh/agent" +) + +// hasAgent returns true if the ssh agent is available +func hasAgent() bool { + if _, err := os.Stat(os.Getenv("SSH_AUTH_SOCK")); err != nil { + return false + } + + return true +} + +// GetAgent returns a ssh agent +func GetAgent() (agent.Agent, error) { + if !hasAgent() { + return nil, fmt.Errorf("no ssh agent available") + } + + sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) + if err != nil { + return nil, err + } + + return agent.NewClient(sshAgent), nil +} diff --git a/forges/forgejo/sdk/agent_windows.go b/forges/forgejo/sdk/agent_windows.go new file mode 100644 index 0000000..7a03f7d --- /dev/null +++ b/forges/forgejo/sdk/agent_windows.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +//go:build windows + +package sdk + +import ( + "fmt" + + "github.com/davidmz/go-pageant" + "golang.org/x/crypto/ssh/agent" +) + +// hasAgent returns true if pageant is available +func hasAgent() bool { + return pageant.Available() +} + +// GetAgent returns a ssh agent +func GetAgent() (agent.Agent, error) { + if !hasAgent() { + return nil, fmt.Errorf("no pageant available") + } + + return pageant.New(), nil +} diff --git a/forges/forgejo/sdk/attachment.go b/forges/forgejo/sdk/attachment.go new file mode 100644 index 0000000..e00442c --- /dev/null +++ b/forges/forgejo/sdk/attachment.go @@ -0,0 +1,112 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + +// Attachment a generic attachment +type Attachment struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + Created time.Time `json:"created_at"` + UUID string `json:"uuid"` + DownloadURL string `json:"browser_download_url"` +} + +// ListReleaseAttachmentsOptions options for listing release's attachments +type ListReleaseAttachmentsOptions struct { + ListOptions +} + +// ListReleaseAttachments list release's attachments +func (c *Client) ListReleaseAttachments(user, repo string, release int64, opt ListReleaseAttachmentsOptions) ([]*Attachment, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + attachments := make([]*Attachment, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/releases/%d/assets?%s", user, repo, release, opt.getURLQuery().Encode()), + nil, nil, &attachments) + return attachments, resp, err +} + +// GetReleaseAttachment returns the requested attachment +func (c *Client) GetReleaseAttachment(user, repo string, release, id int64) (*Attachment, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + a := new(Attachment) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, id), + nil, nil, &a) + return a, resp, err +} + +// CreateReleaseAttachment creates an attachment for the given release +func (c *Client) CreateReleaseAttachment(user, repo string, release int64, file io.Reader, filename string) (*Attachment, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + // Write file to body + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + if err != nil { + return nil, nil, err + } + + if _, err = io.Copy(part, file); err != nil { + return nil, nil, err + } + if err = writer.Close(); err != nil { + return nil, nil, err + } + + // Send request + attachment := new(Attachment) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/releases/%d/assets", user, repo, release), + http.Header{"Content-Type": {writer.FormDataContentType()}}, body, &attachment) + return attachment, resp, err +} + +// EditAttachmentOptions options for editing attachments +type EditAttachmentOptions struct { + Name string `json:"name"` +} + +// EditReleaseAttachment updates the given attachment with the given options +func (c *Client) EditReleaseAttachment(user, repo string, release, attachment int64, form EditAttachmentOptions) (*Attachment, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&form) + if err != nil { + return nil, nil, err + } + attach := new(Attachment) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, attachment), jsonHeader, bytes.NewReader(body), attach) + return attach, resp, err +} + +// DeleteReleaseAttachment deletes the given attachment including the uploaded file +func (c *Client) DeleteReleaseAttachment(user, repo string, release, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, id), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/client.go b/forges/forgejo/sdk/client.go new file mode 100644 index 0000000..94f6794 --- /dev/null +++ b/forges/forgejo/sdk/client.go @@ -0,0 +1,499 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/hashicorp/go-version" +) + +var jsonHeader = http.Header{"content-type": []string{"application/json"}} + +// Version return the library version +func Version() string { + return "0.16.0" +} + +// Client represents a thread-safe Gitea API client. +type Client struct { + url string + accessToken string + username string + password string + otp string + sudo string + userAgent string + debug bool + httpsigner *HTTPSign + client *http.Client + ctx context.Context + mutex sync.RWMutex + serverVersion *version.Version + getVersionOnce sync.Once + ignoreVersion bool // only set by SetGiteaVersion so don't need a mutex lock +} + +// Response represents the gitea response +type Response struct { + *http.Response + + FirstPage int + PrevPage int + NextPage int + LastPage int +} + +// ClientOption are functions used to init a new client +type ClientOption func(*Client) error + +// NewClient initializes and returns a API client. +// Usage of all gitea.Client methods is concurrency-safe. +func NewClient(url string, options ...ClientOption) (*Client, error) { + client := &Client{ + url: strings.TrimSuffix(url, "/"), + client: &http.Client{}, + ctx: context.Background(), + } + for _, opt := range options { + if err := opt(client); err != nil { + return nil, err + } + } + if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil { + if errors.Is(err, &ErrUnknownVersion{}) { + return client, err + } + return nil, err + } + + return client, nil +} + +// NewClientWithHTTP creates an API client with a custom http client +// Deprecated use SetHTTPClient option +func NewClientWithHTTP(url string, httpClient *http.Client) *Client { + client, _ := NewClient(url, SetHTTPClient(httpClient)) + return client +} + +// SetHTTPClient is an option for NewClient to set custom http client +func SetHTTPClient(httpClient *http.Client) ClientOption { + return func(client *Client) error { + client.SetHTTPClient(httpClient) + return nil + } +} + +// SetHTTPClient replaces default http.Client with user given one. +func (c *Client) SetHTTPClient(client *http.Client) { + c.mutex.Lock() + c.client = client + c.mutex.Unlock() +} + +// SetToken is an option for NewClient to set token +func SetToken(token string) ClientOption { + return func(client *Client) error { + client.mutex.Lock() + client.accessToken = token + client.mutex.Unlock() + return nil + } +} + +// SetBasicAuth is an option for NewClient to set username and password +func SetBasicAuth(username, password string) ClientOption { + return func(client *Client) error { + client.SetBasicAuth(username, password) + return nil + } +} + +// UseSSHCert is an option for NewClient to enable SSH certificate authentication via HTTPSign +// If you want to auth against the ssh-agent you'll need to set a principal, if you want to +// use a file on disk you'll need to specify sshKey. +// If you have an encrypted sshKey you'll need to also set the passphrase. +func UseSSHCert(principal, sshKey, passphrase string) ClientOption { + return func(client *Client) error { + if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil { + return err + } + + client.mutex.Lock() + defer client.mutex.Unlock() + + var err error + client.httpsigner, err = NewHTTPSignWithCert(principal, sshKey, passphrase) + if err != nil { + return err + } + + return nil + } +} + +// UseSSHPubkey is an option for NewClient to enable SSH pubkey authentication via HTTPSign +// If you want to auth against the ssh-agent you'll need to set a fingerprint, if you want to +// use a file on disk you'll need to specify sshKey. +// If you have an encrypted sshKey you'll need to also set the passphrase. +func UseSSHPubkey(fingerprint, sshKey, passphrase string) ClientOption { + return func(client *Client) error { + if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil { + return err + } + + client.mutex.Lock() + defer client.mutex.Unlock() + + var err error + client.httpsigner, err = NewHTTPSignWithPubkey(fingerprint, sshKey, passphrase) + if err != nil { + return err + } + + return nil + } +} + +// SetBasicAuth sets username and password +func (c *Client) SetBasicAuth(username, password string) { + c.mutex.Lock() + c.username, c.password = username, password + c.mutex.Unlock() +} + +// SetOTP is an option for NewClient to set OTP for 2FA +func SetOTP(otp string) ClientOption { + return func(client *Client) error { + client.SetOTP(otp) + return nil + } +} + +// SetOTP sets OTP for 2FA +func (c *Client) SetOTP(otp string) { + c.mutex.Lock() + c.otp = otp + c.mutex.Unlock() +} + +// SetContext is an option for NewClient to set the default context +func SetContext(ctx context.Context) ClientOption { + return func(client *Client) error { + client.SetContext(ctx) + return nil + } +} + +// SetContext set default context witch is used for http requests +func (c *Client) SetContext(ctx context.Context) { + c.mutex.Lock() + c.ctx = ctx + c.mutex.Unlock() +} + +// SetSudo is an option for NewClient to set sudo header +func SetSudo(sudo string) ClientOption { + return func(client *Client) error { + client.SetSudo(sudo) + return nil + } +} + +// SetSudo sets username to impersonate. +func (c *Client) SetSudo(sudo string) { + c.mutex.Lock() + c.sudo = sudo + c.mutex.Unlock() +} + +// SetUserAgent is an option for NewClient to set user-agent header +func SetUserAgent(userAgent string) ClientOption { + return func(client *Client) error { + client.SetUserAgent(userAgent) + return nil + } +} + +// SetUserAgent sets the user-agent to send with every request. +func (c *Client) SetUserAgent(userAgent string) { + c.mutex.Lock() + c.userAgent = userAgent + c.mutex.Unlock() +} + +// SetDebugMode is an option for NewClient to enable debug mode +func SetDebugMode() ClientOption { + return func(client *Client) error { + client.mutex.Lock() + client.debug = true + client.mutex.Unlock() + return nil + } +} + +func newResponse(r *http.Response) *Response { + response := &Response{Response: r} + response.parseLinkHeader() + + return response +} + +func (r *Response) parseLinkHeader() { + link := r.Header.Get("Link") + if link == "" { + return + } + + links := strings.Split(link, ",") + for _, l := range links { + u, param, ok := strings.Cut(l, ";") + if !ok { + continue + } + u = strings.Trim(u, " <>") + + key, value, ok := strings.Cut(strings.TrimSpace(param), "=") + if !ok || key != "rel" { + continue + } + + value = strings.Trim(value, "\"") + + parsed, err := url.Parse(u) + if err != nil { + continue + } + + page := parsed.Query().Get("page") + if page == "" { + continue + } + + switch value { + case "first": + r.FirstPage, _ = strconv.Atoi(page) + case "prev": + r.PrevPage, _ = strconv.Atoi(page) + case "next": + r.NextPage, _ = strconv.Atoi(page) + case "last": + r.LastPage, _ = strconv.Atoi(page) + } + } +} + +func (c *Client) getWebResponse(method, path string, body io.Reader) ([]byte, *Response, error) { + c.mutex.RLock() + debug := c.debug + if debug { + fmt.Printf("%s: %s\nBody: %v\n", method, c.url+path, body) + } + req, err := http.NewRequestWithContext(c.ctx, method, c.url+path, body) + + client := c.client // client ref can change from this point on so safe it + c.mutex.RUnlock() + + if err != nil { + return nil, nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if debug { + fmt.Printf("Response: %v\n\n", resp) + } + + return data, newResponse(resp), err +} + +func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) { + c.mutex.RLock() + debug := c.debug + if debug { + var bodyStr string + if body != nil { + bs, _ := io.ReadAll(body) + body = bytes.NewReader(bs) + bodyStr = string(bs) + } + fmt.Printf("%s: %s\nHeader: %v\nBody: %s\n", method, c.url+"/api/v1"+path, header, bodyStr) + } + req, err := http.NewRequestWithContext(c.ctx, method, c.url+"/api/v1"+path, body) + if err != nil { + c.mutex.RUnlock() + return nil, err + } + if len(c.accessToken) != 0 { + req.Header.Set("Authorization", "token "+c.accessToken) + } + if len(c.otp) != 0 { + req.Header.Set("X-GITEA-OTP", c.otp) + } + if len(c.username) != 0 { + req.SetBasicAuth(c.username, c.password) + } + if len(c.sudo) != 0 { + req.Header.Set("Sudo", c.sudo) + } + if len(c.userAgent) != 0 { + req.Header.Set("User-Agent", c.userAgent) + } + + client := c.client // client ref can change from this point on so safe it + c.mutex.RUnlock() + + for k, v := range header { + req.Header[k] = v + } + + if c.httpsigner != nil { + err = c.SignRequest(req) + if err != nil { + return nil, err + } + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + if debug { + fmt.Printf("Response: %v\n\n", resp) + } + + return newResponse(resp), nil +} + +// Converts a response for a HTTP status code indicating an error condition +// (non-2XX) to a well-known error value and response body. For non-problematic +// (2XX) status codes nil will be returned. Note that on a non-2XX response, the +// response body stream will have been read and, hence, is closed on return. +func statusCodeToErr(resp *Response) (body []byte, err error) { + // no error + if resp.StatusCode/100 == 2 { + return nil, nil + } + + // + // error: body will be read for details + // + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("body read on HTTP error %d: %v", resp.StatusCode, err) + } + + // Try to unmarshal and get an error message + errMap := make(map[string]interface{}) + if err = json.Unmarshal(data, &errMap); err != nil { + // when the JSON can't be parsed, data was probably empty or a + // plain string, so we try to return a helpful error anyway + path := resp.Request.URL.Path + method := resp.Request.Method + header := resp.Request.Header + return data, fmt.Errorf("Unknown API Error: %d\nRequest: '%s' with '%s' method '%s' header and '%s' body", resp.StatusCode, path, method, header, string(data)) + } + + if msg, ok := errMap["message"]; ok { + return data, fmt.Errorf("%v", msg) + } + + // If no error message, at least give status and data + return data, fmt.Errorf("%s: %s", resp.Status, string(data)) +} + +func (c *Client) getResponseReader(method, path string, header http.Header, body io.Reader) (io.ReadCloser, *Response, error) { + resp, err := c.doRequest(method, path, header, body) + if err != nil { + return nil, resp, err + } + + // check for errors + data, err := statusCodeToErr(resp) + if err != nil { + return io.NopCloser(bytes.NewReader(data)), resp, err + } + + return resp.Body, resp, nil +} + +func (c *Client) getResponse(method, path string, header http.Header, body io.Reader) ([]byte, *Response, error) { + resp, err := c.doRequest(method, path, header, body) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + // check for errors + data, err := statusCodeToErr(resp) + if err != nil { + return data, resp, err + } + + // success (2XX), read body + data, err = io.ReadAll(resp.Body) + if err != nil { + return nil, resp, err + } + + return data, resp, nil +} + +func (c *Client) getParsedResponse(method, path string, header http.Header, body io.Reader, obj interface{}) (*Response, error) { + data, resp, err := c.getResponse(method, path, header, body) + if err != nil { + return resp, err + } + return resp, json.Unmarshal(data, obj) +} + +func (c *Client) getStatusCode(method, path string, header http.Header, body io.Reader) (int, *Response, error) { + resp, err := c.doRequest(method, path, header, body) + if err != nil { + return -1, resp, err + } + defer resp.Body.Close() + + return resp.StatusCode, resp, nil +} + +// pathEscapeSegments escapes segments of a path while not escaping forward slash +func pathEscapeSegments(path string) string { + slice := strings.Split(path, "/") + for index := range slice { + slice[index] = url.PathEscape(slice[index]) + } + escapedPath := strings.Join(slice, "/") + return escapedPath +} + +// escapeValidatePathSegments is a help function to validate and encode url path segments +func escapeValidatePathSegments(seg ...*string) error { + for i := range seg { + if seg[i] == nil || len(*seg[i]) == 0 { + return fmt.Errorf("path segment [%d] is empty", i) + } + *seg[i] = url.PathEscape(*seg[i]) + } + return nil +} diff --git a/forges/forgejo/sdk/fork.go b/forges/forgejo/sdk/fork.go new file mode 100644 index 0000000..0db2147 --- /dev/null +++ b/forges/forgejo/sdk/fork.go @@ -0,0 +1,51 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ListForksOptions options for listing repository's forks +type ListForksOptions struct { + ListOptions +} + +// ListForks list a repository's forks +func (c *Client) ListForks(user, repo string, opt ListForksOptions) ([]*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + forks := make([]*Repository, opt.PageSize) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/forks?%s", user, repo, opt.getURLQuery().Encode()), + nil, nil, &forks) + return forks, resp, err +} + +// CreateForkOption options for creating a fork +type CreateForkOption struct { + // organization name, if forking into an organization + Organization *string `json:"organization"` + // name of the forked repository + Name *string `json:"name"` +} + +// CreateFork create a fork of a repository +func (c *Client) CreateFork(user, repo string, form CreateForkOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(form) + if err != nil { + return nil, nil, err + } + fork := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/forks", user, repo), jsonHeader, bytes.NewReader(body), &fork) + return fork, resp, err +} diff --git a/forges/forgejo/sdk/git_blob.go b/forges/forgejo/sdk/git_blob.go new file mode 100644 index 0000000..15ae0c8 --- /dev/null +++ b/forges/forgejo/sdk/git_blob.go @@ -0,0 +1,28 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" +) + +// GitBlobResponse represents a git blob +type GitBlobResponse struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + URL string `json:"url"` + SHA string `json:"sha"` + Size int64 `json:"size"` +} + +// GetBlob get the blob of a repository file +func (c *Client) GetBlob(user, repo, sha string) (*GitBlobResponse, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &sha); err != nil { + return nil, nil, err + } + blob := new(GitBlobResponse) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/blobs/%s", user, repo, sha), nil, nil, blob) + return blob, resp, err +} diff --git a/forges/forgejo/sdk/git_hook.go b/forges/forgejo/sdk/git_hook.go new file mode 100644 index 0000000..a7433e6 --- /dev/null +++ b/forges/forgejo/sdk/git_hook.go @@ -0,0 +1,71 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// GitHook represents a Git repository hook +type GitHook struct { + Name string `json:"name"` + IsActive bool `json:"is_active"` + Content string `json:"content,omitempty"` +} + +// ListRepoGitHooksOptions options for listing repository's githooks +type ListRepoGitHooksOptions struct { + ListOptions +} + +// ListRepoGitHooks list all the Git hooks of one repository +func (c *Client) ListRepoGitHooks(user, repo string, opt ListRepoGitHooksOptions) ([]*GitHook, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + hooks := make([]*GitHook, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/git?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &hooks) + return hooks, resp, err +} + +// GetRepoGitHook get a Git hook of a repository +func (c *Client) GetRepoGitHook(user, repo, id string) (*GitHook, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &id); err != nil { + return nil, nil, err + } + h := new(GitHook) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), nil, nil, h) + return h, resp, err +} + +// EditGitHookOption options when modifying one Git hook +type EditGitHookOption struct { + Content string `json:"content"` +} + +// EditRepoGitHook modify one Git hook of a repository +func (c *Client) EditRepoGitHook(user, repo, id string, opt EditGitHookOption) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &id); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteRepoGitHook delete one Git hook from a repository +func (c *Client) DeleteRepoGitHook(user, repo, id string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &id); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/gof3_topic.go b/forges/forgejo/sdk/gof3_topic.go new file mode 100644 index 0000000..ae4dd7b --- /dev/null +++ b/forges/forgejo/sdk/gof3_topic.go @@ -0,0 +1,38 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package sdk + +import ( + "fmt" + "time" +) + +type Topic struct { + ID int64 `json:"id"` + Name string `json:"topic_name"` + RepoCount int `json:"repo_count"` + Created *time.Time `json:"created_at"` + Updated *time.Time `json:"updated_at"` +} + +type SearchTopicsOptions struct { + ListOptions + Keyword string +} + +func (opt *SearchTopicsOptions) QueryEncode() string { + query := opt.getURLQuery() + if opt.Keyword != "" { + query.Add("q", opt.Keyword) + } + return query.Encode() +} + +func (c *Client) SearchTopics(opt SearchTopicsOptions) ([]*Topic, *Response, error) { + opt.setDefaults() + topics := make([]*Topic, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/topics/search?%s", opt.getURLQuery().Encode()), nil, nil, &topics) + return topics, resp, err +} diff --git a/forges/forgejo/sdk/helper.go b/forges/forgejo/sdk/helper.go new file mode 100644 index 0000000..dc6dcfd --- /dev/null +++ b/forges/forgejo/sdk/helper.go @@ -0,0 +1,20 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +// OptionalBool convert a bool to a bool reference +func OptionalBool(v bool) *bool { + return &v +} + +// OptionalString convert a string to a string reference +func OptionalString(v string) *string { + return &v +} + +// OptionalInt64 convert a int64 to a int64 reference +func OptionalInt64(v int64) *int64 { + return &v +} diff --git a/forges/forgejo/sdk/hook.go b/forges/forgejo/sdk/hook.go new file mode 100644 index 0000000..5a4b6eb --- /dev/null +++ b/forges/forgejo/sdk/hook.go @@ -0,0 +1,196 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// Hook a hook is a web hook when one repository changed +type Hook struct { + ID int64 `json:"id"` + Type string `json:"type"` + URL string `json:"-"` + Config map[string]string `json:"config"` + Events []string `json:"events"` + Active bool `json:"active"` + Updated time.Time `json:"updated_at"` + Created time.Time `json:"created_at"` +} + +// HookType represent all webhook types gitea currently offer +type HookType string + +const ( + // HookTypeDingtalk webhook that dingtalk understand + HookTypeDingtalk HookType = "dingtalk" + // HookTypeDiscord webhook that discord understand + HookTypeDiscord HookType = "discord" + // HookTypeGitea webhook that gitea understand + HookTypeGitea HookType = "gitea" + // HookTypeGogs webhook that gogs understand + HookTypeGogs HookType = "gogs" + // HookTypeMsteams webhook that msteams understand + HookTypeMsteams HookType = "msteams" + // HookTypeSlack webhook that slack understand + HookTypeSlack HookType = "slack" + // HookTypeTelegram webhook that telegram understand + HookTypeTelegram HookType = "telegram" + // HookTypeFeishu webhook that feishu understand + HookTypeFeishu HookType = "feishu" +) + +// ListHooksOptions options for listing hooks +type ListHooksOptions struct { + ListOptions +} + +// ListOrgHooks list all the hooks of one organization +func (c *Client) ListOrgHooks(org string, opt ListHooksOptions) ([]*Hook, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + hooks := make([]*Hook, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/hooks?%s", org, opt.getURLQuery().Encode()), nil, nil, &hooks) + return hooks, resp, err +} + +// ListRepoHooks list all the hooks of one repository +func (c *Client) ListRepoHooks(user, repo string, opt ListHooksOptions) ([]*Hook, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + hooks := make([]*Hook, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &hooks) + return hooks, resp, err +} + +// GetOrgHook get a hook of an organization +func (c *Client) GetOrgHook(org string, id int64) (*Hook, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + h := new(Hook) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), nil, nil, h) + return h, resp, err +} + +// GetRepoHook get a hook of a repository +func (c *Client) GetRepoHook(user, repo string, id int64) (*Hook, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + h := new(Hook) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), nil, nil, h) + return h, resp, err +} + +// CreateHookOption options when create a hook +type CreateHookOption struct { + Type HookType `json:"type"` + Config map[string]string `json:"config"` + Events []string `json:"events"` + BranchFilter string `json:"branch_filter"` + Active bool `json:"active"` + AuthorizationHeader string `json:"authorization_header"` +} + +// Validate the CreateHookOption struct +func (opt CreateHookOption) Validate() error { + if len(opt.Type) == 0 { + return fmt.Errorf("hook type needed") + } + return nil +} + +// CreateOrgHook create one hook for an organization, with options +func (c *Client) CreateOrgHook(org string, opt CreateHookOption) (*Hook, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + h := new(Hook) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/hooks", org), jsonHeader, bytes.NewReader(body), h) + return h, resp, err +} + +// CreateRepoHook create one hook for a repository, with options +func (c *Client) CreateRepoHook(user, repo string, opt CreateHookOption) (*Hook, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + h := new(Hook) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/hooks", user, repo), jsonHeader, bytes.NewReader(body), h) + return h, resp, err +} + +// EditHookOption options when modify one hook +type EditHookOption struct { + Config map[string]string `json:"config"` + Events []string `json:"events"` + BranchFilter string `json:"branch_filter"` + Active *bool `json:"active"` + AuthorizationHeader string `json:"authorization_header"` +} + +// EditOrgHook modify one hook of an organization, with hook id and options +func (c *Client) EditOrgHook(org string, id int64, opt EditHookOption) (*Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// EditRepoHook modify one hook of a repository, with hook id and options +func (c *Client) EditRepoHook(user, repo string, id int64, opt EditHookOption) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteOrgHook delete one hook from an organization, with hook id +func (c *Client) DeleteOrgHook(org string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), nil, nil) + return resp, err +} + +// DeleteRepoHook delete one hook from a repository, with hook id +func (c *Client) DeleteRepoHook(user, repo string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/hook_validate.go b/forges/forgejo/sdk/hook_validate.go new file mode 100644 index 0000000..3456c2e --- /dev/null +++ b/forges/forgejo/sdk/hook_validate.go @@ -0,0 +1,59 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" +) + +// VerifyWebhookSignature verifies that a payload matches the X-Gitea-Signature based on a secret +func VerifyWebhookSignature(secret, expected string, payload []byte) (bool, error) { + hash := hmac.New(sha256.New, []byte(secret)) + if _, err := hash.Write(payload); err != nil { + return false, err + } + expectedSum, err := hex.DecodeString(expected) + if err != nil { + return false, err + } + return hmac.Equal(hash.Sum(nil), expectedSum), nil +} + +// VerifyWebhookSignatureMiddleware is a http.Handler for verifying X-Gitea-Signature on incoming webhooks +func VerifyWebhookSignatureMiddleware(secret string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b bytes.Buffer + if _, err := io.Copy(&b, r.Body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + expected := r.Header.Get("X-Gitea-Signature") + if expected == "" { + http.Error(w, "no signature found", http.StatusBadRequest) + return + } + + ok, err := VerifyWebhookSignature(secret, expected, b.Bytes()) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if !ok { + http.Error(w, "invalid payload", http.StatusUnauthorized) + return + } + + r.Body = io.NopCloser(&b) + next.ServeHTTP(w, r) + }) + } +} diff --git a/forges/forgejo/sdk/httpsign.go b/forges/forgejo/sdk/httpsign.go new file mode 100644 index 0000000..6703b57 --- /dev/null +++ b/forges/forgejo/sdk/httpsign.go @@ -0,0 +1,253 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "crypto" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/42wim/httpsig" + "golang.org/x/crypto/ssh" +) + +// HTTPSign contains the signer used for signing requests +type HTTPSign struct { + ssh.Signer + cert bool +} + +// HTTPSignConfig contains the configuration for creating a HTTPSign +type HTTPSignConfig struct { + fingerprint string + principal string + pubkey bool + cert bool + sshKey string + passphrase string +} + +// NewHTTPSignWithPubkey can be used to create a HTTPSign with a public key +// if no fingerprint is specified it returns the first public key found +func NewHTTPSignWithPubkey(fingerprint, sshKey, passphrase string) (*HTTPSign, error) { + return newHTTPSign(&HTTPSignConfig{ + fingerprint: fingerprint, + pubkey: true, + sshKey: sshKey, + passphrase: passphrase, + }) +} + +// NewHTTPSignWithCert can be used to create a HTTPSign with a certificate +// if no principal is specified it returns the first certificate found +func NewHTTPSignWithCert(principal, sshKey, passphrase string) (*HTTPSign, error) { + return newHTTPSign(&HTTPSignConfig{ + principal: principal, + cert: true, + sshKey: sshKey, + passphrase: passphrase, + }) +} + +// NewHTTPSign returns a new HTTPSign +// It will check the ssh-agent or a local file is config.sshKey is set. +// Depending on the configuration it will either use a certificate or a public key +func newHTTPSign(config *HTTPSignConfig) (*HTTPSign, error) { + var signer ssh.Signer + + if config.sshKey != "" { + priv, err := os.ReadFile(config.sshKey) + if err != nil { + return nil, err + } + + if config.passphrase == "" { + signer, err = ssh.ParsePrivateKey(priv) + if err != nil { + return nil, err + } + } else { + signer, err = ssh.ParsePrivateKeyWithPassphrase(priv, []byte(config.passphrase)) + if err != nil { + return nil, err + } + } + + if config.cert { + certbytes, err := os.ReadFile(config.sshKey + "-cert.pub") + if err != nil { + return nil, err + } + + pub, _, _, _, err := ssh.ParseAuthorizedKey(certbytes) + if err != nil { + return nil, err + } + + cert, ok := pub.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("failed to parse certificate") + } + + signer, err = ssh.NewCertSigner(cert, signer) + if err != nil { + return nil, err + } + } + } else { + // if no sshKey is specified, check if we have a ssh-agent and use it + agent, err := GetAgent() + if err != nil { + return nil, err + } + + signers, err := agent.Signers() + if err != nil { + return nil, err + } + + if len(signers) == 0 { + return nil, fmt.Errorf("no signers found") + } + + if config.cert { + signer = findCertSigner(signers, config.principal) + if signer == nil { + return nil, fmt.Errorf("no certificate found for %s", config.principal) + } + } + + if config.pubkey { + signer = findPubkeySigner(signers, config.fingerprint) + if signer == nil { + return nil, fmt.Errorf("no public key found for %s", config.fingerprint) + } + } + } + + return &HTTPSign{ + Signer: signer, + cert: config.cert, + }, nil +} + +// SignRequest signs a HTTP request +func (c *Client) SignRequest(r *http.Request) error { + var contents []byte + + headersToSign := []string{httpsig.RequestTarget, "(created)", "(expires)"} + + if c.httpsigner.cert { + // add our certificate to the headers to sign + pubkey, _ := ssh.ParsePublicKey(c.httpsigner.Signer.PublicKey().Marshal()) + if cert, ok := pubkey.(*ssh.Certificate); ok { + certString := base64.RawStdEncoding.EncodeToString(cert.Marshal()) + r.Header.Add("x-ssh-certificate", certString) + + headersToSign = append(headersToSign, "x-ssh-certificate") + } else { + return fmt.Errorf("no ssh certificate found") + } + } + + // if we have a body, the Digest header will be added and we'll include this also in + // our signature. + if r.Body != nil { + body, err := r.GetBody() + if err != nil { + return fmt.Errorf("getBody() failed: %s", err) + } + + contents, err = io.ReadAll(body) + if err != nil { + return fmt.Errorf("failed reading body: %s", err) + } + + headersToSign = append(headersToSign, "Digest") + } + + // create a signer for the request and headers, the signature will be valid for 10 seconds + signer, _, err := httpsig.NewSSHSigner(c.httpsigner.Signer, httpsig.DigestSha512, headersToSign, httpsig.Signature, 10) + if err != nil { + return fmt.Errorf("httpsig.NewSSHSigner failed: %s", err) + } + + // sign the request, use the fingerprint if we don't have a certificate + keyID := "gitea" + if !c.httpsigner.cert { + keyID = ssh.FingerprintSHA256(c.httpsigner.Signer.PublicKey()) + } + + err = signer.SignRequest(keyID, r, contents) + if err != nil { + return fmt.Errorf("httpsig.Signrequest failed: %s", err) + } + + return nil +} + +// findCertSigner returns the Signer containing a valid certificate +// if no principal is specified it returns the first certificate found +func findCertSigner(sshsigners []ssh.Signer, principal string) ssh.Signer { + for _, s := range sshsigners { + // Check if the key is a certificate + if !strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") { + continue + } + + // convert the ssh.Signer to a ssh.Certificate + mpubkey, _ := ssh.ParsePublicKey(s.PublicKey().Marshal()) + cryptopub := mpubkey.(crypto.PublicKey) + cert := cryptopub.(*ssh.Certificate) + t := time.Unix(int64(cert.ValidBefore), 0) + + // make sure the certificate is at least 10 seconds valid + if time.Until(t) <= time.Second*10 { + continue + } + + if principal == "" { + return s + } + + for _, p := range cert.ValidPrincipals { + if p == principal { + return s + } + } + } + + return nil +} + +// findPubkeySigner returns the Signer containing a valid public key +// if no fingerprint is specified it returns the first public key found +func findPubkeySigner(sshsigners []ssh.Signer, fingerprint string) ssh.Signer { + for _, s := range sshsigners { + // Check if the key is a certificate + if strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") { + continue + } + + if fingerprint == "" { + return s + } + + if strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey()))) == fingerprint { + return s + } + + if ssh.FingerprintSHA256(s.PublicKey()) == fingerprint { + return s + } + } + + return nil +} diff --git a/forges/forgejo/sdk/issue.go b/forges/forgejo/sdk/issue.go new file mode 100644 index 0000000..e4959a0 --- /dev/null +++ b/forges/forgejo/sdk/issue.go @@ -0,0 +1,309 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" +) + +// PullRequestMeta PR info if an issue is a PR +type PullRequestMeta struct { + HasMerged bool `json:"merged"` + Merged *time.Time `json:"merged_at"` +} + +// RepositoryMeta basic repository information +type RepositoryMeta struct { + ID int64 `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + FullName string `json:"full_name"` +} + +// Issue represents an issue in a repository +type Issue struct { + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Index int64 `json:"number"` + Poster *User `json:"user"` + OriginalAuthor string `json:"original_author"` + OriginalAuthorID int64 `json:"original_author_id"` + Title string `json:"title"` + Body string `json:"body"` + Ref string `json:"ref"` + Labels []*Label `json:"labels"` + Milestone *Milestone `json:"milestone"` + Assignees []*User `json:"assignees"` + // Whether the issue is open or closed + State StateType `json:"state"` + IsLocked bool `json:"is_locked"` + Comments int `json:"comments"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` + Closed *time.Time `json:"closed_at"` + Deadline *time.Time `json:"due_date"` + PullRequest *PullRequestMeta `json:"pull_request"` + Repository *RepositoryMeta `json:"repository"` +} + +// ListIssueOption list issue options +type ListIssueOption struct { + ListOptions + State StateType + Type IssueType + Labels []string + Milestones []string + KeyWord string + Since time.Time + Before time.Time + // filter by created by username + CreatedBy string + // filter by assigned to username + AssignedBy string + // filter by username mentioned + MentionedBy string + // filter by owner (only works on ListIssues on User) + Owner string + // filter by team (requires organization owner parameter to be provided and only works on ListIssues on User) + Team string +} + +// StateType issue state type +type StateType string + +const ( + // StateOpen pr/issue is opend + StateOpen StateType = "open" + // StateClosed pr/issue is closed + StateClosed StateType = "closed" + // StateAll is all + StateAll StateType = "all" +) + +// IssueType is issue a pull or only an issue +type IssueType string + +const ( + // IssueTypeAll pr and issue + IssueTypeAll IssueType = "" + // IssueTypeIssue only issues + IssueTypeIssue IssueType = "issues" + // IssueTypePull only pulls + IssueTypePull IssueType = "pulls" +) + +// QueryEncode turns options into querystring argument +func (opt *ListIssueOption) QueryEncode() string { + query := opt.getURLQuery() + + if len(opt.State) > 0 { + query.Add("state", string(opt.State)) + } + + if len(opt.Labels) > 0 { + query.Add("labels", strings.Join(opt.Labels, ",")) + } + + if len(opt.KeyWord) > 0 { + query.Add("q", opt.KeyWord) + } + + query.Add("type", string(opt.Type)) + + if len(opt.Milestones) > 0 { + query.Add("milestones", strings.Join(opt.Milestones, ",")) + } + + if !opt.Since.IsZero() { + query.Add("since", opt.Since.Format(time.RFC3339)) + } + if !opt.Before.IsZero() { + query.Add("before", opt.Before.Format(time.RFC3339)) + } + + if len(opt.CreatedBy) > 0 { + query.Add("created_by", opt.CreatedBy) + } + if len(opt.AssignedBy) > 0 { + query.Add("assigned_by", opt.AssignedBy) + } + if len(opt.MentionedBy) > 0 { + query.Add("mentioned_by", opt.MentionedBy) + } + if len(opt.Owner) > 0 { + query.Add("owner", opt.Owner) + } + if len(opt.Team) > 0 { + query.Add("team", opt.MentionedBy) + } + + return query.Encode() +} + +// ListIssues returns all issues assigned the authenticated user +func (c *Client) ListIssues(opt ListIssueOption) ([]*Issue, *Response, error) { + opt.setDefaults() + issues := make([]*Issue, 0, opt.PageSize) + + link, _ := url.Parse("/repos/issues/search") + link.RawQuery = opt.QueryEncode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues) + if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil { + for i := 0; i < len(issues); i++ { + if issues[i].Repository != nil { + issues[i].Repository.Owner = strings.Split(issues[i].Repository.FullName, "/")[0] + } + } + } + for i := range issues { + c.issueBackwardsCompatibility(issues[i]) + } + return issues, resp, err +} + +// ListRepoIssues returns all issues for a given repository +func (c *Client) ListRepoIssues(owner, repo string, opt ListIssueOption) ([]*Issue, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + issues := make([]*Issue, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues", owner, repo)) + link.RawQuery = opt.QueryEncode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues) + if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil { + for i := 0; i < len(issues); i++ { + if issues[i].Repository != nil { + issues[i].Repository.Owner = strings.Split(issues[i].Repository.FullName, "/")[0] + } + } + } + for i := range issues { + c.issueBackwardsCompatibility(issues[i]) + } + return issues, resp, err +} + +// GetIssue returns a single issue for a given repository +func (c *Client) GetIssue(owner, repo string, index int64) (*Issue, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + issue := new(Issue) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index), nil, nil, issue) + if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil && issue.Repository != nil { + issue.Repository.Owner = strings.Split(issue.Repository.FullName, "/")[0] + } + c.issueBackwardsCompatibility(issue) + return issue, resp, err +} + +// CreateIssueOption options to create one issue +type CreateIssueOption struct { + Title string `json:"title"` + Body string `json:"body"` + Ref string `json:"ref"` + Assignees []string `json:"assignees"` + Deadline *time.Time `json:"due_date"` + // milestone id + Milestone int64 `json:"milestone"` + // list of label ids + Labels []int64 `json:"labels"` + Closed bool `json:"closed"` +} + +// Validate the CreateIssueOption struct +func (opt CreateIssueOption) Validate() error { + if len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + return nil +} + +// CreateIssue create a new issue for a given repository +func (c *Client) CreateIssue(owner, repo string, opt CreateIssueOption) (*Issue, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + issue := new(Issue) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues", owner, repo), + jsonHeader, bytes.NewReader(body), issue) + c.issueBackwardsCompatibility(issue) + return issue, resp, err +} + +// EditIssueOption options for editing an issue +type EditIssueOption struct { + Title string `json:"title"` + Body *string `json:"body"` + Ref *string `json:"ref"` + Assignees []string `json:"assignees"` + Milestone *int64 `json:"milestone"` + State *StateType `json:"state"` + Deadline *time.Time `json:"due_date"` + RemoveDeadline *bool `json:"unset_due_date"` +} + +// Validate the EditIssueOption struct +func (opt EditIssueOption) Validate() error { + if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + return nil +} + +// EditIssue modify an existing issue for a given repository +func (c *Client) EditIssue(owner, repo string, index int64, opt EditIssueOption) (*Issue, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + issue := new(Issue) + resp, err := c.getParsedResponse("PATCH", + fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index), + jsonHeader, bytes.NewReader(body), issue) + c.issueBackwardsCompatibility(issue) + return issue, resp, err +} + +// DeleteIssue delete a issue from a repository +func (c *Client) DeleteIssue(user, repo string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/issues/%d", user, repo, id), + nil, nil) + return resp, err +} + +func (c *Client) issueBackwardsCompatibility(issue *Issue) { + if c.checkServerVersionGreaterThanOrEqual(version1_12_0) != nil { + c.mutex.RLock() + issue.HTMLURL = fmt.Sprintf("%s/%s/issues/%d", c.url, issue.Repository.FullName, issue.Index) + c.mutex.RUnlock() + } +} diff --git a/forges/forgejo/sdk/issue_comment.go b/forges/forgejo/sdk/issue_comment.go new file mode 100644 index 0000000..6e27ce7 --- /dev/null +++ b/forges/forgejo/sdk/issue_comment.go @@ -0,0 +1,154 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// Comment represents a comment on a commit or issue +type Comment struct { + ID int64 `json:"id"` + HTMLURL string `json:"html_url"` + PRURL string `json:"pull_request_url"` + IssueURL string `json:"issue_url"` + Poster *User `json:"user"` + OriginalAuthor string `json:"original_author"` + OriginalAuthorID int64 `json:"original_author_id"` + Body string `json:"body"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` +} + +// ListIssueCommentOptions list comment options +type ListIssueCommentOptions struct { + ListOptions + Since time.Time + Before time.Time +} + +// QueryEncode turns options into querystring argument +func (opt *ListIssueCommentOptions) QueryEncode() string { + query := opt.getURLQuery() + if !opt.Since.IsZero() { + query.Add("since", opt.Since.Format(time.RFC3339)) + } + if !opt.Before.IsZero() { + query.Add("before", opt.Before.Format(time.RFC3339)) + } + return query.Encode() +} + +// ListIssueComments list comments on an issue. +func (c *Client) ListIssueComments(owner, repo string, index int64, opt ListIssueCommentOptions) ([]*Comment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index)) + link.RawQuery = opt.QueryEncode() + comments := make([]*Comment, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments) + return comments, resp, err +} + +// ListRepoIssueComments list comments for a given repo. +func (c *Client) ListRepoIssueComments(owner, repo string, opt ListIssueCommentOptions) ([]*Comment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/comments", owner, repo)) + link.RawQuery = opt.QueryEncode() + comments := make([]*Comment, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments) + return comments, resp, err +} + +// GetIssueComment get a comment for a given repo by id. +func (c *Client) GetIssueComment(owner, repo string, id int64) (*Comment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + comment := new(Comment) + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return comment, nil, err + } + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, id), nil, nil, &comment) + return comment, resp, err +} + +// CreateIssueCommentOption options for creating a comment on an issue +type CreateIssueCommentOption struct { + Body string `json:"body"` +} + +// Validate the CreateIssueCommentOption struct +func (opt CreateIssueCommentOption) Validate() error { + if len(opt.Body) == 0 { + return fmt.Errorf("body is empty") + } + return nil +} + +// CreateIssueComment create comment on an issue. +func (c *Client) CreateIssueComment(owner, repo string, index int64, opt CreateIssueCommentOption) (*Comment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + comment := new(Comment) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index), jsonHeader, bytes.NewReader(body), comment) + return comment, resp, err +} + +// EditIssueCommentOption options for editing a comment +type EditIssueCommentOption struct { + Body string `json:"body"` +} + +// Validate the EditIssueCommentOption struct +func (opt EditIssueCommentOption) Validate() error { + if len(opt.Body) == 0 { + return fmt.Errorf("body is empty") + } + return nil +} + +// EditIssueComment edits an issue comment. +func (c *Client) EditIssueComment(owner, repo string, commentID int64, opt EditIssueCommentOption) (*Comment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + comment := new(Comment) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, commentID), jsonHeader, bytes.NewReader(body), comment) + return comment, resp, err +} + +// DeleteIssueComment deletes an issue comment. +func (c *Client) DeleteIssueComment(owner, repo string, commentID int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, commentID), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/issue_label.go b/forges/forgejo/sdk/issue_label.go new file mode 100644 index 0000000..5bb697d --- /dev/null +++ b/forges/forgejo/sdk/issue_label.go @@ -0,0 +1,211 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// Label a label to an issue or a pr +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + // example: 00aabb + Color string `json:"color"` + Description string `json:"description"` + URL string `json:"url"` +} + +// ListLabelsOptions options for listing repository's labels +type ListLabelsOptions struct { + ListOptions +} + +// ListRepoLabels list labels of one repository +func (c *Client) ListRepoLabels(owner, repo string, opt ListLabelsOptions) ([]*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + labels := make([]*Label, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/labels?%s", owner, repo, opt.getURLQuery().Encode()), nil, nil, &labels) + return labels, resp, err +} + +// GetRepoLabel get one label of repository by repo it +func (c *Client) GetRepoLabel(owner, repo string, id int64) (*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + label := new(Label) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), nil, nil, label) + return label, resp, err +} + +// CreateLabelOption options for creating a label +type CreateLabelOption struct { + Name string `json:"name"` + // example: #00aabb + Color string `json:"color"` + Description string `json:"description"` +} + +// Validate the CreateLabelOption struct +func (opt CreateLabelOption) Validate() error { + aw, err := regexp.MatchString("^#?[0-9,a-f,A-F]{6}$", opt.Color) + if err != nil { + return err + } + if !aw { + return fmt.Errorf("invalid color format") + } + if len(strings.TrimSpace(opt.Name)) == 0 { + return fmt.Errorf("empty name not allowed") + } + return nil +} + +// CreateLabel create one label of repository +func (c *Client) CreateLabel(owner, repo string, opt CreateLabelOption) (*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + if len(opt.Color) == 6 { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + opt.Color = "#" + opt.Color + } + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + label := new(Label) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/labels", owner, repo), + jsonHeader, bytes.NewReader(body), label) + return label, resp, err +} + +// EditLabelOption options for editing a label +type EditLabelOption struct { + Name *string `json:"name"` + Color *string `json:"color"` + Description *string `json:"description"` +} + +// Validate the EditLabelOption struct +func (opt EditLabelOption) Validate() error { + if opt.Color != nil { + aw, err := regexp.MatchString("^#?[0-9,a-f,A-F]{6}$", *opt.Color) + if err != nil { + return err + } + if !aw { + return fmt.Errorf("invalid color format") + } + } + if opt.Name != nil { + if len(strings.TrimSpace(*opt.Name)) == 0 { + return fmt.Errorf("empty name not allowed") + } + } + return nil +} + +// EditLabel modify one label with options +func (c *Client) EditLabel(owner, repo string, id int64, opt EditLabelOption) (*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + label := new(Label) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), jsonHeader, bytes.NewReader(body), label) + return label, resp, err +} + +// DeleteLabel delete one label of repository by id +func (c *Client) DeleteLabel(owner, repo string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), nil, nil) + return resp, err +} + +// GetIssueLabels get labels of one issue via issue id +func (c *Client) GetIssueLabels(owner, repo string, index int64, opts ListLabelsOptions) ([]*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + labels := make([]*Label, 0, 5) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/labels?%s", owner, repo, index, opts.getURLQuery().Encode()), nil, nil, &labels) + return labels, resp, err +} + +// IssueLabelsOption a collection of labels +type IssueLabelsOption struct { + // list of label IDs + Labels []int64 `json:"labels"` +} + +// AddIssueLabels add one or more labels to one issue +func (c *Client) AddIssueLabels(owner, repo string, index int64, opt IssueLabelsOption) ([]*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + var labels []*Label + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), jsonHeader, bytes.NewReader(body), &labels) + return labels, resp, err +} + +// ReplaceIssueLabels replace old labels of issue with new labels +func (c *Client) ReplaceIssueLabels(owner, repo string, index int64, opt IssueLabelsOption) ([]*Label, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + var labels []*Label + resp, err := c.getParsedResponse("PUT", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), jsonHeader, bytes.NewReader(body), &labels) + return labels, resp, err +} + +// DeleteIssueLabel delete one label of one issue by issue id and label id +// TODO: maybe we need delete by label name and issue id +func (c *Client) DeleteIssueLabel(owner, repo string, index, label int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, label), nil, nil) + return resp, err +} + +// ClearIssueLabels delete all the labels of one issue. +func (c *Client) ClearIssueLabels(owner, repo string, index int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/issue_milestone.go b/forges/forgejo/sdk/issue_milestone.go new file mode 100644 index 0000000..58bbecd --- /dev/null +++ b/forges/forgejo/sdk/issue_milestone.go @@ -0,0 +1,237 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" +) + +// Milestone milestone is a collection of issues on one repository +type Milestone struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + State StateType `json:"state"` + OpenIssues int `json:"open_issues"` + ClosedIssues int `json:"closed_issues"` + Created time.Time `json:"created_at"` + Updated *time.Time `json:"updated_at"` + Closed *time.Time `json:"closed_at"` + Deadline *time.Time `json:"due_on"` +} + +// ListMilestoneOption list milestone options +type ListMilestoneOption struct { + ListOptions + // open, closed, all + State StateType + Name string +} + +// QueryEncode turns options into querystring argument +func (opt *ListMilestoneOption) QueryEncode() string { + query := opt.getURLQuery() + if opt.State != "" { + query.Add("state", string(opt.State)) + } + if len(opt.Name) != 0 { + query.Add("name", opt.Name) + } + return query.Encode() +} + +// ListRepoMilestones list all the milestones of one repository +func (c *Client) ListRepoMilestones(owner, repo string, opt ListMilestoneOption) ([]*Milestone, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + milestones := make([]*Milestone, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/milestones", owner, repo)) + link.RawQuery = opt.QueryEncode() + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &milestones) + return milestones, resp, err +} + +// GetMilestone get one milestone by repo name and milestone id +func (c *Client) GetMilestone(owner, repo string, id int64) (*Milestone, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + milestone := new(Milestone) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), nil, nil, milestone) + return milestone, resp, err +} + +// GetMilestoneByName get one milestone by repo and milestone name +func (c *Client) GetMilestoneByName(owner, repo, name string) (*Milestone, *Response, error) { + if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil { + // backwards compatibility mode + m, resp, err := c.resolveMilestoneByName(owner, repo, name) + return m, resp, err + } + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, nil, err + } + milestone := new(Milestone) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), nil, nil, milestone) + return milestone, resp, err +} + +// CreateMilestoneOption options for creating a milestone +type CreateMilestoneOption struct { + Title string `json:"title"` + Description string `json:"description"` + State StateType `json:"state"` + Deadline *time.Time `json:"due_on"` +} + +// Validate the CreateMilestoneOption struct +func (opt CreateMilestoneOption) Validate() error { + if len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + return nil +} + +// CreateMilestone create one milestone with options +func (c *Client) CreateMilestone(owner, repo string, opt CreateMilestoneOption) (*Milestone, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + milestone := new(Milestone) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/milestones", owner, repo), jsonHeader, bytes.NewReader(body), milestone) + + // make creating closed milestones need gitea >= v1.13.0 + // this make it backwards compatible + if err == nil && opt.State == StateClosed && milestone.State != StateClosed { + closed := StateClosed + return c.EditMilestone(owner, repo, milestone.ID, EditMilestoneOption{ + State: &closed, + }) + } + + return milestone, resp, err +} + +// EditMilestoneOption options for editing a milestone +type EditMilestoneOption struct { + Title string `json:"title"` + Description *string `json:"description"` + State *StateType `json:"state"` + Deadline *time.Time `json:"due_on"` +} + +// Validate the EditMilestoneOption struct +func (opt EditMilestoneOption) Validate() error { + if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + return nil +} + +// EditMilestone modify milestone with options +func (c *Client) EditMilestone(owner, repo string, id int64, opt EditMilestoneOption) (*Milestone, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + milestone := new(Milestone) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), jsonHeader, bytes.NewReader(body), milestone) + return milestone, resp, err +} + +// EditMilestoneByName modify milestone with options +func (c *Client) EditMilestoneByName(owner, repo, name string, opt EditMilestoneOption) (*Milestone, *Response, error) { + if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil { + // backwards compatibility mode + m, _, err := c.resolveMilestoneByName(owner, repo, name) + if err != nil { + return nil, nil, err + } + return c.EditMilestone(owner, repo, m.ID, opt) + } + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + milestone := new(Milestone) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), jsonHeader, bytes.NewReader(body), milestone) + return milestone, resp, err +} + +// DeleteMilestone delete one milestone by id +func (c *Client) DeleteMilestone(owner, repo string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), nil, nil) + return resp, err +} + +// DeleteMilestoneByName delete one milestone by name +func (c *Client) DeleteMilestoneByName(owner, repo, name string) (*Response, error) { + if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil { + // backwards compatibility mode + m, _, err := c.resolveMilestoneByName(owner, repo, name) + if err != nil { + return nil, err + } + return c.DeleteMilestone(owner, repo, m.ID) + } + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), nil, nil) + return resp, err +} + +// resolveMilestoneByName is a fallback method to find milestone id by name +func (c *Client) resolveMilestoneByName(owner, repo, name string) (*Milestone, *Response, error) { + for i := 1; ; i++ { + miles, resp, err := c.ListRepoMilestones(owner, repo, ListMilestoneOption{ + ListOptions: ListOptions{ + Page: i, + }, + State: "all", + }) + if err != nil { + return nil, nil, err + } + if len(miles) == 0 { + return nil, nil, fmt.Errorf("milestone '%s' do not exist", name) + } + for _, m := range miles { + if strings.EqualFold(strings.TrimSpace(m.Title), strings.TrimSpace(name)) { + return m, resp, nil + } + } + } +} diff --git a/forges/forgejo/sdk/issue_reaction.go b/forges/forgejo/sdk/issue_reaction.go new file mode 100644 index 0000000..8939da8 --- /dev/null +++ b/forges/forgejo/sdk/issue_reaction.go @@ -0,0 +1,104 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// Reaction contain one reaction +type Reaction struct { + User *User `json:"user"` + Reaction string `json:"content"` + Created time.Time `json:"created_at"` +} + +// GetIssueReactions get a list reactions of an issue +func (c *Client) GetIssueReactions(owner, repo string, index int64) ([]*Reaction, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + reactions := make([]*Reaction, 0, 10) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index), nil, nil, &reactions) + return reactions, resp, err +} + +// GetIssueCommentReactions get a list of reactions from a comment of an issue +func (c *Client) GetIssueCommentReactions(owner, repo string, commentID int64) ([]*Reaction, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + reactions := make([]*Reaction, 0, 10) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID), nil, nil, &reactions) + return reactions, resp, err +} + +// editReactionOption contain the reaction type +type editReactionOption struct { + Reaction string `json:"content"` +} + +// PostIssueReaction add a reaction to an issue +func (c *Client) PostIssueReaction(owner, repo string, index int64, reaction string) (*Reaction, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + reactionResponse := new(Reaction) + body, err := json.Marshal(&editReactionOption{Reaction: reaction}) + if err != nil { + return nil, nil, err + } + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index), + jsonHeader, bytes.NewReader(body), reactionResponse) + return reactionResponse, resp, err +} + +// DeleteIssueReaction remove a reaction from an issue +func (c *Client) DeleteIssueReaction(owner, repo string, index int64, reaction string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + body, err := json.Marshal(&editReactionOption{Reaction: reaction}) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// PostIssueCommentReaction add a reaction to a comment of an issue +func (c *Client) PostIssueCommentReaction(owner, repo string, commentID int64, reaction string) (*Reaction, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + reactionResponse := new(Reaction) + body, err := json.Marshal(&editReactionOption{Reaction: reaction}) + if err != nil { + return nil, nil, err + } + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID), + jsonHeader, bytes.NewReader(body), reactionResponse) + return reactionResponse, resp, err +} + +// DeleteIssueCommentReaction remove a reaction from a comment of an issue +func (c *Client) DeleteIssueCommentReaction(owner, repo string, commentID int64, reaction string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + body, err := json.Marshal(&editReactionOption{Reaction: reaction}) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID), + jsonHeader, bytes.NewReader(body)) + return resp, err +} diff --git a/forges/forgejo/sdk/issue_stopwatch.go b/forges/forgejo/sdk/issue_stopwatch.go new file mode 100644 index 0000000..afc75ba --- /dev/null +++ b/forges/forgejo/sdk/issue_stopwatch.go @@ -0,0 +1,57 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "time" +) + +// StopWatch represents a running stopwatch of an issue / pr +type StopWatch struct { + Created time.Time `json:"created"` + Seconds int64 `json:"seconds"` + Duration string `json:"duration"` + IssueIndex int64 `json:"issue_index"` + IssueTitle string `json:"issue_title"` + RepoOwnerName string `json:"repo_owner_name"` + RepoName string `json:"repo_name"` +} + +// GetMyStopwatches list all stopwatches +func (c *Client) GetMyStopwatches() ([]*StopWatch, *Response, error) { + stopwatches := make([]*StopWatch, 0, 1) + resp, err := c.getParsedResponse("GET", "/user/stopwatches", nil, nil, &stopwatches) + return stopwatches, resp, err +} + +// DeleteIssueStopwatch delete / cancel a specific stopwatch +func (c *Client) DeleteIssueStopwatch(owner, repo string, index int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/delete", owner, repo, index), nil, nil) + return resp, err +} + +// StartIssueStopWatch starts a stopwatch for an existing issue for a given +// repository +func (c *Client) StartIssueStopWatch(owner, repo string, index int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/start", owner, repo, index), nil, nil) + return resp, err +} + +// StopIssueStopWatch stops an existing stopwatch for an issue in a given +// repository +func (c *Client) StopIssueStopWatch(owner, repo string, index int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/stop", owner, repo, index), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/issue_subscription.go b/forges/forgejo/sdk/issue_subscription.go new file mode 100644 index 0000000..af9dfdf --- /dev/null +++ b/forges/forgejo/sdk/issue_subscription.go @@ -0,0 +1,87 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/http" +) + +// GetIssueSubscribers get list of users who subscribed on an issue +func (c *Client) GetIssueSubscribers(owner, repo string, index int64) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + subscribers := make([]*User, 0, 10) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions", owner, repo, index), nil, nil, &subscribers) + return subscribers, resp, err +} + +// AddIssueSubscription Subscribe user to issue +func (c *Client) AddIssueSubscription(owner, repo string, index int64, user string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &user); err != nil { + return nil, err + } + status, resp, err := c.getStatusCode("PUT", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/%s", owner, repo, index, user), nil, nil) + if err != nil { + return resp, err + } + if status == http.StatusCreated { + return resp, nil + } + if status == http.StatusOK { + return resp, fmt.Errorf("already subscribed") + } + return resp, fmt.Errorf("unexpected Status: %d", status) +} + +// DeleteIssueSubscription unsubscribe user from issue +func (c *Client) DeleteIssueSubscription(owner, repo string, index int64, user string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &user); err != nil { + return nil, err + } + status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/%s", owner, repo, index, user), nil, nil) + if err != nil { + return resp, err + } + if status == http.StatusCreated { + return resp, nil + } + if status == http.StatusOK { + return resp, fmt.Errorf("already unsubscribed") + } + return resp, fmt.Errorf("unexpected Status: %d", status) +} + +// CheckIssueSubscription check if current user is subscribed to an issue +func (c *Client) CheckIssueSubscription(owner, repo string, index int64) (*WatchInfo, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + wi := new(WatchInfo) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/check", owner, repo, index), nil, nil, wi) + return wi, resp, err +} + +// IssueSubscribe subscribe current user to an issue +func (c *Client) IssueSubscribe(owner, repo string, index int64) (*Response, error) { + u, _, err := c.GetMyUserInfo() + if err != nil { + return nil, err + } + return c.AddIssueSubscription(owner, repo, index, u.UserName) +} + +// IssueUnSubscribe unsubscribe current user from an issue +func (c *Client) IssueUnSubscribe(owner, repo string, index int64) (*Response, error) { + u, _, err := c.GetMyUserInfo() + if err != nil { + return nil, err + } + return c.DeleteIssueSubscription(owner, repo, index, u.UserName) +} diff --git a/forges/forgejo/sdk/issue_template.go b/forges/forgejo/sdk/issue_template.go new file mode 100644 index 0000000..22047fa --- /dev/null +++ b/forges/forgejo/sdk/issue_template.go @@ -0,0 +1,97 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" +) + +// IssueTemplate provides metadata and content on an issue template. +// There are two types of issue templates: .Markdown- and .Form-based. +type IssueTemplate struct { + Name string `json:"name"` + About string `json:"about"` + Filename string `json:"file_name"` + IssueTitle string `json:"title"` + IssueLabels []string `json:"labels"` + IssueRef string `json:"ref"` + // If non-nil, this is a form-based template + Form []IssueFormElement `json:"body"` + // Should only be used when .Form is nil. + MarkdownContent string `json:"content"` +} + +// IssueFormElement describes a part of a IssueTemplate form +type IssueFormElement struct { + ID string `json:"id"` + Type IssueFormElementType `json:"type"` + Attributes IssueFormElementAttributes `json:"attributes"` + Validations IssueFormElementValidations `json:"validations"` +} + +// IssueFormElementAttributes contains the combined set of attributes available on all element types. +type IssueFormElementAttributes struct { + // required for all element types. + // A brief description of the expected user input, which is also displayed in the form. + Label string `json:"label"` + // required for element types "dropdown", "checkboxes" + // for dropdown, contains the available options + Options []string `json:"options"` + // for element types "markdown", "textarea", "input" + // Text that is pre-filled in the input + Value string `json:"value"` + // for element types "textarea", "input", "dropdown", "checkboxes" + // A description of the text area to provide context or guidance, which is displayed in the form. + Description string `json:"description"` + // for element types "textarea", "input" + // A semi-opaque placeholder that renders in the text area when empty. + Placeholder string `json:"placeholder"` + // for element types "textarea" + // A language specifier. If set, the input is rendered as codeblock with syntax highlighting. + SyntaxHighlighting string `json:"render"` + // for element types "dropdown" + Multiple bool `json:"multiple"` +} + +// IssueFormElementValidations contains the combined set of validations available on all element types. +type IssueFormElementValidations struct { + // for all element types + Required bool `json:"required"` + // for element types "input" + IsNumber bool `json:"is_number"` + // for element types "input" + Regex string `json:"regex"` +} + +// IssueFormElementType is an enum +type IssueFormElementType string + +const ( + // IssueFormElementMarkdown is markdown rendered to the form for context, but omitted in the resulting issue + IssueFormElementMarkdown IssueFormElementType = "markdown" + // IssueFormElementTextarea is a multi line input + IssueFormElementTextarea IssueFormElementType = "textarea" + // IssueFormElementInput is a single line input + IssueFormElementInput IssueFormElementType = "input" + // IssueFormElementDropdown is a select form + IssueFormElementDropdown IssueFormElementType = "dropdown" + // IssueFormElementCheckboxes are a multi checkbox input + IssueFormElementCheckboxes IssueFormElementType = "checkboxes" +) + +// GetIssueTemplates lists all issue templates of the repository +func (c *Client) GetIssueTemplates(owner, repo string) ([]*IssueTemplate, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + templates := new([]*IssueTemplate) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issue_templates", owner, repo), nil, nil, templates) + return *templates, resp, err +} + +// IsForm tells if this template is a form instead of a markdown-based template. +func (t IssueTemplate) IsForm() bool { + return t.Form != nil +} diff --git a/forges/forgejo/sdk/issue_tracked_time.go b/forges/forgejo/sdk/issue_tracked_time.go new file mode 100644 index 0000000..6c7bbcd --- /dev/null +++ b/forges/forgejo/sdk/issue_tracked_time.go @@ -0,0 +1,142 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// TrackedTime worked time for an issue / pr +type TrackedTime struct { + ID int64 `json:"id"` + Created time.Time `json:"created"` + // Time in seconds + Time int64 `json:"time"` + // deprecated (only for backwards compatibility) + UserID int64 `json:"user_id"` + UserName string `json:"user_name"` + // deprecated (only for backwards compatibility) + IssueID int64 `json:"issue_id"` + Issue *Issue `json:"issue"` +} + +// ListTrackedTimesOptions options for listing repository's tracked times +type ListTrackedTimesOptions struct { + ListOptions + Since time.Time + Before time.Time + // User filter is only used by ListRepoTrackedTimes !!! + User string +} + +// QueryEncode turns options into querystring argument +func (opt *ListTrackedTimesOptions) QueryEncode() string { + query := opt.getURLQuery() + + if !opt.Since.IsZero() { + query.Add("since", opt.Since.Format(time.RFC3339)) + } + if !opt.Before.IsZero() { + query.Add("before", opt.Before.Format(time.RFC3339)) + } + + if len(opt.User) != 0 { + query.Add("user", opt.User) + } + + return query.Encode() +} + +// ListRepoTrackedTimes list tracked times of a repository +func (c *Client) ListRepoTrackedTimes(owner, repo string, opt ListTrackedTimesOptions) ([]*TrackedTime, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/times", owner, repo)) + opt.setDefaults() + link.RawQuery = opt.QueryEncode() + times := make([]*TrackedTime, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ×) + return times, resp, err +} + +// GetMyTrackedTimes list tracked times of the current user +func (c *Client) GetMyTrackedTimes() ([]*TrackedTime, *Response, error) { + times := make([]*TrackedTime, 0, 10) + resp, err := c.getParsedResponse("GET", "/user/times", jsonHeader, nil, ×) + return times, resp, err +} + +// AddTimeOption options for adding time to an issue +type AddTimeOption struct { + // time in seconds + Time int64 `json:"time"` + // optional + Created time.Time `json:"created"` + // optional + User string `json:"user_name"` +} + +// Validate the AddTimeOption struct +func (opt AddTimeOption) Validate() error { + if opt.Time == 0 { + return fmt.Errorf("no time to add") + } + return nil +} + +// AddTime adds time to issue with the given index +func (c *Client) AddTime(owner, repo string, index int64, opt AddTimeOption) (*TrackedTime, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + t := new(TrackedTime) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index), + jsonHeader, bytes.NewReader(body), t) + return t, resp, err +} + +// ListIssueTrackedTimes list tracked times of a single issue for a given repository +func (c *Client) ListIssueTrackedTimes(owner, repo string, index int64, opt ListTrackedTimesOptions) ([]*TrackedTime, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index)) + opt.setDefaults() + link.RawQuery = opt.QueryEncode() + times := make([]*TrackedTime, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ×) + return times, resp, err +} + +// ResetIssueTime reset tracked time of a single issue for a given repository +func (c *Client) ResetIssueTime(owner, repo string, index int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index), jsonHeader, nil) + return resp, err +} + +// DeleteTime delete a specific tracked time by id of a single issue for a given repository +func (c *Client) DeleteTime(owner, repo string, index, timeID int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/times/%d", owner, repo, index, timeID), jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/list_options.go b/forges/forgejo/sdk/list_options.go new file mode 100644 index 0000000..daf907d --- /dev/null +++ b/forges/forgejo/sdk/list_options.go @@ -0,0 +1,40 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" +) + +// ListOptions options for using Gitea's API pagination +type ListOptions struct { + // Setting Page to -1 disables pagination on endpoints that support it. + // Page numbering starts at 1. + Page int + // The default value depends on the server config DEFAULT_PAGING_NUM + // The highest valid value depends on the server config MAX_RESPONSE_ITEMS + PageSize int +} + +func (o ListOptions) getURLQuery() url.Values { + query := make(url.Values) + query.Add("page", fmt.Sprintf("%d", o.Page)) + query.Add("limit", fmt.Sprintf("%d", o.PageSize)) + + return query +} + +// setDefaults applies default pagination options. +// If .Page is set to -1, it will disable pagination. +// WARNING: This function is not idempotent, make sure to never call this method twice! +func (o *ListOptions) setDefaults() { + if o.Page < 0 { + o.Page, o.PageSize = 0, 0 + return + } else if o.Page == 0 { + o.Page = 1 + } +} diff --git a/forges/forgejo/sdk/notifications.go b/forges/forgejo/sdk/notifications.go new file mode 100644 index 0000000..cf724f6 --- /dev/null +++ b/forges/forgejo/sdk/notifications.go @@ -0,0 +1,257 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" + "time" +) + +// NotificationThread expose Notification on API +type NotificationThread struct { + ID int64 `json:"id"` + Repository *Repository `json:"repository"` + Subject *NotificationSubject `json:"subject"` + Unread bool `json:"unread"` + Pinned bool `json:"pinned"` + UpdatedAt time.Time `json:"updated_at"` + URL string `json:"url"` +} + +// NotificationSubject contains the notification subject (Issue/Pull/Commit) +type NotificationSubject struct { + Title string `json:"title"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + LatestCommentURL string `json:"latest_comment_url"` + LatestCommentHTMLURL string `json:"latest_comment_html_url"` + Type NotifySubjectType `json:"type"` + State NotifySubjectState `json:"state"` +} + +// NotifyStatus notification status type +type NotifyStatus string + +const ( + // NotifyStatusUnread was not read + NotifyStatusUnread NotifyStatus = "unread" + // NotifyStatusRead was already read by user + NotifyStatusRead NotifyStatus = "read" + // NotifyStatusPinned notification is pinned by user + NotifyStatusPinned NotifyStatus = "pinned" +) + +// NotifySubjectType represent type of notification subject +type NotifySubjectType string + +const ( + // NotifySubjectIssue an issue is subject of an notification + NotifySubjectIssue NotifySubjectType = "Issue" + // NotifySubjectPull an pull is subject of an notification + NotifySubjectPull NotifySubjectType = "Pull" + // NotifySubjectCommit an commit is subject of an notification + NotifySubjectCommit NotifySubjectType = "Commit" + // NotifySubjectRepository an repository is subject of an notification + NotifySubjectRepository NotifySubjectType = "Repository" +) + +// NotifySubjectState reflect state of notification subject +type NotifySubjectState string + +const ( + // NotifySubjectOpen if subject is a pull/issue and is open at the moment + NotifySubjectOpen NotifySubjectState = "open" + // NotifySubjectClosed if subject is a pull/issue and is closed at the moment + NotifySubjectClosed NotifySubjectState = "closed" + // NotifySubjectMerged if subject is a pull and got merged + NotifySubjectMerged NotifySubjectState = "merged" +) + +// ListNotificationOptions represents the filter options +type ListNotificationOptions struct { + ListOptions + Since time.Time + Before time.Time + Status []NotifyStatus + SubjectTypes []NotifySubjectType +} + +// MarkNotificationOptions represents the filter & modify options +type MarkNotificationOptions struct { + LastReadAt time.Time + Status []NotifyStatus + ToStatus NotifyStatus +} + +// QueryEncode encode options to url query +func (opt *ListNotificationOptions) QueryEncode() string { + query := opt.getURLQuery() + if !opt.Since.IsZero() { + query.Add("since", opt.Since.Format(time.RFC3339)) + } + if !opt.Before.IsZero() { + query.Add("before", opt.Before.Format(time.RFC3339)) + } + for _, s := range opt.Status { + query.Add("status-types", string(s)) + } + for _, s := range opt.SubjectTypes { + query.Add("subject-type", string(s)) + } + return query.Encode() +} + +// Validate the CreateUserOption struct +func (opt ListNotificationOptions) Validate(c *Client) error { + if len(opt.Status) != 0 { + return c.checkServerVersionGreaterThanOrEqual(version1_12_3) + } + return nil +} + +// QueryEncode encode options to url query +func (opt *MarkNotificationOptions) QueryEncode() string { + query := make(url.Values) + if !opt.LastReadAt.IsZero() { + query.Add("last_read_at", opt.LastReadAt.Format(time.RFC3339)) + } + for _, s := range opt.Status { + query.Add("status-types", string(s)) + } + if len(opt.ToStatus) != 0 { + query.Add("to-status", string(opt.ToStatus)) + } + return query.Encode() +} + +// Validate the CreateUserOption struct +func (opt MarkNotificationOptions) Validate(c *Client) error { + if len(opt.Status) != 0 || len(opt.ToStatus) != 0 { + return c.checkServerVersionGreaterThanOrEqual(version1_12_3) + } + return nil +} + +// CheckNotifications list users's notification threads +func (c *Client) CheckNotifications() (int64, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return 0, nil, err + } + new := struct { + New int64 `json:"new"` + }{} + + resp, err := c.getParsedResponse("GET", "/notifications/new", jsonHeader, nil, &new) + return new.New, resp, err +} + +// GetNotification get notification thread by ID +func (c *Client) GetNotification(id int64) (*NotificationThread, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + thread := new(NotificationThread) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/notifications/threads/%d", id), nil, nil, thread) + return thread, resp, err +} + +// ReadNotification mark notification thread as read by ID +// It optionally takes a second argument if status has to be set other than 'read' +// The relevant notification will be returned as the first parameter when the Gitea server is 1.16.0 or higher. +func (c *Client) ReadNotification(id int64, status ...NotifyStatus) (*NotificationThread, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + link := fmt.Sprintf("/notifications/threads/%d", id) + if len(status) != 0 { + link += fmt.Sprintf("?to-status=%s", status[0]) + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil { + thread := &NotificationThread{} + resp, err := c.getParsedResponse("PATCH", link, nil, nil, thread) + return thread, resp, err + } + _, resp, err := c.getResponse("PATCH", link, nil, nil) + return nil, resp, err +} + +// ListNotifications list users's notification threads +func (c *Client) ListNotifications(opt ListNotificationOptions) ([]*NotificationThread, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + link, _ := url.Parse("/notifications") + link.RawQuery = opt.QueryEncode() + threads := make([]*NotificationThread, 0, 10) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &threads) + return threads, resp, err +} + +// ReadNotifications mark notification threads as read +// The relevant notifications will only be returned as the first parameter when the Gitea server is 1.16.0 or higher. +func (c *Client) ReadNotifications(opt MarkNotificationOptions) ([]*NotificationThread, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + link, _ := url.Parse("/notifications") + link.RawQuery = opt.QueryEncode() + + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil { + threads := make([]*NotificationThread, 0, 10) + resp, err := c.getParsedResponse("PUT", link.String(), nil, nil, &threads) + return threads, resp, err + } + _, resp, err := c.getResponse("PUT", link.String(), nil, nil) + return nil, resp, err +} + +// ListRepoNotifications list users's notification threads on a specific repo +func (c *Client) ListRepoNotifications(owner, repo string, opt ListNotificationOptions) ([]*NotificationThread, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/notifications", owner, repo)) + link.RawQuery = opt.QueryEncode() + threads := make([]*NotificationThread, 0, 10) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &threads) + return threads, resp, err +} + +// ReadRepoNotifications mark notification threads as read on a specific repo +// The relevant notifications will only be returned as the first parameter when the Gitea server is 1.16.0 or higher. +func (c *Client) ReadRepoNotifications(owner, repo string, opt MarkNotificationOptions) ([]*NotificationThread, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/notifications", owner, repo)) + link.RawQuery = opt.QueryEncode() + + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil { + threads := make([]*NotificationThread, 0, 10) + resp, err := c.getParsedResponse("PUT", link.String(), nil, nil, &threads) + return threads, resp, err + } + _, resp, err := c.getResponse("PUT", link.String(), nil, nil) + return nil, resp, err +} diff --git a/forges/forgejo/sdk/oauth2.go b/forges/forgejo/sdk/oauth2.go new file mode 100644 index 0000000..1315631 --- /dev/null +++ b/forges/forgejo/sdk/oauth2.go @@ -0,0 +1,93 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// Oauth2 represents an Oauth2 Application +type Oauth2 struct { + ID int64 `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURIs []string `json:"redirect_uris"` + ConfidentialClient bool `json:"confidential_client"` + Created time.Time `json:"created"` +} + +// ListOauth2Option for listing Oauth2 Applications +type ListOauth2Option struct { + ListOptions +} + +// CreateOauth2Option required options for creating an Application +type CreateOauth2Option struct { + Name string `json:"name"` + ConfidentialClient bool `json:"confidential_client"` + RedirectURIs []string `json:"redirect_uris"` +} + +// CreateOauth2 create an Oauth2 Application and returns a completed Oauth2 object. +func (c *Client) CreateOauth2(opt CreateOauth2Option) (*Oauth2, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + oauth := new(Oauth2) + resp, err := c.getParsedResponse("POST", "/user/applications/oauth2", jsonHeader, bytes.NewReader(body), oauth) + return oauth, resp, err +} + +// UpdateOauth2 a specific Oauth2 Application by ID and return a completed Oauth2 object. +func (c *Client) UpdateOauth2(oauth2id int64, opt CreateOauth2Option) (*Oauth2, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + oauth := new(Oauth2) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), jsonHeader, bytes.NewReader(body), oauth) + return oauth, resp, err +} + +// GetOauth2 a specific Oauth2 Application by ID. +func (c *Client) GetOauth2(oauth2id int64) (*Oauth2, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + oauth2s := &Oauth2{} + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), nil, nil, &oauth2s) + return oauth2s, resp, err +} + +// ListOauth2 all of your Oauth2 Applications. +func (c *Client) ListOauth2(opt ListOauth2Option) ([]*Oauth2, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + opt.setDefaults() + oauth2s := make([]*Oauth2, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/applications/oauth2?%s", opt.getURLQuery().Encode()), nil, nil, &oauth2s) + return oauth2s, resp, err +} + +// DeleteOauth2 delete an Oauth2 application by ID +func (c *Client) DeleteOauth2(oauth2id int64) (*Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/org.go b/forges/forgejo/sdk/org.go new file mode 100644 index 0000000..7fd803e --- /dev/null +++ b/forges/forgejo/sdk/org.go @@ -0,0 +1,163 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// Organization represents an organization +type Organization struct { + ID int64 `json:"id"` + UserName string `json:"username"` + FullName string `json:"full_name"` + AvatarURL string `json:"avatar_url"` + Description string `json:"description"` + Website string `json:"website"` + Location string `json:"location"` + Visibility string `json:"visibility"` +} + +// VisibleType defines the visibility +type VisibleType string + +const ( + // VisibleTypePublic Visible for everyone + VisibleTypePublic VisibleType = "public" + + // VisibleTypeLimited Visible for every connected user + VisibleTypeLimited VisibleType = "limited" + + // VisibleTypePrivate Visible only for organization's members + VisibleTypePrivate VisibleType = "private" +) + +// ListOrgsOptions options for listing organizations +type ListOrgsOptions struct { + ListOptions +} + +// ListMyOrgs list all of current user's organizations +func (c *Client) ListMyOrgs(opt ListOrgsOptions) ([]*Organization, *Response, error) { + opt.setDefaults() + orgs := make([]*Organization, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/orgs?%s", opt.getURLQuery().Encode()), nil, nil, &orgs) + return orgs, resp, err +} + +// ListOrgs list all organizations +func (c *Client) ListOrgs(opt ListOrgsOptions) ([]*Organization, *Response, error) { + opt.setDefaults() + orgs := make([]*Organization, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs?%s", opt.getURLQuery().Encode()), nil, nil, &orgs) + return orgs, resp, err +} + +// ListUserOrgs list all of some user's organizations +func (c *Client) ListUserOrgs(user string, opt ListOrgsOptions) ([]*Organization, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + orgs := make([]*Organization, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/orgs?%s", user, opt.getURLQuery().Encode()), nil, nil, &orgs) + return orgs, resp, err +} + +// GetOrg get one organization by name +func (c *Client) GetOrg(orgname string) (*Organization, *Response, error) { + if err := escapeValidatePathSegments(&orgname); err != nil { + return nil, nil, err + } + org := new(Organization) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s", orgname), nil, nil, org) + return org, resp, err +} + +// CreateOrgOption options for creating an organization +type CreateOrgOption struct { + Name string `json:"username"` + FullName string `json:"full_name"` + Description string `json:"description"` + Website string `json:"website"` + Location string `json:"location"` + Visibility VisibleType `json:"visibility"` + RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"` +} + +// checkVisibilityOpt check if mode exist +func checkVisibilityOpt(v VisibleType) bool { + return v == VisibleTypePublic || v == VisibleTypeLimited || v == VisibleTypePrivate +} + +// Validate the CreateOrgOption struct +func (opt CreateOrgOption) Validate() error { + if len(opt.Name) == 0 { + return fmt.Errorf("empty org name") + } + if len(opt.Visibility) != 0 && !checkVisibilityOpt(opt.Visibility) { + return fmt.Errorf("infalid bisibility option") + } + return nil +} + +// CreateOrg creates an organization +func (c *Client) CreateOrg(opt CreateOrgOption) (*Organization, *Response, error) { + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + org := new(Organization) + resp, err := c.getParsedResponse("POST", "/orgs", jsonHeader, bytes.NewReader(body), org) + return org, resp, err +} + +// EditOrgOption options for editing an organization +type EditOrgOption struct { + FullName string `json:"full_name"` + Description string `json:"description"` + Website string `json:"website"` + Location string `json:"location"` + Visibility VisibleType `json:"visibility"` +} + +// Validate the EditOrgOption struct +func (opt EditOrgOption) Validate() error { + if len(opt.Visibility) != 0 && !checkVisibilityOpt(opt.Visibility) { + return fmt.Errorf("infalid bisibility option") + } + return nil +} + +// EditOrg modify one organization via options +func (c *Client) EditOrg(orgname string, opt EditOrgOption) (*Response, error) { + if err := escapeValidatePathSegments(&orgname); err != nil { + return nil, err + } + if err := opt.Validate(); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/orgs/%s", orgname), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteOrg deletes an organization +func (c *Client) DeleteOrg(orgname string) (*Response, error) { + if err := escapeValidatePathSegments(&orgname); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/orgs/%s", orgname), jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/org_action.go b/forges/forgejo/sdk/org_action.go new file mode 100644 index 0000000..141bdd3 --- /dev/null +++ b/forges/forgejo/sdk/org_action.go @@ -0,0 +1,29 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" +) + +// ListOrgMembershipOption list OrgMembership options +type ListOrgActionSecretOption struct { + ListOptions +} + +// ListOrgMembership list an organization's members +func (c *Client) ListOrgActionSecret(org string, opt ListOrgActionSecretOption) ([]*Secret, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + secrets := make([]*Secret, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/orgs/%s/actions/secrets", org)) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &secrets) + return secrets, resp, err +} diff --git a/forges/forgejo/sdk/org_member.go b/forges/forgejo/sdk/org_member.go new file mode 100644 index 0000000..3dcd0bf --- /dev/null +++ b/forges/forgejo/sdk/org_member.go @@ -0,0 +1,142 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/http" + "net/url" +) + +// DeleteOrgMembership remove a member from an organization +func (c *Client) DeleteOrgMembership(org, user string) (*Response, error) { + if err := escapeValidatePathSegments(&org, &user); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/orgs/%s/members/%s", org, user), nil, nil) + return resp, err +} + +// ListOrgMembershipOption list OrgMembership options +type ListOrgMembershipOption struct { + ListOptions +} + +// ListOrgMembership list an organization's members +func (c *Client) ListOrgMembership(org string, opt ListOrgMembershipOption) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/orgs/%s/members", org)) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &users) + return users, resp, err +} + +// ListPublicOrgMembership list an organization's members +func (c *Client) ListPublicOrgMembership(org string, opt ListOrgMembershipOption) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/orgs/%s/public_members", org)) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &users) + return users, resp, err +} + +// CheckOrgMembership Check if a user is a member of an organization +func (c *Client) CheckOrgMembership(org, user string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&org, &user); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/orgs/%s/members/%s", org, user), nil, nil) + if err != nil { + return false, resp, err + } + switch status { + case http.StatusNoContent: + return true, resp, nil + case http.StatusNotFound: + return false, resp, nil + default: + return false, resp, fmt.Errorf("unexpected Status: %d", status) + } +} + +// CheckPublicOrgMembership Check if a user is a member of an organization +func (c *Client) CheckPublicOrgMembership(org, user string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&org, &user); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil) + if err != nil { + return false, resp, err + } + switch status { + case http.StatusNoContent: + return true, resp, nil + case http.StatusNotFound: + return false, resp, nil + default: + return false, resp, fmt.Errorf("unexpected Status: %d", status) + } +} + +// SetPublicOrgMembership publicize/conceal a user's membership +func (c *Client) SetPublicOrgMembership(org, user string, visible bool) (*Response, error) { + if err := escapeValidatePathSegments(&org, &user); err != nil { + return nil, err + } + var ( + status int + err error + resp *Response + ) + if visible { + status, resp, err = c.getStatusCode("PUT", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil) + } else { + status, resp, err = c.getStatusCode("DELETE", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil) + } + if err != nil { + return resp, err + } + switch status { + case http.StatusNoContent: + return resp, nil + case http.StatusNotFound: + return resp, fmt.Errorf("forbidden") + default: + return resp, fmt.Errorf("unexpected Status: %d", status) + } +} + +// OrgPermissions represents the permissions for an user in an organization +type OrgPermissions struct { + CanCreateRepository bool `json:"can_create_repository"` + CanRead bool `json:"can_read"` + CanWrite bool `json:"can_write"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` +} + +// GetOrgPermissions returns user permissions for specific organization. +func (c *Client) GetOrgPermissions(org, user string) (*OrgPermissions, *Response, error) { + if err := escapeValidatePathSegments(&org, &user); err != nil { + return nil, nil, err + } + + perm := &OrgPermissions{} + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/orgs/%s/permissions", user, org), jsonHeader, nil, &perm) + if err != nil { + return nil, resp, err + } + return perm, resp, nil +} diff --git a/forges/forgejo/sdk/org_team.go b/forges/forgejo/sdk/org_team.go new file mode 100644 index 0000000..f3cd020 --- /dev/null +++ b/forges/forgejo/sdk/org_team.go @@ -0,0 +1,283 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" +) + +// Team represents a team in an organization +type Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Organization *Organization `json:"organization"` + Permission AccessMode `json:"permission"` + CanCreateOrgRepo bool `json:"can_create_org_repo"` + IncludesAllRepositories bool `json:"includes_all_repositories"` + Units []RepoUnitType `json:"units"` +} + +// RepoUnitType represent all unit types of a repo gitea currently offer +type RepoUnitType string + +const ( + // RepoUnitCode represent file view of a repository + RepoUnitCode RepoUnitType = "repo.code" + // RepoUnitIssues represent issues of a repository + RepoUnitIssues RepoUnitType = "repo.issues" + // RepoUnitPulls represent pulls of a repository + RepoUnitPulls RepoUnitType = "repo.pulls" + // RepoUnitExtIssues represent external issues of a repository + RepoUnitExtIssues RepoUnitType = "repo.ext_issues" + // RepoUnitWiki represent wiki of a repository + RepoUnitWiki RepoUnitType = "repo.wiki" + // RepoUnitExtWiki represent external wiki of a repository + RepoUnitExtWiki RepoUnitType = "repo.ext_wiki" + // RepoUnitReleases represent releases of a repository + RepoUnitReleases RepoUnitType = "repo.releases" + // RepoUnitProjects represent projects of a repository + RepoUnitProjects RepoUnitType = "repo.projects" + // RepoUnitPackages represents packages of a repository + RepoUnitPackages RepoUnitType = "repo.packages" +) + +// ListTeamsOptions options for listing teams +type ListTeamsOptions struct { + ListOptions +} + +// ListOrgTeams lists all teams of an organization +func (c *Client) ListOrgTeams(org string, opt ListTeamsOptions) ([]*Team, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + teams := make([]*Team, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/teams?%s", org, opt.getURLQuery().Encode()), nil, nil, &teams) + return teams, resp, err +} + +// ListMyTeams lists all the teams of the current user +func (c *Client) ListMyTeams(opt *ListTeamsOptions) ([]*Team, *Response, error) { + opt.setDefaults() + teams := make([]*Team, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/teams?%s", opt.getURLQuery().Encode()), nil, nil, &teams) + return teams, resp, err +} + +// GetTeam gets a team by ID +func (c *Client) GetTeam(id int64) (*Team, *Response, error) { + t := new(Team) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d", id), nil, nil, t) + return t, resp, err +} + +// SearchTeamsOptions options for searching teams. +type SearchTeamsOptions struct { + ListOptions + Query string + IncludeDescription bool +} + +func (o SearchTeamsOptions) getURLQuery() url.Values { + query := make(url.Values) + query.Add("page", fmt.Sprintf("%d", o.Page)) + query.Add("limit", fmt.Sprintf("%d", o.PageSize)) + query.Add("q", o.Query) + query.Add("include_desc", fmt.Sprintf("%t", o.IncludeDescription)) + + return query +} + +// TeamSearchResults is the JSON struct that is returned from Team search API. +type TeamSearchResults struct { + OK bool `json:"ok"` + Error string `json:"error"` + Data []*Team `json:"data"` +} + +// SearchOrgTeams search for teams in a org. +func (c *Client) SearchOrgTeams(org string, opt *SearchTeamsOptions) ([]*Team, *Response, error) { + responseBody := TeamSearchResults{} + opt.setDefaults() + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/teams/search?%s", org, opt.getURLQuery().Encode()), nil, nil, &responseBody) + if err != nil { + return nil, resp, err + } + if !responseBody.OK { + return nil, resp, fmt.Errorf("gitea error: %v", responseBody.Error) + } + return responseBody.Data, resp, err +} + +// CreateTeamOption options for creating a team +type CreateTeamOption struct { + Name string `json:"name"` + Description string `json:"description"` + Permission AccessMode `json:"permission"` + CanCreateOrgRepo bool `json:"can_create_org_repo"` + IncludesAllRepositories bool `json:"includes_all_repositories"` + Units []RepoUnitType `json:"units"` +} + +// Validate the CreateTeamOption struct +func (opt *CreateTeamOption) Validate() error { + if opt.Permission == AccessModeOwner { + opt.Permission = AccessModeAdmin + } else if opt.Permission != AccessModeRead && opt.Permission != AccessModeWrite && opt.Permission != AccessModeAdmin { + return fmt.Errorf("permission mode invalid") + } + if len(opt.Name) == 0 { + return fmt.Errorf("name required") + } + if len(opt.Name) > 30 { + return fmt.Errorf("name to long") + } + if len(opt.Description) > 255 { + return fmt.Errorf("description to long") + } + return nil +} + +// CreateTeam creates a team for an organization +func (c *Client) CreateTeam(org string, opt CreateTeamOption) (*Team, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + if err := (&opt).Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + t := new(Team) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/teams", org), jsonHeader, bytes.NewReader(body), t) + return t, resp, err +} + +// EditTeamOption options for editing a team +type EditTeamOption struct { + Name string `json:"name"` + Description *string `json:"description"` + Permission AccessMode `json:"permission"` + CanCreateOrgRepo *bool `json:"can_create_org_repo"` + IncludesAllRepositories *bool `json:"includes_all_repositories"` + Units []RepoUnitType `json:"units"` +} + +// Validate the EditTeamOption struct +func (opt *EditTeamOption) Validate() error { + if opt.Permission == AccessModeOwner { + opt.Permission = AccessModeAdmin + } else if opt.Permission != AccessModeRead && opt.Permission != AccessModeWrite && opt.Permission != AccessModeAdmin { + return fmt.Errorf("permission mode invalid") + } + if len(opt.Name) == 0 { + return fmt.Errorf("name required") + } + if len(opt.Name) > 30 { + return fmt.Errorf("name to long") + } + if opt.Description != nil && len(*opt.Description) > 255 { + return fmt.Errorf("description to long") + } + return nil +} + +// EditTeam edits a team of an organization +func (c *Client) EditTeam(id int64, opt EditTeamOption) (*Response, error) { + if err := (&opt).Validate(); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PATCH", fmt.Sprintf("/teams/%d", id), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteTeam deletes a team of an organization +func (c *Client) DeleteTeam(id int64) (*Response, error) { + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/teams/%d", id), nil, nil) + return resp, err +} + +// ListTeamMembersOptions options for listing team's members +type ListTeamMembersOptions struct { + ListOptions +} + +// ListTeamMembers lists all members of a team +func (c *Client) ListTeamMembers(id int64, opt ListTeamMembersOptions) ([]*User, *Response, error) { + opt.setDefaults() + members := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/members?%s", id, opt.getURLQuery().Encode()), nil, nil, &members) + return members, resp, err +} + +// GetTeamMember gets a member of a team +func (c *Client) GetTeamMember(id int64, user string) (*User, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + m := new(User) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil, m) + return m, resp, err +} + +// AddTeamMember adds a member to a team +func (c *Client) AddTeamMember(id int64, user string) (*Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil) + return resp, err +} + +// RemoveTeamMember removes a member from a team +func (c *Client) RemoveTeamMember(id int64, user string) (*Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil) + return resp, err +} + +// ListTeamRepositoriesOptions options for listing team's repositories +type ListTeamRepositoriesOptions struct { + ListOptions +} + +// ListTeamRepositories lists all repositories of a team +func (c *Client) ListTeamRepositories(id int64, opt ListTeamRepositoriesOptions) ([]*Repository, *Response, error) { + opt.setDefaults() + repos := make([]*Repository, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/repos?%s", id, opt.getURLQuery().Encode()), nil, nil, &repos) + return repos, resp, err +} + +// AddTeamRepository adds a repository to a team +func (c *Client) AddTeamRepository(id int64, org, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&org, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/teams/%d/repos/%s/%s", id, org, repo), nil, nil) + return resp, err +} + +// RemoveTeamRepository removes a repository from a team +func (c *Client) RemoveTeamRepository(id int64, org, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&org, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/teams/%d/repos/%s/%s", id, org, repo), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/package.go b/forges/forgejo/sdk/package.go new file mode 100644 index 0000000..f967e0b --- /dev/null +++ b/forges/forgejo/sdk/package.go @@ -0,0 +1,93 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "time" +) + +// Package represents a package +type Package struct { + // the package's id + ID int64 `json:"id"` + // the package's owner + Owner User `json:"owner"` + // the repo this package belongs to (if any) + Repository *string `json:"repository"` + // the package's creator + Creator User `json:"creator"` + // the type of package: + Type string `json:"type"` + // the name of the package + Name string `json:"name"` + // the version of the package + Version string `json:"version"` + // the date the package was uploaded + CreatedAt time.Time `json:"created_at"` +} + +// PackageFile represents a file from a package +type PackageFile struct { + // the file's ID + ID int64 `json:"id"` + // the size of the file in bytes + Size int64 `json:"size"` + // the name of the file + Name string `json:"name"` + // the md5 hash of the file + MD5 string `json:"md5"` + // the sha1 hash of the file + SHA1 string `json:"sha1"` + // the sha256 hash of the file + SHA256 string `json:"sha256"` + // the sha512 hash of the file + SHA512 string `json:"sha512"` +} + +// ListPackagesOptions options for listing packages +type ListPackagesOptions struct { + ListOptions +} + +// ListPackages lists all the packages owned by a given owner (user, organisation) +func (c *Client) ListPackages(owner string, opt ListPackagesOptions) ([]*Package, *Response, error) { + if err := escapeValidatePathSegments(&owner); err != nil { + return nil, nil, err + } + opt.setDefaults() + packages := make([]*Package, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s?%s", owner, opt.getURLQuery().Encode()), nil, nil, &packages) + return packages, resp, err +} + +// GetPackage gets the details of a specific package version +func (c *Client) GetPackage(owner, packageType, name, version string) (*Package, *Response, error) { + if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil { + return nil, nil, err + } + foundPackage := new(Package) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s/%s", owner, packageType, name, version), nil, nil, foundPackage) + return foundPackage, resp, err +} + +// DeletePackage deletes a specific package version +func (c *Client) DeletePackage(owner, packageType, name, version string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/packages/%s/%s/%s/%s", owner, packageType, name, version), nil, nil) + return resp, err +} + +// ListPackageFiles lists the files within a package +func (c *Client) ListPackageFiles(owner, packageType, name, version string) ([]*PackageFile, *Response, error) { + if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil { + return nil, nil, err + } + packageFiles := make([]*PackageFile, 0) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s/%s/files", owner, packageType, name, version), nil, nil, &packageFiles) + return packageFiles, resp, err +} diff --git a/forges/forgejo/sdk/pull.go b/forges/forgejo/sdk/pull.go new file mode 100644 index 0000000..b969bda --- /dev/null +++ b/forges/forgejo/sdk/pull.go @@ -0,0 +1,383 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" +) + +// PRBranchInfo information about a branch +type PRBranchInfo struct { + Name string `json:"label"` + Ref string `json:"ref"` + Sha string `json:"sha"` + RepoID int64 `json:"repo_id"` + Repository *Repository `json:"repo"` +} + +// PullRequest represents a pull request +type PullRequest struct { + ID int64 `json:"id"` + URL string `json:"url"` + Index int64 `json:"number"` + Poster *User `json:"user"` + Title string `json:"title"` + Body string `json:"body"` + Labels []*Label `json:"labels"` + Milestone *Milestone `json:"milestone"` + Assignee *User `json:"assignee"` + Assignees []*User `json:"assignees"` + State StateType `json:"state"` + IsLocked bool `json:"is_locked"` + Comments int `json:"comments"` + + HTMLURL string `json:"html_url"` + DiffURL string `json:"diff_url"` + PatchURL string `json:"patch_url"` + + Mergeable bool `json:"mergeable"` + HasMerged bool `json:"merged"` + Merged *time.Time `json:"merged_at"` + MergedCommitID *string `json:"merge_commit_sha"` + MergedBy *User `json:"merged_by"` + AllowMaintainerEdit bool `json:"allow_maintainer_edit"` + + Base *PRBranchInfo `json:"base"` + Head *PRBranchInfo `json:"head"` + MergeBase string `json:"merge_base"` + + Deadline *time.Time `json:"due_date"` + Created *time.Time `json:"created_at"` + Updated *time.Time `json:"updated_at"` + Closed *time.Time `json:"closed_at"` +} + +// ChangedFile is a changed file in a diff +type ChangedFile struct { + Filename string `json:"filename"` + PreviousFilename string `json:"previous_filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` + HTMLURL string `json:"html_url"` + ContentsURL string `json:"contents_url"` + RawURL string `json:"raw_url"` +} + +// ListPullRequestsOptions options for listing pull requests +type ListPullRequestsOptions struct { + ListOptions + State StateType `json:"state"` + // oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority + Sort string + Milestone int64 +} + +// MergeStyle is used specify how a pull is merged +type MergeStyle string + +const ( + // MergeStyleMerge merge pull as usual + MergeStyleMerge MergeStyle = "merge" + // MergeStyleRebase rebase pull + MergeStyleRebase MergeStyle = "rebase" + // MergeStyleRebaseMerge rebase and merge pull + MergeStyleRebaseMerge MergeStyle = "rebase-merge" + // MergeStyleSquash squash and merge pull + MergeStyleSquash MergeStyle = "squash" +) + +// QueryEncode turns options into querystring argument +func (opt *ListPullRequestsOptions) QueryEncode() string { + query := opt.getURLQuery() + if len(opt.State) > 0 { + query.Add("state", string(opt.State)) + } + if len(opt.Sort) > 0 { + query.Add("sort", opt.Sort) + } + if opt.Milestone > 0 { + query.Add("milestone", fmt.Sprintf("%d", opt.Milestone)) + } + return query.Encode() +} + +// ListRepoPullRequests list PRs of one repository +func (c *Client) ListRepoPullRequests(owner, repo string, opt ListPullRequestsOptions) ([]*PullRequest, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + prs := make([]*PullRequest, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls", owner, repo)) + link.RawQuery = opt.QueryEncode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &prs) + if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil { + for i := range prs { + if err := fixPullHeadSha(c, prs[i]); err != nil { + return prs, resp, err + } + } + } + return prs, resp, err +} + +// GetPullRequest get information of one PR +func (c *Client) GetPullRequest(owner, repo string, index int64) (*PullRequest, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + pr := new(PullRequest) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index), nil, nil, pr) + if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil { + if err := fixPullHeadSha(c, pr); err != nil { + return pr, resp, err + } + } + return pr, resp, err +} + +// CreatePullRequestOption options when creating a pull request +type CreatePullRequestOption struct { + Head string `json:"head"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body"` + Assignee string `json:"assignee"` + Assignees []string `json:"assignees"` + Milestone int64 `json:"milestone"` + Labels []int64 `json:"labels"` + Deadline *time.Time `json:"due_date"` +} + +// CreatePullRequest create pull request with options +func (c *Client) CreatePullRequest(owner, repo string, opt CreatePullRequestOption) (*PullRequest, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + pr := new(PullRequest) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls", owner, repo), + jsonHeader, bytes.NewReader(body), pr) + return pr, resp, err +} + +// EditPullRequestOption options when modify pull request +type EditPullRequestOption struct { + Title string `json:"title"` + Body string `json:"body"` + Base string `json:"base"` + Assignee string `json:"assignee"` + Assignees []string `json:"assignees"` + Milestone int64 `json:"milestone"` + Labels []int64 `json:"labels"` + State *StateType `json:"state"` + Deadline *time.Time `json:"due_date"` + RemoveDeadline *bool `json:"unset_due_date"` + AllowMaintainerEdit *bool `json:"allow_maintainer_edit"` +} + +// Validate the EditPullRequestOption struct +func (opt EditPullRequestOption) Validate(c *Client) error { + if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + if len(opt.Base) != 0 { + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return fmt.Errorf("can not change base gitea to old") + } + } + return nil +} + +// EditPullRequest modify pull request with PR id and options +func (c *Client) EditPullRequest(owner, repo string, index int64, opt EditPullRequestOption) (*PullRequest, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + pr := new(PullRequest) + resp, err := c.getParsedResponse("PATCH", + fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index), + jsonHeader, bytes.NewReader(body), pr) + return pr, resp, err +} + +// MergePullRequestOption options when merging a pull request +type MergePullRequestOption struct { + Style MergeStyle `json:"Do"` + MergeCommitID string `json:"MergeCommitID"` + Title string `json:"MergeTitleField"` + Message string `json:"MergeMessageField"` + DeleteBranchAfterMerge bool `json:"delete_branch_after_merge"` + ForceMerge bool `json:"force_merge"` + HeadCommitId string `json:"head_commit_id"` + MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed"` +} + +// Validate the MergePullRequestOption struct +func (opt MergePullRequestOption) Validate(c *Client) error { + if opt.Style == MergeStyleSquash { + if err := c.checkServerVersionGreaterThanOrEqual(version1_11_5); err != nil { + return err + } + } + return nil +} + +// MergePullRequest merge a PR to repository by PR id +func (c *Client) MergePullRequest(owner, repo string, index int64, opt MergePullRequestOption) (bool, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return false, nil, err + } + if err := opt.Validate(c); err != nil { + return false, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("POST", fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index), jsonHeader, bytes.NewReader(body)) + if err != nil { + return false, resp, err + } + return status == 200, resp, nil +} + +// IsPullRequestMerged test if one PR is merged to one repository +func (c *Client) IsPullRequestMerged(owner, repo string, index int64) (bool, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index), nil, nil) + if err != nil { + return false, resp, err + } + + return status == 204, resp, nil +} + +// PullRequestDiffOptions options for GET /repos///pulls/.[diff|patch] +type PullRequestDiffOptions struct { + // Include binary file changes when requesting a .diff + Binary bool +} + +// QueryEncode converts the options to a query string +func (o PullRequestDiffOptions) QueryEncode() string { + query := make(url.Values) + query.Add("binary", fmt.Sprintf("%v", o.Binary)) + return query.Encode() +} + +type pullRequestDiffType string + +const ( + pullRequestDiffTypeDiff pullRequestDiffType = "diff" + pullRequestDiffTypePatch pullRequestDiffType = "patch" +) + +// getPullRequestDiffOrPatch gets the patch or diff file as bytes for a PR +func (c *Client) getPullRequestDiffOrPatch(owner, repo string, kind pullRequestDiffType, index int64, opts PullRequestDiffOptions) ([]byte, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + r, _, err2 := c.GetRepo(owner, repo) + if err2 != nil { + return nil, nil, err + } + if r.Private { + return nil, nil, err + } + url := fmt.Sprintf("/%s/%s/pulls/%d.%s?%s", owner, repo, index, kind, opts.QueryEncode()) + return c.getWebResponse("GET", url, nil) + } + return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d.%s", owner, repo, index, kind), nil, nil) +} + +// GetPullRequestPatch gets the git patchset of a PR +func (c *Client) GetPullRequestPatch(owner, repo string, index int64) ([]byte, *Response, error) { + return c.getPullRequestDiffOrPatch(owner, repo, pullRequestDiffTypePatch, index, PullRequestDiffOptions{}) +} + +// GetPullRequestDiff gets the diff of a PR. For Gitea >= 1.16, you must set includeBinary to get an applicable diff +func (c *Client) GetPullRequestDiff(owner, repo string, index int64, opts PullRequestDiffOptions) ([]byte, *Response, error) { + return c.getPullRequestDiffOrPatch(owner, repo, pullRequestDiffTypeDiff, index, opts) +} + +// ListPullRequestCommitsOptions options for listing pull requests +type ListPullRequestCommitsOptions struct { + ListOptions +} + +// ListPullRequestCommits list commits for a pull request +func (c *Client) ListPullRequestCommits(owner, repo string, index int64, opt ListPullRequestCommitsOptions) ([]*Commit, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/commits", owner, repo, index)) + opt.setDefaults() + commits := make([]*Commit, 0, opt.PageSize) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &commits) + return commits, resp, err +} + +// fixPullHeadSha is a workaround for https://github.com/go-gitea/gitea/issues/12675 +// When no head sha is available, this is because the branch got deleted in the base repo. +// pr.Head.Ref points in this case not to the head repo branch name, but the base repo ref, +// which stays available to resolve the commit sha. This is fixed for gitea >= 1.14.0 +func fixPullHeadSha(client *Client, pr *PullRequest) error { + if pr.Base != nil && pr.Base.Repository != nil && pr.Base.Repository.Owner != nil && + pr.Head != nil && pr.Head.Ref != "" && pr.Head.Sha == "" { + owner := pr.Base.Repository.Owner.UserName + repo := pr.Base.Repository.Name + refs, _, err := client.GetRepoRefs(owner, repo, pr.Head.Ref) + if err != nil { + return err + } else if len(refs) == 0 { + return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref) + } + pr.Head.Sha = refs[0].Object.SHA + } + return nil +} + +// ListPullRequestFilesOptions options for listing pull request files +type ListPullRequestFilesOptions struct { + ListOptions +} + +// ListPullRequestFiles list changed files for a pull request +func (c *Client) ListPullRequestFiles(owner, repo string, index int64, opt ListPullRequestFilesOptions) ([]*ChangedFile, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/files", owner, repo, index)) + opt.setDefaults() + files := make([]*ChangedFile, 0, opt.PageSize) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &files) + return files, resp, err +} diff --git a/forges/forgejo/sdk/pull_review.go b/forges/forgejo/sdk/pull_review.go new file mode 100644 index 0000000..19ab154 --- /dev/null +++ b/forges/forgejo/sdk/pull_review.go @@ -0,0 +1,368 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" +) + +// ReviewStateType review state type +type ReviewStateType string + +const ( + // ReviewStateApproved pr is approved + ReviewStateApproved ReviewStateType = "APPROVED" + // ReviewStatePending pr state is pending + ReviewStatePending ReviewStateType = "PENDING" + // ReviewStateComment is a comment review + ReviewStateComment ReviewStateType = "COMMENT" + // ReviewStateRequestChanges changes for pr are requested + ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES" + // ReviewStateRequestReview review is requested from user + ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW" + // ReviewStateUnknown state of pr is unknown + ReviewStateUnknown ReviewStateType = "" +) + +// PullReview represents a pull request review +type PullReview struct { + ID int64 `json:"id"` + Reviewer *User `json:"user"` + ReviewerTeam *Team `json:"team"` + State ReviewStateType `json:"state"` + Body string `json:"body"` + CommitID string `json:"commit_id"` + // Stale indicates if the pull has changed since the review + Stale bool `json:"stale"` + // Official indicates if the review counts towards the required approval limit, if PR base is a protected branch + Official bool `json:"official"` + Dismissed bool `json:"dismissed"` + CodeCommentsCount int `json:"comments_count"` + Submitted time.Time `json:"submitted_at"` + + HTMLURL string `json:"html_url"` + HTMLPullURL string `json:"pull_request_url"` +} + +// PullReviewComment represents a comment on a pull request review +type PullReviewComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + Reviewer *User `json:"user"` + ReviewID int64 `json:"pull_request_review_id"` + Resolver *User `json:"resolver"` + + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` + + Path string `json:"path"` + CommitID string `json:"commit_id"` + OrigCommitID string `json:"original_commit_id"` + DiffHunk string `json:"diff_hunk"` + LineNum uint64 `json:"position"` + OldLineNum uint64 `json:"original_position"` + + HTMLURL string `json:"html_url"` + HTMLPullURL string `json:"pull_request_url"` +} + +// CreatePullReviewOptions are options to create a pull review +type CreatePullReviewOptions struct { + State ReviewStateType `json:"event"` + Body string `json:"body"` + CommitID string `json:"commit_id"` + Comments []CreatePullReviewComment `json:"comments"` +} + +// CreatePullReviewComment represent a review comment for creation api +type CreatePullReviewComment struct { + // the tree path + Path string `json:"path"` + Body string `json:"body"` + // if comment to old file line or 0 + OldLineNum uint64 `json:"old_position"` + // if comment to new file line or 0 + LineNum uint64 `json:"new_position"` +} + +// SubmitPullReviewOptions are options to submit a pending pull review +type SubmitPullReviewOptions struct { + State ReviewStateType `json:"event"` + Body string `json:"body"` +} + +// DismissPullReviewOptions are options to dismiss a pull review +type DismissPullReviewOptions struct { + Message string `json:"message"` +} + +// PullReviewRequestOptions are options to add or remove pull review requests +type PullReviewRequestOptions struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` +} + +// ListPullReviewsOptions options for listing PullReviews +type ListPullReviewsOptions struct { + ListOptions +} + +// Validate the CreatePullReviewOptions struct +func (opt CreatePullReviewOptions) Validate() error { + if opt.State != ReviewStateApproved && len(opt.Comments) == 0 && len(strings.TrimSpace(opt.Body)) == 0 { + return fmt.Errorf("body is empty") + } + for i := range opt.Comments { + if err := opt.Comments[i].Validate(); err != nil { + return err + } + } + return nil +} + +// Validate the SubmitPullReviewOptions struct +func (opt SubmitPullReviewOptions) Validate() error { + if opt.State != ReviewStateApproved && len(strings.TrimSpace(opt.Body)) == 0 { + return fmt.Errorf("body is empty") + } + return nil +} + +// Validate the CreatePullReviewComment struct +func (opt CreatePullReviewComment) Validate() error { + if len(strings.TrimSpace(opt.Body)) == 0 { + return fmt.Errorf("body is empty") + } + if opt.LineNum != 0 && opt.OldLineNum != 0 { + return fmt.Errorf("old and new line num are set, cant identify the code comment position") + } + return nil +} + +// ListPullReviews lists all reviews of a pull request +func (c *Client) ListPullReviews(owner, repo string, index int64, opt ListPullReviewsOptions) ([]*PullReview, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + opt.setDefaults() + rs := make([]*PullReview, 0, opt.PageSize) + + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index)) + link.RawQuery = opt.ListOptions.getURLQuery().Encode() + + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rs) + return rs, resp, err +} + +// GetPullReview gets a specific review of a pull request +func (c *Client) GetPullReview(owner, repo string, index, id int64) (*PullReview, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + + r := new(PullReview) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil, &r) + return r, resp, err +} + +// ListPullReviewComments lists all comments of a pull request review +func (c *Client) ListPullReviewComments(owner, repo string, index, id int64) ([]*PullReviewComment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + rcl := make([]*PullReviewComment, 0, 4) + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments", owner, repo, index, id)) + + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rcl) + return rcl, resp, err +} + +// GetPullReviewComment get a comment from a pull request review +func (c *Client) GetPullReviewComment(owner, repo string, index, id, comment int64) (*PullReviewComment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + rc := new(PullReviewComment) + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments/%d", owner, repo, index, id, comment)) + + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, rc) + return rc, resp, err +} + +// DeletePullReviewComment delete a comment from a pull request review +func (c *Client) DeletePullReviewComment(owner, repo string, index, id, comment int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments/%d", owner, repo, index, id, comment)) + + _, resp, err := c.getResponse("DELETE", link.String(), jsonHeader, nil) + return resp, err +} + +// CreatePullReviewComment create a review comment for an existing review +func (c *Client) CreatePullReviewComment(owner, repo string, index, id int64, opt CreatePullReviewComment) (*PullReviewComment, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + + r := new(PullReviewComment) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments", owner, repo, index, id), + jsonHeader, bytes.NewReader(body), r) + return r, resp, err +} + +// DeletePullReview delete a specific review from a pull request +func (c *Client) DeletePullReview(owner, repo string, index, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, err + } + + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil) + return resp, err +} + +// CreatePullReview create a review to an pull request +func (c *Client) CreatePullReview(owner, repo string, index int64, opt CreatePullReviewOptions) (*PullReview, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + + r := new(PullReview) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index), + jsonHeader, bytes.NewReader(body), r) + return r, resp, err +} + +// SubmitPullReview submit a pending review to an pull request +func (c *Client) SubmitPullReview(owner, repo string, index, id int64, opt SubmitPullReviewOptions) (*PullReview, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + + r := new(PullReview) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), + jsonHeader, bytes.NewReader(body), r) + return r, resp, err +} + +// CreateReviewRequests create review requests to an pull request +func (c *Client) CreateReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + + _, resp, err := c.getResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index), + jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteReviewRequests delete review requests to an pull request +func (c *Client) DeleteReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index), + jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DismissPullReview dismiss a review for a pull request +func (c *Client) DismissPullReview(owner, repo string, index, id int64, opt DismissPullReviewOptions) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + + _, resp, err := c.getResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, id), + jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// UnDismissPullReview cancel to dismiss a review for a pull request +func (c *Client) UnDismissPullReview(owner, repo string, index, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + + _, resp, err := c.getResponse("POST", + fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, id), + jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/release.go b/forges/forgejo/sdk/release.go new file mode 100644 index 0000000..b8d5176 --- /dev/null +++ b/forges/forgejo/sdk/release.go @@ -0,0 +1,202 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Release represents a repository release +type Release struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Target string `json:"target_commitish"` + Title string `json:"name"` + Note string `json:"body"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + TarURL string `json:"tarball_url"` + ZipURL string `json:"zipball_url"` + IsDraft bool `json:"draft"` + IsPrerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Publisher *User `json:"author"` + Attachments []*Attachment `json:"assets"` +} + +// ListReleasesOptions options for listing repository's releases +type ListReleasesOptions struct { + ListOptions + IsDraft *bool + IsPreRelease *bool +} + +// QueryEncode turns options into querystring argument +func (opt *ListReleasesOptions) QueryEncode() string { + query := opt.getURLQuery() + + if opt.IsDraft != nil { + query.Add("draft", fmt.Sprintf("%t", *opt.IsDraft)) + } + if opt.IsPreRelease != nil { + query.Add("pre-release", fmt.Sprintf("%t", *opt.IsPreRelease)) + } + + return query.Encode() +} + +// ListReleases list releases of a repository +func (c *Client) ListReleases(owner, repo string, opt ListReleasesOptions) ([]*Release, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + releases := make([]*Release, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/releases?%s", owner, repo, opt.QueryEncode()), + nil, nil, &releases) + return releases, resp, err +} + +// GetRelease get a release of a repository by id +func (c *Client) GetRelease(owner, repo string, id int64) (*Release, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + r := new(Release) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/releases/%d", owner, repo, id), + jsonHeader, nil, &r) + return r, resp, err +} + +// GetReleaseByTag get a release of a repository by tag +func (c *Client) GetReleaseByTag(owner, repo, tag string) (*Release, *Response, error) { + if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil { + return c.fallbackGetReleaseByTag(owner, repo, tag) + } + if err := escapeValidatePathSegments(&owner, &repo, &tag); err != nil { + return nil, nil, err + } + r := new(Release) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/releases/tags/%s", owner, repo, tag), + nil, nil, &r) + return r, resp, err +} + +// CreateReleaseOption options when creating a release +type CreateReleaseOption struct { + TagName string `json:"tag_name"` + Target string `json:"target_commitish"` + Title string `json:"name"` + Note string `json:"body"` + IsDraft bool `json:"draft"` + IsPrerelease bool `json:"prerelease"` +} + +// Validate the CreateReleaseOption struct +func (opt CreateReleaseOption) Validate() error { + if len(strings.TrimSpace(opt.Title)) == 0 { + return fmt.Errorf("title is empty") + } + return nil +} + +// CreateRelease create a release +func (c *Client) CreateRelease(owner, repo string, opt CreateReleaseOption) (*Release, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(opt) + if err != nil { + return nil, nil, err + } + r := new(Release) + resp, err := c.getParsedResponse("POST", + fmt.Sprintf("/repos/%s/%s/releases", owner, repo), + jsonHeader, bytes.NewReader(body), r) + return r, resp, err +} + +// EditReleaseOption options when editing a release +type EditReleaseOption struct { + TagName string `json:"tag_name"` + Target string `json:"target_commitish"` + Title string `json:"name"` + Note string `json:"body"` + IsDraft *bool `json:"draft"` + IsPrerelease *bool `json:"prerelease"` +} + +// EditRelease edit a release +func (c *Client) EditRelease(owner, repo string, id int64, form EditReleaseOption) (*Release, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(form) + if err != nil { + return nil, nil, err + } + r := new(Release) + resp, err := c.getParsedResponse("PATCH", + fmt.Sprintf("/repos/%s/%s/releases/%d", owner, repo, id), + jsonHeader, bytes.NewReader(body), r) + return r, resp, err +} + +// DeleteRelease delete a release from a repository, keeping its tag +func (c *Client) DeleteRelease(user, repo string, id int64) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/releases/%d", user, repo, id), + nil, nil) + return resp, err +} + +// DeleteReleaseByTag deletes a release frm a repository by tag +func (c *Client) DeleteReleaseByTag(user, repo, tag string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &tag); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/releases/tags/%s", user, repo, tag), + nil, nil) + return resp, err +} + +// fallbackGetReleaseByTag is fallback for old gitea installations ( < 1.13.0 ) +func (c *Client) fallbackGetReleaseByTag(owner, repo, tag string) (*Release, *Response, error) { + for i := 1; ; i++ { + rl, resp, err := c.ListReleases(owner, repo, ListReleasesOptions{ListOptions: ListOptions{Page: i}}) + if err != nil { + return nil, resp, err + } + if len(rl) == 0 { + return nil, + newResponse(&http.Response{StatusCode: 404}), + fmt.Errorf("release with tag '%s' not found", tag) + } + for _, r := range rl { + if r.TagName == tag { + return r, resp, nil + } + } + } +} diff --git a/forges/forgejo/sdk/repo.go b/forges/forgejo/sdk/repo.go new file mode 100644 index 0000000..19e2e72 --- /dev/null +++ b/forges/forgejo/sdk/repo.go @@ -0,0 +1,538 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" + "time" +) + +// Permission represents a set of permissions +type Permission struct { + Admin bool `json:"admin"` + Push bool `json:"push"` + Pull bool `json:"pull"` +} + +// InternalTracker represents settings for internal tracker +type InternalTracker struct { + // Enable time tracking (Built-in issue tracker) + EnableTimeTracker bool `json:"enable_time_tracker"` + // Let only contributors track time (Built-in issue tracker) + AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"` + // Enable dependencies for issues and pull requests (Built-in issue tracker) + EnableIssueDependencies bool `json:"enable_issue_dependencies"` +} + +// ExternalTracker represents settings for external tracker +type ExternalTracker struct { + // URL of external issue tracker. + ExternalTrackerURL string `json:"external_tracker_url"` + // External Issue Tracker URL Format. Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index. + ExternalTrackerFormat string `json:"external_tracker_format"` + // External Issue Tracker Number Format, either `numeric` or `alphanumeric` + ExternalTrackerStyle string `json:"external_tracker_style"` +} + +// ExternalWiki represents setting for external wiki +type ExternalWiki struct { + // URL of external wiki. + ExternalWikiURL string `json:"external_wiki_url"` +} + +// Repository represents a repository +type Repository struct { + ID int64 `json:"id"` + Owner *User `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Empty bool `json:"empty"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Template bool `json:"template"` + Parent *Repository `json:"parent"` + Mirror bool `json:"mirror"` + Size int `json:"size"` + HTMLURL string `json:"html_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + OriginalURL string `json:"original_url"` + Website string `json:"website"` + Stars int `json:"stars_count"` + Forks int `json:"forks_count"` + Watchers int `json:"watchers_count"` + OpenIssues int `json:"open_issues_count"` + OpenPulls int `json:"open_pr_counter"` + Releases int `json:"release_counter"` + DefaultBranch string `json:"default_branch"` + Archived bool `json:"archived"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` + Permissions *Permission `json:"permissions,omitempty"` + HasIssues bool `json:"has_issues"` + InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` + ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` + HasWiki bool `json:"has_wiki"` + ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` + HasPullRequests bool `json:"has_pull_requests"` + HasReleases bool `json:"has_releases"` + HasProjects bool `json:"has_projects"` + IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"` + AllowMerge bool `json:"allow_merge_commits"` + AllowRebase bool `json:"allow_rebase"` + AllowRebaseMerge bool `json:"allow_rebase_explicit"` + AllowSquash bool `json:"allow_squash_merge"` + AvatarURL string `json:"avatar_url"` + Internal bool `json:"internal"` + MirrorInterval string `json:"mirror_interval"` + MirrorUpdated time.Time `json:"mirror_updated,omitempty"` + DefaultMergeStyle MergeStyle `json:"default_merge_style"` +} + +// RepoType represent repo type +type RepoType string + +const ( + // RepoTypeNone dont specify a type + RepoTypeNone RepoType = "" + // RepoTypeSource is the default repo type + RepoTypeSource RepoType = "source" + // RepoTypeFork is a repo witch was forked from an other one + RepoTypeFork RepoType = "fork" + // RepoTypeMirror represents an mirror repo + RepoTypeMirror RepoType = "mirror" +) + +// TrustModel represent how git signatures are handled in a repository +type TrustModel string + +const ( + // TrustModelDefault use TM set by global config + TrustModelDefault TrustModel = "default" + // TrustModelCollaborator gpg signature has to be owned by a repo collaborator + TrustModelCollaborator TrustModel = "collaborator" + // TrustModelCommitter gpg signature has to match committer + TrustModelCommitter TrustModel = "committer" + // TrustModelCollaboratorCommitter gpg signature has to match committer and owned by a repo collaborator + TrustModelCollaboratorCommitter TrustModel = "collaboratorcommitter" +) + +// ListReposOptions options for listing repositories +type ListReposOptions struct { + ListOptions +} + +// ListMyRepos lists all repositories for the authenticated user that has access to. +func (c *Client) ListMyRepos(opt ListReposOptions) ([]*Repository, *Response, error) { + opt.setDefaults() + repos := make([]*Repository, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/repos?%s", opt.getURLQuery().Encode()), nil, nil, &repos) + return repos, resp, err +} + +// ListUserRepos list all repositories of one user by user's name +func (c *Client) ListUserRepos(user string, opt ListReposOptions) ([]*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + repos := make([]*Repository, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/repos?%s", user, opt.getURLQuery().Encode()), nil, nil, &repos) + return repos, resp, err +} + +// ListOrgReposOptions options for a organization's repositories +type ListOrgReposOptions struct { + ListOptions +} + +// ListOrgRepos list all repositories of one organization by organization's name +func (c *Client) ListOrgRepos(org string, opt ListOrgReposOptions) ([]*Repository, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + opt.setDefaults() + repos := make([]*Repository, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/repos?%s", org, opt.getURLQuery().Encode()), nil, nil, &repos) + return repos, resp, err +} + +// SearchRepoOptions options for searching repositories +type SearchRepoOptions struct { + ListOptions + + // The keyword to query + Keyword string + // Limit search to repositories with keyword as topic + KeywordIsTopic bool + // Include search of keyword within repository description + KeywordInDescription bool + + /* + User Filter + */ + + // Repo Owner + OwnerID int64 + // Stared By UserID + StarredByUserID int64 + + /* + Repo Attributes + */ + + // pubic, private or all repositories (defaults to all) + IsPrivate *bool + // archived, non-archived or all repositories (defaults to all) + IsArchived *bool + // Exclude template repos from search + ExcludeTemplate bool + // Filter by "fork", "source", "mirror" + Type RepoType + + /* + Sort Filters + */ + + // sort repos by attribute. Supported values are "alpha", "created", "updated", "size", and "id". Default is "alpha" + Sort string + // sort order, either "asc" (ascending) or "desc" (descending). Default is "asc", ignored if "sort" is not specified. + Order string + // Repo owner to prioritize in the results + PrioritizedByOwnerID int64 + + /* + Cover EdgeCases + */ + // if set all other options are ignored and this string is used as query + RawQuery string +} + +// QueryEncode turns options into querystring argument +func (opt *SearchRepoOptions) QueryEncode() string { + query := opt.getURLQuery() + if opt.Keyword != "" { + query.Add("q", opt.Keyword) + } + if opt.KeywordIsTopic { + query.Add("topic", "true") + } + if opt.KeywordInDescription { + query.Add("includeDesc", "true") + } + + // User Filter + if opt.OwnerID > 0 { + query.Add("uid", fmt.Sprintf("%d", opt.OwnerID)) + query.Add("exclusive", "true") + } + if opt.StarredByUserID > 0 { + query.Add("starredBy", fmt.Sprintf("%d", opt.StarredByUserID)) + } + + // Repo Attributes + if opt.IsPrivate != nil { + query.Add("is_private", fmt.Sprintf("%v", opt.IsPrivate)) + } + if opt.IsArchived != nil { + query.Add("archived", fmt.Sprintf("%v", opt.IsArchived)) + } + if opt.ExcludeTemplate { + query.Add("template", "false") + } + if len(opt.Type) != 0 { + query.Add("mode", string(opt.Type)) + } + + // Sort Filters + if opt.Sort != "" { + query.Add("sort", opt.Sort) + } + if opt.PrioritizedByOwnerID > 0 { + query.Add("priority_owner_id", fmt.Sprintf("%d", opt.PrioritizedByOwnerID)) + } + if opt.Order != "" { + query.Add("order", opt.Order) + } + + return query.Encode() +} + +type searchRepoResponse struct { + Repos []*Repository `json:"data"` +} + +// SearchRepos searches for repositories matching the given filters +func (c *Client) SearchRepos(opt SearchRepoOptions) ([]*Repository, *Response, error) { + opt.setDefaults() + repos := new(searchRepoResponse) + + link, _ := url.Parse("/repos/search") + + if len(opt.RawQuery) != 0 { + link.RawQuery = opt.RawQuery + } else { + link.RawQuery = opt.QueryEncode() + // IsPrivate only works on gitea >= 1.12.0 + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil && opt.IsPrivate != nil { + if *opt.IsPrivate { + // private repos only not supported on gitea <= 1.11.x + return nil, nil, err + } + newQuery := link.Query() + newQuery.Add("private", "false") + link.RawQuery = newQuery.Encode() + } + } + + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &repos) + return repos.Repos, resp, err +} + +// CreateRepoOption options when creating repository +type CreateRepoOption struct { + // Name of the repository to create + Name string `json:"name"` + // Description of the repository to create + Description string `json:"description"` + // Whether the repository is private + Private bool `json:"private"` + // Issue Label set to use + IssueLabels string `json:"issue_labels"` + // Whether the repository should be auto-intialized? + AutoInit bool `json:"auto_init"` + // Whether the repository is template + Template bool `json:"template"` + // Gitignores to use + Gitignores string `json:"gitignores"` + // License to use + License string `json:"license"` + // Readme of the repository to create + Readme string `json:"readme"` + // DefaultBranch of the repository (used when initializes and in template) + DefaultBranch string `json:"default_branch"` + // TrustModel of the repository + TrustModel TrustModel `json:"trust_model"` +} + +// Validate the CreateRepoOption struct +func (opt CreateRepoOption) Validate(c *Client) error { + if len(strings.TrimSpace(opt.Name)) == 0 { + return fmt.Errorf("name is empty") + } + if len(opt.Name) > 100 { + return fmt.Errorf("name has more than 100 chars") + } + if len(opt.Description) > 255 { + return fmt.Errorf("description has more than 255 chars") + } + if len(opt.DefaultBranch) > 100 { + return fmt.Errorf("default branch name has more than 100 chars") + } + if len(opt.TrustModel) != 0 { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return err + } + } + return nil +} + +// CreateRepo creates a repository for authenticated user. +func (c *Client) CreateRepo(opt CreateRepoOption) (*Repository, *Response, error) { + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", "/user/repos", jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} + +// CreateOrgRepo creates an organization repository for authenticated user. +func (c *Client) CreateOrgRepo(org string, opt CreateRepoOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&org); err != nil { + return nil, nil, err + } + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/org/%s/repos", org), jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} + +// GetRepo returns information of a repository of given owner. +func (c *Client) GetRepo(owner, reponame string) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&owner, &reponame); err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s", owner, reponame), nil, nil, repo) + return repo, resp, err +} + +// GetRepoByID returns information of a repository by a giver repository ID. +func (c *Client) GetRepoByID(id int64) (*Repository, *Response, error) { + repo := new(Repository) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repositories/%d", id), nil, nil, repo) + return repo, resp, err +} + +// EditRepoOption options when editing a repository's properties +type EditRepoOption struct { + // name of the repository + Name *string `json:"name,omitempty"` + // a short description of the repository. + Description *string `json:"description,omitempty"` + // a URL with more information about the repository. + Website *string `json:"website,omitempty"` + // either `true` to make the repository private or `false` to make it public. + // Note: you will get a 422 error if the organization restricts changing repository visibility to organization + // owners and a non-owner tries to change the value of private. + Private *bool `json:"private,omitempty"` + // either `true` to make this repository a template or `false` to make it a normal repository + Template *bool `json:"template,omitempty"` + // either `true` to enable issues for this repository or `false` to disable them. + HasIssues *bool `json:"has_issues,omitempty"` + // set this structure to configure internal issue tracker (requires has_issues) + InternalTracker *InternalTracker `json:"internal_tracker,omitempty"` + // set this structure to use external issue tracker (requires has_issues) + ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"` + // either `true` to enable the wiki for this repository or `false` to disable it. + HasWiki *bool `json:"has_wiki,omitempty"` + // set this structure to use external wiki instead of internal (requires has_wiki) + ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"` + // sets the default branch for this repository. + DefaultBranch *string `json:"default_branch,omitempty"` + // either `true` to allow pull requests, or `false` to prevent pull request. + HasPullRequests *bool `json:"has_pull_requests,omitempty"` + // either `true` to enable project unit, or `false` to disable them. + HasProjects *bool `json:"has_projects,omitempty"` + // either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace. `has_pull_requests` must be `true`. + IgnoreWhitespaceConflicts *bool `json:"ignore_whitespace_conflicts,omitempty"` + // either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. `has_pull_requests` must be `true`. + AllowMerge *bool `json:"allow_merge_commits,omitempty"` + // either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging. `has_pull_requests` must be `true`. + AllowRebase *bool `json:"allow_rebase,omitempty"` + // either `true` to allow rebase with explicit merge commits (--no-ff), or `false` to prevent rebase with explicit merge commits. `has_pull_requests` must be `true`. + AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"` + // either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. `has_pull_requests` must be `true`. + AllowSquash *bool `json:"allow_squash_merge,omitempty"` + // set to `true` to archive this repository. + Archived *bool `json:"archived,omitempty"` + // set to a string like `8h30m0s` to set the mirror interval time + MirrorInterval *string `json:"mirror_interval,omitempty"` + // either `true` to allow mark pr as merged manually, or `false` to prevent it. `has_pull_requests` must be `true`. + AllowManualMerge *bool `json:"allow_manual_merge,omitempty"` + // either `true` to enable AutodetectManualMerge, or `false` to prevent it. `has_pull_requests` must be `true`, Note: In some special cases, misjudgments can occur. + AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"` + // set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", or "squash". `has_pull_requests` must be `true`. + DefaultMergeStyle *MergeStyle `json:"default_merge_style,omitempty"` + // set to `true` to archive this repository. +} + +// EditRepo edit the properties of a repository +func (c *Client) EditRepo(owner, reponame string, opt EditRepoOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&owner, &reponame); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s", owner, reponame), jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} + +// DeleteRepo deletes a repository of user or organization. +func (c *Client) DeleteRepo(owner, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s", owner, repo), nil, nil) + return resp, err +} + +// MirrorSync adds a mirrored repository to the mirror sync queue. +func (c *Client) MirrorSync(owner, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("POST", fmt.Sprintf("/repos/%s/%s/mirror-sync", owner, repo), nil, nil) + return resp, err +} + +// GetRepoLanguages return language stats of a repo +func (c *Client) GetRepoLanguages(owner, repo string) (map[string]int64, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + langMap := make(map[string]int64) + + data, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/languages", owner, repo), jsonHeader, nil) + if err != nil { + return nil, resp, err + } + if err = json.Unmarshal(data, &langMap); err != nil { + return nil, resp, err + } + return langMap, resp, nil +} + +// ArchiveType represent supported archive formats by gitea +type ArchiveType string + +const ( + // ZipArchive represent zip format + ZipArchive ArchiveType = ".zip" + // TarGZArchive represent tar.gz format + TarGZArchive ArchiveType = ".tar.gz" +) + +// GetArchive get an archive of a repository by git reference +// e.g.: ref -> master, 70b7c74b33, v1.2.1, ... +func (c *Client) GetArchive(owner, repo, ref string, ext ArchiveType) ([]byte, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + ref = pathEscapeSegments(ref) + return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/archive/%s%s", owner, repo, ref, ext), nil, nil) +} + +// GetArchiveReader gets a `git archive` for a particular tree-ish git reference +// such as a branch name (`master`), a commit hash (`70b7c74b33`), a tag +// (`v1.2.1`). The archive is returned as a byte stream in a ReadCloser. It is +// the responsibility of the client to close the reader. +func (c *Client) GetArchiveReader(owner, repo, ref string, ext ArchiveType) (io.ReadCloser, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + ref = pathEscapeSegments(ref) + resp, err := c.doRequest("GET", fmt.Sprintf("/repos/%s/%s/archive/%s%s", owner, repo, ref, ext), nil, nil) + if err != nil { + return nil, resp, err + } + + if _, err := statusCodeToErr(resp); err != nil { + return nil, resp, err + } + + return resp.Body, resp, nil +} diff --git a/forges/forgejo/sdk/repo_branch.go b/forges/forgejo/sdk/repo_branch.go new file mode 100644 index 0000000..e02494a --- /dev/null +++ b/forges/forgejo/sdk/repo_branch.go @@ -0,0 +1,143 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// PayloadUser represents the author or committer of a commit +type PayloadUser struct { + // Full name of the commit author + Name string `json:"name"` + Email string `json:"email"` + UserName string `json:"username"` +} + +// PayloadCommit represents a commit +type PayloadCommit struct { + // sha1 hash of the commit + ID string `json:"id"` + Message string `json:"message"` + URL string `json:"url"` + Author *PayloadUser `json:"author"` + Committer *PayloadUser `json:"committer"` + Verification *PayloadCommitVerification `json:"verification"` + Timestamp time.Time `json:"timestamp"` + Added []string `json:"added"` + Removed []string `json:"removed"` + Modified []string `json:"modified"` +} + +// PayloadCommitVerification represents the GPG verification of a commit +type PayloadCommitVerification struct { + Verified bool `json:"verified"` + Reason string `json:"reason"` + Signature string `json:"signature"` + Payload string `json:"payload"` +} + +// Branch represents a repository branch +type Branch struct { + Name string `json:"name"` + Commit *PayloadCommit `json:"commit"` + Protected bool `json:"protected"` + RequiredApprovals int64 `json:"required_approvals"` + EnableStatusCheck bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + UserCanPush bool `json:"user_can_push"` + UserCanMerge bool `json:"user_can_merge"` + EffectiveBranchProtectionName string `json:"effective_branch_protection_name"` +} + +// ListRepoBranchesOptions options for listing a repository's branches +type ListRepoBranchesOptions struct { + ListOptions +} + +// ListRepoBranches list all the branches of one repository +func (c *Client) ListRepoBranches(user, repo string, opt ListRepoBranchesOptions) ([]*Branch, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + branches := make([]*Branch, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branches?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &branches) + return branches, resp, err +} + +// GetRepoBranch get one branch's information of one repository +func (c *Client) GetRepoBranch(user, repo, branch string) (*Branch, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &branch); err != nil { + return nil, nil, err + } + b := new(Branch) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), nil, nil, &b) + if err != nil { + return nil, resp, err + } + return b, resp, nil +} + +// DeleteRepoBranch delete a branch in a repository +func (c *Client) DeleteRepoBranch(user, repo, branch string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &branch); err != nil { + return false, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), nil, nil) + if err != nil { + return false, resp, err + } + return status == 204, resp, nil +} + +// CreateBranchOption options when creating a branch in a repository +type CreateBranchOption struct { + // Name of the branch to create + BranchName string `json:"new_branch_name"` + // Name of the old branch to create from (optional) + OldBranchName string `json:"old_branch_name"` +} + +// Validate the CreateBranchOption struct +func (opt CreateBranchOption) Validate() error { + if len(opt.BranchName) == 0 { + return fmt.Errorf("BranchName is empty") + } + if len(opt.BranchName) > 100 { + return fmt.Errorf("BranchName to long") + } + if len(opt.OldBranchName) > 100 { + return fmt.Errorf("OldBranchName to long") + } + return nil +} + +// CreateBranch creates a branch for a user's repository +func (c *Client) CreateBranch(owner, repo string, opt CreateBranchOption) (*Branch, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + branch := new(Branch) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/branches", owner, repo), jsonHeader, bytes.NewReader(body), branch) + return branch, resp, err +} diff --git a/forges/forgejo/sdk/repo_branch_protection.go b/forges/forgejo/sdk/repo_branch_protection.go new file mode 100644 index 0000000..6a989b2 --- /dev/null +++ b/forges/forgejo/sdk/repo_branch_protection.go @@ -0,0 +1,173 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// BranchProtection represents a branch protection for a repository +type BranchProtection struct { + BranchName string `json:"branch_name"` + RuleName string `json:"rule_name"` + EnablePush bool `json:"enable_push"` + EnablePushWhitelist bool `json:"enable_push_whitelist"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals int64 `json:"required_approvals"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals"` + RequireSignedCommits bool `json:"require_signed_commits"` + ProtectedFilePatterns string `json:"protected_file_patterns"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` +} + +// CreateBranchProtectionOption options for creating a branch protection +type CreateBranchProtectionOption struct { + BranchName string `json:"branch_name"` + RuleName string `json:"rule_name"` + EnablePush bool `json:"enable_push"` + EnablePushWhitelist bool `json:"enable_push_whitelist"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals int64 `json:"required_approvals"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals"` + RequireSignedCommits bool `json:"require_signed_commits"` + ProtectedFilePatterns string `json:"protected_file_patterns"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns"` +} + +// EditBranchProtectionOption options for editing a branch protection +type EditBranchProtectionOption struct { + EnablePush *bool `json:"enable_push"` + EnablePushWhitelist *bool `json:"enable_push_whitelist"` + PushWhitelistUsernames []string `json:"push_whitelist_usernames"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"` + EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` + MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck *bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals *int64 `json:"required_approvals"` + EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"` + DismissStaleApprovals *bool `json:"dismiss_stale_approvals"` + RequireSignedCommits *bool `json:"require_signed_commits"` + ProtectedFilePatterns *string `json:"protected_file_patterns"` + UnprotectedFilePatterns *string `json:"unprotected_file_patterns"` +} + +// ListBranchProtectionsOptions list branch protection options +type ListBranchProtectionsOptions struct { + ListOptions +} + +// ListBranchProtections list branch protections for a repo +func (c *Client) ListBranchProtections(owner, repo string, opt ListBranchProtectionsOptions) ([]*BranchProtection, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + bps := make([]*BranchProtection, 0, opt.PageSize) + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/branch_protections", owner, repo)) + link.RawQuery = opt.getURLQuery().Encode() + resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &bps) + return bps, resp, err +} + +// GetBranchProtection gets a branch protection +func (c *Client) GetBranchProtection(owner, repo, name string) (*BranchProtection, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + bp := new(BranchProtection) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, nil, bp) + return bp, resp, err +} + +// CreateBranchProtection creates a branch protection for a repo +func (c *Client) CreateBranchProtection(owner, repo string, opt CreateBranchProtectionOption) (*BranchProtection, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + bp := new(BranchProtection) + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/branch_protections", owner, repo), jsonHeader, bytes.NewReader(body), bp) + return bp, resp, err +} + +// EditBranchProtection edits a branch protection for a repo +func (c *Client) EditBranchProtection(owner, repo, name string, opt EditBranchProtectionOption) (*BranchProtection, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + bp := new(BranchProtection) + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, bytes.NewReader(body), bp) + return bp, resp, err +} + +// DeleteBranchProtection deletes a branch protection for a repo +func (c *Client) DeleteBranchProtection(owner, repo, name string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/repo_collaborator.go b/forges/forgejo/sdk/repo_collaborator.go new file mode 100644 index 0000000..4521b60 --- /dev/null +++ b/forges/forgejo/sdk/repo_collaborator.go @@ -0,0 +1,163 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2016 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ListCollaboratorsOptions options for listing a repository's collaborators +type ListCollaboratorsOptions struct { + ListOptions +} + +// CollaboratorPermissionResult result type for CollaboratorPermission +type CollaboratorPermissionResult struct { + Permission AccessMode `json:"permission"` + Role string `json:"role_name"` + User *User `json:"user"` +} + +// ListCollaborators list a repository's collaborators +func (c *Client) ListCollaborators(user, repo string, opt ListCollaboratorsOptions) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + collaborators := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/collaborators?%s", user, repo, opt.getURLQuery().Encode()), + nil, nil, &collaborators) + return collaborators, resp, err +} + +// IsCollaborator check if a user is a collaborator of a repository +func (c *Client) IsCollaborator(user, repo, collaborator string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), nil, nil) + if err != nil { + return false, resp, err + } + if status == 204 { + return true, resp, nil + } + return false, resp, nil +} + +// CollaboratorPermission gets collaborator permission of a repository +func (c *Client) CollaboratorPermission(user, repo, collaborator string) (*CollaboratorPermissionResult, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil { + return nil, nil, err + } + rv := new(CollaboratorPermissionResult) + resp, err := c.getParsedResponse("GET", + fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", user, repo, collaborator), + nil, + nil, + rv) + if err != nil { + return nil, resp, err + } + if resp.StatusCode != 200 { + rv = nil + } + return rv, resp, nil +} + +// AddCollaboratorOption options when adding a user as a collaborator of a repository +type AddCollaboratorOption struct { + Permission *AccessMode `json:"permission"` +} + +// AccessMode represent the grade of access you have to something +type AccessMode string + +const ( + // AccessModeNone no access + AccessModeNone AccessMode = "none" + // AccessModeRead read access + AccessModeRead AccessMode = "read" + // AccessModeWrite write access + AccessModeWrite AccessMode = "write" + // AccessModeAdmin admin access + AccessModeAdmin AccessMode = "admin" + // AccessModeOwner owner + AccessModeOwner AccessMode = "owner" +) + +// Validate the AddCollaboratorOption struct +func (opt *AddCollaboratorOption) Validate() error { + if opt.Permission != nil { + if *opt.Permission == AccessModeOwner { + *opt.Permission = AccessModeAdmin + return nil + } + if *opt.Permission == AccessModeNone { + opt.Permission = nil + return nil + } + if *opt.Permission != AccessModeRead && *opt.Permission != AccessModeWrite && *opt.Permission != AccessModeAdmin { + return fmt.Errorf("permission mode invalid") + } + } + return nil +} + +// AddCollaborator add some user as a collaborator of a repository +func (c *Client) AddCollaborator(user, repo, collaborator string, opt AddCollaboratorOption) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil { + return nil, err + } + if err := (&opt).Validate(); err != nil { + return nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// DeleteCollaborator remove a collaborator from a repository +func (c *Client) DeleteCollaborator(user, repo, collaborator string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), nil, nil) + return resp, err +} + +// GetReviewers return all users that can be requested to review in this repo +func (c *Client) GetReviewers(user, repo string) ([]*User, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + reviewers := make([]*User, 0, 5) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/reviewers", user, repo), nil, nil, &reviewers) + return reviewers, resp, err +} + +// GetAssignees return all users that have write access and can be assigned to issues +func (c *Client) GetAssignees(user, repo string) ([]*User, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + assignees := make([]*User, 0, 5) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/assignees", user, repo), nil, nil, &assignees) + return assignees, resp, err +} diff --git a/forges/forgejo/sdk/repo_commit.go b/forges/forgejo/sdk/repo_commit.go new file mode 100644 index 0000000..ef1330a --- /dev/null +++ b/forges/forgejo/sdk/repo_commit.go @@ -0,0 +1,141 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" + "time" +) + +// Identity for a person's identity like an author or committer +type Identity struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// CommitMeta contains meta information of a commit in terms of API. +type CommitMeta struct { + URL string `json:"url"` + SHA string `json:"sha"` + Created time.Time `json:"created"` +} + +// CommitUser contains information of a user in the context of a commit. +type CommitUser struct { + Identity + Date string `json:"date"` +} + +// RepoCommit contains information of a commit in the context of a repository. +type RepoCommit struct { + URL string `json:"url"` + Author *CommitUser `json:"author"` + Committer *CommitUser `json:"committer"` + Message string `json:"message"` + Tree *CommitMeta `json:"tree"` + Verification *PayloadCommitVerification `json:"verification"` +} + +// CommitStats contains stats from a Git commit +type CommitStats struct { + Total int `json:"total"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +// Commit contains information generated from a Git commit. +type Commit struct { + *CommitMeta + HTMLURL string `json:"html_url"` + RepoCommit *RepoCommit `json:"commit"` + Author *User `json:"author"` + Committer *User `json:"committer"` + Parents []*CommitMeta `json:"parents"` + Files []*CommitAffectedFiles `json:"files"` + Stats *CommitStats `json:"stats"` +} + +// CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE +type CommitDateOptions struct { + Author time.Time `json:"author"` + Committer time.Time `json:"committer"` +} + +// CommitAffectedFiles store information about files affected by the commit +type CommitAffectedFiles struct { + Filename string `json:"filename"` +} + +// GetSingleCommit returns a single commit +func (c *Client) GetSingleCommit(user, repo, commitID string) (*Commit, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &commitID); err != nil { + return nil, nil, err + } + commit := new(Commit) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s", user, repo, commitID), nil, nil, &commit) + return commit, resp, err +} + +// ListCommitOptions list commit options +type ListCommitOptions struct { + ListOptions + // SHA or branch to start listing commits from (usually 'master') + SHA string + // Path indicates that only commits that include the path's file/dir should be returned. + Path string +} + +// QueryEncode turns options into querystring argument +func (opt *ListCommitOptions) QueryEncode() string { + query := opt.getURLQuery() + if opt.SHA != "" { + query.Add("sha", opt.SHA) + } + if opt.Path != "" { + query.Add("path", opt.Path) + } + return query.Encode() +} + +// ListRepoCommits return list of commits from a repo +func (c *Client) ListRepoCommits(user, repo string, opt ListCommitOptions) ([]*Commit, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/commits", user, repo)) + opt.setDefaults() + commits := make([]*Commit, 0, opt.PageSize) + link.RawQuery = opt.QueryEncode() + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &commits) + return commits, resp, err +} + +// GetCommitDiff returns the commit's raw diff. +func (c *Client) GetCommitDiff(user, repo, commitID string) ([]byte, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil { + return nil, nil, err + } + + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + + return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s.%s", user, repo, commitID, pullRequestDiffTypeDiff), nil, nil) +} + +// GetCommitPatch returns the commit's raw patch. +func (c *Client) GetCommitPatch(user, repo, commitID string) ([]byte, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil { + return nil, nil, err + } + + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + + return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s.%s", user, repo, commitID, pullRequestDiffTypePatch), nil, nil) +} diff --git a/forges/forgejo/sdk/repo_file.go b/forges/forgejo/sdk/repo_file.go new file mode 100644 index 0000000..b29f5d3 --- /dev/null +++ b/forges/forgejo/sdk/repo_file.go @@ -0,0 +1,277 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/url" + "strings" +) + +// FileOptions options for all file APIs +type FileOptions struct { + // message (optional) for the commit of this file. if not supplied, a default message will be used + Message string `json:"message"` + // branch (optional) to base this file from. if not given, the default branch is used + BranchName string `json:"branch"` + // new_branch (optional) will make a new branch from `branch` before creating the file + NewBranchName string `json:"new_branch"` + // `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) + Author Identity `json:"author"` + Committer Identity `json:"committer"` + Dates CommitDateOptions `json:"dates"` + // Add a Signed-off-by trailer by the committer at the end of the commit log message. + Signoff bool `json:"signoff"` +} + +// CreateFileOptions options for creating files +// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +type CreateFileOptions struct { + FileOptions + // content must be base64 encoded + // required: true + Content string `json:"content"` +} + +// DeleteFileOptions options for deleting files (used for other File structs below) +// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +type DeleteFileOptions struct { + FileOptions + // sha is the SHA for the file that already exists + // required: true + SHA string `json:"sha"` +} + +// UpdateFileOptions options for updating files +// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) +type UpdateFileOptions struct { + FileOptions + // sha is the SHA for the file that already exists + // required: true + SHA string `json:"sha"` + // content must be base64 encoded + // required: true + Content string `json:"content"` + // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL + FromPath string `json:"from_path"` +} + +// FileLinksResponse contains the links for a repo's file +type FileLinksResponse struct { + Self *string `json:"self"` + GitURL *string `json:"git"` + HTMLURL *string `json:"html"` +} + +// ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content +type ContentsResponse struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + // `type` will be `file`, `dir`, `symlink`, or `submodule` + Type string `json:"type"` + Size int64 `json:"size"` + // `encoding` is populated when `type` is `file`, otherwise null + Encoding *string `json:"encoding"` + // `content` is populated when `type` is `file`, otherwise null + Content *string `json:"content"` + // `target` is populated when `type` is `symlink`, otherwise null + Target *string `json:"target"` + URL *string `json:"url"` + HTMLURL *string `json:"html_url"` + GitURL *string `json:"git_url"` + DownloadURL *string `json:"download_url"` + // `submodule_git_url` is populated when `type` is `submodule`, otherwise null + SubmoduleGitURL *string `json:"submodule_git_url"` + Links *FileLinksResponse `json:"_links"` +} + +// FileCommitResponse contains information generated from a Git commit for a repo's file. +type FileCommitResponse struct { + CommitMeta + HTMLURL string `json:"html_url"` + Author *CommitUser `json:"author"` + Committer *CommitUser `json:"committer"` + Parents []*CommitMeta `json:"parents"` + Message string `json:"message"` + Tree *CommitMeta `json:"tree"` +} + +// FileResponse contains information about a repo's file +type FileResponse struct { + Content *ContentsResponse `json:"content"` + Commit *FileCommitResponse `json:"commit"` + Verification *PayloadCommitVerification `json:"verification"` +} + +// FileDeleteResponse contains information about a repo's file that was deleted +type FileDeleteResponse struct { + Content interface{} `json:"content"` // to be set to nil + Commit *FileCommitResponse `json:"commit"` + Verification *PayloadCommitVerification `json:"verification"` +} + +// GetFile downloads a file of repository, ref can be branch/tag/commit. +// it optional can resolve lfs pointers and server the file instead +// e.g.: ref -> master, filepath -> README.md (no leading slash) +func (c *Client) GetFile(owner, repo, ref, filepath string, resolveLFS ...bool) ([]byte, *Response, error) { + reader, resp, err := c.GetFileReader(owner, repo, ref, filepath, resolveLFS...) + if reader == nil { + return nil, resp, err + } + defer reader.Close() + + data, err2 := io.ReadAll(reader) + if err2 != nil { + return nil, resp, err2 + } + + return data, resp, err +} + +// GetFileReader return reader for download a file of repository, ref can be branch/tag/commit. +// it optional can resolve lfs pointers and server the file instead +// e.g.: ref -> master, filepath -> README.md (no leading slash) +func (c *Client) GetFileReader(owner, repo, ref, filepath string, resolveLFS ...bool) (io.ReadCloser, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + + // resolve lfs + if len(resolveLFS) != 0 && resolveLFS[0] { + if err := c.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil { + return nil, nil, err + } + return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/media/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), nil, nil) + } + + // normal get + filepath = pathEscapeSegments(filepath) + if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil { + ref = pathEscapeSegments(ref) + return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/raw/%s/%s", owner, repo, ref, filepath), nil, nil) + } + return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/raw/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), nil, nil) +} + +// GetContents get the metadata and contents of a file in a repository +// ref is optional +func (c *Client) GetContents(owner, repo, ref, filepath string) (*ContentsResponse, *Response, error) { + data, resp, err := c.getDirOrFileContents(owner, repo, ref, filepath) + if err != nil { + return nil, resp, err + } + cr := new(ContentsResponse) + if json.Unmarshal(data, &cr) != nil { + return nil, resp, fmt.Errorf("expect file, got directory") + } + return cr, resp, err +} + +// ListContents gets a list of entries in a dir +// ref is optional +func (c *Client) ListContents(owner, repo, ref, filepath string) ([]*ContentsResponse, *Response, error) { + data, resp, err := c.getDirOrFileContents(owner, repo, ref, filepath) + if err != nil { + return nil, resp, err + } + crl := make([]*ContentsResponse, 0) + if json.Unmarshal(data, &crl) != nil { + return nil, resp, fmt.Errorf("expect directory, got file") + } + return crl, resp, err +} + +func (c *Client) getDirOrFileContents(owner, repo, ref, filepath string) ([]byte, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + filepath = pathEscapeSegments(strings.TrimPrefix(filepath, "/")) + return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/contents/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), jsonHeader, nil) +} + +// CreateFile create a file in a repository +func (c *Client) CreateFile(owner, repo, filepath string, opt CreateFileOptions) (*FileResponse, *Response, error) { + var err error + if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + filepath = pathEscapeSegments(filepath) + + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + fr := new(FileResponse) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body), fr) + return fr, resp, err +} + +// UpdateFile update a file in a repository +func (c *Client) UpdateFile(owner, repo, filepath string, opt UpdateFileOptions) (*FileResponse, *Response, error) { + var err error + if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil { + return nil, nil, err + } + + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + filepath = pathEscapeSegments(filepath) + + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + fr := new(FileResponse) + resp, err := c.getParsedResponse("PUT", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body), fr) + return fr, resp, err +} + +// DeleteFile delete a file from repository +func (c *Client) DeleteFile(owner, repo, filepath string, opt DeleteFileOptions) (*Response, error) { + var err error + if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil { + return nil, err + } + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + filepath = pathEscapeSegments(filepath) + + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body)) + if err != nil { + return resp, err + } + if status != 200 && status != 204 { + return resp, fmt.Errorf("unexpected Status: %d", status) + } + return resp, nil +} + +func (c *Client) setDefaultBranchForOldVersions(owner, repo, branch string) (string, error) { + if len(branch) == 0 { + // Gitea >= 1.12.0 Use DefaultBranch on "", mimic this for older versions + if c.checkServerVersionGreaterThanOrEqual(version1_12_0) != nil { + r, _, err := c.GetRepo(owner, repo) + if err != nil { + return "", err + } + return r.DefaultBranch, nil + } + } + return branch, nil +} diff --git a/forges/forgejo/sdk/repo_key.go b/forges/forgejo/sdk/repo_key.go new file mode 100644 index 0000000..4372664 --- /dev/null +++ b/forges/forgejo/sdk/repo_key.go @@ -0,0 +1,91 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// DeployKey a deploy key +type DeployKey struct { + ID int64 `json:"id"` + KeyID int64 `json:"key_id"` + Key string `json:"key"` + URL string `json:"url"` + Title string `json:"title"` + Fingerprint string `json:"fingerprint"` + Created time.Time `json:"created_at"` + ReadOnly bool `json:"read_only"` + Repository *Repository `json:"repository,omitempty"` +} + +// ListDeployKeysOptions options for listing a repository's deploy keys +type ListDeployKeysOptions struct { + ListOptions + KeyID int64 + Fingerprint string +} + +// QueryEncode turns options into querystring argument +func (opt *ListDeployKeysOptions) QueryEncode() string { + query := opt.getURLQuery() + if opt.KeyID > 0 { + query.Add("key_id", fmt.Sprintf("%d", opt.KeyID)) + } + if len(opt.Fingerprint) > 0 { + query.Add("fingerprint", opt.Fingerprint) + } + return query.Encode() +} + +// ListDeployKeys list all the deploy keys of one repository +func (c *Client) ListDeployKeys(user, repo string, opt ListDeployKeysOptions) ([]*DeployKey, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/keys", user, repo)) + opt.setDefaults() + link.RawQuery = opt.QueryEncode() + keys := make([]*DeployKey, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &keys) + return keys, resp, err +} + +// GetDeployKey get one deploy key with key id +func (c *Client) GetDeployKey(user, repo string, keyID int64) (*DeployKey, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + key := new(DeployKey) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/keys/%d", user, repo, keyID), nil, nil, &key) + return key, resp, err +} + +// CreateDeployKey options when create one deploy key +func (c *Client) CreateDeployKey(user, repo string, opt CreateKeyOption) (*DeployKey, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + key := new(DeployKey) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/keys", user, repo), jsonHeader, bytes.NewReader(body), key) + return key, resp, err +} + +// DeleteDeployKey delete deploy key with key id +func (c *Client) DeleteDeployKey(owner, repo string, keyID int64) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/keys/%d", owner, repo, keyID), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/repo_migrate.go b/forges/forgejo/sdk/repo_migrate.go new file mode 100644 index 0000000..39f1ebe --- /dev/null +++ b/forges/forgejo/sdk/repo_migrate.go @@ -0,0 +1,132 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// GitServiceType represents a git service +type GitServiceType string + +const ( + // GitServicePlain represents a plain git service + GitServicePlain GitServiceType = "git" + // GitServiceGithub represents github.com + GitServiceGithub GitServiceType = "github" + // GitServiceGitlab represents a gitlab service + GitServiceGitlab GitServiceType = "gitlab" + // GitServiceGitea represents a gitea service + GitServiceGitea GitServiceType = "gitea" + // GitServiceGogs represents a gogs service + GitServiceGogs GitServiceType = "gogs" +) + +// MigrateRepoOption options for migrating a repository from an external service +type MigrateRepoOption struct { + RepoName string `json:"repo_name"` + RepoOwner string `json:"repo_owner"` + // deprecated use RepoOwner + RepoOwnerID int64 `json:"uid"` + CloneAddr string `json:"clone_addr"` + Service GitServiceType `json:"service"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + AuthToken string `json:"auth_token"` + Mirror bool `json:"mirror"` + Private bool `json:"private"` + Description string `json:"description"` + Wiki bool `json:"wiki"` + Milestones bool `json:"milestones"` + Labels bool `json:"labels"` + Issues bool `json:"issues"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` + MirrorInterval string `json:"mirror_interval"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint"` +} + +// Validate the MigrateRepoOption struct +func (opt *MigrateRepoOption) Validate(c *Client) error { + // check user options + if len(opt.CloneAddr) == 0 { + return fmt.Errorf("CloneAddr required") + } + if len(opt.RepoName) == 0 { + return fmt.Errorf("RepoName required") + } else if len(opt.RepoName) > 100 { + return fmt.Errorf("RepoName to long") + } + if len(opt.Description) > 255 { + return fmt.Errorf("Description to long") + } + switch opt.Service { + case GitServiceGithub: + if len(opt.AuthToken) == 0 { + return fmt.Errorf("github requires token authentication") + } + case GitServiceGitlab, GitServiceGitea: + if len(opt.AuthToken) == 0 { + return fmt.Errorf("%s requires token authentication", opt.Service) + } + // Gitlab is supported since 1.12.0 but api cant handle it until 1.13.0 + // https://github.com/go-gitea/gitea/pull/12672 + if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil { + return fmt.Errorf("migrate from service %s need gitea >= 1.13.0", opt.Service) + } + case GitServiceGogs: + if len(opt.AuthToken) == 0 { + return fmt.Errorf("gogs requires token authentication") + } + if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil { + return fmt.Errorf("migrate from service gogs need gitea >= 1.14.0") + } + } + return nil +} + +// MigrateRepo migrates a repository from other Git hosting sources for the authenticated user. +// +// To migrate a repository for a organization, the authenticated user must be a +// owner of the specified organization. +func (c *Client) MigrateRepo(opt MigrateRepoOption) (*Repository, *Response, error) { + if err := opt.Validate(c); err != nil { + return nil, nil, err + } + + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + if len(opt.AuthToken) != 0 { + // gitea <= 1.12 dont understand AuthToken + opt.AuthUsername = opt.AuthToken + opt.AuthPassword, opt.AuthToken = "", "" + } + if len(opt.RepoOwner) != 0 { + // gitea <= 1.12 dont understand RepoOwner + u, _, err := c.GetUserInfo(opt.RepoOwner) + if err != nil { + return nil, nil, err + } + opt.RepoOwnerID = u.ID + } else if opt.RepoOwnerID == 0 { + // gitea <= 1.12 require RepoOwnerID + u, _, err := c.GetMyUserInfo() + if err != nil { + return nil, nil, err + } + opt.RepoOwnerID = u.ID + } + } + + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", "/repos/migrate", jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} diff --git a/forges/forgejo/sdk/repo_refs.go b/forges/forgejo/sdk/repo_refs.go new file mode 100644 index 0000000..5f1680d --- /dev/null +++ b/forges/forgejo/sdk/repo_refs.go @@ -0,0 +1,78 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Reference represents a Git reference. +type Reference struct { + Ref string `json:"ref"` + URL string `json:"url"` + Object *GitObject `json:"object"` +} + +// GitObject represents a Git object. +type GitObject struct { + Type string `json:"type"` + SHA string `json:"sha"` + URL string `json:"url"` +} + +// GetRepoRef get one ref's information of one repository +func (c *Client) GetRepoRef(user, repo, ref string) (*Reference, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + ref = strings.TrimPrefix(ref, "refs/") + ref = pathEscapeSegments(ref) + r := new(Reference) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/refs/%s", user, repo, ref), nil, nil, &r) + if _, ok := err.(*json.UnmarshalTypeError); ok { + // Multiple refs + return nil, resp, errors.New("no exact match found for this ref") + } else if err != nil { + return nil, resp, err + } + + return r, resp, nil +} + +// GetRepoRefs get list of ref's information of one repository +func (c *Client) GetRepoRefs(user, repo, ref string) ([]*Reference, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + ref = strings.TrimPrefix(ref, "refs/") + ref = pathEscapeSegments(ref) + + data, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/refs/%s", user, repo, ref), nil, nil) + if err != nil { + return nil, resp, err + } + + // Attempt to unmarshal single returned ref. + r := new(Reference) + refErr := json.Unmarshal(data, r) + if refErr == nil { + return []*Reference{r}, resp, nil + } + + // Attempt to unmarshal multiple refs. + var rs []*Reference + refsErr := json.Unmarshal(data, &rs) + if refsErr == nil { + if len(rs) == 0 { + return nil, resp, errors.New("unexpected response: an array of refs with length 0") + } + return rs, resp, nil + } + + return nil, resp, fmt.Errorf("unmarshalling failed for both single and multiple refs: %s and %s", refErr, refsErr) +} diff --git a/forges/forgejo/sdk/repo_stars.go b/forges/forgejo/sdk/repo_stars.go new file mode 100644 index 0000000..0253f90 --- /dev/null +++ b/forges/forgejo/sdk/repo_stars.go @@ -0,0 +1,96 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/http" +) + +// ListStargazersOptions options for listing a repository's stargazers +type ListStargazersOptions struct { + ListOptions +} + +// ListRepoStargazers list a repository's stargazers +func (c *Client) ListRepoStargazers(user, repo string, opt ListStargazersOptions) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + stargazers := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/stargazers?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &stargazers) + return stargazers, resp, err +} + +// GetStarredRepos returns the repos that the given user has starred +func (c *Client) GetStarredRepos(user string) ([]*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + repos := make([]*Repository, 0, 10) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/starred", user), jsonHeader, nil, &repos) + return repos, resp, err +} + +// GetMyStarredRepos returns the repos that the authenticated user has starred +func (c *Client) GetMyStarredRepos() ([]*Repository, *Response, error) { + repos := make([]*Repository, 0, 10) + resp, err := c.getParsedResponse("GET", "/user/starred", jsonHeader, nil, &repos) + return repos, resp, err +} + +// IsRepoStarring returns whether the authenticated user has starred the repo or not +func (c *Client) IsRepoStarring(user, repo string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return false, nil, err + } + _, resp, err := c.getResponse("GET", fmt.Sprintf("/user/starred/%s/%s", user, repo), jsonHeader, nil) + if resp != nil { + switch resp.StatusCode { + case http.StatusNotFound: + return false, resp, nil + case http.StatusNoContent: + return true, resp, nil + default: + return false, resp, fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + } + return false, nil, err +} + +// StarRepo star specified repo as the authenticated user +func (c *Client) StarRepo(user, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/user/starred/%s/%s", user, repo), jsonHeader, nil) + if resp != nil { + switch resp.StatusCode { + case http.StatusNoContent: + return resp, nil + default: + return resp, fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + } + return nil, err +} + +// UnStarRepo remove star to specified repo as the authenticated user +func (c *Client) UnStarRepo(user, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/user/starred/%s/%s", user, repo), jsonHeader, nil) + if resp != nil { + switch resp.StatusCode { + case http.StatusNoContent: + return resp, nil + default: + return resp, fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + } + return nil, err +} diff --git a/forges/forgejo/sdk/repo_tag.go b/forges/forgejo/sdk/repo_tag.go new file mode 100644 index 0000000..ae58be2 --- /dev/null +++ b/forges/forgejo/sdk/repo_tag.go @@ -0,0 +1,130 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// Tag represents a repository tag +type Tag struct { + Name string `json:"name"` + Message string `json:"message"` + ID string `json:"id"` + Commit *CommitMeta `json:"commit"` + ZipballURL string `json:"zipball_url"` + TarballURL string `json:"tarball_url"` +} + +// AnnotatedTag represents an annotated tag +type AnnotatedTag struct { + Tag string `json:"tag"` + SHA string `json:"sha"` + URL string `json:"url"` + Message string `json:"message"` + Tagger *CommitUser `json:"tagger"` + Object *AnnotatedTagObject `json:"object"` + Verification *PayloadCommitVerification `json:"verification"` +} + +// AnnotatedTagObject contains meta information of the tag object +type AnnotatedTagObject struct { + Type string `json:"type"` + URL string `json:"url"` + SHA string `json:"sha"` +} + +// ListRepoTagsOptions options for listing a repository's tags +type ListRepoTagsOptions struct { + ListOptions +} + +// ListRepoTags list all the branches of one repository +func (c *Client) ListRepoTags(user, repo string, opt ListRepoTagsOptions) ([]*Tag, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + tags := make([]*Tag, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/tags?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &tags) + return tags, resp, err +} + +// GetTag get the tag of a repository +func (c *Client) GetTag(user, repo, tag string) (*Tag, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo, &tag); err != nil { + return nil, nil, err + } + t := new(Tag) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/tags/%s", user, repo, tag), nil, nil, &t) + return t, resp, err +} + +// GetAnnotatedTag get the tag object of an annotated tag (not lightweight tags) of a repository +func (c *Client) GetAnnotatedTag(user, repo, sha string) (*AnnotatedTag, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo, &sha); err != nil { + return nil, nil, err + } + t := new(AnnotatedTag) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/tags/%s", user, repo, sha), nil, nil, &t) + return t, resp, err +} + +// CreateTagOption options when creating a tag +type CreateTagOption struct { + TagName string `json:"tag_name"` + Message string `json:"message"` + Target string `json:"target"` +} + +// Validate validates CreateTagOption +func (opt CreateTagOption) Validate() error { + if len(opt.TagName) == 0 { + return fmt.Errorf("TagName is required") + } + return nil +} + +// CreateTag create a new git tag in a repository +func (c *Client) CreateTag(user, repo string, opt CreateTagOption) (*Tag, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(opt) + if err != nil { + return nil, nil, err + } + t := new(Tag) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/tags", user, repo), jsonHeader, bytes.NewReader(body), &t) + return t, resp, err +} + +// DeleteTag deletes a tag from a repository, if no release refers to it +func (c *Client) DeleteTag(user, repo, tag string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &tag); err != nil { + return nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", + fmt.Sprintf("/repos/%s/%s/tags/%s", user, repo, tag), + nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/repo_team.go b/forges/forgejo/sdk/repo_team.go new file mode 100644 index 0000000..ae6f73a --- /dev/null +++ b/forges/forgejo/sdk/repo_team.go @@ -0,0 +1,65 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/http" +) + +// GetRepoTeams return teams from a repository +func (c *Client) GetRepoTeams(user, repo string) ([]*Team, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + teams := make([]*Team, 0, 5) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/teams", user, repo), nil, nil, &teams) + return teams, resp, err +} + +// AddRepoTeam add a team to a repository +func (c *Client) AddRepoTeam(user, repo, team string) (*Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, err + } + if err := escapeValidatePathSegments(&user, &repo, &team); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/repos/%s/%s/teams/%s", user, repo, team), nil, nil) + return resp, err +} + +// RemoveRepoTeam delete a team from a repository +func (c *Client) RemoveRepoTeam(user, repo, team string) (*Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, err + } + if err := escapeValidatePathSegments(&user, &repo, &team); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/teams/%s", user, repo, team), nil, nil) + return resp, err +} + +// CheckRepoTeam check if team is assigned to repo by name and return it. +// If not assigned, it will return nil. +func (c *Client) CheckRepoTeam(user, repo, team string) (*Team, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + if err := escapeValidatePathSegments(&user, &repo, &team); err != nil { + return nil, nil, err + } + t := new(Team) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/teams/%s", user, repo, team), nil, nil, &t) + if resp != nil && resp.StatusCode == http.StatusNotFound { + // if not found it's not an error, it indicates it's not assigned + return nil, resp, nil + } + return t, resp, err +} diff --git a/forges/forgejo/sdk/repo_template.go b/forges/forgejo/sdk/repo_template.go new file mode 100644 index 0000000..270b3a0 --- /dev/null +++ b/forges/forgejo/sdk/repo_template.go @@ -0,0 +1,65 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// CreateRepoFromTemplateOption options when creating repository using a template +type CreateRepoFromTemplateOption struct { + // Owner is the organization or person who will own the new repository + Owner string `json:"owner"` + // Name of the repository to create + Name string `json:"name"` + // Description of the repository to create + Description string `json:"description"` + // Private is whether the repository is private + Private bool `json:"private"` + // GitContent include git content of default branch in template repo + GitContent bool `json:"git_content"` + // Topics include topics of template repo + Topics bool `json:"topics"` + // GitHooks include git hooks of template repo + GitHooks bool `json:"git_hooks"` + // Webhooks include webhooks of template repo + Webhooks bool `json:"webhooks"` + // Avatar include avatar of the template repo + Avatar bool `json:"avatar"` + // Labels include labels of template repo + Labels bool `json:"labels"` +} + +// Validate validates CreateRepoFromTemplateOption +func (opt CreateRepoFromTemplateOption) Validate() error { + if len(opt.Owner) == 0 { + return fmt.Errorf("field Owner is required") + } + if len(opt.Name) == 0 { + return fmt.Errorf("field Name is required") + } + return nil +} + +// CreateRepoFromTemplate create a repository using a template +func (c *Client) CreateRepoFromTemplate(templateOwner, templateRepo string, opt CreateRepoFromTemplateOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&templateOwner, &templateRepo); err != nil { + return nil, nil, err + } + + if err := opt.Validate(); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/generate", templateOwner, templateRepo), jsonHeader, bytes.NewReader(body), &repo) + return repo, resp, err +} diff --git a/forges/forgejo/sdk/repo_topics.go b/forges/forgejo/sdk/repo_topics.go new file mode 100644 index 0000000..6b0f73d --- /dev/null +++ b/forges/forgejo/sdk/repo_topics.go @@ -0,0 +1,68 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// ListRepoTopicsOptions options for listing repo's topics +type ListRepoTopicsOptions struct { + ListOptions +} + +// topicsList represents a list of repo's topics +type topicsList struct { + Topics []string `json:"topics"` +} + +// ListRepoTopics list all repository's topics +func (c *Client) ListRepoTopics(user, repo string, opt ListRepoTopicsOptions) ([]string, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, nil, err + } + opt.setDefaults() + + list := new(topicsList) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/topics?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, list) + if err != nil { + return nil, resp, err + } + return list.Topics, resp, nil +} + +// SetRepoTopics replaces the list of repo's topics +func (c *Client) SetRepoTopics(user, repo string, list []string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo); err != nil { + return nil, err + } + l := topicsList{Topics: list} + body, err := json.Marshal(&l) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/repos/%s/%s/topics", user, repo), jsonHeader, bytes.NewReader(body)) + return resp, err +} + +// AddRepoTopic adds a topic to a repo's topics list +func (c *Client) AddRepoTopic(user, repo, topic string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &topic); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/repos/%s/%s/topics/%s", user, repo, topic), nil, nil) + return resp, err +} + +// DeleteRepoTopic deletes a topic from repo's topics list +func (c *Client) DeleteRepoTopic(user, repo, topic string) (*Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &topic); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/topics/%s", user, repo, topic), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/repo_transfer.go b/forges/forgejo/sdk/repo_transfer.go new file mode 100644 index 0000000..52f0266 --- /dev/null +++ b/forges/forgejo/sdk/repo_transfer.go @@ -0,0 +1,62 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// TransferRepoOption options when transfer a repository's ownership +type TransferRepoOption struct { + // required: true + NewOwner string `json:"new_owner"` + // ID of the team or teams to add to the repository. Teams can only be added to organization-owned repositories. + TeamIDs *[]int64 `json:"team_ids"` +} + +// TransferRepo transfers the ownership of a repository +func (c *Client) TransferRepo(owner, reponame string, opt TransferRepoOption) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&owner, &reponame); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/transfer", owner, reponame), jsonHeader, bytes.NewReader(body), repo) + return repo, resp, err +} + +// AcceptRepoTransfer accepts a repo transfer. +func (c *Client) AcceptRepoTransfer(owner, reponame string) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&owner, &reponame); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/transfer/accept", owner, reponame), jsonHeader, nil, repo) + return repo, resp, err +} + +// RejectRepoTransfer rejects a repo transfer. +func (c *Client) RejectRepoTransfer(owner, reponame string) (*Repository, *Response, error) { + if err := escapeValidatePathSegments(&owner, &reponame); err != nil { + return nil, nil, err + } + if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil { + return nil, nil, err + } + repo := new(Repository) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/transfer/reject", owner, reponame), jsonHeader, nil, repo) + return repo, resp, err +} diff --git a/forges/forgejo/sdk/repo_tree.go b/forges/forgejo/sdk/repo_tree.go new file mode 100644 index 0000000..6a7923f --- /dev/null +++ b/forges/forgejo/sdk/repo_tree.go @@ -0,0 +1,44 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" +) + +// GitEntry represents a git tree +type GitEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + Size int64 `json:"size"` + SHA string `json:"sha"` + URL string `json:"url"` +} + +// GitTreeResponse returns a git tree +type GitTreeResponse struct { + SHA string `json:"sha"` + URL string `json:"url"` + Entries []GitEntry `json:"tree"` + Truncated bool `json:"truncated"` + Page int `json:"page"` + TotalCount int `json:"total_count"` +} + +// GetTrees downloads a file of repository, ref can be branch/tag/commit. +// e.g.: ref -> master, tree -> macaron.go(no leading slash) +func (c *Client) GetTrees(user, repo, ref string, recursive bool) (*GitTreeResponse, *Response, error) { + if err := escapeValidatePathSegments(&user, &repo, &ref); err != nil { + return nil, nil, err + } + trees := new(GitTreeResponse) + path := fmt.Sprintf("/repos/%s/%s/git/trees/%s", user, repo, ref) + if recursive { + path += "?recursive=1" + } + resp, err := c.getParsedResponse("GET", path, nil, nil, trees) + return trees, resp, err +} diff --git a/forges/forgejo/sdk/repo_watch.go b/forges/forgejo/sdk/repo_watch.go new file mode 100644 index 0000000..c9befe4 --- /dev/null +++ b/forges/forgejo/sdk/repo_watch.go @@ -0,0 +1,87 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/http" + "time" +) + +// WatchInfo represents an API watch status of one repository +type WatchInfo struct { + Subscribed bool `json:"subscribed"` + Ignored bool `json:"ignored"` + Reason interface{} `json:"reason"` + CreatedAt time.Time `json:"created_at"` + URL string `json:"url"` + RepositoryURL string `json:"repository_url"` +} + +// GetWatchedRepos list all the watched repos of user +func (c *Client) GetWatchedRepos(user string) ([]*Repository, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + repos := make([]*Repository, 0, 10) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/subscriptions", user), nil, nil, &repos) + return repos, resp, err +} + +// GetMyWatchedRepos list repositories watched by the authenticated user +func (c *Client) GetMyWatchedRepos() ([]*Repository, *Response, error) { + repos := make([]*Repository, 0, 10) + resp, err := c.getParsedResponse("GET", "/user/subscriptions", nil, nil, &repos) + return repos, resp, err +} + +// CheckRepoWatch check if the current user is watching a repo +func (c *Client) CheckRepoWatch(owner, repo string) (bool, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return false, nil, err + } + status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/repos/%s/%s/subscription", owner, repo), nil, nil) + if err != nil { + return false, resp, err + } + switch status { + case http.StatusNotFound: + return false, resp, nil + case http.StatusOK: + return true, resp, nil + default: + return false, resp, fmt.Errorf("unexpected Status: %d", status) + } +} + +// WatchRepo start to watch a repository +func (c *Client) WatchRepo(owner, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + status, resp, err := c.getStatusCode("PUT", fmt.Sprintf("/repos/%s/%s/subscription", owner, repo), nil, nil) + if err != nil { + return resp, err + } + if status == http.StatusOK { + return resp, nil + } + return resp, fmt.Errorf("unexpected Status: %d", status) +} + +// UnWatchRepo stop to watch a repository +func (c *Client) UnWatchRepo(owner, repo string) (*Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, err + } + status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/subscription", owner, repo), nil, nil) + if err != nil { + return resp, err + } + if status == http.StatusNoContent { + return resp, nil + } + return resp, fmt.Errorf("unexpected Status: %d", status) +} diff --git a/forges/forgejo/sdk/secret.go b/forges/forgejo/sdk/secret.go new file mode 100644 index 0000000..df4d682 --- /dev/null +++ b/forges/forgejo/sdk/secret.go @@ -0,0 +1,14 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import "time" + +type Secret struct { + // the secret's name + Name string `json:"name"` + // Date and Time of secret creation + Created time.Time `json:"created_at"` +} diff --git a/forges/forgejo/sdk/settings.go b/forges/forgejo/sdk/settings.go new file mode 100644 index 0000000..b547703 --- /dev/null +++ b/forges/forgejo/sdk/settings.go @@ -0,0 +1,78 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +// GlobalUISettings represent the global ui settings of a gitea instance witch is exposed by API +type GlobalUISettings struct { + DefaultTheme string `json:"default_theme"` + AllowedReactions []string `json:"allowed_reactions"` + CustomEmojis []string `json:"custom_emojis"` +} + +// GlobalRepoSettings represent the global repository settings of a gitea instance witch is exposed by API +type GlobalRepoSettings struct { + MirrorsDisabled bool `json:"mirrors_disabled"` + HTTPGitDisabled bool `json:"http_git_disabled"` + MigrationsDisabled bool `json:"migrations_disabled"` + StarsDisabled bool `json:"stars_disabled"` + TimeTrackingDisabled bool `json:"time_tracking_disabled"` + LFSDisabled bool `json:"lfs_disabled"` +} + +// GlobalAPISettings contains global api settings exposed by it +type GlobalAPISettings struct { + MaxResponseItems int `json:"max_response_items"` + DefaultPagingNum int `json:"default_paging_num"` + DefaultGitTreesPerPage int `json:"default_git_trees_per_page"` + DefaultMaxBlobSize int64 `json:"default_max_blob_size"` +} + +// GlobalAttachmentSettings contains global Attachment settings exposed by API +type GlobalAttachmentSettings struct { + Enabled bool `json:"enabled"` + AllowedTypes string `json:"allowed_types"` + MaxSize int64 `json:"max_size"` + MaxFiles int `json:"max_files"` +} + +// GetGlobalUISettings get global ui settings witch are exposed by API +func (c *Client) GetGlobalUISettings() (*GlobalUISettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + conf := new(GlobalUISettings) + resp, err := c.getParsedResponse("GET", "/settings/ui", jsonHeader, nil, &conf) + return conf, resp, err +} + +// GetGlobalRepoSettings get global repository settings witch are exposed by API +func (c *Client) GetGlobalRepoSettings() (*GlobalRepoSettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + conf := new(GlobalRepoSettings) + resp, err := c.getParsedResponse("GET", "/settings/repository", jsonHeader, nil, &conf) + return conf, resp, err +} + +// GetGlobalAPISettings get global api settings witch are exposed by it +func (c *Client) GetGlobalAPISettings() (*GlobalAPISettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + conf := new(GlobalAPISettings) + resp, err := c.getParsedResponse("GET", "/settings/api", jsonHeader, nil, &conf) + return conf, resp, err +} + +// GetGlobalAttachmentSettings get global repository settings witch are exposed by API +func (c *Client) GetGlobalAttachmentSettings() (*GlobalAttachmentSettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, nil, err + } + conf := new(GlobalAttachmentSettings) + resp, err := c.getParsedResponse("GET", "/settings/attachment", jsonHeader, nil, &conf) + return conf, resp, err +} diff --git a/forges/forgejo/sdk/status.go b/forges/forgejo/sdk/status.go new file mode 100644 index 0000000..a0e1cd6 --- /dev/null +++ b/forges/forgejo/sdk/status.go @@ -0,0 +1,108 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// StatusState holds the state of a Status +// It can be "pending", "success", "error", "failure", and "warning" +type StatusState string + +const ( + // StatusPending is for when the Status is Pending + StatusPending StatusState = "pending" + // StatusSuccess is for when the Status is Success + StatusSuccess StatusState = "success" + // StatusError is for when the Status is Error + StatusError StatusState = "error" + // StatusFailure is for when the Status is Failure + StatusFailure StatusState = "failure" + // StatusWarning is for when the Status is Warning + StatusWarning StatusState = "warning" +) + +// Status holds a single Status of a single Commit +type Status struct { + ID int64 `json:"id"` + State StatusState `json:"status"` + TargetURL string `json:"target_url"` + Description string `json:"description"` + URL string `json:"url"` + Context string `json:"context"` + Creator *User `json:"creator"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` +} + +// CreateStatusOption holds the information needed to create a new Status for a Commit +type CreateStatusOption struct { + State StatusState `json:"state"` + TargetURL string `json:"target_url"` + Description string `json:"description"` + Context string `json:"context"` +} + +// CreateStatus creates a new Status for a given Commit +func (c *Client) CreateStatus(owner, repo, sha string, opts CreateStatusOption) (*Status, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opts) + if err != nil { + return nil, nil, err + } + status := new(Status) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/statuses/%s", owner, repo, url.QueryEscape(sha)), jsonHeader, bytes.NewReader(body), status) + return status, resp, err +} + +// ListStatusesOption options for listing a repository's commit's statuses +type ListStatusesOption struct { + ListOptions +} + +// ListStatuses returns all statuses for a given Commit by ref +func (c *Client) ListStatuses(owner, repo, ref string, opt ListStatusesOption) ([]*Status, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &ref); err != nil { + return nil, nil, err + } + opt.setDefaults() + statuses := make([]*Status, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/commits/%s/statuses?%s", owner, repo, ref, opt.getURLQuery().Encode()), jsonHeader, nil, &statuses) + return statuses, resp, err +} + +// CombinedStatus holds the combined state of several statuses for a single commit +type CombinedStatus struct { + State StatusState `json:"state"` + SHA string `json:"sha"` + TotalCount int `json:"total_count"` + Statuses []*Status `json:"statuses"` + Repository *Repository `json:"repository"` + CommitURL string `json:"commit_url"` + URL string `json:"url"` +} + +// GetCombinedStatus returns the CombinedStatus for a given Commit +func (c *Client) GetCombinedStatus(owner, repo, ref string) (*CombinedStatus, *Response, error) { + if err := escapeValidatePathSegments(&owner, &repo, &ref); err != nil { + return nil, nil, err + } + status := new(CombinedStatus) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/commits/%s/status", owner, repo, ref), jsonHeader, nil, status) + + // gitea api return empty body if nothing here jet + if resp != nil && resp.StatusCode == 200 && err != nil { + return status, resp, nil + } + + return status, resp, err +} diff --git a/forges/forgejo/sdk/user.go b/forges/forgejo/sdk/user.go new file mode 100644 index 0000000..a5037e6 --- /dev/null +++ b/forges/forgejo/sdk/user.go @@ -0,0 +1,85 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" + "strconv" + "time" +) + +// User represents a user +type User struct { + // the user's id + ID int64 `json:"id"` + // the user's username + UserName string `json:"login"` + // the user's full name + FullName string `json:"full_name"` + Email string `json:"email"` + // URL to the user's avatar + AvatarURL string `json:"avatar_url"` + // User locale + Language string `json:"language"` + // Is the user an administrator + IsAdmin bool `json:"is_admin"` + // Date and Time of last login + LastLogin time.Time `json:"last_login"` + // Date and Time of user creation + Created time.Time `json:"created"` + // Is user restricted + Restricted bool `json:"restricted"` + // Is user active + IsActive bool `json:"active"` + // Is user login prohibited + ProhibitLogin bool `json:"prohibit_login"` + // the user's location + Location string `json:"location"` + // the user's website + Website string `json:"website"` + // the user's description + Description string `json:"description"` + // User visibility level option + Visibility VisibleType `json:"visibility"` + + // user counts + FollowerCount int `json:"followers_count"` + FollowingCount int `json:"following_count"` + StarredRepoCount int `json:"starred_repos_count"` +} + +// GetUserInfo get user info by user's name +func (c *Client) GetUserInfo(user string) (*User, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + u := new(User) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s", user), nil, nil, u) + return u, resp, err +} + +// GetMyUserInfo get user info of current user +func (c *Client) GetMyUserInfo() (*User, *Response, error) { + u := new(User) + resp, err := c.getParsedResponse("GET", "/user", nil, nil, u) + return u, resp, err +} + +// GetUserByID returns user by a given user ID +func (c *Client) GetUserByID(id int64) (*User, *Response, error) { + query := make(url.Values) + query.Add("uid", strconv.FormatInt(id, 10)) + users, resp, err := c.searchUsers(query.Encode()) + if err != nil { + return nil, resp, err + } + + if len(users) == 1 { + return users[0], resp, err + } + + return nil, resp, fmt.Errorf("user not found with id %d", id) +} diff --git a/forges/forgejo/sdk/user_app.go b/forges/forgejo/sdk/user_app.go new file mode 100644 index 0000000..92dd452 --- /dev/null +++ b/forges/forgejo/sdk/user_app.go @@ -0,0 +1,143 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "reflect" +) + +// AccessTokenScope represents the scope for an access token. +type AccessTokenScope string + +const ( + AccessTokenScopeAll AccessTokenScope = "all" + + AccessTokenScopeRepo AccessTokenScope = "repo" + AccessTokenScopeRepoStatus AccessTokenScope = "repo:status" + AccessTokenScopePublicRepo AccessTokenScope = "public_repo" + + AccessTokenScopeAdminOrg AccessTokenScope = "admin:org" + AccessTokenScopeWriteOrg AccessTokenScope = "write:org" + AccessTokenScopeReadOrg AccessTokenScope = "read:org" + + AccessTokenScopeAdminPublicKey AccessTokenScope = "admin:public_key" + AccessTokenScopeWritePublicKey AccessTokenScope = "write:public_key" + AccessTokenScopeReadPublicKey AccessTokenScope = "read:public_key" + + AccessTokenScopeAdminRepoHook AccessTokenScope = "admin:repo_hook" + AccessTokenScopeWriteRepoHook AccessTokenScope = "write:repo_hook" + AccessTokenScopeReadRepoHook AccessTokenScope = "read:repo_hook" + + AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook" + + AccessTokenScopeAdminUserHook AccessTokenScope = "admin:user_hook" + + AccessTokenScopeNotification AccessTokenScope = "notification" + + AccessTokenScopeUser AccessTokenScope = "user" + AccessTokenScopeReadUser AccessTokenScope = "read:user" + AccessTokenScopeUserEmail AccessTokenScope = "user:email" + AccessTokenScopeUserFollow AccessTokenScope = "user:follow" + + AccessTokenScopeDeleteRepo AccessTokenScope = "delete_repo" + + AccessTokenScopePackage AccessTokenScope = "package" + AccessTokenScopeWritePackage AccessTokenScope = "write:package" + AccessTokenScopeReadPackage AccessTokenScope = "read:package" + AccessTokenScopeDeletePackage AccessTokenScope = "delete:package" + + AccessTokenScopeAdminGPGKey AccessTokenScope = "admin:gpg_key" + AccessTokenScopeWriteGPGKey AccessTokenScope = "write:gpg_key" + AccessTokenScopeReadGPGKey AccessTokenScope = "read:gpg_key" + + AccessTokenScopeAdminApplication AccessTokenScope = "admin:application" + AccessTokenScopeWriteApplication AccessTokenScope = "write:application" + AccessTokenScopeReadApplication AccessTokenScope = "read:application" + + AccessTokenScopeSudo AccessTokenScope = "sudo" +) + +// AccessToken represents an API access token. +type AccessToken struct { + ID int64 `json:"id"` + Name string `json:"name"` + Token string `json:"sha1"` + TokenLastEight string `json:"token_last_eight"` + Scopes []AccessTokenScope `json:"scopes"` +} + +// ListAccessTokensOptions options for listing a users's access tokens +type ListAccessTokensOptions struct { + ListOptions +} + +// ListAccessTokens lists all the access tokens of user +func (c *Client) ListAccessTokens(opts ListAccessTokensOptions) ([]*AccessToken, *Response, error) { + c.mutex.RLock() + username := c.username + c.mutex.RUnlock() + if len(username) == 0 { + return nil, nil, fmt.Errorf("\"username\" not set: only BasicAuth allowed") + } + opts.setDefaults() + tokens := make([]*AccessToken, 0, opts.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/tokens?%s", url.PathEscape(username), opts.getURLQuery().Encode()), jsonHeader, nil, &tokens) + return tokens, resp, err +} + +// CreateAccessTokenOption options when create access token +type CreateAccessTokenOption struct { + Name string `json:"name"` + Scopes []AccessTokenScope `json:"scopes"` +} + +// CreateAccessToken create one access token with options +func (c *Client) CreateAccessToken(opt CreateAccessTokenOption) (*AccessToken, *Response, error) { + c.mutex.RLock() + username := c.username + c.mutex.RUnlock() + if len(username) == 0 { + return nil, nil, fmt.Errorf("\"username\" not set: only BasicAuth allowed") + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + t := new(AccessToken) + resp, err := c.getParsedResponse("POST", fmt.Sprintf("/users/%s/tokens", url.PathEscape(username)), jsonHeader, bytes.NewReader(body), t) + return t, resp, err +} + +// DeleteAccessToken delete token, identified by ID and if not available by name +func (c *Client) DeleteAccessToken(value interface{}) (*Response, error) { + c.mutex.RLock() + username := c.username + c.mutex.RUnlock() + if len(username) == 0 { + return nil, fmt.Errorf("\"username\" not set: only BasicAuth allowed") + } + + token := "" + + switch reflect.ValueOf(value).Kind() { + case reflect.Int64: + token = fmt.Sprintf("%d", value.(int64)) + case reflect.String: + if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil { + return nil, err + } + token = value.(string) + default: + return nil, fmt.Errorf("only string and int64 supported") + } + + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/users/%s/tokens/%s", url.PathEscape(username), url.PathEscape(token)), jsonHeader, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/user_email.go b/forges/forgejo/sdk/user_email.go new file mode 100644 index 0000000..120642f --- /dev/null +++ b/forges/forgejo/sdk/user_email.go @@ -0,0 +1,64 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// Email an email address belonging to a user +type Email struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` +} + +// ListEmailsOptions options for listing current's user emails +type ListEmailsOptions struct { + ListOptions +} + +// ListEmails all the email addresses of user +func (c *Client) ListEmails(opt ListEmailsOptions) ([]*Email, *Response, error) { + opt.setDefaults() + emails := make([]*Email, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/emails?%s", opt.getURLQuery().Encode()), nil, nil, &emails) + return emails, resp, err +} + +// CreateEmailOption options when creating email addresses +type CreateEmailOption struct { + // email addresses to add + Emails []string `json:"emails"` +} + +// AddEmail add one email to current user with options +func (c *Client) AddEmail(opt CreateEmailOption) ([]*Email, *Response, error) { + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + emails := make([]*Email, 0, 3) + resp, err := c.getParsedResponse("POST", "/user/emails", jsonHeader, bytes.NewReader(body), &emails) + return emails, resp, err +} + +// DeleteEmailOption options when deleting email addresses +type DeleteEmailOption struct { + // email addresses to delete + Emails []string `json:"emails"` +} + +// DeleteEmail delete one email of current users' +func (c *Client) DeleteEmail(opt DeleteEmailOption) (*Response, error) { + body, err := json.Marshal(&opt) + if err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", "/user/emails", jsonHeader, bytes.NewReader(body)) + return resp, err +} diff --git a/forges/forgejo/sdk/user_follow.go b/forges/forgejo/sdk/user_follow.go new file mode 100644 index 0000000..c4235ec --- /dev/null +++ b/forges/forgejo/sdk/user_follow.go @@ -0,0 +1,93 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import "fmt" + +// ListFollowersOptions options for listing followers +type ListFollowersOptions struct { + ListOptions +} + +// ListMyFollowers list all the followers of current user +func (c *Client) ListMyFollowers(opt ListFollowersOptions) ([]*User, *Response, error) { + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/followers?%s", opt.getURLQuery().Encode()), nil, nil, &users) + return users, resp, err +} + +// ListFollowers list all the followers of one user +func (c *Client) ListFollowers(user string, opt ListFollowersOptions) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/followers?%s", user, opt.getURLQuery().Encode()), nil, nil, &users) + return users, resp, err +} + +// ListFollowingOptions options for listing a user's users being followed +type ListFollowingOptions struct { + ListOptions +} + +// ListMyFollowing list all the users current user followed +func (c *Client) ListMyFollowing(opt ListFollowingOptions) ([]*User, *Response, error) { + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/following?%s", opt.getURLQuery().Encode()), nil, nil, &users) + return users, resp, err +} + +// ListFollowing list all the users the user followed +func (c *Client) ListFollowing(user string, opt ListFollowingOptions) ([]*User, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + users := make([]*User, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/following?%s", user, opt.getURLQuery().Encode()), nil, nil, &users) + return users, resp, err +} + +// IsFollowing if current user followed the target +func (c *Client) IsFollowing(target string) (bool, *Response) { + if err := escapeValidatePathSegments(&target); err != nil { + // ToDo return err + return false, nil + } + _, resp, err := c.getResponse("GET", fmt.Sprintf("/user/following/%s", target), nil, nil) + return err == nil, resp +} + +// IsUserFollowing if the user followed the target +func (c *Client) IsUserFollowing(user, target string) (bool, *Response) { + if err := escapeValidatePathSegments(&user, &target); err != nil { + // ToDo return err + return false, nil + } + _, resp, err := c.getResponse("GET", fmt.Sprintf("/users/%s/following/%s", user, target), nil, nil) + return err == nil, resp +} + +// Follow set current user follow the target +func (c *Client) Follow(target string) (*Response, error) { + if err := escapeValidatePathSegments(&target); err != nil { + return nil, err + } + _, resp, err := c.getResponse("PUT", fmt.Sprintf("/user/following/%s", target), nil, nil) + return resp, err +} + +// Unfollow set current user unfollow the target +func (c *Client) Unfollow(target string) (*Response, error) { + if err := escapeValidatePathSegments(&target); err != nil { + return nil, err + } + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/user/following/%s", target), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/user_gpgkey.go b/forges/forgejo/sdk/user_gpgkey.go new file mode 100644 index 0000000..73a5fb5 --- /dev/null +++ b/forges/forgejo/sdk/user_gpgkey.go @@ -0,0 +1,89 @@ +// Copyright 2017 Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// GPGKey a user GPG key to sign commit and tag in repository +type GPGKey struct { + ID int64 `json:"id"` + PrimaryKeyID string `json:"primary_key_id"` + KeyID string `json:"key_id"` + PublicKey string `json:"public_key"` + Emails []*GPGKeyEmail `json:"emails"` + SubsKey []*GPGKey `json:"subkeys"` + CanSign bool `json:"can_sign"` + CanEncryptComms bool `json:"can_encrypt_comms"` + CanEncryptStorage bool `json:"can_encrypt_storage"` + CanCertify bool `json:"can_certify"` + Created time.Time `json:"created_at,omitempty"` + Expires time.Time `json:"expires_at,omitempty"` +} + +// GPGKeyEmail an email attached to a GPGKey +type GPGKeyEmail struct { + Email string `json:"email"` + Verified bool `json:"verified"` +} + +// ListGPGKeysOptions options for listing a user's GPGKeys +type ListGPGKeysOptions struct { + ListOptions +} + +// ListGPGKeys list all the GPG keys of the user +func (c *Client) ListGPGKeys(user string, opt ListGPGKeysOptions) ([]*GPGKey, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + keys := make([]*GPGKey, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/gpg_keys?%s", user, opt.getURLQuery().Encode()), nil, nil, &keys) + return keys, resp, err +} + +// ListMyGPGKeys list all the GPG keys of current user +func (c *Client) ListMyGPGKeys(opt *ListGPGKeysOptions) ([]*GPGKey, *Response, error) { + opt.setDefaults() + keys := make([]*GPGKey, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/gpg_keys?%s", opt.getURLQuery().Encode()), nil, nil, &keys) + return keys, resp, err +} + +// GetGPGKey get current user's GPG key by key id +func (c *Client) GetGPGKey(keyID int64) (*GPGKey, *Response, error) { + key := new(GPGKey) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/gpg_keys/%d", keyID), nil, nil, &key) + return key, resp, err +} + +// CreateGPGKeyOption options create user GPG key +type CreateGPGKeyOption struct { + // An armored GPG key to add + // + ArmoredKey string `json:"armored_public_key"` +} + +// CreateGPGKey create GPG key with options +func (c *Client) CreateGPGKey(opt CreateGPGKeyOption) (*GPGKey, *Response, error) { + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + key := new(GPGKey) + resp, err := c.getParsedResponse("POST", "/user/gpg_keys", jsonHeader, bytes.NewReader(body), key) + return key, resp, err +} + +// DeleteGPGKey delete GPG key with key id +func (c *Client) DeleteGPGKey(keyID int64) (*Response, error) { + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/user/gpg_keys/%d", keyID), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/user_key.go b/forges/forgejo/sdk/user_key.go new file mode 100644 index 0000000..f933a48 --- /dev/null +++ b/forges/forgejo/sdk/user_key.go @@ -0,0 +1,83 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// PublicKey publickey is a user key to push code to repository +type PublicKey struct { + ID int64 `json:"id"` + Key string `json:"key"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Created time.Time `json:"created_at,omitempty"` + Owner *User `json:"user,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + KeyType string `json:"key_type,omitempty"` +} + +// ListPublicKeysOptions options for listing a user's PublicKeys +type ListPublicKeysOptions struct { + ListOptions +} + +// ListPublicKeys list all the public keys of the user +func (c *Client) ListPublicKeys(user string, opt ListPublicKeysOptions) ([]*PublicKey, *Response, error) { + if err := escapeValidatePathSegments(&user); err != nil { + return nil, nil, err + } + opt.setDefaults() + keys := make([]*PublicKey, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/keys?%s", user, opt.getURLQuery().Encode()), nil, nil, &keys) + return keys, resp, err +} + +// ListMyPublicKeys list all the public keys of current user +func (c *Client) ListMyPublicKeys(opt ListPublicKeysOptions) ([]*PublicKey, *Response, error) { + opt.setDefaults() + keys := make([]*PublicKey, 0, opt.PageSize) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/keys?%s", opt.getURLQuery().Encode()), nil, nil, &keys) + return keys, resp, err +} + +// GetPublicKey get current user's public key by key id +func (c *Client) GetPublicKey(keyID int64) (*PublicKey, *Response, error) { + key := new(PublicKey) + resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/keys/%d", keyID), nil, nil, &key) + return key, resp, err +} + +// CreateKeyOption options when creating a key +type CreateKeyOption struct { + // Title of the key to add + Title string `json:"title"` + // An armored SSH key to add + Key string `json:"key"` + // Describe if the key has only read access or read/write + ReadOnly bool `json:"read_only"` +} + +// CreatePublicKey create public key with options +func (c *Client) CreatePublicKey(opt CreateKeyOption) (*PublicKey, *Response, error) { + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + key := new(PublicKey) + resp, err := c.getParsedResponse("POST", "/user/keys", jsonHeader, bytes.NewReader(body), key) + return key, resp, err +} + +// DeletePublicKey delete public key with key id +func (c *Client) DeletePublicKey(keyID int64) (*Response, error) { + _, resp, err := c.getResponse("DELETE", fmt.Sprintf("/user/keys/%d", keyID), nil, nil) + return resp, err +} diff --git a/forges/forgejo/sdk/user_search.go b/forges/forgejo/sdk/user_search.go new file mode 100644 index 0000000..0c77bf8 --- /dev/null +++ b/forges/forgejo/sdk/user_search.go @@ -0,0 +1,48 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "net/url" +) + +type searchUsersResponse struct { + Users []*User `json:"data"` +} + +// SearchUsersOption options for SearchUsers +type SearchUsersOption struct { + ListOptions + KeyWord string +} + +// QueryEncode turns options into querystring argument +func (opt *SearchUsersOption) QueryEncode() string { + query := make(url.Values) + if opt.Page > 0 { + query.Add("page", fmt.Sprintf("%d", opt.Page)) + } + if opt.PageSize > 0 { + query.Add("limit", fmt.Sprintf("%d", opt.PageSize)) + } + if len(opt.KeyWord) > 0 { + query.Add("q", opt.KeyWord) + } + return query.Encode() +} + +func (c *Client) searchUsers(rawQuery string) ([]*User, *Response, error) { + link, _ := url.Parse("/users/search") + link.RawQuery = rawQuery + userResp := new(searchUsersResponse) + resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &userResp) + return userResp.Users, resp, err +} + +// SearchUsers finds users by query +func (c *Client) SearchUsers(opt SearchUsersOption) ([]*User, *Response, error) { + return c.searchUsers(opt.QueryEncode()) +} diff --git a/forges/forgejo/sdk/user_settings.go b/forges/forgejo/sdk/user_settings.go new file mode 100644 index 0000000..3ae1748 --- /dev/null +++ b/forges/forgejo/sdk/user_settings.go @@ -0,0 +1,62 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "bytes" + "encoding/json" +) + +// UserSettings represents user settings +type UserSettings struct { + FullName string `json:"full_name"` + Website string `json:"website"` + Description string `json:"description"` + Location string `json:"location"` + Language string `json:"language"` + Theme string `json:"theme"` + DiffViewStyle string `json:"diff_view_style"` + // Privacy + HideEmail bool `json:"hide_email"` + HideActivity bool `json:"hide_activity"` +} + +// UserSettingsOptions represents options to change user settings +type UserSettingsOptions struct { + FullName *string `json:"full_name,omitempty"` + Website *string `json:"website,omitempty"` + Description *string `json:"description,omitempty"` + Location *string `json:"location,omitempty"` + Language *string `json:"language,omitempty"` + Theme *string `json:"theme,omitempty"` + DiffViewStyle *string `json:"diff_view_style,omitempty"` + // Privacy + HideEmail *bool `json:"hide_email,omitempty"` + HideActivity *bool `json:"hide_activity,omitempty"` +} + +// GetUserSettings returns user settings +func (c *Client) GetUserSettings() (*UserSettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + userConfig := new(UserSettings) + resp, err := c.getParsedResponse("GET", "/user/settings", nil, nil, userConfig) + return userConfig, resp, err +} + +// UpdateUserSettings returns user settings +func (c *Client) UpdateUserSettings(opt UserSettingsOptions) (*UserSettings, *Response, error) { + if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil { + return nil, nil, err + } + body, err := json.Marshal(&opt) + if err != nil { + return nil, nil, err + } + userConfig := new(UserSettings) + resp, err := c.getParsedResponse("PATCH", "/user/settings", jsonHeader, bytes.NewReader(body), userConfig) + return userConfig, resp, err +} diff --git a/forges/forgejo/sdk/version.go b/forges/forgejo/sdk/version.go new file mode 100644 index 0000000..0fc6e04 --- /dev/null +++ b/forges/forgejo/sdk/version.go @@ -0,0 +1,126 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sdk + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-version" +) + +// ServerVersion returns the version of the server +func (c *Client) ServerVersion() (string, *Response, error) { + v := struct { + Version string `json:"version"` + }{} + resp, err := c.getParsedResponse("GET", "/version", nil, nil, &v) + return v.Version, resp, err +} + +// CheckServerVersionConstraint validates that the login's server satisfies a +// given version constraint such as ">= 1.11.0+dev" +func (c *Client) CheckServerVersionConstraint(constraint string) error { + if err := c.loadServerVersion(); err != nil { + return err + } + + check, err := version.NewConstraint(constraint) + if err != nil { + return err + } + if !check.Check(c.serverVersion) { + c.mutex.RLock() + url := c.url + c.mutex.RUnlock() + return fmt.Errorf("gitea server at %s does not satisfy version constraint %s", url, constraint) + } + return nil +} + +// SetGiteaVersion configures the Client to assume the given version of the +// Gitea server, instead of querying the server for it when initializing. +// Use "" to skip all canonical ways in the SDK to check for versions +func SetGiteaVersion(v string) ClientOption { + if v == "" { + return func(c *Client) error { + c.ignoreVersion = true + return nil + } + } + return func(c *Client) (err error) { + c.getVersionOnce.Do(func() { + c.serverVersion, err = version.NewVersion(v) + }) + return + } +} + +// predefined versions only have to be parsed by library once +var ( + version1_11_0 = version.Must(version.NewVersion("1.11.0")) + version1_11_5 = version.Must(version.NewVersion("1.11.5")) + version1_12_0 = version.Must(version.NewVersion("1.12.0")) + version1_12_3 = version.Must(version.NewVersion("1.12.3")) + version1_13_0 = version.Must(version.NewVersion("1.13.0")) + version1_14_0 = version.Must(version.NewVersion("1.14.0")) + version1_15_0 = version.Must(version.NewVersion("1.15.0")) + version1_16_0 = version.Must(version.NewVersion("1.16.0")) + version1_17_0 = version.Must(version.NewVersion("1.17.0")) +) + +// ErrUnknownVersion is an unknown version from the API +type ErrUnknownVersion struct { + raw string +} + +// Error fulfills error +func (e ErrUnknownVersion) Error() string { + return fmt.Sprintf("unknown version: %s", e.raw) +} + +func (_ ErrUnknownVersion) Is(target error) bool { + _, ok1 := target.(*ErrUnknownVersion) + _, ok2 := target.(ErrUnknownVersion) + return ok1 || ok2 +} + +// checkServerVersionGreaterThanOrEqual is the canonical way in the SDK to check for versions for API compatibility reasons +func (c *Client) checkServerVersionGreaterThanOrEqual(v *version.Version) error { + if c.ignoreVersion { + return nil + } + if err := c.loadServerVersion(); err != nil { + return err + } + + if !c.serverVersion.GreaterThanOrEqual(v) { + c.mutex.RLock() + url := c.url + c.mutex.RUnlock() + return fmt.Errorf("gitea server at %s is older than %s", url, v.Original()) + } + return nil +} + +// loadServerVersion init the serverVersion variable +func (c *Client) loadServerVersion() (err error) { + c.getVersionOnce.Do(func() { + raw, _, err2 := c.ServerVersion() + if err2 != nil { + err = err2 + return + } + if c.serverVersion, err = version.NewVersion(raw); err != nil { + if strings.TrimSpace(raw) != "" { + // Version was something, just not recognized + c.serverVersion = version1_11_0 + err = &ErrUnknownVersion{raw: raw} + } + return + } + }) + return +} diff --git a/forges/forgejo/tests/init.go b/forges/forgejo/tests/init.go new file mode 100644 index 0000000..9ffa4e3 --- /dev/null +++ b/forges/forgejo/tests/init.go @@ -0,0 +1,19 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +func init() { + tests_forge.RegisterFactory(forgejo_options.Name, func() tests_forge.Interface { + return newForgeTest(forgejo_options.Name) + }) + tests_forge.RegisterFactory(forgejo_options.NameAliasGitea, func() tests_forge.Interface { + return newForgeTest(forgejo_options.NameAliasGitea) + }) +} diff --git a/forges/forgejo/tests/new.go b/forges/forgejo/tests/new.go new file mode 100644 index 0000000..b3143e1 --- /dev/null +++ b/forges/forgejo/tests/new.go @@ -0,0 +1,58 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "testing" + + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/options" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +type forgeTest struct { + tests_forge.Base +} + +func (o *forgeTest) NewOptions(t *testing.T) options.Interface { + return newTestOptions(t, o.Base.GetName()) +} + +func (o *forgeTest) GetNameExceptions() []string { + if o.GetName() == forgejo_options.NameAliasGitea { + return []string{tests_forge.ComplianceNameForkedPullRequest} + } + return nil +} + +func (o *forgeTest) GetKindExceptions() []kind.Kind { + exceptions := []kind.Kind{ + f3_tree.KindTopics, + } + if o.GetName() == forgejo_options.NameAliasGitea { + exceptions = append( + exceptions, + f3_tree.KindReviewComments, + f3_tree.KindIssues, + f3_tree.KindComments, + f3_tree.KindReactions, + ) + } + return exceptions +} + +func (o *forgeTest) GetNonTestUsers() []string { + return []string{ + GetFixtureUsername(), + } +} + +func newForgeTest(name string) tests_forge.Interface { + t := &forgeTest{} + t.SetName(name) + return t +} diff --git a/forges/forgejo/tests/testhelpers.go b/forges/forgejo/tests/testhelpers.go new file mode 100644 index 0000000..fd554a4 --- /dev/null +++ b/forges/forgejo/tests/testhelpers.go @@ -0,0 +1,60 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "os" + "strings" + "testing" + + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/forges/helpers/auth" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" +) + +func GetFixtureURL(name string) string { + upperName := strings.ToUpper(name) + hostPort := os.Getenv("GOF3_" + upperName + "_HOST_PORT") + if hostPort == "" { + return "" + } + return "http://" + hostPort +} + +func GetFixtureUsername() string { + user := os.Getenv("FORGEJO_TEST_USER") + if user == "" { + user = "root" + } + return user +} + +func GetFixturePassword() string { + password := os.Getenv("FORGEJO_TEST_PASSWORD") + if password == "" { + password = "admin1234" + } + return password +} + +func newTestOptions(t *testing.T, name string) options.Interface { + t.Helper() + url := GetFixtureURL(name) + if url == "" { + t.Skip("test server is not up") + } + forgeAuth := auth.NewForgeAuth() + forgeAuth.SetURL(url) + forgeAuth.SetUsername(GetFixtureUsername()) + forgeAuth.SetPassword(GetFixturePassword()) + o := options.GetFactory(forgejo_options.Name)().(*forgejo_options.Options) + o.ForgeAuth = forgeAuth + l := logger.NewLogger() + l.SetLevel(logger.Trace) + o.OptionsLogger.SetLogger(l) + + return o +} diff --git a/forges/forgejo/topics.go b/forges/forgejo/topics.go new file mode 100644 index 0000000..93b39c1 --- /dev/null +++ b/forges/forgejo/topics.go @@ -0,0 +1,17 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type topics struct { + container +} + +func newTopics() generic.NodeDriverInterface { + return &topics{} +} diff --git a/forges/forgejo/tree.go b/forges/forgejo/tree.go new file mode 100644 index 0000000..e2f10a2 --- /dev/null +++ b/forges/forgejo/tree.go @@ -0,0 +1,287 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + "code.forgejo.org/f3/gof3/v3/kind" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" + "github.com/hashicorp/go-version" +) + +type treeDriver struct { + generic.NullTreeDriver + + user *forgejo_sdk.User + client *forgejo_sdk.Client + options *forgejo_options.Options + version *version.Version +} + +func (o *treeDriver) GetClient() *forgejo_sdk.Client { + return o.client +} + +func (o *treeDriver) GetIsAdmin() bool { + return o.user.IsAdmin +} + +func (o *treeDriver) SetIsAdmin() { + user, _, err := o.client.GetMyUserInfo() + if err != nil { + panic(fmt.Errorf("Failed to get information about the user for: %s. Error: %v", o.options.GetURL(), err)) + } + o.user = user + o.options.SetUsername(user.UserName) +} + +var ( + ForgejoVersion700 = version.Must(version.NewVersion("7.0.0")) // 1.22 + ForgejoVersion600 = version.Must(version.NewVersion("6.0.0")) // 1.21 + ForgejoVersion500 = version.Must(version.NewVersion("5.0.0")) // 1.20.1 + ForgejoVersion501 = version.Must(version.NewVersion("5.0.1")) // 1.20.2 + ForgejoVersion502 = version.Must(version.NewVersion("5.0.2")) // 1.20.3 + ForgejoVersion503 = version.Must(version.NewVersion("5.0.3")) // 1.20.4 + ForgejoVersion504 = version.Must(version.NewVersion("5.0.4")) // 1.20.5 + ForgejoVersion4 = version.Must(version.NewVersion("4.0.0")) // 1.19 + + ForgejoVersionNotFound = version.Must(version.NewVersion("1.0.0")) +) + +func (o *treeDriver) GetVersion() *version.Version { + o.SetVersion() + return o.version +} + +func (o *treeDriver) SetVersion() { + if o.version != nil { + return + } + client := &http.Client{} + url := fmt.Sprintf("%s/api/forgejo/v1/version", o.options.GetURL()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + panic(err) + } + resp, err := client.Do(req) + if err != nil { + panic(fmt.Errorf("while getting %s %w", url, err)) + } + + switch resp.StatusCode { + case http.StatusNotFound: + o.version = ForgejoVersionNotFound + case http.StatusOK: + v := struct{ Version string }{} + body, err := io.ReadAll(resp.Body) + if err != nil { + panic(fmt.Errorf("reading response body %+v %w", resp, err)) + } + if err := json.Unmarshal(body, &v); err != nil { + panic(fmt.Errorf("decoding JSON response from %s %s %w", url, string(body), err)) + } + o.version = version.Must(version.NewVersion(v.Version)) + default: + panic(fmt.Errorf("unexpected status code fetching %s %d %v", url, resp.StatusCode, resp)) + } +} + +func (o *treeDriver) SetToken() { + scopes := []forgejo_sdk.AccessTokenScope{ + forgejo_sdk.AccessTokenScopeAll, + } + var token int + var name string + existingTokens, _, err := o.client.ListAccessTokens(forgejo_sdk.ListAccessTokensOptions{}) + if err != nil { + panic(fmt.Errorf("ListAccessTokens %w", err)) + } + for { + token++ + name = fmt.Sprintf("f3-token-%d", token) + exists := false + for _, existingToken := range existingTokens { + if existingToken.Name == name { + exists = true + break + } + } + if !exists { + break + } + } + + t, _, err := o.client.CreateAccessToken(forgejo_sdk.CreateAccessTokenOption{ + Name: name, + Scopes: scopes, + }) + if err != nil { + panic(fmt.Errorf("Failed to create Forgejo token for: %s. Error: %v", o.options.GetURL(), err)) + } + + exists := false + for _, delay := range []time.Duration{1 * time.Second, 1 * time.Second, 1 * time.Second, 1 * time.Second} { + existingTokens, _, err = o.client.ListAccessTokens(forgejo_sdk.ListAccessTokensOptions{}) + if err != nil { + panic(fmt.Errorf("ListAccessTokens %w", err)) + } + for _, existingToken := range existingTokens { + if existingToken.Name == name { + exists = true + break + } + } + if exists { + break + } + o.GetLogger().Error("token does not exist yet %s:%s, waiting", o.options.GetUsername(), name) + time.Sleep(delay) + } + if !exists { + panic(fmt.Errorf("token %s cannot be verified to exist", name)) + } + + o.options.SetToken(t.Token) +} + +func (o *treeDriver) SetClient(options ...forgejo_sdk.ClientOption) { + options = append(options, forgejo_sdk.SetHTTPClient(o.options.GetNewMigrationHTTPClient()())) + c, err := forgejo_sdk.NewClient( + o.options.GetURL(), + options..., + ) + if err != nil { + panic(fmt.Errorf("Failed to create Forgejo client for: %s. Error: %v", o.options.GetURL(), err)) + } + o.client = c +} + +func (o *treeDriver) maybeSudoInternal(getUsername func() string) { + if !o.GetIsAdmin() { + return + } + username := getUsername() + if o.options.GetUsername() != username { + o.Debug(fmt.Sprintf("sudo %s", username)) + o.GetClient().SetSudo(username) + } +} + +func (o *treeDriver) MaybeSudoName(name string) { + o.maybeSudoInternal(func() string { return name }) +} + +func (o *treeDriver) maybeSudoID(ctx context.Context, id int64) { + o.maybeSudoInternal(func() string { return f3_tree.GetUsernameFromID(ctx, o.GetTree().(f3_tree.TreeInterface), id) }) +} + +func (o *treeDriver) NotSudo() { + o.GetClient().SetSudo("") +} + +func (o *treeDriver) Init() { + o.NullTreeDriver.Init() + if o.options.GetToken() != "" { + o.Debug("Connecting to %s with token", o.options.GetURL()) + o.SetClient(forgejo_sdk.SetToken(o.options.GetToken())) + } else { + o.Debug("Connecting to %s as user %s", o.options.GetURL(), o.options.GetUsername()) + o.SetClient(forgejo_sdk.SetBasicAuth(o.options.GetUsername(), o.options.GetPassword())) + } + o.SetIsAdmin() + if o.GetIsAdmin() { + o.Debug("Connected as admin") + } else { + o.Debug("Connected as regular user") + } +} + +func newTreeDriver(tree generic.TreeInterface, anyOptions any) generic.TreeDriverInterface { + driver := &treeDriver{ + options: anyOptions.(*forgejo_options.Options), + } + driver.SetTree(tree) + driver.Init() + return driver +} + +func (o *treeDriver) Factory(ctx context.Context, k kind.Kind) generic.NodeDriverInterface { + switch k { + case f3_tree.KindForge: + return newForge() + case f3_tree.KindOrganizations: + return newOrganizations() + case f3_tree.KindOrganization: + return newOrganization() + case f3_tree.KindUsers: + return newUsers() + case f3_tree.KindUser: + return newUser() + case f3_tree.KindProjects: + return newProjects() + case f3_tree.KindProject: + return newProject() + case f3_tree.KindIssues: + return newIssues() + case f3_tree.KindIssue: + return newIssue() + case f3_tree.KindComments: + return newComments() + case f3_tree.KindComment: + return newComment() + case f3_tree.KindAssets: + return newAssets() + case f3_tree.KindAsset: + return newAsset() + case f3_tree.KindLabels: + return newLabels() + case f3_tree.KindLabel: + return newLabel() + case f3_tree.KindReactions: + return newReactions() + case f3_tree.KindReaction: + return newReaction() + case f3_tree.KindReviews: + return newReviews() + case f3_tree.KindReview: + return newReview() + case f3_tree.KindReviewComments: + return newReviewComments() + case f3_tree.KindReviewComment: + return newReviewComment() + case f3_tree.KindMilestones: + return newMilestones() + case f3_tree.KindMilestone: + return newMilestone() + case f3_tree.KindPullRequests: + return newPullRequests() + case f3_tree.KindPullRequest: + return newPullRequest() + case f3_tree.KindReleases: + return newReleases() + case f3_tree.KindRelease: + return newRelease() + case f3_tree.KindTopics: + return newTopics() + case f3_tree.KindRepositories: + return newRepositories() + case f3_tree.KindRepository: + return newRepository(ctx) + case kind.KindRoot: + return newRoot(o.GetTree().(f3_tree.TreeInterface).NewFormat(k)) + default: + panic(fmt.Errorf("unexpected kind %s", k)) + } +} diff --git a/forges/forgejo/user.go b/forges/forgejo/user.go new file mode 100644 index 0000000..5f58536 --- /dev/null +++ b/forges/forgejo/user.go @@ -0,0 +1,194 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" + "github.com/hashicorp/go-version" +) + +type user struct { + common + forgejoUser *forgejo_sdk.User + Password string +} + +var _ f3_tree.ForgeDriverInterface = &user{} + +func newUser() generic.NodeDriverInterface { + return &user{} +} + +const ( + GhostUserID = int64(-1) + GhostUserName = "Ghost" + ActionsUserID = int64(-2) + ActionsUserName = "forgejo-actions" +) + +var SystemUserMinVersion = ForgejoVersion600 + +func getGhostUser() *forgejo_sdk.User { + return &forgejo_sdk.User{ + ID: GhostUserID, + UserName: GhostUserName, + FullName: "Ghost", + Email: "ghost@forgejo.org", + } +} + +func getActionsUser() *forgejo_sdk.User { + return &forgejo_sdk.User{ + ID: ActionsUserID, + UserName: ActionsUserName, + FullName: "Forgejo Actions", + Email: "noreply@forgejo.org", + } +} + +func getSystemUserByID(id int64) *forgejo_sdk.User { + switch id { + case GhostUserID: + return getGhostUser() + case ActionsUserID: + return getActionsUser() + default: + return nil + } +} + +func getSystemUserByName(name string) *forgejo_sdk.User { + switch name { + case GhostUserName: + return getGhostUser() + case ActionsUserName: + return getActionsUser() + default: + return nil + } +} + +// Hardcode system users for Forgejo versions that do not support it +func (o *user) getSystemUserByID(ctx context.Context, minVersion *version.Version, id int64) *forgejo_sdk.User { + if id < 0 { + if o.getVersion().LessThan(minVersion) { + return getSystemUserByID(id) + } + } + return nil +} + +func (o *user) SetNative(user any) { + o.forgejoUser = user.(*forgejo_sdk.User) +} + +func (o *user) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoUser.ID) +} + +func (o *user) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *user) ToFormat() f3.Interface { + if o.forgejoUser == nil { + return o.NewFormat() + } + return &f3.User{ + Common: f3.NewCommon(fmt.Sprintf("%d", o.forgejoUser.ID)), + UserName: o.forgejoUser.UserName, + Name: o.forgejoUser.FullName, + Email: o.forgejoUser.Email, + IsAdmin: o.forgejoUser.IsAdmin, + Password: o.Password, + } +} + +func (o *user) FromFormat(content f3.Interface) { + user := content.(*f3.User) + o.forgejoUser = &forgejo_sdk.User{ + ID: util.ParseInt(user.GetID()), + UserName: user.UserName, + FullName: user.Name, + Email: user.Email, + IsAdmin: user.IsAdmin, + } + o.Password = user.Password +} + +func (o *user) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + if user := o.getSystemUserByID(ctx, SystemUserMinVersion, node.GetID().Int64()); user != nil { + o.forgejoUser = user + return true + } + var user *forgejo_sdk.User + var err error + if node.GetID() != id.NilID { + user, _, err = o.getClient().GetUserByID(node.GetID().Int64()) + } else { + panic("GetID() == 0") + } + if err != nil { + if strings.Contains(err.Error(), "user not found") { + return false + } + panic(fmt.Errorf("user %v %w", o, err)) + } + o.forgejoUser = user + return true +} + +func (o *user) Patch(context.Context) { +} + +func (o *user) Put(context.Context) id.NodeID { + if user := getSystemUserByName(o.forgejoUser.UserName); user != nil { + return id.NewNodeID(user.ID) + } + + mustChangePassword := false + + if o.Password == "" { + o.Password = util.RandSeq(30) + } + + u, _, err := o.getClient().AdminCreateUser(forgejo_sdk.CreateUserOption{ + Username: o.forgejoUser.UserName, + FullName: o.forgejoUser.FullName, + Email: o.forgejoUser.Email, + Password: o.Password, + MustChangePassword: &mustChangePassword, + }) + if err != nil { + panic(fmt.Errorf("%v: %w", o.forgejoUser, err)) + } + o.forgejoUser = u + o.Trace("%s %d", o.forgejoUser.UserName, o.forgejoUser.ID) + return id.NewNodeID(u.ID) +} + +func (o *user) Delete(ctx context.Context) { + if user := getSystemUserByID(o.forgejoUser.ID); user != nil { + return + } + + _, err := o.getClient().AdminDeleteUser(o.forgejoUser.UserName) + if err != nil { + panic(fmt.Errorf("%v: %v", o.forgejoUser.UserName, err)) + } +} diff --git a/forges/forgejo/users.go b/forges/forgejo/users.go new file mode 100644 index 0000000..c6fd88f --- /dev/null +++ b/forges/forgejo/users.go @@ -0,0 +1,60 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forgejo + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + forgejo_sdk "code.forgejo.org/f3/gof3/v3/forges/forgejo/sdk" +) + +type users struct { + container +} + +func (o *users) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + var userFounds []*forgejo_sdk.User + var err error + if o.getIsAdmin() { + userFounds, _, err = o.getClient().AdminListUsers(forgejo_sdk.AdminListUsersOptions{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + } else { + userFounds, _, err = o.getClient().SearchUsers(forgejo_sdk.SearchUsersOption{ + ListOptions: forgejo_sdk.ListOptions{Page: page, PageSize: pageSize}, + }) + } + if err != nil { + panic(fmt.Errorf("error while listing users: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(userFounds...)...) +} + +func (o *users) GetIDFromName(ctx context.Context, name string) id.NodeID { + user, resp, err := o.getClient().GetUserInfo(name) + if resp.StatusCode == 404 { + return id.NilID + } + if err != nil { + if strings.Contains(err.Error(), "user not found") { + return id.NilID + } + panic(fmt.Errorf("user %v %w", o, err)) + } + return id.NewNodeID(user.ID) +} + +func newUsers() generic.NodeDriverInterface { + return &users{} +} diff --git a/forges/gitlab/common.go b/forges/gitlab/common.go new file mode 100644 index 0000000..3a03865 --- /dev/null +++ b/forges/gitlab/common.go @@ -0,0 +1,84 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + options_http "code.forgejo.org/f3/gof3/v3/options/http" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + "github.com/hashicorp/go-version" + "gitlab.com/gitlab-org/api/client-go" +) + +type common struct { + generic.NullDriver +} + +func (o *common) GetHelper() any { + panic("not implemented") +} + +func (o *common) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + return generic.NewChildrenSlice(0) +} + +func (o *common) getTree() generic.TreeInterface { + return o.GetNode().GetTree() +} + +func (o *common) getPageSize() int { + return o.getTreeDriver().GetPageSize() +} + +func (o *common) getF3Tree() f3_tree.TreeInterface { + return o.getTree().(f3_tree.TreeInterface) +} + +func (o *common) getKind() kind.Kind { + return o.GetNode().GetKind() +} + +func (o *common) getChildDriver(kind kind.Kind) generic.NodeDriverInterface { + return o.GetNode().GetChild(id.NewNodeID(kind)).GetDriver() +} + +func (o *common) isContainer() bool { + return o.getF3Tree().IsContainer(o.getKind()) +} + +func (o *common) getURL() string { + return o.getTreeDriver().options.GetURL() +} + +func (o *common) getPushURL() string { + return o.getTreeDriver().options.GetPushURL() +} + +func (o *common) getNewMigrationHTTPClient() options_http.NewMigrationHTTPClientFun { + return o.getTreeDriver().options.GetNewMigrationHTTPClient() +} + +func (o *common) getTreeDriver() *treeDriver { + return o.GetTreeDriver().(*treeDriver) +} + +func (o *common) getIsAdmin() bool { + return o.getTreeDriver().GetIsAdmin() +} + +func (o *common) getClient() *gitlab.Client { + return o.getTreeDriver().GetClient() +} + +func (o *common) getVersion() *version.Version { + return o.getTreeDriver().GetVersion() +} + +func (o *common) IsNull() bool { return false } diff --git a/forges/gitlab/container.go b/forges/gitlab/container.go new file mode 100644 index 0000000..fb44520 --- /dev/null +++ b/forges/gitlab/container.go @@ -0,0 +1,43 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" +) + +type container struct { + common +} + +func (o *container) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *container) ToFormat() f3.Interface { + return o.NewFormat() +} + +func (o *container) FromFormat(content f3.Interface) { +} + +func (o *container) Get(context.Context) bool { return true } + +func (o *container) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *container) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *container) upsert(context.Context) id.NodeID { + return id.NewNodeID(o.getKind()) +} diff --git a/forges/gitlab/forge.go b/forges/gitlab/forge.go new file mode 100644 index 0000000..590e667 --- /dev/null +++ b/forges/gitlab/forge.go @@ -0,0 +1,46 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type forge struct { + common + + ownersKind map[string]kind.Kind +} + +func newForge() generic.NodeDriverInterface { + return &forge{ + ownersKind: make(map[string]kind.Kind), + } +} + +func (o *forge) Equals(context.Context, generic.NodeInterface) bool { return true } +func (o *forge) Get(context.Context) bool { return true } +func (o *forge) Put(context.Context) id.NodeID { return id.NewNodeID("forge") } +func (o *forge) Patch(context.Context) {} +func (o *forge) Delete(context.Context) {} +func (o *forge) NewFormat() f3.Interface { return &f3.Forge{} } +func (o *forge) FromFormat(f3.Interface) {} + +func (o *forge) ToFormat() f3.Interface { + return &f3.Forge{ + Common: f3.NewCommon("forge"), + URL: o.String(), + } +} + +func (o *forge) String() string { + options := o.GetTreeDriver().(*treeDriver).options + return options.ForgeAuth.GetURL() +} diff --git a/forges/gitlab/main.go b/forges/gitlab/main.go new file mode 100644 index 0000000..9559760 --- /dev/null +++ b/forges/gitlab/main.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + gitlab_options "code.forgejo.org/f3/gof3/v3/forges/gitlab/options" + "code.forgejo.org/f3/gof3/v3/options" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" +) + +func init() { + f3_tree.RegisterForgeFactory(gitlab_options.Name, newTreeDriver) + options.RegisterFactory(gitlab_options.Name, newOptions) +} diff --git a/forges/gitlab/options.go b/forges/gitlab/options.go new file mode 100644 index 0000000..f6862a5 --- /dev/null +++ b/forges/gitlab/options.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + gitlab_options "code.forgejo.org/f3/gof3/v3/forges/gitlab/options" + "code.forgejo.org/f3/gof3/v3/options" +) + +func newOptions() options.Interface { + o := &gitlab_options.Options{} + o.SetName(gitlab_options.Name) + return o +} diff --git a/forges/gitlab/options/name.go b/forges/gitlab/options/name.go new file mode 100644 index 0000000..fb71bb8 --- /dev/null +++ b/forges/gitlab/options/name.go @@ -0,0 +1,9 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +const ( + Name = "gitlab" +) diff --git a/forges/gitlab/options/options.go b/forges/gitlab/options/options.go new file mode 100644 index 0000000..4a27f9b --- /dev/null +++ b/forges/gitlab/options/options.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/forges/helpers/auth" + "code.forgejo.org/f3/gof3/v3/options" + options_http "code.forgejo.org/f3/gof3/v3/options/http" + "code.forgejo.org/f3/gof3/v3/options/logger" + + "github.com/urfave/cli/v3" +) + +type Options struct { + options.Options + logger.OptionsLogger + auth.ForgeAuth + options_http.Implementation + + Version string +} + +func (o *Options) FromFlags(ctx context.Context, c *cli.Command, prefix string) { + o.ForgeAuth.FromFlags(ctx, c, prefix) +} + +func (o *Options) GetFlags(prefix, category string) []cli.Flag { + return o.ForgeAuth.GetFlags(prefix, category) +} diff --git a/forges/gitlab/organization.go b/forges/gitlab/organization.go new file mode 100644 index 0000000..8364b4e --- /dev/null +++ b/forges/gitlab/organization.go @@ -0,0 +1,121 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + "fmt" + "net/http" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "gitlab.com/gitlab-org/api/client-go" +) + +type organization struct { + common + forgejoOrganization *gitlab.Group +} + +var _ f3_tree.ForgeDriverInterface = &organization{} + +func newOrganization() generic.NodeDriverInterface { + return &organization{} +} + +func (o *organization) SetNative(organization any) { + o.forgejoOrganization = organization.(*gitlab.Group) +} + +func (o *organization) GetNativeID() string { + return fmt.Sprintf("%d", o.forgejoOrganization.ID) +} + +func (o *organization) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *organization) ToFormat() f3.Interface { + if o.forgejoOrganization == nil { + return o.NewFormat() + } + return &f3.Organization{ + Common: f3.NewCommon(fmt.Sprintf("%d", o.forgejoOrganization.ID)), + Name: o.forgejoOrganization.Name, + FullName: o.forgejoOrganization.Description, + } +} + +func (o *organization) FromFormat(content f3.Interface) { + organization := content.(*f3.Organization) + o.forgejoOrganization = &gitlab.Group{ + ID: int(util.ParseInt(organization.GetID())), + Name: organization.Name, + Path: organization.Name, + Description: organization.FullName, + } +} + +func (o *organization) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + organization, resp, err := o.getClient().Groups.GetGroup(node.GetID().Int(), &gitlab.GetGroupOptions{}) + if resp.StatusCode == http.StatusNotFound { + return false + } + if err != nil { + panic(fmt.Errorf("organization %v %w", o, err)) + } + o.forgejoOrganization = organization + return true +} + +func (o *organization) Patch(context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + _, _, err := o.getClient().Groups.UpdateGroup(o.forgejoOrganization.Name, &gitlab.UpdateGroupOptions{ + Description: &o.forgejoOrganization.Description, + }) + if err != nil { + panic(fmt.Errorf("UpdateGroup %v %w", o, err)) + } +} + +func (o *organization) Put(context.Context) id.NodeID { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + organization, _, err := o.getClient().Groups.CreateGroup(&gitlab.CreateGroupOptions{ + Name: &o.forgejoOrganization.Name, + Path: &o.forgejoOrganization.Name, + Description: &o.forgejoOrganization.Description, + }) + if err != nil { + panic(fmt.Errorf("CreateGroup %v %w", o, err)) + } + o.forgejoOrganization = organization + o.Trace("%s", organization) + return id.NewNodeID(o.GetNativeID()) +} + +func (o *organization) Delete(ctx context.Context) { + node := o.GetNode() + o.Trace("%s", node.GetID()) + + permanentlyRemove := true + _, err := o.getClient().Groups.DeleteGroup(o.forgejoOrganization.Name, &gitlab.DeleteGroupOptions{ + PermanentlyRemove: &permanentlyRemove, + }) + if err != nil { + panic(fmt.Errorf("DeleteGroup %v %w", o, err)) + } +} diff --git a/forges/gitlab/organizations.go b/forges/gitlab/organizations.go new file mode 100644 index 0000000..cd2ef92 --- /dev/null +++ b/forges/gitlab/organizations.go @@ -0,0 +1,56 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + "fmt" + "net/http" + + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + "gitlab.com/gitlab-org/api/client-go" +) + +type organizations struct { + container +} + +func (o *organizations) listOrganizationsPage(ctx context.Context, page int) []*gitlab.Group { + pageSize := o.getPageSize() + + var organizationFounds []*gitlab.Group + var err error + organizationFounds, _, err = o.getClient().Groups.ListGroups(&gitlab.ListGroupsOptions{ + ListOptions: gitlab.ListOptions{Page: page, PerPage: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing organizations: %v", err)) + } + + return organizationFounds +} + +func (o *organizations) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(o.listOrganizationsPage(ctx, page)...)...) +} + +func (o *organizations) GetIDFromName(ctx context.Context, name string) id.NodeID { + organization, resp, err := o.getClient().Groups.GetGroup(name, &gitlab.GetGroupOptions{}) + if resp.StatusCode == http.StatusNotFound { + return id.NilID + } + if err != nil { + panic(fmt.Errorf("organization %v %w", o, err)) + } + return id.NewNodeID(organization.ID) +} + +func newOrganizations() generic.NodeDriverInterface { + return &organizations{} +} diff --git a/forges/gitlab/projects.go b/forges/gitlab/projects.go new file mode 100644 index 0000000..e4a6c4a --- /dev/null +++ b/forges/gitlab/projects.go @@ -0,0 +1,18 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type projects struct { + container +} + +func newProjects() generic.NodeDriverInterface { + return &projects{} +} diff --git a/forges/gitlab/root.go b/forges/gitlab/root.go new file mode 100644 index 0000000..4427881 --- /dev/null +++ b/forges/gitlab/root.go @@ -0,0 +1,42 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type root struct { + generic.NullDriver + + content f3.Interface +} + +func newRoot(content f3.Interface) generic.NodeDriverInterface { + return &root{ + content: content, + } +} + +func (o *root) FromFormat(content f3.Interface) { + o.content = content +} + +func (o *root) ToFormat() f3.Interface { + return o.content +} + +func (o *root) Get(context.Context) bool { return true } + +func (o *root) Put(context.Context) id.NodeID { + return id.NilID +} + +func (o *root) Patch(context.Context) { +} diff --git a/forges/gitlab/tests/init.go b/forges/gitlab/tests/init.go new file mode 100644 index 0000000..7343202 --- /dev/null +++ b/forges/gitlab/tests/init.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + gitlab_options "code.forgejo.org/f3/gof3/v3/forges/gitlab/options" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +func init() { + tests_forge.RegisterFactory(gitlab_options.Name, func() tests_forge.Interface { + return newForgeTest(gitlab_options.Name) + }) +} diff --git a/forges/gitlab/tests/new.go b/forges/gitlab/tests/new.go new file mode 100644 index 0000000..59fffd1 --- /dev/null +++ b/forges/gitlab/tests/new.go @@ -0,0 +1,57 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "testing" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/options" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +type forgeTest struct { + tests_forge.Base +} + +func (o *forgeTest) NewOptions(t *testing.T) options.Interface { + return newTestOptions(t, o.Base.GetName()) +} + +func (o *forgeTest) GetNameExceptions() []string { + return []string{tests_forge.ComplianceNameForkedPullRequest} +} + +func (o *forgeTest) GetKindExceptions() []kind.Kind { + return []kind.Kind{ + f3_tree.KindAssets, + f3_tree.KindComments, + f3_tree.KindIssues, + f3_tree.KindLabels, + f3_tree.KindMilestones, + f3_tree.KindProjects, + f3_tree.KindPullRequests, + f3_tree.KindReactions, + f3_tree.KindReleases, + f3_tree.KindRepositories, + f3_tree.KindReviews, + f3_tree.KindReviewComments, + f3_tree.KindTopics, + } +} + +func (o *forgeTest) GetNonTestUsers() []string { + return []string{ + GetFixtureUsername(), + "ghost", + } +} + +func newForgeTest(name string) tests_forge.Interface { + t := &forgeTest{} + t.SetName(name) + return t +} diff --git a/forges/gitlab/tests/testhelpers.go b/forges/gitlab/tests/testhelpers.go new file mode 100644 index 0000000..87b1e09 --- /dev/null +++ b/forges/gitlab/tests/testhelpers.go @@ -0,0 +1,60 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package tests + +import ( + "os" + "strings" + "testing" + + gitlab_options "code.forgejo.org/f3/gof3/v3/forges/gitlab/options" + "code.forgejo.org/f3/gof3/v3/forges/helpers/auth" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" +) + +func GetFixtureURL(name string) string { + upperName := strings.ToUpper(name) + hostPort := os.Getenv("GOF3_" + upperName + "_HOST_PORT") + if hostPort == "" { + return "" + } + return "http://" + hostPort +} + +func GetFixtureUsername() string { + user := os.Getenv("GITLAB_TEST_USER") + if user == "" { + user = "root" + } + return user +} + +func GetFixturePassword() string { + password := os.Getenv("GITLAB_TEST_PASSWORD") + if password == "" { + password = "Wrobyak4" + } + return password +} + +func newTestOptions(t *testing.T, name string) options.Interface { + t.Helper() + url := GetFixtureURL(name) + if url == "" { + t.Skip("test server is not up") + } + forgeAuth := auth.NewForgeAuth() + forgeAuth.SetURL(url) + forgeAuth.SetUsername(GetFixtureUsername()) + forgeAuth.SetPassword(GetFixturePassword()) + o := options.GetFactory(gitlab_options.Name)().(*gitlab_options.Options) + o.ForgeAuth = forgeAuth + l := logger.NewLogger() + l.SetLevel(logger.Trace) + o.OptionsLogger.SetLogger(l) + + return o +} diff --git a/forges/gitlab/topics.go b/forges/gitlab/topics.go new file mode 100644 index 0000000..1021d5e --- /dev/null +++ b/forges/gitlab/topics.go @@ -0,0 +1,18 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type topics struct { + container +} + +func newTopics() generic.NodeDriverInterface { + return &topics{} +} diff --git a/forges/gitlab/tree.go b/forges/gitlab/tree.go new file mode 100644 index 0000000..9794841 --- /dev/null +++ b/forges/gitlab/tree.go @@ -0,0 +1,213 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + gitlab_options "code.forgejo.org/f3/gof3/v3/forges/gitlab/options" + "code.forgejo.org/f3/gof3/v3/kind" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + "github.com/hashicorp/go-version" + "gitlab.com/gitlab-org/api/client-go" +) + +type treeDriver struct { + generic.NullTreeDriver + + user *gitlab.User + client *gitlab.Client + options *gitlab_options.Options + version *version.Version +} + +func (o *treeDriver) GetClient() *gitlab.Client { + return o.client +} + +func (o *treeDriver) GetIsAdmin() bool { + return o.user.IsAdmin +} + +func (o *treeDriver) SetIsAdmin() { + user, _, err := o.client.Users.CurrentUser() + if err != nil { + panic(fmt.Errorf("Failed to get information about the user for: %s. Error: %v", o.options.GetURL(), err)) + } + o.user = user + o.options.SetUsername(user.Username) +} + +var ( + ForgejoVersion700 = version.Must(version.NewVersion("7.0.0")) // 1.22 + ForgejoVersion600 = version.Must(version.NewVersion("6.0.0")) // 1.21 + ForgejoVersion500 = version.Must(version.NewVersion("5.0.0")) // 1.20.1 + ForgejoVersion501 = version.Must(version.NewVersion("5.0.1")) // 1.20.2 + ForgejoVersion502 = version.Must(version.NewVersion("5.0.2")) // 1.20.3 + ForgejoVersion503 = version.Must(version.NewVersion("5.0.3")) // 1.20.4 + ForgejoVersion504 = version.Must(version.NewVersion("5.0.4")) // 1.20.5 + ForgejoVersion4 = version.Must(version.NewVersion("4.0.0")) // 1.19 + + ForgejoVersionNotFound = version.Must(version.NewVersion("1.0.0")) +) + +func (o *treeDriver) GetVersion() *version.Version { + o.SetVersion() + return o.version +} + +func (o *treeDriver) SetVersion() { + if o.version != nil { + return + } + client := &http.Client{} + url := fmt.Sprintf("%s/api/forgejo/v1/version", o.options.GetURL()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + panic(err) + } + resp, err := client.Do(req) + if err != nil { + panic(fmt.Errorf("while getting %s %w", url, err)) + } + + switch resp.StatusCode { + case http.StatusNotFound: + o.version = ForgejoVersionNotFound + case http.StatusOK: + v := struct{ Version string }{} + body, err := io.ReadAll(resp.Body) + if err != nil { + panic(fmt.Errorf("reading response body %+v %w", resp, err)) + } + if err := json.Unmarshal(body, &v); err != nil { + panic(fmt.Errorf("decoding JSON response from %s %s %w", url, string(body), err)) + } + o.version = version.Must(version.NewVersion(v.Version)) + default: + panic(fmt.Errorf("unexpected status code fetching %s %d %v", url, resp.StatusCode, resp)) + } +} + +func (o *treeDriver) maybeSudo(uid interface{}) []gitlab.RequestOptionFunc { + if !o.GetIsAdmin() { + return []gitlab.RequestOptionFunc{} + } + if name, ok := uid.(string); ok && name == o.options.GetUsername() { + return []gitlab.RequestOptionFunc{} + } + return []gitlab.RequestOptionFunc{ + gitlab.WithSudo(uid), + } +} + +func (o *treeDriver) Init() { + o.NullTreeDriver.Init() + + var err error + var client *gitlab.Client + if o.options.GetToken() != "" { + o.Debug("Connecting to %s with token", o.options.GetURL()) + client, err = gitlab.NewClient(o.options.GetToken(), gitlab.WithBaseURL(o.options.GetURL()), gitlab.WithHTTPClient(o.options.GetNewMigrationHTTPClient()())) + } else { + o.Debug("Connecting to %s as user %s", o.options.GetURL(), o.options.GetUsername()) + client, err = gitlab.NewBasicAuthClient(o.options.GetUsername(), o.options.GetPassword(), gitlab.WithBaseURL(o.options.GetURL()), gitlab.WithHTTPClient(o.options.GetNewMigrationHTTPClient()())) + } + if err != nil { + panic(fmt.Errorf("Failed to create Forgejo client for: %s. Error: %v", o.options.GetURL(), err)) + } + o.client = client + + o.SetIsAdmin() + if o.GetIsAdmin() { + o.Debug("Connected as admin") + } else { + o.Debug("Connected as regular user") + } +} + +func newTreeDriver(tree generic.TreeInterface, anyOptions any) generic.TreeDriverInterface { + driver := &treeDriver{ + options: anyOptions.(*gitlab_options.Options), + } + driver.SetTree(tree) + driver.Init() + return driver +} + +func (o *treeDriver) Factory(ctx context.Context, k kind.Kind) generic.NodeDriverInterface { + switch k { + case f3_tree.KindForge: + return newForge() + case f3_tree.KindOrganizations: + return newOrganizations() + case f3_tree.KindOrganization: + return newOrganization() + case f3_tree.KindUsers: + return newUsers() + case f3_tree.KindUser: + return newUser() + case f3_tree.KindProjects: + return newProjects() + // case f3_tree.KindProject: + // return newProject() + // case f3_tree.KindIssues: + // return newIssues() + // case f3_tree.KindIssue: + // return newIssue() + // case f3_tree.KindComments: + // return newComments() + // case f3_tree.KindComment: + // return newComment() + // case f3_tree.KindAssets: + // return newAssets() + // case f3_tree.KindAsset: + // return newAsset() + // case f3_tree.KindLabels: + // return newLabels() + // case f3_tree.KindLabel: + // return newLabel() + // case f3_tree.KindReactions: + // return newReactions() + // case f3_tree.KindReaction: + // return newReaction() + // case f3_tree.KindReviews: + // return newReviews() + // case f3_tree.KindReview: + // return newReview() + // case f3_tree.KindReviewComments: + // return newReviewComments() + // case f3_tree.KindReviewComment: + // return newReviewComment() + // case f3_tree.KindMilestones: + // return newMilestones() + // case f3_tree.KindMilestone: + // return newMilestone() + // case f3_tree.KindPullRequests: + // return newPullRequests() + // case f3_tree.KindPullRequest: + // return newPullRequest() + // case f3_tree.KindReleases: + // return newReleases() + // case f3_tree.KindRelease: + // return newRelease() + case f3_tree.KindTopics: + return newTopics() + // case f3_tree.KindRepositories: + // return newRepositories() + // case f3_tree.KindRepository: + // return newRepository(ctx) + case kind.KindRoot: + return newRoot(o.GetTree().(f3_tree.TreeInterface).NewFormat(k)) + default: + panic(fmt.Errorf("unexpected kind %s", k)) + } +} diff --git a/forges/gitlab/user.go b/forges/gitlab/user.go new file mode 100644 index 0000000..ced7a30 --- /dev/null +++ b/forges/gitlab/user.go @@ -0,0 +1,157 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + "fmt" + "net/http" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "gitlab.com/gitlab-org/api/client-go" +) + +type user struct { + common + gitlabUser *gitlab.User + Password string +} + +var _ f3_tree.ForgeDriverInterface = &user{} + +func newUser() generic.NodeDriverInterface { + return &user{} +} + +func (o *user) SetNative(user any) { + o.gitlabUser = user.(*gitlab.User) +} + +func (o *user) GetNativeID() string { + return fmt.Sprintf("%d", o.gitlabUser.ID) +} + +func (o *user) NewFormat() f3.Interface { + node := o.GetNode() + return node.GetTree().(f3_tree.TreeInterface).NewFormat(node.GetKind()) +} + +func (o *user) ToFormat() f3.Interface { + if o.gitlabUser == nil { + return o.NewFormat() + } + return &f3.User{ + Common: f3.NewCommon(fmt.Sprintf("%d", o.gitlabUser.ID)), + UserName: o.gitlabUser.Username, + Name: o.gitlabUser.Name, + Email: o.gitlabUser.Email, + IsAdmin: o.gitlabUser.IsAdmin, + Password: o.Password, + } +} + +func (o *user) FromFormat(content f3.Interface) { + user := content.(*f3.User) + o.gitlabUser = &gitlab.User{ + ID: int(util.ParseInt(user.GetID())), + Username: user.UserName, + Name: user.Name, + Email: user.Email, + IsAdmin: user.IsAdmin, + } + o.Password = user.Password +} + +func (o *user) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("%s", node.GetID()) + if node.GetID() == id.NilID { + panic("GetID() == 0") + } + user, resp, err := o.getClient().Users.GetUser(node.GetID().Int(), gitlab.GetUsersOptions{}) + if resp.StatusCode == http.StatusNotFound { + return false + } + if err != nil { + panic(fmt.Errorf("user %v %w", o, err)) + } + o.gitlabUser = user + return true +} + +func (o *user) Patch(context.Context) { +} + +func (o *user) Put(context.Context) id.NodeID { + skipConfirmation := true + + if o.Password == "" { + o.Password = util.RandSeq(30) + } + + u, _, err := o.getClient().Users.CreateUser(&gitlab.CreateUserOptions{ + Username: &o.gitlabUser.Username, + Name: &o.gitlabUser.Name, + Email: &o.gitlabUser.Email, + Password: &o.Password, + Admin: &o.gitlabUser.IsAdmin, + SkipConfirmation: &skipConfirmation, + }) + if err != nil { + panic(fmt.Errorf("%v: %w", o.gitlabUser, err)) + } + o.gitlabUser = u + o.Trace("%s %d", o.gitlabUser.Username, o.gitlabUser.ID) + return id.NewNodeID(u.ID) +} + +func (o *user) deleteUser(ctx context.Context) int { + u := fmt.Sprintf("users/%d", o.gitlabUser.ID) + + type deleteUserOptions struct { + HardDelete *bool `url:"hard_delete,omitempty" json:"hard_delete,omitempty"` + } + + hardDelete := true + req, err := o.getClient().NewRequest(http.MethodDelete, u, &deleteUserOptions{ + HardDelete: &hardDelete, + }, nil) + if err != nil { + panic(fmt.Errorf("NewRequest: %v %v: %w", o.gitlabUser.ID, o.gitlabUser.Username, err)) + } + resp, err := o.getClient().Do(req, nil) + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + panic(fmt.Errorf("unexpected status code: %v %v: %v %w", o.gitlabUser.ID, o.gitlabUser.Username, resp.StatusCode, err)) + } + if resp.StatusCode != http.StatusNotFound && err != nil { + panic(fmt.Errorf("Do: %v %v: %w", o.gitlabUser.ID, o.gitlabUser.Username, err)) + } + return resp.StatusCode +} + +func (o *user) Delete(ctx context.Context) { + o.Trace("%s %d", o.gitlabUser.Username, o.gitlabUser.ID) + statusCode := o.deleteUser(ctx) + if statusCode != http.StatusNoContent { + panic(fmt.Errorf("expected user deletion to return 204 but got %d", statusCode)) + } + loop := 100 + for i := 0; i < loop; i++ { + if o.deleteUser(ctx) == http.StatusNotFound { + o.Trace("user deletion complete") + return + } + o.Trace("waiting for asynchronous user deletion (%d/%d)", i, loop) + time.Sleep(5 * time.Second) + } + o.Trace("user still present after %d attempts", loop) +} diff --git a/forges/gitlab/users.go b/forges/gitlab/users.go new file mode 100644 index 0000000..2c48d1b --- /dev/null +++ b/forges/gitlab/users.go @@ -0,0 +1,50 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package gitlab + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/id" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + "gitlab.com/gitlab-org/api/client-go" +) + +type users struct { + container +} + +func (o *users) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + pageSize := o.getPageSize() + + userFounds, _, err := o.getClient().Users.ListUsers(&gitlab.ListUsersOptions{ + ListOptions: gitlab.ListOptions{Page: page, PerPage: pageSize}, + }) + if err != nil { + panic(fmt.Errorf("error while listing users: %v", err)) + } + + return f3_tree.ConvertListed(ctx, o.GetNode(), f3_tree.ConvertToAny(userFounds...)...) +} + +func (o *users) GetIDFromName(ctx context.Context, name string) id.NodeID { + users, _, err := o.getClient().Users.ListUsers(&gitlab.ListUsersOptions{Username: &name}) + if err != nil { + panic(fmt.Errorf("user %v %w", o, err)) + } + if len(users) == 0 { + return id.NilID + } else if len(users) > 1 { + panic(fmt.Errorf("user %v found multiple users %v", name, users)) + } + return id.NewNodeID(users[0].ID) +} + +func newUsers() generic.NodeDriverInterface { + return &users{} +} diff --git a/forges/helpers/auth/auth.go b/forges/helpers/auth/auth.go new file mode 100644 index 0000000..afc6f91 --- /dev/null +++ b/forges/helpers/auth/auth.go @@ -0,0 +1,81 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/url" +) + +type ForgeAuthInterface interface { + SetURL(url string) + GetURL() string + + GetPushURL() string + + SetUsername(username string) + GetUsername() string + + SetPassword(password string) + GetPassword() string + + SetToken(token string) + GetToken() string +} + +type ForgeAuth struct { + url string + username string + password string + token string +} + +func NewForgeAuth() ForgeAuth { + return ForgeAuth{} +} + +func (o *ForgeAuth) GetPushURL() string { + u, err := url.Parse(o.url) + if err != nil { + panic(err) + } + if o.GetToken() != "" { + u.User = url.UserPassword("token", o.GetToken()) + } else { + u.User = url.UserPassword(o.GetUsername(), o.GetPassword()) + } + return u.String() +} + +func (o *ForgeAuth) SetURL(url string) { + o.url = url +} + +func (o *ForgeAuth) GetURL() string { + return o.url +} + +func (o *ForgeAuth) SetUsername(username string) { + o.username = username +} + +func (o *ForgeAuth) GetUsername() string { + return o.username +} + +func (o *ForgeAuth) SetPassword(password string) { + o.password = password +} + +func (o *ForgeAuth) GetPassword() string { + return o.password +} + +func (o *ForgeAuth) SetToken(token string) { + o.token = token +} + +func (o *ForgeAuth) GetToken() string { + return o.token +} diff --git a/forges/helpers/auth/cli.go b/forges/helpers/auth/cli.go new file mode 100644 index 0000000..1ef0f87 --- /dev/null +++ b/forges/helpers/auth/cli.go @@ -0,0 +1,61 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +func ForgeUserOption(prefix string) string { + return prefix + "-user" +} + +func ForgePasswordOption(prefix string) string { + return prefix + "-password" +} + +func ForgeTokenOption(prefix string) string { + return prefix + "-token" +} + +func ForgeURLOption(prefix string) string { + return prefix + "-url" +} + +func (o *ForgeAuth) FromFlags(ctx context.Context, c *cli.Command, prefix string) { + o.SetUsername(c.String(ForgeUserOption(prefix))) + o.SetPassword(c.String(ForgePasswordOption(prefix))) + o.SetToken(c.String(ForgeTokenOption(prefix))) + o.SetURL(c.String(ForgeURLOption(prefix))) +} + +func (o *ForgeAuth) GetFlags(prefix, category string) []cli.Flag { + flags := make([]cli.Flag, 0, 10) + + flags = append(flags, &cli.StringFlag{ + Name: ForgeUserOption(prefix), + Usage: "`USER` to access the forge API", + Category: prefix, + }) + flags = append(flags, &cli.StringFlag{ + Name: ForgePasswordOption(prefix), + Usage: "`PASSWORD` of the user", + Category: prefix, + }) + flags = append(flags, &cli.StringFlag{ + Name: ForgeTokenOption(prefix), + Usage: "`TOKEN` of the user", + Category: prefix, + }) + flags = append(flags, &cli.StringFlag{ + Name: ForgeURLOption(prefix), + Usage: "`URL` of the forge", + Category: prefix, + }) + + return flags +} diff --git a/forges/helpers/pullrequest/helper.go b/forges/helpers/pullrequest/helper.go new file mode 100644 index 0000000..3de3dba --- /dev/null +++ b/forges/helpers/pullrequest/helper.go @@ -0,0 +1,73 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package pullrequest + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/logger" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type Interface interface { + Upsert(context.Context) id.NodeID + Get(context.Context) bool +} + +type prInterface interface { + logger.MessageInterface + f3_tree.PullRequestDriverInterface + GetNode() generic.NodeInterface + SetFetchFunc(func(ctx context.Context, url, ref string)) + ToFormat() f3.Interface +} + +type helper struct { + pr prInterface + r helpers_repository.Interface +} + +func (o *helper) getRepository(ctx context.Context) helpers_repository.Interface { + if o.r == nil { + project := f3_tree.GetFirstNodeKind(o.pr.GetNode(), f3_tree.KindProject) + r := project.Find(generic.NewPathFromString("repositories/vcs")) + if r == generic.NilNode { + panic(fmt.Errorf("no repository found for %s", project.GetCurrentPath())) + } + o.r = r.GetDriver().(f3_tree.ForgeDriverInterface).GetHelper().(helpers_repository.Interface) + } + return o.r +} + +func (o *helper) Get(ctx context.Context) bool { + headURL := o.getRepository(ctx).GetRepositoryURL() + headRef := o.pr.GetPullRequestHead() + o.pr.SetFetchFunc(func(ctx context.Context, url, ref string) { + helpers_repository.GitMirrorRef(ctx, o.pr, headURL, headRef, url, ref) + }) + return true +} + +func (o *helper) Upsert(ctx context.Context) id.NodeID { + f := o.pr.ToFormat().(*f3.PullRequest) + if f.FetchFunc != nil { + pushURL := o.getRepository(ctx).GetRepositoryPushURL() + for _, pushRef := range o.pr.GetPullRequestPushRefs() { + f.FetchFunc(ctx, pushURL, pushRef) + } + } + return o.pr.GetNode().GetID() +} + +func NewHelper(ctx context.Context, pr prInterface) Interface { + return &helper{ + pr: pr, + } +} diff --git a/forges/helpers/repository/git.go b/forges/helpers/repository/git.go new file mode 100644 index 0000000..0ff8f9d --- /dev/null +++ b/forges/helpers/repository/git.go @@ -0,0 +1,103 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "fmt" + "os" + "strings" + + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/util" +) + +func GitGetSha(ctx context.Context, log logger.MessageInterface, repo, ref string) string { + sha := util.Command(ctx, log, "git", "-C", repo, "rev-parse", ref) + return strings.TrimSuffix(sha, "\n") +} + +func disableHooks(ctx context.Context, log logger.MessageInterface, dir string) func() { + if !util.FileExists(dir) { + return func() {} + } + util.Command(ctx, log, "git", "-C", dir, "config", "core.hooksPath", "/dev/null") + return func() { + util.Command(ctx, log, "git", "-C", dir, "config", "--unset", "core.hooksPath") + } +} + +func GitMirrorRef(ctx context.Context, log logger.MessageInterface, originURL, originRef, destinationURL, destinationRef string) { + if log == nil { + log = logger.NewLogger() + } + if originURL == destinationURL { + log.Log(1, logger.Trace, "do nothing because origin & destination are the same %s\n", originURL) + return + } + log.Log(1, logger.Trace, "%s:%s => %s:%s\n", originURL, originRef, destinationURL, destinationRef) + defer disableHooks(ctx, log, destinationURL)() + if util.FileExists(originURL) { + util.Command(ctx, log, "git", "-C", originURL, "push", destinationURL, "+"+originRef+":"+destinationRef) + } else { + if util.FileExists(destinationURL) { + util.Command(ctx, log, "git", "-C", destinationURL, "fetch", originURL, "+"+originRef+":"+destinationRef) + } else { + directory, err := os.MkdirTemp("", "driverRepository") + if err != nil { + panic(err) + } + defer func() { + err := os.RemoveAll(directory) + if err != nil { + panic(err) + } + }() + util.Command(ctx, log, "git", "clone", "--bare", "--depth", "1", originURL, directory) + util.Command(ctx, log, "git", "-C", directory, "fetch", "origin", originRef) + util.Command(ctx, log, "git", "-C", directory, "push", destinationURL, "+FETCH_HEAD:"+destinationRef) + } + } +} + +func GitMirror(ctx context.Context, log logger.MessageInterface, origin, destination string, internalRefs []string) { + if log == nil { + log = logger.NewLogger() + } + if origin == destination { + log.Log(1, logger.Trace, "do nothing because origin & destination are the same %s\n", origin) + return + } + log.Log(1, logger.Trace, "%s => %s\n", origin, destination) + defer disableHooks(ctx, log, destination)() + excludeInternalRefs := make([]string, 0, len(internalRefs)) + for _, ref := range internalRefs { + excludeInternalRefs = append(excludeInternalRefs, fmt.Sprintf("^%s", ref)) + } + if util.FileExists(origin) { + args := append([]string{"-C", origin, "push", destination, "+refs/*:refs/*"}, excludeInternalRefs...) + util.Command(ctx, log, "git", args...) + } else { + if util.FileExists(destination) { + util.Command(ctx, log, "git", "-C", destination, "remote", "add", "--mirror=fetch", "fetchMirror", origin) + defer func() { util.Command(ctx, log, "git", "-C", destination, "remote", "remove", "fetchMirror") }() + util.Command(ctx, log, "git", "-C", destination, "fetch", "fetchMirror") + } else { + directory, err := os.MkdirTemp("", "driverRepository") + if err != nil { + panic(err) + } + defer func() { + err := os.RemoveAll(directory) + if err != nil { + panic(err) + } + }() + util.Command(ctx, log, "git", "clone", "--mirror", origin, directory) + args := append([]string{"-C", directory, "push", destination, "+refs/*:refs/*"}, excludeInternalRefs...) + util.Command(ctx, log, "git", args...) + } + } +} diff --git a/forges/helpers/repository/git_test.go b/forges/helpers/repository/git_test.go new file mode 100644 index 0000000..5886164 --- /dev/null +++ b/forges/helpers/repository/git_test.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "strings" + "testing" + + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/util" + + "github.com/stretchr/testify/require" +) + +func Test_disableHooks(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + log := logger.NewLogger() + util.Command(ctx, log, "git", "-C", dir, "init") + unset := "unset" + get := func() string { + out := util.Command(ctx, log, "git", "-C", dir, "config", "--get", "--default", unset, "core.hooksPath") + return strings.TrimSuffix(out, "\n") + } + require.Equal(t, unset, get()) + enable := disableHooks(ctx, log, dir) + require.NotEqual(t, unset, get()) + enable() + require.Equal(t, unset, get()) +} diff --git a/forges/helpers/repository/helper.go b/forges/helpers/repository/helper.go new file mode 100644 index 0000000..68a532a --- /dev/null +++ b/forges/helpers/repository/helper.go @@ -0,0 +1,98 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "os" + "runtime" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/logger" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +type Interface interface { + f3_tree.RepositoryDriverInterface + Upsert(context.Context, *f3.Repository) id.NodeID + Get(context.Context) bool + Fetch(context.Context) string +} + +type repositoryInterface interface { + logger.MessageInterface + f3_tree.RepositoryDriverInterface + GetNode() generic.NodeInterface + ToFormat() f3.Interface + SetFetchFunc(func(ctx context.Context, destination string, internalRefs []string)) +} + +type helper struct { + r repositoryInterface + dir *string +} + +func (o *helper) getDir() string { + if o.dir == nil { + dir, err := os.MkdirTemp("", "repositoryHelper") + if err != nil { + panic(err) + } + runtime.SetFinalizer(o, func(o *helper) { + err := os.RemoveAll(dir) + if err != nil { + panic(err) + } + }) + o.dir = &dir + } + return *o.dir +} + +func (o *helper) GetRepositoryURL() string { return o.r.GetRepositoryURL() } +func (o *helper) GetRepositoryPushURL() string { return o.r.GetRepositoryPushURL() } +func (o *helper) GetRepositoryInternalRefs() []string { return o.r.GetRepositoryInternalRefs() } + +func (o *helper) Fetch(ctx context.Context) string { + to := o.getDir() + o.r.Trace("%s", to) + if err := util.CommandWithErr(ctx, util.CommandOptions{}, "git", "-C", to, "rev-parse", "--is-bare-repository"); err != nil { + o.r.Trace(util.Command(ctx, o.r, "git", "-C", to, "init", "--bare")) + } + from := o.r.GetRepositoryURL() + GitMirror(ctx, o.r, from, to, []string{}) + return to +} + +func (o *helper) Get(ctx context.Context) bool { + from := o.r.GetRepositoryURL() + o.r.Trace("%s", from) + o.r.SetFetchFunc(func(ctx context.Context, destination string, internalRefs []string) { + o.r.Trace("git clone %s %s", from, destination) + GitMirror(ctx, o.r, from, destination, internalRefs) + }) + return true +} + +func (o *helper) Upsert(ctx context.Context, f *f3.Repository) id.NodeID { + if f.FetchFunc != nil { + to := o.r.GetRepositoryPushURL() + internalRefs := o.r.GetRepositoryInternalRefs() + o.r.Trace("%s", to) + f.FetchFunc(ctx, to, internalRefs) + } else { + o.r.Trace("NO FETCH %s", o.r.GetRepositoryPushURL()) + panic("") + } + return o.r.GetNode().GetID() +} + +func NewHelper(r repositoryInterface) Interface { + h := &helper{r: r} + return h +} diff --git a/forges/helpers/tests/repository/helper_test.go b/forges/helpers/tests/repository/helper_test.go new file mode 100644 index 0000000..272d00f --- /dev/null +++ b/forges/helpers/tests/repository/helper_test.go @@ -0,0 +1,48 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "github.com/stretchr/testify/assert" +) + +type repositoryMock struct { + logger.Logger + url string +} + +func newRepositoryMock(url string) *repositoryMock { + r := &repositoryMock{ + url: url, + } + r.SetLogger(logger.NewLogger()) + return r +} + +func (o *repositoryMock) GetNode() generic.NodeInterface { return nil } +func (o *repositoryMock) GetRepositoryPushURL() string { return o.url } +func (o *repositoryMock) GetRepositoryInternalRefs() []string { return []string{} } +func (o *repositoryMock) GetRepositoryURL() string { return o.url } +func (o *repositoryMock) ToFormat() f3.Interface { return nil } +func (o *repositoryMock) SetFetchFunc(func(ctx context.Context, destination string, internalRefs []string)) { +} + +func TestRepositoryHelper(t *testing.T) { + url := t.TempDir() + repositoryHelper := NewTestHelper(t, url, nil) + repositoryHelper.CreateRepositoryContent("").PushMirror() + + h := helpers_repository.NewHelper(newRepositoryMock(url)) + assert.True(t, util.FileExists(h.Fetch(context.Background()))) +} diff --git a/forges/helpers/tests/repository/interface.go b/forges/helpers/tests/repository/interface.go new file mode 100644 index 0000000..dd77611 --- /dev/null +++ b/forges/helpers/tests/repository/interface.go @@ -0,0 +1,15 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "github.com/stretchr/testify/assert" +) + +type TestingT interface { + assert.TestingT + TempDir() string + Skip(args ...any) +} diff --git a/forges/helpers/tests/repository/repositoryhelpers.go b/forges/helpers/tests/repository/repositoryhelpers.go new file mode 100644 index 0000000..8b36648 --- /dev/null +++ b/forges/helpers/tests/repository/repositoryhelpers.go @@ -0,0 +1,182 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "github.com/stretchr/testify/assert" +) + +type TestHelper struct { + t TestingT + clone string + bare string + node generic.NodeInterface +} + +func NewTestHelper(t TestingT, bare string, node generic.NodeInterface) *TestHelper { + o := &TestHelper{ + t: t, + node: node, + } + + o.bare = bare + if o.bare == "" { + if node == nil { + panic("") + } + bare, err := os.MkdirTemp("", "repositoryHelperBare") + assert.NoError(t, err) + o.bare = bare + o.InitBare() + node.ToFormat().(*f3.Repository).FetchFunc(context.Background(), bare, []string{}) + } else { + o.InitBare() + } + + clone, err := os.MkdirTemp("", "repositoryHelperClone") + assert.NoError(t, err) + o.clone = clone + util.Command(context.Background(), nil, "git", "clone", o.bare, o.clone) + o.setRepositoryConfig() + + return o +} + +func (o *TestHelper) GetClone() string { + return o.clone +} + +func (o *TestHelper) PullClone() { + util.Command(context.Background(), nil, "git", "-C", o.clone, "pull") +} + +func (o *TestHelper) GetBare() string { + return o.bare +} + +func (o *TestHelper) GetNode() generic.NodeInterface { + return o.node +} + +func (o *TestHelper) InitBare(args ...string) string { + if !util.FileExists(o.bare) { + assert.NoError(o.t, os.Mkdir(o.bare, 0o700)) + } + init := []string{"-C", o.bare, "init", "--bare"} + init = append(init, args...) + return util.Command(context.Background(), nil, "git", init...) +} + +func (o *TestHelper) RevList() string { + return util.Command(context.Background(), nil, "git", "-C", o.clone, "rev-list", "--all") +} + +func (o *TestHelper) AssertReadmeContains(content string) { + o.PullClone() + readme, err := os.ReadFile(filepath.Join(o.GetClone(), "README.md")) + assert.NoError(o.t, err) + assert.Contains(o.t, string(readme), content) +} + +func (o *TestHelper) AssertRepositoryNotFileExists(file string) { + assert.NotContains(o.t, o.repositoryLsFile(file), file) +} + +func (o *TestHelper) AssertRepositoryFileExists(file string) { + assert.Contains(o.t, o.repositoryLsFile(file), file) +} + +func (o *TestHelper) AssertRepositoryTagExists(tag string) { + assert.EqualValues(o.t, tag+"\n", util.Command(context.Background(), nil, "git", "-C", o.clone, "tag", "-l", tag)) +} + +func (o *TestHelper) AssertRepositoryBranchExists(branch string) { + assert.EqualValues(o.t, "refs/heads/"+branch+"\n", util.Command(context.Background(), nil, "git", "-C", o.bare, "branch", "--format", "%(refname)", "-l", branch)) +} + +func (o *TestHelper) repositoryCountObject() int64 { + out := util.Command(context.Background(), nil, "git", "-C", o.clone, "count-objects") + count := strings.Split(out, " ")[0] + return util.ParseInt(count) +} + +func (o *TestHelper) repositoryLsFile(file string) string { + if o.repositoryCountObject() > 0 { + return util.Command(context.Background(), nil, "git", "-C", o.clone, "ls-tree", "HEAD", file) + } + return "" +} + +func (o *TestHelper) PushMirror() { + util.Command(context.Background(), nil, "git", "-C", o.clone, "push", "--all", "origin") + util.Command(context.Background(), nil, "git", "-C", o.clone, "push", "--tags", "origin") + if o.node != nil { + f := o.node.ToFormat().(*f3.Repository) + f.FetchFunc = func(ctx context.Context, destination string, internalRefs []string) { + helpers_repository.GitMirror(ctx, nil, o.bare, destination, internalRefs) + } + if f.GetID() == "" { + panic(fmt.Errorf("id %s", o.node.GetID())) + } + o.node.FromFormat(f) + o.node.Upsert(context.Background()) + } +} + +func (o *TestHelper) CreateRepositoryContent(content string) *TestHelper { + o.commitREADME(content) + return o +} + +func (o *TestHelper) CreateRepositoryTag(tag, commit string) *TestHelper { + o.createRepositoryTag(tag, commit) + return o +} + +func (o *TestHelper) createRepositoryTag(tag, commit string) { + util.Command(context.Background(), nil, "git", "-C", o.clone, "tag", tag, commit) +} + +func (o *TestHelper) setRepositoryConfig() { + util.Command(context.Background(), nil, "git", "-C", o.clone, "config", "user.email", "author@example.com") + util.Command(context.Background(), nil, "git", "-C", o.clone, "config", "user.name", "Author") +} + +func (o *TestHelper) commitREADME(content string) { + readme := fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s\n%s", o.clone, content) + assert.NoError(o.t, os.WriteFile(filepath.Join(o.clone, "README.md"), []byte(readme), 0o644)) + util.Command(context.Background(), nil, "git", "-C", o.clone, "add", "README.md") + util.Command(context.Background(), nil, "git", "-C", o.clone, "commit", "-m", "Add README", "README.md") +} + +func (o *TestHelper) GetRepositorySha(branch string) string { + sha := util.Command(context.Background(), nil, "git", "-C", o.clone, "rev-parse", "origin/"+branch) + return strings.TrimSuffix(sha, "\n") +} + +func (o *TestHelper) BranchRepositoryFeature(branch, content string) *TestHelper { + o.InternalBranchRepositoryFeature(branch, content) + return o +} + +func (o *TestHelper) InternalBranchRepositoryFeature(branch, content string) { + util.Command(context.Background(), nil, "git", "-C", o.clone, "checkout", "-b", branch, "master") + assert.NoError(o.t, os.WriteFile(filepath.Join(o.clone, "README.md"), []byte(content), 0o644)) + util.Command(context.Background(), nil, "git", "-C", o.clone, "add", "README.md") + util.Command(context.Background(), nil, "git", "-C", o.clone, "commit", "-m", "feature README", "README.md") + util.Command(context.Background(), nil, "git", "-C", o.clone, "push", "origin", branch) + util.Command(context.Background(), nil, "git", "-C", o.clone, "checkout", "master") +} diff --git a/forges/main.go b/forges/main.go new file mode 100644 index 0000000..b57943c --- /dev/null +++ b/forges/main.go @@ -0,0 +1,12 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forges + +import ( + // register + _ "code.forgejo.org/f3/gof3/v3/forges/filesystem" + _ "code.forgejo.org/f3/gof3/v3/forges/forgejo" + _ "code.forgejo.org/f3/gof3/v3/forges/gitlab" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..65dae20 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module code.forgejo.org/f3/gof3/v3 + +go 1.24 + +toolchain go1.24.2 + +require ( + github.com/42wim/httpsig v1.2.3 + github.com/davidmz/go-pageant v1.0.2 + github.com/google/go-cmp v0.7.0 + github.com/hashicorp/go-version v1.7.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 + github.com/stretchr/testify v1.10.0 + github.com/urfave/cli/v3 v3.3.2 + gitlab.com/gitlab-org/api/client-go v0.128.0 + golang.org/x/crypto v0.38.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/oauth2 v0.25.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..09c82e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= +github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +gitlab.com/gitlab-org/api/client-go v0.128.0 h1:Wvy1UIuluKemubao2k8EOqrl3gbgJ1PVifMIQmg2Da4= +gitlab.com/gitlab-org/api/client-go v0.128.0/go.mod h1:bYC6fPORKSmtuPRyD9Z2rtbAjE7UeNatu2VWHRf4/LE= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/id/id.go b/id/id.go new file mode 100644 index 0000000..c0a4954 --- /dev/null +++ b/id/id.go @@ -0,0 +1,36 @@ +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package id + +import ( + "fmt" + + "code.forgejo.org/f3/gof3/v3/util" +) + +type NodeID interface { + String() string + Int() int + Int64() int64 +} + +var NilID = NewNodeID("") + +type nodeID string + +func NewNodeID[T any](id T) NodeID { + return nodeID(fmt.Sprintf("%v", id)) +} + +func (o nodeID) String() string { + return string(o) +} + +func (o nodeID) Int() int { + return int(o.Int64()) +} + +func (o nodeID) Int64() int64 { + return util.ParseInt(string(o)) +} diff --git a/id/id_test.go b/id/id_test.go new file mode 100644 index 0000000..1659265 --- /dev/null +++ b/id/id_test.go @@ -0,0 +1,21 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package id + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNodeID(t *testing.T) { + i := "1234" + id := NewNodeID(i) + assert.Equal(t, i, id.String()) + v64 := int64(1234) + assert.Equal(t, v64, id.Int64()) + v := int(1234) + assert.Equal(t, v, id.Int()) +} diff --git a/internal/hoverfly/hoverfly.go b/internal/hoverfly/hoverfly.go new file mode 100644 index 0000000..b297d6d --- /dev/null +++ b/internal/hoverfly/hoverfly.go @@ -0,0 +1,201 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package hoverfly + +import ( + "fmt" + "io" + "os" + "os/exec" + "path" + "testing" + + "github.com/stretchr/testify/require" +) + +type M interface { + Run() (code int) +} + +type Hoverfly interface { + Run(m M) + + GetOutput() io.Writer + SetOutput(io.Writer) + + GetURL() string +} + +const ( + modeCapture = "capture" + modeSimulate = "simulate" +) + +var ( + exit = os.Exit + getwd = os.Getwd +) + +func Run(t testing.TB, name string) func() { + t.Helper() + + if !hoverflySingleton.active { + return func() {} + } + + httpProxyRestore := func() {} + if val, ok := os.LookupEnv("http_proxy"); ok { + httpProxyRestore = func() { + os.Setenv("http_proxy", val) + } + } + os.Setenv("http_proxy", hoverflySingleton.GetURL()) + + if *hoverflySingleton.mode == modeSimulate { + require.NoError(t, hoverflySingleton.loadSimulation(name)) + require.NoError(t, hoverflySingleton.hoverctl("mode", "simulate", "--matching-strategy", "first")) + return func() { httpProxyRestore() } + } + + if *hoverflySingleton.mode == modeCapture { + require.NoError(t, hoverflySingleton.hoverctl("mode", "capture", "--stateful")) + + return func() { + httpProxyRestore() + require.NoError(t, hoverflySingleton.saveSimulation(name)) + } + } + + panic(fmt.Errorf("unknown mode %s", *hoverflySingleton.mode)) +} + +func MainTest(m *testing.M) { + hoverflySingleton.Run(m) +} + +type hoverfly struct { + home string + httpProxy *string + output io.Writer + mode *string + active bool +} + +var hoverflySingleton = &hoverfly{} + +func GetSingleton() Hoverfly { + return hoverflySingleton +} + +func newHoverfly() Hoverfly { + return &hoverfly{} +} + +func (o *hoverfly) hoverctl(args ...string) error { + hoverctl := "hoverctl" + cmd := exec.Command(hoverctl, args...) + cmd.Env = append( + cmd.Env, + "HOME="+o.home, + ) + if output := o.GetOutput(); output != nil { + cmd.Stdout = output + cmd.Stderr = output + fmt.Fprintf(output, "%v: %s %v\n", cmd.Env, hoverctl, args) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf(hoverctl+"start: %w\n", err) + } + return nil +} + +func (o *hoverfly) getSimulationDir() (string, error) { + pwd, err := getwd() + if err != nil { + return "", err + } + p := path.Join(pwd, "hoverfly") + if err := os.MkdirAll(p, 0o755); err != nil { + return p, err + } + return p, nil +} + +func (o *hoverfly) getSimulationPath(name string) (string, error) { + dir, err := o.getSimulationDir() + if err != nil { + return "", err + } + return path.Join(dir, name+".json"), nil +} + +func (o *hoverfly) loadSimulation(name string) error { + simulation, err := o.getSimulationPath(name) + if err != nil { + return err + } + return o.hoverctl("import", simulation) +} + +func (o *hoverfly) saveSimulation(name string) error { + simulation, err := o.getSimulationPath(name) + if err != nil { + return err + } + return o.hoverctl("export", simulation) +} + +func (o *hoverfly) setup() error { + if val, ok := os.LookupEnv("HOVERFLY"); ok { + o.active = true + o.mode = &val + } else { + return nil + } + + home, err := os.MkdirTemp(os.TempDir(), "hoverfly") + if err != nil { + return fmt.Errorf("TempDir: %w", err) + } + o.home = home + return o.hoverctl("start") +} + +func (o *hoverfly) teardown() error { + if !o.active { + return nil + } + if err := o.hoverctl("logs"); err != nil { + return err + } + if err := o.hoverctl("stop"); err != nil { + return err + } + + if o.home != "" { + return os.RemoveAll(o.home) + } + return nil +} + +func (o *hoverfly) SetOutput(output io.Writer) { + o.output = output +} + +func (o *hoverfly) GetOutput() io.Writer { + return o.output +} + +func (o *hoverfly) GetURL() string { + return "http://localhost:8500" +} + +func (o *hoverfly) Run(m M) { + _ = o.setup() + exitCode := m.Run() + _ = o.teardown() + exit(exitCode) +} diff --git a/internal/hoverfly/hoverfly_test.go b/internal/hoverfly/hoverfly_test.go new file mode 100644 index 0000000..b4a2254 --- /dev/null +++ b/internal/hoverfly/hoverfly_test.go @@ -0,0 +1,139 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package hoverfly + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testNothing struct{} + +func (o testNothing) Run() (code int) { + return 456 +} + +type testSomething struct { + t *testing.T +} + +func (o *testSomething) Run() (code int) { + return 123 +} + +type testSimulate struct { + t *testing.T +} + +func (o testSimulate) Run() (code int) { + return 789 +} + +func verifyRequest(t *testing.T, serverURL, payload string) { + t.Helper() + + proxyString, ok := os.LookupEnv("http_proxy") + require.True(t, ok) + + proxyURL, err := url.Parse(proxyString) + require.NoError(t, err) + + client := http.Client{} + client.Transport = &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return http.ProxyURL(proxyURL)(req) + }, + } + + res, err := client.Get(serverURL) + require.NoError(t, err) + answer, err := io.ReadAll(res.Body) + res.Body.Close() + require.NoError(t, err) + assert.Equal(t, payload, string(answer)) +} + +func runServer(t *testing.T, payload string) (string, func()) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, payload) + })) + require.NotNil(t, ts) + return ts.URL, ts.Close +} + +func TestHoverfly(t *testing.T) { + hoverfly := newHoverfly() + var exitCode int + exit = func(code int) { + exitCode = code + } + saveDir := t.TempDir() + getwd = func() (string, error) { + return saveDir, nil + } + hoverfly.SetOutput(os.Stderr) + + // do not run hoverfly + os.Unsetenv("HOVERFLY") + hoverfly.Run(testNothing{}) + assert.Equal(t, 456, exitCode) + + // run hoverfly and set the proxy + os.Setenv("HOVERFLY", modeCapture) + something := testSomething{t: t} + hoverfly.Run(&something) + assert.Equal(t, 123, exitCode) + + testname := "thetest" + greeting := "Hello world" + serverURL, shutdownServer := runServer(t, greeting) + t.Run("capture requests and save them", func(t *testing.T) { + os.Setenv("HOVERFLY", modeCapture) + output := bytes.NewBuffer([]byte{}) + hoverflySingleton.SetOutput(output) + require.NoError(t, hoverflySingleton.setup()) + + cleanup := Run(t, testname) + assert.Contains(t, output.String(), "capture mode") + + verifyRequest(t, serverURL, greeting) + + cleanup() + simulationPath, err := hoverflySingleton.getSimulationPath(testname) + require.NoError(t, err) + simulation, err := os.ReadFile(simulationPath) + require.NoError(t, err) + assert.Contains(t, string(simulation), greeting) + + require.NoError(t, hoverflySingleton.teardown()) + }) + + shutdownServer() + + t.Run("read saved request and simulate them", func(t *testing.T) { + os.Setenv("HOVERFLY", modeSimulate) + output := bytes.NewBuffer([]byte{}) + hoverflySingleton.SetOutput(output) + require.NoError(t, hoverflySingleton.setup()) + cleanup := Run(t, testname) + assert.Contains(t, output.String(), "simulate mode") + + verifyRequest(t, serverURL, greeting) + + cleanup() + + require.NoError(t, hoverflySingleton.teardown()) + }) +} diff --git a/internal/hoverfly/main_test.go b/internal/hoverfly/main_test.go new file mode 100644 index 0000000..42a58a5 --- /dev/null +++ b/internal/hoverfly/main_test.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package hoverfly + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + if val, ok := os.LookupEnv("HOVERFLY"); ok { + defer func() { + os.Setenv("HOVERFLY", val) + }() + } + code := m.Run() + exit = os.Exit + getwd = os.Getwd + exit(code) +} diff --git a/kind/kind.go b/kind/kind.go new file mode 100644 index 0000000..5ca0562 --- /dev/null +++ b/kind/kind.go @@ -0,0 +1,12 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package kind + +type Kind string + +var ( + KindNil = Kind("") + KindRoot = Kind("") +) diff --git a/logger/context.go b/logger/context.go new file mode 100644 index 0000000..b81da55 --- /dev/null +++ b/logger/context.go @@ -0,0 +1,24 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "context" +) + +type key int + +const ( + loggerKey key = iota + 1 +) + +func ContextSetLogger(ctx context.Context, value Interface) context.Context { + return context.WithValue(ctx, loggerKey, value) +} + +func ContextGetLogger(ctx context.Context) Interface { + value, _ := ctx.Value(loggerKey).(Interface) + return value +} diff --git a/logger/context_test.go b/logger/context_test.go new file mode 100644 index 0000000..cf19f29 --- /dev/null +++ b/logger/context_test.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_LoggerContext(t *testing.T) { + ctx := context.Background() + assert.Nil(t, ContextGetLogger(ctx)) + logger := NewLogger() + ctx = ContextSetLogger(ctx, logger) + assert.EqualValues(t, logger, ContextGetLogger(ctx)) +} diff --git a/logger/interface.go b/logger/interface.go new file mode 100644 index 0000000..f12cbbc --- /dev/null +++ b/logger/interface.go @@ -0,0 +1,70 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "bytes" +) + +type Level int + +const ( + _ = iota + Message Level = iota + Trace Level = iota + Debug Level = iota + Info Level = iota + Warn Level = iota + Error Level = iota + Fatal Level = iota +) + +var toString = map[Level]string{ + Message: "message", + Trace: "trace", + Debug: "debug", + Info: "info", + Warn: "warn", + Error: "error", + Fatal: "fatal", +} + +func (l Level) String() string { + s, ok := toString[l] + if ok { + return s + } + return "undefined" +} + +type Fun func(string, ...any) + +type CaptureInterface interface { + Interface + GetBuffer() *bytes.Buffer + String() string + Reset() +} + +type MessageInterface interface { + Message(string, ...any) + Trace(string, ...any) + Debug(string, ...any) + Info(string, ...any) + Warn(string, ...any) + Error(string, ...any) + Fatal(string, ...any) + Log(skip int, level Level, message string, args ...any) +} + +type ManageInterface interface { + SetLevel(level Level) + GetLevel() Level +} + +type Interface interface { + MessageInterface + ManageInterface +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..aa5906b --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,230 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "runtime" + "strings" + "time" +) + +type logger struct { + levels map[Level]int + writer io.Writer + logger *slog.Logger + level Level + levelVar *slog.LevelVar +} + +var levels = map[Level]int{ + Trace: int(slog.LevelDebug) - 1, + Debug: int(slog.LevelDebug), + Info: int(slog.LevelInfo), + Warn: int(slog.LevelWarn), + Error: int(slog.LevelError), + Fatal: int(slog.LevelError + 1), +} + +var levelList = []Level{ + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +func MoreVerbose(level Level) *Level { + position := levelToPosition(level) - 1 + if position > 0 { + return &levelList[position] + } + return nil +} + +func LessVerbose(level Level) *Level { + position := levelToPosition(level) + 1 + if position < len(levelList) { + return &levelList[position] + } + return nil +} + +func levelToPosition(level Level) int { + var i int + for i = 0; i < len(levelList); i++ { + if levelList[i] == level { + break + } + } + if i >= len(levelList) { + panic(fmt.Errorf("unknown verbosity %v", level)) + } + return i +} + +func NewLogger() Interface { + l := &logger{} + l.Init() + return l +} + +type captureLogger struct { + logger + buf *bytes.Buffer +} + +func NewCaptureLogger() CaptureInterface { + l := &captureLogger{} + l.buf = new(bytes.Buffer) + l.writer = l.buf + l.Init() + return l +} + +func (o *captureLogger) String() string { + return o.buf.String() +} + +func (o *captureLogger) GetBuffer() *bytes.Buffer { + return o.buf +} + +func (o *captureLogger) Reset() { + o.buf.Reset() +} + +var filenamePrefix string + +func init() { + _, filename, _, _ := runtime.Caller(0) + filenamePrefix = strings.TrimSuffix(filename, "logger.go") + if filenamePrefix == filename { + // in case the source code file is moved, we can not trim the suffix, the code above should also be updated. + panic("unable to detect correct package prefix, please update file: " + filename) + } +} + +func (o *logger) Log(skip int, level Level, format string, args ...any) { + slogLevel := levels[level] + logger := o.logger + if !logger.Handler().Enabled(context.Background(), slog.Level(slogLevel)) { + return + } + var pcs [1]uintptr + runtime.Callers(2+skip, pcs[:]) // 2 is to skip [Callers(), Log()] + r := slog.NewRecord(time.Now(), slog.Level(slogLevel), fmt.Sprintf(format, args...), pcs[0]) + _ = logger.Handler().Handle(context.Background(), r) +} + +func (o *logger) Init() { + replace := func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + if a.Key == slog.SourceKey { + source := a.Value.Any().(*slog.Source) + var function string + dot := strings.LastIndex(source.Function, ".") + if dot >= 0 { + function = ":" + source.Function[dot+1:] + } + source.File = strings.TrimPrefix(source.File, projectPackagePrefix) + function + } + return a + } + o.levelVar = new(slog.LevelVar) + if o.writer == nil { + o.writer = os.Stdout + } + o.logger = slog.New(slog.NewTextHandler(o.writer, &slog.HandlerOptions{ + Level: o.levelVar, + AddSource: true, + ReplaceAttr: replace, + })) +} + +func (o *logger) SetLevel(level Level) { + o.level = level + o.levelVar.Set(slog.Level(levels[o.level])) +} + +func (o *logger) GetLevel() Level { + return o.level +} + +func (o *logger) SetWriter(out io.Writer) { + o.writer = out + o.Init() +} + +func (o *logger) Message(message string, args ...any) { o.Log(1, Info, message, args...) } +func (o *logger) Trace(message string, args ...any) { o.Log(1, Trace, message, args...) } +func (o *logger) Debug(message string, args ...any) { o.Log(1, Debug, message, args...) } +func (o *logger) Info(message string, args ...any) { o.Log(1, Info, message, args...) } +func (o *logger) Warn(message string, args ...any) { o.Log(1, Warn, message, args...) } +func (o *logger) Error(message string, args ...any) { o.Log(1, Error, message, args...) } +func (o *logger) Fatal(message string, args ...any) { o.Log(1, Fatal, message, args...) } + +type Logger struct { + logger Interface +} + +func (o *Logger) GetLogger() Interface { return o.logger } + +func (o *Logger) SetLogger(logger Interface) { o.logger = logger } + +func (o *Logger) SetLevel(level Level) { o.logger.SetLevel(level) } + +func (o *Logger) GetLevel() Level { return o.logger.GetLevel() } + +func (o *Logger) Message(message string, args ...any) { + o.logger.Log(1, Message, message, args...) +} + +func (o *Logger) Trace(message string, args ...any) { + o.logger.Log(1, Trace, message, args...) +} + +func (o *Logger) Debug(message string, args ...any) { + o.logger.Log(1, Debug, message, args...) +} + +func (o *Logger) Info(message string, args ...any) { + o.logger.Log(1, Info, message, args...) +} + +func (o *Logger) Warn(message string, args ...any) { + o.logger.Log(1, Warn, message, args...) +} + +func (o *Logger) Error(message string, args ...any) { + o.logger.Log(1, Error, message, args...) +} + +func (o *Logger) Fatal(message string, args ...any) { + o.logger.Log(1, Fatal, message, args...) +} + +func (o *Logger) Log(skip int, level Level, message string, args ...any) { + o.logger.Log(skip+1, level, message, args...) +} + +var projectPackagePrefix string + +func init() { + _, filename, _, _ := runtime.Caller(0) + projectPackagePrefix = strings.TrimSuffix(filename, "logger/logger.go") + if projectPackagePrefix == filename { + // in case the source code file is moved, we can not trim the suffix, the code above should also be updated. + panic("unable to detect correct package prefix, please update file: " + filename) + } +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 0000000..0ca85a3 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,29 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "testing" +) + +func Test_Logger(t *testing.T) { + for _, testCase := range []struct { + expected string + level Level + call func(MessageInterface, string, ...any) + }{ + {expected: "level=INFO ", level: Info, call: func(logger MessageInterface, message string, args ...any) { logger.Message(message, args...) }}, + {expected: "level=DEBUG-1 ", level: Trace, call: func(logger MessageInterface, message string, args ...any) { logger.Trace(message, args...) }}, + {expected: "level=DEBUG ", level: Debug, call: func(logger MessageInterface, message string, args ...any) { logger.Debug(message, args...) }}, + {expected: "level=INFO ", level: Info, call: func(logger MessageInterface, message string, args ...any) { logger.Info(message, args...) }}, + {expected: "level=WARN ", level: Warn, call: func(logger MessageInterface, message string, args ...any) { logger.Warn(message, args...) }}, + {expected: "level=ERROR ", level: Error, call: func(logger MessageInterface, message string, args ...any) { logger.Error(message, args...) }}, + {expected: "level=ERROR+1 ", level: Fatal, call: func(logger MessageInterface, message string, args ...any) { logger.Fatal(message, args...) }}, + } { + t.Run(testCase.expected+testCase.level.String(), func(t *testing.T) { + testLoggerCase(t, testCase.expected, testCase.level, testCase.call) + }) + } +} diff --git a/logger/logger_test_helper.go b/logger/logger_test_helper.go new file mode 100644 index 0000000..ac4cc14 --- /dev/null +++ b/logger/logger_test_helper.go @@ -0,0 +1,54 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testLoggerCase(t *testing.T, expected string, level Level, loggerFunc func(MessageInterface, string, ...any)) { + logger := NewCaptureLogger() + logger.SetLevel(level) + messages := []string{ + "MESSAGE HERE", + } + moreVerbose := MoreVerbose(level) + if moreVerbose != nil { + messages = append(messages, "MESSAGE MORE VERBOSE") + } + lessVerbose := LessVerbose(level) + if lessVerbose != nil { + messages = append(messages, "MESSAGE LESS VERBOSE") + } + + loggerFunc(logger, "MESSAGE %s", "HERE") + if moreVerbose != nil { + logger.Log(1, *moreVerbose, "MESSAGE %s", "MORE VERBOSE") + } + if lessVerbose != nil { + logger.Log(1, *lessVerbose, "MESSAGE %s", "LESS VERBOSE") + } + + i := 0 + assert.Contains(t, logger.String(), messages[i]) + if moreVerbose != nil { + i++ + require.True(t, len(messages) > i) + assert.NotContains(t, logger.String(), messages[i]) + } + if lessVerbose != nil { + i++ + require.True(t, len(messages) > i) + assert.Contains(t, logger.String(), messages[i]) + } + + assert.Contains(t, logger.String(), expected) + // verifies the call stack is calculated correctly and this is the + // reason for having this function in a separate file + assert.Contains(t, logger.String(), "logger_test.go") +} diff --git a/main/main.go b/main/main.go new file mode 100644 index 0000000..8cc5630 --- /dev/null +++ b/main/main.go @@ -0,0 +1,34 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "os" + + "code.forgejo.org/f3/gof3/v3/cmd" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/util" +) + +func main() { + run(os.Args) +} + +func run(args []string) { + ctx, cancel := cmd.InstallSignals() + defer cancel() + log := logger.NewLogger() + ctx = logger.ContextSetLogger(ctx, log) + + app := cmd.NewApp() + if err := app.Run(ctx, args); err != nil { + fmt.Printf("Failed to run F3 with %s\n%v\n", os.Args, err) + + if panicErr, ok := err.(util.PanicError); log.GetLevel() < logger.Info && ok { + fmt.Printf("Stack trace\n%s", panicErr.Stack()) + } + } +} diff --git a/options/auth/interface.go b/options/auth/interface.go new file mode 100644 index 0000000..bc6f2b6 --- /dev/null +++ b/options/auth/interface.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package auth + +type Interface interface { + SetUsername(username string) + GetUsername() string + + SetPassword(password string) + GetPassword() string + + SetToken(token string) + GetToken() string +} diff --git a/options/cli/interface.go b/options/cli/interface.go new file mode 100644 index 0000000..da29bbf --- /dev/null +++ b/options/cli/interface.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cli + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +type Interface interface { + GetFlags(prefix, category string) []cli.Flag + FromFlags(ctx context.Context, c *cli.Command, prefix string) +} diff --git a/options/cli/options.go b/options/cli/options.go new file mode 100644 index 0000000..1adddb4 --- /dev/null +++ b/options/cli/options.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package cli + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +type OptionsCLI struct{} + +func (o *OptionsCLI) FromFlags(ctx context.Context, c *cli.Command, prefix string) { +} + +func (o *OptionsCLI) GetFlags(prefix, category string) []cli.Flag { + return []cli.Flag{} +} diff --git a/options/factory.go b/options/factory.go new file mode 100644 index 0000000..7d7a3c9 --- /dev/null +++ b/options/factory.go @@ -0,0 +1,35 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +import ( + "fmt" + "strings" +) + +type ( + Factory func() Interface + Factories map[string]Factory +) + +var factories = make(Factories, 10) + +func GetFactories() Factories { + return factories +} + +func RegisterFactory(name string, factory Factory) { + name = strings.ToLower(name) + factories[name] = factory +} + +func GetFactory(name string) Factory { + name = strings.ToLower(name) + factory, ok := factories[name] + if !ok { + panic(fmt.Errorf("no options factory registered for %s", name)) + } + return factory +} diff --git a/options/http/implementation.go b/options/http/implementation.go new file mode 100644 index 0000000..ac83224 --- /dev/null +++ b/options/http/implementation.go @@ -0,0 +1,71 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package http + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/url" +) + +type Implementation struct { + newMigrationHTTPClient NewMigrationHTTPClientFun + skipTLSVerify *bool + proxy *string +} + +func (o *Implementation) GetNewMigrationHTTPClient() NewMigrationHTTPClientFun { + if o.newMigrationHTTPClient == nil { + return func() *http.Client { + transport := &http.Transport{} + if o.GetSkipTLSVerify() { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + if o.GetProxy() != "" { + proxyURL, err := url.Parse(o.GetProxy()) + if err != nil { + panic(fmt.Errorf("url.Parse %w", err)) + } + transport.Proxy = func(req *http.Request) (*url.URL, error) { + return http.ProxyURL(proxyURL)(req) + } + } + return &http.Client{ + Transport: transport, + } + } + } + return o.newMigrationHTTPClient +} + +func (o *Implementation) SetNewMigrationHTTPClient(fun NewMigrationHTTPClientFun) { + o.newMigrationHTTPClient = fun +} + +func (o *Implementation) GetSkipTLSVerify() bool { + if o.skipTLSVerify == nil { + return false + } + return *o.skipTLSVerify +} + +func (o *Implementation) SetSkipTLSVerify(skipTLSVerify bool) { + o.skipTLSVerify = &skipTLSVerify +} + +func (o *Implementation) GetProxy() string { + if o.proxy == nil { + return "" + } + return *o.proxy +} + +func (o *Implementation) SetProxy(proxy string) { + o.proxy = &proxy +} diff --git a/options/http/implementation_test.go b/options/http/implementation_test.go new file mode 100644 index 0000000..776576a --- /dev/null +++ b/options/http/implementation_test.go @@ -0,0 +1,41 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// Copyright twenty-panda +// SPDX-License-Identifier: MIT + +package http + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestImplementation(t *testing.T) { + i := Implementation{} + + { + newClient := i.GetNewMigrationHTTPClient() + assert.NotNil(t, newClient) + client := newClient() + assert.Nil(t, client.Transport.(*http.Transport).TLSClientConfig) + assert.Nil(t, client.Transport.(*http.Transport).Proxy) + } + + { + i.SetSkipTLSVerify(true) + proxy := "https://example.com" + i.SetProxy(proxy) + + newClient := i.GetNewMigrationHTTPClient() + assert.NotNil(t, newClient) + client := newClient() + assert.NotNil(t, client.Transport.(*http.Transport).TLSClientConfig) + assert.True(t, client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) + assert.NotNil(t, client.Transport.(*http.Transport).Proxy) + proxyURL, err := client.Transport.(*http.Transport).Proxy(nil) + assert.NoError(t, err) + assert.Equal(t, proxy, proxyURL.String()) + } +} diff --git a/options/http/interface.go b/options/http/interface.go new file mode 100644 index 0000000..4e111b9 --- /dev/null +++ b/options/http/interface.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package http + +import ( + "net/http" +) + +type NewMigrationHTTPClientFun func() *http.Client + +type Interface interface { + GetNewMigrationHTTPClient() NewMigrationHTTPClientFun + SetNewMigrationHTTPClient(fun NewMigrationHTTPClientFun) + GetSkipTLSVerify() bool + SetSkipTLSVerify(skipTLSVerify bool) + GetProxy() string + SetProxy(proxy string) +} diff --git a/options/interface.go b/options/interface.go new file mode 100644 index 0000000..2018bdd --- /dev/null +++ b/options/interface.go @@ -0,0 +1,26 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +import ( + "code.forgejo.org/f3/gof3/v3/options/auth" + "code.forgejo.org/f3/gof3/v3/options/cli" + "code.forgejo.org/f3/gof3/v3/options/http" + "code.forgejo.org/f3/gof3/v3/options/logger" + "code.forgejo.org/f3/gof3/v3/options/url" +) + +type ( + CLIInterface cli.Interface + LoggerInterface logger.Interface + URLInterface url.Interface + AuthInterface auth.Interface + HTTPInterface http.Interface +) + +type Interface interface { + GetName() string + SetName(string) +} diff --git a/options/logger/interface.go b/options/logger/interface.go new file mode 100644 index 0000000..5f72af0 --- /dev/null +++ b/options/logger/interface.go @@ -0,0 +1,14 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "code.forgejo.org/f3/gof3/v3/logger" +) + +type Interface interface { + SetLogger(logger.Interface) + GetLogger() logger.Interface +} diff --git a/options/logger/options.go b/options/logger/options.go new file mode 100644 index 0000000..b01cc80 --- /dev/null +++ b/options/logger/options.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package logger + +import ( + "code.forgejo.org/f3/gof3/v3/logger" +) + +type OptionsLogger struct { + logger logger.Interface +} + +func (o *OptionsLogger) GetLogger() logger.Interface { return o.logger } +func (o *OptionsLogger) SetLogger(logger logger.Interface) { o.logger = logger } diff --git a/options/options.go b/options/options.go new file mode 100644 index 0000000..3f44328 --- /dev/null +++ b/options/options.go @@ -0,0 +1,12 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package options + +type Options struct { + name string +} + +func (o *Options) GetName() string { return o.name } +func (o *Options) SetName(name string) { o.name = name } diff --git a/options/url/interface.go b/options/url/interface.go new file mode 100644 index 0000000..07b5737 --- /dev/null +++ b/options/url/interface.go @@ -0,0 +1,10 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package url + +type Interface interface { + SetURL(url string) + GetURL() string +} diff --git a/path/interface.go b/path/interface.go new file mode 100644 index 0000000..5e2c7ed --- /dev/null +++ b/path/interface.go @@ -0,0 +1,51 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package path + +import ( + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" +) + +type PathElementAllocator func() PathElement + +type PathElement interface { + SetID(id.NodeID) + GetID() id.NodeID + + SetMappedID(id.NodeID) + GetMappedID() id.NodeID + + SetKind(kind kind.Kind) + GetKind() kind.Kind + + ToFormat() f3.Interface +} + +type Path interface { + Length() int + PathString() PathString + PathMappedString() PathString + String() string + ReadablePathString() PathString + ReadableString() string + Append(child PathElement) Path + RemoveFirst() Path + PopFirst() (PathElement, Path) + Pop() (PathElement, Path) + RemoveLast() Path + Empty() bool + First() PathElement + Last() PathElement + All() []PathElement +} + +type PathString interface { + Empty() bool + Join() string + Append(element string) + Elements() []string +} diff --git a/path/operations.go b/path/operations.go new file mode 100644 index 0000000..bc6f849 --- /dev/null +++ b/path/operations.go @@ -0,0 +1,61 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package path + +import ( + "path/filepath" + "strings" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" +) + +func PathAbsoluteString(current, destination string) string { + if !strings.HasPrefix(destination, "/") { + return filepath.Clean(current + "/" + destination) + } + return filepath.Clean(destination) +} + +func PathAbsolute(allocator PathElementAllocator, current, destination string) Path { + return NewPathFromString(allocator, PathAbsoluteString(current, destination)) +} + +func PathRelativeString(current, destination string) string { + r, err := filepath.Rel(current, destination) + if err != nil { + panic(err) + } + return r +} + +func NewPathFromString(allocator PathElementAllocator, pathString string) Path { + pathString = filepath.Clean(pathString) + if pathString == "." { + e := allocator() + e.SetID(id.NewNodeID(".")) + return NewPath(e) + } + path := make([]PathElement, 0, 10) + if strings.HasPrefix(pathString, "/") { + root := allocator() + root.SetKind(kind.KindRoot) + path = append(path, root) + pathString = pathString[1:] + } + if pathString == "" { + return NewPath(path...) + } + for _, i := range strings.Split(pathString, "/") { + e := allocator() + e.SetID(id.NewNodeID(i)) + path = append(path, e) + } + return NewPath(path...) +} + +func NewPath(nodes ...PathElement) Path { + return Implementation(nodes) +} diff --git a/path/path.go b/path/path.go new file mode 100644 index 0000000..27c3a28 --- /dev/null +++ b/path/path.go @@ -0,0 +1,106 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package path + +import ( + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/id" +) + +type Implementation []PathElement + +func (o Implementation) PathString() PathString { + elements := NewPathString() + for i, e := range o { + eid := e.GetID() + // i == 0 is root and intentionally empty + if i > 0 && eid == id.NilID { + eid = id.NewNodeID("nothing") + } + elements.Append(eid.String()) + } + return elements +} + +func (o Implementation) PathMappedString() PathString { + elements := NewPathString() + for _, e := range o { + elements.Append(e.GetMappedID().String()) + } + return elements +} + +func (o Implementation) String() string { + return o.PathString().Join() +} + +var replacer = strings.NewReplacer( + "{", "%7B", + "}", "%7D", +) + +func (o Implementation) ReadablePathString() PathString { + elements := NewPathString() + if o.Length() > 0 { + elements.Append("") + for _, e := range o[1:] { + element := e.GetID().String() + if f := e.ToFormat(); f != nil { + name := f.GetName() + if element != name { + element = fmt.Sprintf("{%s/%s}", replacer.Replace(name), element) + } + } + elements.Append(element) + } + } + return elements +} + +func (o Implementation) ReadableString() string { + return o.ReadablePathString().Join() +} + +func (o Implementation) Length() int { + return len(o) +} + +func (o Implementation) Append(child PathElement) Path { + return append(o, child) +} + +func (o Implementation) PopFirst() (PathElement, Path) { + return o.First(), o.RemoveFirst() +} + +func (o Implementation) RemoveFirst() Path { + return o[1:] +} + +func (o Implementation) Pop() (PathElement, Path) { + return o.Last(), o.RemoveLast() +} + +func (o Implementation) RemoveLast() Path { + return o[:len(o)-1] +} + +func (o Implementation) Empty() bool { + return len(o) == 0 +} + +func (o Implementation) Last() PathElement { + return o[len(o)-1] +} + +func (o Implementation) First() PathElement { + return o[0] +} + +func (o Implementation) All() []PathElement { + return o +} diff --git a/path/path_test.go b/path/path_test.go new file mode 100644 index 0000000..cb23ac9 --- /dev/null +++ b/path/path_test.go @@ -0,0 +1,182 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package path + +import ( + "fmt" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + + "github.com/stretchr/testify/assert" +) + +type PathElementTest struct { + id id.NodeID + mapped id.NodeID + kind kind.Kind + name string +} + +func NewPathElementTest() PathElement { + return &PathElementTest{ + id: id.NilID, + mapped: id.NilID, + } +} + +func (o PathElementTest) GetID() id.NodeID { return o.id } +func (o *PathElementTest) SetID(i id.NodeID) { o.id = i } + +func (o PathElementTest) GetMappedID() id.NodeID { return o.mapped } +func (o *PathElementTest) SetMappedID(mapped id.NodeID) { o.mapped = mapped } + +func (o PathElementTest) GetKind() kind.Kind { return o.kind } +func (o *PathElementTest) SetKind(kind kind.Kind) { o.kind = kind } + +type PathF3Test struct { + f3.Common + name string +} + +func (o *PathF3Test) GetName() string { + if o.name != "" { + return o.name + } + return o.Common.GetName() +} + +func (o PathElementTest) ToFormat() f3.Interface { + return &PathF3Test{ + Common: f3.NewCommon(o.GetID().String()), + name: o.name, + } +} + +func TestNewPathFromString(t *testing.T) { + for _, pathString := range []string{"", ".", "A/.."} { + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 1, path.String()) + assert.EqualValues(t, ".", path.First().GetID()) + } + for _, pathString := range []string{"/", "/.", "/..", "/A/.."} { + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 1, path.String()) + assert.EqualValues(t, kind.KindRoot, path.First().GetKind(), path.String()) + assert.EqualValues(t, "", path.First().GetID()) + } + for _, pathString := range []string{"A", "A/.", "B/../A"} { + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 1, path.String()) + assert.NotEqualValues(t, kind.KindRoot, path.First().GetKind(), path.String()) + assert.EqualValues(t, "A", path.First().GetID()) + } + { + pathString := "/A" + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 2, path.String()) + assert.EqualValues(t, kind.KindRoot, path.First().GetKind(), path.String()) + notRoot := path.RemoveFirst() + assert.NotEqualValues(t, kind.KindRoot, notRoot.First().GetKind(), path.String()) + assert.EqualValues(t, pathString, path.String(), path.String()) + } + { + pathString := "A/B" + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 2, path.String()) + assert.NotEqualValues(t, kind.KindRoot, path.First().GetKind(), path.String()) + notRoot := path.RemoveFirst() + assert.NotEqualValues(t, kind.KindRoot, notRoot.First().GetKind(), path.String()) + assert.EqualValues(t, pathString, path.String(), path.String()) + } + { + pathString := "../B" + path := NewPathFromString(NewPathElementTest, pathString) + assert.False(t, path.Empty(), path.String()) + assert.Len(t, path, 2, path.String()) + assert.NotEqualValues(t, kind.KindRoot, path.First().GetKind(), path.String()) + notRoot := path.RemoveFirst() + assert.NotEqualValues(t, kind.KindRoot, notRoot.First().GetKind(), path.String()) + assert.EqualValues(t, pathString, path.String(), path.String()) + } +} + +func TestPathAbsoluteString(t *testing.T) { + assert.Equal(t, "/c", PathAbsoluteString("/a/b", "/c/d/..")) + assert.Equal(t, "/a/b/c", PathAbsoluteString("/a/b", "c")) + assert.Equal(t, "/a/b/c", PathAbsoluteString("/a/./b", "c/d/..")) +} + +func TestPathAbsolute(t *testing.T) { + assert.Equal(t, "/a/b/c", PathAbsolute(NewPathElementTest, "/a/b", "c").String()) +} + +func TestPathRelativeString(t *testing.T) { + assert.Equal(t, "../c", PathRelativeString("/a/b/d", "/a/b/c")) + assert.Panics(t, func() { PathRelativeString("/a/b/d", "") }) +} + +func TestPathString(t *testing.T) { + path := NewPathFromString(NewPathElementTest, "/a/b/c") + assert.Equal(t, "/a/b/c", path.PathString().Join()) + last, path := path.Pop() + path = path.Append(NewPathElementTest()) + path = path.Append(last) + assert.Equal(t, "/a/b/nothing/c", path.PathString().Join()) +} + +func TestPathReadable(t *testing.T) { + path := NewPathFromString(NewPathElementTest, "/a/b/c") + name := "N{AM}E" + path.Last().(*PathElementTest).name = name + assert.Equal(t, name, path.Last().ToFormat().GetName()) + expected := fmt.Sprintf("/a/b/{%s/c}", replacer.Replace(name)) + assert.Equal(t, expected, path.ReadablePathString().Join()) + assert.Equal(t, expected, path.ReadableString()) + + path = NewPathFromString(NewPathElementTest, "") + assert.Equal(t, "", path.ReadableString()) +} + +func TestPathMappedString(t *testing.T) { + path := NewPathFromString(NewPathElementTest, "/a/b") + for _, node := range path.All() { + node.SetMappedID(id.NewNodeID(node.GetID().String() + "M")) + } + assert.Equal(t, "M/aM/bM", path.PathMappedString().Join()) +} + +func TestPathMethods(t *testing.T) { + path := NewPathFromString(NewPathElementTest, "/a/b/c") + assert.Equal(t, "/a/b/c", path.String()) + assert.Equal(t, 4, path.Length()) + assert.Len(t, path.All(), 4) + assert.False(t, path.Empty()) + + first, path := path.PopFirst() + assert.Equal(t, "", first.GetID().String()) + assert.Equal(t, "a/b/c", path.String()) + + assert.Equal(t, "a", path.First().GetID().String()) + + assert.Equal(t, "c", path.Last().GetID().String()) + + firstRemoved := path.RemoveFirst() + assert.Equal(t, "b/c", firstRemoved.String()) + + last, lastRemoved := path.Pop() + assert.Equal(t, "c", last.GetID().String()) + assert.Equal(t, "a/b", lastRemoved.String()) + + lastRemoved = path.RemoveLast() + assert.Equal(t, "a/b", lastRemoved.String()) +} diff --git a/path/pathstring.go b/path/pathstring.go new file mode 100644 index 0000000..eba40dd --- /dev/null +++ b/path/pathstring.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package path + +import "strings" + +func NewPathString() PathString { + return &pathString{ + elements: make([]string, 0, 10), + } +} + +type pathString struct { + elements []string +} + +func (o pathString) Empty() bool { + return len(o.elements) == 0 +} + +func (o pathString) Join() string { + return strings.Join(o.elements, "/") +} + +func (o *pathString) Append(element string) { + o.elements = append(o.elements, element) +} + +func (o pathString) Elements() []string { + return o.elements +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..6039583 --- /dev/null +++ b/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "ignorePaths": [], + "extends": [ + "local>forgejo/renovate-config" + ], + "packageRules": [ + { + "matchPackageNames": ["gitlab/gitlab-ce"], + "versioning": "regex:^(?\\d+)(\\.(?\\d+))?(\\.(?\\d+))?-ce\\.(?.*)$" + } + ] +} diff --git a/tests/forgejo-app.ini b/tests/forgejo-app.ini new file mode 100644 index 0000000..284c883 --- /dev/null +++ b/tests/forgejo-app.ini @@ -0,0 +1,35 @@ +RUN_MODE = prod +WORK_PATH = forgejo-work-path + +[server] +APP_DATA_PATH = ${WORK_PATH}/data +DOMAIN = ${IP} +ROOT_URL=http://${IP}:3001 +HTTP_PORT = 3001 +SSH_LISTEN_PORT = 2201 + +[queue] +TYPE = immediate + +[queue.push_update] +TYPE = immediate + +[database] +DB_TYPE = sqlite3 +PATH = ${WORK_PATH}/forgejo.db + +[log] +MODE = file +LEVEL = trace +ROUTER = file + +[log.file] +FILE_NAME = forgejo.log + +[security] +INSTALL_LOCK = true + +[repository] +ENABLE_PUSH_CREATE_USER = true +ENABLE_PUSH_CREATE_ORG = true +DEFAULT_PUSH_CREATE_PRIVATE = false diff --git a/tests/gitea-app.ini b/tests/gitea-app.ini new file mode 100644 index 0000000..a011f17 --- /dev/null +++ b/tests/gitea-app.ini @@ -0,0 +1,31 @@ +RUN_MODE = prod +WORK_PATH = gitea-work-path + +[server] +APP_DATA_PATH = ${WORK_PATH}/data +DOMAIN = ${IP} +ROOT_URL=http://${IP}:3002/ +HTTP_PORT = 3002 +SSH_LISTEN_PORT = 2202 + +[queue] +TYPE = immediate + +[database] +DB_TYPE = sqlite3 +PATH = ${WORK_PATH}/forgejo.db + +[log] +MODE = file +LEVEL = trace +ROUTER = file + +[log.file] +FILE_NAME = forgejo.log + +[security] +INSTALL_LOCK = true + +[repository] +ENABLE_PUSH_CREATE_USER = true +DEFAULT_PUSH_CREATE_PRIVATE = false diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..f5a860e --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT + +PS4='${BASH_SOURCE[0]}:$LINENO: ${FUNCNAME[0]}: ' + +set -e + +TESTDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PATH=$TESTDIR/setup-forgejo:$PATH + +if test "$SCRATCHDIR"; then + mkdir -p $SCRATCHDIR +else + SCRATCHDIR=$(mktemp -d) + trap "rm -fr $SCRATCHDIR" EXIT +fi + +SELF_DIR="$TESTDIR/end-to-end" +SELF="$TESTDIR/end-to-end/end-to-end.sh" +FORGEJO_INSTANCE=https://code.forgejo.org +source $SELF_DIR/lib/lib.sh +source $SELF_DIR/upgrade/upgrade.sh + +GO=$(go env GOROOT)/bin/go + +HOVERFLY_VERSION=1.11.0 # renovate: datasource=github-tags depName=SpectoLabs/hoverfly +function hoverfly_version() { + echo ${HOVERFLY_VERSION} +} + +GITEA_VERSION=1.23 # renovate: datasource=docker depName=gitea/gitea +function gitea_version() { + echo ${GITEA_VERSION} +} + +FORGEJO_VERSION=10.0 # renovate: datasource=docker depName=data.forgejo.org/forgejo/forgejo +function forgejo_version() { + echo ${FORGEJO_VERSION} +} + +GITLAB_VERSION=17.11.1-ce.0 # renovate: datasource=docker depName=gitlab/gitlab-ce +function gitlab_version() { + echo ${GITLAB_VERSION} +} + +function test_run_coverage() { + local name=$1 + shift + + local coveragedir="$SCRATCHDIR/coverage-$name" + mkdir -p $coveragedir + rm -f $coveragedir/* + $GO test -cover -coverpkg code.forgejo.org/f3/gof3/... "$@" -args -test.gocoverdir=$coveragedir +} + +function test_merge_coverage() { + local coveragedirs=$(ls --directory --width=0 --format=commas $SCRATCHDIR/coverage-* | tr -d ' ') + rm -fr $SCRATCHDIR/merged* + mkdir -p $SCRATCHDIR/merged + $GO tool covdata merge -i=$coveragedirs -o $SCRATCHDIR/merged + $GO tool covdata textfmt -i=$SCRATCHDIR/merged -o $SCRATCHDIR/merged.out +} + +function test_display_coverage() { + test_merge_coverage + echo "Coverage percentage per package" + $GO tool covdata percent -i=$SCRATCHDIR/merged +} + +function run_forgejo() { + local version=$FORGEJO_VERSION + + echo "========= Forgejo driver compliance with version $version" + + stop_forgejo $TESTDIR/forgejo-app.ini + reset_forgejo $TESTDIR/forgejo-app.ini + start_forgejo $version $TESTDIR/forgejo-app.ini +} + +function test_forgejo() { + run_forgejo + GOF3_FORGEJO_HOST_PORT=$(get_host_port $TESTDIR/forgejo-app.ini) test_run_coverage forgejo code.forgejo.org/f3/gof3/v3/... +} + +function run_gitea() { + local version=$GITEA_VERSION + + echo "========= Gitea driver compliance with version $version" + + stop_forgejo $TESTDIR/gitea-app.ini + reset_forgejo $TESTDIR/gitea-app.ini + start_gitea $version $TESTDIR/gitea-app.ini +} + +function test_gitea() { + local token="$1" + GITEA_AUTHORIZATION_HEADER="Authorization: token $token" + + run_gitea + GOF3_GITEA_HOST_PORT=$(get_host_port $TESTDIR/gitea-app.ini) test_run_coverage gitea -run=TestF3Forge/gitea code.forgejo.org/f3/gof3/v3/tree/tests/... +} + +function run_gitlab() { + local version=gitlab/gitlab-ce:${GITLAB_VERSION} + + echo "========= GitLab driver compliance with version $version" + + stop_gitlab + start_gitlab $version +} + +function test_gitlab() { + run_gitlab + GOF3_GITLAB_HOST_PORT=$IP:8181 test_run_coverage gitlab -run=TestF3Forge/gitlab code.forgejo.org/f3/gof3/v3/tree/tests/... +} + +function run() { + test_forgejo + test_gitlab + test_display_coverage +} + +function prepare_container() { + $SUDO apt-get install -y -qq skopeo wget # replace with gitlab_install_dependencies + forgejo-dependencies.sh install_docker + forgejo-binary.sh ensure_user forgejo + mkdir -p /srv/forgejo-binaries + chown -R forgejo /srv + chmod -R +x /srv + clobber +} + +"${@:-run}" diff --git a/tree/f3/f3.go b/tree/f3/f3.go new file mode 100644 index 0000000..6758102 --- /dev/null +++ b/tree/f3/f3.go @@ -0,0 +1,124 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + objects_helper "code.forgejo.org/f3/gof3/v3/tree/f3/objects" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type treeF3 struct { + generic.Tree + + options options.Interface + objectsHelper objects_helper.Interface +} + +type setFunc func(parent path.Path, node generic.NodeInterface) + +type TreeInterface interface { + generic.TreeInterface + + IsContainer(kind.Kind) bool + NewFormat(kind kind.Kind) f3.Interface + CreateChild(ctx context.Context, pathString string, set setFunc) + GetObjectsHelper() objects_helper.Interface +} + +func (o *treeF3) GetChildrenKind(parentKind kind.Kind) kind.Kind { + if childrenKind, ok := childrenKind[parentKind]; ok { + return childrenKind + } + panic(fmt.Errorf("unexpected kind %s", parentKind)) +} + +func (o *treeF3) IsContainer(kind kind.Kind) bool { + if isContainer, ok := isContainer[kind]; ok { + return isContainer + } + return false +} + +func (o *treeF3) NewFormat(kind kind.Kind) f3.Interface { + return f3.New(string(kind)) +} + +func (o *treeF3) GetObjectsHelper() objects_helper.Interface { + return o.objectsHelper +} + +func (o *treeF3) CreateChild(ctx context.Context, pathString string, set setFunc) { + p := generic.NewPathFromString(pathString) + o.Apply(ctx, p, generic.NewApplyOptions(func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + o.Trace("%s %s | %T %s", parent.String(), node.GetID(), node, node.GetKind()) + child := o.Factory(ctx, o.GetChildrenKind(node.GetKind())) + child.SetParent(node) + childParentPath := parent.Append(node) + if set != nil { + set(childParentPath, child) + } + child.Upsert(ctx) + child.List(ctx) + })) +} + +func newTreeF3(ctx context.Context, opts options.Interface) generic.TreeInterface { + tree := &treeF3{} + tree.Init(tree, opts) + + tree.objectsHelper = objects_helper.NewObjectsHelper() + + tree.SetDriver(GetForgeFactory(opts.GetName())(tree, opts)) + + tree.Register(kind.KindRoot, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindForge}) + }) + tree.Register(KindForge, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindTopics, KindUsers, KindOrganizations}) + }) + tree.Register(KindUser, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindProjects}) + }) + tree.Register(KindOrganization, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindProjects}) + }) + tree.Register(KindProject, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindRepositories, KindLabels, KindMilestones, KindIssues, KindPullRequests, KindReleases}) + }) + tree.Register(KindIssue, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindComments, KindReactions}) + }) + tree.Register(KindRepository, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newRepositoryNode(ctx, tree) + }) + tree.Register(KindPullRequest, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindComments, KindReactions, KindReviews}) + }) + tree.Register(KindReview, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindReviewComments, KindReactions}) + }) + tree.Register(KindComment, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindReactions}) + }) + tree.Register(KindRelease, func(ctx context.Context, k kind.Kind) generic.NodeInterface { + return newFixedChildrenNode(ctx, tree, []kind.Kind{KindAssets}) + }) + + root := tree.Factory(ctx, kind.KindRoot) + tree.SetRoot(root) + + return tree +} + +func init() { + generic.RegisterFactory("f3", newTreeF3) +} diff --git a/tree/f3/fixed_children.go b/tree/f3/fixed_children.go new file mode 100644 index 0000000..1c1571a --- /dev/null +++ b/tree/f3/fixed_children.go @@ -0,0 +1,49 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type fixedChildrenNode struct { + generic.Node + + kinds []kind.Kind +} + +func (o *fixedChildrenNode) GetChildren() generic.ChildrenSlice { + children := generic.NewChildrenSlice(len(o.kinds)) + for _, kind := range o.kinds { + child := o.GetChild(id.NewNodeID(kind)) + children = append(children, child) + } + return children +} + +func (o *fixedChildrenNode) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + if page > 1 { + return generic.NewChildrenSlice(0) + } + return o.GetChildren() +} + +func newFixedChildrenNode(ctx context.Context, tree generic.TreeInterface, kinds []kind.Kind) generic.NodeInterface { + node := &fixedChildrenNode{} + node.Init(node) + node.kinds = kinds + for _, kind := range kinds { + id := id.NewNodeID(kind) + child := tree.Factory(ctx, kind) + child.SetID(id) + child.SetParent(node) + node.SetChild(child) + } + return node +} diff --git a/tree/f3/forge_factory.go b/tree/f3/forge_factory.go new file mode 100644 index 0000000..28e02a8 --- /dev/null +++ b/tree/f3/forge_factory.go @@ -0,0 +1,30 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +var forgeFactories = make(map[string]ForgeFactory, 10) + +type ForgeFactory func(tree generic.TreeInterface, options any) generic.TreeDriverInterface + +func RegisterForgeFactory(name string, factory ForgeFactory) { + name = strings.ToLower(name) + forgeFactories[name] = factory +} + +func GetForgeFactory(name string) ForgeFactory { + name = strings.ToLower(name) + factory, ok := forgeFactories[name] + if !ok { + panic(fmt.Errorf("no forge registered for %s", name)) + } + return factory +} diff --git a/tree/f3/helpers.go b/tree/f3/helpers.go new file mode 100644 index 0000000..91a23af --- /dev/null +++ b/tree/f3/helpers.go @@ -0,0 +1,181 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "fmt" + "slices" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" +) + +type ForgeDriverInterface interface { + SetNative(native any) + GetNativeID() string + GetHelper() any +} + +func ConvertToAny[T any](s ...T) []any { + a := make([]any, 0, len(s)) + for _, e := range s { + a = append(a, e) + } + return a +} + +func ConvertNativeChild(ctx context.Context, tree generic.TreeInterface, parent generic.NodeInterface, kind kind.Kind, nativeChild any) generic.NodeInterface { + child := tree.Factory(ctx, kind) + child.SetParent(parent) + childDriver := child.GetDriver().(ForgeDriverInterface) + childDriver.SetNative(nativeChild) + child.SetID(id.NewNodeID(childDriver.GetNativeID())) + return child +} + +func ConvertListed(ctx context.Context, node generic.NodeInterface, nativeChildren ...any) generic.ChildrenSlice { + children := generic.NewChildrenSlice(len(nativeChildren)) + + tree := node.GetTree() + f3Tree := tree.(TreeInterface) + kind := f3Tree.GetChildrenKind(node.GetKind()) + + for _, nativeChild := range nativeChildren { + children = append(children, ConvertNativeChild(ctx, tree, node, kind, nativeChild)) + } + return children +} + +func GetFirstNodeKind(node generic.NodeInterface, kind ...kind.Kind) generic.NodeInterface { + if slices.Contains(kind, node.GetKind()) { + return node + } + parent := node.GetParent() + if parent == generic.NilNode { + return generic.NilNode + } + return GetFirstNodeKind(parent, kind...) +} + +func GetFirstFormat[T f3.Interface](node generic.NodeInterface) T { + f := node.NewFormat() + switch f.(type) { + case T: + return node.ToFormat().(T) + } + parent := node.GetParent() + if parent == generic.NilNode { + panic(fmt.Errorf("no parent of the desired type")) + } + return GetFirstFormat[T](parent) +} + +func GetProject(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindProject) +} + +func GetProjectID(node generic.NodeInterface) int64 { + return util.ParseInt(GetProject(node).GetID().String()) +} + +func GetProjectName(node generic.NodeInterface) string { + return GetProject(node).ToFormat().(*f3.Project).Name +} + +func GetOwner(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindUser, KindOrganization) +} + +func GetOwnerID(node generic.NodeInterface) int64 { + return GetOwner(node).GetID().Int64() +} + +func GetReactionable(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindComment, KindIssue, KindPullRequest) +} + +func GetReactionableID(node generic.NodeInterface) int64 { + return GetReactionable(node).GetID().Int64() +} + +func GetCommentable(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindIssue, KindPullRequest) +} + +func GetCommentableID(node generic.NodeInterface) int64 { + return GetCommentable(node).GetID().Int64() +} + +func GetComment(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindComment, KindReviewComment) +} + +func GetCommentID(node generic.NodeInterface) int64 { + return GetComment(node).GetID().Int64() +} + +func GetRelease(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindRelease) +} + +func GetReleaseID(node generic.NodeInterface) int64 { + return GetRelease(node).GetID().Int64() +} + +func GetPullRequest(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindPullRequest) +} + +func GetPullRequestID(node generic.NodeInterface) int64 { + return GetPullRequest(node).GetID().Int64() +} + +func GetReview(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindReview) +} + +func GetReviewID(node generic.NodeInterface) int64 { + return GetReview(node).GetID().Int64() +} + +func GetReviewComment(node generic.NodeInterface) generic.NodeInterface { + return GetFirstNodeKind(node, KindReviewComment) +} + +func GetReviewCommentID(node generic.NodeInterface) int64 { + return GetReviewComment(node).GetID().Int64() +} + +func GetOwnerName(node generic.NodeInterface) string { + owner := GetOwner(node) + if owner == generic.NilNode { + panic(fmt.Errorf("no user or organization parent for %s", node)) + } + switch f := owner.ToFormat().(type) { + case *f3.User: + return f.UserName + case *f3.Organization: + return f.Name + default: + panic(fmt.Errorf("unexpected type %T", owner.ToFormat())) + } +} + +func GetUsernameFromID(ctx context.Context, tree TreeInterface, id int64) string { + var name string + getName := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + name = node.ToFormat().(*f3.User).UserName + } + p := NewUserPath(id) + if !tree.ApplyAndGet(ctx, p, generic.NewApplyOptions(getName)) { + panic(fmt.Errorf("%s not found", p)) + } + return name +} diff --git a/tree/f3/kind.go b/tree/f3/kind.go new file mode 100644 index 0000000..7dc56b4 --- /dev/null +++ b/tree/f3/kind.go @@ -0,0 +1,102 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/kind" +) + +const ( + KindAsset = f3.ResourceAsset + KindAssets = f3.ResourceAssets + KindComment = f3.ResourceComment + KindComments = f3.ResourceComments + KindForge = f3.ResourceForge + KindIssue = f3.ResourceIssue + KindIssues = f3.ResourceIssues + KindLabel = f3.ResourceLabel + KindLabels = f3.ResourceLabels + KindMilestone = f3.ResourceMilestone + KindMilestones = f3.ResourceMilestones + KindOrganization = f3.ResourceOrganization + KindOrganizations = f3.ResourceOrganizations + KindProject = f3.ResourceProject + KindProjects = f3.ResourceProjects + KindPullRequest = f3.ResourcePullRequest + KindPullRequests = f3.ResourcePullRequests + KindReaction = f3.ResourceReaction + KindReactions = f3.ResourceReactions + KindRelease = f3.ResourceRelease + KindReleases = f3.ResourceReleases + KindRepository = f3.ResourceRepository + KindRepositories = f3.ResourceRepositories + KindReview = f3.ResourceReview + KindReviews = f3.ResourceReviews + KindReviewComment = f3.ResourceReviewComment + KindReviewComments = f3.ResourceReviewComments + KindTopic = f3.ResourceTopic + KindTopics = f3.ResourceTopics + KindUser = f3.ResourceUser + KindUsers = f3.ResourceUsers +) + +var isContainer = map[kind.Kind]bool{ + kind.KindRoot: true, + KindAssets: true, + KindComments: true, + KindIssues: true, + KindLabels: true, + KindMilestones: true, + KindOrganizations: true, + KindProjects: true, + KindPullRequests: true, + KindReactions: true, + KindReleases: true, + KindRepositories: true, + KindReviews: true, + KindReviewComments: true, + KindTopics: true, + KindUsers: true, +} + +var childrenKind = map[kind.Kind]kind.Kind{ + kind.KindRoot: KindForge, + KindAssets: KindAsset, + KindComments: KindComment, + KindIssues: KindIssue, + KindLabels: KindLabel, + KindMilestones: KindMilestone, + KindOrganizations: KindOrganization, + KindProjects: KindProject, + KindPullRequests: KindPullRequest, + KindReactions: KindReaction, + KindReleases: KindRelease, + KindRepositories: KindRepository, + KindReviews: KindReview, + KindReviewComments: KindReviewComment, + KindTopics: KindTopic, + KindUsers: KindUser, +} + +var containerChildFixedID = map[kind.Kind]bool{ + kind.KindRoot: true, + KindAssets: false, + KindComments: false, + KindForge: true, + KindIssues: false, + KindLabels: false, + KindMilestones: false, + KindOrganizations: false, + KindProjects: false, + KindPullRequests: false, + KindReactions: false, + KindReleases: false, + KindRepositories: false, + KindReviewComments: false, + KindReviews: false, + KindTopics: false, + KindUsers: false, +} diff --git a/tree/f3/label.go b/tree/f3/label.go new file mode 100644 index 0000000..4a4f3e7 --- /dev/null +++ b/tree/f3/label.go @@ -0,0 +1,27 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" +) + +func NewLabelPathString[T any](projectPath string, id T) string { + return fmt.Sprintf("%s/labels/%v", projectPath, id) +} + +func NewLabelReference[T any](projectPath string, id T) *f3.Reference { + return f3.NewReference(NewLabelPathString(projectPath, id)) +} + +func NewIssueLabelReference[T any](id T) *f3.Reference { + return f3.NewReference(NewLabelPathString("../..", id)) +} + +func NewPullRequestLabelReference[T any](id T) *f3.Reference { + return f3.NewReference(NewLabelPathString("../..", id)) +} diff --git a/tree/f3/milestone.go b/tree/f3/milestone.go new file mode 100644 index 0000000..97253ec --- /dev/null +++ b/tree/f3/milestone.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" +) + +func NewMilestonePathString[T any](projectPath string, id T) string { + return fmt.Sprintf("%s/milestones/%v", projectPath, id) +} + +func NewMilestoneReference[T any](projectPath string, id T) *f3.Reference { + return f3.NewReference(NewMilestonePathString(projectPath, id)) +} + +func NewIssueMilestoneReference[T any](id T) *f3.Reference { + return f3.NewReference(NewMilestonePathString("../..", id)) +} diff --git a/tree/f3/objects/objects.go b/tree/f3/objects/objects.go new file mode 100644 index 0000000..92e9466 --- /dev/null +++ b/tree/f3/objects/objects.go @@ -0,0 +1,99 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package objects + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +type Interface interface { + Save(rc io.ReadCloser) (string, string) +} + +type helper struct { + dir *string +} + +func (o *helper) getDir() string { + if o.dir == nil { + dir, err := os.MkdirTemp("", "objectHelper") + if err != nil { + panic(err) + } + runtime.SetFinalizer(o, func(o *helper) { + err := os.RemoveAll(dir) + if err != nil { + panic(err) + } + }) + o.dir = &dir + } + return *o.dir +} + +func (o *helper) getPath(sha string) string { + return filepath.Join(o.getDir(), sha[0:2], sha[2:4], sha) +} + +func (o *helper) Save(rc io.ReadCloser) (string, string) { + tempFile, err := os.CreateTemp("", "object") + if err != nil { + panic(err) + } + tempRemoved := false + defer func() { + if !tempRemoved { + _ = os.Remove(tempFile.Name()) + } + }() + + // reader + defer rc.Close() + + // writer to file + f, err := os.OpenFile(tempFile.Name(), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + panic(err) + } + defer f.Close() + + // writer to sha256 + h := sha256.New() + + // copy reader to file & sha256 + w := io.MultiWriter(f, h) + if _, err := io.Copy(w, rc); err != nil { + panic(fmt.Errorf("while copying object: %w", err)) + } + + // finalize writer to sha256 + sha := hex.EncodeToString(h.Sum(nil)) + + // finalize writer to file + if err := tempFile.Close(); err != nil { + panic(err) + } + path := o.getPath(sha) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + panic(err) + } + if err := os.Rename(tempFile.Name(), path); err != nil { + panic(err) + } + + tempRemoved = true + + return sha, path +} + +func NewObjectsHelper() Interface { + return &helper{} +} diff --git a/tree/f3/objects/sha.go b/tree/f3/objects/sha.go new file mode 100644 index 0000000..9244882 --- /dev/null +++ b/tree/f3/objects/sha.go @@ -0,0 +1,68 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package objects + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "net/http" + + options_http "code.forgejo.org/f3/gof3/v3/options/http" +) + +type SHASetter interface { + SetSHA(sha string) +} + +func FuncReadURLAndSetSHA(newHTTPClient options_http.NewMigrationHTTPClientFun, url string, sha SHASetter) func() io.ReadCloser { + return func() io.ReadCloser { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + panic(err) + } + httpClient := newHTTPClient() + resp, err := httpClient.Do(req) + if err != nil { + panic(fmt.Errorf("while downloading %s %w", url, err)) + } + + return FuncReadAndSetSHA(resp.Body, sha)() + } +} + +type readAndSetSHA struct { + reader io.Reader + closer io.Closer + sha SHASetter + hasher hash.Hash +} + +func (o *readAndSetSHA) Read(b []byte) (n int, err error) { + return o.reader.Read(b) +} + +func (o *readAndSetSHA) Close() error { + o.sha.SetSHA(hex.EncodeToString(o.hasher.Sum(nil))) + return o.closer.Close() +} + +func newReadAndSetSHA(reader io.ReadCloser, sha SHASetter) *readAndSetSHA { + hasher := sha256.New() + return &readAndSetSHA{ + reader: io.TeeReader(reader, hasher), + closer: reader, + sha: sha, + hasher: hasher, + } +} + +func FuncReadAndSetSHA(reader io.ReadCloser, sha SHASetter) func() io.ReadCloser { + return func() io.ReadCloser { + return newReadAndSetSHA(reader, sha) + } +} diff --git a/tree/f3/objects/sha_test.go b/tree/f3/objects/sha_test.go new file mode 100644 index 0000000..920603a --- /dev/null +++ b/tree/f3/objects/sha_test.go @@ -0,0 +1,35 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package objects + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type sha string + +func (o *sha) SetSHA(s string) { + *o = sha(s) +} + +func (o *sha) GetSHA() string { + return string(*o) +} + +func Test_FuncReadAndSetSHA(t *testing.T) { + content := "CONTENT" + r := strings.NewReader(content) + s := new(sha) + f := FuncReadAndSetSHA(io.NopCloser(r), s)() + c, err := io.ReadAll(f) + require.NoError(t, err) + require.NoError(t, f.Close()) + require.Equal(t, "65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12", s.GetSHA()) + require.Equal(t, content, string(c)) +} diff --git a/tree/f3/organizations.go b/tree/f3/organizations.go new file mode 100644 index 0000000..a8cd8b1 --- /dev/null +++ b/tree/f3/organizations.go @@ -0,0 +1,11 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +var OrganizationsPath = generic.NewPathFromString("/forge/organizations") diff --git a/tree/f3/path.go b/tree/f3/path.go new file mode 100644 index 0000000..12ebd5d --- /dev/null +++ b/tree/f3/path.go @@ -0,0 +1,188 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + "slices" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + generic_path "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type Path interface { + NodeIDs() []id.NodeID + OwnerAndProjectID() (owner, project int64) + + AppendID(id string) Path + Ignore() Path + + generic_path.Path + Root() Path + + Forge() Path + SetForge() Path + + Assets() Path + SetAssets() Path + + Comments() Path + SetComments() Path + + Issues() Path + SetIssues() Path + + Labels() Path + SetLabels() Path + + Milestones() Path + SetMilestones() Path + + Owners() Path + SetOwners(owners kind.Kind) Path + + Organizations() Path + SetOrganizations() Path + + Projects() Path + SetProjects() Path + + PullRequests() Path + SetPullRequests() Path + + Reactions() Path + SetReactions() Path + + Releases() Path + SetReleases() Path + + Repositories() Path + SetRepositories() Path + + Reviews() Path + SetReviews() Path + + ReviewComments() Path + SetReviewComments() Path + + Topics() Path + SetTopics() Path + + Users() Path + SetUsers() Path +} + +type f3path struct { + generic_path.Implementation +} + +func (o f3path) popKind(k ...kind.Kind) Path { + firstKind := kind.Kind(o.First().(generic.NodeInterface).GetID().String()) + if !slices.Contains(k, firstKind) { + panic(fmt.Errorf("%s expected one of %s got %s", o, k, firstKind)) + } + return ToPath(o.RemoveFirst()) +} + +func (o f3path) NodeIDs() []id.NodeID { + nodeIDs := make([]id.NodeID, 0, o.Length()) + collectID := false + for _, node := range o.Root().All() { + if collectID { + nodeIDs = append(nodeIDs, node.(generic.NodeInterface).GetID()) + collectID = false + continue + } + kind := kind.Kind(node.(generic.NodeInterface).GetID().String()) + fixedID, ok := containerChildFixedID[kind] + if !ok { + panic(fmt.Errorf("%s unexpected kind %s", o.All(), kind)) + } + if !fixedID { + collectID = true + } + } + return nodeIDs +} + +func (o f3path) OwnerAndProjectID() (owner, project int64) { + nodeIDs := o.NodeIDs() + return nodeIDs[0].Int64(), nodeIDs[1].Int64() +} + +func (o f3path) Root() Path { return o.popKind(kind.Kind("")) } + +func (o f3path) Forge() Path { return o.popKind(KindForge) } +func (o f3path) SetForge() Path { return o.appendKind(KindForge) } + +func (o f3path) Assets() Path { return o.popKind(KindAssets) } +func (o f3path) SetAssets() Path { return o.appendKind(KindAssets) } + +func (o f3path) Comments() Path { return o.popKind(KindComments) } +func (o f3path) SetComments() Path { return o.appendKind(KindComments) } + +func (o f3path) Issues() Path { return o.popKind(KindIssues) } +func (o f3path) SetIssues() Path { return o.appendKind(KindIssues) } + +func (o f3path) Labels() Path { return o.popKind(KindLabels) } +func (o f3path) SetLabels() Path { return o.appendKind(KindLabels) } + +func (o f3path) Milestones() Path { return o.popKind(KindMilestones) } +func (o f3path) SetMilestones() Path { return o.appendKind(KindMilestones) } + +func (o f3path) Organizations() Path { return o.popKind(KindOrganizations) } +func (o f3path) SetOrganizations() Path { return o.appendKind(KindOrganizations) } + +func (o f3path) Projects() Path { return o.popKind(KindProjects) } +func (o f3path) SetProjects() Path { return o.appendKind(KindProjects) } + +func (o f3path) PullRequests() Path { return o.popKind(KindPullRequests) } +func (o f3path) SetPullRequests() Path { return o.appendKind(KindPullRequests) } + +func (o f3path) Reactions() Path { return o.popKind(KindReactions) } +func (o f3path) SetReactions() Path { return o.appendKind(KindReactions) } + +func (o f3path) Releases() Path { return o.popKind(KindReleases) } +func (o f3path) SetReleases() Path { return o.appendKind(KindReleases) } + +func (o f3path) Repositories() Path { return o.popKind(KindRepositories) } +func (o f3path) SetRepositories() Path { return o.appendKind(KindRepositories) } + +func (o f3path) Reviews() Path { return o.popKind(KindReviews) } +func (o f3path) SetReviews() Path { return o.appendKind(KindReviews) } + +func (o f3path) ReviewComments() Path { return o.popKind(KindReviewComments) } +func (o f3path) SetReviewComments() Path { return o.appendKind(KindReviewComments) } + +func (o f3path) Topics() Path { return o.popKind(KindTopics) } +func (o f3path) SetTopics() Path { return o.appendKind(KindTopics) } + +func (o f3path) Users() Path { return o.popKind(KindUsers) } +func (o f3path) SetUsers() Path { return o.appendKind(KindUsers) } + +func (o f3path) Owners() Path { return o.popKind(KindUsers, KindOrganizations) } +func (o f3path) SetOwners(owners kind.Kind) Path { return o.appendKind(owners) } + +func (o f3path) AppendID(id string) Path { + return ToPath(o.Append(generic.NewNodeFromID(id))) +} + +func (o f3path) appendKind(kind kind.Kind) Path { + return o.AppendID(string(kind)) +} + +func (o f3path) Ignore() Path { + return ToPath(o.RemoveFirst()) +} + +func ToPath(other generic_path.Path) Path { + return f3path{other.(generic_path.Implementation)} +} + +func NewPathFromString(pathString string) Path { + return ToPath(generic.NewPathFromString(pathString)) +} diff --git a/tree/f3/path_test.go b/tree/f3/path_test.go new file mode 100644 index 0000000..d6267e9 --- /dev/null +++ b/tree/f3/path_test.go @@ -0,0 +1,181 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "testing" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/util" + + "github.com/stretchr/testify/assert" +) + +func TestF3Path(t *testing.T) { + p := NewPathFromString("/") + p = p.SetForge() + { + p.Root().Forge() + } + nodeIDs := make([]id.NodeID, 0, 10) + { + p := p.SetOwners(KindUsers) + i := "1" + ownerID := util.ParseInt(i) + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Users().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + + { + p := p.SetProjects() + i := "2" + projectID := util.ParseInt(i) + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Users().Ignore().Projects().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + owner, project := p.OwnerAndProjectID() + assert.EqualValues(t, ownerID, owner) + assert.EqualValues(t, projectID, project) + } + } + + { + p := p.SetAssets() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Assets().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetComments() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Comments().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetIssues() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Issues().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetLabels() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Labels().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetMilestones() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Milestones().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetOrganizations() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Organizations().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetProjects() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Projects().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetPullRequests() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().PullRequests().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetReactions() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Reactions().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetReleases() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Releases().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetRepositories() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Repositories().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetReviews() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Reviews().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetReviewComments() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().ReviewComments().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetTopics() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Topics().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } + + { + p := p.SetUsers() + i := "something" + nodeIDs := append(nodeIDs, id.NewNodeID(i)) + p = p.AppendID(i) + assert.EqualValues(t, id.NewNodeID(i), p.Root().Forge().Users().First().(generic.NodeInterface).GetID()) + assert.EqualValues(t, nodeIDs, p.NodeIDs()) + } +} diff --git a/tree/f3/project.go b/tree/f3/project.go new file mode 100644 index 0000000..17403de --- /dev/null +++ b/tree/f3/project.go @@ -0,0 +1,29 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +func NewProjectPathString[U, P any](owners string, user U, project P) string { + return fmt.Sprintf("/forge/%s/%v/projects/%v", owners, user, project) +} + +func NewProjectReference[U, P any](owners string, user U, project P) *f3.Reference { + return f3.NewReference(NewProjectPathString(owners, user, project)) +} + +func ResolveProjectReference(ctx context.Context, tree generic.TreeInterface, r *f3.Reference) (string, string) { + project := tree.Find(generic.NewPathFromString(r.Get())) + if project == generic.NilNode { + panic(fmt.Errorf("%s not found", r.Get())) + } + return GetOwnerName(project), GetProjectName(project) +} diff --git a/tree/f3/pullrequest.go b/tree/f3/pullrequest.go new file mode 100644 index 0000000..88a6c07 --- /dev/null +++ b/tree/f3/pullrequest.go @@ -0,0 +1,47 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type PullRequestDriverInterface interface { + GetPullRequestHead() string + GetPullRequestRef() string + GetPullRequestPushRefs() []string +} + +type PullRequestNodeDriverProxyInterface interface { + PullRequestDriverInterface +} + +type PullRequestNodeInterface interface { + generic.NodeInterface + PullRequestNodeDriverProxyInterface +} + +type pullRequestNode struct { + generic.Node +} + +func (o *pullRequestNode) GetPullRequestHead() string { + return o.GetDriver().(PullRequestDriverInterface).GetPullRequestHead() +} + +func (o *pullRequestNode) GetPullRequestRef() string { + return o.GetDriver().(PullRequestDriverInterface).GetPullRequestRef() +} + +func (o *pullRequestNode) GetPullRequestPushRefs() []string { + return o.GetDriver().(PullRequestDriverInterface).GetPullRequestPushRefs() +} + +func newPullRequestNode(ctx context.Context, tree generic.TreeInterface) generic.NodeInterface { + node := &pullRequestNode{} + return node.Init(node) +} diff --git a/tree/f3/repository.go b/tree/f3/repository.go new file mode 100644 index 0000000..605cc9b --- /dev/null +++ b/tree/f3/repository.go @@ -0,0 +1,66 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type RepositoryDriverInterface interface { + GetRepositoryURL() string + GetRepositoryPushURL() string + GetRepositoryInternalRefs() []string +} + +type RepositoryNodeDriverProxyInterface interface { + RepositoryDriverInterface +} + +type RepositoryNodeInterface interface { + generic.NodeInterface + RepositoryNodeDriverProxyInterface +} + +type repositoryNode struct { + generic.Node +} + +func (o *repositoryNode) GetRepositoryURL() string { + return o.GetDriver().(RepositoryDriverInterface).GetRepositoryURL() +} + +func (o *repositoryNode) GetRepositoryPushURL() string { + return o.GetDriver().(RepositoryDriverInterface).GetRepositoryPushURL() +} + +func (o *repositoryNode) GetRepositoryInternalRefs() []string { + return o.GetDriver().(RepositoryDriverInterface).GetRepositoryInternalRefs() +} + +func newRepositoryNode(ctx context.Context, tree generic.TreeInterface) generic.NodeInterface { + node := &repositoryNode{} + return node.Init(node) +} + +func NewRepositoryPath[T, U any](owners string, owner T, project U) path.Path { + return generic.NewPathFromString(NewRepositoryPathString(owners, owner, project)) +} + +func NewRepositoryPathString[T, U any](owners string, owner T, project U) string { + return fmt.Sprintf("%s/%v/projects/%v/repositories/vcs", owners, owner, project) +} + +func NewRepositoryReference[T, U any](owners string, owner T, project U) *f3.Reference { + return f3.NewReference(NewRepositoryPathString(owners, owner, project)) +} + +func NewPullRequestSameRepositoryReference() *f3.Reference { + return f3.NewReference("../../repositories/vcs") +} diff --git a/tree/f3/topic.go b/tree/f3/topic.go new file mode 100644 index 0000000..1ed4e7f --- /dev/null +++ b/tree/f3/topic.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" +) + +func NewTopicPath[T any](id T) Path { + return NewPathFromString(NewTopicPathString(id)) +} + +func NewTopicPathString[T any](id T) string { + return fmt.Sprintf("/forge/topics/%v", id) +} + +func NewTopicReference[T any](id T) *f3.Reference { + return f3.NewReference(NewTopicPathString(id)) +} diff --git a/tree/f3/topics.go b/tree/f3/topics.go new file mode 100644 index 0000000..952061f --- /dev/null +++ b/tree/f3/topics.go @@ -0,0 +1,11 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +var TopicsPath = generic.NewPathFromString("/forge/topics") diff --git a/tree/f3/user.go b/tree/f3/user.go new file mode 100644 index 0000000..3a0d7b6 --- /dev/null +++ b/tree/f3/user.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" +) + +func NewUserPath[T any](id T) Path { + return NewPathFromString(NewUserPathString(id)) +} + +func NewUserPathString[T any](id T) string { + return fmt.Sprintf("/forge/users/%v", id) +} + +func NewUserReference[T any](id T) *f3.Reference { + return f3.NewReference(NewUserPathString(id)) +} diff --git a/tree/f3/users.go b/tree/f3/users.go new file mode 100644 index 0000000..d2fb410 --- /dev/null +++ b/tree/f3/users.go @@ -0,0 +1,11 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +var UsersPath = generic.NewPathFromString("/forge/users") diff --git a/tree/generic/compare.go b/tree/generic/compare.go new file mode 100644 index 0000000..f5bf350 --- /dev/null +++ b/tree/generic/compare.go @@ -0,0 +1,72 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/path" +) + +func NodeCompare(ctx context.Context, a, b NodeInterface) bool { + a.Trace("a '%s' | b '%s'", a.GetCurrentPath().ReadableString(), b.GetCurrentPath().ReadableString()) + + if a.GetKind() != b.GetKind() { + a.Trace("kind is different a = %s | b = %s", a.GetKind(), b.GetKind()) + return false + } + + if diff := a.GetTree().Diff(a, b); diff != "" { + a.Trace("difference %s", diff) + return false + } + + aChildren := a.GetNodeChildren() + bChildren := b.GetNodeChildren() + aLen := len(aChildren) + bLen := len(bChildren) + if aLen != bLen { + a.Trace("children count is different a = %d | b = %d", aLen, bLen) + return false + } + + for aID, aChild := range aChildren { + bID := aChild.GetID() + mappedID := aChild.GetMappedID() + if mappedID != id.NilID { + bID = mappedID + } + bChild, ok := bChildren[bID] + if !ok { + a.Trace("there is no child in 'b' with id %s matching the child in 'a' with id %s", bID, aID) + return false + } + + if !NodeCompare(ctx, aChild, bChild) { + return false + } + } + + return true +} + +func TreeCompare(ctx context.Context, aTree TreeInterface, aPath path.Path, bTree TreeInterface, bPath path.Path) bool { + aTree.Trace("'%s' => '%s'", aPath.ReadableString(), bPath.ReadableString()) + + a := aTree.Find(aPath) + if a == NilNode { + aTree.Trace("a does not have %s", aPath.ReadableString()) + return false + } + + b := bTree.Find(bPath) + if b == NilNode { + aTree.Trace("b does not have %s", bPath.ReadableString()) + return false + } + + return NodeCompare(ctx, a, b) +} diff --git a/tree/generic/compare_test.go b/tree/generic/compare_test.go new file mode 100644 index 0000000..83015fa --- /dev/null +++ b/tree/generic/compare_test.go @@ -0,0 +1,226 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/path" + + "github.com/stretchr/testify/assert" +) + +type compareFormat struct { + f3.Common + V int +} + +func (o *compareFormat) Clone() f3.Interface { + clone := &compareFormat{} + *clone = *o + return clone +} + +type compareNodeDriver struct { + NullDriver + v int + ID string +} + +func (o *compareNodeDriver) NewFormat() f3.Interface { + return &compareFormat{ + Common: f3.NewCommon(o.ID), + } +} + +func (o *compareNodeDriver) ToFormat() f3.Interface { + f := o.NewFormat().(*compareFormat) + f.V = o.v + return f +} + +func (o *compareNodeDriver) FromFormat(f f3.Interface) { + fc := f.(*compareFormat) + o.v = fc.V + o.ID = fc.GetID() +} + +func newCompareNodeDriver() NodeDriverInterface { + return &compareNodeDriver{} +} + +type compareTreeDriver struct { + NullTreeDriver +} + +func newCompareTreeDriver() TreeDriverInterface { + return &compareTreeDriver{} +} + +func (o *compareTreeDriver) Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface { + d := newCompareNodeDriver() + d.SetTreeDriver(o) + return d +} + +func newCompareTree() TreeInterface { + tree := &testTree{} + tree.Init(tree, newTestOptions()) + tree.Trace("init done") + tree.SetDriver(newCompareTreeDriver()) + tree.Register(kindCompareNode, func(ctx context.Context, kind kind.Kind) NodeInterface { + node := &compareNode{} + return node.Init(node) + }) + return tree +} + +var kindCompareNode = kind.Kind("compare") + +type compareNode struct { + Node +} + +func TestTreeCompare(t *testing.T) { + tree := newTestTree() + log := logger.NewCaptureLogger() + log.SetLevel(logger.Trace) + tree.SetLogger(log) + + root := tree.Factory(context.Background(), kindTestNodeLevelOne) + tree.SetRoot(root) + assert.True(t, TreeCompare(context.Background(), tree, path.NewPathFromString(NewElementNode, ""), tree, path.NewPathFromString(NewElementNode, ""))) + + log.Reset() + assert.False(t, TreeCompare(context.Background(), tree, path.NewPathFromString(NewElementNode, "/notfound"), tree, path.NewPathFromString(NewElementNode, ""))) + assert.Contains(t, log.String(), "a does not have /notfound") + + log.Reset() + assert.False(t, TreeCompare(context.Background(), tree, path.NewPathFromString(NewElementNode, "/"), tree, path.NewPathFromString(NewElementNode, "/notfound"))) + assert.Contains(t, log.String(), "b does not have /notfound") +} + +func TestNodeCompare(t *testing.T) { + log := logger.NewCaptureLogger() + log.SetLevel(logger.Trace) + + treeA := newCompareTree() + treeA.SetLogger(log) + + treeB := newCompareTree() + treeB.SetLogger(log) + + nodeA := treeA.Factory(context.Background(), kindCompareNode) + nodeA.SetID(id.NewNodeID("root")) + assert.True(t, NodeCompare(context.Background(), nodeA, nodeA)) + + t.Run("different kind", func(t *testing.T) { + log.Reset() + other := treeB.Factory(context.Background(), kindCompareNode) + other.SetKind("other") + assert.False(t, NodeCompare(context.Background(), nodeA, other)) + assert.Contains(t, log.String(), "kind is different") + }) + + t.Run("difference", func(t *testing.T) { + log.Reset() + other := treeB.Factory(context.Background(), kindCompareNode) + other.FromFormat(&compareFormat{V: 123456}) + + assert.False(t, NodeCompare(context.Background(), nodeA, other)) + assert.Contains(t, log.String(), "difference") + assert.Contains(t, log.String(), "123456") + }) + + t.Run("children count", func(t *testing.T) { + log.Reset() + other := treeB.Factory(context.Background(), kindCompareNode) + other.SetChild(treeB.Factory(context.Background(), kindCompareNode)) + + assert.False(t, NodeCompare(context.Background(), nodeA, other)) + assert.Contains(t, log.String(), "children count") + }) + + nodeAA := treeA.Factory(context.Background(), kindCompareNode) + nodeAA.SetID(id.NewNodeID("levelone")) + nodeA.SetChild(nodeAA) + + nodeB := treeB.Factory(context.Background(), kindCompareNode) + nodeB.SetID(id.NewNodeID("root")) + + t.Run("children are the same", func(t *testing.T) { + nodeBB := treeB.Factory(context.Background(), kindCompareNode) + nodeBB.SetID(id.NewNodeID("levelone")) + nodeB.SetChild(nodeBB) + + assert.True(t, NodeCompare(context.Background(), nodeA, nodeB)) + + nodeB.DeleteChild(nodeBB.GetID()) + }) + + t.Run("children have different IDs", func(t *testing.T) { + log.Reset() + + nodeBB := treeB.Factory(context.Background(), kindCompareNode) + nodeBB.SetID(id.NewNodeID("SOMETHINGELSE")) + nodeB.SetChild(nodeBB) + + assert.False(t, NodeCompare(context.Background(), nodeA, nodeB)) + assert.Contains(t, log.String(), "id levelone matching the child") + + nodeB.DeleteChild(nodeBB.GetID()) + }) + + t.Run("children have different content", func(t *testing.T) { + log.Reset() + + nodeBB := treeB.Factory(context.Background(), kindCompareNode) + nodeBB.FromFormat(&compareFormat{V: 12345678}) + nodeBB.SetID(id.NewNodeID("levelone")) + nodeB.SetChild(nodeBB) + + assert.False(t, NodeCompare(context.Background(), nodeA, nodeB)) + assert.Contains(t, log.String(), "difference") + assert.Contains(t, log.String(), "12345678") + + nodeB.DeleteChild(nodeBB.GetID()) + }) + + t.Run("children are the same because of their mapped ID", func(t *testing.T) { + log.Reset() + + nodeBB := treeB.Factory(context.Background(), kindCompareNode) + nodeBB.SetID(id.NewNodeID("REMAPPEDID")) + nodeB.SetChild(nodeBB) + + nodeAA.SetMappedID(id.NewNodeID("REMAPPEDID")) + + assert.True(t, NodeCompare(context.Background(), nodeA, nodeB)) + + nodeB.DeleteChild(nodeBB.GetID()) + nodeAA.SetMappedID(id.NilID) + }) + + t.Run("children are different because of their mapped ID", func(t *testing.T) { + log.Reset() + + nodeBB := treeB.Factory(context.Background(), kindCompareNode) + nodeBB.SetID(id.NewNodeID("levelone")) + nodeB.SetChild(nodeBB) + + nodeAA.SetMappedID(id.NewNodeID("REMAPPEDID")) + + assert.False(t, NodeCompare(context.Background(), nodeA, nodeB)) + assert.Contains(t, log.String(), "id REMAPPEDID matching the child") + + nodeB.DeleteChild(nodeBB.GetID()) + nodeAA.SetMappedID(id.NilID) + }) +} diff --git a/tree/generic/driver_node.go b/tree/generic/driver_node.go new file mode 100644 index 0000000..5eb176e --- /dev/null +++ b/tree/generic/driver_node.go @@ -0,0 +1,107 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "errors" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/logger" +) + +type NodeDriverInterface interface { + logger.MessageInterface + + MapIDInterface + + IsNull() bool + + GetNode() NodeInterface + SetNode(NodeInterface) + + SetTreeDriver(treeDriver TreeDriverInterface) + GetTreeDriver() TreeDriverInterface + + ListPage(context.Context, int) ChildrenSlice + GetIDFromName(context.Context, string) id.NodeID + + Equals(context.Context, NodeInterface) bool + + Get(context.Context) bool + Put(context.Context) id.NodeID + Patch(context.Context) + Delete(context.Context) + + NewFormat() f3.Interface + FromFormat(f3.Interface) + ToFormat() f3.Interface + + LookupMappedID(id.NodeID) id.NodeID + + String() string +} + +type NullDriver struct { + logger.Logger + + node NodeInterface + mapped id.NodeID + treeDriver TreeDriverInterface +} + +func NewNullDriver() NodeDriverInterface { + return &NullDriver{} +} + +func (o *NullDriver) IsNull() bool { return true } +func (o *NullDriver) SetNode(node NodeInterface) { o.node = node } +func (o *NullDriver) GetNode() NodeInterface { return o.node } +func (o *NullDriver) GetMappedID() id.NodeID { + if o.mapped == nil { + return id.NilID + } + return o.mapped +} +func (o *NullDriver) SetMappedID(mapped id.NodeID) { o.mapped = mapped } +func (o *NullDriver) SetTreeDriver(treeDriver TreeDriverInterface) { + o.treeDriver = treeDriver + if treeDriver != nil { + o.SetLogger(treeDriver) + } +} +func (o *NullDriver) GetTreeDriver() TreeDriverInterface { return o.treeDriver } +func (o *NullDriver) ListPage(context.Context, int) ChildrenSlice { + panic(fmt.Errorf("ListPage %s", o.GetNode().GetKind())) +} + +func (o *NullDriver) GetIDFromName(ctx context.Context, name string) id.NodeID { + panic(errors.New(name)) +} + +func (o *NullDriver) Equals(context.Context, NodeInterface) bool { + panic(fmt.Errorf("Equals %s", o.GetNode().GetKind())) +} +func (o *NullDriver) Get(context.Context) bool { panic(fmt.Errorf("Get %s", o.GetNode().GetKind())) } +func (o *NullDriver) Put(context.Context) id.NodeID { + panic(fmt.Errorf("Put %s", o.GetNode().GetKind())) +} + +func (o *NullDriver) Patch(context.Context) { + panic(fmt.Errorf("Patch %s", o.GetNode().GetKind())) +} +func (o *NullDriver) Delete(context.Context) { panic(fmt.Errorf("Delete %s", o.GetNode().GetKind())) } +func (o *NullDriver) NewFormat() f3.Interface { + panic(fmt.Errorf("NewFormat %s", o.GetNode().GetKind())) +} + +func (o *NullDriver) FromFormat(f3.Interface) { + panic(fmt.Errorf("FromFormat %s", o.GetNode().GetKind())) +} +func (o *NullDriver) ToFormat() f3.Interface { panic(fmt.Errorf("ToFormat %s", o.GetNode().GetKind())) } +func (o *NullDriver) LookupMappedID(id.NodeID) id.NodeID { return id.NilID } +func (o *NullDriver) String() string { return "" } diff --git a/tree/generic/driver_tree.go b/tree/generic/driver_tree.go new file mode 100644 index 0000000..7dbb40b --- /dev/null +++ b/tree/generic/driver_tree.go @@ -0,0 +1,78 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + + "github.com/google/go-cmp/cmp" +) + +type TreeDriverInterface interface { + logger.Interface + + GetTree() TreeInterface + SetTree(TreeInterface) + + GetPageSize() int + SetPageSize(int) + + AllocateID() bool + + Init() + + Diff(a, b NodeDriverInterface) string + + Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface +} + +var DefaultPageSize = int(25) + +type NullTreeDriver struct { + logger.Logger + + tree TreeInterface + + pageSize int +} + +func (o *NullTreeDriver) Init() { + o.pageSize = DefaultPageSize +} + +func NewNullTreeDriver() TreeDriverInterface { + d := &NullTreeDriver{} + d.Init() + return d +} + +func (o *NullTreeDriver) SetTree(tree TreeInterface) { + o.tree = tree + if tree != nil { + o.SetLogger(tree) + } +} +func (o *NullTreeDriver) GetTree() TreeInterface { return o.tree } + +func (o *NullTreeDriver) GetPageSize() int { return o.pageSize } +func (o *NullTreeDriver) SetPageSize(pageSize int) { o.pageSize = pageSize } + +func (o *NullTreeDriver) AllocateID() bool { return true } + +func (o *NullTreeDriver) Diff(a, b NodeDriverInterface) string { + aFormat := a.ToFormat() + bFormat := b.ToFormat() + + return cmp.Diff(aFormat, bFormat) +} + +func (o *NullTreeDriver) Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface { + d := NewNullDriver() + d.SetTreeDriver(o) + return d +} diff --git a/tree/generic/factory.go b/tree/generic/factory.go new file mode 100644 index 0000000..cf329b3 --- /dev/null +++ b/tree/generic/factory.go @@ -0,0 +1,31 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "fmt" + "strings" + + "code.forgejo.org/f3/gof3/v3/options" +) + +var treeFactories = make(map[string]TreeFactory, 10) + +type TreeFactory func(ctx context.Context, opts options.Interface) TreeInterface + +func RegisterFactory(name string, factory TreeFactory) { + name = strings.ToLower(name) + treeFactories[name] = factory +} + +func GetFactory(name string) TreeFactory { + name = strings.ToLower(name) + factory, ok := treeFactories[name] + if !ok { + panic(fmt.Errorf("no factory registered for %s", name)) + } + return factory +} diff --git a/tree/generic/interface_node.go b/tree/generic/interface_node.go new file mode 100644 index 0000000..ba9940d --- /dev/null +++ b/tree/generic/interface_node.go @@ -0,0 +1,101 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/path" +) + +type NodeAccessorsInterface interface { + SetIsNil(bool) + GetIsNil() bool + + SetIsSync(bool) + GetIsSync() bool + + GetParent() NodeInterface + SetParent(NodeInterface) + + GetKind() kind.Kind + SetKind(kind.Kind) + + GetID() id.NodeID + SetID(id.NodeID) + + GetTree() TreeInterface + SetTree(TreeInterface) + + GetNodeChildren() NodeChildren + GetChildren() ChildrenSlice + SetChildren(NodeChildren) + + GetDriver() NodeDriverInterface + SetDriver(NodeDriverInterface) +} + +type NodeTreeInterface interface { + GetChild(id.NodeID) NodeInterface + GetIDFromName(context.Context, string) id.NodeID + SetChild(NodeInterface) NodeInterface + DeleteChild(id.NodeID) NodeInterface + CreateChild(context.Context) NodeInterface + + MustFind(path.Path) NodeInterface + Find(path.Path) NodeInterface + FindAndGet(context.Context, path.Path) NodeInterface + + GetCurrentPath() path.Path + + Walk(ctx context.Context, parent path.Path, options *WalkOptions) + WalkAndGet(ctx context.Context, parent path.Path, options *WalkOptions) + Apply(ctx context.Context, parent, path path.Path, options *ApplyOptions) bool + ApplyAndGet(ctx context.Context, path path.Path, options *ApplyOptions) bool + + List(context.Context) ChildrenSlice +} + +type NodeDriverProxyInterface interface { + MapIDInterface + ListPage(context.Context, int) ChildrenSlice + GetIDFromName(context.Context, string) id.NodeID + + Equals(context.Context, NodeInterface) bool + + Get(context.Context) NodeInterface + Upsert(context.Context) NodeInterface + Delete(context.Context) NodeInterface + + NewFormat() f3.Interface + FromFormat(f3.Interface) NodeInterface + ToFormat() f3.Interface + + LookupMappedID(id.NodeID) id.NodeID +} + +type MapIDInterface interface { + GetMappedID() id.NodeID + SetMappedID(id.NodeID) +} + +type NodeInterface interface { + logger.MessageInterface + NodeAccessorsInterface + NodeTreeInterface + NodeDriverProxyInterface + path.PathElement + + Init(NodeInterface) NodeInterface + + GetSelf() NodeInterface + SetSelf(NodeInterface) + + String() string +} diff --git a/tree/generic/interface_tree.go b/tree/generic/interface_tree.go new file mode 100644 index 0000000..235bdb8 --- /dev/null +++ b/tree/generic/interface_tree.go @@ -0,0 +1,58 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" +) + +type TreeInterface interface { + logger.Interface + + Init(TreeInterface, options.Interface) TreeInterface + + GetOptions() options.Interface + SetOptions(options.Interface) + + GetSelf() TreeInterface + SetSelf(TreeInterface) + + SetRoot(NodeInterface) + GetRoot() NodeInterface + + SetLogger(logger.Interface) + GetLogger() logger.Interface + + GetDriver() TreeDriverInterface + SetDriver(TreeDriverInterface) + + GetChildrenKind(kind.Kind) kind.Kind + + GetPageSize() int + + AllocateID() bool + + Clear(context.Context) + + Diff(a, b NodeInterface) string + + MustFind(path.Path) NodeInterface + Find(path.Path) NodeInterface + FindAndGet(context.Context, path.Path) NodeInterface + Exists(context.Context, path.Path) bool + + Walk(context.Context, *WalkOptions) + WalkAndGet(context.Context, *WalkOptions) + Apply(context.Context, path.Path, *ApplyOptions) bool + ApplyAndGet(context.Context, path.Path, *ApplyOptions) bool + + Register(kind kind.Kind, factory FactoryFun) + Factory(ctx context.Context, kind kind.Kind) NodeInterface +} diff --git a/tree/generic/main_test.go b/tree/generic/main_test.go new file mode 100644 index 0000000..b79e326 --- /dev/null +++ b/tree/generic/main_test.go @@ -0,0 +1,139 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" + options_logger "code.forgejo.org/f3/gof3/v3/options/logger" +) + +type Storage map[string]int + +type testTree struct { + Tree +} + +type testOptions struct { + options.Options + options_logger.OptionsLogger +} + +func newTestOptions() options.Interface { + opts := &testOptions{} + opts.SetName("test") + l := logger.NewLogger() + l.SetLevel(logger.Trace) + opts.SetLogger(l) + return opts +} + +type noopNodeDriver struct { + NullDriver +} + +func (o *noopNodeDriver) ListPage(context.Context, int) ChildrenSlice { + return ChildrenSlice{} +} + +func (o *noopNodeDriver) GetIDFromName(ctx context.Context, name string) id.NodeID { + return id.NilID +} + +func (o *noopNodeDriver) Equals(context.Context, NodeInterface) bool { + return false +} + +func (o *noopNodeDriver) Put(context.Context) id.NodeID { + return o.GetNode().GetID() +} + +func (o *noopNodeDriver) Patch(context.Context) { +} + +func (o *noopNodeDriver) NewFormat() f3.Interface { + f := f3.NewCommon(id.NilID.String()) + return &f +} + +func (o *noopNodeDriver) ToFormat() f3.Interface { + f := f3.NewCommon(o.GetNode().GetID().String()) + return &f +} + +func (o *noopNodeDriver) FromFormat(f f3.Interface) { + o.GetNode().SetID(id.NewNodeID(f.GetID())) +} + +func newTestNodeDriver() NodeDriverInterface { + return &noopNodeDriver{} +} + +type testTreeDriver struct { + NullTreeDriver +} + +func newTestTreeDriver() TreeDriverInterface { + return &testTreeDriver{} +} + +func (o *testTreeDriver) Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface { + d := newTestNodeDriver() + d.SetTreeDriver(o) + return d +} + +func newTestTree() TreeInterface { + tree := &testTree{} + tree.Init(tree, newTestOptions()) + tree.Trace("init done") + tree.SetDriver(newTestTreeDriver()) + tree.Register(kindTestNodeLevelOne, func(ctx context.Context, kind kind.Kind) NodeInterface { + node := &testNodeLevelOne{} + return node.Init(node) + }) + tree.Register(kindTestNodeLevelTwo, func(ctx context.Context, kind kind.Kind) NodeInterface { + node := &testNodeLevelTwo{} + return node.Init(node) + }) + return tree +} + +type testNodeInterface interface { + GetV() int +} + +type testNode struct { + Node + v int +} + +func (o *testNode) GetV() int { + return o.v +} + +func (o *testNode) Equals(ctx context.Context, other NodeInterface) bool { + if !o.Node.Equals(ctx, other) { + return false + } + return o.GetV() == other.(testNodeInterface).GetV() +} + +var kindTestNodeLevelOne = kind.Kind("levelone") + +type testNodeLevelOne struct { + testNode +} + +var kindTestNodeLevelTwo = kind.Kind("leveltwo") + +type testNodeLevelTwo struct { + testNode +} diff --git a/tree/generic/mirror.go b/tree/generic/mirror.go new file mode 100644 index 0000000..3468fcf --- /dev/null +++ b/tree/generic/mirror.go @@ -0,0 +1,52 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/path" +) + +type MirrorOptions struct { + noremap bool +} + +func NewMirrorOptions() *MirrorOptions { + return &MirrorOptions{} +} + +func (o *MirrorOptions) SetNoRemap(noremap bool) *MirrorOptions { + o.noremap = noremap + return o +} + +func NodeMirror(ctx context.Context, origin, destination NodeInterface, options *MirrorOptions) { + origin.Trace("origin '%s' | destination '%s'", origin.GetCurrentPath().ReadableString(), destination.GetCurrentPath().ReadableString()) + origin.Trace("start unify references") + for _, reference := range NodeCollectReferences(ctx, origin) { + origin.Trace("unify reference %s", reference) + TreeUnifyPath(ctx, origin.GetTree(), reference, destination.GetTree(), NewUnifyOptions(destination.GetTree())) + } + origin.Trace("end unify references") + originParents := origin.GetParent().GetCurrentPath() + destinationParents := destination.GetParent().GetCurrentPath() + NodeUnify(ctx, origin, originParents, destination, destinationParents, NewUnifyOptions(destination.GetTree())) +} + +func TreeMirror(ctx context.Context, originTree, destinationTree TreeInterface, path path.Path, options *MirrorOptions) { + originTree.Trace("'%s'", path.ReadableString()) + TreeUnifyPath(ctx, originTree, path, destinationTree, NewUnifyOptions(destinationTree)) + TreeParallelApply(ctx, originTree, path, destinationTree, NewParallelApplyOptions(func(ctx context.Context, origin, destination NodeInterface) { + NodeMirror(ctx, origin, destination, NewMirrorOptions()) + })) +} + +func TreePartialMirror(ctx context.Context, originTree TreeInterface, originPath path.Path, destinationTree TreeInterface, destinationPath path.Path, options *MirrorOptions) { + originTree.Trace("'%s => %s'", originPath, destinationPath) + originNode := originTree.MustFind(originPath) + destinationNode := destinationTree.MustFind(destinationPath) + NodeMirror(ctx, originNode, destinationNode, NewMirrorOptions()) +} diff --git a/tree/generic/node.go b/tree/generic/node.go new file mode 100644 index 0000000..418d22a --- /dev/null +++ b/tree/generic/node.go @@ -0,0 +1,414 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "fmt" + "sort" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/util" +) + +type ChildrenSlice []NodeInterface + +func NewChildrenSlice(len int) ChildrenSlice { + return make([]NodeInterface, 0, len) +} + +func (o ChildrenSlice) Len() int { + return len(o) +} + +func (o ChildrenSlice) Swap(i, j int) { + o[i], o[j] = o[j], o[i] +} + +func (o ChildrenSlice) Less(i, j int) bool { + return o[i].GetID().String() < o[j].GetID().String() +} + +type NodeChildren map[id.NodeID]NodeInterface + +func NewNodeChildren() NodeChildren { + return make(NodeChildren) +} + +var ( + NilNode = &Node{isNil: true} + NilParent = NilNode +) + +type Node struct { + logger.Logger + + tree TreeInterface + isNil bool + sync bool + kind kind.Kind + id id.NodeID + + driver NodeDriverInterface + self NodeInterface + parent NodeInterface + + children NodeChildren +} + +func NewElementNode() path.PathElement { + return NewNode().(path.PathElement) +} + +func NewNode() NodeInterface { + node := &Node{} + return node.Init(node) +} + +func NewNodeFromID[T any](i T) NodeInterface { + node := NewNode() + node.SetID(id.NewNodeID(i)) + return node +} + +func (o *Node) Init(self NodeInterface) NodeInterface { + o.SetTree(nil) + o.SetIsNil(true) + o.SetKind(kind.KindNil) + o.SetID(id.NilID) + o.SetSelf(self) + o.SetParent(NilNode) + o.children = NewNodeChildren() + + return self +} + +func (o *Node) GetIsNil() bool { return o == nil || o.isNil } +func (o *Node) SetIsNil(isNil bool) { o.isNil = isNil } + +func (o *Node) GetIsSync() bool { return o.sync } +func (o *Node) SetIsSync(sync bool) { o.sync = sync } + +func (o *Node) GetSelf() NodeInterface { return o.self } +func (o *Node) SetSelf(self NodeInterface) { o.self = self } + +func (o *Node) GetParent() NodeInterface { return o.parent } +func (o *Node) SetParent(parent NodeInterface) { o.parent = parent } + +func (o *Node) GetKind() kind.Kind { return o.kind } +func (o *Node) SetKind(kind kind.Kind) { o.kind = kind } + +func (o *Node) GetID() id.NodeID { + if o.id == nil { + return id.NilID + } + return o.id +} +func (o *Node) SetID(id id.NodeID) { o.id = id } + +func (o *Node) GetMappedID() id.NodeID { + mappedID := o.GetDriver().GetMappedID() + if mappedID == nil { + mappedID = id.NilID + } + return mappedID +} + +func (o *Node) SetMappedID(mapped id.NodeID) { + o.GetDriver().SetMappedID(mapped) +} + +func (o *Node) GetTree() TreeInterface { return o.tree } +func (o *Node) SetTree(tree TreeInterface) { + o.tree = tree + if tree != nil { + o.SetLogger(tree.GetLogger()) + } +} + +func (o *Node) GetDriver() NodeDriverInterface { return o.driver } +func (o *Node) SetDriver(driver NodeDriverInterface) { + driver.SetNode(o) + o.driver = driver +} + +func (o *Node) GetNodeChildren() NodeChildren { + return o.children +} + +func (o *Node) GetChildren() ChildrenSlice { + children := NewChildrenSlice(len(o.children)) + for _, child := range o.children { + children = append(children, child) + } + sort.Sort(children) + return children +} + +func (o *Node) SetChildren(children NodeChildren) { o.children = children } + +func (o *Node) String() string { + driver := o.GetDriver().String() + if driver != "" { + driver = "=" + driver + } + return o.GetID().String() + driver +} + +func (o *Node) Equals(ctx context.Context, other NodeInterface) bool { + return o.GetKind() == other.GetKind() && o.GetID() == other.GetID() +} + +func (o *Node) GetCurrentPath() path.Path { + var p path.Path + if o.GetIsNil() { + p = path.NewPath() + } else { + p = o.GetParent().GetCurrentPath().Append(o) + } + return p +} + +func (o *Node) ListPage(ctx context.Context, page int) ChildrenSlice { + return o.GetDriver().ListPage(ctx, page) +} + +func (o *Node) List(ctx context.Context) ChildrenSlice { + o.Trace("%s", o.GetKind()) + children := NewNodeChildren() + + self := o.GetSelf() + for page := 1; ; page++ { + util.MaybeTerminate(ctx) + childrenPage := self.ListPage(ctx, page) + for _, child := range childrenPage { + children[child.GetID()] = child + } + + if len(childrenPage) < o.GetTree().GetPageSize() { + break + } + } + + o.children = children + return o.GetChildren() +} + +func (o *Node) GetChild(id id.NodeID) NodeInterface { + child, ok := o.children[id] + if !ok { + return NilNode + } + return child +} + +func (o *Node) SetChild(child NodeInterface) NodeInterface { + o.children[child.GetID()] = child + return child +} + +func (o *Node) DeleteChild(id id.NodeID) NodeInterface { + if child, ok := o.children[id]; ok { + delete(o.children, id) + return child + } + return NilNode +} + +func (o *Node) CreateChild(ctx context.Context) NodeInterface { + tree := o.GetTree() + child := tree.Factory(ctx, tree.GetChildrenKind(o.GetKind())) + child.SetParent(o) + return child +} + +func (o *Node) GetIDFromName(ctx context.Context, name string) id.NodeID { + return o.GetDriver().GetIDFromName(ctx, name) +} + +func (o *Node) Get(ctx context.Context) NodeInterface { + if o.GetDriver().Get(ctx) { + o.SetIsSync(true) + } + return o +} + +func (o *Node) Upsert(ctx context.Context) NodeInterface { + if o.GetID() != id.NilID { + o.GetDriver().Patch(ctx) + } else { + o.SetID(o.GetDriver().Put(ctx)) + } + if o.GetParent() != NilNode { + o.GetParent().SetChild(o) + } + return o +} + +func (o *Node) Delete(ctx context.Context) NodeInterface { + o.GetDriver().Delete(ctx) + return o.GetParent().DeleteChild(o.GetID()) +} + +func (o *Node) NewFormat() f3.Interface { + return o.GetDriver().NewFormat() +} + +func (o *Node) FromFormat(f f3.Interface) NodeInterface { + o.SetID(id.NewNodeID(f.GetID())) + o.GetDriver().FromFormat(f) + return o +} + +func (o *Node) ToFormat() f3.Interface { + if o == nil || o.GetDriver() == nil { + return nil + } + return o.GetDriver().ToFormat() +} + +func (o *Node) LookupMappedID(id id.NodeID) id.NodeID { return o.GetDriver().LookupMappedID(id) } + +func (o *Node) ApplyAndGet(ctx context.Context, p path.Path, options *ApplyOptions) bool { + applyWrapInGet := func(options *ApplyOptions) ApplyFunc { + return func(ctx context.Context, parent, p path.Path, node NodeInterface) { + if options.fun != nil && (p.Empty() || options.where == ApplyEachNode) { + options.fun(ctx, parent, p, node) + } + if p.Empty() { + return + } + + childID := p.First().(NodeInterface).GetID() + // is it a known child? + child := node.GetChild(childID) + // if not, maybe it is a known child referenced by name + if child.GetIsNil() { + childIDFromName := node.GetIDFromName(ctx, childID.String()) + if childIDFromName != id.NilID { + childID = childIDFromName + child = node.GetChild(childID) + p.First().(NodeInterface).SetID(childID) + } + } + // not a known child by ID or by Name: make one + if child.GetIsNil() { + child = node.CreateChild(ctx) + child.SetID(childID) + if child.Get(ctx).GetIsSync() { + // only set the child if the driver is able to get it, otherwise + // it means that although the ID is known the child itself cannot be + // obtained and is assumed to be not found + node.SetChild(child) + } + } + } + } + return o.Apply(ctx, path.NewPath(), p, NewApplyOptions(applyWrapInGet(options)).SetWhere(ApplyEachNode)) +} + +func (o *Node) WalkAndGet(ctx context.Context, parent path.Path, options *WalkOptions) { + walkWrapInGet := func(fun WalkFunc) WalkFunc { + return func(ctx context.Context, p path.Path, node NodeInterface) { + node.Get(ctx) + node.List(ctx) + if fun != nil { + fun(ctx, p, node) + } + } + } + + o.Walk(ctx, parent, NewWalkOptions(walkWrapInGet(options.fun))) +} + +func (o *Node) Walk(ctx context.Context, parent path.Path, options *WalkOptions) { + util.MaybeTerminate(ctx) + options.fun(ctx, parent, o.GetSelf()) + parent = parent.Append(o.GetSelf().(path.PathElement)) + for _, child := range o.GetSelf().GetChildren() { + child.Walk(ctx, parent, options) + } +} + +func (o *Node) FindAndGet(ctx context.Context, p path.Path) NodeInterface { + var r NodeInterface + r = NilNode + set := func(ctx context.Context, parent, p path.Path, node NodeInterface) { + r = node + } + o.ApplyAndGet(ctx, p, NewApplyOptions(set)) + return r +} + +func (o *Node) MustFind(p path.Path) NodeInterface { + found := o.Find(p) + if found.GetIsNil() { + panic(fmt.Errorf("%s not found", p)) + } + return found +} + +func (o *Node) Find(p path.Path) NodeInterface { + if p.Empty() { + return o + } + + child := o.GetChild(p.First().(NodeInterface).GetID()) + if child.GetIsNil() { + o.Info("'%s' not found", p.String()) + return NilNode + } + + return child.Find(p.RemoveFirst()) +} + +func (o *Node) Apply(ctx context.Context, parentPath, p path.Path, options *ApplyOptions) bool { + o.Trace("parent '%s', node '%s', path '%s'", parentPath.ReadableString(), o.String(), p.ReadableString()) + + util.MaybeTerminate(ctx) + + if p.Empty() { + options.fun(ctx, parentPath, p, o.GetSelf()) + return true + } + + i := p.First().(NodeInterface).GetID().String() + + if i == "." { + return o.Apply(ctx, parentPath, p.RemoveFirst(), options) + } + + if i == ".." { + parent, parentPath := parentPath.Pop() + return parent.(NodeInterface).Apply(ctx, parentPath, p.RemoveFirst(), options) + } + + if options.where == ApplyEachNode { + options.fun(ctx, parentPath, p, o.GetSelf()) + } + + child := o.GetChild(p.First().(NodeInterface).GetID()) + if child.GetIsNil() && options.search == ApplySearchByName { + if childID := o.GetIDFromName(ctx, p.First().(NodeInterface).GetID().String()); childID != id.NilID { + child = o.GetChild(childID) + } + } + if child.GetIsNil() { + return false + } + parentPath = parentPath.Append(o.GetSelf().(path.PathElement)) + return child.Apply(ctx, parentPath, p.RemoveFirst(), options) +} + +type ErrorNodeNotFound error + +func NewError[T error](message string, args ...any) T { + e := fmt.Errorf(message, args...) + return e.(T) +} diff --git a/tree/generic/node_test.go b/tree/generic/node_test.go new file mode 100644 index 0000000..d69841b --- /dev/null +++ b/tree/generic/node_test.go @@ -0,0 +1,296 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "fmt" + "testing" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + + "github.com/stretchr/testify/assert" +) + +func TestNodeInit(t *testing.T) { + node := NewNode() + assert.NotNil(t, node) +} + +func TestNewNodeFromID(t *testing.T) { + { + i := "nodeID" + node := NewNodeFromID(i) + assert.Equal(t, id.NewNodeID(i), node.GetID()) + } + { + i := 123 + node := NewNodeFromID(i) + assert.Equal(t, id.NewNodeID(fmt.Sprintf("%d", i)), node.GetID()) + } +} + +func TestNodeIsNil(t *testing.T) { + node := NewNode() + assert.True(t, node.GetIsNil()) + + node.SetIsNil(false) + assert.False(t, node.GetIsNil()) + + node.SetIsNil(true) + assert.True(t, node.GetIsNil()) + + var nilNode *Node + assert.True(t, nilNode.GetIsNil()) +} + +func TestNodeEquals(t *testing.T) { + ctx := context.Background() + tree := newTestTree() + one := tree.Factory(ctx, kindTestNodeLevelOne) + one.(*testNodeLevelOne).v = 1 + assert.True(t, one.Equals(ctx, one)) + two := tree.Factory(ctx, kindTestNodeLevelOne) + two.(*testNodeLevelOne).v = 2 + assert.False(t, one.Equals(ctx, two)) +} + +func TestNodeParent(t *testing.T) { + node := NewNode() + assert.True(t, node.GetParent().GetIsNil()) + parent := NewNode() + node.SetParent(parent) + assert.True(t, parent == node.GetParent()) +} + +func TestNodeKind(t *testing.T) { + node := NewNode() + assert.EqualValues(t, kind.KindNil, node.GetKind()) + kind := kind.Kind("something") + node.SetKind(kind) + assert.True(t, kind == node.GetKind()) +} + +func TestNodeMappedID(t *testing.T) { + node := NewNode() + node.SetDriver(NewNullDriver()) + assert.EqualValues(t, id.NilID, node.GetMappedID()) + mapped := id.NewNodeID("mapped") + node.SetMappedID(mapped) + assert.True(t, mapped == node.GetMappedID()) +} + +func TestNodeNewFormat(t *testing.T) { + node := NewNode() + node.SetDriver(NewNullDriver()) + + assert.Panics(t, func() { node.NewFormat() }) +} + +func TestChildren(t *testing.T) { + parent := NewNode() + assert.Empty(t, parent.GetChildren()) + + id1 := id.NewNodeID("one") + child1 := NewNode() + child1.SetID(id1) + parent.SetChild(child1) + + id2 := id.NewNodeID("two") + child2 := NewNode() + child2.SetID(id2) + parent.SetChild(child2) + + children := parent.GetChildren() + assert.Len(t, children, 2) + assert.Equal(t, children[0].GetID(), id1) + assert.Equal(t, children[1].GetID(), id2) + + nodeChildren := parent.GetNodeChildren() + assert.Len(t, nodeChildren, 2) + delete(nodeChildren, id1) + parent.SetChildren(nodeChildren) + nodeChildren = parent.GetNodeChildren() + assert.Len(t, nodeChildren, 1) +} + +func TestNodeID(t *testing.T) { + node := NewNode() + assert.EqualValues(t, id.NilID, node.GetID()) + i := id.NewNodeID("1234") + node.SetID(i) + assert.True(t, i == node.GetID()) +} + +func TestNodeTree(t *testing.T) { + node := NewNode() + assert.Nil(t, node.GetTree()) + tree := NewTree(newTestOptions()) + node.SetTree(tree) + assert.True(t, tree == node.GetTree()) +} + +type driverChildren struct { + NullDriver +} + +func (o *driverChildren) ListPage(cxt context.Context, page int) ChildrenSlice { + node := o.GetNode() + tree := node.GetTree() + count := tree.GetPageSize() + if page > 1 { + count = 1 + } + + offset := (page - 1) * tree.GetPageSize() + children := NewChildrenSlice(0) + for i := 0; i < count; i++ { + child := tree.GetSelf().Factory(cxt, kind.KindNil) + child.SetID(id.NewNodeID(i + offset)) + children = append(children, child) + } + + return children +} + +type driverTreeChildren struct { + NullTreeDriver +} + +func (o *driverTreeChildren) Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface { + return &driverChildren{} +} + +type treeChildren struct { + Tree +} + +func newTreeChildren() TreeInterface { + tree := &treeChildren{} + tree.Init(tree, newTestOptions()) + treeDriver := &driverTreeChildren{} + treeDriver.Init() + tree.SetDriver(treeDriver) + tree.Register(kind.KindNil, func(ctx context.Context, kind kind.Kind) NodeInterface { + node := NewNode() + node.SetIsNil(false) + return node + }) + return tree +} + +func TestNodeListPage(t *testing.T) { + tree := newTreeChildren() + + ctx := context.Background() + node := tree.Factory(ctx, kind.KindNil) + + children := node.ListPage(context.Background(), int(1)) + assert.NotNil(t, children) + assert.EqualValues(t, tree.GetPageSize(), len(children)) + + children = node.ListPage(context.Background(), int(2)) + assert.NotNil(t, children) + assert.EqualValues(t, 1, len(children)) +} + +func TestNodeList(t *testing.T) { + tree := newTreeChildren() + + ctx := context.Background() + node := tree.Factory(ctx, kind.KindNil) + children := node.List(ctx) + assert.EqualValues(t, tree.GetPageSize()+1, len(children)) + + idThree := id.NewNodeID("3") + childThree := node.GetChild(idThree) + assert.False(t, childThree.GetIsNil()) + assert.EqualValues(t, idThree, childThree.GetID()) + node.DeleteChild(idThree) + childThree = node.GetChild(idThree) + assert.True(t, childThree.GetIsNil()) +} + +type nodeGetIDFromName struct { + Node + name string +} + +func (o *nodeGetIDFromName) GetIDFromName(ctx context.Context, name string) id.NodeID { + for _, child := range o.GetChildren() { + if child.(*nodeGetIDFromName).name == name { + return child.GetID() + } + } + return id.NilID +} + +func newNodeGetIDFromName(i, name string) NodeInterface { + node := &nodeGetIDFromName{} + node.Init(node) + node.SetIsNil(false) + node.SetID(id.NewNodeID(i)) + node.name = name + return node +} + +func TestNodeGetIDFromName(t *testing.T) { + i := "1243" + name := "NAME" + node := newNodeGetIDFromName(i, name) + + ctx := context.Background() + parent := newNodeGetIDFromName("parent", "PARENT") + parent.SetChild(node) + + r := parent.GetIDFromName(ctx, "OTHERNAME") + assert.EqualValues(t, id.NilID, r) + + r = parent.GetIDFromName(ctx, name) + assert.True(t, r == id.NewNodeID(i)) +} + +func TestNodePath(t *testing.T) { + { + path := NilNode.GetCurrentPath() + assert.True(t, path.Empty()) + pathString := path.PathString() + assert.True(t, pathString.Empty()) + assert.EqualValues(t, "", pathString.Join()) + } + + root := NewNode() + root.SetIsNil(false) + root.SetKind(kind.KindRoot) + + level1 := NewNode() + level1.SetIsNil(false) + level1.SetParent(root) + id1 := id.NewNodeID("1") + level1.SetID(id1) + + level2 := NewNode() + level2.SetIsNil(false) + level2.SetParent(level1) + id2 := id.NewNodeID("2") + level2.SetID(id2) + + { + p := level2.GetCurrentPath() + assert.False(t, p.Empty()) + if assert.EqualValues(t, 3, p.Length()) { + assert.True(t, root == p.First(), p.First().(NodeInterface).GetID()) + rest := p.RemoveFirst() + assert.True(t, level1 == rest.First(), p.First().(NodeInterface).GetID()) + rest = rest.RemoveFirst() + assert.True(t, level2 == rest.First(), p.First().(NodeInterface).GetID()) + } + pathString := p.PathString() + assert.False(t, pathString.Empty()) + assert.EqualValues(t, 3, len(pathString.Elements())) + assert.EqualValues(t, "/1/2", pathString.Join()) + } +} diff --git a/tree/generic/path.go b/tree/generic/path.go new file mode 100644 index 0000000..262bf88 --- /dev/null +++ b/tree/generic/path.go @@ -0,0 +1,13 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "code.forgejo.org/f3/gof3/v3/path" +) + +func NewPathFromString(pathString string) path.Path { + return path.NewPathFromString(NewElementNode, pathString) +} diff --git a/tree/generic/references.go b/tree/generic/references.go new file mode 100644 index 0000000..f38e54c --- /dev/null +++ b/tree/generic/references.go @@ -0,0 +1,92 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "strings" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/util" +) + +type ErrorRemapReferencesRelative error + +func RemapReferences(ctx context.Context, node NodeInterface, f f3.Interface) { + for _, reference := range f.GetReferences() { + toPath := path.NewPath() + collectTo := func(ctx context.Context, parent, p path.Path, node NodeInterface) { + element := NewNode() + mappedID := node.GetMappedID() + if mappedID.String() == "" { + node.Trace("mapped ID for %s is not defined", p.ReadableString()) + } + element.SetID(mappedID) + toPath = toPath.Append(element.(path.PathElement)) + } + from := reference.Get() + isRelative := !strings.HasPrefix(from, "/") + if isRelative && !strings.HasPrefix(from, "..") { + panic(NewError[ErrorRemapReferencesRelative]("relative references that do not start with .. are not supported '%s'", from)) + } + current := node.GetCurrentPath().String() + fromPath := path.PathAbsolute(NewElementNode, current, from) + node.GetTree().Apply(ctx, fromPath, NewApplyOptions(collectTo).SetWhere(ApplyEachNode)) + to := toPath.String() + node.Trace("from '%s' to '%s'", fromPath.ReadableString(), toPath.ReadableString()) + if isRelative { + currentMapped := node.GetParent().GetCurrentPath().PathMappedString().Join() + // because the mapped ID of the current node has not been allocated yet + // and it does not matter as long as it is replaced with .. + // it will not work at all if a relative reference does not start with .. + currentMapped += "/PLACEHODLER" + to = path.PathRelativeString(currentMapped, to) + } + node.Trace("convert reference %s => %s", reference.Get(), to) + reference.Set(to) + } +} + +func NodeCollectReferences(ctx context.Context, node NodeInterface) []path.Path { + pathToReferences := make(map[string]path.Path, 5) + + tree := node.GetTree() + + collect := func(ctx context.Context, parent path.Path, node NodeInterface) { + util.MaybeTerminate(ctx) + f := node.GetSelf().ToFormat() + for _, reference := range f.GetReferences() { + absoluteReference := path.PathAbsoluteString(node.GetCurrentPath().String(), reference.Get()) + if _, ok := pathToReferences[absoluteReference]; ok { + continue + } + tree.ApplyAndGet(ctx, path.NewPathFromString(NewElementNode, absoluteReference), NewApplyOptions(func(ctx context.Context, parent, path path.Path, node NodeInterface) { + pathToReferences[absoluteReference] = node.GetCurrentPath() + })) + } + } + + node.Walk(ctx, path.NewPath(node.(path.PathElement)), NewWalkOptions(collect)) + + references := make([]path.Path, 0, len(pathToReferences)) + + for _, reference := range pathToReferences { + tree.Debug("collect %s", reference) + references = append(references, reference) + } + + return references +} + +func TreeCollectReferences(ctx context.Context, tree TreeInterface, p path.Path) []path.Path { + var references []path.Path + + tree.Apply(ctx, p, NewApplyOptions(func(ctx context.Context, parent, path path.Path, node NodeInterface) { + references = NodeCollectReferences(ctx, node) + })) + + return references +} diff --git a/tree/generic/references_test.go b/tree/generic/references_test.go new file mode 100644 index 0000000..e857c24 --- /dev/null +++ b/tree/generic/references_test.go @@ -0,0 +1,166 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/path" + + "github.com/stretchr/testify/assert" +) + +type referenceFormat struct { + f3.Common + R *f3.Reference +} + +func (o *referenceFormat) Clone() f3.Interface { + clone := &referenceFormat{} + *clone = *o + return clone +} + +func (o *referenceFormat) GetReferences() f3.References { + references := o.Common.GetReferences() + if o.R != nil { + references = append(references, o.R) + } + return references +} + +type referencesNodeDriver struct { + NullDriver + + f referenceFormat +} + +func (o *referencesNodeDriver) GetIDFromName(ctx context.Context, name string) id.NodeID { + return id.NilID +} + +func (o *referencesNodeDriver) Get(context.Context) bool { + return true +} + +func (o *referencesNodeDriver) NewFormat() f3.Interface { + return &referenceFormat{} +} + +func (o *referencesNodeDriver) ToFormat() f3.Interface { + return &o.f +} + +func (o *referencesNodeDriver) FromFormat(f f3.Interface) { + o.f = *f.(*referenceFormat) +} + +func newReferencesNodeDriver() NodeDriverInterface { + return &referencesNodeDriver{} +} + +type testTreeReferencesDriver struct { + NullTreeDriver +} + +func newTestTreeReferencesDriver() TreeDriverInterface { + return &testTreeReferencesDriver{} +} + +func (o *testTreeReferencesDriver) Factory(ctx context.Context, kind kind.Kind) NodeDriverInterface { + d := newReferencesNodeDriver() + d.SetTreeDriver(o) + return d +} + +var kindTestNodeReferences = kind.Kind("references") + +type testNodeReferences struct { + testNode +} + +func newTestTreeReferences() TreeInterface { + tree := &testTree{} + tree.Init(tree, newTestOptions()) + tree.SetDriver(newTestTreeReferencesDriver()) + tree.Register(kindTestNodeReferences, func(ctx context.Context, kind kind.Kind) NodeInterface { + node := &testNodeReferences{} + return node.Init(node) + }) + return tree +} + +func TestTreeCollectReferences(t *testing.T) { + tree := newTestTreeReferences() + root := tree.Factory(context.Background(), kindTestNodeReferences) + tree.SetRoot(root) + + one := tree.Factory(context.Background(), kindTestNodeReferences) + one.FromFormat(&referenceFormat{R: f3.NewReference("/somewhere")}) + one.SetID(id.NewNodeID("one")) + root.SetChild(one) + + // the second node that has the same reference is here to ensure + // they are deduplicated + two := tree.Factory(context.Background(), kindTestNodeReferences) + two.FromFormat(&referenceFormat{R: f3.NewReference("/somewhere")}) + two.SetID(id.NewNodeID("two")) + root.SetChild(two) + + references := TreeCollectReferences(context.Background(), tree, path.NewPathFromString(NewElementNode, "/")) + assert.Len(t, references, 1) + assert.EqualValues(t, "/somewhere", references[0].String()) +} + +func TestRemapReferences(t *testing.T) { + tree := newTestTreeReferences() + + root := tree.Factory(context.Background(), kindTestNodeReferences) + tree.SetRoot(root) + + one := tree.Factory(context.Background(), kindTestNodeReferences) + one.SetID(id.NewNodeID("one")) + one.SetMappedID(id.NewNodeID("remappedone")) + one.SetParent(root) + root.SetChild(one) + + two := tree.Factory(context.Background(), kindTestNodeReferences) + two.FromFormat(&referenceFormat{R: f3.NewReference("/one")}) + two.SetID(id.NewNodeID("two")) + two.SetMappedID(id.NewNodeID("two")) + two.SetParent(root) + root.SetChild(two) + + { + f := two.ToFormat() + RemapReferences(context.Background(), two, f) + r := f.GetReferences() + if assert.Len(t, r, 1) { + assert.Equal(t, "/remappedone", r[0].Get()) + } + } + + three := tree.Factory(context.Background(), kindTestNodeReferences) + three.FromFormat(&referenceFormat{R: f3.NewReference("../../one")}) + three.SetID(id.NewNodeID("three")) + three.SetMappedID(id.NewNodeID("three")) + three.SetParent(two) + two.SetChild(three) + + { + f := three.ToFormat() + RemapReferences(context.Background(), three, f) + r := f.GetReferences() + if assert.Len(t, r, 1) { + assert.Equal(t, "../../remappedone", r[0].Get()) + } + } + + assert.Panics(t, func() { RemapReferences(context.Background(), three, &referenceFormat{R: f3.NewReference("./one")}) }) +} diff --git a/tree/generic/tree.go b/tree/generic/tree.go new file mode 100644 index 0000000..9485911 --- /dev/null +++ b/tree/generic/tree.go @@ -0,0 +1,134 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" +) + +type FactoryFun func(ctx context.Context, kind kind.Kind) NodeInterface + +type kindMap map[kind.Kind]FactoryFun + +type Tree struct { + logger.Logger + + opts options.Interface + self TreeInterface + driver TreeDriverInterface + root NodeInterface + kind kindMap +} + +func NewTree(opts options.Interface) TreeInterface { + tree := &Tree{} + return tree.Init(tree, opts) +} + +func (o *Tree) Init(self TreeInterface, opts options.Interface) TreeInterface { + o.self = self + o.kind = make(kindMap) + o.SetDriver(NewNullTreeDriver()) + o.SetOptions(opts) + o.SetLogger(opts.(options.LoggerInterface).GetLogger()) + return o +} + +func (o *Tree) GetOptions() options.Interface { return o.opts } +func (o *Tree) SetOptions(opts options.Interface) { o.opts = opts } + +func (o *Tree) GetSelf() TreeInterface { return o.self } +func (o *Tree) SetSelf(self TreeInterface) { o.self = self } + +func (o *Tree) GetRoot() NodeInterface { return o.root } +func (o *Tree) SetRoot(root NodeInterface) { o.root = root } + +func (o *Tree) GetDriver() TreeDriverInterface { return o.driver } +func (o *Tree) SetDriver(driver TreeDriverInterface) { + driver.SetTree(o.GetSelf()) + o.driver = driver +} + +func (o *Tree) GetChildrenKind(parentKind kind.Kind) kind.Kind { + return kind.KindNil +} + +func (o *Tree) GetPageSize() int { return o.GetDriver().GetPageSize() } + +func (o *Tree) AllocateID() bool { return o.GetDriver().AllocateID() } + +func (o *Tree) Walk(ctx context.Context, options *WalkOptions) { + o.GetRoot().Walk(ctx, path.NewPath(), options) +} + +func (o *Tree) WalkAndGet(ctx context.Context, options *WalkOptions) { + o.GetRoot().WalkAndGet(ctx, path.NewPath(), options) +} + +func (o *Tree) Clear(ctx context.Context) { + rootKind := o.GetRoot().GetKind() + o.SetRoot(o.Factory(ctx, rootKind)) +} + +func (o *Tree) Exists(ctx context.Context, path path.Path) bool { + return o.Find(path) != NilNode +} + +func (o *Tree) MustFind(path path.Path) NodeInterface { + return o.GetRoot().MustFind(path.RemoveFirst()) +} + +func (o *Tree) Find(path path.Path) NodeInterface { + return o.GetRoot().Find(path.RemoveFirst()) +} + +func (o *Tree) FindAndGet(ctx context.Context, path path.Path) NodeInterface { + return o.GetRoot().FindAndGet(ctx, path.RemoveFirst()) +} + +func (o *Tree) Diff(a, b NodeInterface) string { + return o.GetDriver().Diff(a.GetDriver(), b.GetDriver()) +} + +func (o *Tree) Apply(ctx context.Context, p path.Path, options *ApplyOptions) bool { + if p.Empty() { + return true + } + return o.GetRoot().Apply(ctx, path.NewPath(), p.RemoveFirst(), options) +} + +func (o *Tree) ApplyAndGet(ctx context.Context, path path.Path, options *ApplyOptions) bool { + if path.Empty() { + return true + } + return o.GetRoot().ApplyAndGet(ctx, path.RemoveFirst(), options) +} + +func (o *Tree) Factory(ctx context.Context, kind kind.Kind) NodeInterface { + var node NodeInterface + if factory, ok := o.kind[kind]; ok { + node = factory(ctx, kind) + } else { + node = NewNode() + } + node.SetIsNil(false) + node.SetKind(kind) + node.SetTree(o.GetSelf()) + if o.GetDriver() != nil { + nodeDriver := o.GetDriver().Factory(ctx, kind) + nodeDriver.SetTreeDriver(o.GetDriver()) + node.SetDriver(nodeDriver) + } + return node +} + +func (o *Tree) Register(kind kind.Kind, factory FactoryFun) { + o.kind[kind] = factory +} diff --git a/tree/generic/tree_test.go b/tree/generic/tree_test.go new file mode 100644 index 0000000..bda06a5 --- /dev/null +++ b/tree/generic/tree_test.go @@ -0,0 +1,90 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/path" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTreeInit(t *testing.T) { + tree := NewTree(newTestOptions()) + assert.NotNil(t, tree) + + assert.True(t, tree == tree.GetSelf()) + tree.SetSelf(tree) + assert.True(t, tree == tree.GetSelf()) + + other := NewTree(newTestOptions()) + assert.False(t, other == tree.GetSelf()) + assert.False(t, other.GetSelf() == tree) + assert.False(t, other.GetSelf() == tree.GetSelf()) +} + +func TestTreeRoot(t *testing.T) { + tree := NewTree(newTestOptions()) + root := NewNode() + tree.SetRoot(root) + assert.True(t, root == tree.GetRoot()) +} + +func TestTreePageSize(t *testing.T) { + tree := NewTree(newTestOptions()) + assert.EqualValues(t, DefaultPageSize, tree.GetPageSize()) + pageSize := int(100) + tree.GetDriver().SetPageSize(pageSize) + assert.EqualValues(t, pageSize, tree.GetPageSize()) +} + +func TestTreeAllocateID(t *testing.T) { + tree := NewTree(newTestOptions()) + assert.True(t, tree.AllocateID()) +} + +func TestTreeFactoryDerived(t *testing.T) { + tree := newTestTree() + root := tree.Factory(context.Background(), kindTestNodeLevelOne) + tree.SetRoot(root) + assert.True(t, root == tree.GetRoot()) + assert.True(t, kindTestNodeLevelOne == root.GetKind()) + assert.True(t, tree.GetSelf() == root.GetTree().GetSelf()) + + leveltwo := tree.Factory(context.Background(), kindTestNodeLevelTwo) + leveltwo.SetParent(root) + assert.True(t, root == leveltwo.GetParent()) + assert.True(t, kindTestNodeLevelTwo == leveltwo.GetKind()) + assert.True(t, tree.GetSelf() == leveltwo.GetTree().GetSelf()) +} + +func TestTreeApply(t *testing.T) { + tree := newTestTree() + root := tree.Factory(context.Background(), kindTestNodeLevelOne) + tree.SetRoot(root) + + assert.True(t, tree.Apply(context.Background(), path.NewPath(), nil)) + assert.True(t, tree.Apply(context.Background(), path.NewPathFromString(NewElementNode, "/"), + NewApplyOptions(func(ctx context.Context, parent, path path.Path, node NodeInterface) { + require.Equal(t, root, node) + })), + ) +} + +func TestTreeApplyAndGet(t *testing.T) { + tree := newTestTree() + root := tree.Factory(context.Background(), kindTestNodeLevelOne) + tree.SetRoot(root) + + assert.True(t, tree.ApplyAndGet(context.Background(), path.NewPath(), nil)) + assert.True(t, tree.ApplyAndGet(context.Background(), path.NewPathFromString(NewElementNode, "/"), + NewApplyOptions(func(ctx context.Context, parent, path path.Path, node NodeInterface) { + require.Equal(t, root, node) + })), + ) +} diff --git a/tree/generic/unify.go b/tree/generic/unify.go new file mode 100644 index 0000000..b659e8f --- /dev/null +++ b/tree/generic/unify.go @@ -0,0 +1,320 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/util" +) + +type ( + UnifyUpsertFunc func(ctx context.Context, origin NodeInterface, originParent path.Path, destination NodeInterface, destinationParent path.Path) + UnifyDeleteFunc func(ctx context.Context, destination NodeInterface, destinationParent path.Path) +) + +type UnifyOptions struct { + destinationTree TreeInterface + + upsert UnifyUpsertFunc + delete UnifyDeleteFunc + noremap bool +} + +func NewUnifyOptions(destinationTree TreeInterface) *UnifyOptions { + return &UnifyOptions{ + destinationTree: destinationTree, + } +} + +func (o *UnifyOptions) SetUpsert(upsert UnifyUpsertFunc) *UnifyOptions { + o.upsert = upsert + return o +} + +func (o *UnifyOptions) SetDelete(delete UnifyDeleteFunc) *UnifyOptions { + o.delete = delete + return o +} + +func (o *UnifyOptions) SetNoRemap(noremap bool) *UnifyOptions { + o.noremap = noremap + return o +} + +func TreeUnifyPath(ctx context.Context, origin TreeInterface, p path.Path, destination TreeInterface, options *UnifyOptions) { + if p.Empty() || p.First().(NodeInterface).GetID().String() == "." { + return + } + + originRoot := origin.GetRoot() + destinationRoot := destination.GetRoot() + + if destinationRoot == nil { + destinationRoot = destination.Factory(ctx, originRoot.GetKind()) + destination.SetRoot(destinationRoot) + } + + p = p.RemoveFirst() + + if p.Empty() { + return + } + + originNode := originRoot.GetChild(p.First().(NodeInterface).GetID()).GetSelf() + NodeUnifyPath(ctx, originNode, path.NewPath(originRoot.(path.PathElement)), p.RemoveFirst(), path.NewPath(destinationRoot.(path.PathElement)), options) +} + +func TreeUnify(ctx context.Context, origin, destination TreeInterface, options *UnifyOptions) { + origin.Trace("") + + originRoot := origin.GetRoot() + + if originRoot == nil { + destination.SetRoot(nil) + return + } + + destinationRoot := destination.GetRoot() + + if destinationRoot == nil { + destinationRoot = destination.Factory(ctx, originRoot.GetKind()) + destination.SetRoot(destinationRoot) + NodeCopy(ctx, originRoot, destinationRoot, originRoot.GetID(), options) + } + + NodeUnify(ctx, originRoot, path.NewPath(), destinationRoot, path.NewPath(), options) +} + +func NodeCopy(ctx context.Context, origin, destination NodeInterface, destinationID id.NodeID, options *UnifyOptions) { + f := origin.GetSelf().ToFormat() + if options.noremap { + origin.Trace("noremap") + } else { + RemapReferences(ctx, origin, f) + } + f.SetID(destinationID.String()) + destination.GetSelf().FromFormat(f) + destination.Upsert(ctx) +} + +func NodeUnify(ctx context.Context, origin NodeInterface, originPath path.Path, destination NodeInterface, destinationPath path.Path, options *UnifyOptions) { + origin.Trace("origin '%s' | destination '%s'", origin.GetCurrentPath().ReadableString(), destination.GetCurrentPath().ReadableString()) + util.MaybeTerminate(ctx) + + originPath = originPath.Append(origin.(path.PathElement)) + destinationPath = destinationPath.Append(destination.(path.PathElement)) + + originChildren := origin.GetSelf().GetChildren() + existing := make(map[id.NodeID]any, len(originChildren)) + for _, originChild := range originChildren { + destinationID := GetMappedID(ctx, originChild, destination, options) + destinationChild := destination.GetChild(destinationID) + createDestinationChild := destinationChild == NilNode + if createDestinationChild { + destinationChild = options.destinationTree.Factory(ctx, originChild.GetKind()) + destinationChild.SetParent(destination) + } + + NodeCopy(ctx, originChild, destinationChild, destinationID, options) + + if options.upsert != nil { + options.upsert(ctx, originChild.GetSelf(), originPath, destinationChild.GetSelf(), destinationPath) + } + + if createDestinationChild { + destination.SetChild(destinationChild) + } + SetMappedID(ctx, originChild, destinationChild, options) + + existing[destinationChild.GetID()] = true + + NodeUnify(ctx, originChild, originPath, destinationChild, destinationPath, options) + } + + destinationChildren := destination.GetSelf().GetChildren() + for _, destinationChild := range destinationChildren { + destinationID := destinationChild.GetID() + if _, ok := existing[destinationID]; !ok { + destinationChild.GetSelf().Delete(ctx) + if options.delete != nil { + options.delete(ctx, destinationChild.GetSelf(), destinationPath) + } + destination.DeleteChild(destinationID) + } + } +} + +func SetMappedID(ctx context.Context, origin, destination NodeInterface, options *UnifyOptions) { + if options.noremap { + return + } + origin.SetMappedID(destination.GetID()) +} + +func GetMappedID(ctx context.Context, origin, destinationParent NodeInterface, options *UnifyOptions) id.NodeID { + if options.noremap { + return origin.GetID() + } + + if i := origin.GetMappedID(); i != id.NilID { + return i + } + + return destinationParent.LookupMappedID(origin.GetID()) +} + +func NodeUnifyOne(ctx context.Context, origin NodeInterface, originPath, path, destinationPath path.Path, options *UnifyOptions) NodeInterface { + destinationParent := destinationPath.Last().(NodeInterface) + destinationID := GetMappedID(ctx, origin, destinationParent, options) + origin.Trace("'%s' '%s' '%s' %v", originPath.ReadableString(), path.ReadableString(), destinationPath.ReadableString(), destinationID) + destination := destinationParent.GetChild(destinationID) + createDestination := destination == NilNode + if createDestination { + destination = options.destinationTree.Factory(ctx, origin.GetKind()) + destination.SetParent(destinationParent) + } + + NodeCopy(ctx, origin, destination, destinationID, options) + + if options.upsert != nil { + options.upsert(ctx, origin.GetSelf(), originPath, destination.GetSelf(), destinationPath) + } + + if createDestination { + destinationParent.SetChild(destination) + } + origin.SetMappedID(destination.GetID()) + + return destination +} + +func NodeUnifyPath(ctx context.Context, origin NodeInterface, originPath, path, destinationPath path.Path, options *UnifyOptions) { + origin.Trace("origin '%s' '%s' | destination '%s' | path '%s'", originPath.ReadableString(), origin.GetID(), destinationPath.ReadableString(), path.ReadableString()) + + util.MaybeTerminate(ctx) + + if path.Empty() { + NodeUnifyOne(ctx, origin, originPath, path, destinationPath, options) + return + } + + id := path.First().(NodeInterface).GetID().String() + + if id == "." { + NodeUnifyOne(ctx, origin, originPath, path, destinationPath, options) + return + } + + if id == ".." { + parent, originPath := originPath.Pop() + _, destinationPath := destinationPath.Pop() + NodeUnifyPath(ctx, parent.(NodeInterface), originPath, path.RemoveFirst(), destinationPath, options) + return + } + + destination := NodeUnifyOne(ctx, origin, originPath, path, destinationPath, options) + + originPath = originPath.Append(origin.GetSelf()) + destinationPath = destinationPath.Append(destination.GetSelf()) + + child := origin.GetSelf().GetChild(path.First().(NodeInterface).GetID()) + if child == NilNode { + panic(NewError[ErrorNodeNotFound]("%s has no child with id %s", originPath.String(), path.First().(NodeInterface).GetID())) + } + + NodeUnifyPath(ctx, child, originPath, path.RemoveFirst(), destinationPath, options) +} + +type ParallelApplyFunc func(ctx context.Context, origin, destination NodeInterface) + +type ParallelApplyOptions struct { + fun ParallelApplyFunc + + where ApplyWhere + noremap bool +} + +func NewParallelApplyOptions(fun ParallelApplyFunc) *ParallelApplyOptions { + return &ParallelApplyOptions{ + fun: fun, + } +} + +func (o *ParallelApplyOptions) SetWhere(where ApplyWhere) *ParallelApplyOptions { + o.where = where + return o +} + +func (o *ParallelApplyOptions) SetNoRemap(noremap bool) *ParallelApplyOptions { + o.noremap = noremap + return o +} + +func TreePathRemap(ctx context.Context, origin TreeInterface, p path.Path, destination TreeInterface) path.Path { + remappedPath := path.NewPath() + remap := func(ctx context.Context, origin, destination NodeInterface) { + remappedPath = destination.GetCurrentPath() + } + TreeParallelApply(ctx, origin, p, destination, NewParallelApplyOptions(remap)) + return remappedPath +} + +func TreeParallelApply(ctx context.Context, origin TreeInterface, path path.Path, destination TreeInterface, options *ParallelApplyOptions) bool { + if path.Empty() { + return true + } + return NodeParallelApply(ctx, origin.GetRoot(), path.RemoveFirst(), destination.GetRoot(), options) +} + +func NodeParallelApply(ctx context.Context, origin NodeInterface, path path.Path, destination NodeInterface, options *ParallelApplyOptions) bool { + origin.Trace("origin '%s' | destination '%s' | path '%s'", origin.GetCurrentPath().ReadableString(), destination.GetCurrentPath().ReadableString(), path.ReadableString()) + + util.MaybeTerminate(ctx) + + if path.Empty() { + options.fun(ctx, origin, destination) + return true + } + + i := path.First().(NodeInterface).GetID().String() + + if i == "." { + return NodeParallelApply(ctx, origin, path.RemoveFirst(), destination, options) + } + + if i == ".." { + return NodeParallelApply(ctx, origin.GetParent(), path.RemoveFirst(), destination.GetParent(), options) + } + + if options.where == ApplyEachNode { + options.fun(ctx, origin, destination) + } + + originChild := origin.GetSelf().GetChild(path.First().(NodeInterface).GetID()) + if originChild == NilNode { + origin.Trace("no child %s", path.First().(NodeInterface).GetID()) + return false + } + var mappedID id.NodeID + if options.noremap { + mappedID = originChild.GetID() + } else { + mappedID = originChild.GetMappedID() + if mappedID == id.NilID { + origin.Trace("%s no mapped", originChild.GetID()) + return false + } + } + + destinationChild := destination.GetChild(mappedID) + if destinationChild == NilNode { + panic(NewError[ErrorNodeNotFound]("%s has no child with id %s", destination.String(), originChild.GetMappedID())) + } + + return NodeParallelApply(ctx, originChild, path.RemoveFirst(), destinationChild, options) +} diff --git a/tree/generic/unify_test.go b/tree/generic/unify_test.go new file mode 100644 index 0000000..08aa6f0 --- /dev/null +++ b/tree/generic/unify_test.go @@ -0,0 +1,57 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTreeUnify(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + name string + originRoot bool + destinationRoot bool + }{ + { + name: "Origin", + originRoot: true, + destinationRoot: false, + }, + { + name: "Destination", + originRoot: false, + destinationRoot: true, + }, + { + name: "None", + originRoot: false, + destinationRoot: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + origin := newTestTree() + if testCase.originRoot { + origin.SetRoot(origin.Factory(ctx, kindTestNodeLevelOne)) + } + destination := newTestTree() + if testCase.destinationRoot { + destination.SetRoot(destination.Factory(ctx, kindTestNodeLevelTwo)) + } + TreeUnify(ctx, origin, destination, NewUnifyOptions(destination)) + if testCase.originRoot { + destinationRoot := destination.GetRoot() + assert.NotNil(t, destinationRoot) + assert.EqualValues(t, kindTestNodeLevelOne, destinationRoot.GetKind()) + } else { + assert.EqualValues(t, nil, destination.GetRoot()) + } + }) + } +} diff --git a/tree/generic/walk_options.go b/tree/generic/walk_options.go new file mode 100644 index 0000000..eea4312 --- /dev/null +++ b/tree/generic/walk_options.go @@ -0,0 +1,63 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + + "code.forgejo.org/f3/gof3/v3/path" +) + +type WalkFunc func(context.Context, path.Path, NodeInterface) + +type WalkOptions struct { + fun WalkFunc +} + +func NewWalkOptions(fun WalkFunc) *WalkOptions { + return &WalkOptions{ + fun: fun, + } +} + +type ApplyFunc func(ctx context.Context, parentPath, path path.Path, node NodeInterface) + +type ApplyWhere bool + +const ( + ApplyEachNode ApplyWhere = true + ApplyLastNode ApplyWhere = false +) + +type ApplySearch bool + +const ( + ApplySearchByName ApplySearch = true + ApplySearchByID ApplySearch = false +) + +type ApplyOptions struct { + fun ApplyFunc + where ApplyWhere + search ApplySearch +} + +func NewApplyOptions(fun ApplyFunc) *ApplyOptions { + return &ApplyOptions{ + fun: fun, + where: ApplyLastNode, + search: ApplySearchByID, + } +} + +func (o *ApplyOptions) SetWhere(where ApplyWhere) *ApplyOptions { + o.where = where + return o +} + +func (o *ApplyOptions) SetSearch(search ApplySearch) *ApplyOptions { + o.search = search + return o +} diff --git a/tree/memory/memory.go b/tree/memory/memory.go new file mode 100644 index 0000000..e35a524 --- /dev/null +++ b/tree/memory/memory.go @@ -0,0 +1,350 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package memory + +import ( + "context" + "fmt" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/options" + options_logger "code.forgejo.org/f3/gof3/v3/options/logger" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +type IDAllocatorInterface interface { + allocateID(id string) string + isNull() bool +} + +type idAllocatorGenerator struct { + prefix string + lastID rune +} + +func NewIDAllocatorGenerator(prefix string) IDAllocatorInterface { + return &idAllocatorGenerator{ + prefix: prefix, + lastID: 'A', + } +} + +func (o *idAllocatorGenerator) allocateID(string) string { + r := fmt.Sprintf("%s-%c", o.prefix, o.lastID) + o.lastID++ + return r +} + +func (o *idAllocatorGenerator) isNull() bool { return false } + +type idAllocatorNull struct{} + +func (o *idAllocatorNull) allocateID(id string) string { return id } + +func (o *idAllocatorNull) isNull() bool { return true } + +func NewIDAllocatorNull() IDAllocatorInterface { + return &idAllocatorNull{} +} + +type memoryOptions struct { + options.Options + options_logger.OptionsLogger + + IDAllocator IDAllocatorInterface +} + +func NewOptions(idAllocator IDAllocatorInterface) options.Interface { + opts := &memoryOptions{} + opts.IDAllocator = idAllocator + opts.SetName("memory") + l := logger.NewLogger() + l.SetLevel(logger.Trace) + opts.SetLogger(l) + return opts +} + +type memoryStorage struct { + idAllocator IDAllocatorInterface + root *memoryStorageNode +} + +func newmemoryStorage(opts options.Interface) *memoryStorage { + return &memoryStorage{ + idAllocator: opts.(*memoryOptions).IDAllocator, + } +} + +func (o *memoryStorage) newStorageNode(id string) *memoryStorageNode { + return &memoryStorageNode{ + storage: o, + f: NewFormat(o.idAllocator.allocateID(id)), + children: make(map[string]*memoryStorageNode), + } +} + +func (o *memoryStorage) Find(path path.PathString) *memoryStorageNode { + p := path.Elements() + fmt.Printf("memoryStorage Find %s\n", path.Join()) + current := o.root + for { + fmt.Printf(" memoryStorage Find lookup '%s'\n", current.f.GetID()) + if current.f.GetID() != p[0] { + panic("") + } + p = p[1:] + if len(p) == 0 { + return current + } + if next, ok := current.children[p[0]]; ok { + current = next + } else { + return nil + } + } +} + +type memoryStorageNode struct { + storage *memoryStorage + f *FormatMemory + children map[string]*memoryStorageNode +} + +type treeDriver struct { + generic.NullTreeDriver + storage *memoryStorage + allocateID bool +} + +func newTreeDriver(opts options.Interface) *treeDriver { + tree := &treeDriver{ + storage: newmemoryStorage(opts), + allocateID: !opts.(*memoryOptions).IDAllocator.isNull(), + } + tree.Init() + return tree +} + +func (o *treeDriver) AllocateID() bool { return o.allocateID } + +func (o *treeDriver) Factory(ctx context.Context, kind kind.Kind) generic.NodeDriverInterface { + return &Driver{} +} + +type Driver struct { + generic.NullDriver + f *FormatMemory +} + +type FormatMemory struct { + f3.Common + Ref *f3.Reference + Content string +} + +func NewFormat(id string) *FormatMemory { + f := &FormatMemory{} + f.Ref = f3.NewReference("") + f.Content = "????" + f.SetID(id) + return f +} + +func (o *FormatMemory) GetReferences() f3.References { + references := o.Common.GetReferences() + if o.Ref.Get() != "" { + references = append(references, o.Ref) + } + return references +} + +func (o *Driver) NewFormat() f3.Interface { + return &FormatMemory{} +} + +func (o *Driver) ToFormat() f3.Interface { + if o == nil || o.f == nil { + return o.NewFormat() + } + return &FormatMemory{ + Common: o.f.Common, + Ref: o.f.Ref, + Content: o.f.Content, + } +} + +func (o *Driver) FromFormat(f f3.Interface) { + m := f.(*FormatMemory) + o.f = &FormatMemory{ + Common: m.Common, + Ref: m.Ref, + Content: m.Content, + } +} + +func (o *Driver) Equals(ctx context.Context, other generic.NodeInterface) bool { + switch i := other.GetDriver().(type) { + case *Driver: + return o.f.Content == i.f.Content + default: + return false + } +} + +func (o *Driver) String() string { + if o.f == nil { + return "" + } + return o.f.Content +} + +func (o *Driver) Get(ctx context.Context) bool { + node := o.GetNode() + o.Trace("id '%s' '%s'", node.GetID(), node.GetCurrentPath().ReadableString()) + storage := o.getStorage(node, node.GetCurrentPath()) + if storage != nil { + o.f = storage.f + } + node.List(ctx) + return true +} + +func (o *Driver) Put(ctx context.Context) id.NodeID { + return o.upsert(ctx) +} + +func (o *Driver) Patch(ctx context.Context) { + o.upsert(ctx) +} + +func (o *Driver) upsert(ctx context.Context) id.NodeID { + node := o.GetNode() + path := node.GetParent().GetCurrentPath() + storageParent := o.getStorage(node, path) + i := node.GetID() + o.Trace("node id '%s'", i) + if existing, ok := storageParent.children[i.String()]; ok { + o.Trace("update %s content=%s ref=%s", node.GetCurrentPath().ReadableString(), o.f.Content, o.f.Ref) + existing.f = o.f + } else { + storageChild := storageParent.storage.newStorageNode(i.String()) + storageID := storageChild.f.GetID() + storageParent.children[storageID] = storageChild + i = id.NewNodeID(storageID) + if o.f == nil { + o.f = storageChild.f + } else { + o.f.SetID(storageChild.f.GetID()) + } + o.Trace("create '%s' '%s'", node.GetCurrentPath().ReadableString(), i) + } + return i +} + +func (o *Driver) Delete(ctx context.Context) { + node := o.GetNode() + storage := o.getStorage(node, node.GetParent().GetCurrentPath()) + if storage != nil && storage.children != nil { + id := node.GetID().String() + delete(storage.children, id) + } +} + +func (o *Driver) getStorage(node generic.NodeInterface, path path.Path) *memoryStorageNode { + storage := node.GetTree().GetDriver().(*treeDriver).storage + return storage.Find(path.PathString()) +} + +func (o *Driver) ListPage(ctx context.Context, page int) generic.ChildrenSlice { + node := o.GetNode() + o.Trace("'%s'", node.GetCurrentPath().ReadableString()) + storage := o.getStorage(node, node.GetCurrentPath()) + + children := generic.NewChildrenSlice(0) + if storage != nil { + for i := range storage.children { + node := node.CreateChild(context.Background()) + childID := id.NewNodeID(i) + node.SetID(childID) + children = append(children, node) + } + } + return children +} + +func (o *Driver) GetIDFromName(ctx context.Context, content string) id.NodeID { + node := o.GetNode() + o.Trace("'%s'", node.GetCurrentPath().ReadableString()) + storage := o.getStorage(node, node.GetCurrentPath()) + + if storage != nil { + for i, child := range storage.children { + if child.f.Content == content { + o.Trace("found '%s'", i) + return id.NewNodeID(i) + } + } + } + return id.NilID +} + +type treeMemory struct { + generic.Tree +} + +func newTreeMemory(ctx context.Context, opts options.Interface) generic.TreeInterface { + t := &treeMemory{} + t.Init(t, opts) + t.GetLogger().SetLevel(logger.Trace) + t.Register(kind.KindNil, func(ctx context.Context, kind kind.Kind) generic.NodeInterface { + return generic.NewNode() + }) + + treeDriver := newTreeDriver(opts) + t.SetDriver(treeDriver) + + f := NewFormat("") + f.Content = "ROOT" + + storage := treeDriver.storage + storage.root = &memoryStorageNode{ + storage: storage, + f: f, + children: make(map[string]*memoryStorageNode), + } + root := t.Factory(ctx, kind.KindRoot) + root.FromFormat(f) + t.SetRoot(root) + + return t +} + +func SetContent(node generic.NodeInterface, content string) { + f := node.GetDriver().ToFormat().(*FormatMemory) + f.Content = content + node.FromFormat(f) +} + +func GetContent(node generic.NodeInterface) string { + return node.GetDriver().ToFormat().(*FormatMemory).Content +} + +func SetRef(node generic.NodeInterface, ref string) { + f := node.GetDriver().ToFormat().(*FormatMemory) + f.Ref = f3.NewReference(ref) + node.FromFormat(f) +} + +func GetRef(node generic.NodeInterface) string { + return node.GetDriver().ToFormat().(*FormatMemory).Ref.Get() +} + +func init() { + generic.RegisterFactory("memory", newTreeMemory) +} diff --git a/tree/tests/f3/creator.go b/tree/tests/f3/creator.go new file mode 100644 index 0000000..5f85ee0 --- /dev/null +++ b/tree/tests/f3/creator.go @@ -0,0 +1,397 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "strings" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + tests_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/logger" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + + "github.com/stretchr/testify/require" +) + +var RootUser *f3.User = &f3.User{ + UserName: "root", +} + +type Creator struct { + logger logger.Interface + t TestingT + d string + name string + serial int +} + +func now() time.Time { + return time.Now().Truncate(time.Second) +} + +func tick(now *time.Time) time.Time { + *now = now.Add(1 * time.Minute) + return *now +} + +func NewCreator(t TestingT, name string, logger logger.Interface) *Creator { + return &Creator{ + t: t, + d: t.TempDir(), + logger: logger, + name: name, + } +} + +func (f *Creator) randomString(prefix string) string { + f.serial++ + return fmt.Sprintf("%s%s%015d", prefix, f.name, f.serial) +} + +func (f *Creator) GetDirectory() string { + return f.d +} + +func (f *Creator) Generate(k kind.Kind, parent path.Path) f3.Interface { + switch k { + case f3_tree.KindForge: + return f.GenerateForge() + case f3_tree.KindUser: + return f.GenerateUser() + case f3_tree.KindOrganization: + return f.GenerateOrganization() + case f3_tree.KindProject: + return f.GenerateProject() + case f3_tree.KindIssue: + return f.GenerateIssue(parent) + case f3_tree.KindMilestone: + return f.GenerateMilestone() + case f3_tree.KindTopic: + return f.GenerateTopic() + case f3_tree.KindReaction: + return f.GenerateReaction(parent) + case f3_tree.KindLabel: + return f.GenerateLabel() + case f3_tree.KindComment: + return f.GenerateComment(parent) + case f3_tree.KindRepository: + return f.GenerateRepository(f3.RepositoryNameDefault) + case f3_tree.KindRelease: + return f.GenerateRelease(parent) + case f3_tree.KindAsset: + return f.GenerateAsset(parent) + case f3_tree.KindPullRequest: + return f.GeneratePullRequest(parent) + case f3_tree.KindReview: + return f.GenerateReview(parent) + case f3_tree.KindReviewComment: + return f.GenerateReviewComment(parent) + default: + panic(fmt.Errorf("not implemented %s", k)) + } +} + +func (f *Creator) GenerateForge() *f3.Forge { + return &f3.Forge{ + Common: f3.NewCommon("forge"), + } +} + +func (f *Creator) GenerateUser() *f3.User { + username := f.randomString("user") + return &f3.User{ + Name: username + " Doe", + UserName: username, + Email: username + "@example.com", + Password: "Wrobyak4", + } +} + +func (f *Creator) GenerateOrganization() *f3.Organization { + orgname := f.randomString("org") + return &f3.Organization{ + FullName: orgname + " Lambda", + Name: orgname, + } +} + +func (f *Creator) GenerateProject() *f3.Project { + projectname := f.randomString("project") + + return &f3.Project{ + Name: projectname, + IsPrivate: false, + IsMirror: false, + Description: "project description", + DefaultBranch: "master", + } +} + +func (f *Creator) GenerateForkedProject(parent path.Path, forked string) *f3.Project { + project := f.GenerateProject() + project.Forked = f3.NewReference(forked) + return project +} + +func (f *Creator) GenerateRepository(name string) *f3.Repository { + repository := &f3.Repository{ + Name: name, + } + p := f.t.TempDir() + helper := tests_repository.NewTestHelper(f.t, p, nil) + helper.CreateRepositoryContent("").PushMirror() + repository.FetchFunc = func(ctx context.Context, destination string, internalRefs []string) { + f.logger.Debug("%s %s", p, destination) + helpers_repository.GitMirror(context.Background(), nil, p, destination, internalRefs) + } + return repository +} + +func (f *Creator) GenerateReaction(parent path.Path) *f3.Reaction { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + return &f3.Reaction{ + UserID: f3_tree.NewUserReference(user.GetID()), + Content: "heart", + } +} + +func (f *Creator) GenerateMilestone() *f3.Milestone { + now := now() + created := tick(&now) + updated := tick(&now) + deadline := tick(&now) + + return &f3.Milestone{ + Title: "milestone1", + Description: "milestone1 description", + Deadline: &deadline, + Created: created, + Updated: &updated, + Closed: nil, + State: f3.MilestoneStateOpen, + } +} + +func (f *Creator) GeneratePullRequest(parent path.Path) *f3.PullRequest { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(f.t, "", repositoryNode) + + mainRef := "master" + mainSha := repositoryHelper.GetRepositorySha(mainRef) + featureRef := "feature" + repositoryHelper.InternalBranchRepositoryFeature(featureRef, "feature content") + featureSha := repositoryHelper.GetRepositorySha(featureRef) + f.logger.Debug("master %s at main %s feature %s", repositoryHelper.GetBare(), mainSha, featureSha) + repositoryHelper.PushMirror() + + now := now() + prCreated := tick(&now) + prUpdated := tick(&now) + + return &f3.PullRequest{ + PosterID: f3_tree.NewUserReference(user.GetID()), + Title: "pr title", + Content: "pr content", + State: f3.PullRequestStateOpen, + IsLocked: false, + Created: prCreated, + Updated: prUpdated, + Closed: nil, + Merged: false, + MergedTime: nil, + MergeCommitSHA: "", + Head: f3.PullRequestBranch{ + Ref: featureRef, + SHA: featureSha, + Repository: f3_tree.NewPullRequestSameRepositoryReference(), + }, + Base: f3.PullRequestBranch{ + Ref: mainRef, + SHA: mainSha, + Repository: f3_tree.NewPullRequestSameRepositoryReference(), + }, + } +} + +func (f *Creator) GenerateLabel() *f3.Label { + name := f.randomString("label") + return &f3.Label{ + Name: name, + Color: "ffffff", + Description: name + " description", + } +} + +func (f *Creator) GenerateTopic() *f3.Topic { + return &f3.Topic{ + Name: "topic1", + } +} + +func (f *Creator) GenerateIssue(parent path.Path) *f3.Issue { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + + labelsNode := projectNode.Find(generic.NewPathFromString("labels")) + require.NotEqualValues(f.t, generic.NilNode, labelsNode) + labels := labelsNode.GetChildren() + require.NotEmpty(f.t, labels) + firstLabel := labels[0] + + milestonesNode := projectNode.Find(generic.NewPathFromString("milestones")) + require.NotEqualValues(f.t, generic.NilNode, milestonesNode) + milestones := milestonesNode.GetChildren() + require.NotEmpty(f.t, milestones) + firstMilestone := milestones[0] + + now := now() + updated := tick(&now) + closed := tick(&now) + created := tick(&now) + + return &f3.Issue{ + PosterID: f3_tree.NewUserReference(user.GetID()), + Assignees: []*f3.Reference{f3_tree.NewUserReference(user.GetID())}, + Labels: []*f3.Reference{f3_tree.NewIssueLabelReference(firstLabel.GetID())}, + Milestone: f3_tree.NewIssueMilestoneReference(firstMilestone.GetID()), + Title: "title", + Content: "content", + State: f3.IssueStateOpen, + IsLocked: false, + Created: created, + Updated: updated, + Closed: &closed, + } +} + +func (f *Creator) GenerateComment(parent path.Path) *f3.Comment { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + now := now() + commentCreated := tick(&now) + commentUpdated := tick(&now) + + posterID := f3_tree.NewUserReference(user.GetID()) + + return &f3.Comment{ + PosterID: posterID, + Created: commentCreated, + Updated: commentUpdated, + Content: "comment content", + } +} + +func (f *Creator) GenerateRelease(parent path.Path) *f3.Release { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + project := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repository := project.Find(generic.NewPathFromString("repositories/vcs")) + repository.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(f.t, "", repository) + + now := now() + releaseCreated := tick(&now) + + tag := "releasetagv12" + repositoryHelper.CreateRepositoryTag(tag, "master") + sha := repositoryHelper.GetRepositorySha("master") + repositoryHelper.PushMirror() + + return &f3.Release{ + TagName: tag, + TargetCommitish: sha, + Name: "v12 name", + Body: "v12 body", + Draft: false, + Prerelease: false, + PublisherID: f3_tree.NewUserReference(user.GetID()), + Created: releaseCreated, + } +} + +func (f *Creator) GenerateAsset(parent path.Path) *f3.ReleaseAsset { + name := "assetname" + content := "assetcontent" + downloadURL := "downloadURL" + now := now() + assetCreated := tick(&now) + + size := len(content) + downloadCount := int64(10) + sha256 := fmt.Sprintf("%x", sha256.Sum256([]byte(content))) + + return &f3.ReleaseAsset{ + Name: name, + Size: int64(size), + DownloadCount: downloadCount, + Created: assetCreated, + SHA256: sha256, + DownloadURL: downloadURL, + DownloadFunc: func() io.ReadCloser { + rc := io.NopCloser(strings.NewReader(content)) + return rc + }, + } +} + +func (f *Creator) GenerateReview(parent path.Path) *f3.Review { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(f.t, "", repositoryNode) + + now := now() + reviewCreated := tick(&now) + + featureSha := repositoryHelper.GetRepositorySha("feature") + + return &f3.Review{ + ReviewerID: f3_tree.NewUserReference(user.GetID()), + Official: true, + CommitID: featureSha, + Content: "the review content", + CreatedAt: reviewCreated, + State: f3.ReviewStateCommented, + } +} + +func (f *Creator) GenerateReviewComment(parent path.Path) *f3.ReviewComment { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(f.t, "", repositoryNode) + + now := now() + commentCreated := tick(&now) + commentUpdated := tick(&now) + + featureSha := repositoryHelper.GetRepositorySha("feature") + + return &f3.ReviewComment{ + Content: "comment content", + TreePath: "README.md", + DiffHunk: "@@ -108,7 +108,6 @@", + Line: 1, + CommitID: featureSha, + PosterID: f3_tree.NewUserReference(user.GetID()), + CreatedAt: commentCreated, + UpdatedAt: commentUpdated, + } +} diff --git a/tree/tests/f3/f3_test.go b/tree/tests/f3/f3_test.go new file mode 100644 index 0000000..75bdd47 --- /dev/null +++ b/tree/tests/f3/f3_test.go @@ -0,0 +1,260 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + tests_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/stretchr/testify/assert" +) + +func TestF3Mirror(t *testing.T) { + ctx := context.Background() + + for _, factory := range tests_forge.GetFactories() { + testForge := factory() + t.Run(testForge.GetName(), func(t *testing.T) { + // testCase.options will t.Skip if the forge instance is not up + forgeWriteOptions := testForge.NewOptions(t) + forgeReadOptions := testForge.NewOptions(t) + forgeReadOptions.(options.URLInterface).SetURL(forgeWriteOptions.(options.URLInterface).GetURL()) + + fixtureTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + fixtureTree.Trace("======= build fixture") + TreeBuildPartial(t, "F3Mirror"+testForge.GetName(), testForge.GetKindExceptions(), forgeWriteOptions, fixtureTree) + + fixtureTree.Trace("======= mirror fixture to forge") + + forgeWriteTree := generic.GetFactory("f3")(ctx, forgeWriteOptions) + generic.TreeMirror(ctx, fixtureTree, forgeWriteTree, generic.NewPathFromString(""), generic.NewMirrorOptions()) + + paths := []string{""} + if testForge.GetName() != filesystem_options.Name { + paths = []string{"/forge/users/10111", "/forge/users/20222"} + } + pathPairs := make([][2]path.Path, 0, 5) + for _, p := range paths { + p := generic.NewPathFromString(p) + pathPairs = append(pathPairs, [2]path.Path{p, generic.TreePathRemap(ctx, fixtureTree, p, forgeWriteTree)}) + } + + fixtureTree.Trace("======= read from forge") + + forgeReadTree := generic.GetFactory("f3")(ctx, forgeReadOptions) + forgeReadTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + + fixtureTree.Trace("======= mirror forge to filesystem") + + verificationTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + + for _, pathPair := range pathPairs { + generic.TreeMirror(ctx, forgeReadTree, verificationTree, pathPair[1], generic.NewMirrorOptions()) + } + + fixtureTree.Trace("======= compare fixture with forge mirrored to filesystem") + for _, pathPair := range pathPairs { + assert.True(t, generic.TreeCompare(ctx, fixtureTree, pathPair[0], verificationTree, pathPair[1])) + } + + TreeDelete(t, testForge.GetNonTestUsers(), forgeWriteOptions, forgeWriteTree) + }) + } +} + +func TestF3Tree(t *testing.T) { + ctx := context.Background() + + verify := func(tree generic.TreeInterface, expected []string) { + collected := make([]string, 0, 10) + collect := func(ctx context.Context, path path.Path, node generic.NodeInterface) { + path = path.Append(node) + collected = append(collected, path.String()) + } + tree.WalkAndGet(ctx, generic.NewWalkOptions(collect)) + sort.Strings(expected) + sort.Strings(collected) + assert.EqualValues(t, expected, collected) + } + + for _, testCase := range []struct { + name string + build func(tree generic.TreeInterface) + operations func(tree generic.TreeInterface) + expected []string + }{ + { + name: "full tree", + build: func(tree generic.TreeInterface) { TreeBuild(t, "F3", nil, tree) }, + operations: func(tree generic.TreeInterface) {}, + expected: []string{ + "", + "/forge", + "/forge/organizations", + "/forge/organizations/3330001", + "/forge/organizations/3330001/projects", + "/forge/topics", + "/forge/topics/14411441", + "/forge/users", + "/forge/users/10111", + "/forge/users/10111/projects", + "/forge/users/10111/projects/74823", + "/forge/users/10111/projects/74823/issues", + "/forge/users/10111/projects/74823/issues/1234567", + "/forge/users/10111/projects/74823/issues/1234567/comments", + "/forge/users/10111/projects/74823/issues/1234567/comments/1111999", + "/forge/users/10111/projects/74823/issues/1234567/comments/1111999/reactions", + "/forge/users/10111/projects/74823/issues/1234567/reactions", + "/forge/users/10111/projects/74823/issues/1234567/reactions/1212", + "/forge/users/10111/projects/74823/labels", + "/forge/users/10111/projects/74823/labels/7777", + "/forge/users/10111/projects/74823/labels/99999", + "/forge/users/10111/projects/74823/milestones", + "/forge/users/10111/projects/74823/milestones/7888", + "/forge/users/10111/projects/74823/pull_requests", + "/forge/users/10111/projects/74823/pull_requests/2222", + "/forge/users/10111/projects/74823/pull_requests/2222/comments", + "/forge/users/10111/projects/74823/pull_requests/2222/reactions", + "/forge/users/10111/projects/74823/pull_requests/2222/reviews", + "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593", + "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593/reactions", + "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593/reviewcomments", + "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593/reviewcomments/9876543", + "/forge/users/10111/projects/74823/releases", + "/forge/users/10111/projects/74823/releases/123", + "/forge/users/10111/projects/74823/releases/123/assets", + "/forge/users/10111/projects/74823/releases/123/assets/585858", + "/forge/users/10111/projects/74823/repositories", + "/forge/users/10111/projects/74823/repositories/vcs", + "/forge/users/20222", + "/forge/users/20222/projects", + "/forge/users/20222/projects/99099", + "/forge/users/20222/projects/99099/issues", + "/forge/users/20222/projects/99099/labels", + "/forge/users/20222/projects/99099/milestones", + "/forge/users/20222/projects/99099/pull_requests", + "/forge/users/20222/projects/99099/releases", + "/forge/users/20222/projects/99099/repositories", + "/forge/users/20222/projects/99099/repositories/vcs", + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + tree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + + tree.Trace("======= BUILD") + testCase.build(tree) + tree.Trace("======= OPERATIONS") + testCase.operations(tree) + + tree.Trace("======= VERIFY") + verify(tree, testCase.expected) + + tree.Clear(ctx) + + tree.Trace("======= VERIFY STEP 2") + verify(tree, testCase.expected) + + tree.Trace("======= COMPARE") + otherTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + otherTree.GetOptions().(options.URLInterface).SetURL(tree.GetOptions().(options.URLInterface).GetURL()) + otherTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + assert.True(t, generic.TreeCompare(ctx, tree, generic.NewPathFromString(""), otherTree, generic.NewPathFromString(""))) + }) + } +} + +func TestF3Repository(t *testing.T) { + ctx := context.Background() + + verify := func(tree generic.TreeInterface, name string) { + repositoryPath := generic.NewPathFromString(filepath.Join("/forge/users/10111/projects/74823/repositories", name)) + found := false + tree.Apply(ctx, repositoryPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + node.Get(ctx) + node.List(ctx) + if node.GetKind() != f3_tree.KindRepository { + return + } + helper := tests_repository.NewTestHelper(t, "", node) + helper.AssertRepositoryFileExists("README.md") + if name == f3.RepositoryNameDefault { + helper.AssertRepositoryTagExists("releasetagv12") + helper.AssertRepositoryBranchExists("feature") + } + found = true + }).SetWhere(generic.ApplyEachNode)) + assert.True(t, found) + } + + for _, name := range []string{f3.RepositoryNameDefault} { + t.Run(name, func(t *testing.T) { + tree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + + tree.Trace("======= BUILD") + TreeBuild(t, "F3", nil, tree) + + tree.Trace("======= VERIFY") + verify(tree, name) + + tree.Clear(ctx) + tree.Trace("======= VERIFY STEP 2") + verify(tree, name) + }) + } +} + +func TestF3Asset(t *testing.T) { + ctx := context.Background() + + content := "OTHER CONTENT" + expectedSHA256 := fmt.Sprintf("%x", sha256.Sum256([]byte(content))) + opts := tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t) + { + tree := generic.GetFactory("f3")(ctx, opts) + TreeBuild(t, "F3", nil, tree) + + assetPath := generic.NewPathFromString("/forge/users/10111/projects/74823/releases/123/assets/585858") + asset := tree.Find(assetPath) + assert.False(t, asset.GetIsNil()) + + assetFormat := asset.ToFormat().(*f3.ReleaseAsset) + assetFormat.DownloadFunc = func() io.ReadCloser { + rc := io.NopCloser(strings.NewReader(content)) + return rc + } + + asset.FromFormat(assetFormat) + asset.Upsert(ctx) + assetFormat = asset.ToFormat().(*f3.ReleaseAsset) + assert.EqualValues(t, expectedSHA256, assetFormat.SHA256) + } + { + tree := generic.GetFactory("f3")(ctx, opts) + tree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + + assetPath := generic.NewPathFromString("/forge/users/10111/projects/74823/releases/123/assets/585858") + asset := tree.Find(assetPath) + assert.False(t, asset.GetIsNil()) + + assetFormat := asset.ToFormat().(*f3.ReleaseAsset) + assert.EqualValues(t, expectedSHA256, assetFormat.SHA256) + } +} diff --git a/tree/tests/f3/filesystem_test.go b/tree/tests/f3/filesystem_test.go new file mode 100644 index 0000000..5d55557 --- /dev/null +++ b/tree/tests/f3/filesystem_test.go @@ -0,0 +1,105 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "path/filepath" + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/stretchr/testify/assert" +) + +func TestF3FilesystemMappedID(t *testing.T) { + ctx := context.Background() + + // + // aTree only has /forge/user/10111 + // + aTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + aDir := aTree.GetOptions().(options.URLInterface).GetURL() + + creator := NewCreator(t, "F3", aTree.GetLogger()) + + aF3Tree := aTree.(f3_tree.TreeInterface) + + userID := "10111" + + aF3Tree.CreateChild(ctx, "/", func(parent path.Path, forge generic.NodeInterface) { + forge.FromFormat(creator.GenerateForge()) + }) + + aF3Tree.CreateChild(ctx, "/forge/users", func(parent path.Path, user generic.NodeInterface) { + user.FromFormat(GeneratorSetID(creator.GenerateUser(), userID)) + }) + + // + // bTree mirrors aTree exactly + // + rootPath := generic.NewPathFromString("") + + bTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + + generic.TreeMirror(ctx, aTree, bTree, rootPath, generic.NewMirrorOptions()) + + assert.True(t, generic.TreeCompare(ctx, aTree, rootPath, bTree, rootPath)) + + // + // aTree maps user id 10111 to 10111.mapped + // + userPath := generic.NewPathFromString(filepath.Join("/forge/users", userID)) + + userMappedID := id.NewNodeID("10111.mapped") + + assert.True(t, aTree.Apply(ctx, userPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + node.SetMappedID(userMappedID) + node.Upsert(ctx) + }))) + + aTree = generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + aTree.GetOptions().(options.URLInterface).SetURL(aDir) + + aTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + assert.NotEqualValues(t, generic.NilNode, aTree.Find(userPath)) + + // + // cTree mirrors aTree with user id 10111 remapped to 10111.mapped + // + cTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + cDir := cTree.GetOptions().(options.URLInterface).GetURL() + + generic.TreeMirror(ctx, aTree, cTree, rootPath, generic.NewMirrorOptions()) + + userMappedPath := generic.NewPathFromString(filepath.Join("/forge/users", userMappedID.String())) + + assert.NotEqualValues(t, generic.NilNode, cTree.Find(userMappedPath)) + assert.EqualValues(t, generic.NilNode, cTree.Find(userPath)) + + // + // reset cTree and read from the filesystem + // + cTree = generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + cTree.GetOptions().(options.URLInterface).SetURL(cDir) + + cTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + + assert.NotEqualValues(t, generic.NilNode, cTree.Find(userMappedPath)) + assert.EqualValues(t, generic.NilNode, cTree.Find(userPath)) + + // + // delete aTree user + // + deleted := aTree.Find(userPath).Delete(ctx) + assert.EqualValues(t, userMappedID, deleted.GetMappedID()) + assert.EqualValues(t, generic.NilNode, cTree.Find(userPath)) +} diff --git a/tree/tests/f3/fixture.go b/tree/tests/f3/fixture.go new file mode 100644 index 0000000..7e1c5d4 --- /dev/null +++ b/tree/tests/f3/fixture.go @@ -0,0 +1,224 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "path/filepath" + "slices" + "sort" + "testing" + + "code.forgejo.org/f3/gof3/v3/f3" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +var KindToFixturePath = map[kind.Kind]string{ + f3_tree.KindTopics: "/forge/topics", + f3_tree.KindTopic: "/forge/topics/14411441", + f3_tree.KindUsers: "/forge/users", + f3_tree.KindUser: "/forge/users/10111", + f3_tree.KindProjects: "/forge/users/10111/projects", + f3_tree.KindProject: "/forge/users/10111/projects/74823", + f3_tree.KindLabels: "/forge/users/10111/projects/74823/labels", + f3_tree.KindLabel: "/forge/users/10111/projects/74823/labels/7777", + f3_tree.KindIssues: "/forge/users/10111/projects/74823/issues", + f3_tree.KindIssue: "/forge/users/10111/projects/74823/issues/1234567", + f3_tree.KindPullRequests: "/forge/users/10111/projects/74823/pull_requests", + f3_tree.KindPullRequest: "/forge/users/10111/projects/74823/pull_requests/2222", + f3_tree.KindReviews: "/forge/users/10111/projects/74823/pull_requests/2222/reviews", + f3_tree.KindReview: "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593", + f3_tree.KindReviewComments: "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593/reviewcomments", + f3_tree.KindReviewComment: "/forge/users/10111/projects/74823/pull_requests/2222/reviews/4593/reviewcomments/9876543", + f3_tree.KindMilestones: "/forge/users/10111/projects/74823/milestones", + f3_tree.KindMilestone: "/forge/users/10111/projects/74823/milestones/7888", + f3_tree.KindReactions: "/forge/users/10111/projects/74823/issues/1234567/reactions", + f3_tree.KindReaction: "/forge/users/10111/projects/74823/issues/1234567/reactions/1212", + f3_tree.KindComments: "/forge/users/10111/projects/74823/issues/1234567/comments", + f3_tree.KindComment: "/forge/users/10111/projects/74823/issues/1234567/comments/1111999", + f3_tree.KindRepositories: "/forge/users/10111/projects/74823/repositories", + f3_tree.KindRepository: "/forge/users/10111/projects/74823/repositories/vcs", + f3_tree.KindReleases: "/forge/users/10111/projects/74823/releases", + f3_tree.KindRelease: "/forge/users/10111/projects/74823/releases/123", + f3_tree.KindAssets: "/forge/users/10111/projects/74823/releases/123/assets", + f3_tree.KindAsset: "/forge/users/10111/projects/74823/releases/123/assets/585858", + f3_tree.KindOrganizations: "/forge/organizations", + f3_tree.KindOrganization: "/forge/organizations/3330001", +} + +var KindWithFixturePath = SetKindWithFixturePath() + +func SetKindWithFixturePath() []kind.Kind { + l := make([]kind.Kind, 0, len(KindToFixturePath)) + + for kind := range KindToFixturePath { + l = append(l, kind) + } + sort.Slice(l, func(i, j int) bool { return string(l[i]) < string(l[j]) }) + return l +} + +func TreeBuild(t *testing.T, name string, opts options.Interface, tree generic.TreeInterface) { + TreeBuildPartial(t, name, []kind.Kind{}, opts, tree) +} + +func TreeBuildPartial(t *testing.T, name string, exceptions []kind.Kind, opts options.Interface, tree generic.TreeInterface) { + ctx := context.Background() + + creator := NewCreator(t, name, tree.GetLogger()) + + f3Tree := tree.(f3_tree.TreeInterface) + url := "" + if urlInterface, ok := opts.(options.URLInterface); ok { + url = urlInterface.GetURL() + } + + f3Tree.CreateChild(ctx, "/", func(parent path.Path, forge generic.NodeInterface) { + f := creator.GenerateForge() + f.URL = url + forge.FromFormat(f) + }) + + if slices.Contains(exceptions, f3_tree.KindUsers) { + return + } + + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindUsers], func(parent path.Path, user generic.NodeInterface) { + user.FromFormat(GeneratorSetID(creator.GenerateUser(), "10111")) + }) + + if slices.Contains(exceptions, f3_tree.KindProjects) { + return + } + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindProjects], func(parent path.Path, project generic.NodeInterface) { + project.FromFormat(GeneratorSetID(creator.GenerateProject(), "74823")) + }) + + if slices.Contains(exceptions, f3_tree.KindRepositories) { + return + } + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindRepositories], func(parent path.Path, repository generic.NodeInterface) { + repository.FromFormat(GeneratorSetID(creator.GenerateRepository(f3.RepositoryNameDefault), f3.RepositoryNameDefault)) + }) + + if !slices.Contains(exceptions, f3_tree.KindReleases) { + + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindReleases], func(parent path.Path, release generic.NodeInterface) { + release.FromFormat(GeneratorSetID(creator.GenerateRelease(parent), "123")) + }) + + if !slices.Contains(exceptions, f3_tree.KindAssets) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindAssets], func(parent path.Path, asset generic.NodeInterface) { + asset.FromFormat(GeneratorSetID(creator.GenerateAsset(parent), "585858")) + }) + } + } + + reviewerID := "20222" + { + userID := "20222" + f3Tree.CreateChild(ctx, "/forge/users", func(parent path.Path, user generic.NodeInterface) { + user.FromFormat(GeneratorSetID(creator.GenerateUser(), userID)) + }) + projectID := "99099" + f3Tree.CreateChild(ctx, filepath.Join("/forge/users", userID, "projects"), func(parent path.Path, user generic.NodeInterface) { + user.FromFormat(GeneratorSetID(creator.GenerateForkedProject(parent, KindToFixturePath[f3_tree.KindProject]), projectID)) + }) + f3Tree.CreateChild(ctx, filepath.Join("/forge/users", userID, "projects", projectID, "repositories"), func(parent path.Path, repository generic.NodeInterface) { + repository.FromFormat(GeneratorSetID(creator.GenerateRepository(f3.RepositoryNameDefault), f3.RepositoryNameDefault)) + }) + } + + if !slices.Contains(exceptions, f3_tree.KindPullRequests) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindPullRequests], func(parent path.Path, pullRequest generic.NodeInterface) { + pullRequest.FromFormat(GeneratorSetID(creator.GeneratePullRequest(parent), "2222")) + }) + + if !slices.Contains(exceptions, f3_tree.KindReviews) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindReviews], func(parent path.Path, review generic.NodeInterface) { + reviewFormat := creator.GenerateReview(parent) + GeneratorSetID(reviewFormat, "4593") + reviewFormat.ReviewerID = f3_tree.NewUserReference(reviewerID) + review.FromFormat(reviewFormat) + }) + + if !slices.Contains(exceptions, f3_tree.KindReviewComments) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindReviewComments], func(parent path.Path, reviewComment generic.NodeInterface) { + reviewCommentFormat := creator.GenerateReviewComment(parent) + GeneratorSetID(reviewCommentFormat, "9876543") + reviewCommentFormat.PosterID = f3_tree.NewUserReference(reviewerID) + reviewComment.FromFormat(reviewCommentFormat) + }) + } + } + } + + if !slices.Contains(exceptions, f3_tree.KindLabels) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindLabels], func(parent path.Path, label generic.NodeInterface) { + label.FromFormat(GeneratorSetID(creator.GenerateLabel(), "99999")) + }) + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindLabels], func(parent path.Path, label generic.NodeInterface) { + label.FromFormat(GeneratorSetID(creator.GenerateLabel(), "7777")) + }) + } + + if !slices.Contains(exceptions, f3_tree.KindMilestones) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindMilestones], func(parent path.Path, milestone generic.NodeInterface) { + milestone.FromFormat(GeneratorSetID(creator.GenerateMilestone(), "7888")) + }) + } + + if !slices.Contains(exceptions, f3_tree.KindIssues) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindIssues], func(parent path.Path, issue generic.NodeInterface) { + issue.FromFormat(GeneratorSetID(creator.GenerateIssue(parent), "1234567")) + }) + + if !slices.Contains(exceptions, f3_tree.KindComments) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindComments], func(parent path.Path, comment generic.NodeInterface) { + comment.FromFormat(GeneratorSetID(creator.GenerateComment(parent), "1111999")) + }) + + if !slices.Contains(exceptions, f3_tree.KindReactions) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindReactions], func(parent path.Path, reaction generic.NodeInterface) { + reaction.FromFormat(GeneratorSetID(creator.GenerateReaction(parent), "1212")) + }) + } + } + } + + if !slices.Contains(exceptions, f3_tree.KindOrganizations) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindOrganizations], func(parent path.Path, organization generic.NodeInterface) { + organization.FromFormat(GeneratorSetID(creator.GenerateOrganization(), "3330001")) + }) + } + + if !slices.Contains(exceptions, f3_tree.KindTopics) { + f3Tree.CreateChild(ctx, KindToFixturePath[f3_tree.KindTopics], func(parent path.Path, topic generic.NodeInterface) { + topic.FromFormat(GeneratorSetID(creator.GenerateTopic(), "14411441")) + }) + } +} + +func TreeDelete(t *testing.T, nonTestUsers []string, options options.Interface, tree generic.TreeInterface) { + ctx := context.Background() + + for _, owners := range []path.Path{f3_tree.OrganizationsPath, f3_tree.UsersPath} { + for _, owner := range tree.Find(owners).List(ctx) { + if user, ok := owner.ToFormat().(*f3.User); ok { + if slices.Contains(nonTestUsers, user.UserName) { + continue + } + } + for _, project := range owner.Find(generic.NewPathFromString(f3_tree.KindProjects)).List(ctx) { + project.Delete(ctx) + } + owner.Delete(ctx) + } + } +} diff --git a/tree/tests/f3/forge/base.go b/tree/tests/f3/forge/base.go new file mode 100644 index 0000000..81f5205 --- /dev/null +++ b/tree/tests/f3/forge/base.go @@ -0,0 +1,30 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forge + +import ( + "os" + + "code.forgejo.org/f3/gof3/v3/kind" +) + +const ( + ComplianceNameForkedPullRequest = "forked_pull_request" +) + +type Base struct { + name string +} + +func (o *Base) GetName() string { return o.name } +func (o *Base) SetName(name string) { o.name = name } +func (o *Base) GetNonTestUsers() []string { return []string{} } + +func (o *Base) GetKindExceptions() []kind.Kind { return nil } +func (o *Base) GetNameExceptions() []string { return nil } + +func (o *Base) DeleteAfterCompliance() bool { + return os.Getenv("GOF3_TEST_COMPLIANCE_CLEANUP") != "false" +} diff --git a/tree/tests/f3/forge/factory.go b/tree/tests/f3/forge/factory.go new file mode 100644 index 0000000..798f432 --- /dev/null +++ b/tree/tests/f3/forge/factory.go @@ -0,0 +1,35 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forge + +import ( + "fmt" + "strings" +) + +type ( + Factory func() Interface + Factories map[string]Factory +) + +var factories = make(Factories, 10) + +func GetFactories() Factories { + return factories +} + +func RegisterFactory(name string, factory Factory) { + name = strings.ToLower(name) + factories[name] = factory +} + +func GetFactory(name string) Factory { + name = strings.ToLower(name) + factory, ok := factories[name] + if !ok { + panic(fmt.Errorf("no factory registered for %s", name)) + } + return factory +} diff --git a/tree/tests/f3/forge/interface.go b/tree/tests/f3/forge/interface.go new file mode 100644 index 0000000..85479f2 --- /dev/null +++ b/tree/tests/f3/forge/interface.go @@ -0,0 +1,26 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package forge + +import ( + "testing" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/options" +) + +type Interface interface { + GetName() string + SetName(string) + + DeleteAfterCompliance() bool + + GetKindExceptions() []kind.Kind + GetNameExceptions() []string + + GetNonTestUsers() []string + + NewOptions(t *testing.T) options.Interface +} diff --git a/tree/tests/f3/forge_compliance.go b/tree/tests/f3/forge_compliance.go new file mode 100644 index 0000000..24d7018 --- /dev/null +++ b/tree/tests/f3/forge_compliance.go @@ -0,0 +1,254 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "slices" + "testing" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + tests_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository" + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type remapper func(path string) path.Path + +func ForgeCompliance(t *testing.T, name string) { + testForge := tests_forge.GetFactory(name)() + + ctx := context.Background() + + forgeOptions := testForge.NewOptions(t) + forgeTree := generic.GetFactory("f3")(ctx, forgeOptions) + + forgeTree.Trace("======= build fixture") + fixtureTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + TreeBuildPartial(t, name, testForge.GetKindExceptions(), forgeOptions, fixtureTree) + + forgeTree.Trace("======= mirror fixture to forge") + + generic.TreeMirror(ctx, fixtureTree, forgeTree, generic.NewPathFromString(""), generic.NewMirrorOptions()) + + forgeTree.Trace("======= run compliance tests") + + remap := func(p string) path.Path { + return generic.TreePathRemap(ctx, fixtureTree, generic.NewPathFromString(p), forgeTree) + } + + kindToForgePath := make(map[kind.Kind]string, len(KindToFixturePath)) + for kind, path := range KindToFixturePath { + remappedPath := remap(path) + if remappedPath.Empty() { + forgeTree.Trace("%s was not mirrored, ignored", path) + continue + } + kindToForgePath[kind] = remappedPath.String() + } + + ComplianceKindTests(t, name, forgeTree.(f3_tree.TreeInterface), kindToForgePath, testForge.GetKindExceptions()) + ComplianceNameTests(t, name, forgeTree.(f3_tree.TreeInterface), remap, kindToForgePath, testForge.GetNameExceptions()) + + if testForge.DeleteAfterCompliance() { + TreeDelete(t, testForge.GetNonTestUsers(), forgeOptions, forgeTree) + } +} + +func ComplianceNameTests(t *testing.T, name string, tree f3_tree.TreeInterface, remap remapper, kindToForgePath map[kind.Kind]string, exceptions []string) { + t.Helper() + ctx := context.Background() + if !slices.Contains(exceptions, tests_forge.ComplianceNameForkedPullRequest) { + t.Run(tests_forge.ComplianceNameForkedPullRequest, func(t *testing.T) { + kind := kind.Kind(f3_tree.KindPullRequests) + p := kindToForgePath[kind] + parent := tree.Find(generic.NewPathFromString(p)) + require.NotEqualValues(t, generic.NilNode, parent, p) + child := tree.Factory(ctx, tree.GetChildrenKind(kind)) + childFormat := ComplianceForkedPullRequest(t, tree, remap, child.NewFormat().(*f3.PullRequest), parent.GetCurrentPath()) + + child.FromFormat(childFormat) + tree.Trace("'Upsert' the new forked pull request in the parent and store it in the forge") + child.SetParent(parent) + child.Upsert(ctx) + + tree.Trace("'Delete' child forked pull request") + child.Delete(ctx) + }) + } +} + +func ComplianceForkedPullRequest(t *testing.T, tree f3_tree.TreeInterface, remap remapper, pullRequest *f3.PullRequest, parent path.Path) *f3.PullRequest { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(t, "", repositoryNode) + + mainRef := "master" + mainSha := repositoryHelper.GetRepositorySha(mainRef) + + tree.Trace("create a feature branch in the /forge/users/20222/projects/99099 fork") + forkedRepositoryPath := remap("/forge/users/20222/projects/99099/repositories/vcs") + forkedRepositoryNode := tree.Find(forkedRepositoryPath) + require.NotEqual(t, forkedRepositoryNode, generic.NilNode) + forkedRepositoryHelper := tests_repository.NewTestHelper(t, "", forkedRepositoryNode) + featureRef := "generatedforkfeature" + forkedRepositoryHelper.InternalBranchRepositoryFeature(featureRef, featureRef+" content") + featureSha := forkedRepositoryHelper.GetRepositorySha(featureRef) + forkedRepositoryHelper.PushMirror() + + now := now() + prCreated := tick(&now) + prUpdated := tick(&now) + + pullRequest.PosterID = f3_tree.NewUserReference(user.GetID()) + pullRequest.Title = featureRef + " pr title" + pullRequest.Content = featureRef + " pr content" + pullRequest.State = f3.PullRequestStateOpen + pullRequest.IsLocked = false + pullRequest.Created = prCreated + pullRequest.Updated = prUpdated + pullRequest.Closed = nil + pullRequest.Merged = false + pullRequest.MergedTime = nil + pullRequest.MergeCommitSHA = "" + pullRequest.Head = f3.PullRequestBranch{ + Ref: featureRef, + SHA: featureSha, + Repository: f3.NewReference(forkedRepositoryPath.String()), + } + pullRequest.Base = f3.PullRequestBranch{ + Ref: mainRef, + SHA: mainSha, + Repository: f3.NewReference("../../repository/vcs"), + } + return pullRequest +} + +func ComplianceKindTests(t *testing.T, name string, tree f3_tree.TreeInterface, kindToForgePath map[kind.Kind]string, exceptions []kind.Kind) { + t.Helper() + exceptions = append(exceptions, f3_tree.KindRepositories) + + for _, kind := range KindWithFixturePath { + path := kindToForgePath[kind] + if !tree.IsContainer(kind) { + continue + } + if slices.Contains(exceptions, kind) { + continue + } + t.Run(string(kind), func(t *testing.T) { + Compliance(t, name, tree, path, kind, GeneratorSetRandom, GeneratorModify) + }) + } +} + +func Compliance(t *testing.T, name string, tree f3_tree.TreeInterface, p string, kind kind.Kind, generator GeneratorFunc, modificator ModificatorFunc) { + t.Helper() + ctx := context.Background() + + tree.Trace("%s", p) + parent := tree.Find(generic.NewPathFromString(p)) + require.NotEqualValues(t, generic.NilNode, parent, p) + tree.Trace("create a new child in memory") + child := tree.Factory(ctx, tree.GetChildrenKind(kind)) + childFormat := generator(t, name, child.NewFormat(), parent.GetCurrentPath()) + child.FromFormat(childFormat) + if i := child.GetID(); i != id.NilID { + tree.Trace("about to insert child %s", i) + assert.EqualValues(t, generic.NilNode, parent.GetChild(child.GetID())) + } else { + tree.Trace("about to insert child with nil ID") + } + if child.GetDriver().IsNull() { + t.Skip("no driver, skipping") + } + + tree.Trace("'Upsert' the new child in the parent and store it in the forge") + child.SetParent(parent) + child.Upsert(ctx) + tree.Trace("done inserting child '%s'", child.GetID()) + before := child.ToFormat() + require.EqualValues(t, before.GetID(), child.GetID().String()) + tree.Trace("'Get' the child '%s' from the forge", child.GetID()) + child.Get(ctx) + after := child.ToFormat() + tree.Trace("check the F3 representations Upsert & Get to/from the forge are equivalent") + require.True(t, cmp.Equal(before, after), cmp.Diff(before, after)) + + tree.Trace("check the F3 representation FromFormat/ToFormat are identical") + { + saved := after + + a := childFormat.Clone() + if tree.AllocateID() { + a.SetID("123456") + } + child.FromFormat(a) + b := child.ToFormat() + require.True(t, cmp.Equal(a, b), cmp.Diff(a, b)) + + child.FromFormat(saved) + } + + if childFormat.GetName() != childFormat.GetID() { + tree.Trace("'GetIDFromName' %s %s", kind, childFormat.GetName()) + id := parent.GetIDFromName(ctx, childFormat.GetName()) + assert.EqualValues(t, child.GetID(), id) + } + + for i, modified := range modificator(t, after, parent.GetCurrentPath()) { + tree.Trace("%d: %s 'Upsert' a modified child %v", i, kind, modified) + child.FromFormat(modified) + child.Upsert(ctx) + tree.Trace("%d: 'Get' the modified child '%s' from the forge", i, child.GetID()) + child.Get(ctx) + after = child.ToFormat() + tree.Trace("%d: check the F3 representations Upsert & Get to/from the forge of the modified child are equivalent", i) + require.True(t, cmp.Equal(modified, after), cmp.Diff(modified, after)) + } + + nodeChildren := parent.GetNodeChildren() + tree.Trace("'ListPage' and only 'Get' known %d children of %s", len(nodeChildren), parent.GetKind()) + if len(nodeChildren) > 0 { + parent.List(ctx) + for _, child := range parent.GetChildren() { + if _, ok := nodeChildren[child.GetID()]; ok { + tree.Trace("'WalkAndGet' %s child %s %s", parent.GetCurrentPath().ReadableString(), child.GetKind(), child.GetID()) + child.WalkAndGet(ctx, parent.GetCurrentPath(), generic.NewWalkOptions(nil)) + } + } + } + + tree.Trace("'Delete' child '%s' from the forge", child.GetID()) + child.Delete(ctx) + assert.EqualValues(t, generic.NilNode, parent.GetChild(child.GetID())) + + assert.True(t, child.GetIsSync()) + loop := 100 + for i := 0; i < loop; i++ { + child.SetIsSync(false) + child.Get(ctx) + if !child.GetIsSync() { + break + } + tree.Trace("waiting for asynchronous child deletion (%d/%d)", i, loop) + time.Sleep(5 * time.Second) + } + assert.False(t, child.GetIsSync()) + + tree.Trace("%s did something %s", kind, child) +} diff --git a/tree/tests/f3/forge_test.go b/tree/tests/f3/forge_test.go new file mode 100644 index 0000000..4d35918 --- /dev/null +++ b/tree/tests/f3/forge_test.go @@ -0,0 +1,19 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "testing" + + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" +) + +func TestF3Forge(t *testing.T) { + for name := range tests_forge.GetFactories() { + t.Run(name, func(t *testing.T) { + ForgeCompliance(t, name) + }) + } +} diff --git a/tree/tests/f3/generator.go b/tree/tests/f3/generator.go new file mode 100644 index 0000000..de5d492 --- /dev/null +++ b/tree/tests/f3/generator.go @@ -0,0 +1,474 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "strings" + "testing" + "time" + + "code.forgejo.org/f3/gof3/v3/f3" + tests_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository" + "code.forgejo.org/f3/gof3/v3/path" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" +) + +func GeneratorSetRandomID(id string, f f3.Interface, parent path.Path) f3.Interface { + if parent.First().(generic.NodeInterface).GetTree().AllocateID() { + return f + } + return GeneratorSetID(f, id) +} + +func GeneratorSetID(f f3.Interface, id string) f3.Interface { + f.SetID(id) + return f +} + +type ModificatorFunc func(t *testing.T, f f3.Interface, parent path.Path) []f3.Interface + +func GeneratorModify(t *testing.T, f f3.Interface, parent path.Path) []f3.Interface { + switch v := f.(type) { + case *f3.User: + return GeneratorModifyUser(v) + case *f3.Organization: + return GeneratorModifyOrganization(v) + case *f3.Project: + return GeneratorModifyProject(v, parent) + case *f3.Issue: + return GeneratorModifyIssue(v, parent) + case *f3.Milestone: + return GeneratorModifyMilestone(v, parent) + case *f3.Topic: + return GeneratorModifyTopic(v, parent) + case *f3.Reaction: + // a reaction cannot be modified, it can only be created and deleted + return []f3.Interface{} + case *f3.Label: + return GeneratorModifyLabel(v, parent) + case *f3.Comment: + return GeneratorModifyComment(v, parent) + case *f3.Release: + return GeneratorModifyRelease(t, v, parent) + case *f3.ReleaseAsset: + return GeneratorModifyReleaseAsset(v, parent) + case *f3.PullRequest: + return GeneratorModifyPullRequest(t, v, parent) + case *f3.Review: + // a review cannot be modified, it can only be created and deleted + return []f3.Interface{} + case *f3.ReviewComment: + return GeneratorModifyReviewComment(t, v, parent) + default: + panic(fmt.Errorf("not implemented %T", f)) + } +} + +type GeneratorFunc func(t *testing.T, name string, f f3.Interface, parent path.Path) f3.Interface + +func GeneratorSetRandom(t *testing.T, name string, f f3.Interface, parent path.Path) f3.Interface { + GeneratorSetRandomID(name, f, parent) + switch v := f.(type) { + case *f3.User: + return GeneratorSetRandomUser(v) + case *f3.Organization: + return GeneratorSetRandomOrganization(v) + case *f3.Project: + return GeneratorSetRandomProject(v, parent) + case *f3.Issue: + return GeneratorSetRandomIssue(v, parent) + case *f3.Milestone: + return GeneratorSetRandomMilestone(v, parent) + case *f3.Topic: + return GeneratorSetRandomTopic(v, parent) + case *f3.Reaction: + return GeneratorSetRandomReaction(v, parent) + case *f3.Label: + return GeneratorSetRandomLabel(v, parent) + case *f3.Comment: + return GeneratorSetRandomComment(v, parent) + case *f3.Release: + return GeneratorSetRandomRelease(t, v, parent) + case *f3.ReleaseAsset: + return GeneratorSetRandomReleaseAsset(v, parent) + case *f3.PullRequest: + return GeneratorSetRandomPullRequest(t, v, parent) + case *f3.Review: + return GeneratorSetReview(t, v, parent) + case *f3.ReviewComment: + return GeneratorSetReviewComment(t, v, parent) + default: + panic(fmt.Errorf("not implemented %T", f)) + } +} + +func GeneratorSetRandomUser(user *f3.User) *f3.User { + username := fmt.Sprintf("generateduser%s", user.GetID()) + user.Name = username + " Doe" + user.UserName = username + user.Email = username + "@example.com" + user.Password = "Wrobyak4" + return user +} + +func GeneratorModifyUser(user *f3.User) []f3.Interface { + return []f3.Interface{user.Clone()} +} + +func GeneratorSetRandomOrganization(organization *f3.Organization) *f3.Organization { + organizationname := fmt.Sprintf("generatedorg%s", organization.GetID()) + organization.FullName = organizationname + " Lambda" + organization.Name = organizationname + return organization +} + +func GeneratorModifyOrganization(organization *f3.Organization) []f3.Interface { + organization0 := organization.Clone().(*f3.Organization) + organization0.FullName = "modified " + organization.FullName + return []f3.Interface{organization0} +} + +func GeneratorSetRandomProject(project *f3.Project, parent path.Path) *f3.Project { + projectname := fmt.Sprintf("project%s", project.GetID()) + project.Name = projectname + project.IsPrivate = false + project.IsMirror = false + project.Description = "project description" + project.DefaultBranch = "main" + return project +} + +func GeneratorModifyProject(project *f3.Project, parent path.Path) []f3.Interface { + project0 := project.Clone().(*f3.Project) + project0.Description = "modified " + project.Description + return []f3.Interface{project0} +} + +func GeneratorSetRandomIssue(issue *f3.Issue, parent path.Path) *f3.Issue { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + labelsNode := projectNode.Find(generic.NewPathFromString("labels")) + labels := labelsNode.GetChildren() + firstLabel := labels[0] + + now := now() + updated := tick(&now) + closed := tick(&now) + created := tick(&now) + + userRef := f3_tree.NewUserReference(user.GetID()) + labelRef := f3_tree.NewIssueLabelReference(firstLabel.GetID()) + milestonesNode := projectNode.Find(generic.NewPathFromString("milestones")) + milestones := milestonesNode.GetChildren() + firstMilestone := milestones[0] + + issue.PosterID = userRef + issue.Assignees = []*f3.Reference{userRef} + issue.Labels = []*f3.Reference{labelRef} + issue.Milestone = f3_tree.NewIssueMilestoneReference(firstMilestone.GetID()) + issue.Title = "title" + issue.Content = "content" + issue.State = f3.IssueStateOpen + issue.IsLocked = false + issue.Created = created + issue.Updated = updated + issue.Closed = &closed + + return issue +} + +func GeneratorModifyIssue(issue *f3.Issue, parent path.Path) []f3.Interface { + assignees := issue.Assignees + milestone := issue.Milestone + labels := issue.Labels + + issue0 := issue.Clone().(*f3.Issue) + issue0.Title = "modified " + issue.Title + issue0.Content = "modified " + issue.Content + + issueClosed := issue0.Clone().(*f3.Issue) + issueClosed.Assignees = []*f3.Reference{} + issueClosed.Milestone = &f3.Reference{} + issueClosed.Labels = []*f3.Reference{} + issueClosed.State = f3.IssueStateClosed + issueClosed.IsLocked = true + + issueOpen := issue0.Clone().(*f3.Issue) + issueOpen.Assignees = assignees + issueOpen.Milestone = milestone + issueOpen.Labels = labels + issueOpen.State = f3.IssueStateOpen + issueClosed.IsLocked = false + + return []f3.Interface{ + issue0, + issueClosed, + issueOpen, + } +} + +func GeneratorSetRandomMilestone(milestone *f3.Milestone, parent path.Path) *f3.Milestone { + now := now() + created := tick(&now) + updated := tick(&now) + deadline := tick(&now) + + title := fmt.Sprintf("milestone%s", milestone.GetID()) + milestone.Title = title + milestone.Description = title + " description" + milestone.Deadline = &deadline + milestone.Created = created + milestone.Updated = &updated + milestone.Closed = nil + milestone.State = f3.MilestoneStateOpen + + return milestone +} + +func GeneratorModifyMilestone(milestone *f3.Milestone, parent path.Path) []f3.Interface { + milestone0 := milestone.Clone().(*f3.Milestone) + milestone0.Title = "modified " + milestone.Title + + milestoneClosed := milestone0.Clone().(*f3.Milestone) + milestoneClosed.State = f3.MilestoneStateClosed + deadline := time.Now().Truncate(time.Second).Add(5 * time.Minute) + milestoneClosed.Deadline = &deadline + + milestoneOpen := milestone0.Clone().(*f3.Milestone) + milestoneOpen.State = f3.MilestoneStateOpen + + return []f3.Interface{ + milestone0, + milestoneClosed, + milestoneOpen, + } +} + +func GeneratorSetRandomTopic(topic *f3.Topic, parent path.Path) *f3.Topic { + topic.Name = fmt.Sprintf("topic%s", topic.GetID()) + return topic +} + +func GeneratorModifyTopic(topic *f3.Topic, parent path.Path) []f3.Interface { + topic0 := topic.Clone().(*f3.Topic) + return []f3.Interface{topic0} +} + +func GeneratorSetRandomReaction(reaction *f3.Reaction, parent path.Path) *f3.Reaction { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + reaction.UserID = f3_tree.NewUserReference(user.GetID()) + reaction.Content = "laugh" + return reaction +} + +func GeneratorSetRandomLabel(label *f3.Label, parent path.Path) *f3.Label { + name := fmt.Sprintf("label%s", label.GetID()) + label.Name = name + label.Description = name + " description" + label.Color = "ffffff" + return label +} + +func GeneratorModifyLabel(label *f3.Label, parent path.Path) []f3.Interface { + label0 := label.Clone().(*f3.Label) + label0.Name = "modified" + label.Name + label0.Color = "f0f0f0" + label0.Description = "modified " + label.Description + return []f3.Interface{label0} +} + +func GeneratorSetRandomComment(comment *f3.Comment, parent path.Path) *f3.Comment { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + now := now() + commentCreated := tick(&now) + commentUpdated := tick(&now) + + comment.PosterID = f3_tree.NewUserReference(user.GetID()) + comment.Created = commentCreated + comment.Updated = commentUpdated + comment.Content = "comment content" + return comment +} + +func GeneratorModifyComment(comment *f3.Comment, parent path.Path) []f3.Interface { + comment0 := comment.Clone().(*f3.Comment) + comment0.Content = "modified" + comment.Content + return []f3.Interface{comment0} +} + +func GeneratorSetRandomRelease(t *testing.T, release *f3.Release, parent path.Path) *f3.Release { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + project := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repository := project.Find(generic.NewPathFromString("repositories/vcs")) + repository.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(t, "", repository) + + now := now() + releaseCreated := tick(&now) + + tag := fmt.Sprintf("release%s", release.GetID()) + repositoryHelper.CreateRepositoryTag(tag, "master") + sha := repositoryHelper.GetRepositorySha("master") + fmt.Printf("GeneratorSetRandomRelease %s %s\n", repository.GetCurrentPath(), repository.GetID()) + repositoryHelper.PushMirror() + + release.TagName = tag + release.TargetCommitish = sha + release.Name = tag + " name" + release.Body = tag + " body" + release.Draft = false + release.Prerelease = false + release.PublisherID = f3_tree.NewUserReference(user.GetID()) + release.Created = releaseCreated + + return release +} + +func GeneratorModifyRelease(t *testing.T, release *f3.Release, parent path.Path) []f3.Interface { + release0 := release.Clone().(*f3.Release) + release0.Body = "modified " + release.Body + return []f3.Interface{release0} +} + +func GeneratorSetRandomReleaseAsset(asset *f3.ReleaseAsset, parent path.Path) *f3.ReleaseAsset { + name := fmt.Sprintf("assetname%s", asset.GetID()) + content := fmt.Sprintf("assetcontent%s", asset.GetID()) + downloadURL := "downloadURL" + now := now() + assetCreated := tick(&now) + + size := len(content) + downloadCount := int64(10) + sha256 := fmt.Sprintf("%x", sha256.Sum256([]byte(content))) + + asset.Name = name + asset.Size = int64(size) + asset.DownloadCount = downloadCount + asset.Created = assetCreated + asset.SHA256 = sha256 + asset.DownloadURL = downloadURL + asset.DownloadFunc = func() io.ReadCloser { + rc := io.NopCloser(strings.NewReader(content)) + return rc + } + + return asset +} + +func GeneratorModifyReleaseAsset(asset *f3.ReleaseAsset, parent path.Path) []f3.Interface { + asset0 := asset.Clone().(*f3.ReleaseAsset) + asset0.Name = "modified" + asset.Name + return []f3.Interface{asset0} +} + +func GeneratorSetRandomPullRequest(t *testing.T, pullRequest *f3.PullRequest, parent path.Path) *f3.PullRequest { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(t, "", repositoryNode) + + mainRef := "master" + mainSha := repositoryHelper.GetRepositorySha(mainRef) + featureRef := "generatedfeature" + repositoryHelper.InternalBranchRepositoryFeature(featureRef, featureRef+" content") + featureSha := repositoryHelper.GetRepositorySha(featureRef) + fmt.Printf("createPullRequest: master %s at main %s feature %s\n", repositoryHelper.GetBare(), mainSha, featureSha) + repositoryHelper.PushMirror() + + now := now() + prCreated := tick(&now) + prUpdated := tick(&now) + + pullRequest.PosterID = f3_tree.NewUserReference(user.GetID()) + pullRequest.Title = featureRef + " pr title" + pullRequest.Content = featureRef + " pr content" + pullRequest.State = f3.PullRequestStateOpen + pullRequest.IsLocked = false + pullRequest.Created = prCreated + pullRequest.Updated = prUpdated + pullRequest.Closed = nil + pullRequest.Merged = false + pullRequest.MergedTime = nil + pullRequest.MergeCommitSHA = "" + pullRequest.Head = f3.PullRequestBranch{ + Ref: featureRef, + SHA: featureSha, + Repository: f3.NewReference("../../repository/vcs"), + } + pullRequest.Base = f3.PullRequestBranch{ + Ref: mainRef, + SHA: mainSha, + Repository: f3.NewReference("../../repository/vcs"), + } + return pullRequest +} + +func GeneratorModifyPullRequest(t *testing.T, pullRequest *f3.PullRequest, parent path.Path) []f3.Interface { + pullRequest0 := pullRequest.Clone().(*f3.PullRequest) + pullRequest0.Title = "modified " + pullRequest.Title + return []f3.Interface{pullRequest0} +} + +func GeneratorSetReview(t *testing.T, review *f3.Review, parent path.Path) *f3.Review { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(t, "", repositoryNode) + + now := now() + reviewCreated := tick(&now) + + featureSha := repositoryHelper.GetRepositorySha("feature") + + review.ReviewerID = f3_tree.NewUserReference(user.GetID()) + review.Official = true + review.CommitID = featureSha + review.Content = "the review content" + review.CreatedAt = reviewCreated + review.State = f3.ReviewStateCommented + + return review +} + +func GeneratorSetReviewComment(t *testing.T, comment *f3.ReviewComment, parent path.Path) *f3.ReviewComment { + user := f3_tree.GetFirstFormat[*f3.User](parent.Last().(generic.NodeInterface)) + + projectNode := f3_tree.GetFirstNodeKind(parent.Last().(generic.NodeInterface), f3_tree.KindProject) + repositoryNode := projectNode.Find(generic.NewPathFromString("repositories/vcs")) + repositoryNode.Get(context.Background()) + repositoryHelper := tests_repository.NewTestHelper(t, "", repositoryNode) + + now := now() + commentCreated := tick(&now) + commentUpdated := tick(&now) + + featureSha := repositoryHelper.GetRepositorySha("feature") + + comment.Content = "comment content" + comment.TreePath = "README.md" + comment.DiffHunk = "@@ -108,7 +108,6 @@" + comment.Line = 1 + comment.CommitID = featureSha + comment.PosterID = f3_tree.NewUserReference(user.GetID()) + comment.CreatedAt = commentCreated + comment.UpdatedAt = commentUpdated + + return comment +} + +func GeneratorModifyReviewComment(t *testing.T, comment *f3.ReviewComment, parent path.Path) []f3.Interface { + comment0 := comment.Clone().(*f3.ReviewComment) + comment0.Content = "modified " + comment.Content + return []f3.Interface{comment0} +} diff --git a/tree/tests/f3/helpers_repository_test.go b/tree/tests/f3/helpers_repository_test.go new file mode 100644 index 0000000..18351ef --- /dev/null +++ b/tree/tests/f3/helpers_repository_test.go @@ -0,0 +1,125 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + forgejo_options "code.forgejo.org/f3/gof3/v3/forges/forgejo/options" + helpers_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/repository" + tests_repository "code.forgejo.org/f3/gof3/v3/forges/helpers/tests/repository" + "code.forgejo.org/f3/gof3/v3/logger" + f3_tree "code.forgejo.org/f3/gof3/v3/tree/f3" + "code.forgejo.org/f3/gof3/v3/tree/generic" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNodeRepositoryDriverMirrorNoop(t *testing.T) { + ctx := context.Background() + url := t.TempDir() + repositoryHelper := tests_repository.NewTestHelper(t, url, nil) + repositoryHelper.CreateRepositoryContent("").PushMirror() + log := logger.NewCaptureLogger() + log.SetLevel(logger.Trace) + + log.Reset() + helpers_repository.GitMirror(ctx, log, url, url, []string{}) + assert.Contains(t, log.String(), "do nothing") + + log.Reset() + helpers_repository.GitMirrorRef(ctx, log, url, "fakeref", url, "fakeref") + assert.Contains(t, log.String(), "do nothing") +} + +func TestNodeRepositoryDriverMirrorForgejo(t *testing.T) { + ctx := context.Background() + log := logger.NewCaptureLogger() + log.SetLevel(logger.Trace) + + testForgejo := tests_forge.GetFactory(forgejo_options.Name)() + opts := testForgejo.NewOptions(t) + forgeTree := generic.GetFactory("f3")(ctx, opts) + + forgeTree.Trace("======= build fixture") + fixtureTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + TreeBuildPartial(t, "F3", testForgejo.GetKindExceptions(), opts, fixtureTree) + + forgeTree.Trace("======= mirror fixture to forge") + + generic.TreeMirror(ctx, fixtureTree, forgeTree, generic.NewPathFromString(""), generic.NewMirrorOptions()) + + forgeTree.Trace("======= NodeRepositoryDriverMirror from a forgejo project to a directory") + + repositoryPath := generic.TreePathRemap(ctx, fixtureTree, generic.NewPathFromString("/forge/users/10111/projects/74823/repositories/vcs"), forgeTree) + require.False(t, repositoryPath.Empty()) + repository := forgeTree.Find(repositoryPath) + repositoryURL := repository.GetDriver().(f3_tree.RepositoryNodeDriverProxyInterface).GetRepositoryURL() + internalRefs := repository.GetDriver().(f3_tree.RepositoryNodeDriverProxyInterface).GetRepositoryInternalRefs() + + log.Reset() + destination := t.TempDir() + tests_repository.NewTestHelper(t, destination, nil) + helpers_repository.GitMirror(ctx, log, repositoryURL, destination, internalRefs) + require.Contains(t, log.String(), "fetch fetchMirror") + + forgeTree.Trace("======= NodeRepositoryDriverMirror from one forgejo project to another forgejo project") + + otherRepositoryPath := generic.TreePathRemap(ctx, fixtureTree, generic.NewPathFromString("/forge/users/20222/projects/99099/repositories/vcs"), forgeTree) + require.False(t, otherRepositoryPath.Empty()) + otherRepository := forgeTree.Find(otherRepositoryPath) + otherRepositoryURL := otherRepository.GetDriver().(f3_tree.RepositoryNodeDriverProxyInterface).GetRepositoryURL() + otherRepositoryPushURL := otherRepository.GetDriver().(f3_tree.RepositoryNodeDriverProxyInterface).GetRepositoryPushURL() + otherInternalRefs := otherRepository.GetDriver().(f3_tree.RepositoryNodeDriverProxyInterface).GetRepositoryInternalRefs() + + log.Reset() + helpers_repository.GitMirror(ctx, log, repositoryURL, otherRepositoryPushURL, otherInternalRefs) + require.Contains(t, log.String(), "+refs/") + + forgeTree.Trace("======= NodeRepositoryDriverMirror from a directory to a forgejo project") + + log.Reset() + repositoryHelper := tests_repository.NewTestHelper(t, destination, nil) + content := "SOMETHING DIFFERENT" + repositoryHelper.CreateRepositoryContent(content).PushMirror() + helpers_repository.GitMirror(ctx, log, destination, otherRepositoryPushURL, otherInternalRefs) + require.Contains(t, log.String(), "+refs/") + verificationDir := t.TempDir() + repositoryHelper = tests_repository.NewTestHelper(t, verificationDir, nil) + helpers_repository.GitMirror(ctx, log, otherRepositoryURL, verificationDir, []string{}) + repositoryHelper.AssertReadmeContains(content) + + forgeTree.Trace("======= NodeRepositoryDriverMirrorRef from a forgejo project to a directory") + + masterRef := "refs/heads/master" + forgejoRef := "refs/forgejo/test" + directoryRef := "refs/directory/test" + + log.Reset() + helpers_repository.GitMirrorRef(ctx, log, repositoryURL, masterRef, destination, directoryRef) + require.Contains(t, log.String(), "new branch") + directorySha := helpers_repository.GitGetSha(ctx, log, destination, directoryRef) + + forgeTree.Trace("======= NodeRepositoryDriverMirrorRef from one forgejo project to another forgejo project") + + log.Reset() + otherForgejoRef := "refs/otherforgejo/test" + otherDirectoryRef := "refs/otherdirectory/test" + helpers_repository.GitMirrorRef(ctx, log, repositoryURL, masterRef, otherRepositoryPushURL, otherForgejoRef) + helpers_repository.GitMirrorRef(ctx, log, otherRepositoryURL, otherForgejoRef, destination, otherDirectoryRef) + assert.EqualValues(t, directorySha, helpers_repository.GitGetSha(ctx, log, destination, otherDirectoryRef)) + + forgeTree.Trace("======= NodeRepositoryDriverMirrorRef from a directory to a forgejo project") + + log.Reset() + helpers_repository.GitMirrorRef(ctx, log, verificationDir, masterRef, otherRepositoryPushURL, forgejoRef) + masterSha := helpers_repository.GitGetSha(ctx, log, verificationDir, masterRef) + helpers_repository.GitMirrorRef(ctx, log, otherRepositoryURL, forgejoRef, verificationDir, directoryRef) + assert.EqualValues(t, masterSha, helpers_repository.GitGetSha(ctx, log, verificationDir, directoryRef)) +} diff --git a/tree/tests/f3/init.go b/tree/tests/f3/init.go new file mode 100644 index 0000000..989aa38 --- /dev/null +++ b/tree/tests/f3/init.go @@ -0,0 +1,19 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + // register filesystem test factory + _ "code.forgejo.org/f3/gof3/v3/forges/filesystem" + _ "code.forgejo.org/f3/gof3/v3/forges/filesystem/tests" + + // register forgejo test factory + _ "code.forgejo.org/f3/gof3/v3/forges/forgejo" + _ "code.forgejo.org/f3/gof3/v3/forges/forgejo/tests" + + // register gitlab test factory + _ "code.forgejo.org/f3/gof3/v3/forges/gitlab" + _ "code.forgejo.org/f3/gof3/v3/forges/gitlab/tests" +) diff --git a/tree/tests/f3/interface.go b/tree/tests/f3/interface.go new file mode 100644 index 0000000..ed12e49 --- /dev/null +++ b/tree/tests/f3/interface.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "github.com/stretchr/testify/assert" +) + +type TestingT interface { + assert.TestingT + TempDir() string + Skip(args ...any) + FailNow() +} diff --git a/tree/tests/f3/main_test.go b/tree/tests/f3/main_test.go new file mode 100644 index 0000000..6f79cb5 --- /dev/null +++ b/tree/tests/f3/main_test.go @@ -0,0 +1,9 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + _ "code.forgejo.org/f3/gof3/v3/forges" +) diff --git a/tree/tests/f3/pullrequest_test.go b/tree/tests/f3/pullrequest_test.go new file mode 100644 index 0000000..6f49914 --- /dev/null +++ b/tree/tests/f3/pullrequest_test.go @@ -0,0 +1,71 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + "testing" + + filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + "code.forgejo.org/f3/gof3/v3/options" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + + "github.com/stretchr/testify/assert" +) + +func TestF3PullRequest(t *testing.T) { + ctx := context.Background() + + for _, factory := range tests_forge.GetFactories() { + testForge := factory() + t.Run(testForge.GetName(), func(t *testing.T) { + // testCase.options will t.Skip if the forge instance is not up + forgeWriteOptions := testForge.NewOptions(t) + forgeReadOptions := testForge.NewOptions(t) + forgeReadOptions.(options.URLInterface).SetURL(forgeWriteOptions.(options.URLInterface).GetURL()) + + fixtureTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + fixtureTree.Trace("======= build fixture") + TreeBuildPartial(t, "F3PullRequest"+testForge.GetName(), testForge.GetKindExceptions(), forgeWriteOptions, fixtureTree) + + // craft a PR condition depending on testCase + + fixtureTree.Trace("======= mirror fixture to forge") + + forgeWriteTree := generic.GetFactory("f3")(ctx, forgeWriteOptions) + generic.TreeMirror(ctx, fixtureTree, forgeWriteTree, generic.NewPathFromString(""), generic.NewMirrorOptions()) + + paths := []string{"/forge/users/10111/projects/74823/repositories", "/forge/users/10111/projects/74823/pull_requests"} + pathPairs := make([][2]path.Path, 0, 5) + for _, p := range paths { + p := generic.NewPathFromString(p) + pathPairs = append(pathPairs, [2]path.Path{p, generic.TreePathRemap(ctx, fixtureTree, p, forgeWriteTree)}) + } + + fixtureTree.Trace("======= read from forge") + + forgeReadTree := generic.GetFactory("f3")(ctx, forgeReadOptions) + forgeReadTree.WalkAndGet(ctx, generic.NewWalkOptions(nil)) + + fixtureTree.Trace("======= mirror forge to filesystem") + + verificationTree := generic.GetFactory("f3")(ctx, tests_forge.GetFactory(filesystem_options.Name)().NewOptions(t)) + + for _, pathPair := range pathPairs { + generic.TreeMirror(ctx, forgeReadTree, verificationTree, pathPair[1], generic.NewMirrorOptions()) + } + + fixtureTree.Trace("======= compare fixture with forge mirrored to filesystem") + for _, pathPair := range pathPairs { + fixtureTree.Trace("======= compare %s with %s", pathPair[0], pathPair[1]) + assert.True(t, generic.TreeCompare(ctx, fixtureTree, pathPair[0], verificationTree, pathPair[1])) + } + + TreeDelete(t, testForge.GetNonTestUsers(), forgeWriteOptions, forgeWriteTree) + }) + } +} diff --git a/tree/tests/generic/compare_test.go b/tree/tests/generic/compare_test.go new file mode 100644 index 0000000..071a642 --- /dev/null +++ b/tree/tests/generic/compare_test.go @@ -0,0 +1,74 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" +) + +func TestCompare(t *testing.T) { + ctx := context.Background() + + aTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, aTree, 2) + bTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, bTree, 2) + + assert.True(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + + { + toDelete := generic.NewPathFromString("/O-A/O-B") + + aTree.Find(toDelete).Delete(ctx) + assert.False(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + + bTree.Find(toDelete).Delete(ctx) + assert.True(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + } + + { + content := "OTHER CONTENT" + toModify := generic.NewPathFromString("/O-A/O-F") + aNode := aTree.Find(toModify) + memory.SetContent(aNode, content) + aNode.Upsert(ctx) + assert.False(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + + bNode := bTree.Find(toModify) + memory.SetContent(bNode, content) + bNode.Upsert(ctx) + + assert.True(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + } + + { + toModify := generic.NewPathFromString("/O-A/O-F") + aTree.Find(toModify).SetKind(kind.Kind("???")) + assert.False(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + bTree.Find(toModify).SetKind(kind.Kind("???")) + assert.True(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + } + + { + pathToMap := generic.NewPathFromString("/O-A/O-J/O-M") + mappedID := id.NewNodeID("MAPPED") + aTree.Find(pathToMap).SetMappedID(mappedID) + assert.False(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + + bNode := bTree.Find(pathToMap).Delete(ctx) + bNode.SetID(mappedID) + parentPathToMap := generic.NewPathFromString("/O-A/O-J") + bTree.Find(parentPathToMap).SetChild(bNode) + assert.True(t, generic.TreeCompare(ctx, aTree, generic.NewPathFromString(""), bTree, generic.NewPathFromString(""))) + } +} diff --git a/tree/tests/generic/memory_test.go b/tree/tests/generic/memory_test.go new file mode 100644 index 0000000..a9df134 --- /dev/null +++ b/tree/tests/generic/memory_test.go @@ -0,0 +1,193 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "sort" + "testing" + + "code.forgejo.org/f3/gof3/v3/id" + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" +) + +func NewMemoryTree(ctx context.Context, name string) generic.TreeInterface { + return generic.GetFactory("memory")(ctx, memory.NewOptions(memory.NewIDAllocatorGenerator(name))) +} + +func TestMemoryTreeIDAllocator(t *testing.T) { + ctx := context.Background() + + name := "T" + id := "THEID" + + for _, testCase := range []struct { + idAllocator memory.IDAllocatorInterface + setID bool + expectedID string + }{ + { + idAllocator: memory.NewIDAllocatorGenerator(name), + setID: false, + expectedID: name + "-A", + }, + { + idAllocator: memory.NewIDAllocatorNull(), + setID: true, + expectedID: id, + }, + } { + t.Run(testCase.expectedID, func(t *testing.T) { + tree := generic.GetFactory("memory")(ctx, memory.NewOptions(testCase.idAllocator)) + node := tree.GetRoot().CreateChild(ctx) + f := memory.NewFormat("") + if testCase.setID { + f.SetID(id) + } + node.FromFormat(f) + node.Upsert(ctx) + assert.EqualValues(t, testCase.expectedID, node.GetID()) + }) + } +} + +func testTreeBuild(t *testing.T, tree generic.TreeInterface, maxDepth int) { + ctx := context.Background() + + insert := func(tree generic.TreeInterface, parent generic.NodeInterface) generic.NodeInterface { + node := parent.CreateChild(ctx) + node.Upsert(ctx) + memory.SetContent(node, "content "+node.GetID().String()) + node.Upsert(ctx) + return node + } + + var populate func(depth int, tree generic.TreeInterface, parent generic.NodeInterface) + populate = func(depth int, tree generic.TreeInterface, parent generic.NodeInterface) { + if depth >= maxDepth { + return + } + depth++ + for i := 1; i <= 3; i++ { + node := insert(tree, parent) + populate(depth, tree, node) + } + } + + populate(0, tree, insert(tree, tree.GetRoot())) +} + +func TestMemoryTreeBuild(t *testing.T) { + ctx := context.Background() + + verify := func(tree generic.TreeInterface, expected []string) { + collected := make([]string, 0, 10) + collect := func(ctx context.Context, p path.Path, node generic.NodeInterface) { + if node.GetKind() == kind.KindRoot { + return + } + p = p.Append(node) + collected = append(collected, p.String()+":"+memory.GetContent(node)) + } + tree.WalkAndGet(ctx, generic.NewWalkOptions(collect)) + sort.Strings(expected) + sort.Strings(collected) + assert.EqualValues(t, expected, collected) + } + + for _, testCase := range []struct { + name string + build func(tree generic.TreeInterface) + operations func(tree generic.TreeInterface) + expected []string + }{ + { + name: "full tree", + build: func(tree generic.TreeInterface) { testTreeBuild(t, tree, 2) }, + operations: func(tree generic.TreeInterface) {}, + expected: []string{"/T-A/T-B/T-C:content T-C", "/T-A/T-B/T-D:content T-D", "/T-A/T-B/T-E:content T-E", "/T-A/T-B:content T-B", "/T-A/T-F/T-G:content T-G", "/T-A/T-F/T-H:content T-H", "/T-A/T-F/T-I:content T-I", "/T-A/T-F:content T-F", "/T-A/T-J/T-K:content T-K", "/T-A/T-J/T-L:content T-L", "/T-A/T-J/T-M:content T-M", "/T-A/T-J:content T-J", "/T-A:content T-A"}, + }, + { + name: "scenario 1", + build: func(tree generic.TreeInterface) { testTreeBuild(t, tree, 2) }, + operations: func(tree generic.TreeInterface) { + root := tree.GetRoot() + + id0 := id.NewNodeID("T-A") + root.List(ctx) + zero := root.GetChild(id0) + assert.False(t, generic.NilNode == zero) + assert.True(t, zero == zero.Get(ctx)) + + id1 := id.NewNodeID("T-B") + zero.List(ctx) + one := zero.GetChild(id1) + assert.False(t, generic.NilNode == one) + one.Get(ctx) + memory.SetContent(one, "other one") + one.Upsert(ctx) + + id2 := id.NewNodeID("T-F") + two := zero.GetChild(id2) + two.Delete(ctx) + two.Delete(ctx) + assert.True(t, generic.NilNode == zero.GetChild(id2)) + }, + expected: []string{"/T-A/T-B/T-C:content T-C", "/T-A/T-B/T-D:content T-D", "/T-A/T-B/T-E:content T-E", "/T-A/T-B:other one", "/T-A/T-J/T-K:content T-K", "/T-A/T-J/T-L:content T-L", "/T-A/T-J/T-M:content T-M", "/T-A/T-J:content T-J", "/T-A:content T-A"}, + }, + { + name: "scenario 2", + build: func(tree generic.TreeInterface) { testTreeBuild(t, tree, 0) }, + operations: func(tree generic.TreeInterface) { + root := tree.GetRoot() + + id0 := id.NewNodeID("T-A") + root.List(ctx) + zero := root.GetChild(id0) + assert.False(t, generic.NilNode == zero) + zero.Get(ctx) + + one := zero.CreateChild(ctx) + one.Upsert(ctx) + memory.SetContent(one, "ONE") + one.Upsert(ctx) + + two := one.CreateChild(ctx) + two.Upsert(ctx) + memory.SetContent(two, "SOMETHING") + two.Upsert(ctx) + memory.SetContent(two, "ONE/TWO") + two.Upsert(ctx) + one.DeleteChild(two.GetID()) + two.Get(ctx) + + three := two.CreateChild(ctx) + three.Upsert(ctx) + memory.SetContent(three, "ONE/THREE") + three.Upsert(ctx) + three.Delete(ctx) + }, + expected: []string{"/T-A/T-B/T-C:ONE/TWO", "/T-A/T-B:ONE", "/T-A:content T-A"}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + tree := NewMemoryTree(ctx, "T") + + tree.Trace("========== BUILD") + testCase.build(tree) + tree.Trace("========== OPERATIONS") + testCase.operations(tree) + verify(tree, testCase.expected) + tree.Trace("========== VERIFY RELOAD") + tree.Clear(ctx) + verify(tree, testCase.expected) + }) + } +} diff --git a/tree/tests/generic/mirror_test.go b/tree/tests/generic/mirror_test.go new file mode 100644 index 0000000..2cebccc --- /dev/null +++ b/tree/tests/generic/mirror_test.go @@ -0,0 +1,98 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "testing" + + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" +) + +type testReference struct { + originPath string + originReference string + destinationPath string + destinationReference string +} + +func TestMirror(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + start string + references []testReference + expected []string + }{ + { + start: "/", + expected: []string{":ROOT:", "/D-A:content O-A:", "/D-A/D-B:content O-B:", "/D-A/D-B/D-C:content O-C:", "/D-A/D-B/D-D:content O-D:", "/D-A/D-B/D-E:content O-E:", "/D-A/D-F:content O-F:", "/D-A/D-F/D-G:content O-G:", "/D-A/D-F/D-H:content O-H:", "/D-A/D-F/D-I:content O-I:", "/D-A/D-J:content O-J:", "/D-A/D-J/D-K:content O-K:", "/D-A/D-J/D-L:content O-L:", "/D-A/D-J/D-M:content O-M:"}, + }, + { + start: "/O-A/O-B", + references: []testReference{ + { + originPath: "/O-A/O-B/O-C", + originReference: "/O-A/O-F", + destinationPath: "/D-A/D-B/D-D", + destinationReference: "/D-A/D-C", + }, + }, + expected: []string{":ROOT:", "/D-A:content O-A:", "/D-A/D-B:content O-B:", "/D-A/D-B/D-D:content O-C:/D-A/D-C", "/D-A/D-B/D-E:content O-D:", "/D-A/D-B/D-F:content O-E:", "/D-A/D-C:content O-F:"}, + }, + { + start: "/O-A/O-F", + references: []testReference{ + { + originPath: "/O-A/O-F/O-G", + originReference: "../../O-J", + destinationPath: "/D-A/D-B/D-D", + destinationReference: "../../D-C", + }, + }, + expected: []string{":ROOT:", "/D-A:content O-A:", "/D-A/D-B:content O-F:", "/D-A/D-B/D-D:content O-G:../../D-C", "/D-A/D-B/D-E:content O-H:", "/D-A/D-B/D-F:content O-I:", "/D-A/D-C:content O-J:"}, + }, + } { + t.Run(" "+testCase.start, func(t *testing.T) { + originTree := NewMemoryTree(ctx, "O") + log := originTree.GetLogger() + log.Trace("=========== build") + testTreeBuild(t, originTree, 2) + + for _, c := range testCase.references { + log.Trace("=========== inject reference %s", c.originReference) + assert.True(t, originTree.Apply(ctx, generic.NewPathFromString(c.originPath), generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + memory.SetRef(node, c.originReference) + }))) + } + + log.Trace("=========== mirror") + destinationTree := NewMemoryTree(ctx, "D") + + generic.TreeMirror(ctx, originTree, destinationTree, generic.NewPathFromString(testCase.start), generic.NewMirrorOptions()) + log.Trace("=========== verify") + collected := make([]string, 0, 10) + collect := func(ctx context.Context, parent path.Path, node generic.NodeInterface) { + collected = append(collected, node.GetCurrentPath().String()+":"+memory.GetContent(node)+":"+memory.GetRef(node)) + } + destinationTree.Walk(ctx, generic.NewWalkOptions(collect)) + assert.EqualValues(t, testCase.expected, collected) + + for _, c := range testCase.references { + log.Trace("=========== look for reference %s", c.destinationReference) + var called bool + assert.True(t, destinationTree.Apply(ctx, generic.NewPathFromString(c.destinationPath), generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + assert.EqualValues(t, c.destinationReference, memory.GetRef(node)) + called = true + }))) + assert.True(t, called) + } + }) + } +} diff --git a/tree/tests/generic/node_walk_test.go b/tree/tests/generic/node_walk_test.go new file mode 100644 index 0000000..02a39d1 --- /dev/null +++ b/tree/tests/generic/node_walk_test.go @@ -0,0 +1,341 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "fmt" + "sort" + "testing" + + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBothApplyWalk(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + for _, testCase := range []struct { + path string + expected []string + }{ + { + path: "/T-A", + expected: []string{"/T-A", "/T-A/T-B", "/T-A/T-B/T-C", "/T-A/T-B/T-D", "/T-A/T-B/T-E", "/T-A/T-F", "/T-A/T-F/T-G", "/T-A/T-F/T-H", "/T-A/T-F/T-I", "/T-A/T-J", "/T-A/T-J/T-K", "/T-A/T-J/T-L", "/T-A/T-J/T-M"}, + }, + { + path: "/T-A/T-B", + expected: []string{"/T-A/T-B", "/T-A/T-B/T-C", "/T-A/T-B/T-D", "/T-A/T-B/T-E"}, + }, + { + path: "/T-A/T-B/T-C", + expected: []string{"/T-A/T-B/T-C"}, + }, + } { + testTreeBuild(t, tree, 2) + + collected := make([]string, 0, 10) + p := generic.NewPathFromString(testCase.path) + walk := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + collect := func(ctx context.Context, p path.Path, node generic.NodeInterface) { + p = p.Append(node) + collected = append(collected, p.String()) + } + node.Walk(ctx, parent, generic.NewWalkOptions(collect)) + } + assert.True(t, tree.Apply(ctx, p, generic.NewApplyOptions(walk))) + sort.Strings(testCase.expected) + sort.Strings(collected) + assert.EqualValues(t, testCase.expected, collected) + } +} + +func TestWalk(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + expected := []string{"", "/T-A", "/T-A/T-B", "/T-A/T-B/T-C", "/T-A/T-B/T-D", "/T-A/T-B/T-E", "/T-A/T-F", "/T-A/T-F/T-G", "/T-A/T-F/T-H", "/T-A/T-F/T-I", "/T-A/T-J", "/T-A/T-J/T-K", "/T-A/T-J/T-L", "/T-A/T-J/T-M"} + testTreeBuild(t, tree, 2) + + collected := make([]string, 0, 10) + collect := func(ctx context.Context, p path.Path, node generic.NodeInterface) { + p = p.Append(node) + collected = append(collected, p.String()) + } + tree.Walk(ctx, generic.NewWalkOptions(collect)) + sort.Strings(expected) + sort.Strings(collected) + assert.EqualValues(t, expected, collected) +} + +func TestWalkAndGet(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + testTreeBuild(t, tree, 1) + tree.Clear(ctx) + + collected := make([]string, 0, 10) + collect := func(ctx context.Context, p path.Path, node generic.NodeInterface) { + p = p.Append(node) + collected = append(collected, p.String()) + } + tree.Walk(ctx, generic.NewWalkOptions(collect)) + assert.EqualValues(t, []string{""}, collected) + + collected = make([]string, 0, 10) + tree.WalkAndGet(ctx, generic.NewWalkOptions(collect)) + expected := []string{"", "/T-A", "/T-A/T-B", "/T-A/T-C", "/T-A/T-D"} + sort.Strings(expected) + sort.Strings(collected) + assert.EqualValues(t, expected, collected) +} + +func TestApplyVisitID(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + testTreeBuild(t, tree, 2) + + for _, testPath := range []string{"/T-A", "/T-A/T-B", "/T-A/T-B/T-C"} { + + collected := make([]string, 0, 10) + p := generic.NewPathFromString(testPath) + collect := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + parent = parent.Append(node) + assert.False(t, node.GetIsNil(), node.String()) + assert.EqualValues(t, parent.Length(), node.GetCurrentPath().Length()) + expected := parent.PathString().Join() + actual := node.GetCurrentPath().String() + assert.EqualValues(t, actual, expected) + collected = append(collected, parent.String()) + } + assert.True(t, tree.Apply(ctx, p, generic.NewApplyOptions(collect))) + if assert.EqualValues(t, 1, len(collected)) { + assert.EqualValues(t, testPath, collected[0]) + } + } + + p := generic.NewPathFromString("/1/2/3/4") + called := false + assert.False(t, tree.Apply(ctx, p, generic.NewApplyOptions(func(context.Context, path.Path, path.Path, generic.NodeInterface) { called = true }))) + assert.False(t, called) +} + +func TestApplyVisitByName(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + testTreeBuild(t, tree, 2) + + for _, testCase := range []struct { + path string + expected string + }{ + { + path: "/T-A/content T-B/T-C", + expected: "/T-A/T-B/T-C", + }, + { + path: "/T-A/content T-B/content T-C", + expected: "/T-A/T-B/T-C", + }, + { + path: "/content T-A/content T-B/content T-C", + expected: "/T-A/T-B/T-C", + }, + } { + collected := make([]string, 0, 10) + p := generic.NewPathFromString(testCase.path) + collect := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + parent = parent.Append(node) + assert.False(t, node.GetIsNil(), node.String()) + assert.EqualValues(t, parent.Length(), node.GetCurrentPath().Length()) + expected := parent.PathString().Join() + actual := node.GetCurrentPath().String() + assert.EqualValues(t, actual, expected) + collected = append(collected, parent.String()) + } + assert.True(t, tree.Apply(ctx, p, generic.NewApplyOptions(collect).SetSearch(generic.ApplySearchByName))) + if assert.EqualValues(t, 1, len(collected)) { + assert.EqualValues(t, testCase.expected, collected[0]) + } + } +} + +func TestApplyAndGet(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + path string + expected []string + }{ + { + path: "/T-A", + expected: []string{"", "/T-A"}, + }, + { + path: "/T-A/T-B", + expected: []string{"", "/T-A", "/T-A/T-B"}, + }, + { + path: "/T-A/T-B/T-C", + expected: []string{"", "/T-A", "/T-A/T-B", "/T-A/T-B/T-C"}, + }, + } { + tree := NewMemoryTree(ctx, "T") + + testTreeBuild(t, tree, 2) + tree.Clear(ctx) + + p := generic.NewPathFromString(testCase.path) + + var collected []string + collect := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + parent = parent.Append(node) + collected = append(collected, parent.String()) + } + + { + collected = make([]string, 0, 10) + require.False(t, tree.Apply(ctx, p, generic.NewApplyOptions(collect))) + } + + { + collected = make([]string, 0, 10) + require.True(t, tree.ApplyAndGet(ctx, p, generic.NewApplyOptions(collect))) + require.EqualValues(t, 1, len(collected)) + assert.EqualValues(t, testCase.path, collected[0]) + } + + { + collected = make([]string, 0, 10) + require.True(t, tree.ApplyAndGet(ctx, p, generic.NewApplyOptions(collect).SetWhere(generic.ApplyEachNode))) + sort.Strings(testCase.expected) + sort.Strings(collected) + assert.EqualValues(t, testCase.expected, collected) + } + } +} + +func TestApplyVisitRelative(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + // The "destination" is clean and there is no need to test a/../b which becomes + // b etc. Only when the first element of the path is either an id or .. + for _, testCase := range []struct { + start string + destination string + expected string + }{ + { + start: "/T-A", + destination: "T-B", + expected: "/T-A/T-B", + }, + { + start: "/T-A/T-B", + destination: ".", + expected: "/T-A/T-B", + }, + { + start: "/T-A/T-B", + destination: "T-C", + expected: "/T-A/T-B/T-C", + }, + { + start: "/T-A/T-B", + destination: "..", + expected: "/T-A", + }, + { + start: "/T-A/T-B", + destination: "../T-F/T-G", + expected: "/T-A/T-F/T-G", + }, + { + start: "/T-A/T-B/T-C", + destination: "../../T-F", + expected: "/T-A/T-F", + }, + } { + t.Run(" "+testCase.start+" => "+testCase.destination, func(t *testing.T) { + testTreeBuild(t, tree, 2) + + collected := make([]string, 0, 10) + start := generic.NewPathFromString(testCase.start) + collect := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + parent = parent.Append(node) + assert.False(t, node.GetIsNil(), node.String()) + assert.EqualValues(t, parent.Length(), node.GetCurrentPath().Length()) + expected := parent.PathString().Join() + actual := node.GetCurrentPath().String() + assert.EqualValues(t, actual, expected) + collected = append(collected, parent.String()) + } + cd := func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + destination := generic.NewPathFromString(testCase.destination) + fmt.Println("start ", node.GetCurrentPath().String()) + assert.True(t, node.Apply(ctx, parent, destination, generic.NewApplyOptions(collect))) + } + assert.True(t, tree.Apply(ctx, start, generic.NewApplyOptions(cd))) + if assert.EqualValues(t, 1, len(collected)) { + assert.EqualValues(t, testCase.expected, collected[0]) + } + }) + + p := generic.NewPathFromString("/1/2/3/4") + called := false + assert.False(t, tree.Apply(ctx, p, generic.NewApplyOptions(func(context.Context, path.Path, path.Path, generic.NodeInterface) { called = true }))) + assert.False(t, called) + } +} + +func TestApplyUpsert(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + testTreeBuild(t, tree, 2) + + expected := generic.NewPathFromString("/T-A/T-B/T-N") + assert.False(t, tree.Exists(ctx, expected)) + + assert.True(t, tree.Apply(ctx, generic.NewPathFromString("/T-A/T-B"), generic.NewApplyOptions(func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + new := node.CreateChild(ctx) + new.Upsert(ctx) + memory.SetContent(new, "content "+new.GetID().String()) + new.Upsert(ctx) + }))) + + assert.True(t, tree.Exists(ctx, expected)) +} + +func TestApplyDelete(t *testing.T) { + ctx := context.Background() + + tree := NewMemoryTree(ctx, "T") + + testTreeBuild(t, tree, 2) + + toDelete := generic.NewPathFromString("/T-A/T-B") + assert.True(t, tree.Exists(ctx, toDelete)) + + assert.True(t, tree.Apply(ctx, toDelete, generic.NewApplyOptions(func(ctx context.Context, parent, p path.Path, node generic.NodeInterface) { + node.Delete(ctx) + }))) + + assert.False(t, tree.Exists(ctx, toDelete)) +} diff --git a/tree/tests/generic/references_test.go b/tree/tests/generic/references_test.go new file mode 100644 index 0000000..6c10ce6 --- /dev/null +++ b/tree/tests/generic/references_test.go @@ -0,0 +1,74 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "sort" + "testing" + + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" +) + +func TestTreeCollectReferences(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + path string + expected []string + }{ + { + path: "/O-A/O-B", + expected: []string{"/O-A/O-B/O-C"}, + }, + { + path: "/O-A/O-D", + expected: []string{}, + }, + { + path: "/O-A/O-J", + expected: []string{"/O-A/O-F", "/O-A/O-J/O-K"}, + }, + { + path: "/O-A/O-J/O-L", + expected: []string{"/O-A/O-J/O-K"}, + }, + { + path: "/O-A/O-J/O-M", + expected: []string{"/O-A/O-F"}, + }, + { + path: "/O-A", + expected: []string{"/O-A/O-B/O-C", "/O-A/O-F", "/O-A/O-F/O-H", "/O-A/O-J/O-K"}, + }, + } { + t.Run(testCase.path, func(t *testing.T) { + tree := NewMemoryTree(ctx, "O") + testTreeBuild(t, tree, 2) + + setReference := func(p, reference string) { + assert.True(t, tree.Apply(ctx, generic.NewPathFromString(p), generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + memory.SetRef(node, reference) + }))) + } + setReference("/O-A/O-B", "/O-A/O-B/O-C") + setReference("/O-A/O-F/O-G", "/O-A/O-F/O-H") + setReference("/O-A/O-J/O-M", "../../O-F") + setReference("/O-A/O-J/O-L", "../O-K") + + actual := make([]string, 0, 10) + for _, reference := range generic.TreeCollectReferences(ctx, tree, generic.NewPathFromString(testCase.path)) { + actual = append(actual, reference.String()) + } + sort.Strings(actual) + + assert.EqualValues(t, testCase.expected, actual) + }) + } +} diff --git a/tree/tests/generic/unify_test.go b/tree/tests/generic/unify_test.go new file mode 100644 index 0000000..33ba05a --- /dev/null +++ b/tree/tests/generic/unify_test.go @@ -0,0 +1,626 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package generic + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "testing" + + "code.forgejo.org/f3/gof3/v3/kind" + "code.forgejo.org/f3/gof3/v3/path" + "code.forgejo.org/f3/gof3/v3/tree/generic" + "code.forgejo.org/f3/gof3/v3/tree/memory" + + "github.com/stretchr/testify/assert" +) + +func TestUnifyPathSimpleRemap(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + path string + expected []string + }{ + { + path: "", + expected: []string{}, + }, + { + path: "/O-A", + expected: []string{"/O-A:O-A=content O-A => /D-A:D-A=content O-A"}, + }, + { + path: "/O-A/O-B", + expected: []string{"/O-A:O-A=content O-A => /D-A:D-A=content O-A", "/O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B"}, + }, + { + path: "/O-A/O-B/O-C", + expected: []string{"/O-A:O-A=content O-A => /D-A:D-A=content O-A", "/O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "/O-A/O-B/O-C:O-C=content O-C => /D-A/D-B/D-C:D-C=content O-C"}, + }, + } { + t.Run(" "+testCase.path, func(t *testing.T) { + originTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, originTree, 2) + destinationTree := NewMemoryTree(ctx, "D") + + collected := make([]string, 0, 10) + p := generic.NewPathFromString(testCase.path) + upsert := func(ctx context.Context, origin generic.NodeInterface, originPath path.Path, destination generic.NodeInterface, destinationPath path.Path) { + fmt.Printf("origin %v destination %v\n", origin, destination) + originPath = originPath.Append(origin) + destinationPath = destinationPath.Append(destination) + collected = append(collected, originPath.String()+":"+origin.String()+" => "+destinationPath.String()+":"+destination.String()) + } + generic.TreeUnifyPath(ctx, originTree, p, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + assert.EqualValues(t, testCase.expected, collected) + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, p, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + assert.EqualValues(t, testCase.expected, collected) + }) + } +} + +func TestUnifyPathSimpleNoRemap(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + noremap bool + path string + expected []string + }{ + { + noremap: false, + path: "/O-A/O-B", + expected: []string{"/O-A:O-A=content O-A => /D-A:D-A=content O-A", "/O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B"}, + }, + { + noremap: true, + path: "/O-A/O-B", + expected: []string{"/O-A:O-A=content O-A => /O-A:O-A=content O-A", "/O-A/O-B:O-B=content O-B => /O-A/O-B:O-B=content O-B"}, + }, + } { + t.Run(fmt.Sprintf("noremap=%v,path=%s", testCase.noremap, testCase.path), func(t *testing.T) { + originName := "O" + originTree := NewMemoryTree(ctx, originName) + testTreeBuild(t, originTree, 2) + var destinationName string + if testCase.noremap { + destinationName = originName + } else { + destinationName = "D" + } + destinationTree := NewMemoryTree(ctx, destinationName) + + collected := make([]string, 0, 10) + p := generic.NewPathFromString(testCase.path) + upsert := func(ctx context.Context, origin generic.NodeInterface, originPath path.Path, destination generic.NodeInterface, destinationPath path.Path) { + fmt.Printf("origin %v destination %v\n", origin, destination) + originPath = originPath.Append(origin) + destinationPath = destinationPath.Append(destination) + collected = append(collected, originPath.String()+":"+origin.String()+" => "+destinationPath.String()+":"+destination.String()) + } + generic.TreeUnifyPath(ctx, originTree, p, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetNoRemap(testCase.noremap)) + assert.EqualValues(t, testCase.expected, collected) + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, p, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetNoRemap(testCase.noremap)) + assert.EqualValues(t, testCase.expected, collected) + }) + } +} + +func TestUnifyPathRelative(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + start string + destination string + expected []string + }{ + { + start: "/O-A/O-B", + destination: "O-C", + expected: []string{"cd: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "unify: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "unify: /O-A/O-B/O-C:O-C=content O-C => /D-A/D-B/D-C:D-C=content O-C"}, + }, + { + start: "/O-A/O-B", + destination: ".", + expected: []string{"cd: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "unify: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B"}, + }, + { + start: "/O-A/O-B", + destination: "../O-F/O-G", + expected: []string{"cd: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "unify: /O-A:O-A=content O-A => /D-A:D-A=content O-A", "unify: /O-A/O-F:O-F=content O-F => /D-A/D-C:D-C=content O-F", "unify: /O-A/O-F/O-G:O-G=content O-G => /D-A/D-C/D-D:D-D=content O-G"}, + }, + { + start: "/O-A/O-B/O-C", + destination: "../O-E", + expected: []string{"cd: /O-A/O-B/O-C:O-C=content O-C => /D-A/D-B/D-C:D-C=content O-C", "unify: /O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "unify: /O-A/O-B/O-E:O-E=content O-E => /D-A/D-B/D-D:D-D=content O-E"}, + }, + } { + t.Run(" "+testCase.start+" => "+testCase.destination, func(t *testing.T) { + originTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, originTree, 2) + destinationTree := NewMemoryTree(ctx, "D") + + var collected []string + start := generic.NewPathFromString(testCase.start) + collect := func(prefix string, origin, destination generic.NodeInterface) { + originPath := origin.GetCurrentPath().String() + destinationPath := destination.GetCurrentPath().String() + collected = append(collected, prefix+originPath+":"+origin.GetSelf().String()+" => "+destinationPath+":"+destination.GetSelf().String()) + } + // + // Unify testCase.start + // + upsert := func(ctx context.Context, origin generic.NodeInterface, originParent path.Path, destination generic.NodeInterface, destinationParent path.Path) { + collect("unify: ", origin, destination) + } + generic.TreeUnifyPath(ctx, originTree, start, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + // + // Beginning from testCase.start, unify testCase.destination + // + cd := func(ctx context.Context, origin, destination generic.NodeInterface) { + collect("cd: ", origin, destination) + path := generic.NewPathFromString(testCase.destination) + originParent := origin.GetParent().GetCurrentPath() + destinationParent := destination.GetParent().GetCurrentPath() + generic.NodeUnifyPath(ctx, origin.GetSelf(), originParent, path, destinationParent, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + } + // + // + // + collected = make([]string, 0, 10) + generic.TreeParallelApply(ctx, originTree, start, destinationTree, generic.NewParallelApplyOptions(cd)) + assert.EqualValues(t, testCase.expected, collected) + // + // Do it twice to verify it is idempotent + // + collected = make([]string, 0, 10) + generic.TreeParallelApply(ctx, originTree, start, destinationTree, generic.NewParallelApplyOptions(cd)) + assert.EqualValues(t, testCase.expected, collected) + }) + } +} + +func TestUnifyPathScenario(t *testing.T) { + ctx := context.Background() + + // + // build and populate a tree + // + originTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, originTree, 2) + + // + // build an empty tree + // + destinationTree := NewMemoryTree(ctx, "D") + + // + // accumulate the call results for verification + // + var collected []string + upsert := func(ctx context.Context, origin generic.NodeInterface, originPath path.Path, destination generic.NodeInterface, destinationPath path.Path) { + originPath = originPath.Append(origin) + destinationPath = destinationPath.Append(destination) + what := originPath.String() + ":" + origin.String() + " => " + destinationPath.String() + ":" + destination.String() + fmt.Printf("unify: %T => %T | %s\n", origin, destination, what) + collected = append(collected, what) + } + + assertTree := func(tree generic.TreeInterface, expected []string) { + collected := make([]string, 0, 10) + tree.Walk(ctx, generic.NewWalkOptions(func(ctx context.Context, path path.Path, node generic.NodeInterface) { + if node.GetKind() == kind.KindRoot { + return + } + path = path.Append(node) + collected = append(collected, path.String()+":"+node.String()) + })) + sort.Strings(collected) + assert.EqualValues(t, expected, collected) + } + + // + // unify the originTree with the destinationTree on the specified path + // + fullPath := generic.NewPathFromString("/O-A/O-B/O-C") + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, fullPath, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + sort.Strings(collected) + assert.EqualValues(t, []string{"/O-A/O-B/O-C:O-C=content O-C => /D-A/D-B/D-C:D-C=content O-C", "/O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "/O-A:O-A=content O-A => /D-A:D-A=content O-A"}, collected) + assertTree(destinationTree, []string{"/D-A/D-B/D-C:D-C=content O-C", "/D-A/D-B:D-B=content O-B", "/D-A:D-A=content O-A"}) + + // + // Add a node unrelated to the unification path + // + var unrelatedOriginPath path.Path + { + originTree.Apply(ctx, generic.NewPathFromString("/O-A/O-B"), generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + assert.EqualValues(t, parent.Length()+1, node.GetCurrentPath().Length()) + unrelated := node.CreateChild(ctx) + unrelated.Upsert(ctx) + memory.SetContent(unrelated, "content "+unrelated.GetID().String()) + unrelated.Upsert(ctx) + unrelatedOriginPath = unrelated.GetCurrentPath() + assert.EqualValues(t, "/O-A/O-B/O-N", (unrelatedOriginPath.PathString().Join())) + })) + } + + // + // Replace the content of the last node + // + lastContent := "LAST" + { + lastPath := generic.NewPathFromString("/O-A/O-B/O-C") + originTree.Apply(ctx, lastPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + memory.SetContent(node, lastContent) + })) + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, lastPath, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + sort.Strings(collected) + assert.EqualValues(t, []string{"/O-A/O-B/O-C:O-C=LAST => /D-A/D-B/D-C:D-C=LAST", "/O-A/O-B:O-B=content O-B => /D-A/D-B:D-B=content O-B", "/O-A:O-A=content O-A => /D-A:D-A=content O-A"}, collected) + assertTree(destinationTree, []string{"/D-A/D-B/D-C:D-C=LAST", "/D-A/D-B:D-B=content O-B", "/D-A:D-A=content O-A"}) + } + // + // Replace the content of the first node + // + firstContent := "FIRST" + { + firstPath := generic.NewPathFromString("/O-A") + originTree.Apply(ctx, firstPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + memory.SetContent(node, firstContent) + })) + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, firstPath, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + sort.Strings(collected) + assert.EqualValues(t, []string{"/O-A:O-A=" + firstContent + " => /D-A:D-A=" + firstContent}, collected) + assertTree(destinationTree, []string{"/D-A/D-B/D-C:D-C=LAST", "/D-A/D-B:D-B=content O-B", "/D-A:D-A=FIRST"}) + } + // + // Replace the content of the second node + // + secondContent := "SECOND" + { + secondPath := generic.NewPathFromString("/O-A/O-B") + originTree.Apply(ctx, secondPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + memory.SetContent(node, secondContent) + })) + collected = make([]string, 0, 10) + generic.TreeUnifyPath(ctx, originTree, secondPath, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert)) + assert.EqualValues(t, []string{"/O-A:O-A=" + firstContent + " => /D-A:D-A=" + firstContent, "/O-A/O-B:O-B=" + secondContent + " => /D-A/D-B:D-B=" + secondContent}, collected) + sort.Strings(collected) + assertTree(destinationTree, []string{"/D-A/D-B/D-C:D-C=LAST", "/D-A/D-B:D-B=SECOND", "/D-A:D-A=FIRST"}) + } + // + // verify the node unrelated to the unification is still there + // + { + var found bool + originTree.Apply(ctx, unrelatedOriginPath, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + found = true + })) + assert.True(t, found) + } +} + +func TestUnifyMirror(t *testing.T) { + ctx := context.Background() + + // + // build and populate a tree + // + originTree := NewMemoryTree(ctx, "O") + log := originTree.GetLogger() + testTreeBuild(t, originTree, 2) + + // + // build an empty tree + // + destinationTree := NewMemoryTree(ctx, "D") + + upsert := func(ctx context.Context, origin generic.NodeInterface, originPath path.Path, destination generic.NodeInterface, destinationPath path.Path) { + assert.NotNil(t, origin.GetDriver().(*memory.Driver)) + assert.NotNil(t, destination.GetDriver().(*memory.Driver)) + originPath = originPath.Append(origin) + destinationPath = destinationPath.Append(destination) + what := fmt.Sprintf("%s:%s => %s:%s", originPath, origin.GetSelf(), destinationPath, destination.GetSelf()) + log.Trace("mirror upsert: %T => %T | %s", origin, destination, what) + } + delete := func(ctx context.Context, destination generic.NodeInterface, destinationPath path.Path) { + assert.NotNil(t, destination.GetDriver().(*memory.Driver)) + destinationPath = destinationPath.Append(destination) + log.Trace("mirror delete: %T | %s:%s", destination, destinationPath, destination) + } + + var sameTree func(origin, destination generic.NodeInterface) bool + sameTree = func(origin, destination generic.NodeInterface) bool { + what := origin.GetCurrentPath().String() + ":" + origin.GetSelf().String() + " => " + destination.GetCurrentPath().String() + ":" + destination.GetSelf().String() + log.Trace("sameTree: %T => %T | %s", origin.GetSelf(), destination.GetSelf(), what) + if origin.GetMappedID() != destination.GetID() { + log.Trace("sameTree: different: %s != %s", origin.GetMappedID(), destination.GetID()) + return false + } + originChildren := origin.GetChildren() + destinationChildren := destination.GetChildren() + if len(originChildren) != len(destinationChildren) { + log.Trace("sameTree: different: length %v != %v", len(originChildren), len(destinationChildren)) + return false + } + + for _, originChild := range originChildren { + destinationChild := destination.GetChild(originChild.GetMappedID()) + if destinationChild == generic.NilNode { + log.Trace("sameTree: different: %s not found", originChild.GetMappedID()) + return false + } + if !sameTree(originChild, destinationChild) { + return false + } + } + return true + } + + // + // unify the originTree with the destinationTree + // + assert.False(t, sameTree(originTree.GetRoot(), destinationTree.GetRoot())) + generic.TreeUnify(ctx, originTree, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetDelete(delete)) + assert.True(t, sameTree(originTree.GetRoot(), destinationTree.GetRoot())) + generic.TreeUnify(ctx, originTree, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetDelete(delete)) + assert.True(t, sameTree(originTree.GetRoot(), destinationTree.GetRoot())) + + { + addNode := func(tree generic.TreeInterface, pathString string) { + tree.Apply(ctx, generic.NewPathFromString(pathString), generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + new := node.CreateChild(ctx) + new.Upsert(ctx) + memory.SetContent(new, "content "+new.GetID().String()) + new.Upsert(ctx) + log.Trace("add: %s", parent.ReadableString()) + log.Trace("add: %s", node.GetCurrentPath().ReadableString()) + log.Trace("add: %s", new.GetCurrentPath().ReadableString()) + })) + } + + for _, testCase := range []struct { + existingPath string + newPath string + }{ + { + existingPath: "/O-A/O-B", + newPath: "/D-A/D-B/D-N", + }, + { + existingPath: "/O-A", + newPath: "/D-A/D-O", + }, + { + existingPath: "/O-A/O-J/O-K", + newPath: "/D-A/D-J/D-K/D-P", + }, + } { + t.Run("add"+testCase.newPath, func(t *testing.T) { + destinationPath := generic.NewPathFromString(testCase.newPath) + addNode(originTree, testCase.existingPath) + assert.False(t, sameTree(originTree.GetRoot(), destinationTree.GetRoot())) + assert.False(t, destinationTree.Exists(ctx, destinationPath), destinationPath.String()) + generic.TreeUnify(ctx, originTree, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetDelete(delete)) + assert.True(t, destinationTree.Exists(ctx, destinationPath), destinationPath.String()) + assert.True(t, sameTree(originTree.GetRoot(), destinationTree.GetRoot())) + }) + } + } + + { + deleteNode := func(tree generic.TreeInterface, toDelete path.Path) { + tree.Apply(ctx, toDelete, generic.NewApplyOptions(func(ctx context.Context, parent, path path.Path, node generic.NodeInterface) { + node.Delete(ctx) + })) + } + + for _, testCase := range []struct { + originPath string + destinationPath string + }{ + { + originPath: "/O-A/O-F", + destinationPath: "/D-A/D-F", + }, + } { + t.Run("delete"+testCase.originPath, func(t *testing.T) { + originPath := generic.NewPathFromString(testCase.originPath) + destinationPath := generic.NewPathFromString(testCase.destinationPath) + assert.True(t, originTree.Exists(ctx, originPath)) + assert.True(t, destinationTree.Exists(ctx, destinationPath), destinationPath.String()) + deleteNode(originTree, originPath) + assert.False(t, originTree.Exists(ctx, originPath)) + generic.TreeUnify(ctx, originTree, destinationTree, generic.NewUnifyOptions(destinationTree).SetUpsert(upsert).SetDelete(delete)) + assert.False(t, destinationTree.Exists(ctx, destinationPath), destinationPath.String()) + }) + } + } +} + +func TestNodeParallelApplyFound(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + start string + destination string + expected []string + expectedRemapped string + }{ + { + start: "/O-A/O-B", + destination: "O-C", + expected: []string{"/O-A/O-B => /D-A/D-B", "/O-A/O-B/O-C => /D-A/D-B/D-C"}, + expectedRemapped: "/D-A/D-B/D-C", + }, + { + start: "/O-A/O-B/O-D", + destination: ".", + expected: []string{"/O-A/O-B/O-D => /D-A/D-B/D-D"}, + expectedRemapped: "/D-A/D-B/D-D", + }, + { + start: ".", + destination: ".", + expected: []string{" => "}, + expectedRemapped: "", + }, + { + start: "/O-A/O-B/O-C", + destination: "../O-D", + expected: []string{"/O-A/O-B => /D-A/D-B", "/O-A/O-B/O-D => /D-A/D-B/D-D"}, + expectedRemapped: "/D-A/D-B/D-D", + }, + { + start: "/", + destination: ".", + expected: []string{" => "}, + expectedRemapped: "", + }, + } { + t.Run(" "+testCase.start+" => "+testCase.destination, func(t *testing.T) { + originTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, originTree, 2) + destinationTree := NewMemoryTree(ctx, "D") + + // + // Mirror two trees + // + generic.TreeMirror(ctx, originTree, destinationTree, generic.NewPathFromString("/"), generic.NewMirrorOptions()) + // + // collect all nodes traversed by the apply function along testCase.destination + // + collected := make([]string, 0, 10) + collect := func(ctx context.Context, origin, destination generic.NodeInterface) { + collected = append(collected, fmt.Sprintf("%s => %s", origin.GetCurrentPath().String(), destination.GetCurrentPath().String())) + } + // + // get to testCase.start and from there run the apply function to reach testCase.destination + // + nodeApply := func(ctx context.Context, origin, destination generic.NodeInterface) { + assert.True(t, generic.NodeParallelApply(ctx, origin, generic.NewPathFromString(testCase.destination), destination, generic.NewParallelApplyOptions(collect).SetWhere(generic.ApplyEachNode))) + } + assert.True(t, generic.TreeParallelApply(ctx, originTree, generic.NewPathFromString(testCase.start), destinationTree, generic.NewParallelApplyOptions(nodeApply))) + assert.EqualValues(t, testCase.expected, collected) + + // + // test TreePathRemap + // + remappedPath := generic.TreePathRemap(ctx, originTree, generic.NewPathFromString(filepath.Join(testCase.start, testCase.destination)), destinationTree) + assert.EqualValues(t, testCase.expectedRemapped, remappedPath.String()) + }) + } +} + +func TestNodeParallelApplyNoRemap(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + noremap bool + start string + expected []string + expectedRemapped string + }{ + { + noremap: false, + start: "/O-A/O-B", + expected: []string{" => ", "/O-A => /D-A", "/O-A/O-B => /D-A/D-B"}, + expectedRemapped: "/D-A/D-B", + }, + { + noremap: true, + start: "/O-A/O-B", + expected: []string{" => ", "/O-A => /O-A", "/O-A/O-B => /O-A/O-B"}, + expectedRemapped: "/O-A/O-B", + }, + } { + t.Run(fmt.Sprintf("noremap=%v,start=%s", testCase.noremap, testCase.start), func(t *testing.T) { + originName := "O" + originTree := NewMemoryTree(ctx, originName) + testTreeBuild(t, originTree, 2) + var destinationName string + if testCase.noremap { + destinationName = originName + } else { + destinationName = "D" + } + destinationTree := NewMemoryTree(ctx, destinationName) + + // + // Mirror two trees + // + generic.TreeMirror(ctx, originTree, destinationTree, generic.NewPathFromString("/"), generic.NewMirrorOptions()) + // + // collect all nodes traversed by the apply function along testCase.destination + // + collected := make([]string, 0, 10) + collect := func(ctx context.Context, origin, destination generic.NodeInterface) { + collected = append(collected, fmt.Sprintf("%s => %s", origin.GetCurrentPath(), destination.GetCurrentPath())) + } + // + // get to testCase.start and from there run the apply function to reach testCase.destination + // + assert.True(t, generic.TreeParallelApply(ctx, originTree, generic.NewPathFromString(testCase.start), destinationTree, generic.NewParallelApplyOptions(collect).SetWhere(generic.ApplyEachNode).SetNoRemap(testCase.noremap))) + assert.EqualValues(t, testCase.expected, collected) + + // + // test TreePathRemap + // + remappedPath := generic.TreePathRemap(ctx, originTree, generic.NewPathFromString(testCase.start), destinationTree) + assert.EqualValues(t, testCase.expectedRemapped, remappedPath.String()) + }) + } +} + +func TestNodeParallelApplyNotFound(t *testing.T) { + ctx := context.Background() + + for _, testCase := range []struct { + start string + destination string + }{ + { + start: "/O-A", + destination: "O-B/???", + }, + { + start: "/O-A/O-B", + destination: "???", + }, + { + start: "/O-A/O-B", + destination: "../???", + }, + } { + t.Run(" "+testCase.start+" => "+testCase.destination, func(t *testing.T) { + originTree := NewMemoryTree(ctx, "O") + testTreeBuild(t, originTree, 2) + destinationTree := NewMemoryTree(ctx, "D") + + // + // Mirror two trees + // + generic.TreeMirror(ctx, originTree, destinationTree, generic.NewPathFromString("/"), generic.NewMirrorOptions()) + // + // get to testCase.start and from there run the apply function to reach testCase.destination + // + var called bool + nodeApply := func(ctx context.Context, origin, destination generic.NodeInterface) { + called = true + found := generic.NodeParallelApply(ctx, origin, generic.NewPathFromString(testCase.destination), destination, generic.NewParallelApplyOptions(nil)) + assert.False(t, found) + } + assert.True(t, generic.TreeParallelApply(ctx, originTree, generic.NewPathFromString(testCase.start), destinationTree, generic.NewParallelApplyOptions(nodeApply))) + assert.True(t, called) + }) + } +} diff --git a/util/convert.go b/util/convert.go new file mode 100644 index 0000000..e1be33c --- /dev/null +++ b/util/convert.go @@ -0,0 +1,29 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "strconv" +) + +func ConvertMap[FromType, ToType any](objects []FromType, converter func(f FromType) ToType) []ToType { + converted := make([]ToType, 0, len(objects)) + for _, object := range objects { + converted = append(converted, converter(object)) + } + return converted +} + +func ParseInt(s string) int64 { + if s == "" { + return 0 + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + panic(fmt.Errorf("%s %w", s, err)) + } + return v +} diff --git a/util/convert_test.go b/util/convert_test.go new file mode 100644 index 0000000..93ec223 --- /dev/null +++ b/util/convert_test.go @@ -0,0 +1,26 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type A struct{} + +func (A) FA() {} + +type AI interface { + FA() +} + +func TestConvertInterface(t *testing.T) { + length := 10 + input := make([]A, length) + output := ConvertMap(input, func(a A) AI { return a }) + assert.EqualValues(t, length, len(output)) +} diff --git a/util/exec.go b/util/exec.go new file mode 100644 index 0000000..096847c --- /dev/null +++ b/util/exec.go @@ -0,0 +1,152 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + + "code.forgejo.org/f3/gof3/v3/logger" +) + +type CommandOptions struct { + ExitCodes []int + Log logger.MessageInterface +} + +func (o *CommandOptions) setDefaults() { + if o.ExitCodes == nil { + o.ExitCodes = []int{0} + } + if o.Log == nil { + o.Log = logger.NewLogger() + } +} + +func CommandWithErr(ctx context.Context, options CommandOptions, prog string, args ...string) (err error) { + defer func() { + if r := recover(); r != nil { + var ok bool + err, ok = r.(error) + if !ok { + panic(r) + } + } + }() + + CommandWithOptions(ctx, options, prog, args...) + + return nil +} + +func Command(ctx context.Context, log logger.MessageInterface, prog string, args ...string) string { + options := CommandOptions{ + Log: log, + } + return CommandWithOptions(ctx, options, prog, args...) +} + +func CommandWithOptions(ctx context.Context, options CommandOptions, prog string, args ...string) string { + if ctx == nil { + panic("ctx context.Context is nil") + } + options.setDefaults() + options.Log.Log(1, logger.Trace, "%s\n", args) + cmd := exec.Command(prog, args...) + SetSysProcAttribute(cmd) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + cmd.Env = append( + cmd.Env, + "GIT_TERMINAL_PROMPT=0", + ) + + err := cmd.Start() + if err != nil { + panic(err) + } + ctxErr := watchCtx(ctx, cmd.Process) + err = cmd.Wait() + interruptErr := <-ctxErr + // If cmd.Wait returned an error, prefer that. + // Otherwise, report any error from the interrupt goroutine. + if interruptErr != nil && err == nil { + err = interruptErr + } + + var code int + if err == nil { + code = 0 + } else if exiterr, ok := err.(*exec.ExitError); ok { + code = exiterr.ExitCode() + if code == -1 { + panic("killed") + } + } else { + panic(err) + } + + found := false + for _, valid := range options.ExitCodes { + if valid == code { + found = true + break + } + } + if !found { + panic(fmt.Errorf("%w: exit code: %d, %v, %v, %v", err, code, out.String(), prog, args)) + } + options.Log.Log(1, logger.Trace, "%s\n", out.String()) + return out.String() +} + +// wrappedError wraps an error without relying on fmt.Errorf. +type wrappedError struct { + prefix string + err error +} + +func (w wrappedError) Error() string { + return w.prefix + ": " + w.err.Error() +} + +func (w wrappedError) Unwrap() error { + return w.err +} + +func watchCtx(ctx context.Context, p *os.Process) <-chan error { + if ctx == nil { + return nil + } + + errc := make(chan error) + go func() { + select { + case errc <- nil: + return + case <-ctx.Done(): + } + + var err error + if killErr := kill(p); killErr == nil { + // We appear to have successfully delivered a kill signal, so any + // program behavior from this point may be due to ctx. + err = ctx.Err() + } else if !errors.Is(killErr, os.ErrProcessDone) { + err = wrappedError{ + prefix: "util: exec: error sending signal", + err: killErr, + } + } + errc <- err + }() + + return errc +} diff --git a/util/exec_test.go b/util/exec_test.go new file mode 100644 index 0000000..588cd1c --- /dev/null +++ b/util/exec_test.go @@ -0,0 +1,66 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommand(t *testing.T) { + something := "SOMETHING" + assert.EqualValues(t, something, Command(context.Background(), nil, "echo", "-n", something)) + + assert.Panics(t, func() { Command(context.Background(), nil, "false") }) +} + +func TestCommandWithOptions(t *testing.T) { + assert.EqualValues(t, "", CommandWithOptions(context.Background(), CommandOptions{ + ExitCodes: []int{0, 1}, + }, "false")) +} + +func TestCommandWithErr(t *testing.T) { + assert.NoError(t, CommandWithErr(context.Background(), CommandOptions{}, "true")) + assert.Error(t, CommandWithErr(context.Background(), CommandOptions{}, "false")) +} + +func TestCommandTimeout(t *testing.T) { + tmp := t.TempDir() + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + // blocks forever because of the firewall at 4.4.4.4 and + // the git clone process forks a git-remote-https child process + assert.PanicsWithValue(t, "killed", func() { + _ = Command(ctx, nil, "git", "clone", "https://4.4.4.4", filepath.Join(tmp, "something")) + }) + }() + + pattern := "git-remote-https origin https://4.4.4.4" + ps := []string{"-x", "-o", "pid,ppid,pgid,args"} + + Retry(func() { + out := Command(context.Background(), nil, "ps", ps...) + if !strings.Contains(out, pattern) { + panic(out + " does not contain " + pattern) + } + }, 5) + + cancel() + <-ctx.Done() + + Retry(func() { + out := Command(context.Background(), nil, "ps", ps...) + if strings.Contains(out, pattern) { + panic(out + " contains " + pattern) + } + }, 5) +} diff --git a/util/exec_unix.go b/util/exec_unix.go new file mode 100644 index 0000000..58dc5f8 --- /dev/null +++ b/util/exec_unix.go @@ -0,0 +1,24 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +// along with this program. If not, see . +// + +//go:build !windows + +package util + +import ( + "os" + "os/exec" + "syscall" +) + +func SetSysProcAttribute(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func kill(p *os.Process) error { + return syscall.Kill(-p.Pid, syscall.SIGKILL) +} diff --git a/util/exec_windows.go b/util/exec_windows.go new file mode 100644 index 0000000..ff724bf --- /dev/null +++ b/util/exec_windows.go @@ -0,0 +1,22 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +// along with this program. If not, see . +// + +//go:build windows + +package util + +import ( + "os" + "os/exec" +) + +func SetSysProcAttribute(cmd *exec.Cmd) { +} + +func kill(p *os.Process) error { + return p.Kill() +} diff --git a/util/file.go b/util/file.go new file mode 100644 index 0000000..8a5b685 --- /dev/null +++ b/util/file.go @@ -0,0 +1,20 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "os" +) + +func FileExists(pathname string) bool { + _, err := os.Stat(pathname) + if err == nil { + return true + } + if os.IsNotExist(err) { + return false + } + panic(err) +} diff --git a/util/json.go b/util/json.go new file mode 100644 index 0000000..0240d5e --- /dev/null +++ b/util/json.go @@ -0,0 +1,51 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" +) + +func jsonMap[T any](data any, key string) T { + return jsonGet[T](jsonGetObject(data)[key]) +} + +func jsonGetObject(data any) map[string]any { + switch v := data.(type) { + case nil: + return nil + case map[string]any: + return v + default: + panic(fmt.Errorf("unexpected %T", data)) + } +} + +func jsonElement[T any](data any, index int) T { + return jsonGet[T](jsonGetArray(data)[index]) +} + +func jsonGetArray(data any) []any { + switch v := data.(type) { + case nil: + return nil + case []any: + return v + default: + panic(fmt.Errorf("unexpected %T", data)) + } +} + +func jsonGet[T any](data any) T { + switch v := data.(type) { + case nil: + var s T + return s + case T: + return v + default: + panic(fmt.Errorf("unexpected %T", data)) + } +} diff --git a/util/json_test.go b/util/json_test.go new file mode 100644 index 0000000..27e58c8 --- /dev/null +++ b/util/json_test.go @@ -0,0 +1,39 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJSON(t *testing.T) { + var j any + content := []byte(` +{ + "A": "B", + "C": 1, + "D": 2.1, + "E": true, + "F": null, + "G": ["A", 1] +} +`) + assert.NoError(t, json.Unmarshal(content, &j)) + assert.EqualValues(t, "B", jsonMap[string](jsonGetObject(j), "A")) + assert.EqualValues(t, 1, int(jsonMap[float64](jsonGetObject(j), "C"))) + assert.EqualValues(t, 2.1, jsonMap[float64](jsonGetObject(j), "D")) + assert.EqualValues(t, true, jsonMap[bool](jsonGetObject(j), "E")) + + assert.EqualValues(t, nil, jsonMap[any](jsonGetObject(j), "F")) + assert.EqualValues(t, "", jsonMap[string](jsonGetObject(j), "F")) + assert.EqualValues(t, 0, jsonMap[int](jsonGetObject(j), "F")) + + a := jsonMap[any](jsonGetObject(j), "G") + assert.EqualValues(t, "A", jsonElement[string](a, 0)) + assert.EqualValues(t, 1, int(jsonElement[float64](a, 1))) +} diff --git a/util/panic.go b/util/panic.go new file mode 100644 index 0000000..f63d8c2 --- /dev/null +++ b/util/panic.go @@ -0,0 +1,98 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "runtime" + "strings" +) + +var packagePrefix string + +func init() { + _, filename, _, _ := runtime.Caller(0) + packagePrefix = strings.TrimSuffix(filename, "util/panic.go") + if packagePrefix == filename { + // in case the source code file is moved, we can not trim the suffix, the code above should also be updated. + panic("unable to detect correct package prefix, please update file: " + filename) + } +} + +func getStack() string { + callersLength := 5 + var callers []uintptr + var callersCount int + for { + callers = make([]uintptr, callersLength) + callersCount = runtime.Callers(4, callers) + if callersCount <= 0 { + panic("runtime.Callers <= 0") + } + if callersCount >= callersLength { + callersLength *= 2 + } else { + break + } + } + callers = callers[:callersCount] + frames := runtime.CallersFrames(callers) + stack := make([]string, 0, 10) + for { + frame, more := frames.Next() + if strings.HasPrefix(frame.File, packagePrefix) { + file := strings.TrimPrefix(frame.File, packagePrefix) + if file == "util/panic.go" || file == "util/terminate.go" && more { + continue + } + var function string + dot := strings.LastIndex(frame.Function, ".") + if dot >= 0 { + function = frame.Function[dot+1:] + } + stack = append(stack, fmt.Sprintf("%s:%d:%s", file, frame.Line, function)) + } + if !more { + break + } + } + return strings.Join(stack, "\n") + "\n" +} + +type PanicError interface { + error + Stack() string +} + +type panicError struct { + message string + stack string +} + +func (o panicError) Error() string { return o.message } +func (o panicError) Stack() string { return o.stack } + +func PanicToError(fun func()) (err PanicError) { + defer func() { + if r := recover(); r != nil { + recoveredErr, ok := r.(error) + var message string + if ok { + message = recoveredErr.Error() + } + err = panicError{ + message: message, + stack: getStack(), + } + if !ok { + panic(r) + } + } + }() + + fun() + + return err +} diff --git a/util/panic_test.go b/util/panic_test.go new file mode 100644 index 0000000..7b532dd --- /dev/null +++ b/util/panic_test.go @@ -0,0 +1,24 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPanicToError(t *testing.T) { + assert.NoError(t, PanicToError(func() {})) + + errorMessage := "ERROR" + panicingFun := func() { + panic(errors.New(errorMessage)) + } + err := PanicToError(panicingFun) + assert.EqualValues(t, errorMessage, err.Error()) + assert.Contains(t, err.Stack(), "PanicToError") +} diff --git a/util/rand.go b/util/rand.go new file mode 100644 index 0000000..cd2b514 --- /dev/null +++ b/util/rand.go @@ -0,0 +1,23 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "math/rand" + "time" +) + +var ( + letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + random = rand.New(rand.NewSource(time.Now().UnixNano())) +) + +func RandSeq(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[random.Intn(len(letters))] + } + return string(b) +} diff --git a/util/rand_test.go b/util/rand_test.go new file mode 100644 index 0000000..281d618 --- /dev/null +++ b/util/rand_test.go @@ -0,0 +1,16 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRandSeq(t *testing.T) { + l := 23 + assert.Len(t, RandSeq(l), l) +} diff --git a/util/retry.go b/util/retry.go new file mode 100644 index 0000000..30c94aa --- /dev/null +++ b/util/retry.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "time" +) + +func retry(fun func()) (status interface{}) { + defer func() { + status = recover() + }() + + fun() + + return status +} + +func Retry(fun func(), tries int) { + errors := make([]interface{}, 0, tries) + for i := 0; i < tries; i++ { + err := retry(fun) + if err == nil { + return + } + errors = append(errors, err) + <-time.After(1 * time.Second) + } + panic(fmt.Errorf("Retry: failed %v", errors)) +} diff --git a/util/retry_test.go b/util/retry_test.go new file mode 100644 index 0000000..806d06d --- /dev/null +++ b/util/retry_test.go @@ -0,0 +1,24 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRetryOK(t *testing.T) { + Retry(func() {}, 1) + i := 0 + Retry(func() { + i++ + if i < 3 { + panic(fmt.Errorf("i == %d", i)) + } + }, 5) + assert.EqualValues(t, i, 3) +} diff --git a/util/terminate.go b/util/terminate.go new file mode 100644 index 0000000..cbb7efb --- /dev/null +++ b/util/terminate.go @@ -0,0 +1,42 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "context" +) + +// Terminated terminated +type Terminated struct{} + +func (err Terminated) Error() string { + return "execution is terminated" +} + +func MaybeTerminate(ctx context.Context) { + select { + case <-ctx.Done(): + panic(Terminated{}) + default: + } +} + +func CatchTerminate(f func()) (status bool) { + status = false + defer func() { + if r := recover(); r != nil { + _, ok := r.(Terminated) + if ok { + status = true + } else { + panic(r) + } + } + }() + + f() + + return status +} diff --git a/util/terminate_test.go b/util/terminate_test.go new file mode 100644 index 0000000..bafeff0 --- /dev/null +++ b/util/terminate_test.go @@ -0,0 +1,22 @@ +// Copyright Earl Warren +// Copyright Loïc Dachary +// SPDX-License-Identifier: MIT + +package util + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTerminate(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + assert.False(t, CatchTerminate(func() { MaybeTerminate(ctx) })) + cancel() + assert.True(t, CatchTerminate(func() { MaybeTerminate(ctx) })) + assert.Panics(t, func() { + CatchTerminate(func() { panic(true) }) + }) +}