From 3fbb5aebe7f53854d230c0a091f647a50dccf0c9 Mon Sep 17 00:00:00 2001
From: Julien Dessaux <julien.dessaux@adyxax.org>
Date: Thu, 8 May 2025 23:54:51 +0200
Subject: [PATCH] feat(provider): add users data-source

---
 CHANGELOG.md                                  |   3 +-
 docs/data-sources/users.md                    |  53 +++++
 .../data-sources/forgejo_users/data-source.tf |   1 +
 go.mod                                        |   1 +
 go.sum                                        |   2 +
 internal/client/users.go                      |  56 +++++
 internal/provider/provider.go                 |   4 +-
 internal/provider/users_data_source.go        | 211 ++++++++++++++++++
 8 files changed, 329 insertions(+), 2 deletions(-)
 create mode 100644 docs/data-sources/users.md
 create mode 100644 examples/data-sources/forgejo_users/data-source.tf
 create mode 100644 internal/client/users.go
 create mode 100644 internal/provider/users_data_source.go

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2247d6..955ad70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,4 +6,5 @@ All notable changes to this project will be documented in this file.
 
 ### Added
 
-- Added provider configuration
+- Added provider configuration.
+- Added users data-source.
diff --git a/docs/data-sources/users.md b/docs/data-sources/users.md
new file mode 100644
index 0000000..b4529ce
--- /dev/null
+++ b/docs/data-sources/users.md
@@ -0,0 +1,53 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "forgejo_users Data Source - terraform-provider-forgejo"
+subcategory: ""
+description: |-
+  Use this data source to retrieve information about existing forgejo users.
+---
+
+# forgejo_users (Data Source)
+
+Use this data source to retrieve information about existing forgejo users.
+
+## Example Usage
+
+```terraform
+data "forgejo_users" "example" {}
+```
+
+<!-- schema generated by tfplugindocs -->
+## Schema
+
+### Read-Only
+
+- `elements` (Attributes List) The list of users. (see [below for nested schema](#nestedatt--elements))
+
+<a id="nestedatt--elements"></a>
+### Nested Schema for `elements`
+
+Read-Only:
+
+- `active` (Boolean) Whether the user is active or not.
+- `avatar_url` (String) The user's avatar URL.
+- `created` (String) The user's creation date and time.
+- `description` (String) A description string.
+- `email` (String) The user's email address.
+- `followers_count` (Number) The number of followers.
+- `following_count` (Number) The number of followings.
+- `full_name` (String) The user's full name.
+- `html_url` (String) The URL to this user's Forgejo profile page.
+- `id` (Number) The identifier of the user.
+- `is_admin` (Boolean) Whether the user is an admin or not.
+- `language` (String) The user's chosen language.
+- `last_login` (String) The user's last login date and time.
+- `location` (String) The user's advertised location.
+- `login` (String) The login of the user.
+- `login_name` (String) The user's authentication sign-in name.
+- `prohibit_login` (Boolean) Whether the user is allowed to log in or not.
+- `pronouns` (String) The user's advertised pronouns.
+- `restricted` (Boolean) Whether the user is restricted or not.
+- `source_id` (Number) The identifier of the users authentication source.
+- `starred_repos_count` (Number) The number of repositoties starred by the user.
+- `visibility` (String) The user's visibility option: limited, private, public.
+- `website` (String) The user's advertised website.
diff --git a/examples/data-sources/forgejo_users/data-source.tf b/examples/data-sources/forgejo_users/data-source.tf
new file mode 100644
index 0000000..9b8ba57
--- /dev/null
+++ b/examples/data-sources/forgejo_users/data-source.tf
@@ -0,0 +1 @@
+data "forgejo_users" "example" {}
diff --git a/go.mod b/go.mod
index 0915fb8..7ba4a90 100644
--- a/go.mod
+++ b/go.mod
@@ -35,6 +35,7 @@ require (
 	github.com/hashicorp/hc-install v0.9.1 // indirect
 	github.com/hashicorp/terraform-exec v0.22.0 // indirect
 	github.com/hashicorp/terraform-json v0.24.0 // indirect
+	github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 // indirect
 	github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect
 	github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect
 	github.com/hashicorp/terraform-registry-address v0.2.5 // indirect
diff --git a/go.sum b/go.sum
index 081565e..4061737 100644
--- a/go.sum
+++ b/go.sum
@@ -91,6 +91,8 @@ github.com/hashicorp/terraform-plugin-docs v0.21.0 h1:yoyA/Y719z9WdFJAhpUkI1jRbK
 github.com/hashicorp/terraform-plugin-docs v0.21.0/go.mod h1:J4Wott1J2XBKZPp/NkQv7LMShJYOcrqhQ2myXBcu64s=
 github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY=
 github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4=
+github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 h1:v3DapR8gsp3EM8fKMh6up9cJUFQ2iRaFsYLP8UJnCco=
+github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0/go.mod h1:c3PnGE9pHBDfdEVG9t1S1C9ia5LW+gkFR0CygXlM8ak=
 github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M=
 github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY=
 github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
diff --git a/internal/client/users.go b/internal/client/users.go
new file mode 100644
index 0000000..be2bcaf
--- /dev/null
+++ b/internal/client/users.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"time"
+)
+
+type User struct {
+	Active           bool      `json:"active"`
+	AvatarUrl        string    `json:"avatar_url"`
+	Created          time.Time `json:"created"`
+	Description      string    `json:"description"`
+	Email            string    `json:"email"`
+	FollowerCount    int64     `json:"followers_count"`
+	FollowingCount   int64     `json:"following_count"`
+	FullName         string    `json:"full_name"`
+	HtmlUrl          string    `json:"html_url"`
+	Id               int64     `json:"id"`
+	IsAdmin          bool      `json:"is_admin"`
+	Language         string    `json:"language"`
+	LastLogin        time.Time `json:"last_login"`
+	Location         string    `json:"location"`
+	LoginName        string    `json:"login_name"`
+	Login            string    `json:"login"`
+	ProhibitLogin    bool      `json:"prohibit_login"`
+	Pronouns         string    `json:"pronouns"`
+	Restricted       bool      `json:"restricted"`
+	SourceId         int64     `json:"source_id"`
+	StarredRepoCount int64     `json:"starred_repos_count"`
+	Visibility       string    `json:"visibility"`
+	Website          string    `json:"website"`
+}
+
+func (c *Client) UsersList(ctx context.Context) ([]User, error) {
+	type Response struct {
+		Data []User `json:"data"`
+		Ok   bool   `json:"ok"`
+	}
+	var response Response
+	query := make(url.Values)
+	query.Set("limit", "50")
+	query.Set("page", "1")
+	uriRef := url.URL{
+		Path:     "api/v1/users/search",
+		RawQuery: query.Encode(),
+	}
+	if err := c.Send(ctx, "GET", &uriRef, nil, &response); err != nil {
+		return nil, fmt.Errorf("failed to search users: %w", err)
+	}
+	if !response.Ok {
+		return response.Data, fmt.Errorf("got a non OK status when querying users/search")
+	}
+	return response.Data, nil
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index a932f16..55b5847 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -79,5 +79,7 @@ func (p *Provider) Resources(ctx context.Context) []func() resource.Resource {
 }
 
 func (p *Provider) DataSources(ctx context.Context) []func() datasource.DataSource {
-	return []func() datasource.DataSource{}
+	return []func() datasource.DataSource{
+		NewUsersDataSource,
+	}
 }
diff --git a/internal/provider/users_data_source.go b/internal/provider/users_data_source.go
new file mode 100644
index 0000000..1a6ffdf
--- /dev/null
+++ b/internal/provider/users_data_source.go
@@ -0,0 +1,211 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+
+	"git.adyxax.org/adyxax/terraform-provider-forgejo/internal/client"
+	"github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes"
+	"github.com/hashicorp/terraform-plugin-framework/datasource"
+	"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+type UsersDataSource struct {
+	client *client.Client
+}
+
+var _ datasource.DataSource = &UsersDataSource{} // Ensure provider defined types fully satisfy framework interfaces
+func NewUsersDataSource() datasource.DataSource {
+	return &UsersDataSource{}
+}
+
+type UsersDataSourceModel struct {
+	Elements []UserDataSourceModel `tfsdk:"elements"`
+}
+type UserDataSourceModel struct {
+	Active           types.Bool        `tfsdk:"active"`
+	AvatarUrl        types.String      `tfsdk:"avatar_url"`
+	Created          timetypes.RFC3339 `tfsdk:"created"`
+	Description      types.String      `tfsdk:"description"`
+	Email            types.String      `tfsdk:"email"`
+	FollowerCount    types.Int64       `tfsdk:"followers_count"`
+	FollowingCount   types.Int64       `tfsdk:"following_count"`
+	FullName         types.String      `tfsdk:"full_name"`
+	HtmlUrl          types.String      `tfsdk:"html_url"`
+	Id               types.Int64       `tfsdk:"id"`
+	IsAdmin          types.Bool        `tfsdk:"is_admin"`
+	Language         types.String      `tfsdk:"language"`
+	LastLogin        timetypes.RFC3339 `tfsdk:"last_login"`
+	Location         types.String      `tfsdk:"location"`
+	LoginName        types.String      `tfsdk:"login_name"`
+	Login            types.String      `tfsdk:"login"`
+	ProhibitLogin    types.Bool        `tfsdk:"prohibit_login"`
+	Pronouns         types.String      `tfsdk:"pronouns"`
+	Restricted       types.Bool        `tfsdk:"restricted"`
+	SourceId         types.Int64       `tfsdk:"source_id"`
+	StarredRepoCount types.Int64       `tfsdk:"starred_repos_count"`
+	Visibility       types.String      `tfsdk:"visibility"`
+	Website          types.String      `tfsdk:"website"`
+}
+
+func (d *UsersDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_users"
+}
+
+func (d *UsersDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Attributes: map[string]schema.Attribute{
+			"elements": schema.ListNestedAttribute{
+				Computed:            true,
+				MarkdownDescription: "The list of users.",
+				NestedObject: schema.NestedAttributeObject{
+					Attributes: map[string]schema.Attribute{
+						"active": schema.BoolAttribute{
+							Computed:            true,
+							MarkdownDescription: "Whether the user is active or not.",
+						},
+						"avatar_url": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's avatar URL.",
+						},
+						"created": schema.StringAttribute{
+							Computed:            true,
+							CustomType:          timetypes.RFC3339Type{},
+							MarkdownDescription: "The user's creation date and time.",
+						},
+						"description": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "A description string.",
+						},
+						"email": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's email address.",
+						},
+						"followers_count": schema.Int64Attribute{
+							Computed:            true,
+							MarkdownDescription: "The number of followers.",
+						},
+						"following_count": schema.Int64Attribute{
+							Computed:            true,
+							MarkdownDescription: "The number of followings.",
+						},
+						"full_name": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's full name.",
+						},
+						"html_url": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The URL to this user's Forgejo profile page.",
+						},
+						"id": schema.Int64Attribute{
+							Computed:            true,
+							MarkdownDescription: "The identifier of the user.",
+						},
+						"is_admin": schema.BoolAttribute{
+							Computed:            true,
+							MarkdownDescription: "Whether the user is an admin or not.",
+						},
+						"language": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's chosen language.",
+						},
+						"last_login": schema.StringAttribute{
+							Computed:            true,
+							CustomType:          timetypes.RFC3339Type{},
+							MarkdownDescription: "The user's last login date and time.",
+						},
+						"location": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's advertised location.",
+						},
+						"login_name": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's authentication sign-in name.",
+						},
+						"login": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The login of the user.",
+						},
+						"prohibit_login": schema.BoolAttribute{
+							Computed:            true,
+							MarkdownDescription: "Whether the user is allowed to log in or not.",
+						},
+						"pronouns": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's advertised pronouns.",
+						},
+						"restricted": schema.BoolAttribute{
+							Computed:            true,
+							MarkdownDescription: "Whether the user is restricted or not.",
+						},
+						"source_id": schema.Int64Attribute{
+							Computed:            true,
+							MarkdownDescription: "The identifier of the users authentication source.",
+						},
+						"starred_repos_count": schema.Int64Attribute{
+							Computed:            true,
+							MarkdownDescription: "The number of repositoties starred by the user.",
+						},
+						"visibility": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's visibility option: limited, private, public.",
+						},
+						"website": schema.StringAttribute{
+							Computed:            true,
+							MarkdownDescription: "The user's advertised website.",
+						},
+					},
+				},
+			},
+		},
+		MarkdownDescription: "Use this data source to retrieve information about existing forgejo users.",
+	}
+}
+
+func (d *UsersDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+	d.client, _ = req.ProviderData.(*client.Client)
+}
+
+func (d *UsersDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+	var data UsersDataSourceModel
+	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+	users, err := d.client.UsersList(ctx)
+	if err != nil {
+		resp.Diagnostics.AddError("ListUsers", fmt.Sprintf("failed to list users: %s", err))
+		return
+	}
+	userList := make([]UserDataSourceModel, len(users))
+	for i, user := range users {
+		userList[i] = UserDataSourceModel{
+			Active:           types.BoolValue(user.Active),
+			AvatarUrl:        types.StringValue(user.AvatarUrl),
+			Created:          timetypes.NewRFC3339TimeValue(user.Created),
+			Description:      types.StringValue(user.Description),
+			Email:            types.StringValue(user.Email),
+			FollowerCount:    types.Int64Value(user.FollowerCount),
+			FollowingCount:   types.Int64Value(user.FollowingCount),
+			FullName:         types.StringValue(user.FullName),
+			HtmlUrl:          types.StringValue(user.HtmlUrl),
+			Id:               types.Int64Value(user.Id),
+			IsAdmin:          types.BoolValue(user.IsAdmin),
+			Language:         types.StringValue(user.Language),
+			LastLogin:        timetypes.NewRFC3339TimeValue(user.LastLogin),
+			Location:         types.StringValue(user.Location),
+			LoginName:        types.StringValue(user.LoginName),
+			Login:            types.StringValue(user.Login),
+			ProhibitLogin:    types.BoolValue(user.ProhibitLogin),
+			Pronouns:         types.StringValue(user.Pronouns),
+			Restricted:       types.BoolValue(user.Restricted),
+			SourceId:         types.Int64Value(user.SourceId),
+			StarredRepoCount: types.Int64Value(user.StarredRepoCount),
+			Visibility:       types.StringValue(user.Visibility),
+			Website:          types.StringValue(user.Website),
+		}
+	}
+	data.Elements = userList
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
+}