Compare commits

..
Author SHA1 Message Date
sriram veeraghantaandGitHub 768b4abf22 Merge pull request #3287 from makeplane/preview
promote: preview to master
2023-12-29 22:11:37 +05:30
sriram veeraghantaandGitHub 6f0af4689b Merge pull request #3286 from makeplane/develop
promote: develop to preview
2023-12-29 22:10:24 +05:30
sriram veeraghanta 22bbdd5ab8 fix: add validations 2023-12-29 22:07:52 +05:30
sriram veeraghantaandGitHub 28a9c53202 Merge pull request #3281 from makeplane/preview
release: preview to master
2023-12-29 20:28:18 +05:30
sriram veeraghantaandGitHub 33d88f56da Merge pull request #3280 from makeplane/develop
promote: develop changes to preview
2023-12-29 18:21:26 +05:30
sriram veeraghanta 23001d425f Merge branch 'preview' of github.com:makeplane/plane into develop 2023-12-29 18:16:08 +05:30
sriram veeraghantaandGitHub 5de94c575a fix: workspace invitation status fixes (#3279) 2023-12-29 17:49:39 +05:30
AbId KhAnandGitHub f200acc1e8 fix websocket connection issue in docker makeplane/plane#3195 (#3196)
* fix websocket connection issue in docker makeplane/plane#3195

* fix websocket connection issue for local env with removing from the prod nginx conf template makeplane/plane#319
2023-12-29 16:49:09 +05:30
Prateek ShouryaandGitHub f132fe59ae Chore/user role validation (#3260)
* chore: remove `Save View` option for guest/ viewer from project issues header.

* chore: allow guest/ viewer to clear applied filters in project issues.

* chore: disable `date`, `label` property access for guests/ viewer in all project issue views.

* chore: update `Workspace Issues` -> `All Issues` header.

* chore: refactor apply/ clear filter implementation in All Issues.

* Revert "chore: refactor apply/ clear filter implementation in All Issues."

This reverts commit 024822d54f.

* Revert "chore: allow guest/ viewer to clear applied filters in project issues."

This reverts commit 3dae871d23.

* Revert "chore: update `Workspace Issues` -> `All Issues` header."

This reverts commit 03f90be188.

* chore: remove `cursor-pointer` style from non actionable issue properties.
2023-12-29 16:35:03 +05:30
NikhilandGitHub e584259c04 feat: add external id and external source field to issue, cycle, module, label, comment, state and inbox issues (#3277) 2023-12-29 16:32:12 +05:30
sriram veeraghanta a77ceb636f Merge branches 'develop' and 'develop' of github.com:makeplane/plane into develop 2023-12-29 16:25:54 +05:30
10ab081a0b chore: cycle current status (#3270)
* dev: cycle status

* chore: cycle status logic updated

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2023-12-29 15:24:07 +05:30
Anmol Singh BhatiaandGitHub 1d5a3a02c1 fix: issue parent select (#3267) 2023-12-29 15:22:30 +05:30
sriram veeraghantaandGitHub 62b9b259c0 fix: adding issue title to the activity (#3271) 2023-12-28 18:53:06 +05:30
Anmol Singh BhatiaandGitHub 91e84aede1 fix: onboarding redirection loop and bug fixes (#3250)
* chore: try and catch added in handleSignInRedirection

* chore: remove unnecessary hooks

* fix: handleCopyIssueLink url updated

* chore: swap next_url with next_path and validate redirection logic for next_path url
2023-12-28 17:17:04 +05:30
Prateek ShouryaandGitHub 71bf049e89 Style/UI improvements (#3269)
* style: update `Workspace Issues` -> `All Issues` header.

* style: fix issue activity text overflow issue.
2023-12-28 14:30:33 +05:30
sriram veeraghantaandGitHub 685e62a72f fix: replacing onclick redirections with link tag. (#3263)
* fix: adding links to dashboard summary items

* fix: adding links to workspace sidebar dropdown

* fix: adding links to the sidebar
2023-12-28 14:29:20 +05:30
sriram veeraghanta d9bd43f43c fix: adding links to the sidebar 2023-12-28 11:54:16 +05:30
sriram veeraghanta 10bdcc906c fix: adding links to workspace sidebar dropdown 2023-12-27 16:35:43 +05:30
sriram veeraghanta 54964924f0 fix: adding links to dashboard summary items 2023-12-27 16:15:56 +05:30
sriram veeraghanta ff8008cbed Merge branch 'preview' of github.com:makeplane/plane into develop 2023-12-27 15:37:03 +05:30
78428fb564 chore: estimates and issue parent id fixes (#3188)
* chore: validations and bug fixes

* chore: estimate point character limit validation

---------

Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
2023-12-27 15:35:15 +05:30
Bavisetti NarayanandGitHub b2824366a8 fix: external api serializer (#3259)
* fix: passed workspace id and project id as context

* fix: issue activity
2023-12-27 15:33:31 +05:30
Anmol Singh BhatiaandGitHub 05eb728c40 chore: cycle & module sidebar improvement (#3251)
* chore: no lead option added in module lead select dropdown

* chore: module lead select dropdown code refactor

* chore: cycle sidebar improvement

* chore: module sidebar improvement

* style: cycle and module sidebar improvement

* style: app sidebar improvement
2023-12-27 15:16:13 +05:30
sriram veeraghantaandGitHub ad2471c5a7 Merge pull request #3258 from makeplane/develop
fix: adding access token to build Pr (#3257)
2023-12-26 19:00:10 +05:30
sriram veeraghantaandGitHub 440cfc0f20 fix: adding access token to build Pr (#3257) 2023-12-26 18:59:24 +05:30
sriram veeraghantaandGitHub 9a32d722dc Merge pull request #3256 from makeplane/develop
promote: develop to preview
2023-12-26 18:22:04 +05:30
sriram veeraghantaandGitHub 37fe9a185c fix: adding token to the build pr workflow (#3254)
* fix: adding token to the build pr workflow

* fix: using automatic github token
2023-12-26 18:13:13 +05:30
sriram veeraghanta eabb31a764 fix: upgrading turbo repo changes 2023-12-26 17:04:59 +05:30
sriram veeraghanta 1e27c7936d fix: eslint dependencies fixes 2023-12-26 17:03:43 +05:30
M. PalanikannanandGitHub 0a617eec26 🔧 fix: Pages mutation on component mount (#3230)
* 🔧 chore: Update revalidate option and add useEffect hook in PageDetailsPage component

- Set the `revalidate` option to `false` in the `commonSwrOptions` object.
- Add an `useEffect` hook that calls the `mutatePageDetails` function with specific options.
- Inside the `useEffect` hook, set `revalidate` to `true`, `populateCache` to `true`, and define a rollback function for error handling.
- If an error occurs during the rollback, display an error alert message using the `actionCompleteAlert` function.
- In the `updatePage` function, ensure that `workspaceSlug`, `projectId`, and `pageId` are all defined before proceeding.

* ♻️ refactor: Update code styling for inline code in CoreEditorExtensions

In the `packages/editor/core/src/ui/extensions/index.tsx` file, the code for inline code styling in the CoreEditorExtensions component has been updated. Previously, it was set to `false`, but now it is defined as an object with specific HTML attributes. The code styling includes a rounded-md background, custom primary color, margin, padding, font styling, and disabling spellcheck. This change improves the visual appearance and readability of inline code elements in the editor.
2023-12-26 16:45:00 +05:30
sriram veeraghantaandGitHub e7468292c7 Merge pull request #3175 from makeplane/preview
release: moving changes from preview to master
2023-12-18 19:29:43 +05:30
117 changed files with 1116 additions and 7582 deletions
@@ -1,6 +1,6 @@
name: Build Pull Request Contents
on:
on:
pull_request:
types: ["opened", "synchronize"]
@@ -14,13 +14,15 @@ jobs:
steps:
- name: Checkout Repository to Actions
uses: actions/checkout@v3.3.0
with:
token: ${{ secrets.ACCESS_TOKEN }}
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 18.x
cache: 'yarn'
cache: "yarn"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v38
@@ -44,5 +46,3 @@ jobs:
run: |
yarn
yarn build --filter=space
-3
View File
@@ -80,6 +80,3 @@ tmp/
## packages
dist
.temp/
# logs
combined.log
+1 -1
View File
@@ -1,3 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -l info -Q internal_tasks,external_tasks
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO
+2 -2
View File
@@ -96,7 +96,7 @@ class IssueSerializer(BaseSerializer):
if (
data.get("state")
and not State.objects.filter(
project_id=self.context.get("project_id"), pk=data.get("state")
project_id=self.context.get("project_id"), pk=data.get("state").id
).exists()
):
raise serializers.ValidationError(
@@ -107,7 +107,7 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspace_id=self.context.get("workspace_id"), pk=data.get("parent")
workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id
).exists()
):
raise serializers.ValidationError(
+7 -7
View File
@@ -65,18 +65,18 @@ class ModuleSerializer(BaseSerializer):
def create(self, validated_data):
members = validated_data.pop("members", None)
project = self.context["project"]
module = Module.objects.create(**validated_data, project=project)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
module = Module.objects.create(**validated_data, project_id=project_id)
if members is not None:
ModuleMember.objects.bulk_create(
[
ModuleMember(
module=module,
member=member,
project=project,
workspace=project.workspace,
member_id=str(member),
project_id=project_id,
workspace_id=workspace_id,
created_by=module.created_by,
updated_by=module.updated_by,
)
@@ -97,7 +97,7 @@ class ModuleSerializer(BaseSerializer):
[
ModuleMember(
module=instance,
member=member,
member_id=str(member),
project=instance.project,
workspace=instance.project.workspace,
created_by=instance.created_by,
+36 -40
View File
@@ -281,22 +281,20 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(pk),
"cycle_name": str(cycle.name),
"issues": [str(issue_id) for issue_id in cycle_issues],
}),
'actor_id': str(request.user.id),
'issue_id': None,
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Delete the cycle
cycle.delete()
@@ -456,21 +454,21 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
# Capture Issue Activity
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.created",
'requested_data': json.dumps({"cycles_list": str(issues)}),
'actor_id': str(self.request.user.id),
'issue_id': None,
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': json.dumps({
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize("json", record_to_create),
}),
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
"created_cycle_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
)
# Return all Cycle Issues
@@ -485,21 +483,19 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.apply_async(
args=[],
kwargs={
'type': "cycle.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(self.kwargs.get("cycle_id")),
"issues": [str(issue_id)],
}),
'actor_id': str(self.request.user.id),
'issue_id': str(issue_id),
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+19 -27
View File
@@ -142,18 +142,14 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Create an Issue Activity
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
"type": "issue.activity.created",
"requested_data": json.dumps(request.data, cls=DjangoJSONEncoder),
"actor_id": str(request.user.id),
"issue_id": str(issue.id),
"project_id": str(project_id),
"current_instance": None,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
@@ -236,21 +232,17 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.apply_async(
args=[],
kwargs={
"type": "issue.activity.updated",
"requested_data": requested_data,
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
)
issue_serializer.save()
else:
+85 -118
View File
@@ -207,43 +207,44 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer.save()
# Track the issue
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "issue.activity.created",
'requested_data': json.dumps(self.request.data, cls=DjangoJSONEncoder),
'actor_id': str(request.user.id),
'issue_id': str(serializer.data.get("id", None)),
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
project = Project.objects.get(pk=project_id)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueSerializer(issue, data=request.data, partial=True)
serializer = IssueSerializer(
issue,
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
},
partial=True,
)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[],
kwargs={
'type': "issue.activity.updated",
'requested_data': requested_data,
'actor_id': str(request.user.id),
'issue_id': str(pk),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -254,19 +255,14 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.apply_async(
args=[],
kwargs={
'type': "issue.activity.deleted",
'requested_data': json.dumps({"issue_id": str(pk)}),
'actor_id': str(request.user.id),
'issue_id': str(pk),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='your_routing_key',
queue='your_queue_name'
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -322,11 +318,7 @@ class LabelAPIEndpoint(BaseAPIView):
).data,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
)
serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk=None):
@@ -336,6 +328,7 @@ class LabelAPIEndpoint(BaseAPIView):
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
@@ -400,18 +393,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
project_id=project_id,
issue_id=issue_id,
)
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.created",
'requested_data': json.dumps(serializer.data, cls=DjangoJSONEncoder),
'actor_id': str(self.request.user.id),
'issue_id': str(self.kwargs.get("issue_id")),
'project_id': str(self.kwargs.get("project_id")),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -428,18 +417,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.updated",
'requested_data': requested_data,
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -452,18 +437,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
IssueLinkSerializer(issue_link).data,
cls=DjangoJSONEncoder,
)
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "link.activity.deleted",
'requested_data': json.dumps({"link_id": str(pk)}),
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': current_instance,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
issue_activity.delay(
type="link.activity.deleted",
requested_data=json.dumps({"link_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -535,20 +516,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
issue_id=issue_id,
actor=request.user,
)
issue_activity.apply_async(
args=[],
kwargs={
"type": "comment.activity.created",
"requested_data": json.dumps(
serializer.data, cls=DjangoJSONEncoder
),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("issue_id")),
"project_id": str(self.kwargs.get("project_id")),
"current_instance": None,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -567,18 +542,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
)
if serializer.is_valid():
serializer.save()
issue_activity.apply_async(
args=[],
kwargs={
"type": "comment.activity.updated",
"requested_data": requested_data,
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": current_instance,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -592,18 +563,14 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
cls=DjangoJSONEncoder,
)
issue_comment.delete()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
"type": "comment.activity.deleted",
"requested_data": json.dumps({"comment_id": str(pk)}),
"actor_id": str(request.user.id),
"issue_id": str(issue_id),
"project_id": str(project_id),
"current_instance": current_instance,
"epoch": int(timezone.now().timestamp()),
},
routing_key="external",
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -624,7 +591,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
if pk:
issue_activities = issue_activities.get(pk=pk)
serializer = IssueActivitySerializer(issue_activities)
+39 -43
View File
@@ -121,8 +121,8 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
)
def post(self, request, slug, project_id):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
serializer = ModuleSerializer(data=request.data, context={"project": project})
project = Project.objects.get(pk=project_id, workspace__slug=slug)
serializer = ModuleSerializer(data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id})
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
@@ -132,7 +132,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data)
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -166,22 +166,20 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
)
issue_activity.apply_async(
args=[],
kwargs={
'type': "module.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}),
'actor_id': str(request.user.id),
'issue_id': None,
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -332,21 +330,21 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
# Capture Issue Activity
issue_activity.apply_async(
args=[],
kwargs={
'type': "module.activity.created",
'requested_data': json.dumps({"modules_list": str(issues)}),
'actor_id': str(self.request.user.id),
'issue_id': None,
'project_id': str(self.kwargs.get("project_id", None)),
'current_instance': json.dumps({
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize("json", record_to_create),
}),
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
)
return Response(
@@ -359,20 +357,18 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
)
module_issue.delete()
issue_activity.apply_async(
args=[], # If no positional arguments are required
kwargs={
'type': "module.activity.deleted",
'requested_data': json.dumps({
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(module_id),
"issues": [str(module_issue.issue_id)],
}),
'actor_id': str(request.user.id),
'issue_id': str(issue_id),
'project_id': str(project_id),
'current_instance': None,
'epoch': int(timezone.now().timestamp()),
},
routing_key='external',
}
),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -40,6 +40,7 @@ class CycleSerializer(BaseSerializer):
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
status = serializers.CharField(read_only=True)
def validate(self, data):
if (
@@ -4,6 +4,7 @@ from .base import BaseSerializer
from plane.db.models import Estimate, EstimatePoint
from plane.app.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer
from rest_framework import serializers
class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
@@ -19,6 +20,15 @@ class EstimateSerializer(BaseSerializer):
class EstimatePointSerializer(BaseSerializer):
def validate(self, data):
if not data:
raise serializers.ValidationError("Estimate points are required")
value = data.get("value")
if value and len(value) > 20:
raise serializers.ValidationError("Value can't be more than 20 characters")
return data
class Meta:
model = EstimatePoint
fields = "__all__"
+31 -3
View File
@@ -11,6 +11,10 @@ from django.db.models import (
Count,
Prefetch,
Sum,
Case,
When,
Value,
CharField
)
from django.core import serializers
from django.utils import timezone
@@ -157,6 +161,28 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
),
)
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT")
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING")
),
When(
end_date__lt=timezone.now(),
then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT")
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
@@ -177,7 +203,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite","-created_at")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
if cycle_view == "current":
@@ -575,7 +601,9 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
)
issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data
issues = IssueStateSerializer(
issues, many=True, fields=fields if fields else None
).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(issue_dict, status=status.HTTP_200_OK)
@@ -805,4 +833,4 @@ class TransferCycleIssueEndpoint(BaseAPIView):
updated_cycles, ["cycle_id"], batch_size=100
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+4 -4
View File
@@ -53,11 +53,11 @@ class BulkEstimatePointEndpoint(BaseViewSet):
)
estimate_points = request.data.get("estimate_points", [])
if not len(estimate_points) or len(estimate_points) > 8:
serializer = EstimatePointSerializer(data=request.data.get("estimate_points"), many=True)
if not serializer.is_valid():
return Response(
{"error": "Estimate points are required"},
status=status.HTTP_400_BAD_REQUEST,
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate_serializer = EstimateSerializer(data=request.data.get("estimate"))
+112 -147
View File
@@ -1,7 +1,5 @@
# Python imports
import uuid
import json
import requests
# Third party imports
from rest_framework import status
@@ -9,7 +7,7 @@ from rest_framework.response import Response
# Django imports
from django.db.models import Max, Q
from django.conf import settings
# Module imports
from plane.app.views import BaseAPIView
from plane.db.models import (
@@ -36,15 +34,20 @@ from plane.app.serializers import (
IssueFlatSerializer,
ModuleSerializer,
)
from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.app.permissions import WorkSpaceAdminPermission
from plane.bgtasks.importer_task import service_importer
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service):
if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
@@ -55,10 +58,11 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
integration__provider="github", workspace__slug=slug
)
installtion_id = workspace_integration.config.get("installation_id", False)
access_tokens_url = workspace_integration.metadata.get(
"access_tokens_url", False
)
# Check for the installation id
if not installtion_id:
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
@@ -66,33 +70,18 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Request segway for the required information
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"owner": owner,
"repo": repo,
"installationId": installtion_id,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
res.json(),
status=status.HTTP_200_OK,
)
return Response(
{"error": "Inetgration service is not available please try later"},
status=status.HTTP_400_BAD_REQUEST,
issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo
)
return Response(
{
"issue_count": issue_count,
"labels": labels,
"collaborators": collaborators,
},
status=status.HTTP_200_OK,
)
if service == "jira":
# Check for all the keys
params = {
@@ -113,35 +102,16 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
email = request.GET.get("email", "")
cloud_hostname = request.GET.get("cloud_hostname", "")
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"project_key": project_key,
"api_token": api_token,
"email": email,
"cloud_hostname": cloud_hostname,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/jira",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
res.json(),
status=status.HTTP_200_OK,
)
return Response(
{"error": "Inetgration service is not available please try later"},
status=status.HTTP_400_BAD_REQUEST,
response = jira_project_issue_summary(
email, api_token, project_key, cloud_hostname
)
if "error" in response:
return Response(response, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
response,
status=status.HTTP_200_OK,
)
return Response(
{"error": "Service not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
@@ -152,21 +122,7 @@ class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
if service not in ["github", "jira"]:
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
if service == "github":
workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug
)
installation_id = workspace_integration.config.get("installation_id", False)
project_id = request.data.get("project_id", False)
if not project_id:
@@ -174,84 +130,87 @@ class ImportServiceEndpoint(BaseAPIView):
{"error": "Project ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
# Validate the data
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
if service == "github":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata or not config:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
# Update config
if config and service == "github":
config.update({"installation_id": installation_id})
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# Get the api token -- # derecated
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
if service == "jira":
data = request.data.get("data", False)
metadata = request.data.get("metadata", False)
config = request.data.get("config", False)
if not data or not metadata:
return Response(
{"error": "Data, config and metadata are required"},
status=status.HTTP_400_BAD_REQUEST,
)
api_token = APIToken.objects.filter(
user=request.user, workspace=workspace
).first()
if api_token is None:
api_token = APIToken.objects.create(
user=request.user,
label="Importer",
workspace=workspace,
)
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
)
# Create an import
importer = Importer.objects.create(
service=service,
project_id=project_id,
status="queued",
initiated_by=request.user,
data=data,
metadata=metadata,
token=api_token,
config=config,
created_by=request.user,
updated_by=request.user,
service_importer.delay(service, importer.id)
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
{"error": "Servivce not supported yet"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"metadata": metadata,
"data": data,
"config": config,
"workspace_id": str(workspace.id),
"project_id": str(project_id),
"created_by": str(request.user.id),
"importer_id": str(importer.id),
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/import",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
importer.status = "failed"
importer.reason = str(res.json())
importer.save()
else:
importer.status = "processing"
importer.save(update_fields=["status"])
else:
importer.status = "failed"
importer.reason = "Segway base url is not present"
importer.save(update_fields=["status", "reason"])
# return the response
serializer = ImporterSerializer(importer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug):
imports = (
Importer.objects.filter(workspace__slug=slug)
@@ -262,7 +221,9 @@ class ImportServiceEndpoint(BaseAPIView):
return Response(serializer.data)
def delete(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
if importer.imported_data is not None:
# Delete all imported Issues
@@ -280,7 +241,9 @@ class ImportServiceEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, service, pk):
importer = Importer.objects.get(pk=pk, service=service, workspace__slug=slug)
importer = Importer.objects.get(
pk=pk, service=service, workspace__slug=slug
)
serializer = ImporterSerializer(importer, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -516,7 +479,9 @@ class BulkImportModulesEndpoint(BaseAPIView):
[
ModuleLink(
module=module,
url=module_data.get("link", {}).get("url", "https://plane.so"),
url=module_data.get("link", {}).get(
"url", "https://plane.so"
),
title=module_data.get("link", {}).get(
"title", "Original Issue"
),
+11 -25
View File
@@ -1,11 +1,8 @@
# Python improts
import uuid
import requests
import json
# Django imports
from django.contrib.auth.hashers import make_password
from django.conf import settings
# Third party imports
from rest_framework.response import Response
@@ -30,7 +27,6 @@ from plane.utils.integrations.github import (
from plane.app.permissions import WorkSpaceAdminPermission
from plane.utils.integrations.slack import slack_oauth
class IntegrationViewSet(BaseViewSet):
serializer_class = IntegrationSerializer
model = Integration
@@ -50,7 +46,9 @@ class IntegrationViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IntegrationSerializer(integration, data=request.data, partial=True)
serializer = IntegrationSerializer(
integration, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
@@ -96,30 +94,14 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
{"error": "Installation ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {"installationId": installation_id}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/metadata",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
metadata = res.json()
metadata = get_github_metadata(installation_id)
config = {"installation_id": installation_id}
if provider == "slack":
code = request.data.get("code", False)
if not code:
return Response(
{"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST
)
return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST)
slack_response = slack_oauth(code=code)
@@ -141,7 +123,9 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
is_password_autoset=True,
is_bot=True,
first_name=integration.title,
avatar=integration.avatar_url if integration.avatar_url is not None else "",
avatar=integration.avatar_url
if integration.avatar_url is not None
else "",
)
# Create an API Token for the bot user
@@ -177,7 +161,9 @@ class WorkspaceIntegrationViewSet(BaseViewSet):
)
if workspace_integration.integration.provider == "github":
installation_id = workspace_integration.config.get("installation_id", False)
installation_id = workspace_integration.config.get(
"installation_id", False
)
if installation_id:
delete_github_installation(installation_id=installation_id)
@@ -1,15 +1,8 @@
# Python imports
import json
import requests
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Django imports
from django.conf import settings
# Module imports
from plane.app.views import BaseViewSet, BaseAPIView
from plane.db.models import (
@@ -42,32 +35,19 @@ class GithubRepositoriesEndpoint(BaseAPIView):
workspace__slug=slug, pk=workspace_integration_id
)
installation_id = workspace_integration.config.get("installation_id")
if not installation_id:
if workspace_integration.integration.provider != "github":
return Response(
{"error": "Not a github integration"},
status=status.HTTP_400_BAD_REQUEST,
)
# Push it to segway
if settings.SEGWAY_BASE_URL:
headers = {
"Content-Type": "application/json",
"x-api-key": settings.SEGWAY_KEY,
}
data = {
"installationId": installation_id,
"page": page,
}
res = requests.post(
f"{settings.SEGWAY_BASE_URL}/api/github/repos",
data=json.dumps(data),
headers=headers,
)
if "error" in res.json():
return Response(res.json(), status=status.HTTP_400_BAD_REQUEST)
else:
return Response(res.json(), status=status.HTTP_200_OK)
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
repositories_url = (
workspace_integration.metadata["repositories_url"]
+ f"?per_page=100&page={page}"
)
repositories = get_github_repos(access_tokens_url, repositories_url)
return Response(repositories, status=status.HTTP_200_OK)
class GithubRepositorySyncViewSet(BaseViewSet):
+2 -1
View File
@@ -50,7 +50,8 @@ class GlobalSearchEndpoint(BaseAPIView):
q = Q()
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\d+\.\d+|\d+", query)
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
-1
View File
@@ -1 +0,0 @@
from .issue_sync_task import issue_sync
@@ -373,7 +373,7 @@ def generate_non_segmented_rows(
return [tuple(row_zero)] + rows
@shared_task(queue='internal_tasks')
@shared_task
def analytic_export_task(email, data, slug):
try:
filters = issue_filters(data, "POST")
@@ -29,7 +29,7 @@ def posthogConfiguration():
return None, None
@shared_task(queue='internal_tasks')
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
@@ -54,7 +54,7 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
capture_exception(e)
@shared_task(queue='internal_tasks')
@shared_task
def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from):
try:
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
+1 -1
View File
@@ -259,7 +259,7 @@ def generate_xlsx(header, project_id, issues, files):
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task(queue='internal_tasks')
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
@@ -15,7 +15,7 @@ from botocore.client import Config
from plane.db.models import ExporterHistory
@shared_task(queue='internal_tasks')
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
+1 -1
View File
@@ -12,7 +12,7 @@ from celery import shared_task
from plane.db.models import FileAsset
@shared_task(queue='internal_tasks')
@shared_task
def delete_file_asset():
# file assets to delete
@@ -17,7 +17,7 @@ from sentry_sdk import capture_exception
from plane.license.utils.instance_value import get_email_configuration
@shared_task(queue='internal_tasks')
@shared_task
def forgot_password(first_name, email, uidb64, token, current_site):
try:
relative_link = (
+159 -426
View File
@@ -1,467 +1,200 @@
# Python imports
import json
import requests
import uuid
# Django imports
from django.db.models import Q, Max
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.auth.hashers import make_password
# Third Party imports
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
from sentry_sdk import capture_exception
# Module imports
from plane.app.serializers import ImporterSerializer
from plane.db.models import (
Importer,
WorkspaceMember,
GithubRepositorySync,
GithubRepository,
ProjectMember,
WorkspaceIntegration,
Label,
User,
IssueProperty,
IssueAssignee,
IssueLabel,
IssueSequence,
IssueActivity,
IssueComment,
IssueLink,
ModuleIssue,
State,
Module,
Issue,
Cycle,
)
from plane.bgtasks.user_welcome_task import send_welcome_slack
@shared_task(queue="internal_tasks")
@shared_task
def service_importer(service, importer_id):
pass
## Utility functions
def get_label_id(name, data):
try:
existing_label = (
Label.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name__iexact=name,
)
.values("id")
.first()
)
return existing_label
except Label.DoesNotExist:
return None
importer = Importer.objects.get(pk=importer_id)
importer.status = "processing"
importer.save()
users = importer.data.get("users", [])
def get_state_id(name, data):
try:
existing_state = (
State.objects.filter(
name__iexact=name,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
.values("id")
.first()
)
return existing_state
except State.DoesNotExist:
return None
def get_user_id(name):
try:
existing_user = User.objects.filter(email=name).values("id").first()
return existing_user
except User.DoesNotExist:
return None
def update_imported_items(importer_id, entity, entity_id):
importer = Importer.objects.get(pk=importer_id)
if importer.imported_data:
importer.imported_data.setdefault(str(entity), []).append(str(entity_id))
else:
importer.imported_data = {
str(entity): [str(entity_id)]
}
importer.save()
## Sync functions
def members_sync(data):
try:
user = User.objects.get(email=data.get("email"))
_ = WorkspaceMember.objects.get_or_create(
member_id=user.id, workspace_id=data.get("workspace_id")
)
_ = ProjectMember.objects.get_or_create(
member_id=user.id,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
_ = IssueProperty.objects.get_or_create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
user_id=user.id,
created_by_id=data.get("created_by"),
)
except User.DoesNotExist:
# For all invited users create the users
new_user = User.objects.create(
email=data.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
service = data.get("external_source")
WorkspaceMember.objects.create(
member_id=new_user.id,
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
)
ProjectMember.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
member_id=new_user.id,
created_by_id=data.get("created_by"),
)
IssueProperty.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
user_id=new_user.id,
created_by_id=data.get("created_by"),
)
if data.get("source", False) == "slack":
send_welcome_slack.delay(
str(new_user.id),
True,
f"{new_user.email} was imported to Plane from {service}",
# Check if we need to import users as well
if len(users):
# For all invited users create the users
new_users = User.objects.bulk_create(
[
User(
email=user.get("email").strip().lower(),
username=uuid.uuid4().hex,
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
for user in users
if user.get("import", False) == "invite"
],
batch_size=10,
ignore_conflicts=True,
)
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
def label_sync(data):
existing_label = Label.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name__iexact=data.get("name"),
external_id=data.get("external_id", None),
external_source=data.get("external_source"),
)
if not existing_label.exists() and data.get("name"):
label = Label.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name=data.get("name"),
color=data.get("color"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id", None),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "labels", label.id)
def state_sync(data):
try:
state = State.objects.get(
external_id=data.get("external_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except State.DoesNotExist:
existing_states = State.objects.filter(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
group=data.get("state_group"),
name__iexact=data.get("state_name"),
)
if existing_states.exists():
existing_state = existing_states.first()
existing_state.external_id = data.get("external_id")
existing_state.external_source = data.get("external_source")
existing_state.save()
else:
state = State.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
name=data.get("state_name"),
group=data.get("state_group"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
workspace_users = User.objects.filter(
email__in=[
user.get("email").strip().lower()
for user in users
if user.get("import", False) == "invite"
or user.get("import", False) == "map"
]
)
update_imported_items(data.get("importer_id"), "states", state.id)
# Check if any of the users are already member of workspace
_ = WorkspaceMember.objects.filter(
member__in=[user for user in workspace_users],
workspace_id=importer.workspace_id,
).update(is_active=True)
def issue_sync(data):
try:
issue = Issue.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Issue.DoesNotExist:
# Get the default state
default_state = State.objects.filter(
~Q(name="Triage"), project_id=data.get("project_id"), default=True
).first()
# Add new users to Workspace and project automatically
WorkspaceMember.objects.bulk_create(
[
WorkspaceMember(
member=user,
workspace_id=importer.workspace_id,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), project_id=data.get("project_id")
ProjectMember.objects.bulk_create(
[
ProjectMember(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
user=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
url = importer.metadata.get("url", False)
config = importer.metadata.get("config", {})
owner = importer.metadata.get("owner", False)
repository_id = importer.metadata.get("repository_id", False)
workspace_integration = WorkspaceIntegration.objects.get(
workspace_id=importer.workspace_id, integration__provider="github"
)
# Delete the old repository object
GithubRepositorySync.objects.filter(project_id=importer.project_id).delete()
GithubRepository.objects.filter(project_id=importer.project_id).delete()
# Create a Label for github
label = Label.objects.filter(
name="GitHub", project_id=importer.project_id
).first()
# Get the maximum sequence_id
last_id = IssueSequence.objects.filter(
project_id=data.get("project_id")
).aggregate(largest=Max("sequence"))["largest"]
if label is None:
label = Label.objects.create(
name="GitHub",
project_id=importer.project_id,
description="Label to sync Plane issues with GitHub issues",
color="#003773",
)
# Create repository
repo = GithubRepository.objects.create(
name=name,
url=url,
config=config,
repository_id=repository_id,
owner=owner,
project_id=importer.project_id,
)
last_id = 1 if last_id is None else last_id + 1
# Create repo sync
_ = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
credentials=importer.data.get("credentials", {}),
project_id=importer.project_id,
label=label,
)
# Get the maximum sort order
largest_sort_order = Issue.objects.filter(
project_id=data.get("project_id"), state=default_state
).aggregate(largest=Max("sort_order"))["largest"]
# Add bot as a member in the project
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor,
role=20,
project_id=importer.project_id,
)
largest_sort_order = (
65535 if largest_sort_order is None else largest_sort_order + 10000
)
parent_id = None
if data.get("parent_id", False):
parent_id = Issue.objects.filter(
external_id=data.get("parent_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
).values("id")
if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"}
import_data_json = json.dumps(
ImporterSerializer(importer).data,
cls=DjangoJSONEncoder,
)
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
json=import_data_json,
headers=headers,
)
# Issues
issue = Issue.objects.create(
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
state_id=get_state_id(data.get("state"), data).get("id")
if get_state_id(data.get("state"), data)
else default_state.id,
name=data.get("name", "Issue Created through Importer")[:255],
description_html=data.get("description_html", "<p></p>"),
sequence_id=last_id,
sort_order=largest_sort_order,
start_date=data.get("start_date", None),
target_date=data.get("target_date", None),
priority=data.get("priority", "none"),
created_by_id=data.get("created_by_id"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
parent_id=parent_id,
)
# Sequences
_ = IssueSequence.objects.create(
issue=issue,
sequence=issue.sequence_id,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
# Attach Links
_ = IssueLink.objects.create(
issue=issue,
url=data.get("link", {}).get("url", "https://github.com"),
title=data.get("link", {}).get("title", "Original Issue"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
# Track the issue activities
_ = IssueActivity.objects.create(
issue=issue,
actor_id=data.get("created_by_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
comment=f"imported the issue from {data.get('external_source')}",
verb="created",
created_by_id=data.get("created_by_id"),
)
update_imported_items(data.get("importer_id"), "issues", issue.id)
def issue_label_sync(data):
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
if get_label_id(data.get("name"), data):
IssueLabel.objects.create(
issue=issue,
label_id=get_label_id(data.get("name"), data).get("id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
def issue_assignee_sync(data):
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
user = User.objects.filter(email=data.get("email")).values("id")
IssueAssignee.objects.create(
issue=issue,
assignee_id=user,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by_id"),
)
def issue_comment_sync(data):
# Create Comments
issue = Issue.objects.get(
external_source=data.get("external_issue_source"),
external_id=data.get("external_issue_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
IssueComment.objects.create(
issue=issue,
comment_html=data.get("comment_html", "<p></p>"),
actor_id=data.get("created_by_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=get_user_id(data.get("created_by_id")).get("id")
if get_user_id(data.get("created_by_id"))
else data.get("created_by_id"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
def cycles_sync(data):
try:
_ = Cycle.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Cycle.DoesNotExist:
cycle = Cycle.objects.create(
name=data.get("name"),
description_html=data.get("description_html", "<p></p>"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "cycles", cycle.id)
def module_sync(data):
try:
_ = Module.objects.get(
external_id=data.get("external_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
except Module.DoesNotExist:
module = Module.objects.create(
name=data.get("name"),
description_html=data.get("description_html", "<p></p>"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
external_id=data.get("external_id"),
external_source=data.get("external_source"),
)
update_imported_items(data.get("importer_id"), "modules", module.id)
def modules_issue_sync(data):
module = Module.objects.get(
external_id=data.get("module_id"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
external_source=data.get("external_source"),
)
issue = Issue.objects.get(
external_id=data.get("issue_id"),
external_source=data.get("external_source"),
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
)
_ = ModuleIssue.objects.create(
module=module,
issue=issue,
project_id=data.get("project_id"),
workspace_id=data.get("workspace_id"),
created_by_id=data.get("created_by"),
)
def import_sync(data):
importer = Importer.objects.get(pk=data.get("importer_id"))
importer.status = data.get("status")
importer.save(update_fields=["status"])
@shared_task(bind=True, queue="segway_task", max_retries=5)
def import_task(self, data):
type = data.get("type")
if type is None:
return
TYPE_MAPPER = {
"member.sync": members_sync,
"label.sync": label_sync,
"state.sync": state_sync,
"issue.sync": issue_sync,
"issue.label.sync": issue_label_sync,
"issue.assignee.sync": issue_assignee_sync,
"issue.comment.sync": issue_comment_sync,
"cycle.sync": cycles_sync,
"module.sync": module_sync,
"module.issue.sync": modules_issue_sync,
"import.sync": import_sync,
}
try:
func = TYPE_MAPPER.get(type)
if func is None:
return
# Call the function
func(data)
return
except Exception as e:
try:
# Retry with exponential backoff
self.retry(exc=e, countdown=50, backoff=2)
except MaxRetriesExceededError:
# For max retries reached items fail the import
importer = Importer.objects.get(pk=data.get("importer_id"))
importer.status = "failed"
importer.reason = e
importer.save()
importer = Importer.objects.get(pk=importer_id)
importer.status = "failed"
importer.save()
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -112,8 +112,8 @@ def track_parent(
epoch,
):
if current_instance.get("parent") != requested_data.get("parent"):
old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first()
new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first()
old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() if current_instance.get("parent") is not None else None
new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first() if requested_data.get("parent") is not None else None
issue_activities.append(
IssueActivity(
@@ -1460,7 +1460,7 @@ def delete_draft_issue_activity(
# Receive message from room group
@shared_task(queue='internal_tasks')
@shared_task
def issue_activity(
type,
requested_data,
@@ -16,7 +16,7 @@ from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
@shared_task(queue='internal_tasks')
@shared_task
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
@@ -1,5 +0,0 @@
from celery import shared_task
@shared_task(queue="segway_tasks")
def issue_sync(data):
print(f"Received data from Segway: {data}")
@@ -17,7 +17,7 @@ from sentry_sdk import capture_exception
from plane.license.utils.instance_value import get_email_configuration
@shared_task(queue='internal_tasks')
@shared_task
def magic_link(email, key, token, current_site):
try:
(
+1 -1
View File
@@ -183,7 +183,7 @@ def createMentionNotification(project, notification_comment, issue, actor_id, me
)
@shared_task(queue='internal_tasks')
@shared_task
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
issue_activities_created = (
json.loads(
@@ -15,7 +15,7 @@ from sentry_sdk import capture_exception
from plane.db.models import Project, User, ProjectMemberInvite
from plane.license.utils.instance_value import get_email_configuration
@shared_task(queue='internal_tasks')
@shared_task
def project_invitation(email, project_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
+1 -1
View File
@@ -11,7 +11,7 @@ from slack_sdk.errors import SlackApiError
from plane.db.models import User
@shared_task(queue='internal_tasks')
@shared_task
def send_welcome_slack(user_id, created, message):
try:
instance = User.objects.get(pk=user_id)
+1 -2
View File
@@ -71,7 +71,6 @@ def get_model_data(event, event_id, many=False):
retry_backoff=600,
max_retries=5,
retry_jitter=True,
queue='internal_tasks'
)
def webhook_task(self, webhook, slug, event, event_data, action):
try:
@@ -162,7 +161,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
return
@shared_task(queue='internal_tasks')
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -20,7 +20,7 @@ from plane.db.models import Workspace, WorkspaceMemberInvite, User
from plane.license.utils.instance_value import get_email_configuration
@shared_task(queue='internal_tasks')
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
try:
user = User.objects.get(email=invitor)
+1 -1
View File
@@ -8,7 +8,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
ri = redis_instance()
app = Celery('tasks', broker='pyamqp://guest:guest@localhost:5672//')
app = Celery("plane")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
@@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2023-12-20 14:33
# Generated by Django 4.2.7 on 2023-12-29 10:20
from django.db import migrations, models
@@ -18,12 +18,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cycle',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='importer',
name='reason',
field=models.TextField(blank=True),
model_name='inboxissue',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='inboxissue',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='issue',
@@ -33,7 +38,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='issue',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='issuecomment',
@@ -43,7 +48,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='issuecomment',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='label',
@@ -53,7 +58,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='label',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='module',
@@ -63,7 +68,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='module',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='state',
@@ -73,6 +78,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='state',
name='external_source',
field=models.CharField(blank=True, null=True),
field=models.CharField(blank=True, max_length=255, null=True),
),
]
+2
View File
@@ -22,6 +22,8 @@ class BaseModel(AuditModel):
user = get_current_user()
if user is None or user.is_anonymous:
self.created_by = None
self.updated_by = None
super(BaseModel, self).save(*args, **kwargs)
else:
# Check if the model is being created or updated
+4 -4
View File
@@ -18,7 +18,7 @@ class Cycle(ProjectBaseModel):
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
@@ -29,9 +29,9 @@ class Cycle(ProjectBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(project=self.project).aggregate(
smallest=models.Min("sort_order")
)["smallest"]
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
-1
View File
@@ -34,7 +34,6 @@ class Importer(ProjectBaseModel):
"db.APIToken", on_delete=models.CASCADE, related_name="importer"
)
imported_data = models.JSONField(null=True)
reason = models.TextField(blank=True)
class Meta:
verbose_name = "Importer"
+2
View File
@@ -39,6 +39,8 @@ class InboxIssue(ProjectBaseModel):
"db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True
)
source = models.TextField(blank=True, null=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
verbose_name = "InboxIssue"
+7 -8
View File
@@ -102,7 +102,7 @@ class Issue(ProjectBaseModel):
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
objects = models.Manager()
@@ -134,6 +134,7 @@ class Issue(ProjectBaseModel):
except ImportError:
pass
if self._state.adding:
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
@@ -211,9 +212,8 @@ class IssueRelation(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
return f"{self.issue.name} {self.related_issue.name}"
class IssueMention(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_mention"
@@ -223,7 +223,6 @@ class IssueMention(ProjectBaseModel):
on_delete=models.CASCADE,
related_name="issue_mention",
)
class Meta:
unique_together = ["issue", "mention"]
verbose_name = "Issue Mention"
@@ -232,7 +231,7 @@ class IssueMention(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.mention.email}"
return f"{self.issue.name} {self.mention.email}"
class IssueAssignee(ProjectBaseModel):
@@ -369,7 +368,7 @@ class IssueComment(ProjectBaseModel):
default="INTERNAL",
max_length=100,
)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
def save(self, *args, **kwargs):
@@ -421,7 +420,7 @@ class Label(ProjectBaseModel):
description = models.TextField(blank=True)
color = models.CharField(max_length=255, blank=True)
sort_order = models.FloatField(default=65535)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
+1 -1
View File
@@ -41,7 +41,7 @@ class Module(ProjectBaseModel):
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
class Meta:
+1 -1
View File
@@ -24,7 +24,7 @@ class State(ProjectBaseModel):
max_length=20,
)
default = models.BooleanField(default=False)
external_source = models.CharField(null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
def __str__(self):
+16 -29
View File
@@ -5,7 +5,6 @@ import ssl
import certifi
from datetime import timedelta
from urllib.parse import urlparse
from kombu import Exchange, Queue
# Django imports
from django.core.management.utils import get_random_secret_key
@@ -149,9 +148,6 @@ else:
REDIS_URL = os.environ.get("REDIS_URL")
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
# RabbitMq Config
RABBITMQ_URL = os.environ.get("RABBITMQ_URL")
if REDIS_SSL:
CACHES = {
"default": {
@@ -274,28 +270,18 @@ SIMPLE_JWT = {
# Celery Configuration
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_BROKER_URL = RABBITMQ_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_QUEUES = (
Queue(
"internal_tasks",
Exchange("internal_exchange", type="direct"),
routing_key="internal",
),
Queue(
"external_tasks",
Exchange("external_exchange", type="direct"),
routing_key="external",
),
Queue(
"segway_tasks",
Exchange("segway_exchange", type="direct"),
routing_key="segway",
),
)
if REDIS_SSL:
redis_url = os.environ.get("REDIS_URL")
broker_url = (
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
)
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
else:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task",
@@ -343,11 +329,12 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# instance key
INSTANCE_KEY = os.environ.get(
"INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3"
)
# Skip environment variable configuration
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Segway
SEGWAY_BASE_URL = os.environ.get("SEGWAY_BASE_URL", "http://localhost:9000")
SEGWAY_KEY = os.environ.get("SEGWAY_KEY", False)
+3
View File
@@ -18,6 +18,9 @@ http {
location / {
proxy_pass http://web:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/ {
+1 -1
View File
@@ -27,7 +27,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.11.1"
"turbo": "^1.11.2"
},
"resolutions": {
"@types/react": "18.2.42"
@@ -52,12 +52,12 @@ export const CoreEditorExtensions = (
class: "leading-normal -mb-2",
},
},
// blockquote: {
// HTMLAttributes: {
// class: "border-l-4 border-custom-border-300",
// },
// },
code: false,
code: {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
+1 -2
View File
@@ -35,7 +35,6 @@
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"react-popper": "^2.3.0",
"tippy.js": "^6.3.7",
@@ -45,7 +44,7 @@
"@types/node": "18.15.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint": "^7.32.0",
"eslint": "8.36.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
+1 -2
View File
@@ -33,7 +33,6 @@
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"lucide-react": "^0.294.0",
"tippy.js": "^6.3.7"
@@ -42,7 +41,7 @@
"@types/node": "18.15.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint": "^7.32.0",
"eslint": "8.36.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"baseUrl": "./src",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
-7
View File
@@ -1,7 +0,0 @@
APP_ENV=local
SERVER_PORT=9000
DATABASE_URL=""
RABBITMQ_URL=""
SENTRY_DSN=""
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""
-20
View File
@@ -1,20 +0,0 @@
# Use the official Node.js 18-alpine image as the base image
FROM node:18-alpine
# Set the working directory inside the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application code to the working directory
COPY . .
# Build the TypeScript code
RUN npm run build
# Expose the port that your application will run on
EXPOSE 9000
-3
View File
@@ -1,3 +0,0 @@
# Plane Segway
A node process that take care of external integration with plane.
-1776
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
{
"name": "segway",
"version": "0.0.1",
"description": "An integration service syncs plane data with external sources.",
"author": "plane team",
"license": "ISC",
"private": true,
"scripts": {
"build": "npx tsc",
"start": "node dist/start.js",
"dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/start.js\""
},
"dependencies": {
"@octokit/rest": "^20.0.2",
"@overnightjs/core": "^1.7.6",
"@sentry/node": "^7.73.0",
"@sentry/tracing": "^7.73.0",
"amqplib": "^0.10.3",
"axios": "^1.6.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.29.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"octokit": "^3.1.2",
"postgres": "^3.4.1",
"showdown": "^2.1.0",
"uuid": "^9.0.1",
"winston": "^3.10.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/amqplib": "^0.10.4",
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.8.3",
"@types/node-fetch": "^2.6.9",
"@types/pg": "^8.10.9",
"@types/showdown": "^2.0.6",
"concurrently": "^8.2.1",
"drizzle-kit": "^0.20.6",
"nodemon": "^3.0.1",
"typescript": "^5.2.2"
}
}
-435
View File
@@ -1,435 +0,0 @@
// express
import { Request, Response } from "express";
// overnight js
import { Controller, Post, Middleware } from "@overnightjs/core";
// postgres
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
// showdown
import showdown from "showdown";
// octokit
import { Octokit } from "octokit";
import { getOctokit } from "../utils/github.authentication";
// logger
import { logger } from "../utils/logger";
// mq
import { MQSingleton } from "../mq/singleton";
// middleware
import AuthKeyMiddleware from "../middleware/authkey.middleware";
@Controller("api/github")
export class GithubController {
/**
* This controller houses all routes for the Github Importer/Integration
*/
// Initialize database and mq
db: PostgresJsDatabase;
mq: MQSingleton;
constructor(db: PostgresJsDatabase, mq: MQSingleton) {
this.db = db;
this.mq = mq;
}
private getAllEntities = async (
octokit: Octokit,
requestPath: string,
requestParams: any
) => {
let page = 1;
let results;
const returnData = [];
do {
results = await octokit.request(requestPath, { ...requestParams, page });
returnData.push(...results.data);
page++;
} while (results.data.length !== 0);
return returnData;
};
private githubCommentCreator = (
issue_number: number,
comments: { [key: string]: any }[]
) => {
const bulk_comments: { [key: string]: string | number }[] = [];
const converter = new showdown.Converter({ optionKey: "value" });
comments.forEach((comment) => {
if (
parseInt(
comment.issue_url.substring(comment.issue_url.lastIndexOf("/") + 1)
) === issue_number
) {
bulk_comments.push({
external_id: comment.id,
external_source: "github",
comment_html:
comment.body === null
? "<p></p>"
: converter.makeHtml(comment.body),
});
}
});
return bulk_comments;
};
private githubLabelCreator = (
issue_number: number,
labels: (string | { [key: string]: any })[]
) => {
const issueLabels: { [key: string]: string | number }[] = [];
labels.forEach((label) =>
issueLabels.push({
name: typeof label === "object" && label !== null ? label.name : label,
})
);
return issueLabels;
};
@Post("")
@Middleware([AuthKeyMiddleware])
private async home(req: Request, res: Response) {
try {
const { owner, repo, installationId } = req.body;
// Get the octokit instance
const octokit = await getOctokit(installationId);
// Fetch open issues
const openIssuesResponse = await octokit.request("GET /search/issues", {
q: `repo:${owner}/${repo} type:issue state:open`,
});
const openIssuesCount = openIssuesResponse.data.total_count;
// Fetch closed issues
const closedIssuesResponse = await octokit.request("GET /search/issues", {
q: `repo:${owner}/${repo} type:issue state:closed`,
});
const closedIssuesCount = closedIssuesResponse.data.total_count;
// Calculate total issues
const totalIssues = openIssuesCount + closedIssuesCount;
// Fetch total labels count
const labels = await this.getAllEntities(
octokit,
"GET /repos/{owner}/{repo}/labels",
{ owner, repo }
);
// Fetch total collaborators count
const collaborators = await this.getAllEntities(
octokit,
"GET /repos/{owner}/{repo}/collaborators",
{ owner, repo }
);
const labelCount = labels.length;
return res.status(200).json({
issue_count: totalIssues,
labels: labelCount,
collaborators,
});
} catch (error) {
logger.error("Failed to fetch importer summary", error);
return res.json({ message: "Server error", status: 500, error: error });
}
}
@Post("metadata")
@Middleware([AuthKeyMiddleware])
private async metadata(req: Request, res: Response) {
try {
const { installationId } = req.body;
// Get the octokit instance
const octokit = await getOctokit(installationId);
const { data } = await octokit.request("GET /app", {
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
});
return res.status(200).json(data);
} catch (error) {
logger.error("Failed to fetch metadata", error);
return res.json({ message: "Server error", status: 500, error: error });
}
}
@Post("repos")
@Middleware([AuthKeyMiddleware])
private async repos(req: Request, res: Response) {
try {
const { installationId, page } = req.body;
// Get the octokit instance
const octokit = await getOctokit(installationId);
const { data } = await octokit.request("GET /installation/repositories", {
page: page,
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
});
return res.status(200).json(data);
} catch (error) {
logger.error("Failed to fetch repos", error);
return res.json({ message: "Server error", status: 500, error: error });
}
}
@Post("import")
@Middleware([AuthKeyMiddleware])
private async import(req: Request, res: Response) {
const {
metadata: { owner, name: repo },
data: { users },
config: { installation_id, sync },
workspace_id,
project_id,
created_by,
importer_id,
} = req.body;
try {
res.status(200).json({
message: "Successful",
});
// Get the octokit instance
const octokit = await getOctokit(installation_id);
// Markdown converter
const converter = new showdown.Converter({ optionKey: "value" });
// users
const members = [];
for (const user of users) {
if (user?.import == "invite" || user?.import == "map") {
const githubMembers = {
args: [], // args
kwargs: {
data: {
type: "member.sync",
email: user.email,
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
members.push(user);
this.mq?.publish(
githubMembers,
"plane.bgtasks.importer_task.import_task"
);
}
}
// Labels
const githubLabels = await octokit.paginate(
octokit.rest.issues.listLabelsForRepo,
{
owner: owner,
repo: repo,
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
per_page: 100,
}
);
for await (const label of githubLabels) {
const labelSync = {
args: [], // args
kwargs: {
data: {
type: "label.sync",
external_source: "github",
external_id: label.id,
color: `#${label.color}`,
name: label.name,
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(labelSync, "plane.bgtasks.importer_task.import_task");
}
// Issues
const githubIssues = await octokit.paginate(
octokit.rest.issues.listForRepo,
{
state: "all",
owner: owner,
repo: repo,
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
per_page: 100,
sort: "created",
direction: "asc",
}
);
// Issue comments
const comments = [];
const githubComments = await octokit.paginate(
octokit.rest.issues.listCommentsForRepo,
{
owner: owner,
repo: repo,
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
per_page: 100,
}
);
for await (const comment of githubComments) {
comments.push(comment);
}
for await (const issue of githubIssues) {
if (!("pull_request" in issue)) {
const description_html = await converter.makeHtml(
issue?.body_html || "<p><p>"
);
const issueSync = {
args: [], // args
kwargs: {
data: {
type: "issue.sync",
name: issue.title,
description_html: description_html,
state: issue.state,
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
external_id: issue.id,
external_source: "github",
link: {
title: `Original Issue in Github ${issue.number}`,
url: issue.html_url,
},
parent_id: null,
importer_id: importer_id,
},
},
};
// Push the issue
this.mq?.publish(issueSync, "plane.bgtasks.importer_task.import_task");
// Push the comments
const githubIssueComments = this.githubCommentCreator(
issue.number,
comments
);
githubIssueComments.forEach((githubIssueComment) => {
const commentSync = {
args: [],
kwargs: {
data: {
type: "issue.comment.sync",
comment_html: githubIssueComment.comment_html,
external_source: githubIssueComment.external_source,
external_id: githubIssueComment.external_id,
external_issue_id: issue.id,
external_issue_source: "github",
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
},
},
};
// push to queue
this.mq?.publish(
commentSync,
"plane.bgtasks.importer_task.import_task"
);
});
// Push the labels
const githubLabels = this.githubLabelCreator(
issue.number,
issue.labels
);
githubLabels.forEach((githubLabel) => {
const labelSync = {
args: [],
kwargs: {
data: {
type: "issue.label.sync",
name: githubLabel.name,
external_issue_id: issue.id,
external_issue_source: "github",
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
},
},
};
//Push to queue
this.mq?.publish(
labelSync,
"plane.bgtasks.importer_task.import_task"
);
});
}
}
const import_sync = {
args: [], // args
kwargs: {
data: {
type: "import.sync",
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
status: "completed",
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(import_sync, "plane.bgtasks.importer_task.import_task");
return;
} catch (error) {
logger.error("Import failed", error);
const import_sync = {
args: [], // args
kwargs: {
data: {
type: "import.sync",
workspace_id: workspace_id,
project_id: project_id,
created_by_id: created_by,
importer_id: importer_id,
status: "failed",
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(import_sync, "plane.bgtasks.importer_task.import_task");
return res.json({ message: "Server error", status: 500, error: error });
}
}
}
-3
View File
@@ -1,3 +0,0 @@
export * from "./jira.controller";
export * from "./slack.controller"
export * from "./github.controller"
-434
View File
@@ -1,434 +0,0 @@
// overnight js
import { Request, Response } from "express";
import { Controller, Post, Middleware } from "@overnightjs/core";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
// mq
import { MQSingleton } from "../mq/singleton";
// middleware
import AuthKeyMiddleware from "../middleware/authkey.middleware";
// axios
import axios, { AxiosResponse } from "axios";
import { loadIssues, loadComments } from "../utils/paginator";
@Controller("api/jira")
export class JiraController {
/**
* This controller houses all routes for the Jira Importer
*/
// Initialize database and mq
db: PostgresJsDatabase;
mq: MQSingleton;
constructor(db: PostgresJsDatabase, mq: MQSingleton) {
this.db = db;
this.mq = mq;
}
@Post("")
@Middleware([AuthKeyMiddleware])
private async home(req: Request, res: Response) {
try {
const { email, api_token, project_key, cloud_hostname } = req.body;
const auth = {
username: email,
password: api_token,
};
const headers = {
Accept: "application/json",
};
// Constructing URLs
const issueUrl = `https://${cloud_hostname}/rest/api/3/search?jql=project=${project_key}`;
const moduleUrl = `https://${cloud_hostname}/rest/api/3/search?jql=project=${project_key}`;
const statusUrl = `https://${cloud_hostname}/rest/api/3/status/?jql=project={project_key}`;
const labelsUrl = `https://${cloud_hostname}/rest/api/3/label/?jql=project=${project_key}`;
const usersUrl = `https://${cloud_hostname}/rest/api/3/users/search?jql=project=${project_key}`;
// Making requests
const [
issueResponse,
moduleResponse,
statusResponse,
labelsResponse,
usersResponse,
] = await Promise.all([
axios.get(issueUrl, { auth, headers }),
axios.get(moduleUrl, { auth, headers }),
axios.get(statusUrl, { auth, headers }),
axios.get(labelsUrl, { auth, headers }),
axios.get(usersUrl, { auth, headers }),
]);
const issuesTotal = issueResponse.data.total;
const modulesTotal = moduleResponse.data.total;
const labelsTotal = labelsResponse.data.total;
const statusCount = statusResponse.data.length;
const usersData = usersResponse.data.filter(
(user: any) => user.accountType === "atlassian"
);
res.status(200).json({
issues: issuesTotal,
modules: modulesTotal,
labels: labelsTotal,
states: statusCount,
users: usersData,
});
return;
} catch (error) {
return res.json({ message: "Server error", status: 500, error: error });
}
}
@Post("import")
@Middleware([AuthKeyMiddleware])
private async import(req: Request, res: Response) {
try {
res.status(200).json({
message: "Successful",
});
// const result = await this.db.select().from('users');
const { email, api_token, project_key, cloud_hostname } =
req.body.metadata;
const auth = {
username: email,
password: api_token,
};
const headers = {
Accept: "application/json",
};
const workspace_id = req.body.workspace_id;
const project_id = req.body.project_id;
const created_by = req.body.created_by;
const importer_id = req.body.importer_id;
const users = req.body.data.users;
// users
const members = [];
for (const user of users) {
if (user?.import == "invite" || user?.import == "map") {
const jira_members = {
args: [], // args
kwargs: {
data: {
type: "member.sync",
email: user.email,
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
members.push(user);
this.mq?.publish(
jira_members,
"plane.bgtasks.importer_task.import_task"
);
}
}
// labels
const labelsUrl = `https://${cloud_hostname}/rest/api/3/label/?jql=project=${project_key}`;
const labelsResponse = await axios.get(labelsUrl, { auth, headers });
const labels = labelsResponse.data.values;
for (const label of labels) {
const labelssync = {
args: [], // args
kwargs: {
data: {
external_source: "jira",
type: "label.sync",
name: label,
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(labelssync, "plane.bgtasks.importer_task.import_task");
}
// states
const statusUrl = `https://${cloud_hostname}/rest/api/3/project/${project_key}/statuses`;
const response = await axios.get(statusUrl, { auth, headers });
if (response && response.data && response.data.length) {
const statusData = response.data[0];
if (statusData && statusData.statuses) {
for (const statusCategory of statusData.statuses) {
const state_name = statusCategory.name;
const state_group =
statusCategory.statusCategory.name === "To Do"
? "unstarted"
: statusCategory.statusCategory.name === "In Progress"
? "started"
: statusCategory.statusCategory.name === "Done"
? "completed"
: statusCategory.statusCategory.name;
const statessync = {
args: [], // args
kwargs: {
data: {
type: "state.sync",
state_name: state_name,
state_group: state_group,
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
external_id: statusCategory.id,
external_source: "jira",
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(
statessync,
"plane.bgtasks.importer_task.import_task"
);
}
}
}
const modules = [];
const child_issues = [];
const module_issues = [];
const url = `https://${cloud_hostname}/rest/api/3/search/?jql=project=${project_key}&fields=comment, issuetype, summary, description, assignee, priority, status, labels, duedate, parent, parentEpic&maxResults=100&expand=renderedFields`;
for await (const issue of loadIssues(url, auth)) {
if (issue.fields.parent) {
if (issue.fields.parent?.fields?.issuetype?.name == "Epic") {
module_issues.push({
issue_id: issue.id,
module_id: issue.fields.parent?.id,
});
} else {
child_issues.push(issue);
continue;
}
}
// skipping all the epics
if (issue.fields.issuetype.name === "Epic") {
modules.push(issue);
continue;
}
const user = members.find(
(user) => user.username === issue.fields.assignee?.displayName
);
// issue comments
const comments_list = [];
const comment_url = `https://${cloud_hostname}/rest/api/3/issue/${issue.id}/comment?expand=renderedBody`;
const commentResponse = await axios.get(comment_url, { auth, headers });
if (
commentResponse &&
commentResponse.data &&
commentResponse.data.total
) {
for await (const comment of loadComments(comment_url, auth)) {
comments_list.push({
comment_html:
comment.renderedBody === "" ? "<p></p>" : comment.renderedBody,
created_by: comment.updateAuthor.emailAddress,
});
}
}
const issuessync = {
args: [], // args
kwargs: {
data: {
type: "issue.sync",
name: issue.fields.summary.substring(0, 250),
description_html: issue.renderedFields.description ?? null,
assignee: user?.email,
state: issue.fields.status.name,
priority:
issue.fields.priority.name.toLowerCase() === "medium"
? "medium"
: issue.fields.priority.name.toLowerCase() === "highest"
? "high"
: "low",
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
external_id: issue.id,
external_source: "jira",
comments_list: comments_list,
target_date: issue.fields.duedate,
link: {
title: `Original Issue in Jira ${issue.key}`,
url: `https://${cloud_hostname}/browse/${issue.key}`,
},
labels_list: issue.fields.labels,
parent_id: null,
importer_id: importer_id,
},
},
};
this.mq?.publish(issuessync, "plane.bgtasks.importer_task.issue_sync");
}
for (const issue of child_issues) {
const user = members.find(
(user) => user.username === issue.fields.assignee?.displayName
);
// issue comments
const comments_list = [];
const comment_url = `https://${cloud_hostname}/rest/api/3/issue/${issue.id}/comment?expand=renderedBody`;
const commentResponse = await axios.get(comment_url, { auth, headers });
if (
commentResponse &&
commentResponse.data &&
commentResponse.data.total
) {
for await (const comment of loadComments(comment_url, auth)) {
comments_list.push({
comment_html:
comment.renderedBody === "" ? "<p></p>" : comment.renderedBody,
created_by: comment.updateAuthor.emailAddress,
});
}
}
const issuessync = {
args: [], // args
kwargs: {
data: {
type: "issue.create",
name: issue.fields.summary.substring(0, 250),
description_html: issue.renderedFields?.description,
assignee: user?.email,
state: issue.fields.status.name,
priority:
issue.fields.priority.name.toLowerCase() === "medium"
? "medium"
: issue.fields.priority.name.toLowerCase() === "highest"
? "high"
: "low",
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
external_id: issue.id,
external_source: "jira",
comments_list: comments_list,
target_date: issue.fields.duedate,
link: {
title: `Original Issue in Jira ${issue.key}`,
url: `https://${cloud_hostname}/browse/${issue.key}`,
},
labels_list: issue.fields.labels,
parent_id: issue.fields.parent.id,
},
},
};
this.mq?.publish(issuessync, "plane.bgtasks.importer_task.issue_sync");
}
// modules
for (const module of modules) {
const modulessync = {
args: [], // args
kwargs: {
data: {
type: "module.sync",
name: module.fields.summary.substring(0, 250),
description_html: module.renderedFields?.description,
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
external_id: module.id,
external_source: "jira",
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(
modulessync,
"plane.bgtasks.importer_task.import_task"
);
}
for (const module_issue of module_issues) {
const modules_issue_sync = {
args: [], // args
kwargs: {
data: {
type: "module.issue.sync",
module_id: module_issue.module_id,
issue_id: module_issue.issue_id,
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
external_source: "jira",
importer_id: importer_id,
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(
modules_issue_sync,
"plane.bgtasks.importer_task.importer_task"
);
}
const import_sync = {
args: [], // args
kwargs: {
data: {
type: "import.sync",
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
importer_id: importer_id,
status: "completed",
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(import_sync, "plane.bgtasks.importer_task.import_task");
return;
} catch (error) {
const workspace_id = req.body.workspace_id;
const project_id = req.body.project_id;
const created_by = req.body.created_by;
const importer_id = req.body.importer_id;
const import_sync = {
args: [], // args
kwargs: {
data: {
type: "import.sync",
workspace_id: workspace_id,
project_id: project_id,
created_by: created_by,
importer_id: importer_id,
status: "failed",
},
}, // kwargs
other_data: {}, // other data
};
this.mq?.publish(import_sync, "plane.bgtasks.importer_task.import_task");
return res.json({ message: "Server error", error: error });
}
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Request, Response } from "express";
import { Controller, Get } from "@overnightjs/core";
@Controller("/api/slack")
export class SlackController {
/**
* This controller houses all routes for the Slack Integration
*/
}
-33
View File
@@ -1,33 +0,0 @@
import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
// logger
import { logger } from "../utils/logger"
export class DatabaseSingleton {
private static instance: DatabaseSingleton;
public db: PostgresJsDatabase | null = null;
private constructor() {
try {
// Ensure the DATABASE_URL is provided
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL environment variable is not set.");
}
const queryClient = postgres(process.env.DATABASE_URL);
this.db = drizzle(queryClient);
logger.info("🛢️ Connected to Database")
} catch (error) {
logger.error("Failed to initialize database connection:", error);
throw new Error("Could not connect to Database")
}
}
public static getInstance(): DatabaseSingleton {
if (!DatabaseSingleton.instance) {
DatabaseSingleton.instance = new DatabaseSingleton();
}
return DatabaseSingleton.instance;
}
}
@@ -1,22 +0,0 @@
import { RequestHandler } from "express";
import { logger } from "../utils/logger";
const AuthKeyMiddlware: RequestHandler = (req, res, next) => {
// Retrieve the API key from the request header
const apiKey = req.headers["x-api-key"];
// Define the expected API key
const expectedApiKey = process.env.SEGWAY_KEY;
// Check if the API key is present and matches the expected key
if (apiKey === expectedApiKey) {
// If the key is valid, proceed with the next middleware or route handler
next();
} else {
// If the key is invalid, log the error and send an appropriate response
logger.error("Invalid API key");
res.status(401).json({ message: "Invalid API key" });
}
};
export default AuthKeyMiddlware;
@@ -1,8 +0,0 @@
import { RequestHandler } from "express";
import { logger } from "../utils/logger";
const loggerMiddleware: RequestHandler = (req, res, next) => {
logger.info(`${req.method}: ${req.path}`);
next();
};
export default loggerMiddleware;
-99
View File
@@ -1,99 +0,0 @@
//uuid
import { v4 as uuidv4 } from "uuid"
// mq
import { Connection, Channel, connect, ConsumeMessage } from "amqplib";
// utils
import { logger } from "../utils/logger";
export class MQSingleton {
private static instance: MQSingleton;
private connection: Connection | null = null;
public channel: Channel | null = null;
private constructor() {}
// Get the current instance
public static getInstance(): MQSingleton {
if (!this.instance) {
this.instance = new MQSingleton();
}
return this.instance;
}
// Initialize instance
public async initialize(): Promise<void> {
if (!this.connection || !this.channel) {
await this.init();
}
}
private async init(): Promise<void> {
const rabbitMqUrl = process.env.RABBITMQ_URL || "";
try {
this.connection = await connect(rabbitMqUrl);
logger.info(`✅ Rabbit MQ Connection is ready`);
this.channel = await this.connection.createChannel();
logger.info(`🛸 Created RabbitMQ Channel successfully`);
} catch (error) {
console.error("Error in initializing RabbitMQ:", error);
}
}
// Send the message to the given queue
public async publish(body: object, taskName: string): Promise<void> {
// Check if the channel exists
if (!this.channel) {
throw new Error("Channel not initialized");
}
// Initialize the queue variables
const queue = "segway_tasks";
const exchange = "segway_exchange";
const routingKey = "segway";
// Create this message
const msg = {
contentType: "application/json",
contentEncoding: "utf-8",
headers: {
id: uuidv4(),
task: taskName,
},
body: JSON.stringify(body),
};
// Assert the queue
await this.channel.assertExchange(exchange, "direct", { durable: true });
await this.channel.assertQueue(queue, { durable: true });
await this.channel.bindQueue(queue, exchange, routingKey);
// Try publishing the message
try {
this.channel.publish(exchange, routingKey, Buffer.from(msg.body), {
contentType: msg.contentType,
contentEncoding: msg.contentEncoding,
headers: msg.headers
});
} catch (error) {
console.error("Error publishing message:", error);
}
}
// Receive the message from the given queue
public async consume(
queue: string,
callback: (msg: ConsumeMessage | null) => void
): Promise<void> {
if (!this.channel) {
throw new Error("Channel not initialized");
}
logger.info("👂 Listening for incoming events");
const exchange = "django_exchange";
const routingKey = "django.node";
await this.channel.assertExchange(exchange, "direct", { durable: true });
await this.channel.assertQueue(queue, { durable: true });
await this.channel.bindQueue(queue, exchange, routingKey);
await this.channel.consume(queue, callback, { noAck: true });
}
}
-161
View File
@@ -1,161 +0,0 @@
import dotenv from "dotenv";
import path from "path";
import express from "express";
import { Server } from "@overnightjs/core";
import cors from "cors";
import * as Sentry from "@sentry/node";
import * as Tracing from "@sentry/tracing";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
// controllers
import * as controllers from "./controller";
// middlewares
import loggerMiddleware from "./middleware/logger.middleware";
// utils
import { logger } from "./utils/logger";
// db
import { DatabaseSingleton } from "./db/singleton";
// mq
import { MQSingleton } from "./mq/singleton";
class ApiServer extends Server {
private readonly SERVER_STARTED = "🚀 Api server started on port: ";
SERVER_PORT: number;
db: PostgresJsDatabase | null = null;
mq: MQSingleton | null = null; // Declare the channel property
constructor() {
super(true);
// disabling overnight logs
this.showLogs = false;
// enabling env variable from .env file
dotenv.config();
// assigning port
this.SERVER_PORT = process.env.SERVER_PORT
? parseInt(process.env.SERVER_PORT, 10)
: 8080;
// logger
this.app.use(loggerMiddleware);
// exposing public folder for static files.
this.app.use(express.static("public"));
// body parser
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
// views engine
this.app.set("views", path.join(__dirname, "views"));
this.app.set("view engine", "hbs");
// cors
this.app.use(cors());
// setup mq
this.setupMQ();
// sentry setup
if (
process.env.APP_ENV === "staging" ||
process.env.APP_ENV === "production"
) {
// setting up error logging and tracing.
this.setupSentryInit();
}
// setting up db
this.setupDatabase();
// setting up controllers
this.setupControllers();
// not found page
this.setupNotFoundHandler();
// setting up sentry error handling
this.sentryErrorHandling();
}
// get the current app instance
public getAppInstance() {
return this.app;
}
// Setup the database
private setupDatabase(): void {
this.db = DatabaseSingleton.getInstance().db;
}
// Setup MQ and initialize channel
private setupMQ(): void {
this.mq = MQSingleton.getInstance();
this.startMQAndWorkers().catch((error) =>
logger.error("Error in startMQAndWorkers:", error)
);
}
// Start mq and workers
private async startMQAndWorkers(): Promise<void> {
try {
await this.mq?.initialize();
} catch (error) {
logger.error("Failed to initialize MQ:", error);
}
}
// setup all the controllers
private setupControllers(): void {
const controllerInstances = [];
for (const name in controllers) {
if (Object.prototype.hasOwnProperty.call(controllers, name)) {
const Controller = (controllers as any)[name];
controllerInstances.push(new Controller(this.db, this.mq));
}
}
super.addControllers(controllerInstances);
}
// This controller will return 404 for not found pages
private setupNotFoundHandler(): void {
this.app.use((req, res) => {
res.status(404).json({
status: "error",
message: "Not Found",
path: req.path,
});
});
}
private setupSentryInit() {
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
// enable HTTP calls tracing
new Sentry.Integrations.Http({ tracing: true }),
// enable Express.js middleware tracing
new Tracing.Integrations.Express({ app: this.app }),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
});
// RequestHandler creates a separate execution context using domains, so that every
// transaction/span/breadcrumb is attached to its own Hub instance
this.app.use(Sentry.Handlers.requestHandler());
// TracingHandler creates a trace for every incoming request
this.app.use(Sentry.Handlers.tracingHandler());
}
private sentryErrorHandling() {
// The error handler must be before any other error middleware and after all controllers
this.app.use(Sentry.Handlers.errorHandler());
this.app.use(function onError(req, res: any) {
// The error id is attached to `res.sentry` to be returned
// and optionally displayed to the user for support.
res.statusCode = 500;
res.end(res.sentry + "\n");
});
}
public start(port: number): void {
this.app.listen(port, () => {
logger.info(this.SERVER_STARTED + port);
});
}
}
export default ApiServer;
-5
View File
@@ -1,5 +0,0 @@
import ApiServer from "./server";
const apiServer = new ApiServer();
// starting server
apiServer.start(apiServer.SERVER_PORT);
-25
View File
@@ -1,25 +0,0 @@
import { Octokit } from 'octokit'
import { createAppAuth } from '@octokit/auth-app'
export const getOctokit = async (installationId: number): Promise<Octokit> => {
const appId = process.env.GITHUB_APP_ID || "";
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY || "";
if (!privateKey || !appId) {
throw new Error("Private key and App ID not found in environment variables.");
}
// Initiate the octokit
const octokit = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appId,
privateKey: privateKey,
installationId: installationId
}
})
return octokit;
}
-8
View File
@@ -1,8 +0,0 @@
import winston from "winston";
export const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "combined.log" }),
],
});
-73
View File
@@ -1,73 +0,0 @@
import axios from "axios";
async function* pageThroughIssues(endpoint: string, auth: any) {
async function* makeRequest(_endpoint: string): AsyncGenerator<any> {
const response = await axios({
url: _endpoint,
method: "get",
auth: auth,
});
if (response.status !== 200) {
throw new Error(await response.statusText);
}
const page = await response.data;
yield page;
if (page.issues.length) {
const url: string = `${endpoint}&startAt=${page.startAt + 100}`;
yield* makeRequest(url);
}
}
yield* makeRequest(endpoint);
}
export async function* loadIssues(url: any, auth: any) {
const endpoint = url;
const result = pageThroughIssues(endpoint, auth);
for await (const page of result) {
for (const issue of page.issues) {
yield issue;
}
}
}
async function* pageThroughComments(endpoint: any, auth: any) {
async function* makeRequest(_endpoint: string): AsyncGenerator<any> {
const response = await axios({
url: _endpoint,
method: "get",
auth: auth,
});
if (response.status !== 200) {
throw new Error(await response.statusText);
}
const page = await response.data;
yield page;
if (page.comments.length) {
const url: string = `${endpoint}&startAt=${page.startAt + 100}`;
yield* makeRequest(url);
}
}
yield* makeRequest(endpoint);
}
export async function* loadComments(url: any, auth: any) {
const endpoint = url;
const result = pageThroughComments(endpoint, auth);
for await (const page of result) {
for (const comment of page.comments) {
yield comment;
}
}
}
-42
View File
@@ -1,42 +0,0 @@
// mq
import { ConsumeMessage } from "amqplib";
// mq single ton
import { MQSingleton } from "../mq/singleton";
// logger
import { logger } from "../utils/logger";
export abstract class BaseWorker {
mq: MQSingleton | null = null;
protected routingKey: string;
constructor(
protected queueName: string,
routingKey: string
) {
this.mq = MQSingleton.getInstance();
this.routingKey = routingKey;
this.onMessage = this.onMessage.bind(this);
}
// Start the consumer
public async start(): Promise<void> {
try {
this.mq?.consume(this.queueName, this.onMessage);
} catch (error) {
logger.error("Error starting workers");
}
}
// Publish this to queue
protected async publish(body: object, taskName: string): Promise<void> {
try {
this.mq?.publish(body, taskName);
} catch (error) {
logger.error("Error sending to queue");
}
}
protected abstract onMessage(msg: ConsumeMessage | null): void;
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "tsconfig/express.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": "src/",
"outDir": "./dist"
}
}
-2996
View File
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,8 @@ type Props = {
export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
const { user } = useUser();
const { asPath: currentPath } = useRouter();
const { query } = useRouter();
const { next_path } = query;
return (
<DefaultLayout>
@@ -37,7 +38,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
{user ? (
<p>
You have signed in as {user.email}. <br />
<Link href={`/?next=${currentPath}`}>
<Link href={`/?next_path=${next_path}`}>
<span className="font-medium text-custom-text-100">Sign in</span>
</Link>{" "}
with different account that has access to this page.
@@ -45,7 +46,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
) : (
<p>
You need to{" "}
<Link href={`/?next=${currentPath}`}>
<Link href={`/?next_path=${next_path}`}>
<span className="font-medium text-custom-text-100">Sign in</span>
</Link>{" "}
with an account that has access to this page.
+2 -13
View File
@@ -11,7 +11,6 @@ import {
CopyPlus,
Calendar,
Link2Icon,
RocketIcon,
Users2Icon,
ArchiveIcon,
PaperclipIcon,
@@ -48,8 +47,8 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
</Tooltip>
);
@@ -163,7 +162,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
attachment
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -239,7 +237,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -254,7 +251,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -269,7 +265,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.old_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -398,7 +393,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -420,7 +414,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -442,7 +435,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
link
<RocketIcon size={12} color="#6b7280" />
</a>
{showIssue && (
<>
@@ -469,7 +461,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -484,7 +475,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.new_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -499,7 +489,6 @@ const activityDetails: {
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate">{activity.old_value}</span>
<RocketIcon size={12} color="#6b7280" className="flex-shrink-0" />
</a>
</>
);
@@ -28,7 +28,7 @@ import { ViewIssueLabel } from "components/issues";
// icons
import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
@@ -137,7 +137,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
cancelled: cycle.cancelled_issues,
};
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const cycleStatus = cycle.status.toLocaleLowerCase();
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
+3 -8
View File
@@ -10,15 +10,10 @@ import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon }
// icons
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { findHowManyDaysLeft, renderShortDate, renderShortMonthDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
import { ICycle, TCycleGroups } from "types";
// store
import { useMobxStore } from "lib/mobx/store-provider";
// constants
@@ -45,7 +40,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const cycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups;
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
+3 -8
View File
@@ -13,15 +13,10 @@ import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarG
// icons
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// helpers
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { findHowManyDaysLeft, renderShortDate, renderShortMonthDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
import { ICycle, TCycleGroups } from "types";
// constants
import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace";
@@ -50,7 +45,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const cycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups;
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
+15 -16
View File
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
// ui
import { Tooltip, ContrastIcon } from "@plane/ui";
// helpers
import { getDateRangeStatus, renderShortDate } from "helpers/date-time.helper";
import { renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle } from "types";
@@ -11,8 +11,7 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
const cycleStatus = data.status.toLocaleLowerCase();
return (
<div
className="relative flex h-full w-full items-center rounded"
@@ -21,12 +20,12 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: "",
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: "",
}}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)}
>
@@ -52,7 +51,7 @@ export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const cycleStatus = getDateRangeStatus(data?.start_date, data?.end_date);
const cycleStatus = data.status.toLocaleLowerCase();
return (
<div
@@ -65,12 +64,12 @@ export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => {
cycleStatus === "current"
? "#09a953"
: cycleStatus === "upcoming"
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
? "#f7ae59"
: cycleStatus === "completed"
? "#3f76ff"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<h6 className="flex-grow truncate text-sm font-medium">{data?.name}</h6>
+62 -26
View File
@@ -17,12 +17,20 @@ import { CycleDeleteModal } from "components/cycles/delete-modal";
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
import {
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarCheck2,
CalendarClock,
} from "lucide-react";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
getDateRangeStatus,
isDateGreaterThanToday,
renderDateFormat,
renderShortDate,
@@ -266,10 +274,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
const cycleStatus =
cycleDetails?.start_date && cycleDetails?.end_date
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
: "draft";
const cycleStatus = cycleDetails?.status.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const isStartValid = new Date(`${cycleDetails?.start_date}`) <= new Date();
@@ -357,8 +362,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
</div>
<div className="flex flex-col gap-3">
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-center gap-5">
{currentCycle && (
<span
@@ -373,15 +377,39 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
: `${currentCycle.label}`}
</span>
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
</div>
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
</div>
{cycleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{cycleDetails.description}
</span>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Start Date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<Popover.Button
className={`text-sm font-medium text-custom-text-300 ${
className={`text-sm font-medium text-custom-text-300 w-full rounded-sm cursor-pointer hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
<span
className={`group flex w-full items-center justify-between gap-2 py-1 px-1.5 text-sm ${
watch("start_date") ? "" : "text-custom-text-400"
}`}
>
{areYearsEqual
? renderShortDate(startDate, "No date selected")
: renderShortMonthDate(startDate, "No date selected")}
</span>
</Popover.Button>
<Transition
@@ -393,7 +421,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -right-5 top-10 z-20 transform overflow-hidden">
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
@@ -410,16 +438,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Popover.Panel>
</Transition>
</Popover>
<MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target Date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<>
<Popover.Button
className={`text-sm font-medium text-custom-text-300 ${
className={`text-sm font-medium text-custom-text-300 w-full rounded-sm cursor-pointer hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
<span
className={`group flex w-full items-center justify-between gap-2 py-1 px-1.5 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
}`}
>
{areYearsEqual
? renderShortDate(endDate, "No date selected")
: renderShortMonthDate(endDate, "No date selected")}
</span>
</Popover.Button>
<Transition
@@ -431,7 +475,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -right-5 top-10 z-20 transform overflow-hidden">
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
@@ -451,15 +495,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Popover>
</div>
</div>
</div>
{cycleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{cycleDetails.description}
</span>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" />
@@ -15,8 +15,6 @@ import { AlertCircle, Search, X } from "lucide-react";
import { INCOMPLETE_CYCLES_LIST } from "constants/fetch-keys";
// types
import { ICycle } from "types";
//helper
import { getDateRangeStatus } from "helpers/date-time.helper";
type Props = {
isOpen: boolean;
@@ -138,7 +136,7 @@ export const TransferIssuesModal: React.FC<Props> = observer(({ isOpen, handleCl
<div className="flex w-full justify-between">
<span>{option?.name}</span>
<span className=" flex items-center rounded-full bg-custom-background-80 px-2 capitalize">
{getDateRangeStatus(option?.start_date, option?.end_date)}
{option.status}
</span>
</div>
</button>
@@ -129,6 +129,22 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
return;
}
if (
formData.value1.length > 20 ||
formData.value2.length > 20 ||
formData.value3.length > 20 ||
formData.value4.length > 20 ||
formData.value5.length > 20 ||
formData.value6.length > 20
) {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate point cannot have more than 20 characters.",
});
return;
}
if (
checkDuplicates([
formData.value1,
@@ -269,6 +285,12 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
<Controller
control={control}
name={`value${i + 1}` as keyof FormValues}
rules={{
maxLength: {
value: 20,
message: "Estimate point must at most be of 20 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
ref={ref}
@@ -299,8 +321,8 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
? "Updating Estimate..."
: "Update Estimate"
: isSubmitting
? "Creating Estimate..."
: "Create Estimate"}
? "Creating Estimate..."
: "Create Estimate"}
</Button>
</div>
</form>
+1 -1
View File
@@ -95,7 +95,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
<PhotoFilterIcon className="h-4 w-4 text-custom-text-300" />
)
}
label={`Workspace ${activeLayout === "spreadsheet" ? "Issues" : "Views"}`}
label={`All ${activeLayout === "spreadsheet" ? "Issues" : "Views"}`}
/>
</Breadcrumbs>
</div>
@@ -1,29 +1,32 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query as {
workspaceSlug: string;
projectId: string;
};
// mobx stores
const {
projectLabel: { projectLabels },
projectState: projectStateStore,
projectMember: { projectMembers },
projectIssuesFilter: { issueFilters, updateFilters },
user: { currentProjectRole },
} = useMobxStore();
// derived values
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
@@ -73,8 +76,9 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
{isEditingAllowed && (
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
)}
</div>
);
});
@@ -166,7 +166,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* sub-issues */}
{displayProperties && displayProperties?.sub_issue_count && !!issue?.sub_issues_count && (
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 cursor-default">
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.sub_issues_count}</div>
</div>
@@ -176,7 +176,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* attachments */}
{displayProperties && displayProperties?.attachment_count && !!issue?.attachment_count && (
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 cursor-default">
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.attachment_count}</div>
</div>
@@ -186,7 +186,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
{/* link */}
{displayProperties && displayProperties?.link && !!issue?.link_count && (
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 cursor-default">
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.link_count}</div>
</div>
@@ -137,7 +137,7 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
{/* sub-issues */}
{displayProperties && displayProperties?.sub_issue_count && !!issue?.sub_issues_count && (
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 cursor-default items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.sub_issues_count}</div>
</div>
@@ -147,7 +147,7 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
{/* attachments */}
{displayProperties && displayProperties?.attachment_count && !!issue?.attachment_count && (
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 cursor-default items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.attachment_count}</div>
</div>
@@ -157,7 +157,7 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
{/* link */}
{displayProperties && displayProperties?.link && !!issue?.link_count && (
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<div className="flex h-5 flex-shrink-0 cursor-default items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.link_count}</div>
</div>
@@ -61,6 +61,7 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
ref={dropdownBtn}
className="border-none outline-none"
onClick={(e) => e.stopPropagation()}
disabled={disabled}
>
<Tooltip
tooltipHeading={dateOptionDetails.placeholder}
@@ -69,7 +70,7 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
<div
className={`flex h-5 w-full items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 outline-none duration-300 ${
disabled
? "pointer-events-none cursor-not-allowed text-custom-text-200"
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
}`}
>
@@ -128,7 +128,11 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
))}
</>
) : (
<div className="flex h-full flex-shrink-0 cursor-pointer items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs">
<div
className={`flex h-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
>
<Tooltip
position="top"
tooltipHeading="Labels"
@@ -147,7 +151,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
) : (
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
<div
className={`h-full flex items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80 ${
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80 ${
noLabelBorder ? "" : "border-[0.5px] border-custom-border-300"
}`}
>
@@ -23,7 +23,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -27,7 +27,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -27,7 +27,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -37,7 +37,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => {
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`).then(() =>
setToastAlert({
type: "success",
title: "Link copied",
@@ -17,8 +17,6 @@ import {
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { Spinner } from "@plane/ui";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
export const CycleLayoutRoot: React.FC = observer(() => {
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
@@ -50,10 +48,7 @@ export const CycleLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilters?.displayFilters?.layout;
const cycleDetails = cycleId ? cycleStore.cycle_details[cycleId.toString()] : undefined;
const cycleStatus =
cycleDetails?.start_date && cycleDetails?.end_date
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
: "draft";
const cycleStatus = cycleDetails?.status.toLocaleLowerCase() ?? "draft";
return (
<>
@@ -88,7 +88,7 @@ export const IssueActivityCard: FC<IIssueActivityCard> = (props) => {
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="break-words text-xs text-custom-text-200">
<div className="flex gap-1 break-words text-xs text-custom-text-200">
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
@@ -101,8 +101,8 @@ export const IssueActivityCard: FC<IIssueActivityCard> = (props) => {
: activityItem.actor_detail.display_name}
</span>
</Link>
)}{" "}
{message}{" "}
)}
{message}
<Tooltip
tooltipContent={`${renderLongDateFormat(activityItem.created_at)}, ${render24HourFormatTime(
activityItem.created_at
@@ -92,7 +92,9 @@ export const SidebarLabelSelect: React.FC<Props> = observer((props) => {
return (
<button
key={label.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-100 px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
className={`group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-100 px-1 py-0.5 text-xs ${
isNotAllowed || uneditable ? "!cursor-not-allowed" : "hover:border-red-500/20 hover:bg-red-500/20"
}`}
onClick={() => {
const updatedLabels = labelList?.filter((l) => l !== labelId);
submitChanges({
+24 -21
View File
@@ -35,31 +35,34 @@ export const SidebarParentSelect: React.FC<Props> = ({ onChange, issueDetails, p
issueId={issueId as string}
projectId={projectId as string}
/>
<button
type="button"
className={`flex items-center gap-2 rounded bg-custom-background-80 px-2.5 py-0.5 text-xs max-w-max" ${
<div
className={`flex items-center gap-2 rounded bg-custom-background-80 px-2.5 py-0.5 text-xs w-max max-w-max" ${
disabled ? "cursor-not-allowed" : "cursor-pointer "
}`}
onClick={() => {
if (issueDetails?.parent) {
onChange("");
setSelectedParentIssue(null);
} else {
setIsParentModalOpen(true);
}
}}
disabled={disabled}
>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
<button type="button" className="flex-shrink-0" onClick={() => setIsParentModalOpen(true)} disabled={disabled}>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</button>
{issueDetails?.parent && (
<button
type="button"
className="flex-shrink-0"
onClick={() => {
onChange("");
setSelectedParentIssue(null);
}}
>
<X className="h-2.5 w-2.5" />
</button>
)}
{issueDetails?.parent && <X className="h-2.5 w-2.5" />}
</button>
</div>
</>
);
};
@@ -61,9 +61,9 @@ export const ViewDueDateSelect: React.FC<Props> = ({
className={`bg-transparent ${issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"}`}
customInput={
<div
className={`flex cursor-pointer items-center justify-center gap-2 rounded border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-200 hover:bg-custom-background-80 ${
className={`flex items-center justify-center gap-2 rounded border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-200 hover:bg-custom-background-80 ${
issue.target_date ? "pr-6 text-custom-text-300" : "text-custom-text-400"
}`}
} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
{issue.target_date ? (
<>
@@ -49,9 +49,9 @@ export const ViewStartDateSelect: React.FC<Props> = ({
handleOnOpen={handleOnOpen}
customInput={
<div
className={`flex cursor-pointer items-center justify-center gap-2 rounded border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-200 hover:bg-custom-background-80 ${
className={`flex items-center justify-center gap-2 rounded border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-200 hover:bg-custom-background-80 ${
issue?.start_date ? "pr-6 text-custom-text-300" : "text-custom-text-400"
}`}
} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
{issue?.start_date ? (
<>
+5 -4
View File
@@ -7,7 +7,7 @@ import { ProjectMemberService } from "services/project";
import { Avatar, CustomSearchSelect } from "@plane/ui";
// icons
import { Combobox } from "@headlessui/react";
import { UserCircle } from "lucide-react";
import { UserCircle, UserCircle2 } from "lucide-react";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@@ -65,9 +65,10 @@ export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
value=""
className="flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-custom-text-200"
>
<span className="flex items-center justify-start gap-1 text-custom-text-200">
<span>No Lead</span>
</span>
<div className="flex items-center gap-2">
<UserCircle2 className="h-4 w-4" />
No lead
</div>
</Combobox.Option>
}
onChange={onChange}
@@ -31,6 +31,17 @@ export const SidebarLeadSelect: FC<Props> = (props) => {
: null
);
const noLeadOption = {
value: "",
query: "No lead",
content: (
<div className="flex items-center gap-2">
<UserCircle2 className="h-4 w-4" />
No lead
</div>
),
};
const options = members?.map((member) => ({
value: member.member.id,
query: member.member.display_name,
@@ -42,6 +53,8 @@ export const SidebarLeadSelect: FC<Props> = (props) => {
),
}));
const leadOption = (options || []).concat(noLeadOption);
const selectedOption = members?.find((m) => m.member.id === value)?.member;
return (
@@ -69,7 +82,7 @@ export const SidebarLeadSelect: FC<Props> = (props) => {
</div>
)
}
options={options}
options={leadOption}
maxHeight="md"
onChange={onChange}
/>
+69 -23
View File
@@ -15,7 +15,17 @@ import ProgressChart from "components/core/sidebar/progress-chart";
import { CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, LayersIcon, CustomSelect, ModuleStatusIcon } from "@plane/ui";
// icon
import { AlertCircle, ChevronDown, ChevronRight, Info, LinkIcon, MoveRight, Plus, Trash2 } from "lucide-react";
import {
AlertCircle,
CalendarCheck2,
CalendarClock,
ChevronDown,
ChevronRight,
Info,
LinkIcon,
Plus,
Trash2,
} from "lucide-react";
// helpers
import {
isDateGreaterThanToday,
@@ -227,7 +237,13 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
else newValues.push(value);
}
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { [key]: newValues }, moduleId);
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EFilterType.FILTERS,
{ [key]: newValues },
moduleId
);
},
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
);
@@ -328,8 +344,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<div className="flex flex-col gap-3">
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{moduleDetails.name}</h4>
<div className="flex items-center gap-5">
<div className="flex items-center gap-5 pt-2">
<Controller
control={control}
name="status"
@@ -365,16 +380,40 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</CustomSelect>
)}
/>
</div>
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{moduleDetails.name}</h4>
</div>
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
{moduleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{moduleDetails.description}
</span>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Start Date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<Popover.Button
className={`text-sm font-medium text-custom-text-300 ${
className={`text-sm font-medium text-custom-text-300 w-full rounded-sm cursor-pointer hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={!isEditingAllowed}
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
<span
className={`group flex w-full items-center justify-between gap-2 py-1 px-1.5 text-sm ${
watch("start_date") ? "" : "text-custom-text-400"
}`}
>
{areYearsEqual
? renderShortDate(startDate, "No date selected")
: renderShortMonthDate(startDate, "No date selected")}
</span>
</Popover.Button>
<Transition
@@ -386,7 +425,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -right-5 top-10 z-20 transform overflow-hidden">
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : moduleDetails?.start_date}
onChange={(val) => {
@@ -402,16 +441,32 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</Popover.Panel>
</Transition>
</Popover>
<MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target Date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<>
<Popover.Button
className={`text-sm font-medium text-custom-text-300 ${
className={`text-sm font-medium text-custom-text-300 w-full rounded-sm cursor-pointer hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={!isEditingAllowed}
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
<span
className={`group flex w-full items-center justify-between gap-2 py-1 px-1.5 text-sm ${
watch("target_date") ? "" : "text-custom-text-400"
}`}
>
{areYearsEqual
? renderShortDate(endDate, "No date selected")
: renderShortMonthDate(endDate, "No date selected")}
</span>
</Popover.Button>
<Transition
@@ -423,7 +478,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute -right-5 top-10 z-20 transform overflow-hidden">
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("target_date") ? watch("target_date") : moduleDetails?.target_date}
onChange={(val) => {
@@ -442,15 +497,6 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</Popover>
</div>
</div>
</div>
{moduleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{moduleDetails.description}
</span>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<Controller
control={control}
name="lead"
+2 -6
View File
@@ -1,7 +1,5 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { UserService } from "services/user.service";
// components
@@ -9,7 +7,6 @@ import { ActivityMessage } from "components/core";
// ui
import { ProfileEmptyState } from "components/ui";
import { Loader } from "@plane/ui";
import { Rocket } from "lucide-react";
// image
import recentActivityEmptyState from "public/empty-state/recent_activity.svg";
// helpers
@@ -67,10 +64,9 @@ export const ProfileActivity = () => {
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
className="inline-flex items-center gap-1 font-medium text-custom-text-200 hover:underline"
>
Issue
<Rocket className="h-3 w-3" />
Issue.
</a>
</span>
)}

Some files were not shown because too many files have changed in this diff Show More