Repository: apache/airflow

Issue:
Applying labels to task groups shows a cycle in the graph view for the dag
### Apache Airflow version

2.2.2

### Operating System

Docker (debian:buster)

### Versions of Apache Airflow Providers

N/A

### Deployment

Astronomer

### Deployment details

run airflow with this dag

```python3
with DAG(
    dag_id="label_bug_without_chain"
) as dag:

    with TaskGroup(group_id="group1") as taskgroup1:
        t1 = DummyOperator(task_id="dummy1")
        t2 = DummyOperator(task_id="dummy2")
        t3 = DummyOperator(task_id="dummy3")
    
    t4 = DummyOperator(task_id="dummy4")

chain([Label("branch three")], taskgroup1, t4,)
```


### What happened

expanded task views look like they have cycles

<img width="896" alt="Screen Shot 2021-11-22 at 2 33 49 PM" src="https://user-images.githubusercontent.com/17841735/143083099-d250fd7e-963f-4b34-b544-405b51ee2859.png">


### What you expected to happen

The task group shouldn't display as if it has loops in it.

### How to reproduce

View the dag shown in the deployment details.

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

cc @bbovenzi 
Related: #19899

I think we will remove the blue dots entirely as they seem to cause confusion and weird issues.
@bbovenzi I tried to reproduce this issue and It seems like now this DAG is breaking the Grid view:
![Screen Shot 2022-07-04 at 18 35 18](https://user-images.githubusercontent.com/45845474/177185631-95fa5f3b-4255-4de7-aca2-fe8630e7a2c3.png)


Removing the line: `chain([Label("branch three")], taskgroup1, t4,)`
makes the Grid view works:
![Screen Shot 2022-07-04 at 18 38 37](https://user-images.githubusercontent.com/45845474/177185811-a54b2bdc-c871-4284-9d9f-ad219c922ad2.png)


Code:

```
from datetime import datetime

from airflow.models.baseoperator import chain
from airflow.operators.dummy import DummyOperator
from airflow.utils.edgemodifier import Label
from airflow.utils.task_group import TaskGroup
from airflow import DAG


DEFAULT_ARGS = {
    'owner': 'admin',
    'depends_on_past': False,
    'start_date': datetime(2022, 5, 20, 0),
    'retries': 2
}

with DAG(
    dag_id="label_bug_without_chain",
    default_args=DEFAULT_ARGS,
) as dag:

    with TaskGroup(group_id="group1") as taskgroup1:
        t1 = DummyOperator(task_id="dummy1")
        t2 = DummyOperator(task_id="dummy2")
        t3 = DummyOperator(task_id="dummy3")

    t4 = DummyOperator(task_id="dummy4")

chain([Label("branch three")], taskgroup1, t4)
```
Is there a stack trace in the webserver logs? (It looks like the API call failed with a 500)
For the above dag, writing the chain as below works:
```python
chain([Label("branch three")], [taskgroup1], t4)
```
or 
```python
chain(Label("branch three"), taskgroup1, t4)
```

> Is there a stack trace in the webserver logs? (It looks like the API call failed with a 500)

Yes. Here's the stacktrace:
```log
[2022-07-04 21:18:55+0000 929ms] {app.py:1456} ERROR - Exception on /object/grid_data [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 2077, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1525, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1523, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.7/site-packages/flask/app.py", line 1509, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args)
  File "/opt/airflow/airflow/www/auth.py", line 46, in decorated
    return func(*args, **kwargs)
  File "/opt/airflow/airflow/www/views.py", line 3628, in grid_data
    'groups': dag_to_grid(dag, dag_runs, session),
  File "/opt/airflow/airflow/www/views.py", line 409, in dag_to_grid
    return task_group_to_grid(dag.task_group, dag_runs, grouped_tis)
  File "/opt/airflow/airflow/www/views.py", line 361, in task_group_to_grid
    task_group_to_grid(child, dag_runs, grouped_tis) for child in task_group.topological_sort()
  File "/opt/airflow/airflow/utils/task_group.py", line 450, in topological_sort
    raise AirflowDagCycleException(f"A cyclic dependency occurred in dag: {self.dag_id}")
airflow.exceptions.AirflowDagCycleException: A cyclic dependency occurred in dag: label_bug_without_chain
```
I'm not sure this is a bug -- or at least not a bug in the UI.

On 2.2.5:

```
In [1]: import airflow

In [2]: from datetime import datetime
   ...: 
   ...: from airflow.models.baseoperator import chain
   ...: from airflow.operators.dummy import DummyOperator
   ...: from airflow.utils.edgemodifier import Label
   ...: from airflow.utils.task_group import TaskGroup
   ...: from airflow import DAG
   ...: 
   ...: 
   ...: DEFAULT_ARGS = {
   ...:     'owner': 'admin',
   ...:     'depends_on_past': False,
   ...:     'start_date': datetime(2022, 5, 20, 0),
   ...:     'retries': 2
   ...: }
   ...: 
   ...: with DAG(
   ...:     dag_id="label_bug_without_chain",
   ...:     default_args=DEFAULT_ARGS,
   ...: ) as dag:
   ...: 
   ...:     with TaskGroup(group_id="group1") as taskgroup1:
   ...:         t1 = DummyOperator(task_id="dummy1")
   ...:         t2 = DummyOperator(task_id="dummy2")
   ...:         t3 = DummyOperator(task_id="dummy3")
   ...: 
   ...:     t4 = DummyOperator(task_id="dummy4")
   ...: 
   ...: chain([Label("branch three")], taskgroup1, t4)

In [3]: taskgroup1.upstream_task_ids
Out[3]: {'group1.dummy1', 'group1.dummy2', 'group1.dummy3'}
```

The task group is marked as depending on itself! The bug is that we don't detect the cycle earlier during the DAG parsing process!
Not to mention this DAG is invalid -- a label must be _between_ two nodes, it can't be at the start of one.
But the end result of Grid view crash is not desired.
If not valid state why it doesn't raise broken dag? maybe this is the issue to address if it results in broken dag then we won't have an issue with the grid view.
The example code given in the OP doesn't quite match the picture they showed (missing the start task)
```python
from datetime import datetime

from airflow.models.baseoperator import chain
from airflow.operators.dummy import DummyOperator
from airflow.utils.edgemodifier import Label
from airflow.utils.task_group import TaskGroup
from airflow import DAG


DEFAULT_ARGS = {
    'depends_on_past': False,
    'retries': 2
}

with DAG(
    dag_id="label_bug_without_chain",
    start_date=datetime(2022, 5, 20, 0),
    default_args=DEFAULT_ARGS,
) as dag:

    start = DummyOperator(task_id="sleep_3_seconds")

    with TaskGroup(group_id="group1") as taskgroup1:
        t1 = DummyOperator(task_id="dummy1")
        t2 = DummyOperator(task_id="dummy2")
        t3 = DummyOperator(task_id="dummy3")

    t4 = DummyOperator(task_id="echo_done")

chain(start, [Label("branch three")], taskgroup1, t4)
```

The issue is still shows up with this dag though.

Resolving patch:
diff --git a/airflow/utils/task_group.py b/airflow/utils/task_group.py
--- a/airflow/utils/task_group.py
+++ b/airflow/utils/task_group.py
@@ -283,6 +283,12 @@ def _set_relatives(
         Call set_upstream/set_downstream for all root/leaf tasks within this TaskGroup.
         Update upstream_group_ids/downstream_group_ids/upstream_task_ids/downstream_task_ids.
         """
+        if not isinstance(task_or_task_list, Sequence):
+            task_or_task_list = [task_or_task_list]
+
+        for task_like in task_or_task_list:
+            self.update_relative(task_like, upstream)
+
         if upstream:
             for task in self.get_roots():
                 task.set_upstream(task_or_task_list)
@@ -290,12 +296,6 @@ def _set_relatives(
             for task in self.get_leaves():
                 task.set_downstream(task_or_task_list)
 
-        if not isinstance(task_or_task_list, Sequence):
-            task_or_task_list = [task_or_task_list]
-
-        for task_like in task_or_task_list:
-            self.update_relative(task_like, upstream)
-
     def __enter__(self) -> "TaskGroup":
         TaskGroupContext.push_context_managed_task_group(self)
         return self


Test patch:
diff --git a/tests/utils/test_task_group.py b/tests/utils/test_task_group.py
--- a/tests/utils/test_task_group.py
+++ b/tests/utils/test_task_group.py
@@ -1222,3 +1222,28 @@ def test_add_to_another_group():
             tg.add(task)
 
     assert str(ctx.value) == "cannot add 'section_2.task' to 'section_1' (already in group 'section_2')"
+
+
+def test_task_group_edge_modifier_chain():
+    from airflow.models.baseoperator import chain
+    from airflow.utils.edgemodifier import Label
+
+    with DAG(dag_id="test", start_date=pendulum.DateTime(2022, 5, 20)) as dag:
+        start = EmptyOperator(task_id="sleep_3_seconds")
+
+        with TaskGroup(group_id="group1") as tg:
+            t1 = EmptyOperator(task_id="dummy1")
+            t2 = EmptyOperator(task_id="dummy2")
+
+        t3 = EmptyOperator(task_id="echo_done")
+
+    # The case we are testing for is when a Label is inside a list -- meaning that we do tg.set_upstream
+    # instead of label.set_downstream
+    chain(start, [Label("branch three")], tg, t3)
+
+    assert start.downstream_task_ids == {t1.node_id, t2.node_id}
+    assert t3.upstream_task_ids == {t1.node_id, t2.node_id}
+    assert tg.upstream_task_ids == set()
+    assert tg.downstream_task_ids == {t3.node_id}
+    # Check that we can perform a topological_sort
+    dag.topological_sort()