issue-db 1.1.0 → 1.3.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.
- checksums.yaml +4 -4
- data/README.md +230 -6
- data/issue-db.gemspec +1 -1
- data/lib/issue_db/authentication.rb +36 -28
- data/lib/issue_db/cache.rb +27 -25
- data/lib/issue_db/database.rb +245 -189
- data/lib/issue_db/models/record.rb +25 -23
- data/lib/issue_db/models/repository.rb +15 -13
- data/lib/issue_db/utils/generate.rb +38 -36
- data/lib/issue_db/utils/github.rb +282 -280
- data/lib/issue_db/utils/init.rb +19 -17
- data/lib/issue_db/utils/parse.rb +33 -31
- data/lib/issue_db.rb +59 -47
- data/lib/version.rb +4 -2
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8c165f569fbb2648a4c73a0bf4007f67e6da2ff2dae28132af424d9eb9844575
|
4
|
+
data.tar.gz: 224bc98fcc029e417e620e4d8efaa79030fdf1a767558d126ab22145fec66ac7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e870e848ab6765beb5470008c38c03c9d0133fa22c9bd74a93888bbef7e59fb001dc18f9311106bdc8e43c4ef442eeb8f9f18fd8e18df37fcc7f9e9e5e4a1ad2
|
7
|
+
data.tar.gz: 5932e25bbc9bd9f1bf68638d38b61675ac5a497c4cc2fc2e59e121bdc838a3c2759d45a32c3e47014c8e88f6000116d33227379336fc3fbe1370107a4613402f
|
data/README.md
CHANGED
@@ -65,9 +65,12 @@ gem install issue-db --version "X.X.X"
|
|
65
65
|
|
66
66
|
## Usage 💻
|
67
67
|
|
68
|
-
|
68
|
+
This section goes into details on the following CRUD operations are available for the `issue-db` gem.
|
69
69
|
|
70
|
-
|
70
|
+
Note: All methods return the `IssueDB::Record` of the object which was involved in the operation
|
71
|
+
|
72
|
+
> [!IMPORTANT]
|
73
|
+
> The key for the record is the title of the GitHub issue. This means that the key **must be unique** within the database. If you try to do any sort of duplicate operation on a key that already exists (like creating it again), the `issue-db` gem will return the existing record without modifying it. Here is an example log message where someone calls `db.create("order_number_123", { location: "London", items: [ "cookies", "espresso" ] })` but the key already exists: `skipping issue creation and returning existing issue - an issue already exists with the key: order_number_123`. Additionally, if there are duplicates (same issue titles), then the latest issue will be returned (ex: issue 15 will be returned instead of issue 14). Basically, if you use fully unique keys, you won't ever run into this issue so please make sure to use unique keys for your records!
|
71
74
|
|
72
75
|
### `db.create(key, data, options = {})`
|
73
76
|
|
@@ -85,6 +88,23 @@ record = db.create("order_number_123", { location: "London", items: [ "cookies",
|
|
85
88
|
# more on this in another section of the README below
|
86
89
|
options = { body_before: "some markdown text before the data", body_after: "some markdown text after the data" }
|
87
90
|
record = db.create("order_number_123", { location: "London", items: [ "cookies", "espresso" ] }, options)
|
91
|
+
|
92
|
+
# with the `labels` option to add additional GitHub labels to the issue (in addition to the library-managed label)
|
93
|
+
options = { labels: ["priority:high", "customer:premium"] }
|
94
|
+
record = db.create("order_number_123", { location: "London", items: [ "cookies", "espresso" ] }, options)
|
95
|
+
|
96
|
+
# with the `assignees` option to assign GitHub users to the issue
|
97
|
+
options = { assignees: ["alice", "bob"] }
|
98
|
+
record = db.create("order_number_123", { location: "London", items: [ "cookies", "espresso" ] }, options)
|
99
|
+
|
100
|
+
# with multiple options combined
|
101
|
+
options = {
|
102
|
+
labels: ["priority:high", "customer:premium"],
|
103
|
+
assignees: ["alice", "bob"],
|
104
|
+
body_before: "some markdown text before the data",
|
105
|
+
body_after: "some markdown text after the data"
|
106
|
+
}
|
107
|
+
record = db.create("order_number_123", { location: "London", items: [ "cookies", "espresso" ] }, options)
|
88
108
|
```
|
89
109
|
|
90
110
|
Notes:
|
@@ -122,6 +142,23 @@ record = db.update("order_number_123", { location: "London", items: [ "cookies",
|
|
122
142
|
# more on this in another section of the README below
|
123
143
|
options = { body_before: "# Order 123\n\nData:", body_after: "Please do not edit the body of this issue" }
|
124
144
|
record = db.update("order_number_123", { location: "London", items: [ "cookies", "espresso", "chips" ] }, options)
|
145
|
+
|
146
|
+
# with the `labels` option to add additional GitHub labels to the issue (in addition to the library-managed label)
|
147
|
+
options = { labels: ["status:processed", "priority:low"] }
|
148
|
+
record = db.update("order_number_123", { location: "London", items: [ "cookies", "espresso", "chips" ] }, options)
|
149
|
+
|
150
|
+
# with the `assignees` option to assign GitHub users to the issue
|
151
|
+
options = { assignees: ["charlie", "diana"] }
|
152
|
+
record = db.update("order_number_123", { location: "London", items: [ "cookies", "espresso", "chips" ] }, options)
|
153
|
+
|
154
|
+
# with multiple options combined
|
155
|
+
options = {
|
156
|
+
labels: ["status:processed", "priority:low"],
|
157
|
+
assignees: ["charlie", "diana"],
|
158
|
+
body_before: "# Order 123\n\nData:",
|
159
|
+
body_after: "Please do not edit the body of this issue"
|
160
|
+
}
|
161
|
+
record = db.update("order_number_123", { location: "London", items: [ "cookies", "espresso", "chips" ] }, options)
|
125
162
|
```
|
126
163
|
|
127
164
|
### `db.delete(key, options = {})`
|
@@ -133,6 +170,21 @@ Example:
|
|
133
170
|
|
134
171
|
```ruby
|
135
172
|
record = db.delete("order_number_123")
|
173
|
+
|
174
|
+
# with the `labels` option to add additional GitHub labels to the issue before closing it
|
175
|
+
options = { labels: ["archived", "completed"] }
|
176
|
+
record = db.delete("order_number_123", options)
|
177
|
+
|
178
|
+
# with the `assignees` option to assign GitHub users to the issue before closing it
|
179
|
+
options = { assignees: ["alice"] }
|
180
|
+
record = db.delete("order_number_123", options)
|
181
|
+
|
182
|
+
# with multiple options combined
|
183
|
+
options = {
|
184
|
+
labels: ["archived", "completed"],
|
185
|
+
assignees: ["alice"]
|
186
|
+
}
|
187
|
+
record = db.delete("order_number_123", options)
|
136
188
|
```
|
137
189
|
|
138
190
|
### `db.list_keys(options = {})`
|
@@ -192,15 +244,163 @@ This section will go into detail around how you can configure the `issue-db` gem
|
|
192
244
|
| `GH_APP_ALGO` | The algo to use for your GitHub App if providing a private key | `RS256` |
|
193
245
|
| `ISSUE_DB_GITHUB_TOKEN` | The GitHub personal access token to use for authenticating with the GitHub API. You can also use a GitHub app or pass in your own authenticated Octokit.rb instance | `nil` |
|
194
246
|
|
247
|
+
## Labels 🏷️
|
248
|
+
|
249
|
+
The `issue-db` gem uses GitHub issue labels for organization and management. Here's how labels work:
|
250
|
+
|
251
|
+
### Library-Managed Label
|
252
|
+
|
253
|
+
The gem automatically applies a library-managed label (default: `issue-db`) to all issues it creates. This label:
|
254
|
+
|
255
|
+
- **Cannot be modified or removed** by users (the gem will always ensure it's present)
|
256
|
+
- Is used to identify which issues in the repository are managed by the `issue-db` gem
|
257
|
+
- Can be customized by setting the `ISSUE_DB_LABEL` environment variable or passing the `label` parameter to `IssueDB.new()`
|
258
|
+
|
259
|
+
### Additional Custom Labels
|
260
|
+
|
261
|
+
You can add your own custom labels to issues when creating, updating, or deleting records by using the `labels` option:
|
262
|
+
|
263
|
+
```ruby
|
264
|
+
# Add custom labels when creating a record
|
265
|
+
options = { labels: ["priority:high", "customer:premium", "region:europe"] }
|
266
|
+
record = db.create("order_123", { product: "laptop" }, options)
|
267
|
+
|
268
|
+
# Add custom labels when updating a record
|
269
|
+
options = { labels: ["status:processed", "priority:low"] }
|
270
|
+
record = db.update("order_123", { product: "laptop", status: "shipped" }, options)
|
271
|
+
|
272
|
+
# Add custom labels before deleting (closing) a record
|
273
|
+
options = { labels: ["archived", "completed", "Q4-2024"] }
|
274
|
+
record = db.delete("order_123", options)
|
275
|
+
```
|
276
|
+
|
277
|
+
**Important Notes:**
|
278
|
+
|
279
|
+
- Custom labels are **added in addition** to the library-managed label, not instead of it
|
280
|
+
- If you accidentally include the library-managed label in your custom labels array, it will be automatically filtered out to prevent duplicates
|
281
|
+
- Custom labels follow GitHub's label naming conventions and restrictions
|
282
|
+
- Labels help with organization, filtering, and automation workflows in GitHub
|
283
|
+
|
284
|
+
### Label Preservation
|
285
|
+
|
286
|
+
When performing update or delete operations, the gem preserves existing labels by default:
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
# Create a record with custom labels
|
290
|
+
options = { labels: ["priority:high", "customer:premium"] }
|
291
|
+
record = db.create("order_123", { product: "laptop" }, options)
|
292
|
+
# Result: Issue has labels ["issue-db", "priority:high", "customer:premium"]
|
293
|
+
|
294
|
+
# Update the record WITHOUT specifying labels - existing labels are preserved
|
295
|
+
record = db.update("order_123", { product: "laptop", status: "shipped" })
|
296
|
+
# Result: Issue STILL has labels ["issue-db", "priority:high", "customer:premium"]
|
297
|
+
|
298
|
+
# Update the record WITH new labels - replaces all labels (except library-managed)
|
299
|
+
options = { labels: ["status:processed", "priority:low"] }
|
300
|
+
record = db.update("order_123", { product: "laptop", status: "delivered" }, options)
|
301
|
+
# Result: Issue now has labels ["issue-db", "status:processed", "priority:low"]
|
302
|
+
```
|
303
|
+
|
304
|
+
**Key Behavior:**
|
305
|
+
|
306
|
+
- **Labels specified** = Replace all labels with library-managed label + specified labels
|
307
|
+
- **No labels specified** = Preserve existing labels exactly as they are
|
308
|
+
|
309
|
+
### Example with Multiple Options
|
310
|
+
|
311
|
+
You can combine labels with other options:
|
312
|
+
|
313
|
+
```ruby
|
314
|
+
options = {
|
315
|
+
labels: ["priority:high", "customer:vip"],
|
316
|
+
body_before: "## Order Details\n\nCustomer: VIP\n\n",
|
317
|
+
body_after: "\n\n---\n*This order requires special handling*"
|
318
|
+
}
|
319
|
+
record = db.create("vip_order_456", { items: ["premium_service"] }, options)
|
320
|
+
```
|
321
|
+
|
322
|
+
## Assignees 👥
|
323
|
+
|
324
|
+
The `issue-db` gem supports GitHub issue assignees for task ownership and responsibility tracking. Here's how assignees work:
|
325
|
+
|
326
|
+
### Basic Assignee Usage
|
327
|
+
|
328
|
+
You can assign GitHub users to issues when creating, updating, or deleting records by using the `assignees` option:
|
329
|
+
|
330
|
+
```ruby
|
331
|
+
# Assign users when creating a record
|
332
|
+
options = { assignees: ["alice", "bob"] }
|
333
|
+
record = db.create("task_123", { type: "code_review" }, options)
|
334
|
+
|
335
|
+
# Assign users when updating a record
|
336
|
+
options = { assignees: ["charlie", "diana"] }
|
337
|
+
record = db.update("task_123", { type: "code_review", status: "in_progress" }, options)
|
338
|
+
|
339
|
+
# Assign users before deleting (closing) a record
|
340
|
+
options = { assignees: ["alice"] }
|
341
|
+
record = db.delete("task_123", options)
|
342
|
+
```
|
343
|
+
|
344
|
+
### Assignee Preservation
|
345
|
+
|
346
|
+
Just like labels, the gem preserves existing assignees by default when no assignees are specified:
|
347
|
+
|
348
|
+
```ruby
|
349
|
+
# Create a record with assignees
|
350
|
+
options = { assignees: ["alice", "bob"] }
|
351
|
+
record = db.create("task_123", { type: "code_review" }, options)
|
352
|
+
# Result: Issue is assigned to alice and bob
|
353
|
+
|
354
|
+
# Update the record WITHOUT specifying assignees - existing assignees are preserved
|
355
|
+
record = db.update("task_123", { type: "code_review", status: "in_progress" })
|
356
|
+
# Result: Issue is STILL assigned to alice and bob
|
357
|
+
|
358
|
+
# Update the record WITH new assignees - replaces all assignees
|
359
|
+
options = { assignees: ["charlie"] }
|
360
|
+
record = db.update("task_123", { type: "code_review", status: "completed" }, options)
|
361
|
+
# Result: Issue is now only assigned to charlie
|
362
|
+
```
|
363
|
+
|
364
|
+
**Key Behavior:**
|
365
|
+
|
366
|
+
- **Assignees specified** = Replace all assignees with the specified assignees
|
367
|
+
- **No assignees specified** = Preserve existing assignees exactly as they are
|
368
|
+
- **Empty array specified** = Remove all assignees from the issue
|
369
|
+
|
370
|
+
### Combining Labels and Assignees
|
371
|
+
|
372
|
+
You can use both labels and assignees together for comprehensive issue management:
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
options = {
|
376
|
+
labels: ["priority:high", "type:bug", "team:backend"],
|
377
|
+
assignees: ["alice", "bob"],
|
378
|
+
body_before: "## Bug Report\n\nPriority: High\nTeam: Backend\n\n",
|
379
|
+
body_after: "\n\n---\n*Assigned to backend team leads*"
|
380
|
+
}
|
381
|
+
record = db.create("bug_456", {
|
382
|
+
error: "Database timeout",
|
383
|
+
severity: "critical"
|
384
|
+
}, options)
|
385
|
+
```
|
386
|
+
|
387
|
+
**Important Notes:**
|
388
|
+
|
389
|
+
- Assignees must be valid GitHub usernames with access to the repository
|
390
|
+
- You can assign up to 10 users to a single issue (GitHub's limit)
|
391
|
+
- Invalid or inaccessible usernames will cause the API call to fail
|
392
|
+
- Assignees help with responsibility tracking, notifications, and project management workflows
|
393
|
+
|
195
394
|
## Authentication 🔒
|
196
395
|
|
197
|
-
The `issue-db` gem uses the [`Octokit.rb`](https://github.com/octokit/octokit.rb) library under the hood for interactions with the GitHub API. You have
|
396
|
+
The `issue-db` gem uses the [`Octokit.rb`](https://github.com/octokit/octokit.rb) library under the hood for interactions with the GitHub API. You have four options for authentication when using the `issue-db` gem:
|
198
397
|
|
199
398
|
> Note: The order displayed below is also the order of priority that this Gem uses to authenticate.
|
200
399
|
|
201
400
|
1. Pass in your own authenticated `Octokit.rb` instance to the `IssueDB.new` method
|
202
|
-
2.
|
203
|
-
3. Use a GitHub
|
401
|
+
2. Pass GitHub App authentication parameters directly to the `IssueDB.new` method
|
402
|
+
3. Use a GitHub App by setting the `ISSUE_DB_GITHUB_APP_ID`, `ISSUE_DB_GITHUB_APP_INSTALLATION_ID`, and `ISSUE_DB_GITHUB_APP_KEY` environment variables
|
403
|
+
4. Use a GitHub personal access token by setting the `ISSUE_DB_GITHUB_TOKEN` environment variable
|
204
404
|
|
205
405
|
> Using a GitHub App is the suggested method
|
206
406
|
|
@@ -215,7 +415,31 @@ require "issue_db"
|
|
215
415
|
db = IssueDB.new("<org>/<repo>") # THAT'S IT! 🎉
|
216
416
|
```
|
217
417
|
|
218
|
-
### Using
|
418
|
+
### Using GitHub App Parameters Directly
|
419
|
+
|
420
|
+
You can now pass GitHub App authentication parameters directly to the `IssueDB.new` method. This is especially useful when you want to manage authentication credentials in your application code or when you have multiple GitHub Apps for different purposes:
|
421
|
+
|
422
|
+
```ruby
|
423
|
+
require "issue_db"
|
424
|
+
|
425
|
+
# Pass GitHub App credentials directly to IssueDB.new
|
426
|
+
db = IssueDB.new(
|
427
|
+
"<org>/<repo>",
|
428
|
+
app_id: 12345, # Your GitHub App ID
|
429
|
+
installation_id: 56789, # Your GitHub App Installation ID
|
430
|
+
app_key: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", # Your GitHub App private key
|
431
|
+
app_algo: "RS256" # Optional: defaults to RS256
|
432
|
+
)
|
433
|
+
```
|
434
|
+
|
435
|
+
**Parameters:**
|
436
|
+
|
437
|
+
- `app_id` (Integer) - Your GitHub App ID (found on the App's settings page)
|
438
|
+
- `installation_id` (Integer) - Your GitHub App Installation ID (found in the installation URL: `https://github.com/organizations/<org>/settings/installations/<installation_id>`)
|
439
|
+
- `app_key` (String) - Your GitHub App private key (can be the key content as a string or a file path ending in `.pem`)
|
440
|
+
- `app_algo` (String, optional) - The algorithm to use for JWT signing (defaults to "RS256")
|
441
|
+
|
442
|
+
### Using a GitHub App with Environment Variables
|
219
443
|
|
220
444
|
This is the single best way to use the `issue-db` gem because GitHub Apps have increased rate limits, fine-grained permissions, and are more secure than using a personal access token. All you have to do is provide three environment variables and the `issue-db` gem will take care of the rest:
|
221
445
|
|
data/issue-db.gemspec
CHANGED
@@ -3,37 +3,45 @@
|
|
3
3
|
require "octokit"
|
4
4
|
require_relative "utils/github"
|
5
5
|
|
6
|
-
|
6
|
+
module IssueDB
|
7
|
+
class AuthenticationError < StandardError; end
|
7
8
|
|
8
|
-
module Authentication
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
9
|
+
module Authentication
|
10
|
+
def self.login(client = nil, log = nil, app_id: nil, installation_id: nil, app_key: nil, app_algo: nil)
|
11
|
+
# if the client is not nil, use the pre-provided client
|
12
|
+
unless client.nil?
|
13
|
+
log.debug("using pre-provided client") if log
|
14
|
+
return client
|
15
|
+
end
|
15
16
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
app_key = ENV.fetch("ISSUE_DB_GITHUB_APP_KEY", nil)
|
22
|
-
if app_id && installation_id && app_key
|
23
|
-
log.debug("using github app authentication") if log
|
24
|
-
return GitHub.new(log:, app_id:, installation_id:, app_key:)
|
25
|
-
end
|
17
|
+
# if GitHub App parameters are provided, use them first
|
18
|
+
if app_id && installation_id && app_key
|
19
|
+
log.debug("using provided github app authentication parameters") if log
|
20
|
+
return IssueDB::Utils::GitHub.new(log:, app_id:, installation_id:, app_key:, app_algo:)
|
21
|
+
end
|
26
22
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
23
|
+
# if the client is nil, check for GitHub App env vars
|
24
|
+
# first, check if all three of the following env vars are set and have values
|
25
|
+
# ISSUE_DB_GITHUB_APP_ID, ISSUE_DB_GITHUB_APP_INSTALLATION_ID, ISSUE_DB_GITHUB_APP_KEY
|
26
|
+
env_app_id = ENV.fetch("ISSUE_DB_GITHUB_APP_ID", nil)
|
27
|
+
env_installation_id = ENV.fetch("ISSUE_DB_GITHUB_APP_INSTALLATION_ID", nil)
|
28
|
+
env_app_key = ENV.fetch("ISSUE_DB_GITHUB_APP_KEY", nil)
|
29
|
+
if env_app_id && env_installation_id && env_app_key
|
30
|
+
log.debug("using github app authentication from environment variables") if log
|
31
|
+
return IssueDB::Utils::GitHub.new(log:, app_id: env_app_id, installation_id: env_installation_id, app_key: env_app_key)
|
32
|
+
end
|
35
33
|
|
36
|
-
|
37
|
-
|
34
|
+
# if the client is nil and no GitHub App env vars were found, check for the ISSUE_DB_GITHUB_TOKEN
|
35
|
+
token = ENV.fetch("ISSUE_DB_GITHUB_TOKEN", nil)
|
36
|
+
if token
|
37
|
+
log.debug("using github token authentication") if log
|
38
|
+
octokit = Octokit::Client.new(access_token: token, page_size: 100)
|
39
|
+
octokit.auto_paginate = true
|
40
|
+
return octokit
|
41
|
+
end
|
42
|
+
|
43
|
+
# if we make it here, no valid auth method succeeded
|
44
|
+
raise AuthenticationError, "No valid GitHub authentication method was provided"
|
45
|
+
end
|
38
46
|
end
|
39
47
|
end
|
data/lib/issue_db/cache.rb
CHANGED
@@ -1,33 +1,35 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
module IssueDB
|
4
|
+
module Cache
|
5
|
+
# A helper method to update all issues in the cache
|
6
|
+
# :return: The updated issue cache as a list of issues
|
7
|
+
def update_issue_cache!
|
8
|
+
@log.debug("updating issue cache")
|
8
9
|
|
9
|
-
|
10
|
-
|
10
|
+
# find all issues in the repo that were created by this library
|
11
|
+
query = "repo:#{@repo.full_name} label:#{@label}"
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
13
|
+
search_response = nil
|
14
|
+
begin
|
15
|
+
# issues structure: { "total_count": 0, "incomplete_results": false, "items": [<issues>] }
|
16
|
+
search_response = @client.search_issues(query)
|
17
|
+
rescue StandardError => e
|
18
|
+
retry_err_msg = "error search_issues() call: #{e.message}"
|
19
|
+
@log.error(retry_err_msg)
|
20
|
+
raise StandardError, retry_err_msg
|
21
|
+
end
|
21
22
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
23
|
+
# Safety check to ensure search_response and items are not nil
|
24
|
+
if search_response.nil? || search_response.items.nil?
|
25
|
+
@log.error("search_issues returned nil response or nil items")
|
26
|
+
raise StandardError, "search_issues returned invalid response"
|
27
|
+
end
|
27
28
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
29
|
+
@log.debug("issue cache updated - cached #{search_response.total_count} issues")
|
30
|
+
@issues = search_response.items
|
31
|
+
@issues_last_updated = Time.now
|
32
|
+
return @issues
|
33
|
+
end
|
32
34
|
end
|
33
35
|
end
|