wip-ruby 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ module Models
5
+ # Represents a paginated collection of items
6
+ class Collection
7
+ include Enumerable
8
+
9
+ # @return [Array<Base>] The items in the collection
10
+ attr_reader :data
11
+
12
+ # @return [Boolean] Whether there are more items
13
+ attr_reader :has_more
14
+
15
+ # @return [Integer] The total count of items
16
+ attr_reader :total_count
17
+
18
+ # Initialize a new collection
19
+ # @param data [Array<Base>] The items in the collection
20
+ # @param has_more [Boolean] Whether there are more items
21
+ # @param total_count [Integer] The total count of items
22
+ def initialize(data:, has_more:, total_count:)
23
+ @data = data
24
+ @has_more = has_more
25
+ @total_count = total_count
26
+ end
27
+
28
+ # Create a new collection from a JSON response
29
+ # @param json [Hash] The JSON response
30
+ # @param item_class [Class] The class to instantiate items as
31
+ # @return [Collection] The new collection
32
+ # @raise [ArgumentError] If json is nil or not a Hash
33
+ def self.from_json(json, item_class:)
34
+ raise ArgumentError, "Expected Hash, got #{json.class}" unless json.is_a?(Hash)
35
+
36
+ data = json["data"]
37
+ raise ArgumentError, "Missing 'data' key in response" unless data.is_a?(Array)
38
+
39
+ new(
40
+ data: data.map { |item| item_class.from_json(item) },
41
+ has_more: json.fetch("has_more", false),
42
+ total_count: json["total_count"]
43
+ )
44
+ end
45
+
46
+ # Iterate over the items in the collection
47
+ # @yield [item] Each item in the collection
48
+ # @return [Enumerator] If no block is given
49
+ def each(&block)
50
+ data.each(&block)
51
+ end
52
+
53
+ # @return [Integer] The number of items in the collection
54
+ def size
55
+ data.size
56
+ end
57
+ alias length size
58
+ alias count size
59
+
60
+ # @return [Boolean] Whether the collection is empty
61
+ def empty?
62
+ data.empty?
63
+ end
64
+
65
+ # @return [Base, nil] The first item in the collection
66
+ def first
67
+ data.first
68
+ end
69
+
70
+ # @return [Base, nil] The last item in the collection
71
+ def last
72
+ data.last
73
+ end
74
+
75
+ # @return [String, nil] The ID of the last item (for pagination)
76
+ def last_id
77
+ last&.id
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "user"
5
+ require_relative "concerns/reactable"
6
+
7
+ module Wip
8
+ module Models
9
+ # Represents a comment on a todo
10
+ class Comment < Base
11
+ include Concerns::Reactable
12
+
13
+ attribute :id, type: :string
14
+ attribute :body, type: :string
15
+ attribute :created_at, type: :time
16
+ attribute :updated_at, type: :time
17
+ attribute :url, type: :string
18
+ attribute :reactions_count, type: :integer
19
+ attribute :viewer, type: Hash # Hash with reactions array
20
+ attribute :creator, type: User
21
+
22
+ # @return [String] String representation of the comment
23
+ def to_s
24
+ body
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ module Models
5
+ module Concerns
6
+ # Shared behavior for models that can have reactions (Todo, Comment)
7
+ module Reactable
8
+ # @return [Boolean] Whether this resource has any reactions
9
+ def reactions?
10
+ reactions_count&.positive?
11
+ end
12
+
13
+ # @return [Array<Hash>] The viewer's reactions on this resource (empty if none)
14
+ def viewer_reactions
15
+ viewer&.dig("reactions") || []
16
+ end
17
+
18
+ # @return [Boolean] Whether the authenticated user has reacted to this resource
19
+ def reacted_by_viewer?
20
+ viewer_reactions.any?
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "user"
5
+
6
+ module Wip
7
+ module Models
8
+ # Represents a project
9
+ class Project < Base
10
+ attribute :id, type: :string
11
+ attribute :slug, type: :string
12
+ attribute :name, type: :string
13
+ attribute :pitch, type: :string
14
+ attribute :description, type: :string
15
+ attribute :created_at, type: :time
16
+ attribute :updated_at, type: :time
17
+ attribute :hashtag, type: :string
18
+ attribute :website_url, type: :string
19
+ attribute :protected, type: :boolean
20
+ attribute :archived, type: :boolean
21
+ attribute :url, type: :string
22
+ attribute :logo, type: Hash # Hash with small, medium, large URLs
23
+ attribute :owner, type: User
24
+ attribute :makers, type: [User]
25
+
26
+ # @return [String] String representation of the project
27
+ def to_s
28
+ name
29
+ end
30
+
31
+ # @return [Boolean] Whether this project has a logo
32
+ def logo?
33
+ logo&.any?
34
+ end
35
+
36
+ # @return [Boolean] Whether this project has multiple makers
37
+ def team_project?
38
+ makers&.length.to_i > 1
39
+ end
40
+
41
+ # @return [String, nil] The logo URL for the given size
42
+ # @param size [Symbol] The size of the logo (:small, :medium, :large)
43
+ def logo_url(size = :medium)
44
+ logo&.dig(size.to_s)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "user"
5
+
6
+ module Wip
7
+ module Models
8
+ # Represents a reaction (like) on a todo or comment
9
+ class Reaction < Base
10
+ attribute :id, type: :string
11
+ attribute :created_at, type: :time
12
+ attribute :reactable_type, type: :string
13
+ attribute :reactable_id, type: :string
14
+ attribute :reactor, type: User
15
+
16
+ # @return [String] String representation of the reaction
17
+ def to_s
18
+ "Reaction by #{reactor} on #{reactable_type} #{reactable_id}"
19
+ end
20
+
21
+ # @return [Boolean] Whether this reaction is on a todo
22
+ def on_todo?
23
+ reactable_type == "Todo"
24
+ end
25
+
26
+ # @return [Boolean] Whether this reaction is on a comment
27
+ def on_comment?
28
+ reactable_type == "Comment"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "project"
5
+ require_relative "concerns/reactable"
6
+
7
+ module Wip
8
+ module Models
9
+ # Represents a todo item
10
+ class Todo < Base
11
+ include Concerns::Reactable
12
+
13
+ attribute :id, type: :string
14
+ attribute :created_at, type: :time
15
+ attribute :updated_at, type: :time
16
+ attribute :body, type: :string
17
+ attribute :url, type: :string
18
+ attribute :attachments, type: [Hash] # Array of attachment objects
19
+ attribute :creator_id, type: :string
20
+ attribute :user_id, type: :string # @deprecated Use creator_id instead
21
+ attribute :projects, type: [Project]
22
+ attribute :reactions_count, type: :integer
23
+ attribute :comments_count, type: :integer
24
+ attribute :viewer, type: Hash # Hash with reactions and comments arrays
25
+
26
+ # @return [String] String representation of the todo
27
+ def to_s
28
+ body
29
+ end
30
+
31
+ # @return [Boolean] Whether this todo has any attachments
32
+ def attachments?
33
+ attachments&.any?
34
+ end
35
+
36
+ # @return [Boolean] Whether this todo belongs to any projects
37
+ def projects?
38
+ projects&.any?
39
+ end
40
+
41
+ # @return [Boolean] Whether this todo has any comments
42
+ def comments?
43
+ comments_count&.positive?
44
+ end
45
+
46
+ # @return [Array<Hash>] The viewer's comments on this todo (empty if none)
47
+ def viewer_comments
48
+ viewer&.dig("comments") || []
49
+ end
50
+
51
+ # @return [Boolean] Whether the authenticated user has commented on this todo
52
+ def commented_by_viewer?
53
+ viewer_comments.any?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Wip
6
+ module Models
7
+ # Represents a user
8
+ class User < Base
9
+ attribute :id, type: :string
10
+ attribute :username, type: :string
11
+ attribute :streak, type: :integer
12
+ attribute :created_at, type: :time
13
+ attribute :updated_at, type: :time
14
+ attribute :protected, type: :boolean
15
+ attribute :first_name, type: :string
16
+ attribute :last_name, type: :string
17
+ attribute :todos_count, type: :integer
18
+ attribute :time_zone, type: :string
19
+ attribute :url, type: :string
20
+ attribute :avatar, type: Hash # Hash with small, medium, large URLs
21
+ attribute :best_streak, type: :integer
22
+ attribute :streaking, type: :boolean
23
+
24
+ # @return [String] The user's full name
25
+ def full_name
26
+ [first_name, last_name].compact.join(" ").strip
27
+ end
28
+
29
+ # @return [String] String representation of the user
30
+ def to_s
31
+ full_name.empty? ? username : full_name
32
+ end
33
+
34
+ # @return [String, nil] The avatar URL for the given size
35
+ # @param size [Symbol] The size of the avatar (:small, :medium, :large)
36
+ def avatar_url(size = :medium)
37
+ avatar&.dig(size.to_s)
38
+ end
39
+
40
+ # @return [Boolean] Whether the user has completed any todos
41
+ def todos?
42
+ todos_count&.positive?
43
+ end
44
+
45
+ # @return [Boolean] Whether the user is currently on a streak
46
+ def on_streak?
47
+ streak&.positive? && streaking?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Wip
6
+ module Resources
7
+ # Base class for all API resources
8
+ # @api private
9
+ class Base
10
+ # @return [HTTPClient] The HTTP client instance
11
+ attr_reader :client
12
+
13
+ # Initialize a new resource
14
+ # @param client [HTTPClient] The HTTP client instance
15
+ def initialize(client)
16
+ @client = client
17
+ end
18
+
19
+ private
20
+
21
+ # Build a path with parameters
22
+ # @param path [String] The path template
23
+ # @param params [Hash] The parameters to interpolate
24
+ # @return [String] The built path
25
+ def build_path(path, params = {})
26
+ path.gsub(/\{([^}]+)\}/) do |match|
27
+ key = Regexp.last_match(1)
28
+ value = params[key.to_sym] || params[key.to_s]
29
+ raise ArgumentError, "Missing parameter: #{key}" unless value
30
+ value.to_s
31
+ end
32
+ end
33
+
34
+ # Extract pagination parameters from options
35
+ # @param options [Hash] The options hash
36
+ # @return [Hash] The pagination parameters
37
+ def extract_pagination_params(options)
38
+ {
39
+ limit: options[:limit],
40
+ starting_after: options[:starting_after]
41
+ }.compact
42
+ end
43
+
44
+ # Extract pagination and date filter parameters for todo endpoints
45
+ # @param options [Hash] The options hash
46
+ # @option options [Integer] :limit Number of items to return
47
+ # @option options [String] :starting_after Cursor for pagination
48
+ # @option options [Time, Date, String, Integer] :since Filter todos created since this date
49
+ # @option options [Time, Date, String, Integer] :before Filter todos created before this date
50
+ # @return [Hash] The query parameters
51
+ def extract_todo_params(options)
52
+ params = extract_pagination_params(options)
53
+ params[:since] = format_date_param(options[:since]) if options[:since]
54
+ params[:until] = format_date_param(options[:before]) if options[:before]
55
+ params
56
+ end
57
+
58
+ # Format a date parameter for the API
59
+ # Accepts: Time, Date, String (ISO 8601, YYYY, YYYY-MM, YYYY-MM-DD), or Integer (Unix timestamp)
60
+ # @param value [Time, Date, String, Integer] The date value
61
+ # @return [String] The formatted date string
62
+ def format_date_param(value)
63
+ case value
64
+ when Time
65
+ value.utc.iso8601
66
+ when Date
67
+ value.iso8601
68
+ when Integer
69
+ # Unix timestamp
70
+ value.to_s
71
+ when String
72
+ # Pass through strings as-is (API accepts multiple formats)
73
+ value
74
+ else
75
+ raise ArgumentError, "Invalid date value: #{value.inspect}. Expected Time, Date, String, or Integer (Unix timestamp)"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../models/comment"
5
+ require_relative "../models/collection"
6
+
7
+ module Wip
8
+ module Resources
9
+ # Resource for interacting with comments
10
+ #
11
+ # Comments can be added to todos. The authenticated user can create, update,
12
+ # and delete their own comments.
13
+ #
14
+ # @example Create a comment on a todo
15
+ # client.comments.create(
16
+ # commentable_type: "Todo",
17
+ # commentable_id: "todo_123",
18
+ # body: "Great progress!"
19
+ # )
20
+ #
21
+ # @example List comments for a todo
22
+ # client.comments.for_todo("todo_123")
23
+ class Comments < Base
24
+ # Valid commentable types
25
+ COMMENTABLE_TYPES = %w[Todo].freeze
26
+
27
+ # List comments for a todo
28
+ # @param todo_id [String] The todo ID
29
+ # @param limit [Integer] Number of items to return (default: 25)
30
+ # @param starting_after [String] Cursor for pagination
31
+ # @return [Models::Collection<Models::Comment>] The paginated comments
32
+ def for_todo(todo_id, limit: nil, starting_after: nil)
33
+ path = build_path("/v1/todos/{todo_id}/comments", todo_id: todo_id)
34
+ params = extract_pagination_params(limit: limit, starting_after: starting_after)
35
+ response = client.get(path, params)
36
+ Models::Collection.from_json(response.extract_resource, item_class: Models::Comment)
37
+ end
38
+
39
+ # Create a comment on a resource
40
+ # @param commentable_type [String] The type of resource to comment on ("Todo")
41
+ # @param commentable_id [String] The ID of the resource to comment on
42
+ # @param body [String] The content of the comment
43
+ # @return [Models::Comment] The created comment
44
+ # @raise [ArgumentError] If commentable_type is invalid
45
+ def create(commentable_type:, commentable_id:, body:)
46
+ validate_commentable_type!(commentable_type)
47
+ validate_body!(body)
48
+
49
+ response = client.post("/v1/comments", {
50
+ commentable_type: commentable_type,
51
+ commentable_id: commentable_id,
52
+ body: body
53
+ })
54
+ Models::Comment.from_json(response.extract_resource)
55
+ end
56
+
57
+ # Update a comment
58
+ # @param comment_id [String] The comment ID
59
+ # @param body [String] The new content of the comment
60
+ # @return [Models::Comment] The updated comment
61
+ # @note You can only update your own comments
62
+ def update(comment_id, body:)
63
+ validate_body!(body)
64
+
65
+ path = build_path("/v1/comments/{comment_id}", comment_id: comment_id)
66
+ response = client.patch(path, { body: body })
67
+ Models::Comment.from_json(response.extract_resource)
68
+ end
69
+
70
+ # Delete a comment
71
+ # @param comment_id [String] The comment ID
72
+ # @return [Boolean] True if deletion was successful
73
+ # @note You can only delete your own comments
74
+ def delete(comment_id)
75
+ path = build_path("/v1/comments/{comment_id}", comment_id: comment_id)
76
+ client.delete(path)
77
+ true
78
+ end
79
+
80
+ private
81
+
82
+ def validate_commentable_type!(type)
83
+ return if COMMENTABLE_TYPES.include?(type)
84
+
85
+ raise ArgumentError, "Invalid commentable_type '#{type}'. Must be one of: #{COMMENTABLE_TYPES.join(', ')}"
86
+ end
87
+
88
+ def validate_body!(body)
89
+ raise ArgumentError, "Comment body cannot be empty" if body.nil? || body.strip.empty?
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../models/project"
5
+ require_relative "../models/todo"
6
+ require_relative "../models/collection"
7
+
8
+ module Wip
9
+ module Resources
10
+ # Resource for interacting with projects
11
+ #
12
+ # @example Get a project
13
+ # client.projects.find("project_123")
14
+ #
15
+ # @example List todos for a project
16
+ # client.projects.todos("project_123", limit: 10)
17
+ class Projects < Base
18
+ # Get a single project by ID
19
+ # @param project_id [String] The project ID
20
+ # @return [Models::Project] The project
21
+ def find(project_id)
22
+ path = build_path("/v1/projects/{project_id}", project_id: project_id)
23
+ response = client.get(path)
24
+ Models::Project.from_json(response.extract_resource)
25
+ end
26
+
27
+ # List todos for a project
28
+ # @param project_id [String] The project ID
29
+ # @param limit [Integer] Number of items to return (default: 25)
30
+ # @param starting_after [String] Cursor for pagination
31
+ # @param since [Time, Date, String, Integer] Filter todos created since this date
32
+ # @param before [Time, Date, String, Integer] Filter todos created before this date (maps to API's `until` param)
33
+ # @return [Models::Collection<Models::Todo>] The paginated todos
34
+ def todos(project_id, limit: nil, starting_after: nil, since: nil, before: nil)
35
+ path = build_path("/v1/projects/{project_id}/todos", project_id: project_id)
36
+ params = extract_todo_params(limit: limit, starting_after: starting_after, since: since, before: before)
37
+ response = client.get(path, params)
38
+ Models::Collection.from_json(response.extract_resource, item_class: Models::Todo)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../models/reaction"
5
+
6
+ module Wip
7
+ module Resources
8
+ # Resource for interacting with reactions (likes)
9
+ #
10
+ # Reactions can be added to todos and comments. Each user can only have one
11
+ # reaction per resource. If the user has already reacted, the existing
12
+ # reaction is returned.
13
+ #
14
+ # @example React to a todo
15
+ # client.reactions.create(
16
+ # reactable_type: "Todo",
17
+ # reactable_id: "todo_123"
18
+ # )
19
+ #
20
+ # @example Remove a reaction
21
+ # client.reactions.delete("reaction_123")
22
+ class Reactions < Base
23
+ # Valid reactable types
24
+ REACTABLE_TYPES = %w[Todo Comment].freeze
25
+
26
+ # Create a reaction on a resource
27
+ # @param reactable_type [String] The type of resource to react to ("Todo" or "Comment")
28
+ # @param reactable_id [String] The ID of the resource to react to
29
+ # @return [Models::Reaction] The created reaction (or existing if already reacted)
30
+ # @raise [ArgumentError] If reactable_type is invalid
31
+ def create(reactable_type:, reactable_id:)
32
+ validate_reactable_type!(reactable_type)
33
+
34
+ response = client.post("/v1/reactions", {
35
+ reactable_type: reactable_type,
36
+ reactable_id: reactable_id
37
+ })
38
+ Models::Reaction.from_json(response.extract_resource)
39
+ end
40
+
41
+ # Delete a reaction
42
+ # @param reaction_id [String] The reaction ID
43
+ # @return [Boolean] True if deletion was successful
44
+ # @note You can only delete your own reactions
45
+ def delete(reaction_id)
46
+ path = build_path("/v1/reactions/{reaction_id}", reaction_id: reaction_id)
47
+ client.delete(path)
48
+ true
49
+ end
50
+
51
+ # Convenience method to react to a todo
52
+ # @param todo_id [String] The todo ID
53
+ # @return [Models::Reaction] The created reaction
54
+ def react_to_todo(todo_id)
55
+ create(reactable_type: "Todo", reactable_id: todo_id)
56
+ end
57
+
58
+ # Convenience method to react to a comment
59
+ # @param comment_id [String] The comment ID
60
+ # @return [Models::Reaction] The created reaction
61
+ def react_to_comment(comment_id)
62
+ create(reactable_type: "Comment", reactable_id: comment_id)
63
+ end
64
+
65
+ private
66
+
67
+ def validate_reactable_type!(type)
68
+ return if REACTABLE_TYPES.include?(type)
69
+
70
+ raise ArgumentError, "Invalid reactable_type '#{type}'. Must be one of: #{REACTABLE_TYPES.join(', ')}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../models/todo"
5
+ require_relative "../models/collection"
6
+
7
+ module Wip
8
+ module Resources
9
+ # Resource for interacting with todos
10
+ #
11
+ # @example Create a todo
12
+ # client.todos.create(body: "Shipped new feature!")
13
+ #
14
+ # @example Get a specific todo
15
+ # client.todos.find("todo_123")
16
+ #
17
+ # @note To list todos, use the appropriate resource:
18
+ # - `client.users.todos("username")` for a user's todos
19
+ # - `client.projects.todos("project_id")` for a project's todos
20
+ # - `client.viewer.todos` for your own todos
21
+ class Todos < Base
22
+ # Create a new completed todo
23
+ # @param body [String] The content of the todo
24
+ # @param attachments [Array<String>] List of attachment signed IDs
25
+ # @return [Models::Todo] The created todo
26
+ # @raise [ArgumentError] If body is empty
27
+ def create(body:, attachments: [])
28
+ raise ArgumentError, "Todo body cannot be empty" if body.nil? || body.strip.empty?
29
+
30
+ response = client.post("/v1/todos", {
31
+ body: body,
32
+ attachments: attachments
33
+ })
34
+ Models::Todo.from_json(response.extract_resource(:todo))
35
+ end
36
+
37
+ # Get a single todo by ID
38
+ # @param todo_id [String] The todo ID
39
+ # @return [Models::Todo] The todo
40
+ def find(todo_id)
41
+ path = build_path("/v1/todos/{todo_id}", todo_id: todo_id)
42
+ response = client.get(path)
43
+ Models::Todo.from_json(response.extract_resource)
44
+ end
45
+ end
46
+ end
47
+ end