hoja-cli 0.21.4__py3-none-any.whl
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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Reusable GraphQL fragments.
|
|
2
|
+
|
|
3
|
+
These keep our queries DRY and our model deserialization predictable —
|
|
4
|
+
if a model expects a field, the fragment selects it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
USER_CORE = """
|
|
8
|
+
fragment UserCore on User {
|
|
9
|
+
id
|
|
10
|
+
name
|
|
11
|
+
displayName
|
|
12
|
+
email
|
|
13
|
+
active
|
|
14
|
+
isMe
|
|
15
|
+
avatarUrl
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
TEAM_CORE = """
|
|
20
|
+
fragment TeamCore on Team {
|
|
21
|
+
id
|
|
22
|
+
name
|
|
23
|
+
key
|
|
24
|
+
description
|
|
25
|
+
color
|
|
26
|
+
icon
|
|
27
|
+
visibility
|
|
28
|
+
parentTeamId
|
|
29
|
+
timezone
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
STATE_CORE = """
|
|
34
|
+
fragment StateCore on WorkflowState {
|
|
35
|
+
id
|
|
36
|
+
name
|
|
37
|
+
color
|
|
38
|
+
description
|
|
39
|
+
position
|
|
40
|
+
type
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
LABEL_CORE = """
|
|
45
|
+
fragment LabelCore on IssueLabel {
|
|
46
|
+
id
|
|
47
|
+
name
|
|
48
|
+
color
|
|
49
|
+
description
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
PROJECT_CORE = """
|
|
54
|
+
fragment ProjectCore on Project {
|
|
55
|
+
id
|
|
56
|
+
name
|
|
57
|
+
description
|
|
58
|
+
slugId
|
|
59
|
+
icon
|
|
60
|
+
color
|
|
61
|
+
state
|
|
62
|
+
progress
|
|
63
|
+
startDate
|
|
64
|
+
targetDate
|
|
65
|
+
url
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
CYCLE_CORE = """
|
|
70
|
+
fragment CycleCore on Cycle {
|
|
71
|
+
id
|
|
72
|
+
name
|
|
73
|
+
number
|
|
74
|
+
startsAt
|
|
75
|
+
endsAt
|
|
76
|
+
completedAt
|
|
77
|
+
progress
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
ISSUE_CORE = """
|
|
82
|
+
fragment IssueCore on Issue {
|
|
83
|
+
id
|
|
84
|
+
identifier
|
|
85
|
+
title
|
|
86
|
+
priority
|
|
87
|
+
priorityLabel
|
|
88
|
+
estimate
|
|
89
|
+
url
|
|
90
|
+
branchName
|
|
91
|
+
number
|
|
92
|
+
createdAt
|
|
93
|
+
updatedAt
|
|
94
|
+
dueDate
|
|
95
|
+
completedAt
|
|
96
|
+
canceledAt
|
|
97
|
+
startedAt
|
|
98
|
+
state { ...StateCore }
|
|
99
|
+
assignee { ...UserCore }
|
|
100
|
+
creator { ...UserCore }
|
|
101
|
+
team { id key name }
|
|
102
|
+
}
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
ISSUE_FULL = """
|
|
106
|
+
fragment IssueFull on Issue {
|
|
107
|
+
...IssueCore
|
|
108
|
+
description
|
|
109
|
+
project { ...ProjectCore }
|
|
110
|
+
cycle { ...CycleCore }
|
|
111
|
+
parent { id identifier title }
|
|
112
|
+
labels { nodes { ...LabelCore } }
|
|
113
|
+
subscribers { nodes { ...UserCore } }
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
PAGE_INFO = """
|
|
118
|
+
fragment PageInfoCore on PageInfo {
|
|
119
|
+
hasNextPage
|
|
120
|
+
hasPreviousPage
|
|
121
|
+
startCursor
|
|
122
|
+
endCursor
|
|
123
|
+
}
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def assemble(*fragments: str) -> str:
|
|
128
|
+
"""Join a set of fragments deduplicating by name."""
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
parts: list[str] = []
|
|
131
|
+
for frag in fragments:
|
|
132
|
+
# crude fragment name extraction
|
|
133
|
+
name = frag.split("fragment ", 1)[1].split(" ", 1)[0]
|
|
134
|
+
if name in seen:
|
|
135
|
+
continue
|
|
136
|
+
seen.add(name)
|
|
137
|
+
parts.append(frag.strip())
|
|
138
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""GraphQL mutation strings.
|
|
2
|
+
|
|
3
|
+
Mutations that modify state. All return `success` and the affected entity
|
|
4
|
+
so we can echo the result back to the user.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hoja.graphql.fragments import (
|
|
8
|
+
CYCLE_CORE,
|
|
9
|
+
ISSUE_CORE,
|
|
10
|
+
ISSUE_FULL,
|
|
11
|
+
LABEL_CORE,
|
|
12
|
+
PROJECT_CORE,
|
|
13
|
+
STATE_CORE,
|
|
14
|
+
USER_CORE,
|
|
15
|
+
assemble,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Issues
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
ISSUE_CREATE = (
|
|
23
|
+
"""
|
|
24
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
25
|
+
issueCreate(input: $input) {
|
|
26
|
+
success
|
|
27
|
+
issue { ...IssueFull }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
+ assemble(ISSUE_FULL, ISSUE_CORE, USER_CORE, STATE_CORE, PROJECT_CORE, CYCLE_CORE, LABEL_CORE)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ISSUE_UPDATE = (
|
|
35
|
+
"""
|
|
36
|
+
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
|
37
|
+
issueUpdate(id: $id, input: $input) {
|
|
38
|
+
success
|
|
39
|
+
issue { ...IssueCore }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
"""
|
|
43
|
+
+ assemble(ISSUE_CORE, USER_CORE, STATE_CORE)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ISSUE_DELETE = """
|
|
47
|
+
mutation IssueDelete($id: String!) {
|
|
48
|
+
issueDelete(id: $id) {
|
|
49
|
+
success
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
ISSUE_ARCHIVE = """
|
|
55
|
+
mutation IssueArchive($id: String!) {
|
|
56
|
+
issueArchive(id: $id) {
|
|
57
|
+
success
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Comments
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
COMMENT_CREATE = """
|
|
67
|
+
mutation CommentCreate($input: CommentCreateInput!) {
|
|
68
|
+
commentCreate(input: $input) {
|
|
69
|
+
success
|
|
70
|
+
comment {
|
|
71
|
+
id
|
|
72
|
+
body
|
|
73
|
+
url
|
|
74
|
+
createdAt
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
COMMENT_UPDATE = """
|
|
81
|
+
mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
|
|
82
|
+
commentUpdate(id: $id, input: $input) {
|
|
83
|
+
success
|
|
84
|
+
comment {
|
|
85
|
+
id
|
|
86
|
+
body
|
|
87
|
+
url
|
|
88
|
+
updatedAt
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
COMMENT_DELETE = """
|
|
95
|
+
mutation CommentDelete($id: String!) {
|
|
96
|
+
commentDelete(id: $id) {
|
|
97
|
+
success
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Labels
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
LABEL_CREATE = (
|
|
107
|
+
"""
|
|
108
|
+
mutation LabelCreate($input: IssueLabelCreateInput!) {
|
|
109
|
+
issueLabelCreate(input: $input) {
|
|
110
|
+
success
|
|
111
|
+
issueLabel { ...LabelCore }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
"""
|
|
115
|
+
+ assemble(LABEL_CORE)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
LABEL_DELETE = """
|
|
119
|
+
mutation LabelDelete($id: String!) {
|
|
120
|
+
issueLabelDelete(id: $id) {
|
|
121
|
+
success
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Projects
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
PROJECT_CREATE = (
|
|
131
|
+
"""
|
|
132
|
+
mutation ProjectCreate($input: ProjectCreateInput!) {
|
|
133
|
+
projectCreate(input: $input) {
|
|
134
|
+
success
|
|
135
|
+
project { ...ProjectCore }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
"""
|
|
139
|
+
+ assemble(PROJECT_CORE)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
PROJECT_UPDATE = (
|
|
143
|
+
"""
|
|
144
|
+
mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
|
|
145
|
+
projectUpdate(id: $id, input: $input) {
|
|
146
|
+
success
|
|
147
|
+
project { ...ProjectCore }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
"""
|
|
151
|
+
+ assemble(PROJECT_CORE)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
PROJECT_ARCHIVE = """
|
|
155
|
+
mutation ProjectArchive($id: String!) {
|
|
156
|
+
projectArchive(id: $id) {
|
|
157
|
+
success
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
"""
|
hoja/graphql/queries.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""GraphQL query strings.
|
|
2
|
+
|
|
3
|
+
These return read-only data. Each query template includes the fragments
|
|
4
|
+
it needs at the bottom.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hoja.graphql.fragments import (
|
|
8
|
+
CYCLE_CORE,
|
|
9
|
+
ISSUE_CORE,
|
|
10
|
+
ISSUE_FULL,
|
|
11
|
+
LABEL_CORE,
|
|
12
|
+
PAGE_INFO,
|
|
13
|
+
PROJECT_CORE,
|
|
14
|
+
STATE_CORE,
|
|
15
|
+
TEAM_CORE,
|
|
16
|
+
USER_CORE,
|
|
17
|
+
assemble,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Viewer / Me
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
VIEWER = (
|
|
25
|
+
"""
|
|
26
|
+
query Viewer {
|
|
27
|
+
viewer {
|
|
28
|
+
...UserCore
|
|
29
|
+
admin
|
|
30
|
+
url
|
|
31
|
+
timezone
|
|
32
|
+
statusEmoji
|
|
33
|
+
statusLabel
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
+ assemble(USER_CORE)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Teams
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
TEAMS = (
|
|
45
|
+
"""
|
|
46
|
+
query Teams($first: Int = 50, $after: String) {
|
|
47
|
+
teams(first: $first, after: $after) {
|
|
48
|
+
nodes { ...TeamCore }
|
|
49
|
+
pageInfo { ...PageInfoCore }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
"""
|
|
53
|
+
+ assemble(TEAM_CORE, PAGE_INFO)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
TEAM_BY_KEY = (
|
|
57
|
+
"""
|
|
58
|
+
query TeamByKey($key: String!) {
|
|
59
|
+
teams(filter: { key: { eq: $key } }, first: 1) {
|
|
60
|
+
nodes { ...TeamCore }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
"""
|
|
64
|
+
+ assemble(TEAM_CORE)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
TEAM_STATES = (
|
|
68
|
+
"""
|
|
69
|
+
query TeamStates($teamId: String!) {
|
|
70
|
+
team(id: $teamId) {
|
|
71
|
+
id
|
|
72
|
+
key
|
|
73
|
+
name
|
|
74
|
+
states {
|
|
75
|
+
nodes { ...StateCore }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
+ assemble(STATE_CORE)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
TEAM_MEMBERS = (
|
|
84
|
+
"""
|
|
85
|
+
query TeamMembers($teamId: String!, $first: Int = 100) {
|
|
86
|
+
team(id: $teamId) {
|
|
87
|
+
id
|
|
88
|
+
key
|
|
89
|
+
name
|
|
90
|
+
members(first: $first) {
|
|
91
|
+
nodes { ...UserCore }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
"""
|
|
96
|
+
+ assemble(USER_CORE)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Issues
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
ISSUES_LIST = (
|
|
104
|
+
"""
|
|
105
|
+
query Issues($filter: IssueFilter, $first: Int = 50, $after: String, $orderBy: PaginationOrderBy) {
|
|
106
|
+
issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
|
107
|
+
nodes { ...IssueCore }
|
|
108
|
+
pageInfo { ...PageInfoCore }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
"""
|
|
112
|
+
+ assemble(ISSUE_CORE, USER_CORE, STATE_CORE, PAGE_INFO)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ISSUE_BY_IDENTIFIER = (
|
|
116
|
+
"""
|
|
117
|
+
query IssueByIdentifier($id: String!) {
|
|
118
|
+
issue(id: $id) {
|
|
119
|
+
...IssueFull
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
"""
|
|
123
|
+
+ assemble(ISSUE_FULL, ISSUE_CORE, USER_CORE, STATE_CORE, PROJECT_CORE, CYCLE_CORE, LABEL_CORE)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Projects
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
PROJECTS_LIST = (
|
|
131
|
+
"""
|
|
132
|
+
query Projects($filter: ProjectFilter, $first: Int = 50, $after: String) {
|
|
133
|
+
projects(filter: $filter, first: $first, after: $after) {
|
|
134
|
+
nodes { ...ProjectCore }
|
|
135
|
+
pageInfo { ...PageInfoCore }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
"""
|
|
139
|
+
+ assemble(PROJECT_CORE, PAGE_INFO)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
PROJECT_BY_ID = (
|
|
143
|
+
"""
|
|
144
|
+
query Project($id: String!) {
|
|
145
|
+
project(id: $id) {
|
|
146
|
+
...ProjectCore
|
|
147
|
+
lead { ...UserCore }
|
|
148
|
+
creator { ...UserCore }
|
|
149
|
+
members { nodes { ...UserCore } }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
"""
|
|
153
|
+
+ assemble(PROJECT_CORE, USER_CORE)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Cycles
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
TEAM_CYCLES = (
|
|
161
|
+
"""
|
|
162
|
+
query TeamCycles($teamId: String!, $first: Int = 20) {
|
|
163
|
+
team(id: $teamId) {
|
|
164
|
+
id
|
|
165
|
+
key
|
|
166
|
+
cycles(first: $first) {
|
|
167
|
+
nodes { ...CycleCore }
|
|
168
|
+
}
|
|
169
|
+
activeCycle { ...CycleCore }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
"""
|
|
173
|
+
+ assemble(CYCLE_CORE)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Comments
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
ISSUE_COMMENTS = (
|
|
181
|
+
"""
|
|
182
|
+
query IssueComments($issueId: String!, $first: Int = 50) {
|
|
183
|
+
issue(id: $issueId) {
|
|
184
|
+
id
|
|
185
|
+
identifier
|
|
186
|
+
comments(first: $first) {
|
|
187
|
+
nodes {
|
|
188
|
+
id
|
|
189
|
+
body
|
|
190
|
+
url
|
|
191
|
+
createdAt
|
|
192
|
+
updatedAt
|
|
193
|
+
user { ...UserCore }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
"""
|
|
199
|
+
+ assemble(USER_CORE)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Workflow / Labels
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
ISSUE_LABELS = (
|
|
207
|
+
"""
|
|
208
|
+
query IssueLabels($filter: IssueLabelFilter, $first: Int = 100) {
|
|
209
|
+
issueLabels(filter: $filter, first: $first) {
|
|
210
|
+
nodes { ...LabelCore }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
"""
|
|
214
|
+
+ assemble(LABEL_CORE)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Search
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
# searchIssues returns IssueSearchResult which extends Issue with metadata.
|
|
222
|
+
# We use inline fragment to spread Issue fields onto IssueSearchResult.
|
|
223
|
+
SEARCH_ISSUES = (
|
|
224
|
+
"""
|
|
225
|
+
query SearchIssues($term: String!, $first: Int = 20) {
|
|
226
|
+
searchIssues(term: $term, first: $first) {
|
|
227
|
+
nodes {
|
|
228
|
+
id
|
|
229
|
+
identifier
|
|
230
|
+
title
|
|
231
|
+
priority
|
|
232
|
+
priorityLabel
|
|
233
|
+
url
|
|
234
|
+
number
|
|
235
|
+
createdAt
|
|
236
|
+
updatedAt
|
|
237
|
+
state { ...StateCore }
|
|
238
|
+
assignee { ...UserCore }
|
|
239
|
+
team { id key name }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
"""
|
|
244
|
+
+ assemble(USER_CORE, STATE_CORE)
|
|
245
|
+
)
|
hoja/mcp/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""MCP server for Hoja.
|
|
2
|
+
|
|
3
|
+
This package is **optional**. Install with:
|
|
4
|
+
|
|
5
|
+
pip install 'hoja[mcp]'
|
|
6
|
+
|
|
7
|
+
The server exposes:
|
|
8
|
+
- 1 tool: hoja_guide(topic)
|
|
9
|
+
- 7 resources: read-only workspace context
|
|
10
|
+
- 6 prompts: workflow templates (triage, daily-standup, hand-off, ...)
|
|
11
|
+
|
|
12
|
+
It does NOT expose mutation tools by design. Mutations are performed via the
|
|
13
|
+
`hoja` CLI in a shell — that's the supported interface and the agent is
|
|
14
|
+
reinforced to use Bash for any write operation.
|
|
15
|
+
|
|
16
|
+
Entry point: `hoja-mcp` (console script). See `server.main()`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""Run the MCP server without importing it during package initialization."""
|
|
22
|
+
from .server import main as server_main
|
|
23
|
+
|
|
24
|
+
server_main()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["main"]
|
hoja/mcp/content.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Skill content loader + Pydantic model for the `hoja_guide` MCP tool.
|
|
2
|
+
|
|
3
|
+
Loads the canonical markdown files from `hoja/skill_content/*.md` (shipped
|
|
4
|
+
inside the wheel via `[tool.hatch.build.targets.wheel.force-include]`).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Topic enum — kebab-case in protocol, snake_case in Python
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
class Topic(str, Enum):
|
|
19
|
+
"""Skill topics. String values match the markdown filenames (no extension)."""
|
|
20
|
+
|
|
21
|
+
OVERVIEW = "overview"
|
|
22
|
+
COMMANDS = "commands"
|
|
23
|
+
WORKFLOWS = "workflows"
|
|
24
|
+
FILTERS = "filters"
|
|
25
|
+
OUTPUT_FORMATS = "output-formats"
|
|
26
|
+
EXAMPLES = "examples"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Pydantic response model for the MCP tool
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
DEFAULT_REMINDER = (
|
|
33
|
+
"Run hoja commands via the Bash tool. Do NOT write Python wrappers "
|
|
34
|
+
"around the Linear GraphQL API — Hoja already handles auth, retries, "
|
|
35
|
+
"pagination, validation, and error mapping. Resources on this MCP server "
|
|
36
|
+
"give you read-only context for free; perform every mutation via "
|
|
37
|
+
"`hoja <command>` in a shell."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HojaGuide(BaseModel):
|
|
42
|
+
"""Structured response for the `hoja_guide` tool.
|
|
43
|
+
|
|
44
|
+
The agent that calls this tool sees:
|
|
45
|
+
- `instructions`: full markdown content for the requested topic
|
|
46
|
+
- `key_commands`: a short hot-list of one-liners
|
|
47
|
+
- `see_also`: other topics worth reading
|
|
48
|
+
- `reminder`: the behavioral reinforcement string (shown on every reply)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
topic: Topic = Field(description="Which topic this guide covers.")
|
|
52
|
+
title: str = Field(description="Human-readable title for the topic.")
|
|
53
|
+
instructions: str = Field(
|
|
54
|
+
description="Markdown instructions teaching the agent how to use Hoja for this topic.",
|
|
55
|
+
)
|
|
56
|
+
key_commands: list[str] = Field(
|
|
57
|
+
default_factory=list,
|
|
58
|
+
description="One-liner shell commands the agent should know.",
|
|
59
|
+
)
|
|
60
|
+
see_also: list[Topic] = Field(
|
|
61
|
+
default_factory=list,
|
|
62
|
+
description="Other topics linked from this one.",
|
|
63
|
+
)
|
|
64
|
+
reminder: str = Field(
|
|
65
|
+
default=DEFAULT_REMINDER,
|
|
66
|
+
description="Behavioral reminder. Read it on every response.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Deprecated public model alias for one compatibility release.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Content loading
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
def _content_dir() -> Path:
|
|
77
|
+
"""Locate the bundled skill_content directory inside the installed package."""
|
|
78
|
+
return Path(str(files("hoja").joinpath("skill_content")))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_topic_file(topic: Topic) -> str:
|
|
82
|
+
"""Read the markdown file for a topic. Raises FileNotFoundError if missing."""
|
|
83
|
+
path = _content_dir() / f"{topic.value}.md"
|
|
84
|
+
return path.read_text(encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_title(markdown: str, fallback: str) -> str:
|
|
88
|
+
"""Pull the first `# heading` from the markdown body."""
|
|
89
|
+
for line in markdown.splitlines():
|
|
90
|
+
s = line.strip()
|
|
91
|
+
if s.startswith("# "):
|
|
92
|
+
return s[2:].strip()
|
|
93
|
+
return fallback
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_key_commands(markdown: str, limit: int = 10) -> list[str]:
|
|
97
|
+
"""Grab the first N `hoja ...` one-liners that look like bash commands.
|
|
98
|
+
|
|
99
|
+
Heuristic: scan fenced ```bash blocks, collect lines starting with `hoja `.
|
|
100
|
+
Skip comment lines and empty lines. Deduplicate while preserving order.
|
|
101
|
+
"""
|
|
102
|
+
out: list[str] = []
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
in_block = False
|
|
105
|
+
is_bash = False
|
|
106
|
+
for raw in markdown.splitlines():
|
|
107
|
+
line = raw.rstrip()
|
|
108
|
+
if line.startswith("```"):
|
|
109
|
+
if in_block:
|
|
110
|
+
in_block = False
|
|
111
|
+
is_bash = False
|
|
112
|
+
else:
|
|
113
|
+
in_block = True
|
|
114
|
+
# Detect language tag (```bash, ```sh, ```shell) or untagged blocks
|
|
115
|
+
tag = line.removeprefix("```").strip().lower()
|
|
116
|
+
is_bash = tag in {"", "bash", "sh", "shell"}
|
|
117
|
+
continue
|
|
118
|
+
if not in_block or not is_bash:
|
|
119
|
+
continue
|
|
120
|
+
stripped = line.strip()
|
|
121
|
+
if not stripped or stripped.startswith("#"):
|
|
122
|
+
continue
|
|
123
|
+
if stripped.startswith("hoja ") and stripped not in seen:
|
|
124
|
+
seen.add(stripped)
|
|
125
|
+
out.append(stripped)
|
|
126
|
+
if len(out) >= limit:
|
|
127
|
+
return out
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _see_also_for(topic: Topic) -> list[Topic]:
|
|
132
|
+
"""Static cross-link map. Mirrors the 'See also' sections in the markdown."""
|
|
133
|
+
base = [t for t in Topic if t is not topic]
|
|
134
|
+
return base
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_topic(topic: Topic) -> HojaGuide:
|
|
138
|
+
"""Load a topic's full guide.
|
|
139
|
+
|
|
140
|
+
Returns a Pydantic-validated `HojaGuide` ready to be returned from the
|
|
141
|
+
MCP tool. The MCP SDK will auto-serialize to JSON for the wire.
|
|
142
|
+
"""
|
|
143
|
+
body = _read_topic_file(topic)
|
|
144
|
+
title = _extract_title(body, fallback=f"Hoja — {topic.value}")
|
|
145
|
+
return HojaGuide(
|
|
146
|
+
topic=topic,
|
|
147
|
+
title=title,
|
|
148
|
+
instructions=body,
|
|
149
|
+
key_commands=_extract_key_commands(body),
|
|
150
|
+
see_also=_see_also_for(topic),
|
|
151
|
+
)
|